Compare commits
105 Commits
Author | SHA1 | Date |
---|---|---|
Alice Gaudon | 3946a5facc | |
Alice Gaudon | ecb2b13a83 | |
Alice Gaudon | a823503cb4 | |
Alice Gaudon | f0304c0b9f | |
Alice Gaudon | ae31edb622 | |
Alice Gaudon | d378771ffa | |
Alice Gaudon | 839383f3cc | |
Alice Gaudon | 869791b3ad | |
Alice Gaudon | e19a627eb5 | |
Alice Gaudon | 3d960dccf3 | |
Alice Gaudon | 6714e413a2 | |
Alice Gaudon | 27e9abc5f4 | |
Alice Gaudon | 8c083d562d | |
Alice Gaudon | 4cfcaac1cc | |
Alice Gaudon | 3eb33c64d7 | |
Alice Gaudon | fd2852c387 | |
Alice Gaudon | 9f17c5b8cd | |
Alice Gaudon | 144a72895e | |
Alice Gaudon | 81c65344a9 | |
Alice Gaudon | 41a083ba52 | |
Alice Gaudon | 0c4349fac3 | |
Alice Gaudon | cb5001ce6e | |
Alice Gaudon | ee56113808 | |
Alice Gaudon | 2c3286d313 | |
Alice Gaudon | 2e3c5d16c4 | |
Alice Gaudon | 81a62be38d | |
Alice Gaudon | afef367e59 | |
Alice Gaudon | 1e72ec7172 | |
Alice Gaudon | 0e0e633e08 | |
Alice Gaudon | 7a49e47ae7 | |
Alice Gaudon | 67dc33adf4 | |
Alice Gaudon | 231aa8dcd7 | |
Alice Gaudon | 535c8afdb1 | |
Alice Gaudon | 7bb7f5a017 | |
Alice Gaudon | dad4ff62f1 | |
Alice Gaudon | 8626d0b571 | |
Alice Gaudon | 32a1721ef2 | |
Alice Gaudon | 689c860e2e | |
Alice Gaudon | cd3dd454aa | |
Alice Gaudon | 7672a568fc | |
Alice Gaudon | d6b530d16c | |
Alice Gaudon | 366e48757e | |
Alice Gaudon | ae603362e9 | |
Alice Gaudon | be0676611a | |
Alice Gaudon | b85573a64d | |
Alice Gaudon | 172d27a6d2 | |
Alice Gaudon | e033baa57f | |
Alice Gaudon | d1ff09fcc8 | |
Alice Gaudon | 9674fc87dd | |
Alice Gaudon | c606379bcd | |
Alice Gaudon | 179cb09b58 | |
Alice Gaudon | 9980c54fcf | |
Alice Gaudon | 2b85bea9dd | |
Alice Gaudon | 4c895229ec | |
Alice Gaudon | e1542ae476 | |
Alice Gaudon | 59491d63ab | |
Alice Gaudon | aa1484749e | |
Alice Gaudon | f801f6a43b | |
Alice Gaudon | f68e81836b | |
Alice Gaudon | 6348692473 | |
Alice Gaudon | e151c9c744 | |
Alice Gaudon | 6e630c2715 | |
Alice Gaudon | 2e68cf8cae | |
Alice Gaudon | f55ba4d611 | |
Alice Gaudon | 5cfdecfebf | |
Alice Gaudon | 10d5d16967 | |
Alice Gaudon | 7f4996c908 | |
Alice Gaudon | aa4a47230d | |
Alice Gaudon | 92c6433235 | |
Alice Gaudon | 48de7829ec | |
Alice Gaudon | 9998f1a91c | |
Alice Gaudon | d76a227c5c | |
Alice Gaudon | c7d0238d22 | |
Alice Gaudon | b24e9ab580 | |
Alice Gaudon | e9db1f4ded | |
Alice Gaudon | 428990dc00 | |
Alice Gaudon | c0918b17ed | |
Alice Gaudon | da6fda02a9 | |
Alice Gaudon | 297bafcdc8 | |
Alice Gaudon | 8b1e940d0a | |
Alice Gaudon | 61e7282f25 | |
Alice Gaudon | d40481fe3b | |
Alice Gaudon | 9da35de4e0 | |
Alice Gaudon | febde935e3 | |
Alice Gaudon | 2879e014a8 | |
Alice Gaudon | 3c28bd9fbe | |
Alice Gaudon | 3616d54d29 | |
Alice Gaudon | e97cbb5d7f | |
Alice Gaudon | c0245e3e3d | |
Alice Gaudon | e188458f9c | |
Alice Gaudon | 2f2e1f51b8 | |
Alice Gaudon | 575de5ebb1 | |
Alice Gaudon | 4156f978fa | |
Alice Gaudon | 876d509f10 | |
Alice Gaudon | a03dd994e3 | |
Alice Gaudon | 7dde32edb4 | |
Alice Gaudon | a753122290 | |
Alice Gaudon | 5869ba3ee3 | |
Alice Gaudon | ce39f82e66 | |
Alice Gaudon | 0ad9143282 | |
Alice Gaudon | 45bd805969 | |
Alice Gaudon | f41ee9cf32 | |
Alice Gaudon | 404a2ecb16 | |
Alice Gaudon | 4a09d2e1fe | |
Alice Gaudon | ef9cb663e7 |
|
@ -13,7 +13,7 @@ module.exports = {
|
|||
'./tsconfig.test.json',
|
||||
'./src/tsconfig.json',
|
||||
'./src/common/tsconfig.json',
|
||||
'./src/assets/ts/tsconfig.json',
|
||||
'./src/assets/ts/tsconfig.eslint.json',
|
||||
'./src/assets/views/tsconfig.json',
|
||||
]
|
||||
},
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
app: {
|
||||
listen_addr: '127.0.0.1',
|
||||
port: 4899,
|
||||
public_url: "http://localhost:4899",
|
||||
public_websocket_url: "ws://localhost: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,
|
||||
|
@ -36,7 +36,7 @@
|
|||
},
|
||||
mysql: {
|
||||
connectionLimit: 10,
|
||||
host: "localhost",
|
||||
host: "127.0.0.1",
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "swaf",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
approval_mode: true,
|
||||
},
|
||||
mysql: {
|
||||
host: "localhost",
|
||||
host: "127.0.0.1",
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "swaf_test",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default {
|
||||
module.exports = {
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.test.json',
|
||||
|
@ -16,6 +16,5 @@ export default {
|
|||
'**/test/**/*.test.ts'
|
||||
],
|
||||
testEnvironment: 'node',
|
||||
testTimeout: 60000,
|
||||
resolver: "jest-ts-webcompat-resolver",
|
||||
};
|
||||
|
|
45
package.json
45
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "swaf",
|
||||
"version": "0.24.0",
|
||||
"version": "0.25.1",
|
||||
"description": "Structure Web Application Framework.",
|
||||
"repository": "https://eternae.ink/ashpie/swaf",
|
||||
"author": "Alice Gaudon <alice@gaudon.pro>",
|
||||
|
@ -11,7 +11,6 @@
|
|||
"access": "public"
|
||||
},
|
||||
"main": "dist/main.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"test": "jest --verbose --runInBand",
|
||||
|
@ -20,32 +19,29 @@
|
|||
"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\" \"tsc --build --watch --preserveWatchOutput\" \"nodemon -i public -i intermediates -- pre-compile-views --watch\" \"nodemon -i public -i intermediates\"",
|
||||
"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": {
|
||||
"@rollup/plugin-commonjs": "^21.0.1",
|
||||
"@rollup/plugin-node-resolve": "^13.0.6",
|
||||
"@sveltejs/eslint-config": "sveltejs/eslint-config",
|
||||
"@tsconfig/svelte": "^2.0.1",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/config": "^0.0.40",
|
||||
"@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/feather-icons": "^4.7.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/redis": "^2.8.18",
|
||||
"@types/require-from-string": "^1.2.0",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"@types/uuid": "^8.0.0",
|
||||
|
@ -53,38 +49,30 @@
|
|||
"@typescript-eslint/eslint-plugin": "^5.3.0",
|
||||
"@typescript-eslint/parser": "^5.3.0",
|
||||
"chokidar": "^3.5.1",
|
||||
"clear-module": "^4.1.1",
|
||||
"concurrently": "^6.0.0",
|
||||
"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",
|
||||
"feather-icons": "^4.28.0",
|
||||
"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",
|
||||
"normalize.css": "^8.0.1",
|
||||
"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",
|
||||
"sass": "^1.32.12",
|
||||
"supertest": "^6.0.0",
|
||||
"svelte": "^3.35.0",
|
||||
"svelte-check": "^2.2.8",
|
||||
"svelte-preprocess": "4.6.9",
|
||||
"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",
|
||||
|
@ -94,13 +82,24 @@
|
|||
"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": "^3.0.2",
|
||||
"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",
|
||||
|
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
@ -41,6 +42,15 @@ export default commandLineArgs => ({
|
|||
// 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 -
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
export function copyRecursively(file, destination) {
|
||||
function copyRecursively(file, destination) {
|
||||
const target = path.join(destination, path.basename(file));
|
||||
if (fs.statSync(file).isDirectory()) {
|
||||
console.log('mkdir', target);
|
||||
|
@ -16,3 +16,7 @@ export function copyRecursively(file, destination) {
|
|||
fs.copyFileSync(file, target);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
copyRecursively,
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import fs from "fs";
|
||||
const fs = require('fs');
|
||||
|
||||
[
|
||||
'intermediates',
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {copyRecursively} from "./_functions.js";
|
||||
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');
|
||||
});
|
||||
|
@ -16,3 +17,7 @@ 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');
|
||||
});
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {copyRecursively} from "./_functions.js";
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// These folders must exist for nodemon not to loop indefinitely.
|
||||
[
|
||||
|
@ -18,7 +17,5 @@ if (!fs.existsSync(symlink)) {
|
|||
fs.symlinkSync(path.resolve('dist/common'), symlink);
|
||||
}
|
||||
|
||||
// Copy all source files
|
||||
fs.readdirSync('src').forEach(file => {
|
||||
copyRecursively(path.join('src', file), 'dist');
|
||||
});
|
||||
// Copy package.json
|
||||
fs.copyFileSync('package.json', 'dist/package.json');
|
||||
|
|
|
@ -21,6 +21,8 @@ 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";
|
||||
|
||||
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
|
||||
private readonly version: string;
|
||||
|
@ -98,6 +100,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
|||
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?.();
|
||||
|
@ -194,9 +198,9 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
|||
error_instructions: httpError.instructions,
|
||||
error_id: errorId,
|
||||
};
|
||||
res.render('errors/' + httpError.errorCode, locals, (err: Error | undefined, html) => {
|
||||
res.formatViewData('errors/' + httpError.errorCode, locals, (err: Error | undefined, html) => {
|
||||
if (err) {
|
||||
res.render('errors/Error', locals);
|
||||
res.formatViewData('templates/ErrorTemplate', locals);
|
||||
} else {
|
||||
res.send(html);
|
||||
}
|
||||
|
@ -247,6 +251,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
|||
|
||||
protected async processCommandLine(): Promise<boolean> {
|
||||
const args = process.argv;
|
||||
|
||||
// Flags
|
||||
const flags = {
|
||||
verbose: false,
|
||||
|
@ -272,9 +277,13 @@ export default abstract class Application implements Extendable<ApplicationCompo
|
|||
else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
|
||||
break;
|
||||
default:
|
||||
if (mainCommand) mainCommandArgs.push(args[i]);
|
||||
else logger.fatal('Unrecognized argument', args[i]);
|
||||
return true;
|
||||
if (mainCommand) {
|
||||
mainCommandArgs.push(args[i]);
|
||||
} else {
|
||||
logger.fatal('Unrecognized argument', args[i]);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import {NextFunction, Request, Response} from "express";
|
||||
import IncomingForm from "formidable/Formidable.js";
|
||||
import formidable, {Options} from "formidable";
|
||||
|
||||
import {FileError, ValidationBag} from "./db/Validator.js";
|
||||
import Middleware from "./Middleware.js";
|
||||
|
||||
export default abstract class FileUploadMiddleware extends Middleware {
|
||||
protected abstract makeForm(): IncomingForm;
|
||||
protected abstract getFormidableOptions(): Options;
|
||||
|
||||
protected abstract getDefaultField(): string;
|
||||
|
||||
public async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const form = this.makeForm();
|
||||
const form = formidable(this.getFormidableOptions());
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
form.parse(req, (err, fields, files) => {
|
||||
|
@ -23,9 +23,9 @@ export default abstract class FileUploadMiddleware extends Middleware {
|
|||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
const bag = new ValidationBag();
|
||||
const fileError = new FileError(e);
|
||||
const fileError = new FileError(String(e));
|
||||
fileError.thingName = this.getDefaultField();
|
||||
bag.addMessage(fileError);
|
||||
next(bag);
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
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>;
|
||||
}
|
|
@ -148,10 +148,10 @@ export default class TestApp extends Application {
|
|||
this.use(new class extends Controller {
|
||||
public routes(): void {
|
||||
this.get('/', (req, res) => {
|
||||
res.render('home');
|
||||
res.formatViewData('home');
|
||||
}, 'home');
|
||||
this.get('/tests', (req, res) => {
|
||||
res.render('tests');
|
||||
res.formatViewData('tests');
|
||||
}, 'tests');
|
||||
this.get('/design', (req, res) => {
|
||||
req.flash('success', 'Success.');
|
||||
|
@ -159,7 +159,7 @@ export default class TestApp extends Application {
|
|||
req.flash('warning', 'Warning.');
|
||||
req.flash('error', 'Error.');
|
||||
req.flash('error-alert', 'Error alert.');
|
||||
res.render('design');
|
||||
res.formatViewData('design');
|
||||
}, 'design');
|
||||
}
|
||||
}());
|
||||
|
|
29
src/Utils.ts
29
src/Utils.ts
|
@ -1,4 +1,4 @@
|
|||
import {promises as fs} from "fs";
|
||||
import fs, {promises as afs} from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
|
@ -49,10 +49,10 @@ export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[]
|
|||
}
|
||||
|
||||
export async function listFilesRecursively(dir: string): Promise<string[]> {
|
||||
const localFiles = await fs.readdir(dir);
|
||||
const localFiles = await afs.readdir(dir);
|
||||
const files: string[] = [];
|
||||
for (const file of localFiles.map(file => path.join(dir, file))) {
|
||||
const stat = await fs.stat(file);
|
||||
const stat = await afs.stat(file);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...await listFilesRecursively(file));
|
||||
|
@ -65,14 +65,17 @@ export async function listFilesRecursively(dir: string): Promise<string[]> {
|
|||
|
||||
|
||||
export async function doesFileExist(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(file);
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
return false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import {Session} from "express-session";
|
||||
import {IncomingMessage} from "http";
|
||||
import WebSocket from "ws";
|
||||
|
||||
|
@ -20,6 +19,5 @@ export default abstract class WebSocketListener<T extends Application> {
|
|||
public abstract handle(
|
||||
socket: WebSocket,
|
||||
request: IncomingMessage,
|
||||
session: Session | null,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
|
|
@ -27,6 +27,22 @@
|
|||
}
|
||||
|
||||
|
||||
// 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);
|
||||
|
@ -49,19 +65,6 @@
|
|||
--background-color: var(--surface);
|
||||
}
|
||||
|
||||
|
||||
// Buttons
|
||||
button {
|
||||
--background-color: var(--surface);
|
||||
}
|
||||
button:hover::after {
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
:global(button:hover::after) {
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
@if ($shadowStrength > 0) {
|
||||
box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045;
|
||||
}
|
||||
|
|
|
@ -155,7 +155,7 @@ h1 {
|
|||
a {
|
||||
text-decoration: none;
|
||||
|
||||
.feather.feather-external-link { //todo add js
|
||||
.icon.lucide-external-link {
|
||||
--icon-size: 16px;
|
||||
margin-left: 4px;
|
||||
margin-top: -3px;
|
||||
|
@ -166,6 +166,13 @@ 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);
|
||||
|
@ -239,17 +246,17 @@ button, .button {
|
|||
border: 0;
|
||||
}
|
||||
|
||||
.feather {
|
||||
.icon {
|
||||
--icon-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.feather.last {
|
||||
.icon.last {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
&:hover::after:not([disabled]) {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
|
@ -260,4 +267,10 @@ button, .button {
|
|||
background-color: var(--on-background);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,46 @@
|
|||
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 {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
margin: 16px 8px;
|
||||
}
|
||||
|
||||
> .feather:first-child {
|
||||
> .icon:first-child {
|
||||
--icon-size: 24px;
|
||||
position: absolute;
|
||||
opacity: 0.2;
|
||||
|
@ -35,7 +35,7 @@
|
|||
font-size: 24px;
|
||||
line-height: 1;
|
||||
|
||||
.feather {
|
||||
.icon {
|
||||
--icon-size: 24px;
|
||||
margin: 0 16px 0 0;
|
||||
opacity: 0.2;
|
||||
|
|
|
@ -17,6 +17,7 @@ export default class WebsocketClient {
|
|||
const websocket = new WebSocket(this.websocketUrl);
|
||||
websocket.onopen = () => {
|
||||
console.debug('Websocket connected');
|
||||
websocket.send(document.cookie);
|
||||
};
|
||||
websocket.onmessage = (e) => {
|
||||
this.listener(websocket, e);
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
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'));
|
|
@ -1,10 +0,0 @@
|
|||
import feather from "feather-icons";
|
||||
|
||||
let alreadyReplaced = false;
|
||||
|
||||
export function replaceIcons(once: boolean = true) {
|
||||
if (!once || !alreadyReplaced) {
|
||||
alreadyReplaced = true;
|
||||
feather.replace();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
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;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"./**/*"
|
||||
]
|
||||
}
|
|
@ -3,16 +3,19 @@
|
|||
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><i data-feather="key"></i> Change name</h2>
|
||||
<h2><Icon name="key"/> Change name</h2>
|
||||
|
||||
|
||||
{#if $locals.can_change_name}
|
||||
<Form action={$locals.route('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}/>
|
||||
|
@ -29,3 +32,4 @@
|
|||
<Message type="info" content="You will be able to change your name in {$locals.can_change_name_in}" sticky discreet/>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
|
@ -2,21 +2,24 @@
|
|||
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><i data-feather="key"></i> {$locals.has_password ? 'Change' : 'Set'} password</h2>
|
||||
<h2><Icon name="key"/> {$locals.has_password ? 'Change' : 'Set'} password</h2>
|
||||
|
||||
{#if removePasswordMode}
|
||||
<Form action={$locals.route('remove-password')}
|
||||
<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={$locals.route('change-password')}
|
||||
<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"/>
|
||||
|
@ -28,3 +31,4 @@
|
|||
</Form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
|
|
@ -1,93 +1,102 @@
|
|||
<script>
|
||||
import {locals} from "../../../ts/stores";
|
||||
import BaseLayout from "../../layouts/BaseLayout.svelte";
|
||||
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>
|
||||
|
||||
<BaseLayout title="Account" description="Manage your account settings and data.">
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<h2><i data-feather="user"></i> Personal information</h2>
|
||||
<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}
|
||||
{#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}
|
||||
{#each personalInfoFields as field}
|
||||
<p>{field.name}: {field.value}</p>
|
||||
{/each}
|
||||
|
||||
{#if mainEmail}
|
||||
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
|
||||
{/if}
|
||||
{#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 $locals.has_name_component}
|
||||
<NamePanel/>
|
||||
{/if}
|
||||
|
||||
{#if $locals.has_password_component}
|
||||
<PasswordPanel/>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2 id="emails"><i data-feather="shield"></i> 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={$locals.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={$locals.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>
|
||||
|
||||
<Form action={$locals.route('add-email')} class="sub-panel"
|
||||
{#if hasRoute('add-email')}
|
||||
<Form action={route('add-email')} class="sub-panel"
|
||||
submitIcon="plus" submitText="Add email address">
|
||||
<h3>Add an email address:</h3>
|
||||
|
||||
|
@ -95,6 +104,6 @@
|
|||
hint="An email address we can use to identify you in case you lose access to your account"
|
||||
required/>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
{/if}
|
||||
</section>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
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.previousFormData()?.['auth_method'] !== 'password';
|
||||
let loginUsingMagicLink = true;
|
||||
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
|
||||
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
|
||||
|
||||
let queryStr = '';
|
||||
let previousUrl = $locals.getPreviousUrl();
|
||||
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) {
|
||||
|
@ -17,50 +18,76 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<BaseLayout title="Authentication / Registration"
|
||||
description="Join {$locals.app.name} and share your files!"
|
||||
h1="Authentication and registration">
|
||||
<BaseTemplate title="Authentication / Registration"
|
||||
description="Join {$locals.app.name} and share your files!"
|
||||
h1="Authentication and registration">
|
||||
|
||||
<div class="container">
|
||||
{#if hasRoute('login')}
|
||||
<section class="panel">
|
||||
<h2><i data-feather="log-in"></i> Log in</h2>
|
||||
<h2>
|
||||
<Icon name="log-in"/>
|
||||
Log in
|
||||
</h2>
|
||||
|
||||
<Form action={$locals.route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
|
||||
<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 !loginUsingMagicLink}
|
||||
<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>
|
||||
{:else}
|
||||
<button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button>
|
||||
{#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."/>
|
||||
<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><i data-feather="user-plus"></i> Register</h2>
|
||||
<h2>
|
||||
<Icon name="user-plus"/>
|
||||
Register
|
||||
</h2>
|
||||
|
||||
<Form action={$locals.route('register') + queryStr} submitText="Register" submitIcon="check">
|
||||
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
|
||||
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
|
||||
|
||||
{#if $locals.has_username}
|
||||
{#if $locals.hasUsername}
|
||||
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
|
||||
placeholder="Choose your username"
|
||||
pattern="[0-9a-z_-]+" required/>
|
||||
{/if}
|
||||
|
||||
{#if registerUsingMagicLink}
|
||||
{#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/>
|
||||
<button on:click={() => registerUsingMagicLink=false} type="button"><Icon name="key"/>Use password</button>
|
||||
{#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>
|
||||
<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>
|
||||
|
@ -68,5 +95,5 @@
|
|||
</Field>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
{/if}
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
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 {route} from "../../../common/Routing";
|
||||
import {hasRoute, route} from "../../../common/Routing";
|
||||
|
||||
const accounts = $locals.accounts || [];
|
||||
</script>
|
||||
|
@ -16,10 +16,12 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} - Review accounts" h1={false}>
|
||||
<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>
|
||||
|
||||
|
@ -49,16 +51,20 @@
|
|||
<td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td>
|
||||
<td>
|
||||
<div class="max-content">
|
||||
<Form action={$locals.route('approve-account')}
|
||||
{#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}
|
||||
|
||||
<Form action={$locals.route('reject-account')}
|
||||
{#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>
|
||||
|
@ -72,4 +78,4 @@
|
|||
</div>
|
||||
|
||||
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
|
||||
</BaseLayout>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -1,32 +1,31 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import BaseLayout from "../layouts/BaseLayout.svelte";
|
||||
import BaseTemplate from "../templates/BaseTemplate.svelte";
|
||||
import Breadcrumb from "../components/Breadcrumb.svelte";
|
||||
import Icon from "../utils/Icon.svelte";
|
||||
|
||||
const menu = $locals.menu || [];
|
||||
</script>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} backend" h1={false}>
|
||||
<div class="container">
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
|
||||
<Breadcrumb currentPageTitle="Backend"/>
|
||||
|
||||
<h1>App administration</h1>
|
||||
<h1>App administration</h1>
|
||||
|
||||
<div class="panel">
|
||||
<nav>
|
||||
<ul>
|
||||
{#each menu as element}
|
||||
<li>
|
||||
<a href={element.link}>
|
||||
{#if element.display_icon !== null}
|
||||
<i data-feather={element.display_icon}></i>
|
||||
{/if}
|
||||
{element.display_string}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<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>
|
||||
</BaseLayout>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
<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}
|
|
@ -2,7 +2,14 @@
|
|||
import {locals} from "../../ts/stores.js";
|
||||
import Message from "./Message.svelte";
|
||||
|
||||
export let flashed = $locals.flash();
|
||||
export let flashed = $locals.flash;
|
||||
const displayedCategories = [
|
||||
'success',
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
'error-alert',
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -13,7 +20,7 @@
|
|||
|
||||
<div class="messages">
|
||||
{#if flashed}
|
||||
{#each Object.entries(flashed) as [key, bag], i}
|
||||
{#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}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<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}
|
|
@ -45,7 +45,7 @@
|
|||
|
||||
border-radius: 5px;
|
||||
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
--icon-size: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
@ -77,7 +77,7 @@
|
|||
&-discreet {
|
||||
color: var(--on-surface);
|
||||
|
||||
.feather {
|
||||
.icon {
|
||||
--icon-size: 20px;
|
||||
}
|
||||
}
|
||||
|
@ -94,7 +94,7 @@
|
|||
|
||||
border: 0;
|
||||
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,16 +67,19 @@
|
|||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
|
||||
@include large-ge {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +99,7 @@
|
|||
|
||||
border-radius: $headerHeight;
|
||||
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
--icon-size: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
<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>
|
|
@ -6,24 +6,35 @@
|
|||
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: row;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
@include medium-le {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@mixin aHover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
|
||||
@include large-ge {
|
||||
margin-left: 8px;
|
||||
@include darkMode {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -34,17 +45,23 @@
|
|||
height: auto;
|
||||
padding: 8px;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.07);
|
||||
text-transform: uppercase;
|
||||
|
||||
@include darkMode {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
@include medium-le {
|
||||
&:hover {
|
||||
@include aHover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text-transform: uppercase;
|
||||
@include large-ge {
|
||||
&:hover > a {
|
||||
@include aHover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(form) {
|
||||
|
@ -58,14 +75,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
--icon-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<li>
|
||||
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
|
||||
{#if action}
|
||||
<Form action={href} submitIcon={icon} submitText={text}/>
|
||||
{:else}
|
||||
|
@ -73,4 +90,5 @@
|
|||
<Icon name={icon}/>
|
||||
<span class="tip">{text}</span></a>
|
||||
{/if}
|
||||
<slot/>
|
||||
</li>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={400} message="Bad request." />
|
||||
<ErrorTemplate code={400} message="Bad request." />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={401} message="Unauthorized." />
|
||||
<ErrorTemplate code={401} message="Unauthorized." />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={403} message="Forbidden" />
|
||||
<ErrorTemplate code={403} message="Forbidden" />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={404} message="Page not found." />
|
||||
<ErrorTemplate code={404} message="Page not found." />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={429} message="Too many requests." />
|
||||
<ErrorTemplate code={429} message="Too many requests." />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={500} message="Internal server error." />
|
||||
<ErrorTemplate code={500} message="Internal server error." />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import Error from "./Error.svelte";
|
||||
import ErrorTemplate from "../templates/ErrorTemplate.svelte";
|
||||
</script>
|
||||
|
||||
<Error code={503} message="Service unavailable." />
|
||||
<ErrorTemplate code={503} message="Service unavailable." />
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
|
||||
const previousUrl = $locals.getPreviousUrl();
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{code + ' - ' + message}</title>
|
||||
<link rel="stylesheet" href="/css/error.css">
|
||||
</svelte:head>
|
||||
|
||||
<div class="logo"><a href="/">{$locals.app.name}</a></div>
|
||||
|
||||
<main>
|
||||
<!--TODO: flash messages-->
|
||||
|
||||
<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"><i data-feather="arrow-left"></i> Go back</a>
|
||||
{/if}
|
||||
|
||||
<a href="/" class="button"><i data-feather="home"></i> 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>
|
|
@ -1,19 +1,33 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../ts/stores";
|
||||
import {route} from "../../common/Routing";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
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>
|
||||
|
||||
<BaseLayout title="{$locals.app.name}" h1={false}>
|
||||
<BaseTemplate title="{$locals.app.name}" h1={false}>
|
||||
<div class="panel">
|
||||
<h1>swaf - Svelte Web Application Framework</h1>
|
||||
<h1>swaf - {randomTitleSWord} Web Application Framework</h1>
|
||||
<p>Welcome to {$locals.app.name}!</p>
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href={route('tests')}>Frontend tests</a></li>
|
||||
<li><a href={route('design')}>Design test</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
{#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>
|
||||
</BaseLayout>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../../ts/stores.js";
|
||||
import {route} from "../../../common/Routing.js";
|
||||
import FlashMessages from "../components/FlashMessages.svelte";
|
||||
import NavMenuItem from "../components/NavMenuItem.svelte";
|
||||
import NavMenu from "../components/NavMenu.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let h1: string = title;
|
||||
export let description: string;
|
||||
export let refresh_after: number | undefined = undefined;
|
||||
</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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@include container;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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>
|
||||
{#if description}
|
||||
<meta name="description" content={description}>
|
||||
{/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>
|
||||
|
||||
<header>
|
||||
<a href="/" class="logo"><img src="/img/logo.svg" alt="{$locals.app.name} logo"> {$locals.app.name}</a>
|
||||
<NavMenu>
|
||||
{#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'}/>
|
||||
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
|
||||
{:else}
|
||||
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
|
||||
{/if}
|
||||
</NavMenu>
|
||||
</header>
|
||||
|
||||
<div class="flash-messages">
|
||||
<FlashMessages/>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{#if h1}
|
||||
<h1>{h1}</h1>
|
||||
{/if}
|
||||
{#if $$slots.subtitle}
|
||||
<p>
|
||||
<slot name="subtitle"/>
|
||||
</p>
|
||||
{/if}
|
||||
<slot/>
|
||||
</main>
|
||||
|
||||
<footer>{$locals.app.name} v{$locals.app_version} - all rights reserved.</footer>
|
|
@ -1,21 +1,19 @@
|
|||
<script>
|
||||
import {locals} from "../ts/stores";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
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>
|
||||
|
||||
<BaseLayout title="{$locals.app.name} {h1}" {h1}>
|
||||
<div class="container">
|
||||
<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 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>
|
||||
</BaseLayout>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import {locals} from "../ts/stores.js";
|
||||
import BaseLayout from "./layouts/BaseLayout.svelte";
|
||||
import BaseTemplate from "./templates/BaseTemplate.svelte";
|
||||
import Message from "./components/Message.svelte";
|
||||
import WebsocketClient from "../ts/WebsocketClient.js";
|
||||
import {Time} from "../../common/Time.js";
|
||||
|
@ -14,7 +14,7 @@
|
|||
|
||||
let countdown;
|
||||
let validUntilDate = new Date(validUntil);
|
||||
$: countdown = $locals.isPreRender ? '...' : Time.humanizeTimeTo(validUntilDate);
|
||||
$: countdown = $locals.isSsr ? '...' : Time.humanizeTimeTo(validUntilDate);
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
|
@ -37,15 +37,13 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<BaseLayout h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
|
||||
<div class="container">
|
||||
<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.`}/>
|
||||
<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>
|
||||
<p class="center">Waiting for you to open the link...</p>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
</BaseTemplate>
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
<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>
|
|
@ -0,0 +1,93 @@
|
|||
<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}
|
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
import ExternalLinkIcons from "../scripts/ExternalLinkIcons.svelte";
|
||||
</script>
|
||||
|
||||
<ExternalLinkIcons/>
|
|
@ -0,0 +1,145 @@
|
|||
<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>
|
|
@ -0,0 +1,12 @@
|
|||
<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>
|
|
@ -0,0 +1,46 @@
|
|||
<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>
|
|
@ -0,0 +1,33 @@
|
|||
<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>
|
|
@ -0,0 +1,27 @@
|
|||
<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}
|
|
@ -12,15 +12,9 @@
|
|||
import * as stores from '/js/stores.js';
|
||||
const localStore = stores.l;
|
||||
|
||||
const localMap = %locals%;
|
||||
localStore.set((key, args) => {
|
||||
return localMap[args ?
|
||||
`'${key}', \`${args}\``
|
||||
: `'${key}'`];
|
||||
});
|
||||
|
||||
localStore.set(%locals%);
|
||||
setRoutes(%routes%);
|
||||
setPublicUrl(%publicUrl%);
|
||||
setPublicUrl(`%publicUrl%`);
|
||||
|
||||
new View({
|
||||
hydrate: true,
|
|
@ -3,4 +3,4 @@
|
|||
import Field from "./Field.svelte";
|
||||
</script>
|
||||
|
||||
<Field type="hidden" name="csrf" value={$locals.getCsrfToken()}/>
|
||||
<Field type="hidden" name="csrf" value={$locals.csrfToken}/>
|
||||
|
|
|
@ -4,23 +4,29 @@
|
|||
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 | Record<string, FieldValue>;
|
||||
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: { message: string, value?: string } | undefined = $locals.validation()?.[name];
|
||||
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.previousFormData() || [];
|
||||
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' && previousFormData[name] || value || validation?.value || '';
|
||||
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]) {
|
||||
|
@ -39,9 +45,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
function focusInput(e) {
|
||||
if (input) {
|
||||
if (['file', 'checkbox', 'color'].indexOf(input.type) >= 0) {
|
||||
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();
|
||||
|
@ -52,27 +62,37 @@
|
|||
}
|
||||
|
||||
function handleInput() {
|
||||
// in here, you can switch on type and implement
|
||||
// whatever behaviour you need
|
||||
value = type.match(/^(number|range)$/)
|
||||
? +this.value
|
||||
: this.value;
|
||||
|
||||
if (this.type === 'file') {
|
||||
handleFileInput();
|
||||
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();
|
||||
}
|
||||
|
||||
let files: FileList | undefined;
|
||||
export let fileList: FileList | undefined = undefined;
|
||||
|
||||
function handleFileInput() {
|
||||
files = input.files;
|
||||
fileList = input.files;
|
||||
}
|
||||
|
||||
let focused = false;
|
||||
|
@ -88,6 +108,7 @@
|
|||
|
||||
.control {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: row;
|
||||
|
@ -96,7 +117,7 @@
|
|||
background-color: var(--input);
|
||||
border-radius: 5px;
|
||||
|
||||
> :global(.feather.icon) {
|
||||
> :global(.icon) {
|
||||
--icon-size: 24px;
|
||||
margin: 18px;
|
||||
opacity: 0.75;
|
||||
|
@ -180,7 +201,7 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
& + :global(.feather) {
|
||||
& + :global(.icon) {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
right: 0;
|
||||
|
@ -190,7 +211,7 @@
|
|||
}
|
||||
|
||||
// TODO: Temporary
|
||||
&:focus + :global(.feather) {
|
||||
&:focus + :global(.icon) {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
}
|
||||
|
@ -317,7 +338,7 @@
|
|||
.name {
|
||||
font-size: 20px;
|
||||
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
--icon-size: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
@ -398,10 +419,10 @@
|
|||
{/each}
|
||||
</fieldset>
|
||||
{:else if type === 'select'}
|
||||
<select name={name} id={fieldId} {...$$restProps} bind:this={input} on:input={handleInput}>
|
||||
<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)}
|
||||
selected={value === (option.value || option)}>{option.display || option}</option>
|
||||
>{option.display || option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Icon name="chevron-down"/>
|
||||
|
@ -413,7 +434,11 @@
|
|||
on:focusout={() => focused = false}></textarea>
|
||||
</div>
|
||||
{:else if type === 'checkbox'}
|
||||
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps} bind:this={input}>
|
||||
<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}>
|
||||
|
@ -421,15 +446,15 @@
|
|||
|
||||
<div class="sections">
|
||||
{#if type !== 'duration'}
|
||||
<label for={fieldId}>{@html placeholder || ''}
|
||||
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
|
||||
<slot/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if type === 'file'}
|
||||
{#if files}
|
||||
{#if fileList}
|
||||
<div class="files">
|
||||
{#each files as file}
|
||||
{#each fileList as file}
|
||||
<div class="file">
|
||||
<div class="name" title="Type: {file.type}">
|
||||
<Icon name="file"/> {file.name}
|
||||
|
|
|
@ -11,18 +11,20 @@
|
|||
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);
|
||||
|
||||
function handleSubmit(e) {
|
||||
if (confirm && !window.confirm(confirm)) {
|
||||
export let onSubmit = function(e) {
|
||||
if (submitDisabled || confirm && !window.confirm(confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -37,7 +39,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<form {action} method="POST" id="{formId}-form" on:submit={handleSubmit}>
|
||||
<form {action} method="POST" id="{formId}-form" on:submit={onSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
|
||||
<CsrfTokenField/>
|
||||
<slot/>
|
||||
<div class="form-controls">
|
||||
|
@ -45,7 +47,7 @@
|
|||
<button type="reset"><Icon name="trash"/>Reset</button>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class={submitClass} class:bold={isBoldSubmit}>
|
||||
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
|
||||
{#if submitIcon}
|
||||
<Icon name={submitIcon}/>
|
||||
{/if}
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
<script lang="ts">
|
||||
import {replaceIcons} from "../../ts/featherIcons.js";
|
||||
import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
|
||||
import {afterUpdate, onMount} from "svelte";
|
||||
|
||||
export let name: string;
|
||||
|
||||
|
||||
onMount(() => {
|
||||
replaceIcons();
|
||||
replaceIcons(true);
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
replaceIcons(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// ---
|
||||
// --- Feather
|
||||
// ---
|
||||
:global(.feather) {
|
||||
:global(.icon) {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -42,9 +39,9 @@
|
|||
</style>
|
||||
|
||||
{#if name}
|
||||
{#if name.startsWith('fa') }
|
||||
<i class="{name} feather icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{#if isLucideIcon(name) >= 0 }
|
||||
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{:else}
|
||||
<i data-feather="{name}" class="feather icon" aria-hidden="true" {...$$restProps}></i>
|
||||
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
@ -57,7 +57,7 @@ export default class AccountController extends Controller {
|
|||
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
|
||||
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
|
||||
|
||||
res.render('auth/account/account', {
|
||||
res.formatViewData('auth/account/account', {
|
||||
user_personal_info_fields: user.getPersonalInfoFields(),
|
||||
main_email: await user.mainEmail.get(),
|
||||
emails: await user.emails.get(),
|
||||
|
|
|
@ -12,6 +12,15 @@ 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}` : ''}.`);
|
||||
}
|
||||
|
||||
public getRoutesPrefix(): string {
|
||||
return '/auth';
|
||||
}
|
||||
|
@ -35,10 +44,12 @@ export default class AuthController extends Controller {
|
|||
|
||||
const userModelFactory = ModelFactory.get(User);
|
||||
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
|
||||
res.render('auth/auth', {
|
||||
const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
|
||||
res.formatViewData('auth/auth', {
|
||||
auth_methods: authGuard.getAuthMethodNames(),
|
||||
has_username: hasUsername,
|
||||
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
|
||||
hasUsername: hasUsername,
|
||||
hasPassword: hasPassword,
|
||||
canRegisterWithPassword: hasUsername && hasPassword,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -160,6 +160,9 @@ export default class AuthGuard {
|
|||
throw new PendingApprovalAuthError();
|
||||
}
|
||||
|
||||
// Mark auth proof as used
|
||||
await proof.use?.();
|
||||
|
||||
// Login
|
||||
session.isAuthenticated = true;
|
||||
session.persistent = persistSession;
|
||||
|
|
|
@ -38,4 +38,10 @@ 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>;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import Mail from "../../mail/Mail.js";
|
|||
import MailTemplate from "../../mail/MailTemplate.js";
|
||||
import Throttler from "../../Throttler.js";
|
||||
import AuthComponent, {AuthMiddleware} from "../AuthComponent.js";
|
||||
import AuthController from "../AuthController.js";
|
||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
|
||||
import MagicLink from "../models/MagicLink.js";
|
||||
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js";
|
||||
|
@ -141,13 +142,13 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||
}
|
||||
|
||||
if (await validLink.isAuthorized()) {
|
||||
validLink.use();
|
||||
validLink.useLink();
|
||||
await validLink.save();
|
||||
await this.performAction(validLink, req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.render('magic_link_lobby', {
|
||||
res.formatViewData('magic_link_lobby', {
|
||||
email: validLink.getOrFail('email'),
|
||||
type: validLink.getOrFail('action_type'),
|
||||
validUntil: validLink.getExpirationDate().getTime(),
|
||||
|
@ -179,7 +180,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||
}
|
||||
}
|
||||
|
||||
res.render('magic_link', {
|
||||
res.formatViewData('magic_link', {
|
||||
magicLink: magicLink,
|
||||
err: err,
|
||||
success: success && err === null,
|
||||
|
@ -198,8 +199,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||
|
||||
if (!res.headersSent && user) {
|
||||
// Auth success
|
||||
const name = user.asOptional(UserNameComponent)?.getName();
|
||||
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
break;
|
||||
|
|
|
@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
|
|||
import WebSocket from "ws";
|
||||
|
||||
import Application from "../../Application.js";
|
||||
import WebSocketListener from "../../WebSocketListener.js";
|
||||
import SessionWebSocketListener from "../../SessionWebSocketListener.js";
|
||||
import MagicLink from "../models/MagicLink.js";
|
||||
|
||||
export default class MagicLinkWebSocketListener<A extends Application> extends WebSocketListener<A> {
|
||||
export default class MagicLinkWebSocketListener<A extends Application> extends SessionWebSocketListener<A> {
|
||||
private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
|
||||
|
||||
public refreshMagicLink(sessionId: string): void {
|
||||
|
@ -16,13 +16,7 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
|
|||
}
|
||||
}
|
||||
|
||||
public async handle(socket: WebSocket, request: IncomingMessage, session: Session | null): Promise<void> {
|
||||
// Drop if requested without session
|
||||
if (!session) {
|
||||
socket.close(1002, 'Session is required for this request.');
|
||||
return;
|
||||
}
|
||||
|
||||
public async handleSessionSocket(socket: WebSocket, request: IncomingMessage, session: Session): Promise<void> {
|
||||
// Refuse any incoming data
|
||||
socket.on('message', () => {
|
||||
socket.close(1003);
|
||||
|
@ -37,19 +31,22 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
|
|||
// Refresh if immediately applicable
|
||||
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
||||
socket.send('refresh');
|
||||
socket.close(1000);
|
||||
const reason = magicLink ?
|
||||
'Magic link state changed.' :
|
||||
'Magic link not found for session ' + session.id;
|
||||
socket.close(1000, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const validityTimeout = setTimeout(() => {
|
||||
socket.send('refresh');
|
||||
socket.close(1000);
|
||||
socket.close(1000, 'Timed out');
|
||||
}, magicLink.getExpirationDate().getTime() - new Date().getTime());
|
||||
|
||||
const f = () => {
|
||||
clearTimeout(validityTimeout);
|
||||
socket.send('refresh');
|
||||
socket.close(1000);
|
||||
socket.close(1000, 'Closed by server');
|
||||
};
|
||||
|
||||
socket.on('close', () => {
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import Migration from "../../db/Migration.js";
|
||||
|
||||
/**
|
||||
* @deprecated - TODO may be remove at next major version >= 0.24, replace with DummyMigration.
|
||||
*/
|
||||
export default class DropNameFromUsers extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query('ALTER TABLE users DROP COLUMN IF EXISTS name');
|
||||
}
|
||||
|
||||
public async rollback(): Promise<void> {
|
||||
await this.query('ALTER TABLE users ADD COLUMN name VARCHAR(64)');
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import Migration from "../../db/Migration.js";
|
||||
|
||||
/**
|
||||
* @deprecated - TODO may be remove at next major version >= 0.24, replace with DummyMigration.
|
||||
*/
|
||||
export default class FixUserMainEmailRelation extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query(`ALTER TABLE users
|
||||
ADD COLUMN main_email_id INT,
|
||||
ADD FOREIGN KEY main_user_email_fk (main_email_id) REFERENCES user_emails (id)`);
|
||||
await this.query(`UPDATE users u LEFT JOIN user_emails ue ON u.id = ue.user_id
|
||||
SET u.main_email_id=ue.id
|
||||
WHERE ue.main = true`);
|
||||
await this.query(`ALTER TABLE user_emails
|
||||
DROP COLUMN main`);
|
||||
}
|
||||
|
||||
public async rollback(): Promise<void> {
|
||||
await this.query(`ALTER TABLE user_emails
|
||||
ADD COLUMN main BOOLEAN DEFAULT false`);
|
||||
await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_email_id
|
||||
SET ue.main = true`);
|
||||
await this.query(`ALTER TABLE users
|
||||
DROP FOREIGN KEY main_user_email_fk,
|
||||
DROP COLUMN main_email_id`);
|
||||
}
|
||||
}
|
|
@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
|||
return this.used;
|
||||
}
|
||||
|
||||
public use(): void {
|
||||
public useLink(): void {
|
||||
this.used = true;
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
|
|||
import {ServerError} from "../../HttpError.js";
|
||||
import Throttler from "../../Throttler.js";
|
||||
import AuthComponent from "../AuthComponent.js";
|
||||
import AuthController from "../AuthController.js";
|
||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
|
||||
import AuthMethod from "../AuthMethod.js";
|
||||
import User from "../models/User.js";
|
||||
|
@ -89,7 +90,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
|||
}
|
||||
}
|
||||
|
||||
req.flash('success', `Welcome, ${user.name}.`);
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
|
||||
|
@ -136,8 +137,9 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
|||
}
|
||||
|
||||
const user = await passwordAuthProof.getResource();
|
||||
if (!user) throw new Error('Password auth proof has no user.');
|
||||
|
||||
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
|
||||
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Your account was successfully created!');
|
||||
res.redirect(req.getIntendedUrl() || route('home'));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export type RouteParams = { [p: string]: string | number } | string[] | string | number;
|
||||
export type RouteParams = { [p: string]: string | number | undefined } | string[] | string | number;
|
||||
export type QueryParamsRecord = Record<string, string | number | boolean | null | undefined>;
|
||||
export type QueryParams = string[][] | QueryParamsRecord | string | URLSearchParams;
|
||||
|
||||
|
@ -43,7 +43,10 @@ export function route(
|
|||
path = path.replace(/\/+/g, '/');
|
||||
} else {
|
||||
for (const key of Object.keys(params)) {
|
||||
path = path.replace(getRouteParamRegExp(key), params[key].toString());
|
||||
const paramValue = params[key];
|
||||
if (paramValue) {
|
||||
path = path.replace(getRouteParamRegExp(key), paramValue.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,3 +69,19 @@ export function route(
|
|||
export function getRouteParamRegExp(key: string, flags?: string): RegExp {
|
||||
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags);
|
||||
}
|
||||
|
||||
export function hasRoute(...routesToMatch: string[]): boolean {
|
||||
for (const route of routesToMatch) {
|
||||
if (!routes[route]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasAnyRoute(...routesToMatch: string[]): boolean {
|
||||
for (const route of routesToMatch) {
|
||||
if (routes[route]) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export const toLucideIconsPascalCase = (string: string): string =>
|
||||
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());
|
|
@ -67,7 +67,7 @@ export class Time {
|
|||
public static humanizeDuration(
|
||||
duration: number,
|
||||
short: boolean = false,
|
||||
skipOneUnitNumber: boolean = false,
|
||||
skipNumberTextPartWhenSingular: boolean = false,
|
||||
units: TimeUnit[] = [
|
||||
this.UNITS.SECOND,
|
||||
this.UNITS.MINUTE,
|
||||
|
@ -77,13 +77,13 @@ export class Time {
|
|||
this.UNITS.YEAR,
|
||||
],
|
||||
): string {
|
||||
for (let i = units.length - 1; i > 0; i--) {
|
||||
if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) {
|
||||
const amount = Math.floor(duration / units[i - 1].milliseconds);
|
||||
const unit = short ?
|
||||
units[i - 1].shortName :
|
||||
' ' + units[i - 1].longName + (amount > 1 ? 's' : '');
|
||||
return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit;
|
||||
for (const unit of units.reverse()) {
|
||||
if (duration >= unit.milliseconds) {
|
||||
const amount = Math.floor(duration / unit.milliseconds);
|
||||
const unitTextPart = short ?
|
||||
unit.shortName :
|
||||
' ' + unit.longName + (amount > 1 ? 's' : '');
|
||||
return (amount > 1 || !skipNumberTextPartWhenSingular ? amount : '') + unitTextPart;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,26 +5,10 @@ import {Session, SessionData} from "express-session";
|
|||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import {AuthMiddleware} from "../auth/AuthComponent.js";
|
||||
import {BadRequestError} from "../HttpError.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
|
||||
export default class CsrfProtectionComponent extends ApplicationComponent {
|
||||
private static readonly excluders: ((req: Request) => boolean)[] = [];
|
||||
|
||||
public static getCsrfToken(session: Session & Partial<SessionData>): string {
|
||||
if (typeof session.csrf !== 'string') {
|
||||
session.csrf = crypto.randomBytes(64).toString('base64');
|
||||
}
|
||||
return session.csrf;
|
||||
}
|
||||
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
|
||||
if (globals) {
|
||||
globals.set('getCsrfToken', () => null);
|
||||
}
|
||||
}
|
||||
|
||||
public static addExcluder(excluder: (req: Request) => boolean): void {
|
||||
this.excluders.push(excluder);
|
||||
}
|
||||
|
@ -36,9 +20,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
|
|||
}
|
||||
|
||||
const session = req.getSession();
|
||||
res.locals.getCsrfToken = () => {
|
||||
return CsrfProtectionComponent.getCsrfToken(session);
|
||||
};
|
||||
res.setLazyLocal('csrfToken', () => this.getSessionCsrfToken(session));
|
||||
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
|
||||
try {
|
||||
|
@ -58,6 +40,13 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
|
|||
next();
|
||||
});
|
||||
}
|
||||
|
||||
public getSessionCsrfToken(session: Session & Partial<SessionData>): string {
|
||||
if (typeof session.csrf !== 'string') {
|
||||
session.csrf = crypto.randomBytes(64).toString('base64');
|
||||
}
|
||||
return session.csrf;
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidCsrfTokenError extends BadRequestError {
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
|
|||
|
||||
public async start(app: Express): Promise<void> {
|
||||
this.server = app.listen(this.port, this.addr, () => {
|
||||
logger.info(`Web server running on http://${this.addr}:${this.port}.`);
|
||||
logger.info(`Web server running on http://${this.addr}:${this.port}`);
|
||||
});
|
||||
|
||||
// Proxy
|
||||
|
@ -48,6 +48,26 @@ export default class ExpressAppComponent extends ApplicationComponent {
|
|||
if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.');
|
||||
return middleware as M;
|
||||
};
|
||||
res.formatViewData = function (
|
||||
viewName: string,
|
||||
data?: Record<string, unknown>,
|
||||
callback?: (err: Error, html: string) => void,
|
||||
) {
|
||||
this.format({
|
||||
html: () => {
|
||||
this.render(viewName, data, callback);
|
||||
},
|
||||
json: () => {
|
||||
if (typeof data === 'undefined') data = {};
|
||||
const serialized = JSON.stringify({...data, viewName}, (key, value) => {
|
||||
if (key.startsWith('_') || typeof value === 'function') return undefined;
|
||||
else return value;
|
||||
});
|
||||
this.contentType('application/json');
|
||||
this.send(serialized);
|
||||
},
|
||||
});
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,42 +1,10 @@
|
|||
import {Router} from "express";
|
||||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
|
||||
export default class FormHelperComponent extends ApplicationComponent {
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
|
||||
if (globals) {
|
||||
globals.set('validation', () => ({}));
|
||||
globals.set('previousFormData', () => ({}));
|
||||
}
|
||||
}
|
||||
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
let _validation: unknown | null;
|
||||
res.locals.validation = () => {
|
||||
if (!_validation) {
|
||||
const v = req.flash('validation');
|
||||
_validation = v.length > 0 ? v[0] : null;
|
||||
}
|
||||
|
||||
return _validation;
|
||||
};
|
||||
|
||||
let _previousFormData: unknown | null = null;
|
||||
res.locals.previousFormData = () => {
|
||||
if (!_previousFormData) {
|
||||
const v = req.flash('previousFormData');
|
||||
_previousFormData = v.length > 0 ? v[0] : null;
|
||||
}
|
||||
|
||||
return _previousFormData;
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
router.use((req, res, next) => {
|
||||
if (['GET', 'POST'].find(m => m === req.method)) {
|
||||
if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
import config from "config";
|
||||
import {Express, Router} from "express";
|
||||
import path from "path";
|
||||
import util from "util";
|
||||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import {QueryParams, route, RouteParams} from "../common/Routing.js";
|
||||
import AssetCompiler from "../frontend/AssetCompiler.js";
|
||||
import AssetPreCompiler from "../frontend/AssetPreCompiler.js";
|
||||
import Globals from "../frontend/Globals.js";
|
||||
import ViewEngine from "../frontend/ViewEngine.js";
|
||||
import {logger} from "../Logger.js";
|
||||
import {listFilesRecursively} from "../Utils.js";
|
||||
import FileCache from "../utils/FileCache.js";
|
||||
import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
|
||||
|
||||
export default class FrontendToolsComponent extends ApplicationComponent {
|
||||
private readonly publicDir: string;
|
||||
private readonly publicAssetsCache: FileCache = new FileCache();
|
||||
private readonly assetPreCompilers: AssetPreCompiler[];
|
||||
private readonly globals: Globals = new Globals();
|
||||
|
||||
public constructor(
|
||||
private readonly assetCompiler: AssetCompiler,
|
||||
|
@ -31,48 +22,17 @@ export default class FrontendToolsComponent extends ApplicationComponent {
|
|||
if (assetPreCompiler.isPublic()) {
|
||||
this.assetCompiler.addExtension(assetPreCompiler.getExtension());
|
||||
}
|
||||
|
||||
assetPreCompiler.setGlobals(this.globals);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.globals.set('route', (
|
||||
routeName: string,
|
||||
params: RouteParams = [],
|
||||
query: QueryParams = '',
|
||||
absolute: boolean = false,
|
||||
) => route(routeName, params, query, absolute));
|
||||
this.globals.set('app_version', this.getApp().getVersion());
|
||||
this.globals.set('core_version', this.getApp().getCoreVersion());
|
||||
this.globals.set('app', config.get('app'));
|
||||
this.globals.set('dump', (val: unknown) => {
|
||||
return util.inspect(val);
|
||||
});
|
||||
this.globals.set('hex', (v: number) => {
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
public async start(app: Express): Promise<void> {
|
||||
// Cache public assets
|
||||
if (config.get<boolean>('asset_cache')) {
|
||||
logger.info('Caching assets from', this.publicDir, '...');
|
||||
for (const file of await listFilesRecursively(this.publicDir)) {
|
||||
await this.publicAssetsCache.load(file);
|
||||
}
|
||||
} else {
|
||||
logger.info('Asset cache disabled.');
|
||||
}
|
||||
|
||||
this.hookPreCompilers();
|
||||
|
||||
// Setup express view engine
|
||||
let main = true;
|
||||
for (const assetPreCompiler of this.assetPreCompilers) {
|
||||
if (assetPreCompiler instanceof ViewEngine) {
|
||||
assetPreCompiler.setup(app, main);
|
||||
assetPreCompiler.setup(app, main, this.getApp().as(LazyLocalsCoreComponent));
|
||||
main = false;
|
||||
}
|
||||
}
|
||||
|
@ -84,17 +44,9 @@ export default class FrontendToolsComponent extends ApplicationComponent {
|
|||
}
|
||||
}
|
||||
|
||||
public async handleRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.locals.inlineAsset = (urlPath: string) => {
|
||||
return this.publicAssetsCache.getOrFail(path.join(this.publicDir, urlPath));
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Add request context locals
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
// Request context locals
|
||||
res.locals.url = req.url;
|
||||
res.locals.params = req.params;
|
||||
res.locals.query = req.query;
|
||||
|
@ -126,8 +78,4 @@ export default class FrontendToolsComponent extends ApplicationComponent {
|
|||
this.hookPreCompilers();
|
||||
}
|
||||
}
|
||||
|
||||
public getGlobals(): Globals {
|
||||
return this.globals;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,11 @@ import {logger} from "../Logger.js";
|
|||
import Mail from "../mail/Mail.js";
|
||||
import MailError from "../mail/MailError.js";
|
||||
import SecurityError from "../SecurityError.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
|
||||
|
||||
export default class MailComponent extends ApplicationComponent {
|
||||
private transporter?: Transporter;
|
||||
private readonly additionalLocals: Record<string, unknown> = {};
|
||||
|
||||
public constructor(
|
||||
private readonly viewEngine: MailViewEngine,
|
||||
|
@ -48,14 +49,18 @@ export default class MailComponent extends ApplicationComponent {
|
|||
try {
|
||||
await util.promisify(transporter.verify)();
|
||||
this.transporter = transporter;
|
||||
} catch (e: any) {
|
||||
throw new MailError('Connection to mail service unsuccessful.', e);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
throw new MailError('Connection to mail service unsuccessful.', e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
|
||||
});
|
||||
|
||||
await this.viewEngine.setup(app, false);
|
||||
await this.viewEngine.setup(app, false, this.getApp().as(LazyLocalsCoreComponent));
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
|
@ -73,7 +78,6 @@ export default class MailComponent extends ApplicationComponent {
|
|||
|
||||
for (const destEmail of to) {
|
||||
const template = mail.getTemplate();
|
||||
const locals = mail.getData();
|
||||
const options = mail.getOptions();
|
||||
|
||||
// Reset options
|
||||
|
@ -87,17 +91,17 @@ export default class MailComponent extends ApplicationComponent {
|
|||
};
|
||||
|
||||
// Set locals
|
||||
locals.mail_subject = options.subject;
|
||||
locals.mail_to = options.to;
|
||||
locals.mail_link = config.get<string>('app.public_url') +
|
||||
route('mail', [template.template], locals);
|
||||
Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get());
|
||||
const urlLocals = mail.getData();
|
||||
urlLocals.mail_subject = options.subject;
|
||||
urlLocals.mail_to = options.to;
|
||||
urlLocals.mail_link = config.get<string>('app.public_url') +
|
||||
route('mail', [template.template], urlLocals);
|
||||
const locals = {...this.additionalLocals, ...urlLocals};
|
||||
|
||||
// Log
|
||||
logger.debug(`Send mail from ${options.from.address} to ${options.to}`);
|
||||
|
||||
// Render email
|
||||
|
||||
options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false);
|
||||
options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true);
|
||||
|
||||
|
@ -108,6 +112,10 @@ export default class MailComponent extends ApplicationComponent {
|
|||
return results;
|
||||
}
|
||||
|
||||
public setAdditionalLocal(key: string, value: unknown): void {
|
||||
this.additionalLocals[key] = value;
|
||||
}
|
||||
|
||||
private getTransporter(): Transporter {
|
||||
if (!this.transporter) throw new MailError('Mail system was not prepared.');
|
||||
return this.transporter;
|
||||
|
|
|
@ -3,17 +3,9 @@ import onFinished from "on-finished";
|
|||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import {logger} from "../Logger.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
import SessionComponent from "./SessionComponent.js";
|
||||
|
||||
export default class PreviousUrlComponent extends ApplicationComponent {
|
||||
public async init(): Promise<void> {
|
||||
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
|
||||
if (globals) {
|
||||
globals.set('getPreviousUrl', () => null);
|
||||
}
|
||||
}
|
||||
|
||||
public async handleRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
req.getPreviousUrl = () => {
|
||||
|
@ -31,7 +23,7 @@ export default class PreviousUrlComponent extends ApplicationComponent {
|
|||
|
||||
return null;
|
||||
};
|
||||
res.locals.getPreviousUrl = req.getPreviousUrl;
|
||||
res.setLazyLocal('previousUrl', () => req.getPreviousUrl());
|
||||
|
||||
req.getIntendedUrl = () => {
|
||||
return req.query.redirect_uri?.toString() || null;
|
||||
|
|
|
@ -1,29 +1,39 @@
|
|||
import config from "config";
|
||||
import {Express} from "express";
|
||||
import session, {Store} from "express-session";
|
||||
import redis, {RedisClient} from "redis";
|
||||
import {createClient, RedisClientType} from "redis";
|
||||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import CacheProvider from "../CacheProvider.js";
|
||||
import {logger} from "../Logger.js";
|
||||
|
||||
export default class RedisComponent extends ApplicationComponent implements CacheProvider {
|
||||
private redisClient?: RedisClient;
|
||||
private readonly prefix: string = config.get('redis.prefix');
|
||||
private redisClient?: RedisClientType;
|
||||
private store: Store = new RedisStore(this);
|
||||
|
||||
public async start(_app: Express): Promise<void> {
|
||||
this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), {
|
||||
const redisUrl = `redis://${config.get('redis.host')}:${config.get('redis.port')}`;
|
||||
console.log(redisUrl);
|
||||
this.redisClient = createClient({
|
||||
url: redisUrl,
|
||||
password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
|
||||
});
|
||||
this.redisClient.on('error', (err: Error) => {
|
||||
logger.error(err, 'An error occurred with redis.');
|
||||
});
|
||||
|
||||
await this.redisClient.connect();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const redisClient = this.redisClient;
|
||||
if (redisClient) {
|
||||
await this.close('Redis connection', callback => redisClient.quit(callback));
|
||||
await this.close('Redis connection', callback => {
|
||||
redisClient.quit()
|
||||
.then(() => callback())
|
||||
.catch(callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,24 +42,14 @@ export default class RedisComponent extends ApplicationComponent implements Cach
|
|||
}
|
||||
|
||||
public isReady(): boolean {
|
||||
return this.redisClient !== undefined && this.redisClient.connected;
|
||||
return this.redisClient !== undefined && this.redisClient.isOpen;
|
||||
}
|
||||
|
||||
public async get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.redisClient.get(key, (err, val) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve((val || defaultValue || undefined) as T);
|
||||
});
|
||||
});
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
return (await this.redisClient.get(this.prefix + key)|| defaultValue || undefined) as T;
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
|
@ -57,49 +57,27 @@ export default class RedisComponent extends ApplicationComponent implements Cach
|
|||
}
|
||||
|
||||
public async forget(key: string): Promise<void> {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
|
||||
this.redisClient.del(key, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await this.redisClient.del(this.prefix + key);
|
||||
}
|
||||
|
||||
public async remember(key: string, value: string, ttl: number): Promise<void> {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
|
||||
this.redisClient.psetex(key, ttl, value, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await this.redisClient.pSetEx(this.prefix + key, ttl, value);
|
||||
}
|
||||
|
||||
public async persist(key: string, ttl: number): Promise<void> {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
if (!this.redisClient) {
|
||||
reject(`Redis client was not initialized.`);
|
||||
return;
|
||||
}
|
||||
if (!this.redisClient) {
|
||||
throw new Error(`Redis client was not initialized.`);
|
||||
}
|
||||
|
||||
this.redisClient.pexpire(key, ttl, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await this.redisClient.pExpire(this.prefix + key, ttl);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import session from "express-session";
|
|||
|
||||
import ApplicationComponent from "../ApplicationComponent.js";
|
||||
import SecurityError from "../SecurityError.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
import RedisComponent from "./RedisComponent.js";
|
||||
|
||||
export default class SessionComponent extends ApplicationComponent {
|
||||
|
@ -16,13 +15,6 @@ export default class SessionComponent extends ApplicationComponent {
|
|||
this.storeComponent = storeComponent;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const globals = this.getApp().asOptional(FrontendToolsComponent)?.getGlobals();
|
||||
if (globals) {
|
||||
globals.set('flash', () => '');
|
||||
}
|
||||
}
|
||||
|
||||
public async checkSecuritySettings(): Promise<void> {
|
||||
this.checkSecurityConfigField('session.secret');
|
||||
if (!config.get<boolean>('session.cookie.secure')) {
|
||||
|
@ -37,8 +29,9 @@ export default class SessionComponent extends ApplicationComponent {
|
|||
store: this.storeComponent.getStore(),
|
||||
resave: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
httpOnly: false,
|
||||
secure: config.get('session.cookie.secure'),
|
||||
sameSite: 'strict',
|
||||
},
|
||||
rolling: true,
|
||||
}));
|
||||
|
@ -68,40 +61,10 @@ export default class SessionComponent extends ApplicationComponent {
|
|||
res.locals.session = session;
|
||||
|
||||
// Views flash function
|
||||
const _flash: FlashStorage = {};
|
||||
res.locals.flash = (key?: string): FlashMessages | unknown[] => {
|
||||
if (key !== undefined) {
|
||||
if (_flash[key] === undefined) _flash[key] = req.flash(key);
|
||||
return _flash[key] || [];
|
||||
}
|
||||
|
||||
if (_flash._messages === undefined) {
|
||||
_flash._messages = {
|
||||
info: req.flash('info'),
|
||||
success: req.flash('success'),
|
||||
warning: req.flash('warning'),
|
||||
error: req.flash('error'),
|
||||
'error-alert': req.flash('error-alert'),
|
||||
};
|
||||
}
|
||||
return _flash._messages;
|
||||
};
|
||||
res.setLazyLocal('flash', () => {
|
||||
return req.flash();
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type FlashMessages = {
|
||||
[k: string]: unknown[] | undefined
|
||||
};
|
||||
|
||||
export type DefaultFlashMessages = FlashMessages & {
|
||||
info?: unknown[] | undefined;
|
||||
success?: unknown[] | undefined;
|
||||
warning?: unknown[] | undefined;
|
||||
error?: unknown[] | undefined;
|
||||
};
|
||||
|
||||
type FlashStorage = FlashMessages & {
|
||||
_messages?: DefaultFlashMessages,
|
||||
};
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import config from "config";
|
||||
import cookie from "cookie";
|
||||
import cookieParser from "cookie-parser";
|
||||
import {Express, Request} from "express";
|
||||
import {Session} from "express-session";
|
||||
import {Express, Router} from "express";
|
||||
import {WebSocketServer} from "ws";
|
||||
|
||||
import Application from "../Application.js";
|
||||
|
@ -10,7 +7,6 @@ import ApplicationComponent from "../ApplicationComponent.js";
|
|||
import {logger} from "../Logger.js";
|
||||
import WebSocketListener from "../WebSocketListener.js";
|
||||
import ExpressAppComponent from "./ExpressAppComponent.js";
|
||||
import FrontendToolsComponent from "./FrontendToolsComponent.js";
|
||||
import RedisComponent from "./RedisComponent.js";
|
||||
|
||||
export default class WebSocketServerComponent extends ApplicationComponent {
|
||||
|
@ -21,11 +17,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
|
|||
|
||||
app.require(ExpressAppComponent);
|
||||
app.require(RedisComponent);
|
||||
}
|
||||
|
||||
const globals = app.asOptional(FrontendToolsComponent)?.getGlobals();
|
||||
if (globals) {
|
||||
globals.set('websocketUrl', config.get('app.public_websocket_url'));
|
||||
}
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.locals.websocketUrl = config.get('app.public_websocket_url');
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
public async start(_app: Express): Promise<void> {
|
||||
|
@ -44,37 +42,11 @@ export default class WebSocketServerComponent extends ApplicationComponent {
|
|||
if (!listener) {
|
||||
socket.close(1002, `Path not found ${request.url}`);
|
||||
return;
|
||||
} else if (!request.headers.cookie) {
|
||||
listener.handle(socket, request, null).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Websocket on ${request.url}`);
|
||||
|
||||
const cookies = cookie.parse(request.headers.cookie);
|
||||
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
|
||||
|
||||
if (!sid) {
|
||||
socket.close(1002);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = app.as(RedisComponent).getStore();
|
||||
store.get(sid, (err, session) => {
|
||||
if (err || !session) {
|
||||
logger.error(err, 'Error while initializing session in websocket.');
|
||||
socket.close(1011);
|
||||
return;
|
||||
}
|
||||
|
||||
session.id = sid;
|
||||
|
||||
store.createSession(<Request>request, session);
|
||||
listener.handle(socket, request, session as Session).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
listener.handle(socket, request).catch(err => {
|
||||
logger.error(err, 'Error in websocket listener.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import config from "config";
|
||||
import {Router} from "express";
|
||||
|
||||
import ApplicationComponent from "../../ApplicationComponent.js";
|
||||
import MailComponent from "../MailComponent.js";
|
||||
|
||||
export default class AppLocalsCoreComponents extends ApplicationComponent {
|
||||
private appLocals: Record<string, unknown> | undefined;
|
||||
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.appLocals = {
|
||||
...config.get('app'),
|
||||
version: this.getApp().getVersion(),
|
||||
core_version: this.getApp().getCoreVersion(),
|
||||
};
|
||||
this.getApp().as(MailComponent).setAdditionalLocal('app', this.appLocals);
|
||||
}
|
||||
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.locals.app = this.appLocals;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import {Router} from "express";
|
||||
|
||||
import ApplicationComponent from "../../ApplicationComponent.js";
|
||||
|
||||
export default class LazyLocalsCoreComponent extends ApplicationComponent {
|
||||
public async initRoutes(router: Router): Promise<void> {
|
||||
router.use((req, res, next) => {
|
||||
res.locals._lazyLocals = {};
|
||||
res.setLazyLocal = (key: string, valueProvider: () => unknown) => {
|
||||
res.locals._lazyLocals[key] = valueProvider;
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
public setupLazyLocals(localsObject: Record<string, unknown>): void {
|
||||
const lazyLocals = localsObject._lazyLocals as Record<string, () => unknown> | undefined;
|
||||
if (!lazyLocals) throw new Error('No _lazyLocals field found on referenced object.');
|
||||
|
||||
for (const lazyLocal of Object.keys(lazyLocals)) {
|
||||
Object.defineProperty(localsObject, lazyLocal, {
|
||||
get: function () {
|
||||
delete this[lazyLocal];
|
||||
return this[lazyLocal] = lazyLocals[lazyLocal]();
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -183,9 +183,14 @@ export default class MysqlConnectionManager {
|
|||
try {
|
||||
const result = await query('SELECT id FROM migrations ORDER BY id DESC LIMIT 1');
|
||||
currentVersion = Number(result.results[0]?.id);
|
||||
} catch (e: any) {
|
||||
if (e.code === 'ECONNREFUSED' || e.code !== 'ER_NO_SUCH_TABLE') {
|
||||
throw new Error('Cannot run migrations: ' + e.code);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
const mysqlError = e as MysqlError;
|
||||
if (mysqlError.code !== 'ER_NO_SUCH_TABLE') {
|
||||
throw new Error('Cannot run migrations: ' + mysqlError.code);
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -237,6 +242,9 @@ export default class MysqlConnectionManager {
|
|||
if (args.length > i + 1) {
|
||||
migrationId = parseInt(args[i + 1]);
|
||||
}
|
||||
|
||||
logger.info('Rolling back migration', migrationId);
|
||||
|
||||
await this.prepare(false);
|
||||
await this.rollbackMigration(migrationId);
|
||||
return;
|
||||
|
|
|
@ -61,8 +61,12 @@ export default class Validator<V> {
|
|||
if (result instanceof Promise) {
|
||||
result = await result;
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new ServerError(`An error occurred while validating ${thingName} with value "${value}".`, e);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
throw new ServerError(`An error occurred while validating ${thingName} with value "${value}".`, e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (result === false && step.throw) {
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import child_process from "child_process";
|
||||
import config from "config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import {logger} from "../Logger.js";
|
||||
import {listFilesRecursively} from "../Utils.js";
|
||||
import {doesFileExist, listFilesRecursively} from "../Utils.js";
|
||||
|
||||
const ROLLUP_CONFIG_FILE_NAME = 'rollup.config.js';
|
||||
|
||||
export default class AssetCompiler {
|
||||
private rollup?: child_process.ChildProcess;
|
||||
|
@ -35,9 +38,12 @@ export default class AssetCompiler {
|
|||
|
||||
const production = !config.get<boolean>('view.dev');
|
||||
if (!this.rollup) {
|
||||
const rollupConfigPath = await doesFileExist(ROLLUP_CONFIG_FILE_NAME) ?
|
||||
ROLLUP_CONFIG_FILE_NAME :
|
||||
path.resolve('node_modules/swaf/' + ROLLUP_CONFIG_FILE_NAME);
|
||||
const args = [
|
||||
'rollup',
|
||||
'-c', 'rollup.config.js',
|
||||
'-c', rollupConfigPath,
|
||||
'--environment', `ENV:${production ? 'production' : 'dev'},BUILD_DIR:${this.sourceDir},PUBLIC_DIR:${this.targetDir},INPUT:${input.join(':')}`,
|
||||
];
|
||||
if (watch) args.push('--watch');
|
||||
|
|
|
@ -4,11 +4,9 @@ import path from "path";
|
|||
|
||||
import {logger} from "../Logger.js";
|
||||
import {doesFileExist, listFilesRecursively} from "../Utils.js";
|
||||
import Globals from "./Globals.js";
|
||||
|
||||
export default abstract class AssetPreCompiler {
|
||||
protected readonly assetPaths: string[];
|
||||
private globals?: Globals;
|
||||
private watcher?: FSWatcher;
|
||||
private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = [];
|
||||
private inputChangeHandler?: (restart: boolean) => Promise<void>;
|
||||
|
@ -57,15 +55,6 @@ export default abstract class AssetPreCompiler {
|
|||
return this.assetPaths;
|
||||
}
|
||||
|
||||
protected getGlobals(): Globals {
|
||||
if (!this.globals) throw new Error('globals field not intialized.');
|
||||
return this.globals;
|
||||
}
|
||||
|
||||
public setGlobals(globals: Globals): void {
|
||||
this.globals = globals;
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.watcher) {
|
||||
await this.watcher.close();
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
export default class Globals {
|
||||
private readonly globals: Record<string, unknown> = {};
|
||||
|
||||
public get(): Record<string, unknown> {
|
||||
return {...this.globals};
|
||||
}
|
||||
|
||||
public set(key: string, value: unknown): void {
|
||||
this.globals[key] = value;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ import "./register_svelte/register_svelte.js";
|
|||
|
||||
import clearModule from "clear-module";
|
||||
import config from "config";
|
||||
import crypto from "crypto";
|
||||
import {promises as fs} from 'fs';
|
||||
import path from "path";
|
||||
import requireFromString from "require-from-string";
|
||||
|
@ -14,23 +13,10 @@ import {logger} from "../Logger.js";
|
|||
import FileCache from "../utils/FileCache.js";
|
||||
import ViewEngine from "./ViewEngine.js";
|
||||
|
||||
const BACKEND_CODE_PREFIX = '$locals.';
|
||||
const BACKEND_CODE_PREFIX_TEMPORARY_HOLDER = '$$locals$$';
|
||||
const COMPILED_SVELTE_EXTENSION = '.precompiled';
|
||||
|
||||
export default class SvelteViewEngine extends ViewEngine {
|
||||
public static getPreCompileSeparator(canonicalViewName: string): string {
|
||||
return '\n---' +
|
||||
crypto.createHash('sha1')
|
||||
.update(path.basename(path.resolve(canonicalViewName)))
|
||||
.digest('base64') +
|
||||
'---\n';
|
||||
}
|
||||
|
||||
|
||||
private readonly fileCache: FileCache = new FileCache();
|
||||
private readonly dependencyCache: Record<string, Set<string>> = {};
|
||||
private readonly preprocessingCache: Record<string, PreprocessingCacheEntry> = {};
|
||||
private readonly reverseDependencyCache: Record<string, Set<string>> = {};
|
||||
private readonly preprocessingCache: Record<string, string> = {};
|
||||
private readonly cssCache: Record<string, string[] | undefined> = {};
|
||||
|
||||
public constructor(
|
||||
|
@ -48,8 +34,8 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
public async onFileRemove(file: string): Promise<void> {
|
||||
const canonicalName = this.toCanonicalName(file);
|
||||
delete this.preprocessingCache[canonicalName];
|
||||
delete this.dependencyCache[canonicalName];
|
||||
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName));
|
||||
delete this.reverseDependencyCache[canonicalName];
|
||||
Object.values(this.reverseDependencyCache).forEach(set => set.delete(canonicalName));
|
||||
await super.onFileRemove(file);
|
||||
}
|
||||
|
||||
|
@ -59,34 +45,26 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
): Promise<string> {
|
||||
const canonicalViewName = this.toCanonicalName(file);
|
||||
|
||||
// View
|
||||
const actualFile = path.join(this.targetDir, canonicalViewName + COMPILED_SVELTE_EXTENSION);
|
||||
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
|
||||
const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
|
||||
const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get<boolean>('view.cache'));
|
||||
|
||||
// Root template
|
||||
const templateFile = await this.resolveFileFromCanonicalNameOrFail('layouts/svelte_layout.html');
|
||||
const rawOutput = await this.fileCache.get(templateFile, !config.get<boolean>('view.cache'));
|
||||
|
||||
// Pre-compiled parts
|
||||
const [
|
||||
backendCalls,
|
||||
locals.isSsr = true;
|
||||
const {
|
||||
head,
|
||||
html,
|
||||
css,
|
||||
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName));
|
||||
} = await this.renderSvelteSsr(canonicalViewName, locals);
|
||||
|
||||
const localMap: Record<string, unknown> = this.compileBackendCalls(backendCalls.split('\n'), locals, false);
|
||||
const actualLocals = JSON.stringify(localMap, (key, value) => {
|
||||
if (key.startsWith('_')) return undefined;
|
||||
return typeof value === 'function' ?
|
||||
value.toString() :
|
||||
value;
|
||||
locals.isSsr = false;
|
||||
const serializedLocals = JSON.stringify(locals, (key, value) => {
|
||||
if (key.startsWith('_') || typeof value === 'function') return undefined;
|
||||
return value;
|
||||
});
|
||||
|
||||
// Replaces
|
||||
const replaceMap: Record<string, string> = {
|
||||
canonicalViewName: canonicalViewName,
|
||||
locals: actualLocals,
|
||||
locals: serializedLocals,
|
||||
head: head,
|
||||
html: html,
|
||||
css: css,
|
||||
|
@ -104,55 +82,17 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
const targetFile = path.join(this.targetDir, canonicalName);
|
||||
logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile);
|
||||
|
||||
const {backendCalls} = await this.preprocess(canonicalName);
|
||||
await this.preprocessSvelte(canonicalName);
|
||||
|
||||
// Server Side Render (initial HTML and CSS, no-js)
|
||||
const ssr = await this.compileSsr(canonicalName);
|
||||
|
||||
const separator = SvelteViewEngine.getPreCompileSeparator(canonicalName);
|
||||
const finalCode = [
|
||||
[...new Set<string>(backendCalls).values()].join('\n'),
|
||||
ssr.head,
|
||||
ssr.html,
|
||||
ssr.css,
|
||||
].join(separator);
|
||||
|
||||
const swafViewFile = path.join(this.targetDir, canonicalName + COMPILED_SVELTE_EXTENSION);
|
||||
await fs.mkdir(path.dirname(swafViewFile), {recursive: true});
|
||||
await fs.writeFile(swafViewFile, finalCode);
|
||||
|
||||
if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) {
|
||||
logger.info(canonicalName + ' > ', 'Compiling dependents...');
|
||||
for (const dependent of [...this.dependencyCache[canonicalName]]) {
|
||||
if (alsoCompileDependents && Object.keys(this.reverseDependencyCache).indexOf(canonicalName) >= 0) {
|
||||
logger.info(canonicalName + ' > ', 'Pre-compiling dependents...');
|
||||
for (const dependent of [...this.reverseDependencyCache[canonicalName]]) {
|
||||
await this.preCompile(dependent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDependencies(source: string, canonicalViewName: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
|
||||
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
|
||||
}
|
||||
|
||||
// Clear existing links from cache
|
||||
for (const dependency of Object.keys(this.dependencyCache)) {
|
||||
this.dependencyCache[dependency].delete(canonicalViewName);
|
||||
}
|
||||
|
||||
// Add new links to cache
|
||||
for (const dependency of dependencies) {
|
||||
if (Object.keys(this.dependencyCache).indexOf(dependency) < 0) {
|
||||
this.dependencyCache[dependency] = new Set<string>();
|
||||
}
|
||||
this.dependencyCache[dependency].add(canonicalViewName);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private async preprocess(canonicalName: string): Promise<PreprocessingCacheEntry> {
|
||||
private async preprocessSvelte(canonicalName: string): Promise<string> {
|
||||
// Cache
|
||||
if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) {
|
||||
return this.preprocessingCache[canonicalName];
|
||||
|
@ -168,13 +108,10 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
// Read source file if code was not already provided
|
||||
const code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
|
||||
|
||||
// Replace backend calls
|
||||
const replacedBackendCalls = await this.replaceBackendCalls(canonicalName, code);
|
||||
|
||||
// Preprocess svelte
|
||||
logger.info(canonicalName + ' > ', 'Svelte preprocessing');
|
||||
const preprocessed = await preprocess(
|
||||
replacedBackendCalls.code,
|
||||
const processed = await preprocess(
|
||||
code,
|
||||
sveltePreprocess({
|
||||
typescript: {
|
||||
tsconfigFile: 'src/assets/views/tsconfig.json',
|
||||
|
@ -186,95 +123,26 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
);
|
||||
|
||||
// Write to output file
|
||||
await fs.writeFile(outputFile, preprocessed.code);
|
||||
await fs.writeFile(outputFile, processed.code);
|
||||
|
||||
// Preprocess dependencies
|
||||
const backendCalls: string[] = replacedBackendCalls.backendCalls;
|
||||
for (const dependency of this.resolveDependencies(code, canonicalName)) {
|
||||
backendCalls.push(...(await this.preprocess(dependency)).backendCalls);
|
||||
}
|
||||
this.resolveAndCacheDependencies(processed.code, canonicalName);
|
||||
|
||||
return this.preprocessingCache[canonicalName] = {
|
||||
backendCalls: backendCalls,
|
||||
code: preprocessed.code,
|
||||
};
|
||||
return this.preprocessingCache[canonicalName] = processed.code;
|
||||
}
|
||||
|
||||
private async replaceBackendCalls(canonicalName: string, code: string): Promise<PreprocessingCacheEntry> {
|
||||
logger.info(canonicalName + ' > ', 'Replacing backend calls');
|
||||
|
||||
// Skip replace if there is no swaf export
|
||||
if (!code.match(/import[ \n]+{[ \n]*locals[ \n]*}[ \n]+from[ \n]+["'](\.\.\/)+ts\/stores(\.js)?["']/)) {
|
||||
return {
|
||||
backendCalls: [],
|
||||
code: code,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
let output = code;
|
||||
const backendCalls = new Set<string>();
|
||||
|
||||
let index = 0;
|
||||
while ((index = output.indexOf(BACKEND_CODE_PREFIX, index + 1)) >= 0) {
|
||||
// Escaping
|
||||
if (index > 0 && output[index - 1] === '\\') {
|
||||
const isEscapingEscaped: boolean = index > 1 && output[index - 2] === '\\';
|
||||
output = output.substring(0, index - 1 - (isEscapingEscaped ? 1 : 0)) +
|
||||
output.substring(index, output.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const startIndex = index + BACKEND_CODE_PREFIX.length;
|
||||
let endIndex = startIndex;
|
||||
let struct = 0;
|
||||
|
||||
while (endIndex < output.length) {
|
||||
if (['(', '[', '{'].indexOf(output[endIndex]) >= 0) struct++;
|
||||
if ([')', ']', '}'].indexOf(output[endIndex]) >= 0) {
|
||||
struct--;
|
||||
if (struct <= 0) {
|
||||
if (struct === 0) endIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ([' ', '\n', '<', '.', '\'', '"', '?', ','].indexOf(output[endIndex]) >= 0 && struct === 0) break;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
let backendCall = output.substring(startIndex, endIndex);
|
||||
if (backendCall.match(/([^()]+)\((.*?)\)/)) {
|
||||
backendCall = backendCall.replace(/([^()]+)\((.*?)\)/, "'$1', `[$2]`");
|
||||
} else {
|
||||
backendCall = backendCall.replace(/([^()]+)(\(\))?/, "'$1'");
|
||||
}
|
||||
backendCalls.add(backendCall);
|
||||
|
||||
output = output.substring(0, index) +
|
||||
'$locals(' + backendCall.split(BACKEND_CODE_PREFIX).join(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER) + ')' +
|
||||
output.substring(endIndex, output.length);
|
||||
}
|
||||
output = output.split(BACKEND_CODE_PREFIX_TEMPORARY_HOLDER).join(BACKEND_CODE_PREFIX);
|
||||
|
||||
return {
|
||||
backendCalls: [...backendCalls],
|
||||
code: output,
|
||||
};
|
||||
}
|
||||
|
||||
private async compileSsr(canonicalName: string): Promise<{
|
||||
private async renderSvelteSsr(canonicalName: string, locals: {[key: string]: unknown}): Promise<{
|
||||
head: string,
|
||||
css: string,
|
||||
html: string,
|
||||
}> {
|
||||
const targetFile = path.join(this.targetDir, canonicalName);
|
||||
const {backendCalls, code} = await this.preprocess(canonicalName);
|
||||
const code = await this.fileCache.get(targetFile, !config.get<boolean>('view.cache'));
|
||||
|
||||
// Get dependencies css
|
||||
const dependenciesCss: string[] = [];
|
||||
for (const dependency of this.resolveDependencies(code, canonicalName)) {
|
||||
if (this.cssCache[dependency] === undefined) {
|
||||
await this.compileSsr(dependency);
|
||||
for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) {
|
||||
if (this.cssCache[dependency] === undefined || !config.get<boolean>('view.cache')) {
|
||||
await this.renderSvelteSsr(dependency, locals);
|
||||
}
|
||||
const css = this.cssCache[dependency];
|
||||
if (css === undefined) {
|
||||
|
@ -295,15 +163,8 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
});
|
||||
|
||||
// Load locals into locals store
|
||||
const localsModulePath = "../../intermediates/assets/ts/stores.js";
|
||||
const localsModule = await import(localsModulePath);
|
||||
const locals = this.getGlobals().get();
|
||||
const localMap = this.compileBackendCalls(backendCalls, locals, true);
|
||||
localsModule.locals.set((key: string, args: string) => {
|
||||
return localMap[args ?
|
||||
`'${key}', \`${args}\``
|
||||
: `'${key}'`];
|
||||
});
|
||||
const storesModule = await import(path.resolve(this.targetDir, "../ts/stores.js"));
|
||||
storesModule.locals.set(locals);
|
||||
|
||||
// Load module and render
|
||||
const moduleId = path.resolve(targetFile);
|
||||
|
@ -324,40 +185,26 @@ export default class SvelteViewEngine extends ViewEngine {
|
|||
};
|
||||
}
|
||||
|
||||
private compileBackendCalls(
|
||||
backendCalls: string[],
|
||||
locals: Record<string, unknown>,
|
||||
isPreRender: boolean,
|
||||
): Record<string, unknown> {
|
||||
locals = {...locals, isPreRender};
|
||||
private resolveAndCacheDependencies(source: string, canonicalViewName: string): string[] {
|
||||
const dependencies: string[] = [];
|
||||
|
||||
const localMap: Record<string, unknown> = {};
|
||||
backendCalls.forEach(code => {
|
||||
const key = code.substring(1, code.indexOf(',') >= 0 ? code.indexOf(',') - 1 : code.length - 1);
|
||||
if (code.indexOf('`[') >= 0) {
|
||||
const args = code.substring(code.indexOf('`[') + 2, code.length - 2)
|
||||
.split(/, *?/)
|
||||
.map(arg => {
|
||||
if (arg.startsWith("'")) return '"' + arg.substring(1, arg.length - 1) + '"';
|
||||
return arg;
|
||||
})
|
||||
.filter(arg => arg.length > 0)
|
||||
.map(arg => {
|
||||
return Function(`"use strict";const $locals = arguments[0];return (${arg});`)(locals);
|
||||
}); // Uses named parameter locals
|
||||
for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
|
||||
dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
|
||||
}
|
||||
|
||||
const f = locals[key];
|
||||
if (typeof f !== 'function') throw new Error(key + ' is not a function.');
|
||||
localMap[`'${key}', \`[${code.substring(code.indexOf('`[') + 2, code.length - 2)}]\``] = f.call(locals, ...args);
|
||||
} else {
|
||||
localMap[`'${key}'`] = locals[key];
|
||||
// Clear existing links from cache
|
||||
for (const dependency of Object.keys(this.reverseDependencyCache)) {
|
||||
this.reverseDependencyCache[dependency].delete(canonicalViewName);
|
||||
}
|
||||
|
||||
// Add new links to cache
|
||||
for (const dependency of dependencies) {
|
||||
if (Object.keys(this.reverseDependencyCache).indexOf(dependency) < 0) {
|
||||
this.reverseDependencyCache[dependency] = new Set<string>();
|
||||
}
|
||||
});
|
||||
return localMap;
|
||||
this.reverseDependencyCache[dependency].add(canonicalViewName);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
type PreprocessingCacheEntry = {
|
||||
backendCalls: string[],
|
||||
code: string,
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {Express} from "express";
|
||||
|
||||
import LazyLocalsCoreComponent from "../components/core/LazyLocalsCoreComponent.js";
|
||||
import AssetPreCompiler from "./AssetPreCompiler.js";
|
||||
|
||||
export default abstract class ViewEngine extends AssetPreCompiler {
|
||||
|
@ -18,10 +19,11 @@ export default abstract class ViewEngine extends AssetPreCompiler {
|
|||
locals: Record<string, unknown>,
|
||||
): Promise<string>;
|
||||
|
||||
public setup(app: Express, main: boolean): void {
|
||||
public setup(app: Express, main: boolean, lazyLocalsComponent: LazyLocalsCoreComponent): void {
|
||||
app.engine(this.extension, (path, options, callback) => {
|
||||
// Props (locals)
|
||||
const locals = Object.assign(this.getGlobals().get(), options);
|
||||
const locals = {...options};
|
||||
|
||||
lazyLocalsComponent.setupLazyLocals(locals);
|
||||
|
||||
this.render(path, locals)
|
||||
.then(value => callback(null, value))
|
||||
|
|
|
@ -53,7 +53,7 @@ export default class BackendController extends Controller {
|
|||
}
|
||||
|
||||
protected async getIndex(req: Request, res: Response): Promise<void> {
|
||||
res.render('backend/index', {
|
||||
res.formatViewData('backend/index', {
|
||||
menu: await Promise.all(BackendController.menu.map(async m => ({
|
||||
link: await m.getLink(),
|
||||
display_string: await m.getDisplayString(),
|
||||
|
@ -66,7 +66,7 @@ export default class BackendController extends Controller {
|
|||
const accounts = await User.paginate(req, 20, User.select()
|
||||
.where('approved', 0)
|
||||
.with('mainEmail'));
|
||||
res.render('backend/accounts_approval', {
|
||||
res.formatViewData('backend/accounts_approval', {
|
||||
accounts: accounts.map(account => Object.assign({
|
||||
mainEmailStr: account.mainEmail.getOrFail()?.email,
|
||||
created_at_iso: account.created_at?.toISOString(),
|
||||
|
|
|
@ -7,8 +7,8 @@ export default class MailController extends Controller {
|
|||
this.get("/mail/:template", this.getMail, 'mail');
|
||||
}
|
||||
|
||||
protected async getMail(request: Request, response: Response): Promise<void> {
|
||||
protected async getMail(request: Request, res: Response): Promise<void> {
|
||||
const template = request.params['template'];
|
||||
response.render(`mails/${template}.mnjk`, request.query);
|
||||
res.formatViewData(`mails/${template}.mnjk`, request.query);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {MysqlError} from "mysql";
|
||||
|
||||
import Migration from "../db/Migration.js";
|
||||
import {query} from "../db/MysqlConnectionManager.js";
|
||||
|
||||
|
@ -8,8 +10,8 @@ export default class CreateMigrationsTable extends Migration {
|
|||
public async shouldRun(currentVersion: number): Promise<boolean> {
|
||||
try {
|
||||
await query('SELECT 1 FROM migrations LIMIT 1');
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ER_NO_SUCH_TABLE') {
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error) || (e as MysqlError).code !== 'ER_NO_SUCH_TABLE') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
"module": "CommonJS",
|
||||
|
||||
"baseUrl": "../dist",
|
||||
"rootDir": "./",
|
||||
"sourceRoot": "./",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue