Compare commits

..

309 Commits

Author SHA1 Message Date
558afedfb6 code cleanup: use URLSearchParams instead of querystring 2024-09-21 18:44:09 +02:00
aa059527ad test app: fix triplicate route name 2024-09-21 18:40:11 +02:00
6031d3a2e6 app: log all errors (debug) 2024-09-21 18:39:45 +02:00
b59e053cad config/mysql: create database by default, use swaf user with password swaf by default, don't create database in production 2024-09-21 18:39:02 +02:00
8ae520aa07 Upgrade dependencies
- Add a new migration to fix password hash length `IncreaseMagicLinkTokenLengthMigration`
- Fix types
2024-09-21 18:37:51 +02:00
bbd9480000 chore(back): clean leftover console.log 2022-03-07 19:25:25 +01:00
ecb2b13a83 Version 0.25.1 2022-03-07 18:58:49 +01:00
a823503cb4 Upgrade @tsconfig/svelte 2022-03-07 18:58:21 +01:00
f0304c0b9f Upgrade @types/config, @types/node, concurrently, lucide 2022-03-07 18:56:35 +01:00
ae31edb622 Upgrade redis package 2022-03-07 18:42:50 +01:00
d378771ffa Upgrade dependencies 2022-03-07 18:15:48 +01:00
869791b3ad Version 0.25.0 2022-03-07 17:47:28 +01:00
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
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
6714e413a2 feat(front/form): allow forms to be disabled, add disabled buttons style 2022-03-07 17:22:43 +01:00
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
8c083d562d fix(front/auth/login): show password field if previousFormData has password 2022-03-07 16:31:40 +01:00
4cfcaac1cc fix(front/forms): simplify and fix usage of flashed previousFormData and validation errors 2022-03-06 18:55:06 +01:00
3eb33c64d7 fix(front/Field): prevent label from having a greater z-index than the mobile menu 2022-03-06 18:46:33 +01:00
fd2852c387 feat(front/NavMenu): put logout button on the Account link under an extendable dropdown menu 2022-03-06 18:37:46 +01:00
9f17c5b8cd feat(front/style): add hr default style 2022-03-06 18:36:02 +01:00
144a72895e fix(front/account): fix first panel tag name (div->section) 2022-03-06 18:35:41 +01:00
81c65344a9 chore(front): remove unnecessary containers and reformat 2022-03-06 16:57:31 +01:00
41a083ba52 feat(back/auth): add use() method to AuthProof and call it on successful login attempt 2022-03-06 16:34:57 +01:00
0c4349fac3 fix(common/Time): fix humanizeDuration for duration > 1 year 2022-03-06 13:22:12 +01:00
cb5001ce6e fix(front/CopyableText): don't display title when not provided 2022-03-06 13:21:04 +01:00
ee56113808 fix(front/FlashMessages): only display relevant flash bags 2022-03-06 13:20:20 +01:00
2c3286d313 feat(front/data-table): add actions cell, col-grow and col-grow-cell 2022-03-05 10:05:13 +01:00
2e3c5d16c4 fix(back/flash): retrieve all flashed fields 2022-03-05 10:03:49 +01:00
81a62be38d feat(front/CopyableText): add simple button mode 2022-03-02 11:36:23 +01:00
afef367e59 chore(front): cleanup outdated todo 2022-03-02 10:23:19 +01:00
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
0e0e633e08 feat(front/Form): allow overriding onSubmit function 2022-02-28 14:43:36 +01:00
7a49e47ae7 feat(front/Field): expose file list 2022-02-28 14:42:36 +01:00
67dc33adf4 fix(front/checkbox): prevent checkbox double click event 2022-02-19 11:58:36 +01:00
231aa8dcd7 fix(front/checkbox): change value to boolean and use handleInput 2022-02-19 11:30:06 +01:00
535c8afdb1 fix(websockets): send cookies manually for session authentication 2022-02-18 22:59:16 +01:00
dad4ff62f1 Version 0.24.10 2021-12-03 00:42:20 +01:00
8626d0b571 Upgrade dependencies 2021-12-03 00:42:12 +01:00
32a1721ef2 front/BaseTemplate: add facebook, twitter and generic url preview meta tags 2021-12-03 00:40:31 +01:00
689c860e2e front/ErrorTemplate: fix header logo being cropped 2021-11-28 21:58:06 +01:00
7672a568fc Version 0.24.9 2021-11-28 21:28:06 +01:00
d6b530d16c Add formatViewData to response object to fix tests and prepare for async navigation 2021-11-28 21:26:45 +01:00
366e48757e back/config: replace localhost with 127.0.0.1 2021-11-28 21:24:51 +01:00
ae603362e9 back/redis: fix not using redis.prefix config 2021-11-28 17:06:07 +01:00
be0676611a Upgrade dependencies 2021-11-28 17:02:18 +01:00
b85573a64d front: add Loader component 2021-11-28 16:44:51 +01:00
172d27a6d2 front/NavMenuItem: remove margin top/left on first menu item 2021-11-28 16:44:27 +01:00
e033baa57f front/BaseTemplate: allow disabling login link and logo label 2021-11-28 16:43:46 +01:00
d1ff09fcc8 front/BaseFooter: fix app version local 2021-11-28 16:42:22 +01:00
9674fc87dd front/SvelteViewEngine: add isSsr local 2021-11-28 16:41:46 +01:00
c606379bcd front/SvelteViewEngine: don't use css cache in development environment 2021-11-25 00:25:50 +01:00
179cb09b58 back/Application: fix fallback error template view name 2021-11-24 22:18:14 +01:00
9980c54fcf front/SvelteViewEngine: pre-compile ssr on demand, refactor globals into proper locals and lazy locals 2021-11-24 22:18:14 +01:00
2b85bea9dd common/Routing: allow RouteParams to be undefined and ignore them in this case 2021-11-24 18:20:02 +01:00
4c895229ec back/cli: fix main command not able to accept args 2021-11-24 18:20:02 +01:00
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
59491d63ab front/Field: add support for datetime-local field type 2021-11-24 18:20:02 +01:00
aa1484749e front/BaseTemplate: allow disabling header, h1 and footer via boolean attributes 2021-11-24 18:16:16 +01:00
f801f6a43b Add missing dev dependency @types/node 2021-11-24 17:38:16 +01:00
f68e81836b Allow forms to have a file upload enctype 2021-11-22 21:30:23 +01:00
6348692473 Fix select field value bind 2021-11-22 21:29:54 +01:00
e151c9c744 Fix FileUploadMiddleware formidable usage 2021-11-22 17:59:32 +01:00
2e68cf8cae Version 0.24.8 2021-11-21 20:24:56 +01:00
f55ba4d611 Upgrade svelte-preprocess 2021-11-21 20:24:35 +01:00
5cfdecfebf Also replace icons afterUpdate 2021-11-21 20:18:15 +01:00
10d5d16967 Watch DOM changes to add external link icons to new external links 2021-11-21 20:17:49 +01:00
7f4996c908 Add fonts copy to rollup config 2021-11-21 18:34:36 +01:00
92c6433235 Version 0.24.7 2021-11-21 17:06:45 +01:00
48de7829ec Upgrade dependencies 2021-11-21 17:06:28 +01:00
9998f1a91c Make require-from-string a normal dependency for dependants 2021-11-21 17:06:06 +01:00
c7d0238d22 Version 0.24.6 2021-11-21 16:50:47 +01:00
b24e9ab580 front: add CopyableText component 2021-11-21 16:10:38 +01:00
e9db1f4ded front: add an external link icon to external links 2021-11-21 15:02:31 +01:00
428990dc00 front/home page: make first word of the title choosen at random in a list 2021-11-21 13:15:15 +01:00
c0918b17ed Rename front layouts to templates 2021-11-21 13:14:23 +01:00
da6fda02a9 Replace feather icons with lucide icons, use Icon component everywhere 2021-11-21 12:42:26 +01:00
297bafcdc8 Add style to error pages 2021-11-20 22:55:25 +01:00
61e7282f25 Version 0.24.5 2021-11-20 19:52:59 +01:00
d40481fe3b Make rollup available in dependant projects 2021-11-20 19:52:44 +01:00
9da35de4e0 Move design page view to test views 2021-11-20 19:22:04 +01:00
febde935e3 Make more route usage optional 2021-11-20 19:21:15 +01:00
3c28bd9fbe Version 0.24.4 2021-11-20 19:09:14 +01:00
3616d54d29 frontend/BaseLayout: make it easier to edit the BaseLayout without redefining everything 2021-11-20 19:04:55 +01:00
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
c0245e3e3d Move tests page view to test folder 2021-11-20 17:29:17 +01:00
e188458f9c SvelteViewEngine: fix stores file path 2021-11-20 17:29:17 +01:00
2f2e1f51b8 Fix dependencies for sources that will be rebuilt by dependant projects 2021-11-20 16:17:18 +01:00
575de5ebb1 Copy assets to dist folder at dist step 2021-11-20 15:43:20 +01:00
876d509f10 Version 0.24.3 2021-11-20 15:40:22 +01:00
a03dd994e3 Upgrade dependencies 2021-11-20 15:39:51 +01:00
7dde32edb4 Distribute src/assets 2021-11-20 15:39:51 +01:00
a753122290 Do not distribute backend source, export everything to commonjs 2021-11-20 15:39:51 +01:00
5869ba3ee3 Version 0.24.2 2021-11-10 19:26:06 +01:00
ce39f82e66 Copy package.json to dist folder for release 2021-11-10 19:25:50 +01:00
45bd805969 Version 0.24.1 2021-11-10 17:59:44 +01:00
f41ee9cf32 Remove deprecated migrations 2021-11-10 17:58:52 +01:00
404a2ecb16 Fix usages of explicit any 2021-11-10 17:58:52 +01:00
4a09d2e1fe Fix linting on frontend TS
Add return type (void) to replaceIcons function
2021-11-10 17:09:30 +01:00
ef9cb663e7 Add specific tsconfig for eslint 2021-11-10 17:07:05 +01:00
e84b7cf6d3 Version 0.24.0 2021-11-10 16:41:29 +01:00
d6483af1a9 Fix linting errors 2021-11-09 19:46:51 +01:00
e31598fe4e Merge pull request 'Add svelte as a view engine to swaf' (#33) from svelte into develop
Reviewed-on: https://eternae.ink/ashpie/swaf/pulls/33

- config overhaul
- package.json / yarn.lock
  - new scripts, improved
  - now a module
  - new deps and upgraded deps
- rollup instead of webpack
- `assets` folder moved to `src/assets` folder
- `views` moved to `src/assets/views` folder
- make svelte the default view engine (except for mails)
- lots of refactoring
- add personnal information fields to account page
- and more
2021-11-09 19:31:22 +01:00
7650238183 data-table: add data-table-container to overflow-x scroll, use it on relevant pages 2021-11-09 19:29:10 +01:00
5336940dc3 auth page: remove useless hint, use better icon for "Use magic link" button 2021-11-09 19:22:52 +01:00
c29024bb23 Add icons to account page 2021-11-09 19:22:10 +01:00
c5b6b33abd Make magic link lobby messages sticky 2021-11-09 19:15:26 +01:00
54bed4ad7f Add icons to auth page forms 2021-11-09 19:15:03 +01:00
cae7adcee8 Add data-table css component 2021-11-09 18:58:21 +01:00
4d9dae5e3b Allow duration field to specify labels of sub-fields 2021-11-09 18:51:08 +01:00
4a99a5acf5 Fix fieldset and number input display 2021-11-09 18:34:01 +01:00
a4e48eb174 Fix duration field components input displacement 2021-11-09 18:07:53 +01:00
6cd98929d3 Fix Field textarea focus label move 2021-11-09 18:07:31 +01:00
5cea1b866e Fix Field textarea element position and size 2021-11-09 18:00:52 +01:00
baeea368ff Fix pagination second ellipsis 2021-11-09 17:49:56 +01:00
ee4ca0f49b Make pagination and Breadcrumb overflow-x scroll 2021-11-09 17:49:39 +01:00
8254c6cb47 Add breadcrumb to accounts_approval page 2021-11-09 17:43:33 +01:00
ae1d743f15 Add breadcrumb design 2021-11-09 17:43:14 +01:00
9b3822d7f3 Add subsurface for panels in panels, add pagination css/design 2021-11-09 17:32:51 +01:00
93be06d10f Make form submit button bold by default, add optional reset button 2021-11-09 16:54:37 +01:00
e9acde6313 Merge branch 'develop' into svelte 2021-11-08 14:11:47 +01:00
95f6333d6a Handle all existing magic links at once 2021-11-08 13:22:20 +01:00
7d2b088635 Fix no icon fields input and label shift 2021-11-08 01:44:26 +01:00
eefb6e0dac Make tests pass 2021-11-08 01:21:51 +01:00
e7695b7027 Upgrade dependencies 2021-11-08 01:09:26 +01:00
941dc3700e Add FileSize helper class 2021-11-08 00:25:28 +01:00
4a8a1f2da8 More work on default theme and components 2021-11-08 00:24:53 +01:00
f1ae6f6a7b Move frontend tests to a dedicated page 2021-06-02 18:30:20 +02:00
3b636359c6 [TO SQUASH] fix scss assets build pipeline
squash with 7174097388
2021-06-02 18:29:37 +02:00
d0a01ff771 Add images asset type handling 2021-06-02 17:13:46 +02:00
77ff2505b2 CsrfTokenComponent: Use a global empty function for SSR 2021-06-02 17:13:01 +02:00
533cef5ab8 Use user id to throttle failed login attempts instead of name
This allows UserNameComponent to be optional
2021-06-02 16:48:58 +02:00
9ac42bb3db Convert all views to svelte 2021-06-01 16:14:24 +02:00
64cec2987d AccountController: serialize user personal info fields 2021-06-01 16:08:01 +02:00
d925237233 Move pagination to common, add serialization, update BackendController 2021-06-01 16:04:43 +02:00
7174097388 Add scss assets handling 2021-06-01 16:03:07 +02:00
dffbf296ed [TO SQUASH] Add missing src/assets/ts/tsconfig.json 2021-06-01 14:42:58 +02:00
0f415144dc Reorganize many root config parameters 2021-06-01 14:38:53 +02:00
7ccd335649 Move route building to common subproject, fix Time export 2021-06-01 14:34:10 +02:00
1b221c590f package.json: dev script: preserve watch output on tsc 2021-05-31 16:22:14 +02:00
2da637f51d svelte backend calls: stop matching on ',' 2021-05-31 15:34:37 +02:00
4d2dda3615 svelte_layout.html: fix localStore import name 2021-05-31 11:18:46 +02:00
166d1c1458 Swaf export regexp: remove unnecessary escapements 2021-05-31 11:18:04 +02:00
a85d899a21 locals: handle ignored properties, ignore ModelRelation.model 2021-05-31 11:16:46 +02:00
eb6ed8f2d2 Make css properly passed from children to parents with cache and dedup 2021-05-31 11:14:56 +02:00
c6b8c48a72 Move all sources to src folder, add common ts subproject 2021-05-27 15:26:19 +02:00
c36e4c0fbe Make locals override globals instead of the inverse 2021-05-13 17:39:20 +02:00
13bd933b0b svelte: allow locals function calls with no parameter 2021-05-13 17:38:48 +02:00
c9fed2d873 Use maintenance component to throw 503s when some components are unavailable 2021-05-13 16:26:27 +02:00
a3ebf46b54 Add ApplicationComponent init lifecycle step and unstatic globals
This renames ApplicationComponent (previous) init to initRoutes and handle to handleRoutes
2021-05-13 16:03:59 +02:00
cdf95c0c0b Add has and require methods to Extendable 2021-05-13 15:58:41 +02:00
c896accdfa svelte: backend calls: make localStore import .js extension optional 2021-05-13 15:57:31 +02:00
76fa44c245 Convert errors pages to svelte
Fallback non-existing error views to generic Error svelte component
2021-05-13 15:55:56 +02:00
66a696f40e Application: fix isInNodeModules is inverted 2021-05-13 14:13:45 +02:00
26140d2028 svelte: don't eat conditional chains question marks 2021-05-13 14:12:40 +02:00
fac59e12d6 views: delete target file or re-precompile on file remove 2021-05-13 14:11:23 +02:00
c1c7b8920d Add generic type to localStore 2021-05-13 14:08:35 +02:00
afd45bd99d Reorganize frontend tests and add more $locals tests 2021-05-12 14:48:45 +02:00
a4d175addc Handle assets as commonjs 2021-05-12 14:48:02 +02:00
d423d78f2a esm: fix jest not running
following 82ab0b963c
2021-05-12 14:33:18 +02:00
1167e99c30 Svelte view engine: backend calls: don't match dots and quotes 2021-05-12 14:02:58 +02:00
c60bd442e8 Svelte view engine: preprocess dependencies recursively 2021-05-12 14:02:58 +02:00
e851630be4 SvelteViewEngine: update backend calls prefix to $locals (store) 2021-05-12 14:02:58 +02:00
85b4b39dd2 Svelte view engine: also compile backend calls on SSR
Add local to determine whether render is SSR or not
2021-05-12 14:02:58 +02:00
b247d73fcb Use clear-module to clear require cache of dependencies 2021-05-11 15:47:42 +02:00
423f19de68 Only crash when not watching after everything is pre-compiled 2021-05-11 15:14:51 +02:00
076cda8008 Fix localStore set to a function that fetches pre-executed backend calls 2021-05-11 15:13:32 +02:00
911e64a6ae Remove unnecessary svelte preprocess plugin from rollup 2021-05-11 15:08:10 +02:00
8884f70a24 AssetPreCompiler: watch all view directories instead of the first one 2021-05-11 13:54:08 +02:00
42f7ebba05 Svelte: write fully preprocessed code to pre-compiled files 2021-05-11 13:52:48 +02:00
e95595f5a3 package.json dev script: reorder nodemon tasks 2021-05-04 17:39:31 +02:00
4b5111b33a package.json dev script: always clean compile first
This avoids running old code that could modify the databases unexpectedly
2021-05-04 17:34:14 +02:00
50e1b287a9 Catch command line errors and force exit with error code 2021-05-04 17:28:02 +02:00
6aa37eb9e4 Add two step pre-compile/compile asset processing
Reorganize views into new "assets" folder structure
Turn locals into a store so locals don't have to be passed through files that don't need them
Some fixes to previous commit (esm) 82ab0b963c
Remove afs in favor of fs.promises (renamed afs.exists to Utils.doesFileExist
Rename Utils.readdirRecursively to Utils.listFilesRecursively
2021-05-04 17:14:32 +02:00
82ab0b963c Switch to esm and add import auto format 2021-05-03 20:52:52 +02:00
238bbec429 Cancel replaces on ssr html todo 2021-04-30 14:04:42 +02:00
05d112a3b3 Remove NunjucksComponent 2021-04-30 13:59:22 +02:00
c524ddde44 Remove useless node-sass package 2021-04-30 10:50:54 +02:00
e385986aca Svelte: refactor many symbols and safe eval backend calls arguments
Also allow dumping function contents by outputing them directly
2021-04-29 16:13:31 +02:00
bd2b7e7579 svelte render: simplify replaces 2021-04-28 17:03:27 +02:00
05069b15d8 Properly use promises in ViewEngine.render(), use ViewEngine for mail and add NunjucksViewEngine 2021-04-28 17:02:49 +02:00
a7f421d2f8 Move request context locals definition to FrontendToolsComponent 2021-04-28 14:10:29 +02:00
c93ea7691e ViewEngine: demote buildDir and publicDir fields 2021-04-28 14:09:02 +02:00
c4f71e569f Move globals to static ViewEngine and add setGlobal() 2021-04-28 14:05:06 +02:00
0c178955ec Fix svelte template replace would recursively replace 2021-04-28 11:00:51 +02:00
98008517b2 Fix prepare-sources.js comment 2021-04-27 15:44:20 +02:00
c8e9cf963d Fix indent on tsconfig files 2021-04-27 15:44:04 +02:00
fd7a6af98d Remove useless type definitions 2021-04-27 15:43:51 +02:00
9d5c791081 Rollback package.json indent change 2021-04-27 15:10:30 +02:00
58ea522593 Reorganize config params and use view.cache for caching instead of view.dev 2021-04-27 15:08:41 +02:00
faab9e4894 Rollback config files indent change 2021-04-27 15:01:07 +02:00
4f1f88c8f8 Remove useless commandLineArgs.input manipulation 2021-04-27 15:00:43 +02:00
91410b1a15 App startup: add http:// before listen address for conveniance 2021-04-27 14:43:27 +02:00
7b2cdb8269 Clear require cache for watch recompiling 2021-04-23 18:20:22 +02:00
a97e68289e Prevent nodemon infinite loop on yarn dev after yarn clean 2021-04-23 18:19:05 +02:00
10bd8bb95e Upgrade @rollup/plugin-commonjs 2021-04-23 16:11:14 +02:00
4271b6bbc6 Pin svelte-preprocess to 4.6.9
See https://github.com/sveltejs/svelte-preprocess/pull/337
2021-04-23 15:57:34 +02:00
053313002c Move svelte compilation to external rollup and add pre-compile-views cli 2021-04-23 15:56:39 +02:00
e5a9b9908d Refactor and make it nicer codewise 2021-04-23 15:56:39 +02:00
59067e49fe package.json scripts: use NodeJS instead of unix commands 2021-04-23 15:56:06 +02:00
29fa5f4e38 Make svelte work 2021-04-23 15:56:06 +02:00
5f0e11efed Add svelte pre-compilation 2021-04-23 15:56:06 +02:00
a34b97ad5a Version 0.23.10 2021-04-22 18:18:37 +02:00
df59795489 Remove mail spammy log 2021-04-22 18:01:52 +02:00
847fa030b8 Increase jest test timeout 2021-04-22 18:01:35 +02:00
cfc632ba1a Approval mode: revoke unapproved users auth proofs
Also add tests for auth approval mode
2021-04-22 18:01:13 +02:00
85e23b7f42 Version 0.23.9 2021-04-22 12:13:46 +02:00
93aa8579c3 Pagination: don't throw on first page when totalCount=0, validate params 2021-04-22 11:42:47 +02:00
23a11e7d60 Version 0.23.8 2021-04-21 13:57:20 +02:00
f314a4e022 Upgrade dependencies 2021-04-21 13:57:09 +02:00
12ec9b55f9 Handle SIGTERM exit signals (allows clean exit with systemd) 2021-04-21 13:52:52 +02:00
dbf9347ec6 Version 0.23.7 2021-03-30 12:10:58 +02:00
6973b2600c Upgrade dependencies 2021-03-30 12:10:49 +02:00
9ff832fb6f TestApp: don't ignore commands by default 2021-03-30 12:03:26 +02:00
714e747d6e Application: make start/stop sturdier, catch more stop signals 2021-03-30 12:03:26 +02:00
60e32042f7 package.json scripts: use NodeJS instead of unix commands 2021-03-30 12:03:26 +02:00
4cbc73a25f Fix ServeStaticDirectoryComponent while developping swaf
Also move core version detection to Application
2021-03-30 12:03:26 +02:00
69e9f3ce9c Controller: fix route() parsing with regexp params
Also allow numbers in route param names
2021-03-30 12:03:26 +02:00
4692a23696 Properly implement pagination 2021-03-30 12:02:57 +02:00
caae753d74 Allow users to change their username every configurable period of time
Closes #22
2021-02-23 17:43:11 +01:00
dcfb0b8f1c Add Time util class 2021-02-23 17:42:32 +01:00
3ce81e25cf Add tests for password remove flow
Closes #28
2021-02-21 16:28:27 +01:00
21f161a83a Update repository URL 2021-02-21 13:19:28 +01:00
562431449b Allow users to remove their password in case they forget it
Closes #23
2021-02-20 20:18:03 +01:00
cdac3c5f68 Version 0.23.6 2021-02-18 11:47:41 +01:00
5fc7b73668 Upgrade dependencies 2021-02-18 11:47:33 +01:00
f07f7cacee Fix account.njk error on fresh new account with username and password
Fixes #24
2021-02-18 11:43:27 +01:00
9d5fa9d937 Version 0.23.5 2021-01-26 17:57:17 +01:00
dbf6128747 Fix AddNameToUsersMigration not calling callbacks 2021-01-26 17:56:57 +01:00
a1d5597e96 Version 0.23.4 2021-01-25 17:54:29 +01:00
0f08b91370 macros.njk: remove debug default prefix 2021-01-25 17:53:50 +01:00
d96de4874a Add IF EXISTS to DropNameFromUsers migration to not fail in new installs 2021-01-25 17:45:11 +01:00
8b805a484b account view: normalize icons 2021-01-25 17:44:21 +01:00
44f2c2979b Version 0.23.3 2021-01-25 17:28:19 +01:00
aed825c4d6 FormHelperComponent: don't flash empty previous form data 2021-01-25 17:26:42 +01:00
5caa0be862 FormHelperComponent: add field ID prefix to prevent conflicts
Fixes #20
2021-01-25 17:26:42 +01:00
744923dc78 Auth view: merge register forms in one with js switch 2021-01-25 17:25:07 +01:00
4745ae4e17 Fix session id not available in websocket listeners
Fixes #21
2021-01-25 16:36:15 +01:00
8b98c8cc59 AddNameToUsersMigration: fix can't work when db already has users 2021-01-25 16:22:51 +01:00
4817563dc1 Make models and components available immediately after their migration 2021-01-25 16:21:24 +01:00
8a25f214ab AuthGuard: always provide a string to pending account mail username 2021-01-25 14:37:50 +01:00
b211845f57 Mail: also debug log data 2021-01-25 14:36:27 +01:00
27bce2da0a package.json: reorder build script to a more natural position 2021-01-25 14:07:42 +01:00
b9ac4d0f05 AddUsedToMagicLinksMigration: delete all magic links after install 2021-01-25 14:07:20 +01:00
449922490f MagicLinkAuthMethod: do not interrupt auth with used magic links 2021-01-25 14:02:58 +01:00
a314c9a55a Version 0.23.2 2021-01-25 12:52:38 +01:00
ab5d64df3b package.json: fix script name conflict with life cycle scripts 2021-01-25 12:52:18 +01:00
d32f571925 Version 0.23.1 2021-01-25 12:47:54 +01:00
359485170d UserNameComponent: ensure usage of user.name is optional
Fixes #19
2021-01-25 12:47:18 +01:00
e7d66e7c04 .gitignore: add config/local.* 2021-01-25 12:37:55 +01:00
2a184e4ff5 Fix published npm package folder structure 2021-01-25 12:37:41 +01:00
16dedb681a Version 0.23.0 2021-01-25 10:56:23 +01:00
8993e5e5bb Upgrade dependencies 2021-01-25 10:53:56 +01:00
f1a8a4ba07 Express.d.ts: normalize session fields case 2021-01-25 10:53:43 +01:00
e4768141bc Simplify RedirectBackComponent into PreviousUrlComponent
Closes #12
2021-01-24 22:42:20 +01:00
19c8b86ff8 Fix express body parsing middlewares corrupting AsyncLocalStorage
Closes #17
2021-01-24 16:35:33 +01:00
1b8ff1428f Add persist session checkbox on login
Makes session not persistent by default
Closes #11
2021-01-24 16:33:33 +01:00
5897b6bf36 Code cleanup: remove debug log from AccountController 2021-01-24 16:33:14 +01:00
f5b2015ae0 Code cleanup: normalize configuration file line endings 2021-01-24 16:33:08 +01:00
24785c3e71 error.njk: add default string to display when there is no error_id
Related: #17
2021-01-23 18:10:35 +01:00
1c71f66150 Clean logs from unwanted info and use \t as a delimiter to align
Closes #6
2021-01-22 16:07:27 +01:00
94cb157520 Remove connect-redis that breaks context with RedisStore 2021-01-22 15:55:06 +01:00
8fab93e709 Use AsyncLocalStorage to provide requestId context 2021-01-22 15:54:26 +01:00
6f9ecaa9c4 Upgrade dependencies 2021-01-22 13:42:15 +01:00
93c41ebd7e Authenticated.test: reorganize tests
Closes #15
2021-01-22 13:35:30 +01:00
49168b5391 Add account management (email addresses management, password management)
Closes #8
Closes #9
2021-01-22 12:22:11 +01:00
784f2c976c Rename base_url setting to public_url 2021-01-21 17:13:05 +01:00
3e0a25874e Auth: fix middleware applied globally and explicitly log them 2021-01-21 15:45:20 +01:00
2c66c66a39 logging: add status code description for redirections 2021-01-21 15:45:20 +01:00
878f706f82 magic links: fix views titles 2021-01-21 11:18:38 +01:00
4db7217876 Controller: add useMiddleware method 2020-12-30 14:10:58 +01:00
bdaf815aec Upgrade dependencies 2020-12-28 12:01:00 +01:00
87b4facea0 Upgrade dependencies and update to express session new typings 2020-12-04 15:24:22 +01:00
7be3e00c46 Authentication tests: add no username component tests
Closes #7
2020-11-16 12:13:49 +01:00
01277ea910 Authentication tests: add authenticate with email and password tests 2020-11-16 11:44:04 +01:00
70d80d1f0a AuthMethod: add weight to choose when no method was specified 2020-11-16 11:43:14 +01:00
a5ee9922ec Authentication tests: add authenticate with username and password tests 2020-11-15 15:51:52 +01:00
35129cd4f1 PasswordAuthMethod: simplify bad password throw 2020-11-15 15:50:19 +01:00
f99c62a5d9 Increase login fail per ip throttle limit and jail time 2020-11-15 15:49:40 +01:00
72fe0bbda8 Authentication tests: add authenticate with email (magic_link) tests 2020-11-15 15:23:24 +01:00
7db3e0166a Authentication tests: fix Cannot register without username test 2020-11-15 15:21:54 +01:00
124bc8785f MagicLinkUserNameComponent: allow null username 2020-11-15 15:21:26 +01:00
6a65ec723d AuthController: use Validator system for unknown user on login 2020-11-15 15:18:57 +01:00
6eacfdcffa Throttler: log jail triggers 2020-11-15 15:18:49 +01:00
da38fdaf72 tests: get rid of useless csrf.njk template 2020-11-15 15:15:21 +01:00
ef51d128f1 Authentication tests: refactor magic link following from mail 2020-11-15 14:21:11 +01:00
0d0724c315 Authentication tests: add more tests to email registration 2020-11-15 14:16:17 +01:00
683fe7262b MagicLinkAuthMethod: do not allow register for already existing email 2020-11-15 14:14:56 +01:00
c08d03c8fb MagicLinkUserNameComponent: fix validator property name for "username" 2020-11-15 14:13:57 +01:00
cecf28502e ModelComponent: fix validators not transferred to attached model 2020-11-15 14:13:35 +01:00
42da8a68bb Validation: respond with http 400 instead of 401 2020-11-15 14:12:45 +01:00
b28e2b75b7 Authentication: Improve registration tests and fix register/login overlap 2020-11-15 12:20:57 +01:00
698ace965f Add authentication tests for username registration 2020-11-14 18:16:58 +01:00
f8c4906a51 PasswordAuthMethod: fix findUserByIdentifier() 2020-11-14 18:16:05 +01:00
b75b227ca1 Add required username to magic link authentication and fix many errors 2020-11-14 17:24:57 +01:00
acc5233185 Error handling: transform single validation errors into a validation bag 2020-11-14 16:25:18 +01:00
3d819f03c7 Simplify build cp command and add README.md to published package files 2020-11-12 16:56:03 +01:00
9d50c5cc5f Rename project to swaf 2020-11-12 16:11:16 +01:00
efdd81b650 Auth: refactor to support multiple auth factors and add password factor 2020-11-11 19:30:30 +01:00
1fce157104 logging: prevent full logging of errors for silent logs 2020-11-11 19:29:23 +01:00
24d83c73ad Add basic development environment for testing purposes 2020-11-11 19:29:23 +01:00
b8905ea02b Move Controller.validate to static Validator.validate 2020-11-11 19:29:23 +01:00
bb8b44b5a3 ModelFactory: add hasComponent method 2020-11-11 19:29:23 +01:00
ead3c8ce1e Controller: wrap use() middlewares to handle async 2020-11-11 19:29:23 +01:00
79c2f33000 Deprecate legacy migrations
Fix CreateUsersAndUserEmailsTableMigration


sq
2020-11-11 19:29:23 +01:00
03d9826f93 Migration: remove connection parameter from query() method
Closes #5
2020-11-11 19:29:23 +01:00
f20da06d43 logging: also log ip address 2020-11-11 19:29:23 +01:00
a09e92dd96 logging: make silent errors actually silent 2020-11-04 12:52:07 +01:00
570a831172 Mail: remove usage of non-existent config property 2020-11-04 12:11:30 +01:00
223 changed files with 16631 additions and 6445 deletions

135
.eslintrc.cjs Normal file
View File

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

View File

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

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

74
rollup.config.js Normal file
View File

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

22
scripts/_functions.js Normal file
View File

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

12
scripts/clean.js Normal file
View File

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

23
scripts/dist.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,16 @@
import {IncomingForm} from "formidable";
import Middleware from "./Middleware";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {FileError, ValidationBag} from "./db/Validator"; import formidable, {Options} from "formidable";
import {FileError, ValidationBag} from "./db/Validator.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) => {
@ -24,7 +25,7 @@ export default abstract class FileUploadMiddleware extends Middleware {
}); });
} catch (e) { } catch (e) {
const bag = new ValidationBag(); const bag = new ValidationBag();
const fileError = new FileError(e); const fileError = new FileError(String(e));
fileError.thingName = this.getDefaultField(); fileError.thingName = this.getDefaultField();
bag.addMessage(fileError); bag.addMessage(fileError);
next(bag); next(bag);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

173
src/TestApp.ts Normal file
View File

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

View File

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

View File

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

View File

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

239
src/assets/img/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 29 KiB

3
src/assets/package.json Normal file
View File

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

View File

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

View File

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

View File

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

116
src/assets/scss/_vars.scss Normal file
View File

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

276
src/assets/scss/base.scss Normal file
View File

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

View File

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

View File

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

View File

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

46
src/assets/scss/tip.scss Normal file
View File

@ -0,0 +1,46 @@
@import "vars";
@mixin tip {
position: relative;
.tip {
visibility: hidden;
position: absolute;
z-index: 10000;
pointer-events: none;
display: block;
width: max-content;
height: 30px;
padding: 4px 8px;
line-height: 22px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
text-align: center;
font-size: 18px;
color: var(--on-surface);
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: var(--surface);
border-radius: 5px;
text-transform: initial;
font-weight: initial;
&.top {
top: auto;
bottom: calc(100% + 8px);
}
}
&:hover, &:active {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
}
}

View File

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

View File

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

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

3
src/assets/ts/stores.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %} {% extends 'mails/base_layout.mnjk' %}
{% block body %} {% block body %}
<mj-section> <mj-section>

View File

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

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %} {% extends 'mails/base_layout.mnjk' %}
{% block body %} {% block body %}
<mj-section> <mj-section>

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %} {% extends 'mails/base_layout.mnjk' %}
{% block body %} {% block body %}
<mj-section> <mj-section>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

36
src/auth/AuthMethod.ts Normal file
View File

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

View File

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

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