Compare commits

...

99 Commits

Author SHA1 Message Date
Alice Gaudon 3946a5facc Merge branch 'develop' 2022-03-07 18:59:13 +01:00
Alice Gaudon ecb2b13a83 Version 0.25.1 2022-03-07 18:58:49 +01:00
Alice Gaudon a823503cb4 Upgrade @tsconfig/svelte 2022-03-07 18:58:21 +01:00
Alice Gaudon f0304c0b9f Upgrade @types/config, @types/node, concurrently, lucide 2022-03-07 18:56:35 +01:00
Alice Gaudon ae31edb622 Upgrade redis package 2022-03-07 18:42:50 +01:00
Alice Gaudon d378771ffa Upgrade dependencies 2022-03-07 18:15:48 +01:00
Alice Gaudon 839383f3cc Merge branch 'develop' 2022-03-07 17:52:34 +01:00
Alice Gaudon 869791b3ad Version 0.25.0 2022-03-07 17:47:28 +01:00
Alice Gaudon e19a627eb5 fix(front/auth/login form): don't show password login method if there is not UserPasswordComponent 2022-03-07 17:41:20 +01:00
Alice Gaudon 3d960dccf3 fix(front/auth/register form): don't show password register method if there is no UserNameComponent
fixes #44
2022-03-07 17:41:20 +01:00
Alice Gaudon 6714e413a2 feat(front/form): allow forms to be disabled, add disabled buttons style 2022-03-07 17:22:43 +01:00
Alice Gaudon 27e9abc5f4 fix(back/auth): refactor auth success, fix message saying 'Welcome undefined' when there is no UserNameComponent 2022-03-07 17:02:03 +01:00
Alice Gaudon 8c083d562d fix(front/auth/login): show password field if previousFormData has password 2022-03-07 16:31:40 +01:00
Alice Gaudon 4cfcaac1cc fix(front/forms): simplify and fix usage of flashed previousFormData and validation errors 2022-03-06 18:55:06 +01:00
Alice Gaudon 3eb33c64d7 fix(front/Field): prevent label from having a greater z-index than the mobile menu 2022-03-06 18:46:33 +01:00
Alice Gaudon fd2852c387 feat(front/NavMenu): put logout button on the Account link under an extendable dropdown menu 2022-03-06 18:37:46 +01:00
Alice Gaudon 9f17c5b8cd feat(front/style): add hr default style 2022-03-06 18:36:02 +01:00
Alice Gaudon 144a72895e fix(front/account): fix first panel tag name (div->section) 2022-03-06 18:35:41 +01:00
Alice Gaudon 81c65344a9 chore(front): remove unnecessary containers and reformat 2022-03-06 16:57:31 +01:00
Alice Gaudon 41a083ba52 feat(back/auth): add use() method to AuthProof and call it on successful login attempt 2022-03-06 16:34:57 +01:00
Alice Gaudon 0c4349fac3 fix(common/Time): fix humanizeDuration for duration > 1 year 2022-03-06 13:22:12 +01:00
Alice Gaudon cb5001ce6e fix(front/CopyableText): don't display title when not provided 2022-03-06 13:21:04 +01:00
Alice Gaudon ee56113808 fix(front/FlashMessages): only display relevant flash bags 2022-03-06 13:20:20 +01:00
Alice Gaudon 2c3286d313 feat(front/data-table): add actions cell, col-grow and col-grow-cell 2022-03-05 10:05:13 +01:00
Alice Gaudon 2e3c5d16c4 fix(back/flash): retrieve all flashed fields 2022-03-05 10:03:49 +01:00
Alice Gaudon 81a62be38d feat(front/CopyableText): add simple button mode 2022-03-02 11:36:23 +01:00
Alice Gaudon afef367e59 chore(front): cleanup outdated todo 2022-03-02 10:23:19 +01:00
Alice Gaudon 1e72ec7172 fix(front/Icon): better match icon type against Lucide Icons full name instead of starting with fa to avoid collisions 2022-03-02 10:22:55 +01:00
Alice Gaudon 0e0e633e08 feat(front/Form): allow overriding onSubmit function 2022-02-28 14:43:36 +01:00
Alice Gaudon 7a49e47ae7 feat(front/Field): expose file list 2022-02-28 14:42:36 +01:00
Alice Gaudon 67dc33adf4 fix(front/checkbox): prevent checkbox double click event 2022-02-19 11:58:36 +01:00
Alice Gaudon 231aa8dcd7 fix(front/checkbox): change value to boolean and use handleInput 2022-02-19 11:30:06 +01:00
Alice Gaudon 535c8afdb1 fix(websockets): send cookies manually for session authentication 2022-02-18 22:59:16 +01:00
Alice Gaudon 7bb7f5a017 Merge branch 'develop' 2021-12-03 00:42:59 +01:00
Alice Gaudon dad4ff62f1 Version 0.24.10 2021-12-03 00:42:20 +01:00
Alice Gaudon 8626d0b571 Upgrade dependencies 2021-12-03 00:42:12 +01:00
Alice Gaudon 32a1721ef2 front/BaseTemplate: add facebook, twitter and generic url preview meta tags 2021-12-03 00:40:31 +01:00
Alice Gaudon 689c860e2e front/ErrorTemplate: fix header logo being cropped 2021-11-28 21:58:06 +01:00
Alice Gaudon cd3dd454aa Merge branch 'develop' 2021-11-28 21:28:42 +01:00
Alice Gaudon 7672a568fc Version 0.24.9 2021-11-28 21:28:06 +01:00
Alice Gaudon d6b530d16c Add formatViewData to response object to fix tests and prepare for async navigation 2021-11-28 21:26:45 +01:00
Alice Gaudon 366e48757e back/config: replace localhost with 127.0.0.1 2021-11-28 21:24:51 +01:00
Alice Gaudon ae603362e9 back/redis: fix not using redis.prefix config 2021-11-28 17:06:07 +01:00
Alice Gaudon be0676611a Upgrade dependencies 2021-11-28 17:02:18 +01:00
Alice Gaudon b85573a64d front: add Loader component 2021-11-28 16:44:51 +01:00
Alice Gaudon 172d27a6d2 front/NavMenuItem: remove margin top/left on first menu item 2021-11-28 16:44:27 +01:00
Alice Gaudon e033baa57f front/BaseTemplate: allow disabling login link and logo label 2021-11-28 16:43:46 +01:00
Alice Gaudon d1ff09fcc8 front/BaseFooter: fix app version local 2021-11-28 16:42:22 +01:00
Alice Gaudon 9674fc87dd front/SvelteViewEngine: add isSsr local 2021-11-28 16:41:46 +01:00
Alice Gaudon c606379bcd front/SvelteViewEngine: don't use css cache in development environment 2021-11-25 00:25:50 +01:00
Alice Gaudon 179cb09b58 back/Application: fix fallback error template view name 2021-11-24 22:18:14 +01:00
Alice Gaudon 9980c54fcf front/SvelteViewEngine: pre-compile ssr on demand, refactor globals into proper locals and lazy locals 2021-11-24 22:18:14 +01:00
Alice Gaudon 2b85bea9dd common/Routing: allow RouteParams to be undefined and ignore them in this case 2021-11-24 18:20:02 +01:00
Alice Gaudon 4c895229ec back/cli: fix main command not able to accept args 2021-11-24 18:20:02 +01:00
Alice Gaudon e1542ae476 back/migration command: add log to show that we are rolling back a migration and which one 2021-11-24 18:20:02 +01:00
Alice Gaudon 59491d63ab front/Field: add support for datetime-local field type 2021-11-24 18:20:02 +01:00
Alice Gaudon aa1484749e front/BaseTemplate: allow disabling header, h1 and footer via boolean attributes 2021-11-24 18:16:16 +01:00
Alice Gaudon f801f6a43b Add missing dev dependency @types/node 2021-11-24 17:38:16 +01:00
Alice Gaudon f68e81836b Allow forms to have a file upload enctype 2021-11-22 21:30:23 +01:00
Alice Gaudon 6348692473 Fix select field value bind 2021-11-22 21:29:54 +01:00
Alice Gaudon e151c9c744 Fix FileUploadMiddleware formidable usage 2021-11-22 17:59:32 +01:00
Alice Gaudon 6e630c2715 Merge branch 'develop' 2021-11-21 20:25:08 +01:00
Alice Gaudon 2e68cf8cae Version 0.24.8 2021-11-21 20:24:56 +01:00
Alice Gaudon f55ba4d611 Upgrade svelte-preprocess 2021-11-21 20:24:35 +01:00
Alice Gaudon 5cfdecfebf Also replace icons afterUpdate 2021-11-21 20:18:15 +01:00
Alice Gaudon 10d5d16967 Watch DOM changes to add external link icons to new external links 2021-11-21 20:17:49 +01:00
Alice Gaudon 7f4996c908 Add fonts copy to rollup config 2021-11-21 18:34:36 +01:00
Alice Gaudon aa4a47230d Merge branch 'develop' 2021-11-21 17:08:00 +01:00
Alice Gaudon 92c6433235 Version 0.24.7 2021-11-21 17:06:45 +01:00
Alice Gaudon 48de7829ec Upgrade dependencies 2021-11-21 17:06:28 +01:00
Alice Gaudon 9998f1a91c Make require-from-string a normal dependency for dependants 2021-11-21 17:06:06 +01:00
Alice Gaudon d76a227c5c Merge branch 'develop' 2021-11-21 16:50:59 +01:00
Alice Gaudon c7d0238d22 Version 0.24.6 2021-11-21 16:50:47 +01:00
Alice Gaudon b24e9ab580 front: add CopyableText component 2021-11-21 16:10:38 +01:00
Alice Gaudon e9db1f4ded front: add an external link icon to external links 2021-11-21 15:02:31 +01:00
Alice Gaudon 428990dc00 front/home page: make first word of the title choosen at random in a list 2021-11-21 13:15:15 +01:00
Alice Gaudon c0918b17ed Rename front layouts to templates 2021-11-21 13:14:23 +01:00
Alice Gaudon da6fda02a9 Replace feather icons with lucide icons, use Icon component everywhere 2021-11-21 12:42:26 +01:00
Alice Gaudon 297bafcdc8 Add style to error pages 2021-11-20 22:55:25 +01:00
Alice Gaudon 8b1e940d0a Merge branch 'develop' 2021-11-20 19:53:14 +01:00
Alice Gaudon 61e7282f25 Version 0.24.5 2021-11-20 19:52:59 +01:00
Alice Gaudon d40481fe3b Make rollup available in dependant projects 2021-11-20 19:52:44 +01:00
Alice Gaudon 9da35de4e0 Move design page view to test views 2021-11-20 19:22:04 +01:00
Alice Gaudon febde935e3 Make more route usage optional 2021-11-20 19:21:15 +01:00
Alice Gaudon 2879e014a8 Merge branch 'develop' 2021-11-20 19:09:26 +01:00
Alice Gaudon 3c28bd9fbe Version 0.24.4 2021-11-20 19:09:14 +01:00
Alice Gaudon 3616d54d29 frontend/BaseLayout: make it easier to edit the BaseLayout without redefining everything 2021-11-20 19:04:55 +01:00
Alice Gaudon e97cbb5d7f Routing: add hasRoute and hasAnyRoute and make optional components views not render route if they don't exist 2021-11-20 18:02:00 +01:00
Alice Gaudon c0245e3e3d Move tests page view to test folder 2021-11-20 17:29:17 +01:00
Alice Gaudon e188458f9c SvelteViewEngine: fix stores file path 2021-11-20 17:29:17 +01:00
Alice Gaudon 2f2e1f51b8 Fix dependencies for sources that will be rebuilt by dependant projects 2021-11-20 16:17:18 +01:00
Alice Gaudon 575de5ebb1 Copy assets to dist folder at dist step 2021-11-20 15:43:20 +01:00
Alice Gaudon 4156f978fa Merge branch 'develop' 2021-11-20 15:40:38 +01:00
Alice Gaudon 876d509f10 Version 0.24.3 2021-11-20 15:40:22 +01:00
Alice Gaudon a03dd994e3 Upgrade dependencies 2021-11-20 15:39:51 +01:00
Alice Gaudon 7dde32edb4 Distribute src/assets 2021-11-20 15:39:51 +01:00
Alice Gaudon a753122290 Do not distribute backend source, export everything to commonjs 2021-11-20 15:39:51 +01:00
Alice Gaudon 5869ba3ee3 Version 0.24.2 2021-11-10 19:26:06 +01:00
Alice Gaudon ce39f82e66 Copy package.json to dist folder for release 2021-11-10 19:25:50 +01:00
103 changed files with 3104 additions and 2545 deletions

View File

@ -4,8 +4,8 @@
app: { app: {
listen_addr: '127.0.0.1', listen_addr: '127.0.0.1',
port: 4899, port: 4899,
public_url: "http://localhost:4899", public_url: "http://127.0.0.1:4899",
public_websocket_url: "ws://localhost:4899", public_websocket_url: "ws://127.0.0.1:4899",
name: 'Example App', name: 'Example App',
contact_email: 'contact@example.net', contact_email: 'contact@example.net',
display_email_warning: true, display_email_warning: true,
@ -36,7 +36,7 @@
}, },
mysql: { mysql: {
connectionLimit: 10, connectionLimit: 10,
host: "localhost", host: "127.0.0.1",
user: "root", user: "root",
password: "", password: "",
database: "swaf", database: "swaf",

View File

@ -3,7 +3,7 @@
approval_mode: true, approval_mode: true,
}, },
mysql: { mysql: {
host: "localhost", host: "127.0.0.1",
user: "root", user: "root",
password: "", password: "",
database: "swaf_test", database: "swaf_test",

View File

@ -1,4 +1,4 @@
export default { module.exports = {
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: 'tsconfig.test.json', tsconfig: 'tsconfig.test.json',
@ -16,6 +16,5 @@ export default {
'**/test/**/*.test.ts' '**/test/**/*.test.ts'
], ],
testEnvironment: 'node', testEnvironment: 'node',
testTimeout: 60000,
resolver: "jest-ts-webcompat-resolver", resolver: "jest-ts-webcompat-resolver",
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "swaf", "name": "swaf",
"version": "0.24.1", "version": "0.25.1",
"description": "Structure Web Application Framework.", "description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf", "repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",
@ -11,7 +11,6 @@
"access": "public" "access": "public"
}, },
"main": "dist/main.js", "main": "dist/main.js",
"type": "module",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"scripts": { "scripts": {
"test": "jest --verbose --runInBand", "test": "jest --verbose --runInBand",
@ -20,32 +19,29 @@
"compile": "yarn clean && yarn prepare-sources && tsc --build", "compile": "yarn clean && yarn prepare-sources && tsc --build",
"build": "yarn compile && node . pre-compile-views && node scripts/dist.js", "build": "yarn compile && node . pre-compile-views && node scripts/dist.js",
"build-production": "NODE_ENV=production yarn build", "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 .", "lint": "eslint .",
"release": "yarn build && yarn lint && yarn test && cd dist && yarn publish" "release": "yarn build && yarn lint && yarn test && cd dist && yarn publish"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.0.6",
"@sveltejs/eslint-config": "sveltejs/eslint-config", "@sveltejs/eslint-config": "sveltejs/eslint-config",
"@tsconfig/svelte": "^2.0.1", "@tsconfig/svelte": "^3.0.0",
"@types/compression": "^1.7.0", "@types/compression": "^1.7.0",
"@types/config": "^0.0.40", "@types/config": "^0.0.41",
"@types/connect-flash": "^0.0.37", "@types/connect-flash": "^0.0.37",
"@types/cookie": "^0.4.0", "@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2", "@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/express-session": "^1.17.0", "@types/express-session": "^1.17.0",
"@types/feather-icons": "^4.7.0",
"@types/formidable": "^2.0.0", "@types/formidable": "^2.0.0",
"@types/geoip-lite": "^1.1.31", "@types/geoip-lite": "^1.1.31",
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",
"@types/mjml": "^4.0.4", "@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10", "@types/mysql": "^2.15.10",
"@types/node": "^17.0.21",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3", "@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1", "@types/on-finished": "^2.3.1",
"@types/redis": "^2.8.18",
"@types/require-from-string": "^1.2.0", "@types/require-from-string": "^1.2.0",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0", "@types/uuid": "^8.0.0",
@ -53,38 +49,30 @@
"@typescript-eslint/eslint-plugin": "^5.3.0", "@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0", "@typescript-eslint/parser": "^5.3.0",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"clear-module": "^4.1.1", "concurrently": "^7.0.0",
"concurrently": "^6.0.0",
"eslint": "^8.2.0", "eslint": "^8.2.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.1.2", "eslint-plugin-svelte3": "^3.1.2",
"feather-icons": "^4.28.0",
"jest": "^27.3.1", "jest": "^27.3.1",
"jest-resolve": "^27.3.1", "jest-resolve": "^27.3.1",
"jest-ts-webcompat-resolver": "^1.0.0", "jest-ts-webcompat-resolver": "^1.0.0",
"maildev": "^1.1.0", "maildev": "^1.1.0",
"node-fetch": "^3.0.0", "node-fetch": "^3.0.0",
"nodemon": "^2.0.6", "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", "sass": "^1.32.12",
"supertest": "^6.0.0", "supertest": "^6.0.0",
"svelte": "^3.35.0",
"svelte-check": "^2.2.8", "svelte-check": "^2.2.8",
"svelte-preprocess": "4.6.9",
"ts-jest": "^27.0.7", "ts-jest": "^27.0.7",
"typescript": "^4.0.2" "typescript": "^4.0.2"
}, },
"dependencies": { "dependencies": {
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^13.0.6",
"@rollup/plugin-url": "^6.1.0",
"argon2": "^0.28.2", "argon2": "^0.28.2",
"clear-module": "^4.1.1",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.3.1", "config": "^3.3.1",
"connect-flash": "^0.1.1", "connect-flash": "^0.1.1",
@ -94,13 +82,24 @@
"express-session": "^1.17.1", "express-session": "^1.17.1",
"formidable": "^2.0.1", "formidable": "^2.0.1",
"geoip-lite": "^1.4.2", "geoip-lite": "^1.4.2",
"lucide": "^0.17.7",
"mjml": "^4.6.2", "mjml": "^4.6.2",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"nanoid": "^3.1.20", "nanoid": "^3.1.20",
"nodemailer": "^6.4.6", "nodemailer": "^6.4.6",
"normalize.css": "^8.0.1",
"nunjucks": "^3.2.1", "nunjucks": "^3.2.1",
"on-finished": "^2.3.0", "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", "ts-node": "^10.4.0",
"tslog": "^3.0.1", "tslog": "^3.0.1",
"uuid": "^8.0.0", "uuid": "^8.0.0",

View File

@ -6,6 +6,7 @@ import commonjs from "@rollup/plugin-commonjs";
import {terser} from "rollup-plugin-terser"; import {terser} from "rollup-plugin-terser";
import livereloadRollupPlugin from "rollup-plugin-livereload"; import livereloadRollupPlugin from "rollup-plugin-livereload";
import imageminPlugin from "rollup-plugin-imagemin"; import imageminPlugin from "rollup-plugin-imagemin";
import url from "@rollup/plugin-url";
const production = process.env.ENV === 'production'; const production = process.env.ENV === 'production';
const buildDir = process.env.BUILD_DIR; const buildDir = process.env.BUILD_DIR;
@ -41,6 +42,15 @@ export default commandLineArgs => ({
// Extract css into separate files // Extract css into separate files
cssOnlyRollupPlugin({output: 'bundle.css'}), cssOnlyRollupPlugin({output: 'bundle.css'}),
url({
include: [
'**/*.woff2?',
'**/*.ttf',
],
limit: 0,
fileName: path.join('../', 'fonts', '[name][extname]'),
}),
// If you have external dependencies installed from // If you have external dependencies installed from
// npm, you'll most likely need these plugins. In // npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration - // some cases you'll need additional configuration -

View File

@ -1,7 +1,7 @@
import fs from "fs"; const fs = require('fs');
import path from "path"; const path = require('path');
export function copyRecursively(file, destination) { function copyRecursively(file, destination) {
const target = path.join(destination, path.basename(file)); const target = path.join(destination, path.basename(file));
if (fs.statSync(file).isDirectory()) { if (fs.statSync(file).isDirectory()) {
console.log('mkdir', target); console.log('mkdir', target);
@ -16,3 +16,7 @@ export function copyRecursively(file, destination) {
fs.copyFileSync(file, target); fs.copyFileSync(file, target);
} }
} }
module.exports = {
copyRecursively,
};

View File

@ -1,4 +1,4 @@
import fs from "fs"; const fs = require('fs');
[ [
'intermediates', 'intermediates',

View File

@ -1,12 +1,13 @@
import fs from "fs"; const fs = require('fs');
import path from "path"; const path = require('path');
import {copyRecursively} from "./_functions.js"; const {copyRecursively} = require('./_functions.js');
[ [
'yarn.lock', 'yarn.lock',
'README.md', 'README.md',
'config/', 'config/',
'rollup.config.js',
].forEach(file => { ].forEach(file => {
copyRecursively(file, 'dist'); copyRecursively(file, 'dist');
}); });
@ -16,3 +17,7 @@ fs.mkdirSync('dist/types', {recursive: true});
fs.readdirSync('src/types').forEach(file => { fs.readdirSync('src/types').forEach(file => {
copyRecursively(path.join('src/types', file), 'dist/types'); copyRecursively(path.join('src/types', file), 'dist/types');
}); });
fs.readdirSync('src/assets').forEach(file => {
copyRecursively(path.join('src/assets', file), 'dist/assets');
});

View File

@ -1,6 +1,5 @@
import fs from "fs"; const fs = require('fs');
import path from "path"; const path = require('path');
import {copyRecursively} from "./_functions.js";
// These folders must exist for nodemon not to loop indefinitely. // 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); fs.symlinkSync(path.resolve('dist/common'), symlink);
} }
// Copy all source files // Copy package.json
fs.readdirSync('src').forEach(file => { fs.copyFileSync('package.json', 'dist/package.json');
copyRecursively(path.join('src', file), 'dist');
});

View File

@ -21,6 +21,8 @@ import SecurityError from "./SecurityError.js";
import {doesFileExist, Type} from "./Utils.js"; import {doesFileExist, Type} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js"; import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError; 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>> { export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string; private readonly version: string;
@ -98,6 +100,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
MysqlConnectionManager.registerMigrations(this.getMigrations()); MysqlConnectionManager.registerMigrations(this.getMigrations());
// Register and initialize all components and alike // Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init(); await this.init();
for (const component of this.components) { for (const component of this.components) {
await component.init?.(); await component.init?.();
@ -194,9 +198,9 @@ export default abstract class Application implements Extendable<ApplicationCompo
error_instructions: httpError.instructions, error_instructions: httpError.instructions,
error_id: errorId, 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) { if (err) {
res.render('errors/Error', locals); res.formatViewData('templates/ErrorTemplate', locals);
} else { } else {
res.send(html); res.send(html);
} }
@ -247,6 +251,7 @@ export default abstract class Application implements Extendable<ApplicationCompo
protected async processCommandLine(): Promise<boolean> { protected async processCommandLine(): Promise<boolean> {
const args = process.argv; const args = process.argv;
// Flags // Flags
const flags = { const flags = {
verbose: false, 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]})`); else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
break; break;
default: default:
if (mainCommand) mainCommandArgs.push(args[i]); if (mainCommand) {
else logger.fatal('Unrecognized argument', args[i]); mainCommandArgs.push(args[i]);
return true; } else {
logger.fatal('Unrecognized argument', args[i]);
return true;
}
break;
} }
} }

View File

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

View File

@ -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>;
}

View File

@ -148,10 +148,10 @@ export default class TestApp extends Application {
this.use(new class extends Controller { this.use(new class extends Controller {
public routes(): void { public routes(): void {
this.get('/', (req, res) => { this.get('/', (req, res) => {
res.render('home'); res.formatViewData('home');
}, 'home'); }, 'home');
this.get('/tests', (req, res) => { this.get('/tests', (req, res) => {
res.render('tests'); res.formatViewData('tests');
}, 'tests'); }, 'tests');
this.get('/design', (req, res) => { this.get('/design', (req, res) => {
req.flash('success', 'Success.'); req.flash('success', 'Success.');
@ -159,7 +159,7 @@ export default class TestApp extends Application {
req.flash('warning', 'Warning.'); req.flash('warning', 'Warning.');
req.flash('error', 'Error.'); req.flash('error', 'Error.');
req.flash('error-alert', 'Error alert.'); req.flash('error-alert', 'Error alert.');
res.render('design'); res.formatViewData('design');
}, 'design'); }, 'design');
} }
}()); }());

View File

@ -1,4 +1,3 @@
import {Session} from "express-session";
import {IncomingMessage} from "http"; import {IncomingMessage} from "http";
import WebSocket from "ws"; import WebSocket from "ws";
@ -20,6 +19,5 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract handle( public abstract handle(
socket: WebSocket, socket: WebSocket,
request: IncomingMessage, request: IncomingMessage,
session: Session | null,
): Promise<void>; ): Promise<void>;
} }

View File

@ -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 // States modifiers
.primary:not(.bold) { .primary:not(.bold) {
--color: var(--primary-on-surface); --color: var(--primary-on-surface);
@ -49,19 +65,6 @@
--background-color: var(--surface); --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) { @if ($shadowStrength > 0) {
box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045; box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045;
} }

View File

@ -155,7 +155,7 @@ h1 {
a { a {
text-decoration: none; text-decoration: none;
.feather.feather-external-link { //todo add js .icon.lucide-external-link {
--icon-size: 16px; --icon-size: 16px;
margin-left: 4px; margin-left: 4px;
margin-top: -3px; margin-top: -3px;
@ -166,6 +166,13 @@ ul {
list-style-type: '- '; list-style-type: '- ';
} }
hr {
margin: 0;
border: 0;
border-top: 1px solid var(--on-background);
opacity: 0.2;
}
.primary, .bold { .primary, .bold {
--color: var(--primary-on-background); --color: var(--primary-on-background);
--background-color: var(--background); --background-color: var(--background);
@ -239,17 +246,17 @@ button, .button {
border: 0; border: 0;
} }
.feather { .icon {
--icon-size: 16px; --icon-size: 16px;
margin-right: 8px; margin-right: 8px;
} }
.feather.last { .icon.last {
margin-right: 0; margin-right: 0;
margin-left: 8px; margin-left: 8px;
} }
&:hover::after { &:hover::after:not([disabled]) {
content: ""; content: "";
position: absolute; position: absolute;
@ -260,4 +267,10 @@ button, .button {
background-color: var(--on-background); background-color: var(--on-background);
opacity: 0.2; opacity: 0.2;
} }
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
} }

View File

@ -23,6 +23,46 @@
thead tr:hover { thead tr:hover {
background-color: transparent; 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 { .data-table-container {

View File

@ -16,7 +16,7 @@
margin: 16px 8px; margin: 16px 8px;
} }
> .feather:first-child { > .icon:first-child {
--icon-size: 24px; --icon-size: 24px;
position: absolute; position: absolute;
opacity: 0.2; opacity: 0.2;
@ -35,7 +35,7 @@
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
.feather { .icon {
--icon-size: 24px; --icon-size: 24px;
margin: 0 16px 0 0; margin: 0 16px 0 0;
opacity: 0.2; opacity: 0.2;

View File

@ -17,6 +17,7 @@ export default class WebsocketClient {
const websocket = new WebSocket(this.websocketUrl); const websocket = new WebSocket(this.websocketUrl);
websocket.onopen = () => { websocket.onopen = () => {
console.debug('Websocket connected'); console.debug('Websocket connected');
websocket.send(document.cookie);
}; };
websocket.onmessage = (e) => { websocket.onmessage = (e) => {
this.listener(websocket, e); this.listener(websocket, e);

View File

@ -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'));

View File

@ -1,10 +0,0 @@
import feather from "feather-icons";
let alreadyReplaced = false;
export function replaceIcons(once: boolean = true): void {
if (!once || !alreadyReplaced) {
alreadyReplaced = true;
feather.replace();
}
}

17
src/assets/ts/icons.ts Normal file
View File

@ -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;
}

View File

@ -3,16 +3,19 @@
import Message from "../../components/Message.svelte"; import Message from "../../components/Message.svelte";
import Form from "../../utils/Form.svelte"; import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte"; import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let newName = ''; let newName = '';
</script> </script>
{#if hasRoute('change-name')}
<section class="panel"> <section class="panel">
<h2><i data-feather="key"></i> Change name</h2> <h2><Icon name="key"/> Change name</h2>
{#if $locals.can_change_name} {#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 : ''}" submitIcon="save" submitText="Change my name {newName.length > 0 ? 'to ' + newName : ''}"
confirm="Are you sure you want to change your name to {newName}?"> confirm="Are you sure you want to change your name to {newName}?">
<Field type="text" name="name" icon="user" placeholder="New name" required bind:value={newName}/> <Field type="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/> <Message type="info" content="You will be able to change your name in {$locals.can_change_name_in}" sticky discreet/>
{/if} {/if}
</section> </section>
{/if}

View File

@ -2,21 +2,24 @@
import {locals} from "../../../ts/stores"; import {locals} from "../../../ts/stores";
import Form from "../../utils/Form.svelte"; import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.svelte"; import Field from "../../utils/Field.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import Icon from "../../utils/Icon.svelte";
let removePasswordMode = false; let removePasswordMode = false;
</script> </script>
{#if hasRoute('remove-password', 'change-password')}
<section class="panel"> <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} {#if removePasswordMode}
<Form action={$locals.route('remove-password')} <Form action={route('remove-password')}
submitIcon="trash" submitText="Remove password" submitClass="danger" submitIcon="trash" submitText="Remove password" submitClass="danger"
confirm="Are you sure you want to remove your password?"> confirm="Are you sure you want to remove your password?">
<button type="button" on:click={() => removePasswordMode = false}>Go back</button> <button type="button" on:click={() => removePasswordMode = false}>Go back</button>
</Form> </Form>
{:else} {:else}
<Form action={$locals.route('change-password')} <Form action={route('change-password')}
submitIcon="save" submitText="Set password"> submitIcon="save" submitText="Set password">
{#if $locals.has_password} {#if $locals.has_password}
<Field type="password" name="current_password" icon="key" placeholder="Current password"/> <Field type="password" name="current_password" icon="key" placeholder="Current password"/>
@ -28,3 +31,4 @@
</Form> </Form>
{/if} {/if}
</section> </section>
{/if}

View File

@ -1,93 +1,102 @@
<script> <script>
import {locals} from "../../../ts/stores"; import {locals} from "../../../ts/stores";
import BaseLayout from "../../layouts/BaseLayout.svelte"; import BaseTemplate from "../../templates/BaseTemplate.svelte";
import Message from "../../components/Message.svelte"; import Message from "../../components/Message.svelte";
import NamePanel from "./NamePanel.svelte"; import NamePanel from "./NamePanel.svelte";
import PasswordPanel from "./PasswordPanel.svelte"; import PasswordPanel from "./PasswordPanel.svelte";
import Form from "../../utils/Form.svelte"; import Form from "../../utils/Form.svelte";
import Field from "../../utils/Field.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 mainEmail = $locals.main_email?.email;
const personalInfoFields = $locals.user_personal_info_fields || []; const personalInfoFields = $locals.user_personal_info_fields || [];
const emails = $locals.emails || []; const emails = $locals.emails || [];
</script> </script>
<BaseLayout title="Account" description="Manage your account settings and data."> <BaseTemplate title="Account" description="Manage your account settings and data.">
<div class="container"> <section class="panel">
<div class="panel"> <h2>
<h2><i data-feather="user"></i> Personal information</h2> <Icon name="user"/>
Personal information
</h2>
{#if $locals.display_email_warning && $locals.emails.length <= 0} {#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."/> <Message type="warning" content="To avoid losing access to your account, please add an email address."/>
{/if} {/if}
{#each personalInfoFields as field} {#each personalInfoFields as field}
<p>{field.name}: {field.value}</p> <p>{field.name}: {field.value}</p>
{/each} {/each}
{#if mainEmail} {#if mainEmail}
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p> <p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
{/if} {/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> </div>
{#if $locals.has_name_component} {#if hasRoute('add-email')}
<NamePanel/> <Form action={route('add-email')} class="sub-panel"
{/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"
submitIcon="plus" submitText="Add email address"> submitIcon="plus" submitText="Add email address">
<h3>Add an email address:</h3> <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" hint="An email address we can use to identify you in case you lose access to your account"
required/> required/>
</Form> </Form>
</section> {/if}
</div> </section>
</BaseLayout> </BaseTemplate>

View File

@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import {locals} from "../../ts/stores.js"; 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 Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte"; import Field from "../utils/Field.svelte";
import Icon from "../utils/Icon.svelte"; import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.previousFormData()?.['auth_method'] !== 'password'; let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
let loginUsingMagicLink = true; let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
let queryStr = ''; let queryStr = '';
let previousUrl = $locals.getPreviousUrl(); let previousUrl: string = $locals.previousUrl as string | undefined;
if ($locals.query?.redirect_uri) { if ($locals.query?.redirect_uri) {
queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString(); queryStr = '?' + new URLSearchParams({redirect_uri: $locals.query?.redirect_uri}).toString();
} else if (previousUrl) { } else if (previousUrl) {
@ -17,50 +18,76 @@
} }
</script> </script>
<BaseLayout title="Authentication / Registration" <BaseTemplate title="Authentication / Registration"
description="Join {$locals.app.name} and share your files!" description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration"> h1="Authentication and registration">
<div class="container"> {#if hasRoute('login')}
<section class="panel"> <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" <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.' : ''} 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/> placeholder="Your email address or username" required/>
{#if !loginUsingMagicLink} {#if $locals.hasPassword}
<Field type="password" name="password" placeholder="Your password" icon="key" required/> {#if loginUsingMagicLink}
<button on:click={() => loginUsingMagicLink=true} type="button"><Icon name="mail"/> Use magic link</button> <button on:click={() => loginUsingMagicLink=false} type="button">
{:else} <Icon name="key"/>
<button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button> 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} {/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> </Form>
</section> </section>
{/if}
{#if hasRoute('register')}
<section class="panel"> <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'}/> <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" <Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username" placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/> pattern="[0-9a-z_-]+" required/>
{/if} {/if}
{#if registerUsingMagicLink} {#if registerUsingMagicLink || !$locals.canRegisterWithPassword}
<Field type="email" name="identifier" icon="at-sign" placeholder="Your email address" <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." hint="You will receive a magic link in your mailbox. Click on the link from any device to register here."
required/> 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} {:else}
<Field type="password" name="password" icon="key" placeholder="Choose a password" required/> <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/> <Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password"
<button on:click={() => registerUsingMagicLink=true} type="button"><Icon name="at-sign"/>Use email address instead</button> required/>
<button on:click={() => registerUsingMagicLink=true} type="button">
<Icon name="at-sign"/>
Use email address instead
</button>
{/if} {/if}
<Field type="checkbox" name="terms" icon="file-text" required> <Field type="checkbox" name="terms" icon="file-text" required>
@ -68,5 +95,5 @@
</Field> </Field>
</Form> </Form>
</section> </section>
</div> {/if}
</BaseLayout> </BaseTemplate>

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import {locals} from "../../ts/stores"; import {locals} from "../../ts/stores";
import BaseLayout from "../layouts/BaseLayout.svelte"; import BaseTemplate from "../templates/BaseTemplate.svelte";
import Pagination from "../components/Pagination.svelte"; import Pagination from "../components/Pagination.svelte";
import Form from "../utils/Form.svelte"; import Form from "../utils/Form.svelte";
import Field from "../utils/Field.svelte"; import Field from "../utils/Field.svelte";
import Breadcrumb from "../components/Breadcrumb.svelte"; import Breadcrumb from "../components/Breadcrumb.svelte";
import {route} from "../../../common/Routing"; import {hasRoute, route} from "../../../common/Routing";
const accounts = $locals.accounts || []; const accounts = $locals.accounts || [];
</script> </script>
@ -16,10 +16,12 @@
} }
</style> </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={[ <Breadcrumb currentPageTitle="Accounts pending review" pages={[
{link: route('backend'), title:'Backend'}, {link: route('backend'), title:'Backend'},
]}/> ]}/>
{/if}
<h1>Accounts pending review</h1> <h1>Accounts pending review</h1>
@ -49,16 +51,20 @@
<td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td> <td><time datetime={user.created_at_iso}>{user.created_at_human} ago</time></td>
<td> <td>
<div class="max-content"> <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"> submitIcon="check" submitText="Approve" submitClass="success">
<Field type="hidden" name="user_id" value={user.id}/> <Field type="hidden" name="user_id" value={user.id}/>
</Form> </Form>
{/if}
<Form action={$locals.route('reject-account')} {#if hasRoute('reject-account')}
<Form action={route('reject-account')}
submitIcon="trash" submitText="Reject" submitClass="danger" submitIcon="trash" submitText="Reject" submitClass="danger"
confirm="This will irrevocably delete the {user.mainEmailStr || user.name || user.id} account."> confirm="This will irrevocably delete the {user.mainEmailStr || user.name || user.id} account.">
<Field type="hidden" name="user_id" value={user.id}/> <Field type="hidden" name="user_id" value={user.id}/>
</Form> </Form>
{/if}
</div> </div>
</td> </td>
</tr> </tr>
@ -72,4 +78,4 @@
</div> </div>
<Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" /> <Pagination pagination={$locals.pagination} routeName="accounts-approval" contextSize="3" />
</BaseLayout> </BaseTemplate>

View File

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

View File

@ -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}

View File

@ -2,7 +2,14 @@
import {locals} from "../../ts/stores.js"; import {locals} from "../../ts/stores.js";
import Message from "./Message.svelte"; import Message from "./Message.svelte";
export let flashed = $locals.flash(); export let flashed = $locals.flash;
const displayedCategories = [
'success',
'info',
'warning',
'error',
'error-alert',
];
</script> </script>
<style lang="scss"> <style lang="scss">
@ -13,7 +20,7 @@
<div class="messages"> <div class="messages">
{#if flashed} {#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} {#each bag as content}
<Message type={key} content={content}/> <Message type={key} content={content}/>
{/each} {/each}

View File

@ -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}

View File

@ -45,7 +45,7 @@
border-radius: 5px; border-radius: 5px;
:global(.feather) { :global(.icon) {
--icon-size: 24px; --icon-size: 24px;
margin-right: 8px; margin-right: 8px;
} }
@ -77,7 +77,7 @@
&-discreet { &-discreet {
color: var(--on-surface); color: var(--on-surface);
.feather { .icon {
--icon-size: 20px; --icon-size: 20px;
} }
} }
@ -94,7 +94,7 @@
border: 0; border: 0;
:global(.feather) { :global(.icon) {
margin: 0; margin: 0;
} }
} }

View File

@ -67,16 +67,19 @@
} }
ul { ul {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
align-items: center;
@include large-ge { @include large-ge {
display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
} }
} }
} }
@ -96,7 +99,7 @@
border-radius: $headerHeight; border-radius: $headerHeight;
:global(.feather) { :global(.icon) {
--icon-size: 28px; --icon-size: 28px;
margin: 0; margin: 0;
} }

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -1,19 +1,33 @@
<script lang="ts"> <script lang="ts">
import {locals} from "../ts/stores"; import {locals} from "../ts/stores";
import {route} from "../../common/Routing"; import {route, hasRoute, hasAnyRoute} from "../../common/Routing";
import BaseLayout from "./layouts/BaseLayout.svelte"; 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> </script>
<BaseLayout title="{$locals.app.name}" h1={false}> <BaseTemplate title="{$locals.app.name}" h1={false}>
<div class="panel"> <div class="panel">
<h1>swaf - Svelte Web Application Framework</h1> <h1>swaf - {randomTitleSWord} Web Application Framework</h1>
<p>Welcome to {$locals.app.name}!</p> <p>Welcome to {$locals.app.name}!</p>
<nav> {#if hasAnyRoute('tests', 'design')}
<ul> <nav>
<li><a href={route('tests')}>Frontend tests</a></li> <ul>
<li><a href={route('design')}>Design test</a></li> {#if hasRoute('tests')}
</ul> <li><a href={route('tests')}>Frontend tests</a></li>
</nav> {/if}
{#if hasRoute('design')}
<li><a href={route('design')}>Design test</a></li>
{/if}
</ul>
</nav>
{/if}
</div> </div>
</BaseLayout> </BaseTemplate>

View File

@ -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>

View File

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

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {locals} from "../ts/stores.js"; 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 Message from "./components/Message.svelte";
import WebsocketClient from "../ts/WebsocketClient.js"; import WebsocketClient from "../ts/WebsocketClient.js";
import {Time} from "../../common/Time.js"; import {Time} from "../../common/Time.js";
@ -14,7 +14,7 @@
let countdown; let countdown;
let validUntilDate = new Date(validUntil); let validUntilDate = new Date(validUntil);
$: countdown = $locals.isPreRender ? '...' : Time.humanizeTimeTo(validUntilDate); $: countdown = $locals.isSsr ? '...' : Time.humanizeTimeTo(validUntilDate);
onMount(() => { onMount(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@ -37,15 +37,13 @@
}); });
</script> </script>
<BaseLayout h1="Authentication lobby" title="{$locals.app.name} authentication lobby"> <BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<div class="container"> <div class="panel">
<div class="panel"> <Message type="success" sticky
<Message type="success" sticky content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/> <Message type="info" discreet sticky raw
<Message type="info" discreet sticky raw content={`This link will be valid for ${countdown} and can only be used once.`}/>
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> <p class="center">Waiting for you to open the link...</p>
</div>
</div> </div>
</BaseLayout> </BaseTemplate>

View File

@ -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>

View File

@ -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}

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -12,15 +12,9 @@
import * as stores from '/js/stores.js'; import * as stores from '/js/stores.js';
const localStore = stores.l; const localStore = stores.l;
const localMap = %locals%; localStore.set(%locals%);
localStore.set((key, args) => {
return localMap[args ?
`'${key}', \`${args}\``
: `'${key}'`];
});
setRoutes(%routes%); setRoutes(%routes%);
setPublicUrl(%publicUrl%); setPublicUrl(`%publicUrl%`);
new View({ new View({
hydrate: true, hydrate: true,

View File

@ -3,4 +3,4 @@
import Field from "./Field.svelte"; import Field from "./Field.svelte";
</script> </script>
<Field type="hidden" name="csrf" value={$locals.getCsrfToken()}/> <Field type="hidden" name="csrf" value={$locals.csrfToken}/>

View File

@ -4,23 +4,29 @@
import Message from "../components/Message.svelte"; import Message from "../components/Message.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import {getContext} from "svelte"; import {getContext} from "svelte";
import {dateToDatetimeLocal, dateToIsoString} from "../../ts/datetime-local.js";
export let type: string; export let type: string;
export let name: 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 value: FieldValue | undefined = undefined;
export let initialValue: FieldValue | undefined = undefined;
export let placeholder: string | undefined = undefined; export let placeholder: string | undefined = undefined;
export let hint: string | undefined = undefined; export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined; export let extraData: string[] | undefined = undefined;
export let icon: 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 formId = getContext('formId');
const fieldId = `${formId}-${name}-field`; 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 { function durationValue(f: string): number {
if (previousFormData[name]) { if (previousFormData[name]) {
@ -39,9 +45,13 @@
} }
} }
function focusInput() { function focusInput(e) {
if (input) { 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(); input.click();
} else { } else {
input.focus(); input.focus();
@ -52,27 +62,37 @@
} }
function handleInput() { function handleInput() {
// in here, you can switch on type and implement switch (this.type) {
// whatever behaviour you need case 'number':
value = type.match(/^(number|range)$/) case 'range':
? +this.value value = +this.value;
: this.value; break;
case 'file':
if (this.type === 'file') { handleFileInput();
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 input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() { function chooseFile() {
input.click(); input.click();
} }
let files: FileList | undefined; export let fileList: FileList | undefined = undefined;
function handleFileInput() { function handleFileInput() {
files = input.files; fileList = input.files;
} }
let focused = false; let focused = false;
@ -88,6 +108,7 @@
.control { .control {
position: relative; position: relative;
z-index: 0;
display: flex; display: flex;
align-items: start; align-items: start;
flex-direction: row; flex-direction: row;
@ -96,7 +117,7 @@
background-color: var(--input); background-color: var(--input);
border-radius: 5px; border-radius: 5px;
> :global(.feather.icon) { > :global(.icon) {
--icon-size: 24px; --icon-size: 24px;
margin: 18px; margin: 18px;
opacity: 0.75; opacity: 0.75;
@ -180,7 +201,7 @@
display: none; display: none;
} }
& + :global(.feather) { & + :global(.icon) {
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
right: 0; right: 0;
@ -190,7 +211,7 @@
} }
// TODO: Temporary // TODO: Temporary
&:focus + :global(.feather) { &:focus + :global(.icon) {
transform: rotateX(180deg); transform: rotateX(180deg);
} }
} }
@ -317,7 +338,7 @@
.name { .name {
font-size: 20px; font-size: 20px;
:global(.feather) { :global(.icon) {
--icon-size: 24px; --icon-size: 24px;
margin-right: 8px; margin-right: 8px;
} }
@ -398,10 +419,10 @@
{/each} {/each}
</fieldset> </fieldset>
{:else if type === 'select'} {: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} {#each extraData as option}
<option value={(option.display === undefined || option.value !== undefined) && (option.value || 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} {/each}
</select> </select>
<Icon name="chevron-down"/> <Icon name="chevron-down"/>
@ -413,7 +434,11 @@
on:focusout={() => focused = false}></textarea> on:focusout={() => focused = false}></textarea>
</div> </div>
{:else if type === 'checkbox'} {: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} {:else}
<input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput} <input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput}
tabindex={type === 'file' ? '-1' : undefined}> tabindex={type === 'file' ? '-1' : undefined}>
@ -421,15 +446,15 @@
<div class="sections"> <div class="sections">
{#if type !== 'duration'} {#if type !== 'duration'}
<label for={fieldId}>{@html placeholder || ''} <label for={fieldId} bind:this={label}>{@html placeholder || ''}
<slot/> <slot/>
</label> </label>
{/if} {/if}
{#if type === 'file'} {#if type === 'file'}
{#if files} {#if fileList}
<div class="files"> <div class="files">
{#each files as file} {#each fileList as file}
<div class="file"> <div class="file">
<div class="name" title="Type: {file.type}"> <div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name} <Icon name="file"/> {file.name}

View File

@ -11,18 +11,20 @@
export let submitText: string; export let submitText: string;
export let submitIcon: string; export let submitIcon: string;
export let submitClass: string = undefined; export let submitClass: string = undefined;
export let submitDisabled: boolean = false;
export let isBoldSubmit: boolean = true; export let isBoldSubmit: boolean = true;
export let resetButton: boolean = false; export let resetButton: boolean = false;
export let confirm: string = undefined; export let confirm: string = undefined;
export let withFiles: boolean = false;
const formId = nextAvailableFormId++; const formId = nextAvailableFormId++;
setContext('formId', formId); setContext('formId', formId);
function handleSubmit(e) { export let onSubmit = function(e) {
if (confirm && !window.confirm(confirm)) { if (submitDisabled || confirm && !window.confirm(confirm)) {
e.preventDefault(); e.preventDefault();
} }
} };
</script> </script>
<style lang="scss"> <style lang="scss">
@ -37,7 +39,7 @@
} }
</style> </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/> <CsrfTokenField/>
<slot/> <slot/>
<div class="form-controls"> <div class="form-controls">
@ -45,7 +47,7 @@
<button type="reset"><Icon name="trash"/>Reset</button> <button type="reset"><Icon name="trash"/>Reset</button>
{/if} {/if}
<button type="submit" class={submitClass} class:bold={isBoldSubmit}> <button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
{#if submitIcon} {#if submitIcon}
<Icon name={submitIcon}/> <Icon name={submitIcon}/>
{/if} {/if}

View File

@ -1,23 +1,20 @@
<script lang="ts"> <script lang="ts">
import {replaceIcons} from "../../ts/featherIcons.js"; import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
import {afterUpdate, onMount} from "svelte"; import {afterUpdate, onMount} from "svelte";
export let name: string; export let name: string;
onMount(() => { onMount(() => {
replaceIcons(); replaceIcons(true);
}); });
afterUpdate(() => { afterUpdate(() => {
replaceIcons(false); replaceIcons(false);
}); });
</script> </script>
<style lang="scss"> <style lang="scss">
// --- :global(.icon) {
// --- Feather
// ---
:global(.feather) {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -42,9 +39,9 @@
</style> </style>
{#if name} {#if name}
{#if name.startsWith('fa') } {#if isLucideIcon(name) >= 0 }
<i class="{name} feather icon" aria-hidden="true" {...$$restProps}></i> <i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
{:else} {:else}
<i data-feather="{name}" class="feather icon" aria-hidden="true" {...$$restProps}></i> <i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
{/if} {/if}
{/if} {/if}

View File

@ -57,7 +57,7 @@ export default class AccountController extends Controller {
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now(); const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod); const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
res.render('auth/account/account', { res.formatViewData('auth/account/account', {
user_personal_info_fields: user.getPersonalInfoFields(), user_personal_info_fields: user.getPersonalInfoFields(),
main_email: await user.mainEmail.get(), main_email: await user.mainEmail.get(),
emails: await user.emails.get(), emails: await user.emails.get(),

View File

@ -12,6 +12,15 @@ import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js"; import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AuthController extends Controller { 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 { public getRoutesPrefix(): string {
return '/auth'; return '/auth';
} }
@ -35,10 +44,12 @@ export default class AuthController extends Controller {
const userModelFactory = ModelFactory.get(User); const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent); const hasUsername = userModelFactory.hasComponent(UserNameComponent);
res.render('auth/auth', { const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
res.formatViewData('auth/auth', {
auth_methods: authGuard.getAuthMethodNames(), auth_methods: authGuard.getAuthMethodNames(),
has_username: hasUsername, hasUsername: hasUsername,
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent), hasPassword: hasPassword,
canRegisterWithPassword: hasUsername && hasPassword,
}); });
} }

View File

@ -160,6 +160,9 @@ export default class AuthGuard {
throw new PendingApprovalAuthError(); throw new PendingApprovalAuthError();
} }
// Mark auth proof as used
await proof.use?.();
// Login // Login
session.isAuthenticated = true; session.isAuthenticated = true;
session.persistent = persistSession; session.persistent = persistSession;

View File

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

View File

@ -12,6 +12,7 @@ import Mail from "../../mail/Mail.js";
import MailTemplate from "../../mail/MailTemplate.js"; import MailTemplate from "../../mail/MailTemplate.js";
import Throttler from "../../Throttler.js"; import Throttler from "../../Throttler.js";
import AuthComponent, {AuthMiddleware} from "../AuthComponent.js"; import AuthComponent, {AuthMiddleware} from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import MagicLink from "../models/MagicLink.js"; import MagicLink from "../models/MagicLink.js";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js";
@ -141,13 +142,13 @@ export default class MagicLinkController<A extends Application> extends Controll
} }
if (await validLink.isAuthorized()) { if (await validLink.isAuthorized()) {
validLink.use(); validLink.useLink();
await validLink.save(); await validLink.save();
await this.performAction(validLink, req, res); await this.performAction(validLink, req, res);
return; return;
} }
res.render('magic_link_lobby', { res.formatViewData('magic_link_lobby', {
email: validLink.getOrFail('email'), email: validLink.getOrFail('email'),
type: validLink.getOrFail('action_type'), type: validLink.getOrFail('action_type'),
validUntil: validLink.getExpirationDate().getTime(), 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, magicLink: magicLink,
err: err, err: err,
success: success && err === null, success: success && err === null,
@ -198,8 +199,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (!res.headersSent && user) { if (!res.headersSent && user) {
// Auth success // Auth success
const name = user.asOptional(UserNameComponent)?.getName(); AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
res.redirect(req.getIntendedUrl() || route('home')); res.redirect(req.getIntendedUrl() || route('home'));
} }
break; break;

View File

@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
import WebSocket from "ws"; import WebSocket from "ws";
import Application from "../../Application.js"; import Application from "../../Application.js";
import WebSocketListener from "../../WebSocketListener.js"; import SessionWebSocketListener from "../../SessionWebSocketListener.js";
import MagicLink from "../models/MagicLink.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 } = {}; private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
public refreshMagicLink(sessionId: string): void { 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> { public async handleSessionSocket(socket: WebSocket, request: IncomingMessage, session: Session): Promise<void> {
// Drop if requested without session
if (!session) {
socket.close(1002, 'Session is required for this request.');
return;
}
// Refuse any incoming data // Refuse any incoming data
socket.on('message', () => { socket.on('message', () => {
socket.close(1003); socket.close(1003);
@ -37,19 +31,22 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
// Refresh if immediately applicable // Refresh if immediately applicable
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) { if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
socket.send('refresh'); 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; return;
} }
const validityTimeout = setTimeout(() => { const validityTimeout = setTimeout(() => {
socket.send('refresh'); socket.send('refresh');
socket.close(1000); socket.close(1000, 'Timed out');
}, magicLink.getExpirationDate().getTime() - new Date().getTime()); }, magicLink.getExpirationDate().getTime() - new Date().getTime());
const f = () => { const f = () => {
clearTimeout(validityTimeout); clearTimeout(validityTimeout);
socket.send('refresh'); socket.send('refresh');
socket.close(1000); socket.close(1000, 'Closed by server');
}; };
socket.on('close', () => { socket.on('close', () => {

View File

@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
return this.used; return this.used;
} }
public use(): void { public useLink(): void {
this.used = true; this.used = true;
} }

View File

@ -8,6 +8,7 @@ import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
import {ServerError} from "../../HttpError.js"; import {ServerError} from "../../HttpError.js";
import Throttler from "../../Throttler.js"; import Throttler from "../../Throttler.js";
import AuthComponent from "../AuthComponent.js"; import AuthComponent from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import AuthMethod from "../AuthMethod.js"; import AuthMethod from "../AuthMethod.js";
import User from "../models/User.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')); res.redirect(req.getIntendedUrl() || route('home'));
} }
@ -136,8 +137,9 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
} }
const user = await passwordAuthProof.getResource(); 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')); res.redirect(req.getIntendedUrl() || route('home'));
} }

View File

@ -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 QueryParamsRecord = Record<string, string | number | boolean | null | undefined>;
export type QueryParams = string[][] | QueryParamsRecord | string | URLSearchParams; export type QueryParams = string[][] | QueryParamsRecord | string | URLSearchParams;
@ -43,7 +43,10 @@ export function route(
path = path.replace(/\/+/g, '/'); path = path.replace(/\/+/g, '/');
} else { } else {
for (const key of Object.keys(params)) { 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 { export function getRouteParamRegExp(key: string, flags?: string): RegExp {
return new RegExp(`:${key}(\\(.+?\\))?\\??`, flags); 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;
}

View File

@ -0,0 +1,2 @@
export const toLucideIconsPascalCase = (string: string): string =>
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());

View File

@ -67,7 +67,7 @@ export class Time {
public static humanizeDuration( public static humanizeDuration(
duration: number, duration: number,
short: boolean = false, short: boolean = false,
skipOneUnitNumber: boolean = false, skipNumberTextPartWhenSingular: boolean = false,
units: TimeUnit[] = [ units: TimeUnit[] = [
this.UNITS.SECOND, this.UNITS.SECOND,
this.UNITS.MINUTE, this.UNITS.MINUTE,
@ -77,13 +77,13 @@ export class Time {
this.UNITS.YEAR, this.UNITS.YEAR,
], ],
): string { ): string {
for (let i = units.length - 1; i > 0; i--) { for (const unit of units.reverse()) {
if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) { if (duration >= unit.milliseconds) {
const amount = Math.floor(duration / units[i - 1].milliseconds); const amount = Math.floor(duration / unit.milliseconds);
const unit = short ? const unitTextPart = short ?
units[i - 1].shortName : unit.shortName :
' ' + units[i - 1].longName + (amount > 1 ? 's' : ''); ' ' + unit.longName + (amount > 1 ? 's' : '');
return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit; return (amount > 1 || !skipNumberTextPartWhenSingular ? amount : '') + unitTextPart;
} }
} }

View File

@ -5,26 +5,10 @@ import {Session, SessionData} from "express-session";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {AuthMiddleware} from "../auth/AuthComponent.js"; import {AuthMiddleware} from "../auth/AuthComponent.js";
import {BadRequestError} from "../HttpError.js"; import {BadRequestError} from "../HttpError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
export default class CsrfProtectionComponent extends ApplicationComponent { export default class CsrfProtectionComponent extends ApplicationComponent {
private static readonly excluders: ((req: Request) => boolean)[] = []; 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 { public static addExcluder(excluder: (req: Request) => boolean): void {
this.excluders.push(excluder); this.excluders.push(excluder);
} }
@ -36,9 +20,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
} }
const session = req.getSession(); const session = req.getSession();
res.locals.getCsrfToken = () => { res.setLazyLocal('csrfToken', () => this.getSessionCsrfToken(session));
return CsrfProtectionComponent.getCsrfToken(session);
};
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try { try {
@ -58,6 +40,13 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
next(); 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 { class InvalidCsrfTokenError extends BadRequestError {

View File

@ -21,7 +21,7 @@ export default class ExpressAppComponent extends ApplicationComponent {
public async start(app: Express): Promise<void> { public async start(app: Express): Promise<void> {
this.server = app.listen(this.port, this.addr, () => { 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 // Proxy
@ -48,6 +48,26 @@ export default class ExpressAppComponent extends ApplicationComponent {
if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.'); if (!middleware) throw new Error('Middleware ' + type.name + ' not present in this request.');
return middleware as M; 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(); next();
}); });
} }

View File

@ -1,42 +1,10 @@
import {Router} from "express"; import {Router} from "express";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
export default class FormHelperComponent extends ApplicationComponent { 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> { 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) => { router.use((req, res, next) => {
if (['GET', 'POST'].find(m => m === req.method)) { if (['GET', 'POST'].find(m => m === req.method)) {
if (typeof req.body === 'object' && Object.keys(req.body).length > 0) { if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {

View File

@ -1,23 +1,14 @@
import config from "config";
import {Express, Router} from "express"; import {Express, Router} from "express";
import path from "path";
import util from "util";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {QueryParams, route, RouteParams} from "../common/Routing.js";
import AssetCompiler from "../frontend/AssetCompiler.js"; import AssetCompiler from "../frontend/AssetCompiler.js";
import AssetPreCompiler from "../frontend/AssetPreCompiler.js"; import AssetPreCompiler from "../frontend/AssetPreCompiler.js";
import Globals from "../frontend/Globals.js";
import ViewEngine from "../frontend/ViewEngine.js"; import ViewEngine from "../frontend/ViewEngine.js";
import {logger} from "../Logger.js"; import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
import {listFilesRecursively} from "../Utils.js";
import FileCache from "../utils/FileCache.js";
export default class FrontendToolsComponent extends ApplicationComponent { export default class FrontendToolsComponent extends ApplicationComponent {
private readonly publicDir: string; private readonly publicDir: string;
private readonly publicAssetsCache: FileCache = new FileCache();
private readonly assetPreCompilers: AssetPreCompiler[]; private readonly assetPreCompilers: AssetPreCompiler[];
private readonly globals: Globals = new Globals();
public constructor( public constructor(
private readonly assetCompiler: AssetCompiler, private readonly assetCompiler: AssetCompiler,
@ -31,48 +22,17 @@ export default class FrontendToolsComponent extends ApplicationComponent {
if (assetPreCompiler.isPublic()) { if (assetPreCompiler.isPublic()) {
this.assetCompiler.addExtension(assetPreCompiler.getExtension()); 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> { 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(); this.hookPreCompilers();
// Setup express view engine // Setup express view engine
let main = true; let main = true;
for (const assetPreCompiler of this.assetPreCompilers) { for (const assetPreCompiler of this.assetPreCompilers) {
if (assetPreCompiler instanceof ViewEngine) { if (assetPreCompiler instanceof ViewEngine) {
assetPreCompiler.setup(app, main); assetPreCompiler.setup(app, main, this.getApp().as(LazyLocalsCoreComponent));
main = false; main = false;
} }
} }
@ -84,17 +44,9 @@ export default class FrontendToolsComponent extends ApplicationComponent {
} }
} }
public async handleRoutes(router: Router): Promise<void> { public async initRoutes(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
router.use((req, res, next) => { router.use((req, res, next) => {
// Request context locals
res.locals.url = req.url; res.locals.url = req.url;
res.locals.params = req.params; res.locals.params = req.params;
res.locals.query = req.query; res.locals.query = req.query;
@ -126,8 +78,4 @@ export default class FrontendToolsComponent extends ApplicationComponent {
this.hookPreCompilers(); this.hookPreCompilers();
} }
} }
public getGlobals(): Globals {
return this.globals;
}
} }

View File

@ -10,10 +10,11 @@ import {logger} from "../Logger.js";
import Mail from "../mail/Mail.js"; import Mail from "../mail/Mail.js";
import MailError from "../mail/MailError.js"; import MailError from "../mail/MailError.js";
import SecurityError from "../SecurityError.js"; import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js"; import LazyLocalsCoreComponent from "./core/LazyLocalsCoreComponent.js";
export default class MailComponent extends ApplicationComponent { export default class MailComponent extends ApplicationComponent {
private transporter?: Transporter; private transporter?: Transporter;
private readonly additionalLocals: Record<string, unknown> = {};
public constructor( public constructor(
private readonly viewEngine: MailViewEngine, private readonly viewEngine: MailViewEngine,
@ -59,7 +60,7 @@ export default class MailComponent extends ApplicationComponent {
logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`); 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> { public async stop(): Promise<void> {
@ -77,7 +78,6 @@ export default class MailComponent extends ApplicationComponent {
for (const destEmail of to) { for (const destEmail of to) {
const template = mail.getTemplate(); const template = mail.getTemplate();
const locals = mail.getData();
const options = mail.getOptions(); const options = mail.getOptions();
// Reset options // Reset options
@ -91,17 +91,17 @@ export default class MailComponent extends ApplicationComponent {
}; };
// Set locals // Set locals
locals.mail_subject = options.subject; const urlLocals = mail.getData();
locals.mail_to = options.to; urlLocals.mail_subject = options.subject;
locals.mail_link = config.get<string>('app.public_url') + urlLocals.mail_to = options.to;
route('mail', [template.template], locals); urlLocals.mail_link = config.get<string>('app.public_url') +
Object.assign(locals, this.getApp().as(FrontendToolsComponent).getGlobals().get()); route('mail', [template.template], urlLocals);
const locals = {...this.additionalLocals, ...urlLocals};
// Log // Log
logger.debug(`Send mail from ${options.from.address} to ${options.to}`); logger.debug(`Send mail from ${options.from.address} to ${options.to}`);
// Render email // Render email
options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false); options.html = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, false);
options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true); options.text = await this.viewEngine.render('mails/' + template.template + '.mnjk', locals, true);
@ -112,6 +112,10 @@ export default class MailComponent extends ApplicationComponent {
return results; return results;
} }
public setAdditionalLocal(key: string, value: unknown): void {
this.additionalLocals[key] = value;
}
private getTransporter(): Transporter { private getTransporter(): Transporter {
if (!this.transporter) throw new MailError('Mail system was not prepared.'); if (!this.transporter) throw new MailError('Mail system was not prepared.');
return this.transporter; return this.transporter;

View File

@ -3,17 +3,9 @@ import onFinished from "on-finished";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import SessionComponent from "./SessionComponent.js"; import SessionComponent from "./SessionComponent.js";
export default class PreviousUrlComponent extends ApplicationComponent { 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> { public async handleRoutes(router: Router): Promise<void> {
router.use((req, res, next) => { router.use((req, res, next) => {
req.getPreviousUrl = () => { req.getPreviousUrl = () => {
@ -31,7 +23,7 @@ export default class PreviousUrlComponent extends ApplicationComponent {
return null; return null;
}; };
res.locals.getPreviousUrl = req.getPreviousUrl; res.setLazyLocal('previousUrl', () => req.getPreviousUrl());
req.getIntendedUrl = () => { req.getIntendedUrl = () => {
return req.query.redirect_uri?.toString() || null; return req.query.redirect_uri?.toString() || null;

View File

@ -1,29 +1,39 @@
import config from "config"; import config from "config";
import {Express} from "express"; import {Express} from "express";
import session, {Store} from "express-session"; import session, {Store} from "express-session";
import redis, {RedisClient} from "redis"; import {createClient, RedisClientType} from "redis";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import CacheProvider from "../CacheProvider.js"; import CacheProvider from "../CacheProvider.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
export default class RedisComponent extends ApplicationComponent implements CacheProvider { 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); private store: Store = new RedisStore(this);
public async start(_app: Express): Promise<void> { 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, password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
}); });
this.redisClient.on('error', (err: Error) => { this.redisClient.on('error', (err: Error) => {
logger.error(err, 'An error occurred with redis.'); logger.error(err, 'An error occurred with redis.');
}); });
await this.redisClient.connect();
} }
public async stop(): Promise<void> { public async stop(): Promise<void> {
const redisClient = this.redisClient; const redisClient = this.redisClient;
if (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 { 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> { public async get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T> {
return await new Promise<T>((resolve, reject) => { if (!this.redisClient) {
if (!this.redisClient) { throw new Error(`Redis client was not initialized.`);
reject(`Redis client was not initialized.`); }
return; return (await this.redisClient.get(this.prefix + key)|| defaultValue || undefined) as T;
}
this.redisClient.get(key, (err, val) => {
if (err) {
reject(err);
return;
}
resolve((val || defaultValue || undefined) as T);
});
});
} }
public async has(key: string): Promise<boolean> { 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> { public async forget(key: string): Promise<void> {
return await new Promise<void>((resolve, reject) => { if (!this.redisClient) {
if (!this.redisClient) { throw new Error(`Redis client was not initialized.`);
reject(`Redis client was not initialized.`); }
return;
}
this.redisClient.del(key, (err) => { await this.redisClient.del(this.prefix + key);
if (err) {
reject(err);
return;
}
resolve();
});
});
} }
public async remember(key: string, value: string, ttl: number): Promise<void> { public async remember(key: string, value: string, ttl: number): Promise<void> {
return await new Promise<void>((resolve, reject) => { if (!this.redisClient) {
if (!this.redisClient) { throw new Error(`Redis client was not initialized.`);
reject(`Redis client was not initialized.`); }
return;
}
this.redisClient.psetex(key, ttl, value, (err) => { await this.redisClient.pSetEx(this.prefix + key, ttl, value);
if (err) return reject(err);
resolve();
});
});
} }
public async persist(key: string, ttl: number): Promise<void> { public async persist(key: string, ttl: number): Promise<void> {
return await new Promise<void>((resolve, reject) => { if (!this.redisClient) {
if (!this.redisClient) { throw new Error(`Redis client was not initialized.`);
reject(`Redis client was not initialized.`); }
return;
}
this.redisClient.pexpire(key, ttl, (err) => { await this.redisClient.pExpire(this.prefix + key, ttl);
if (err) return reject(err);
resolve();
});
});
} }
} }

View File

@ -5,7 +5,6 @@ import session from "express-session";
import ApplicationComponent from "../ApplicationComponent.js"; import ApplicationComponent from "../ApplicationComponent.js";
import SecurityError from "../SecurityError.js"; import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js"; import RedisComponent from "./RedisComponent.js";
export default class SessionComponent extends ApplicationComponent { export default class SessionComponent extends ApplicationComponent {
@ -16,13 +15,6 @@ export default class SessionComponent extends ApplicationComponent {
this.storeComponent = storeComponent; 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> { public async checkSecuritySettings(): Promise<void> {
this.checkSecurityConfigField('session.secret'); this.checkSecurityConfigField('session.secret');
if (!config.get<boolean>('session.cookie.secure')) { if (!config.get<boolean>('session.cookie.secure')) {
@ -37,8 +29,9 @@ export default class SessionComponent extends ApplicationComponent {
store: this.storeComponent.getStore(), store: this.storeComponent.getStore(),
resave: false, resave: false,
cookie: { cookie: {
httpOnly: true, httpOnly: false,
secure: config.get('session.cookie.secure'), secure: config.get('session.cookie.secure'),
sameSite: 'strict',
}, },
rolling: true, rolling: true,
})); }));
@ -68,40 +61,10 @@ export default class SessionComponent extends ApplicationComponent {
res.locals.session = session; res.locals.session = session;
// Views flash function // Views flash function
const _flash: FlashStorage = {}; res.setLazyLocal('flash', () => {
res.locals.flash = (key?: string): FlashMessages | unknown[] => { return req.flash();
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;
};
next(); 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,
};

View File

@ -1,8 +1,5 @@
import config from "config"; import config from "config";
import cookie from "cookie"; import {Express, Router} from "express";
import cookieParser from "cookie-parser";
import {Express, Request} from "express";
import {Session} from "express-session";
import {WebSocketServer} from "ws"; import {WebSocketServer} from "ws";
import Application from "../Application.js"; import Application from "../Application.js";
@ -10,7 +7,6 @@ import ApplicationComponent from "../ApplicationComponent.js";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import WebSocketListener from "../WebSocketListener.js"; import WebSocketListener from "../WebSocketListener.js";
import ExpressAppComponent from "./ExpressAppComponent.js"; import ExpressAppComponent from "./ExpressAppComponent.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js"; import RedisComponent from "./RedisComponent.js";
export default class WebSocketServerComponent extends ApplicationComponent { export default class WebSocketServerComponent extends ApplicationComponent {
@ -21,11 +17,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
app.require(ExpressAppComponent); app.require(ExpressAppComponent);
app.require(RedisComponent); app.require(RedisComponent);
}
const globals = app.asOptional(FrontendToolsComponent)?.getGlobals(); public async initRoutes(router: Router): Promise<void> {
if (globals) { router.use((req, res, next) => {
globals.set('websocketUrl', config.get('app.public_websocket_url')); res.locals.websocketUrl = config.get('app.public_websocket_url');
} next();
});
} }
public async start(_app: Express): Promise<void> { public async start(_app: Express): Promise<void> {
@ -44,37 +42,11 @@ export default class WebSocketServerComponent extends ApplicationComponent {
if (!listener) { if (!listener) {
socket.close(1002, `Path not found ${request.url}`); socket.close(1002, `Path not found ${request.url}`);
return; 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}`); logger.debug(`Websocket on ${request.url}`);
listener.handle(socket, request).catch(err => {
const cookies = cookie.parse(request.headers.cookie); logger.error(err, 'Error in websocket listener.');
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.');
});
}); });
}); });
} }

View File

@ -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();
});
}
}

View File

@ -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,
});
}
}
}

View File

@ -242,6 +242,9 @@ export default class MysqlConnectionManager {
if (args.length > i + 1) { if (args.length > i + 1) {
migrationId = parseInt(args[i + 1]); migrationId = parseInt(args[i + 1]);
} }
logger.info('Rolling back migration', migrationId);
await this.prepare(false); await this.prepare(false);
await this.rollbackMigration(migrationId); await this.rollbackMigration(migrationId);
return; return;

View File

@ -1,9 +1,12 @@
import child_process from "child_process"; import child_process from "child_process";
import config from "config"; import config from "config";
import fs from "fs"; import fs from "fs";
import path from "path";
import {logger} from "../Logger.js"; 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 { export default class AssetCompiler {
private rollup?: child_process.ChildProcess; private rollup?: child_process.ChildProcess;
@ -35,9 +38,12 @@ export default class AssetCompiler {
const production = !config.get<boolean>('view.dev'); const production = !config.get<boolean>('view.dev');
if (!this.rollup) { 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 = [ const args = [
'rollup', 'rollup',
'-c', 'rollup.config.js', '-c', rollupConfigPath,
'--environment', `ENV:${production ? 'production' : 'dev'},BUILD_DIR:${this.sourceDir},PUBLIC_DIR:${this.targetDir},INPUT:${input.join(':')}`, '--environment', `ENV:${production ? 'production' : 'dev'},BUILD_DIR:${this.sourceDir},PUBLIC_DIR:${this.targetDir},INPUT:${input.join(':')}`,
]; ];
if (watch) args.push('--watch'); if (watch) args.push('--watch');

View File

@ -4,11 +4,9 @@ import path from "path";
import {logger} from "../Logger.js"; import {logger} from "../Logger.js";
import {doesFileExist, listFilesRecursively} from "../Utils.js"; import {doesFileExist, listFilesRecursively} from "../Utils.js";
import Globals from "./Globals.js";
export default abstract class AssetPreCompiler { export default abstract class AssetPreCompiler {
protected readonly assetPaths: string[]; protected readonly assetPaths: string[];
private globals?: Globals;
private watcher?: FSWatcher; private watcher?: FSWatcher;
private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = []; private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = [];
private inputChangeHandler?: (restart: boolean) => Promise<void>; private inputChangeHandler?: (restart: boolean) => Promise<void>;
@ -57,15 +55,6 @@ export default abstract class AssetPreCompiler {
return this.assetPaths; 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> { public async stop(): Promise<void> {
if (this.watcher) { if (this.watcher) {
await this.watcher.close(); await this.watcher.close();

View File

@ -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;
}
}

View File

@ -2,7 +2,6 @@ import "./register_svelte/register_svelte.js";
import clearModule from "clear-module"; import clearModule from "clear-module";
import config from "config"; import config from "config";
import crypto from "crypto";
import {promises as fs} from 'fs'; import {promises as fs} from 'fs';
import path from "path"; import path from "path";
import requireFromString from "require-from-string"; import requireFromString from "require-from-string";
@ -14,23 +13,10 @@ import {logger} from "../Logger.js";
import FileCache from "../utils/FileCache.js"; import FileCache from "../utils/FileCache.js";
import ViewEngine from "./ViewEngine.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 { 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 fileCache: FileCache = new FileCache();
private readonly dependencyCache: Record<string, Set<string>> = {}; private readonly reverseDependencyCache: Record<string, Set<string>> = {};
private readonly preprocessingCache: Record<string, PreprocessingCacheEntry> = {}; private readonly preprocessingCache: Record<string, string> = {};
private readonly cssCache: Record<string, string[] | undefined> = {}; private readonly cssCache: Record<string, string[] | undefined> = {};
public constructor( public constructor(
@ -48,8 +34,8 @@ export default class SvelteViewEngine extends ViewEngine {
public async onFileRemove(file: string): Promise<void> { public async onFileRemove(file: string): Promise<void> {
const canonicalName = this.toCanonicalName(file); const canonicalName = this.toCanonicalName(file);
delete this.preprocessingCache[canonicalName]; delete this.preprocessingCache[canonicalName];
delete this.dependencyCache[canonicalName]; delete this.reverseDependencyCache[canonicalName];
Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName)); Object.values(this.reverseDependencyCache).forEach(set => set.delete(canonicalName));
await super.onFileRemove(file); await super.onFileRemove(file);
} }
@ -59,34 +45,26 @@ export default class SvelteViewEngine extends ViewEngine {
): Promise<string> { ): Promise<string> {
const canonicalViewName = this.toCanonicalName(file); const canonicalViewName = this.toCanonicalName(file);
// View const rootTemplateFile = await this.resolveFileFromCanonicalNameOrFail('templates/svelte_template.html');
const actualFile = path.join(this.targetDir, canonicalViewName + COMPILED_SVELTE_EXTENSION); const rawOutput = await this.fileCache.get(rootTemplateFile, !config.get<boolean>('view.cache'));
const view = await this.fileCache.get(actualFile, !config.get<boolean>('view.cache'));
// Root template locals.isSsr = true;
const templateFile = await this.resolveFileFromCanonicalNameOrFail('layouts/svelte_layout.html'); const {
const rawOutput = await this.fileCache.get(templateFile, !config.get<boolean>('view.cache'));
// Pre-compiled parts
const [
backendCalls,
head, head,
html, html,
css, css,
] = view.split(SvelteViewEngine.getPreCompileSeparator(canonicalViewName)); } = await this.renderSvelteSsr(canonicalViewName, locals);
const localMap: Record<string, unknown> = this.compileBackendCalls(backendCalls.split('\n'), locals, false); locals.isSsr = false;
const actualLocals = JSON.stringify(localMap, (key, value) => { const serializedLocals = JSON.stringify(locals, (key, value) => {
if (key.startsWith('_')) return undefined; if (key.startsWith('_') || typeof value === 'function') return undefined;
return typeof value === 'function' ? return value;
value.toString() :
value;
}); });
// Replaces // Replaces
const replaceMap: Record<string, string> = { const replaceMap: Record<string, string> = {
canonicalViewName: canonicalViewName, canonicalViewName: canonicalViewName,
locals: actualLocals, locals: serializedLocals,
head: head, head: head,
html: html, html: html,
css: css, css: css,
@ -104,55 +82,17 @@ export default class SvelteViewEngine extends ViewEngine {
const targetFile = path.join(this.targetDir, canonicalName); const targetFile = path.join(this.targetDir, canonicalName);
logger.info(canonicalName + ' > ', 'Pre-compiling', canonicalName, '->', targetFile); 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) if (alsoCompileDependents && Object.keys(this.reverseDependencyCache).indexOf(canonicalName) >= 0) {
const ssr = await this.compileSsr(canonicalName); logger.info(canonicalName + ' > ', 'Pre-compiling dependents...');
for (const dependent of [...this.reverseDependencyCache[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]]) {
await this.preCompile(dependent, true); await this.preCompile(dependent, true);
} }
} }
} }
private resolveDependencies(source: string, canonicalViewName: string): string[] { private async preprocessSvelte(canonicalName: string): Promise<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> {
// Cache // Cache
if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) { if (Object.keys(this.preprocessingCache).indexOf(canonicalName) >= 0) {
return this.preprocessingCache[canonicalName]; return this.preprocessingCache[canonicalName];
@ -168,13 +108,10 @@ export default class SvelteViewEngine extends ViewEngine {
// Read source file if code was not already provided // Read source file if code was not already provided
const code = await this.fileCache.get(file, !config.get<boolean>('view.cache')); const code = await this.fileCache.get(file, !config.get<boolean>('view.cache'));
// Replace backend calls
const replacedBackendCalls = await this.replaceBackendCalls(canonicalName, code);
// Preprocess svelte // Preprocess svelte
logger.info(canonicalName + ' > ', 'Svelte preprocessing'); logger.info(canonicalName + ' > ', 'Svelte preprocessing');
const preprocessed = await preprocess( const processed = await preprocess(
replacedBackendCalls.code, code,
sveltePreprocess({ sveltePreprocess({
typescript: { typescript: {
tsconfigFile: 'src/assets/views/tsconfig.json', tsconfigFile: 'src/assets/views/tsconfig.json',
@ -186,95 +123,26 @@ export default class SvelteViewEngine extends ViewEngine {
); );
// Write to output file // Write to output file
await fs.writeFile(outputFile, preprocessed.code); await fs.writeFile(outputFile, processed.code);
// Preprocess dependencies this.resolveAndCacheDependencies(processed.code, canonicalName);
const backendCalls: string[] = replacedBackendCalls.backendCalls;
for (const dependency of this.resolveDependencies(code, canonicalName)) {
backendCalls.push(...(await this.preprocess(dependency)).backendCalls);
}
return this.preprocessingCache[canonicalName] = { return this.preprocessingCache[canonicalName] = processed.code;
backendCalls: backendCalls,
code: preprocessed.code,
};
} }
private async replaceBackendCalls(canonicalName: string, code: string): Promise<PreprocessingCacheEntry> { private async renderSvelteSsr(canonicalName: string, locals: {[key: string]: unknown}): Promise<{
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<{
head: string, head: string,
css: string, css: string,
html: string, html: string,
}> { }> {
const targetFile = path.join(this.targetDir, canonicalName); 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 // Get dependencies css
const dependenciesCss: string[] = []; const dependenciesCss: string[] = [];
for (const dependency of this.resolveDependencies(code, canonicalName)) { for (const dependency of this.resolveAndCacheDependencies(code, canonicalName)) {
if (this.cssCache[dependency] === undefined) { if (this.cssCache[dependency] === undefined || !config.get<boolean>('view.cache')) {
await this.compileSsr(dependency); await this.renderSvelteSsr(dependency, locals);
} }
const css = this.cssCache[dependency]; const css = this.cssCache[dependency];
if (css === undefined) { if (css === undefined) {
@ -295,15 +163,8 @@ export default class SvelteViewEngine extends ViewEngine {
}); });
// Load locals into locals store // Load locals into locals store
const localsModulePath = "../../intermediates/assets/ts/stores.js"; const storesModule = await import(path.resolve(this.targetDir, "../ts/stores.js"));
const localsModule = await import(localsModulePath); storesModule.locals.set(locals);
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}'`];
});
// Load module and render // Load module and render
const moduleId = path.resolve(targetFile); const moduleId = path.resolve(targetFile);
@ -324,40 +185,26 @@ export default class SvelteViewEngine extends ViewEngine {
}; };
} }
private compileBackendCalls( private resolveAndCacheDependencies(source: string, canonicalViewName: string): string[] {
backendCalls: string[], const dependencies: string[] = [];
locals: Record<string, unknown>,
isPreRender: boolean,
): Record<string, unknown> {
locals = {...locals, isPreRender};
const localMap: Record<string, unknown> = {}; for (const match of source.matchAll(/import .+ from ['"](.+?\.svelte)['"];/gm)) {
backendCalls.forEach(code => { dependencies.push(path.join(path.dirname(canonicalViewName), match[1]));
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
const f = locals[key]; // Clear existing links from cache
if (typeof f !== 'function') throw new Error(key + ' is not a function.'); for (const dependency of Object.keys(this.reverseDependencyCache)) {
localMap[`'${key}', \`[${code.substring(code.indexOf('`[') + 2, code.length - 2)}]\``] = f.call(locals, ...args); this.reverseDependencyCache[dependency].delete(canonicalViewName);
} else { }
localMap[`'${key}'`] = locals[key];
// Add new links to cache
for (const dependency of dependencies) {
if (Object.keys(this.reverseDependencyCache).indexOf(dependency) < 0) {
this.reverseDependencyCache[dependency] = new Set<string>();
} }
}); this.reverseDependencyCache[dependency].add(canonicalViewName);
return localMap; }
return dependencies;
} }
} }
type PreprocessingCacheEntry = {
backendCalls: string[],
code: string,
};

View File

@ -1,5 +1,6 @@
import {Express} from "express"; import {Express} from "express";
import LazyLocalsCoreComponent from "../components/core/LazyLocalsCoreComponent.js";
import AssetPreCompiler from "./AssetPreCompiler.js"; import AssetPreCompiler from "./AssetPreCompiler.js";
export default abstract class ViewEngine extends AssetPreCompiler { export default abstract class ViewEngine extends AssetPreCompiler {
@ -18,10 +19,11 @@ export default abstract class ViewEngine extends AssetPreCompiler {
locals: Record<string, unknown>, locals: Record<string, unknown>,
): Promise<string>; ): 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) => { app.engine(this.extension, (path, options, callback) => {
// Props (locals) const locals = {...options};
const locals = Object.assign(this.getGlobals().get(), options);
lazyLocalsComponent.setupLazyLocals(locals);
this.render(path, locals) this.render(path, locals)
.then(value => callback(null, value)) .then(value => callback(null, value))

View File

@ -53,7 +53,7 @@ export default class BackendController extends Controller {
} }
protected async getIndex(req: Request, res: Response): Promise<void> { 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 => ({ menu: await Promise.all(BackendController.menu.map(async m => ({
link: await m.getLink(), link: await m.getLink(),
display_string: await m.getDisplayString(), display_string: await m.getDisplayString(),
@ -66,7 +66,7 @@ export default class BackendController extends Controller {
const accounts = await User.paginate(req, 20, User.select() const accounts = await User.paginate(req, 20, User.select()
.where('approved', 0) .where('approved', 0)
.with('mainEmail')); .with('mainEmail'));
res.render('backend/accounts_approval', { res.formatViewData('backend/accounts_approval', {
accounts: accounts.map(account => Object.assign({ accounts: accounts.map(account => Object.assign({
mainEmailStr: account.mainEmail.getOrFail()?.email, mainEmailStr: account.mainEmail.getOrFail()?.email,
created_at_iso: account.created_at?.toISOString(), created_at_iso: account.created_at?.toISOString(),

View File

@ -7,8 +7,8 @@ export default class MailController extends Controller {
this.get("/mail/:template", this.getMail, 'mail'); 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']; const template = request.params['template'];
response.render(`mails/${template}.mnjk`, request.query); res.formatViewData(`mails/${template}.mnjk`, request.query);
} }
} }

View File

@ -3,6 +3,8 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "CommonJS",
"baseUrl": "../dist", "baseUrl": "../dist",
"rootDir": "./", "rootDir": "./",
"sourceRoot": "./", "sourceRoot": "./",

View File

@ -33,6 +33,16 @@ declare global {
getIntendedUrl(): string | null; getIntendedUrl(): string | null;
} }
export interface Response {
setLazyLocal(key: string, valueProvider: () => unknown): void;
formatViewData(
viewName: string,
data?: Record<string, unknown>,
callback?: (err: Error, html: string) => void,
): void;
}
} }
} }

View File

@ -1185,12 +1185,12 @@ describe('Session persistence', () => {
await followMagicLinkFromMail(agent, cookies); await followMagicLinkFromMail(agent, cookies);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/); expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
res = await agent.get('/csrf') res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/); expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')
@ -1217,7 +1217,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf') const res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/); expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')
@ -1244,7 +1244,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf') const res = await agent.get('/csrf')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(200); .expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/); expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
// Logout // Logout
await agent.post('/auth/logout') await agent.post('/auth/logout')

View File

@ -142,6 +142,7 @@ describe('Authenticate with email (magic_link)', () => {
// Authenticate // Authenticate
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'})) await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.accept('json')
.set('Cookie', cookies) .set('Cookie', cookies)
.send({ .send({
csrf: csrf, csrf: csrf,

View File

@ -12,7 +12,7 @@ useApp(async (addr, port) => {
this.use(new class extends Controller { this.use(new class extends Controller {
public routes(): void { public routes(): void {
this.get('/', (req, res) => { this.get('/', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession())); res.send(this.getApp().as(CsrfProtectionComponent).getSessionCsrfToken(req.getSession()));
}, 'csrf_test'); }, 'csrf_test');
this.post('/', (req, res) => { this.post('/', (req, res) => {
@ -35,6 +35,7 @@ describe('Test CSRF protection', () => {
test('no csrf token should be in session at first', (done) => { test('no csrf token should be in session at first', (done) => {
const agent = supertest(app.getExpressApp()); const agent = supertest(app.getExpressApp());
agent.post('/') agent.post('/')
.accept('json')
.expect(401) .expect(401)
.then(res => { .then(res => {
expect(res.text).toContain(`You weren't assigned any CSRF token.`); expect(res.text).toContain(`You weren't assigned any CSRF token.`);
@ -55,6 +56,7 @@ describe('Test CSRF protection', () => {
const agent = supertest(app.getExpressApp()); const agent = supertest(app.getExpressApp());
agent.post('/') agent.post('/')
.accept('json')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(401) .expect(401)
.then((res) => { .then((res) => {
@ -68,6 +70,7 @@ describe('Test CSRF protection', () => {
const agent = supertest(app.getExpressApp()); const agent = supertest(app.getExpressApp());
agent.post('/') agent.post('/')
.accept('json')
.set('Cookie', cookies) .set('Cookie', cookies)
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.send({csrf: 'not_a_valid_csrf'}) .send({csrf: 'not_a_valid_csrf'})

View File

@ -23,8 +23,10 @@ export async function followMagicLinkFromMail(
expect(query).toBeDefined(); expect(query).toBeDefined();
await agent.get('/magic/link?' + query) await agent.get('/magic/link?' + query)
.accept('json')
.expect(200); .expect(200);
await agent.get('/magic/lobby') await agent.get('/magic/lobby')
.accept('json')
.set('Cookie', cookies) .set('Cookie', cookies)
.expect(302) .expect(302)
.expect('Location', expectedRedirectUrl); .expect('Location', expectedRedirectUrl);
@ -55,10 +57,10 @@ export function authAppProvider(withUsername: boolean = true, approvalMode: bool
this.use(new class extends Controller { this.use(new class extends Controller {
public routes(): void { public routes(): void {
this.get('/', (req, res) => { this.get('/', (req, res) => {
res.render('home'); res.formatViewData('home');
}, 'home'); }, 'home');
this.get('/csrf', (req, res) => { this.get('/csrf', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession())); res.send(this.getApp().as(CsrfProtectionComponent).getSessionCsrfToken(req.getSession()));
}, 'csrf'); }, 'csrf');
this.get('/is-auth', async (req, res) => { this.get('/is-auth', async (req, res) => {
const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req); const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req);

View File

@ -1,7 +1,7 @@
import MailDev, {Mail} from "maildev"; import MailDev, {Mail} from "maildev";
export const MAIL_SERVER = new MailDev({ export const MAIL_SERVER = new MailDev({
ip: 'localhost', ip: '127.0.0.1',
}); });
export async function setupMailServer(): Promise<void> { export async function setupMailServer(): Promise<void> {

View File

@ -12,7 +12,7 @@
} }
</style> </style>
<div class="render-mode">{$locals.isPreRender ? 'SSR' : 'CSR'}</div> <div class="render-mode">{$locals.isSsr ? 'SSR' : 'CSR'}</div>
<div class="data-table-container"> <div class="data-table-container">
<table class="data-table"> <table class="data-table">

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