Compare commits

...

339 Commits

Author SHA1 Message Date
Alice Gaudon bbd9480000 chore(back): clean leftover console.log 2022-03-07 19:25:25 +01:00
Alice Gaudon ecb2b13a83 Version 0.25.1 2022-03-07 18:58:49 +01:00
Alice Gaudon a823503cb4 Upgrade @tsconfig/svelte 2022-03-07 18:58:21 +01:00
Alice Gaudon f0304c0b9f Upgrade @types/config, @types/node, concurrently, lucide 2022-03-07 18:56:35 +01:00
Alice Gaudon ae31edb622 Upgrade redis package 2022-03-07 18:42:50 +01:00
Alice Gaudon d378771ffa Upgrade dependencies 2022-03-07 18:15:48 +01:00
Alice Gaudon 869791b3ad Version 0.25.0 2022-03-07 17:47:28 +01:00
Alice Gaudon e19a627eb5 fix(front/auth/login form): don't show password login method if there is not UserPasswordComponent 2022-03-07 17:41:20 +01:00
Alice Gaudon 3d960dccf3 fix(front/auth/register form): don't show password register method if there is no UserNameComponent
fixes #44
2022-03-07 17:41:20 +01:00
Alice Gaudon 6714e413a2 feat(front/form): allow forms to be disabled, add disabled buttons style 2022-03-07 17:22:43 +01:00
Alice Gaudon 27e9abc5f4 fix(back/auth): refactor auth success, fix message saying 'Welcome undefined' when there is no UserNameComponent 2022-03-07 17:02:03 +01:00
Alice Gaudon 8c083d562d fix(front/auth/login): show password field if previousFormData has password 2022-03-07 16:31:40 +01:00
Alice Gaudon 4cfcaac1cc fix(front/forms): simplify and fix usage of flashed previousFormData and validation errors 2022-03-06 18:55:06 +01:00
Alice Gaudon 3eb33c64d7 fix(front/Field): prevent label from having a greater z-index than the mobile menu 2022-03-06 18:46:33 +01:00
Alice Gaudon fd2852c387 feat(front/NavMenu): put logout button on the Account link under an extendable dropdown menu 2022-03-06 18:37:46 +01:00
Alice Gaudon 9f17c5b8cd feat(front/style): add hr default style 2022-03-06 18:36:02 +01:00
Alice Gaudon 144a72895e fix(front/account): fix first panel tag name (div->section) 2022-03-06 18:35:41 +01:00
Alice Gaudon 81c65344a9 chore(front): remove unnecessary containers and reformat 2022-03-06 16:57:31 +01:00
Alice Gaudon 41a083ba52 feat(back/auth): add use() method to AuthProof and call it on successful login attempt 2022-03-06 16:34:57 +01:00
Alice Gaudon 0c4349fac3 fix(common/Time): fix humanizeDuration for duration > 1 year 2022-03-06 13:22:12 +01:00
Alice Gaudon cb5001ce6e fix(front/CopyableText): don't display title when not provided 2022-03-06 13:21:04 +01:00
Alice Gaudon ee56113808 fix(front/FlashMessages): only display relevant flash bags 2022-03-06 13:20:20 +01:00
Alice Gaudon 2c3286d313 feat(front/data-table): add actions cell, col-grow and col-grow-cell 2022-03-05 10:05:13 +01:00
Alice Gaudon 2e3c5d16c4 fix(back/flash): retrieve all flashed fields 2022-03-05 10:03:49 +01:00
Alice Gaudon 81a62be38d feat(front/CopyableText): add simple button mode 2022-03-02 11:36:23 +01:00
Alice Gaudon afef367e59 chore(front): cleanup outdated todo 2022-03-02 10:23:19 +01:00
Alice Gaudon 1e72ec7172 fix(front/Icon): better match icon type against Lucide Icons full name instead of starting with fa to avoid collisions 2022-03-02 10:22:55 +01:00
Alice Gaudon 0e0e633e08 feat(front/Form): allow overriding onSubmit function 2022-02-28 14:43:36 +01:00
Alice Gaudon 7a49e47ae7 feat(front/Field): expose file list 2022-02-28 14:42:36 +01:00
Alice Gaudon 67dc33adf4 fix(front/checkbox): prevent checkbox double click event 2022-02-19 11:58:36 +01:00
Alice Gaudon 231aa8dcd7 fix(front/checkbox): change value to boolean and use handleInput 2022-02-19 11:30:06 +01:00
Alice Gaudon 535c8afdb1 fix(websockets): send cookies manually for session authentication 2022-02-18 22:59:16 +01:00
Alice Gaudon dad4ff62f1 Version 0.24.10 2021-12-03 00:42:20 +01:00
Alice Gaudon 8626d0b571 Upgrade dependencies 2021-12-03 00:42:12 +01:00
Alice Gaudon 32a1721ef2 front/BaseTemplate: add facebook, twitter and generic url preview meta tags 2021-12-03 00:40:31 +01:00
Alice Gaudon 689c860e2e front/ErrorTemplate: fix header logo being cropped 2021-11-28 21:58:06 +01:00
Alice Gaudon 7672a568fc Version 0.24.9 2021-11-28 21:28:06 +01:00
Alice Gaudon d6b530d16c Add formatViewData to response object to fix tests and prepare for async navigation 2021-11-28 21:26:45 +01:00
Alice Gaudon 366e48757e back/config: replace localhost with 127.0.0.1 2021-11-28 21:24:51 +01:00
Alice Gaudon ae603362e9 back/redis: fix not using redis.prefix config 2021-11-28 17:06:07 +01:00
Alice Gaudon be0676611a Upgrade dependencies 2021-11-28 17:02:18 +01:00
Alice Gaudon b85573a64d front: add Loader component 2021-11-28 16:44:51 +01:00
Alice Gaudon 172d27a6d2 front/NavMenuItem: remove margin top/left on first menu item 2021-11-28 16:44:27 +01:00
Alice Gaudon e033baa57f front/BaseTemplate: allow disabling login link and logo label 2021-11-28 16:43:46 +01:00
Alice Gaudon d1ff09fcc8 front/BaseFooter: fix app version local 2021-11-28 16:42:22 +01:00
Alice Gaudon 9674fc87dd front/SvelteViewEngine: add isSsr local 2021-11-28 16:41:46 +01:00
Alice Gaudon c606379bcd front/SvelteViewEngine: don't use css cache in development environment 2021-11-25 00:25:50 +01:00
Alice Gaudon 179cb09b58 back/Application: fix fallback error template view name 2021-11-24 22:18:14 +01:00
Alice Gaudon 9980c54fcf front/SvelteViewEngine: pre-compile ssr on demand, refactor globals into proper locals and lazy locals 2021-11-24 22:18:14 +01:00
Alice Gaudon 2b85bea9dd common/Routing: allow RouteParams to be undefined and ignore them in this case 2021-11-24 18:20:02 +01:00
Alice Gaudon 4c895229ec back/cli: fix main command not able to accept args 2021-11-24 18:20:02 +01:00
Alice Gaudon e1542ae476 back/migration command: add log to show that we are rolling back a migration and which one 2021-11-24 18:20:02 +01:00
Alice Gaudon 59491d63ab front/Field: add support for datetime-local field type 2021-11-24 18:20:02 +01:00
Alice Gaudon aa1484749e front/BaseTemplate: allow disabling header, h1 and footer via boolean attributes 2021-11-24 18:16:16 +01:00
Alice Gaudon f801f6a43b Add missing dev dependency @types/node 2021-11-24 17:38:16 +01:00
Alice Gaudon f68e81836b Allow forms to have a file upload enctype 2021-11-22 21:30:23 +01:00
Alice Gaudon 6348692473 Fix select field value bind 2021-11-22 21:29:54 +01:00
Alice Gaudon e151c9c744 Fix FileUploadMiddleware formidable usage 2021-11-22 17:59:32 +01:00
Alice Gaudon 2e68cf8cae Version 0.24.8 2021-11-21 20:24:56 +01:00
Alice Gaudon f55ba4d611 Upgrade svelte-preprocess 2021-11-21 20:24:35 +01:00
Alice Gaudon 5cfdecfebf Also replace icons afterUpdate 2021-11-21 20:18:15 +01:00
Alice Gaudon 10d5d16967 Watch DOM changes to add external link icons to new external links 2021-11-21 20:17:49 +01:00
Alice Gaudon 7f4996c908 Add fonts copy to rollup config 2021-11-21 18:34:36 +01:00
Alice Gaudon 92c6433235 Version 0.24.7 2021-11-21 17:06:45 +01:00
Alice Gaudon 48de7829ec Upgrade dependencies 2021-11-21 17:06:28 +01:00
Alice Gaudon 9998f1a91c Make require-from-string a normal dependency for dependants 2021-11-21 17:06:06 +01:00
Alice Gaudon c7d0238d22 Version 0.24.6 2021-11-21 16:50:47 +01:00
Alice Gaudon b24e9ab580 front: add CopyableText component 2021-11-21 16:10:38 +01:00
Alice Gaudon e9db1f4ded front: add an external link icon to external links 2021-11-21 15:02:31 +01:00
Alice Gaudon 428990dc00 front/home page: make first word of the title choosen at random in a list 2021-11-21 13:15:15 +01:00
Alice Gaudon c0918b17ed Rename front layouts to templates 2021-11-21 13:14:23 +01:00
Alice Gaudon da6fda02a9 Replace feather icons with lucide icons, use Icon component everywhere 2021-11-21 12:42:26 +01:00
Alice Gaudon 297bafcdc8 Add style to error pages 2021-11-20 22:55:25 +01:00
Alice Gaudon 61e7282f25 Version 0.24.5 2021-11-20 19:52:59 +01:00
Alice Gaudon d40481fe3b Make rollup available in dependant projects 2021-11-20 19:52:44 +01:00
Alice Gaudon 9da35de4e0 Move design page view to test views 2021-11-20 19:22:04 +01:00
Alice Gaudon febde935e3 Make more route usage optional 2021-11-20 19:21:15 +01:00
Alice Gaudon 3c28bd9fbe Version 0.24.4 2021-11-20 19:09:14 +01:00
Alice Gaudon 3616d54d29 frontend/BaseLayout: make it easier to edit the BaseLayout without redefining everything 2021-11-20 19:04:55 +01:00
Alice Gaudon e97cbb5d7f Routing: add hasRoute and hasAnyRoute and make optional components views not render route if they don't exist 2021-11-20 18:02:00 +01:00
Alice Gaudon c0245e3e3d Move tests page view to test folder 2021-11-20 17:29:17 +01:00
Alice Gaudon e188458f9c SvelteViewEngine: fix stores file path 2021-11-20 17:29:17 +01:00
Alice Gaudon 2f2e1f51b8 Fix dependencies for sources that will be rebuilt by dependant projects 2021-11-20 16:17:18 +01:00
Alice Gaudon 575de5ebb1 Copy assets to dist folder at dist step 2021-11-20 15:43:20 +01:00
Alice Gaudon 876d509f10 Version 0.24.3 2021-11-20 15:40:22 +01:00
Alice Gaudon a03dd994e3 Upgrade dependencies 2021-11-20 15:39:51 +01:00
Alice Gaudon 7dde32edb4 Distribute src/assets 2021-11-20 15:39:51 +01:00
Alice Gaudon a753122290 Do not distribute backend source, export everything to commonjs 2021-11-20 15:39:51 +01:00
Alice Gaudon 5869ba3ee3 Version 0.24.2 2021-11-10 19:26:06 +01:00
Alice Gaudon ce39f82e66 Copy package.json to dist folder for release 2021-11-10 19:25:50 +01:00
Alice Gaudon 45bd805969 Version 0.24.1 2021-11-10 17:59:44 +01:00
Alice Gaudon f41ee9cf32 Remove deprecated migrations 2021-11-10 17:58:52 +01:00
Alice Gaudon 404a2ecb16 Fix usages of explicit any 2021-11-10 17:58:52 +01:00
Alice Gaudon 4a09d2e1fe Fix linting on frontend TS
Add return type (void) to replaceIcons function
2021-11-10 17:09:30 +01:00
Alice Gaudon ef9cb663e7 Add specific tsconfig for eslint 2021-11-10 17:07:05 +01:00
Alice Gaudon e84b7cf6d3 Version 0.24.0 2021-11-10 16:41:29 +01:00
Alice Gaudon d6483af1a9 Fix linting errors 2021-11-09 19:46:51 +01:00
Alice Gaudon 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
Alice Gaudon 7650238183 data-table: add data-table-container to overflow-x scroll, use it on relevant pages 2021-11-09 19:29:10 +01:00
Alice Gaudon 5336940dc3 auth page: remove useless hint, use better icon for "Use magic link" button 2021-11-09 19:22:52 +01:00
Alice Gaudon c29024bb23 Add icons to account page 2021-11-09 19:22:10 +01:00
Alice Gaudon c5b6b33abd Make magic link lobby messages sticky 2021-11-09 19:15:26 +01:00
Alice Gaudon 54bed4ad7f Add icons to auth page forms 2021-11-09 19:15:03 +01:00
Alice Gaudon cae7adcee8 Add data-table css component 2021-11-09 18:58:21 +01:00
Alice Gaudon 4d9dae5e3b Allow duration field to specify labels of sub-fields 2021-11-09 18:51:08 +01:00
Alice Gaudon 4a99a5acf5 Fix fieldset and number input display 2021-11-09 18:34:01 +01:00
Alice Gaudon a4e48eb174 Fix duration field components input displacement 2021-11-09 18:07:53 +01:00
Alice Gaudon 6cd98929d3 Fix Field textarea focus label move 2021-11-09 18:07:31 +01:00
Alice Gaudon 5cea1b866e Fix Field textarea element position and size 2021-11-09 18:00:52 +01:00
Alice Gaudon baeea368ff Fix pagination second ellipsis 2021-11-09 17:49:56 +01:00
Alice Gaudon ee4ca0f49b Make pagination and Breadcrumb overflow-x scroll 2021-11-09 17:49:39 +01:00
Alice Gaudon 8254c6cb47 Add breadcrumb to accounts_approval page 2021-11-09 17:43:33 +01:00
Alice Gaudon ae1d743f15 Add breadcrumb design 2021-11-09 17:43:14 +01:00
Alice Gaudon 9b3822d7f3 Add subsurface for panels in panels, add pagination css/design 2021-11-09 17:32:51 +01:00
Alice Gaudon 93be06d10f Make form submit button bold by default, add optional reset button 2021-11-09 16:54:37 +01:00
Alice Gaudon e9acde6313 Merge branch 'develop' into svelte 2021-11-08 14:11:47 +01:00
Alice Gaudon 95f6333d6a Handle all existing magic links at once 2021-11-08 13:22:20 +01:00
Alice Gaudon 7d2b088635 Fix no icon fields input and label shift 2021-11-08 01:44:26 +01:00
Alice Gaudon eefb6e0dac Make tests pass 2021-11-08 01:21:51 +01:00
Alice Gaudon e7695b7027 Upgrade dependencies 2021-11-08 01:09:26 +01:00
Alice Gaudon 941dc3700e Add FileSize helper class 2021-11-08 00:25:28 +01:00
Alice Gaudon 4a8a1f2da8 More work on default theme and components 2021-11-08 00:24:53 +01:00
Alice Gaudon f1ae6f6a7b Move frontend tests to a dedicated page 2021-06-02 18:30:20 +02:00
Alice Gaudon 3b636359c6 [TO SQUASH] fix scss assets build pipeline
squash with 7174097388
2021-06-02 18:29:37 +02:00
Alice Gaudon d0a01ff771 Add images asset type handling 2021-06-02 17:13:46 +02:00
Alice Gaudon 77ff2505b2 CsrfTokenComponent: Use a global empty function for SSR 2021-06-02 17:13:01 +02:00
Alice Gaudon 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
Alice Gaudon 9ac42bb3db Convert all views to svelte 2021-06-01 16:14:24 +02:00
Alice Gaudon 64cec2987d AccountController: serialize user personal info fields 2021-06-01 16:08:01 +02:00
Alice Gaudon d925237233 Move pagination to common, add serialization, update BackendController 2021-06-01 16:04:43 +02:00
Alice Gaudon 7174097388 Add scss assets handling 2021-06-01 16:03:07 +02:00
Alice Gaudon dffbf296ed [TO SQUASH] Add missing src/assets/ts/tsconfig.json 2021-06-01 14:42:58 +02:00
Alice Gaudon 0f415144dc Reorganize many root config parameters 2021-06-01 14:38:53 +02:00
Alice Gaudon 7ccd335649 Move route building to common subproject, fix Time export 2021-06-01 14:34:10 +02:00
Alice Gaudon 1b221c590f package.json: dev script: preserve watch output on tsc 2021-05-31 16:22:14 +02:00
Alice Gaudon 2da637f51d svelte backend calls: stop matching on ',' 2021-05-31 15:34:37 +02:00
Alice Gaudon 4d2dda3615 svelte_layout.html: fix localStore import name 2021-05-31 11:18:46 +02:00
Alice Gaudon 166d1c1458 Swaf export regexp: remove unnecessary escapements 2021-05-31 11:18:04 +02:00
Alice Gaudon a85d899a21 locals: handle ignored properties, ignore ModelRelation.model 2021-05-31 11:16:46 +02:00
Alice Gaudon eb6ed8f2d2 Make css properly passed from children to parents with cache and dedup 2021-05-31 11:14:56 +02:00
Alice Gaudon c6b8c48a72 Move all sources to src folder, add common ts subproject 2021-05-27 15:26:19 +02:00
Alice Gaudon c36e4c0fbe Make locals override globals instead of the inverse 2021-05-13 17:39:20 +02:00
Alice Gaudon 13bd933b0b svelte: allow locals function calls with no parameter 2021-05-13 17:38:48 +02:00
Alice Gaudon c9fed2d873 Use maintenance component to throw 503s when some components are unavailable 2021-05-13 16:26:27 +02:00
Alice Gaudon 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
Alice Gaudon cdf95c0c0b Add has and require methods to Extendable 2021-05-13 15:58:41 +02:00
Alice Gaudon c896accdfa svelte: backend calls: make localStore import .js extension optional 2021-05-13 15:57:31 +02:00
Alice Gaudon 76fa44c245 Convert errors pages to svelte
Fallback non-existing error views to generic Error svelte component
2021-05-13 15:55:56 +02:00
Alice Gaudon 66a696f40e Application: fix isInNodeModules is inverted 2021-05-13 14:13:45 +02:00
Alice Gaudon 26140d2028 svelte: don't eat conditional chains question marks 2021-05-13 14:12:40 +02:00
Alice Gaudon fac59e12d6 views: delete target file or re-precompile on file remove 2021-05-13 14:11:23 +02:00
Alice Gaudon c1c7b8920d Add generic type to localStore 2021-05-13 14:08:35 +02:00
Alice Gaudon afd45bd99d Reorganize frontend tests and add more $locals tests 2021-05-12 14:48:45 +02:00
Alice Gaudon a4d175addc Handle assets as commonjs 2021-05-12 14:48:02 +02:00
Alice Gaudon d423d78f2a esm: fix jest not running
following 82ab0b963c
2021-05-12 14:33:18 +02:00
Alice Gaudon 1167e99c30 Svelte view engine: backend calls: don't match dots and quotes 2021-05-12 14:02:58 +02:00
Alice Gaudon c60bd442e8 Svelte view engine: preprocess dependencies recursively 2021-05-12 14:02:58 +02:00
Alice Gaudon e851630be4 SvelteViewEngine: update backend calls prefix to $locals (store) 2021-05-12 14:02:58 +02:00
Alice Gaudon 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
Alice Gaudon b247d73fcb Use clear-module to clear require cache of dependencies 2021-05-11 15:47:42 +02:00
Alice Gaudon 423f19de68 Only crash when not watching after everything is pre-compiled 2021-05-11 15:14:51 +02:00
Alice Gaudon 076cda8008 Fix localStore set to a function that fetches pre-executed backend calls 2021-05-11 15:13:32 +02:00
Alice Gaudon 911e64a6ae Remove unnecessary svelte preprocess plugin from rollup 2021-05-11 15:08:10 +02:00
Alice Gaudon 8884f70a24 AssetPreCompiler: watch all view directories instead of the first one 2021-05-11 13:54:08 +02:00
Alice Gaudon 42f7ebba05 Svelte: write fully preprocessed code to pre-compiled files 2021-05-11 13:52:48 +02:00
Alice Gaudon e95595f5a3 package.json dev script: reorder nodemon tasks 2021-05-04 17:39:31 +02:00
Alice Gaudon 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
Alice Gaudon 50e1b287a9 Catch command line errors and force exit with error code 2021-05-04 17:28:02 +02:00
Alice Gaudon 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
Alice Gaudon 82ab0b963c Switch to esm and add import auto format 2021-05-03 20:52:52 +02:00
Alice Gaudon 238bbec429 Cancel replaces on ssr html todo 2021-04-30 14:04:42 +02:00
Alice Gaudon 05d112a3b3 Remove NunjucksComponent 2021-04-30 13:59:22 +02:00
Alice Gaudon c524ddde44 Remove useless node-sass package 2021-04-30 10:50:54 +02:00
Alice Gaudon 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
Alice Gaudon bd2b7e7579 svelte render: simplify replaces 2021-04-28 17:03:27 +02:00
Alice Gaudon 05069b15d8 Properly use promises in ViewEngine.render(), use ViewEngine for mail and add NunjucksViewEngine 2021-04-28 17:02:49 +02:00
Alice Gaudon a7f421d2f8 Move request context locals definition to FrontendToolsComponent 2021-04-28 14:10:29 +02:00
Alice Gaudon c93ea7691e ViewEngine: demote buildDir and publicDir fields 2021-04-28 14:09:02 +02:00
Alice Gaudon c4f71e569f Move globals to static ViewEngine and add setGlobal() 2021-04-28 14:05:06 +02:00
Alice Gaudon 0c178955ec Fix svelte template replace would recursively replace 2021-04-28 11:00:51 +02:00
Alice Gaudon 98008517b2 Fix prepare-sources.js comment 2021-04-27 15:44:20 +02:00
Alice Gaudon c8e9cf963d Fix indent on tsconfig files 2021-04-27 15:44:04 +02:00
Alice Gaudon fd7a6af98d Remove useless type definitions 2021-04-27 15:43:51 +02:00
Alice Gaudon 9d5c791081 Rollback package.json indent change 2021-04-27 15:10:30 +02:00
Alice Gaudon 58ea522593 Reorganize config params and use view.cache for caching instead of view.dev 2021-04-27 15:08:41 +02:00
Alice Gaudon faab9e4894 Rollback config files indent change 2021-04-27 15:01:07 +02:00
Alice Gaudon 4f1f88c8f8 Remove useless commandLineArgs.input manipulation 2021-04-27 15:00:43 +02:00
Alice Gaudon 91410b1a15 App startup: add http:// before listen address for conveniance 2021-04-27 14:43:27 +02:00
Alice Gaudon 7b2cdb8269 Clear require cache for watch recompiling 2021-04-23 18:20:22 +02:00
Alice Gaudon a97e68289e Prevent nodemon infinite loop on yarn dev after yarn clean 2021-04-23 18:19:05 +02:00
Alice Gaudon 10bd8bb95e Upgrade @rollup/plugin-commonjs 2021-04-23 16:11:14 +02:00
Alice Gaudon 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
Alice Gaudon 053313002c Move svelte compilation to external rollup and add pre-compile-views cli 2021-04-23 15:56:39 +02:00
Alice Gaudon e5a9b9908d Refactor and make it nicer codewise 2021-04-23 15:56:39 +02:00
Alice Gaudon 59067e49fe package.json scripts: use NodeJS instead of unix commands 2021-04-23 15:56:06 +02:00
Alice Gaudon 29fa5f4e38 Make svelte work 2021-04-23 15:56:06 +02:00
Alice Gaudon 5f0e11efed Add svelte pre-compilation 2021-04-23 15:56:06 +02:00
Alice Gaudon a34b97ad5a Version 0.23.10 2021-04-22 18:18:37 +02:00
Alice Gaudon df59795489 Remove mail spammy log 2021-04-22 18:01:52 +02:00
Alice Gaudon 847fa030b8 Increase jest test timeout 2021-04-22 18:01:35 +02:00
Alice Gaudon cfc632ba1a Approval mode: revoke unapproved users auth proofs
Also add tests for auth approval mode
2021-04-22 18:01:13 +02:00
Alice Gaudon 85e23b7f42 Version 0.23.9 2021-04-22 12:13:46 +02:00
Alice Gaudon 93aa8579c3 Pagination: don't throw on first page when totalCount=0, validate params 2021-04-22 11:42:47 +02:00
Alice Gaudon 23a11e7d60 Version 0.23.8 2021-04-21 13:57:20 +02:00
Alice Gaudon f314a4e022 Upgrade dependencies 2021-04-21 13:57:09 +02:00
Alice Gaudon 12ec9b55f9 Handle SIGTERM exit signals (allows clean exit with systemd) 2021-04-21 13:52:52 +02:00
Alice Gaudon dbf9347ec6 Version 0.23.7 2021-03-30 12:10:58 +02:00
Alice Gaudon 6973b2600c Upgrade dependencies 2021-03-30 12:10:49 +02:00
Alice Gaudon 9ff832fb6f TestApp: don't ignore commands by default 2021-03-30 12:03:26 +02:00
Alice Gaudon 714e747d6e Application: make start/stop sturdier, catch more stop signals 2021-03-30 12:03:26 +02:00
Alice Gaudon 60e32042f7 package.json scripts: use NodeJS instead of unix commands 2021-03-30 12:03:26 +02:00
Alice Gaudon 4cbc73a25f Fix ServeStaticDirectoryComponent while developping swaf
Also move core version detection to Application
2021-03-30 12:03:26 +02:00
Alice Gaudon 69e9f3ce9c Controller: fix route() parsing with regexp params
Also allow numbers in route param names
2021-03-30 12:03:26 +02:00
Alice Gaudon 4692a23696 Properly implement pagination 2021-03-30 12:02:57 +02:00
Alice Gaudon caae753d74 Allow users to change their username every configurable period of time
Closes #22
2021-02-23 17:43:11 +01:00
Alice Gaudon dcfb0b8f1c Add Time util class 2021-02-23 17:42:32 +01:00
Alice Gaudon 3ce81e25cf Add tests for password remove flow
Closes #28
2021-02-21 16:28:27 +01:00
Alice Gaudon 21f161a83a Update repository URL 2021-02-21 13:19:28 +01:00
Alice Gaudon 562431449b Allow users to remove their password in case they forget it
Closes #23
2021-02-20 20:18:03 +01:00
Alice Gaudon cdac3c5f68 Version 0.23.6 2021-02-18 11:47:41 +01:00
Alice Gaudon 5fc7b73668 Upgrade dependencies 2021-02-18 11:47:33 +01:00
Alice Gaudon f07f7cacee Fix account.njk error on fresh new account with username and password
Fixes #24
2021-02-18 11:43:27 +01:00
Alice Gaudon 9d5fa9d937 Version 0.23.5 2021-01-26 17:57:17 +01:00
Alice Gaudon dbf6128747 Fix AddNameToUsersMigration not calling callbacks 2021-01-26 17:56:57 +01:00
Alice Gaudon a1d5597e96 Version 0.23.4 2021-01-25 17:54:29 +01:00
Alice Gaudon 0f08b91370 macros.njk: remove debug default prefix 2021-01-25 17:53:50 +01:00
Alice Gaudon d96de4874a Add IF EXISTS to DropNameFromUsers migration to not fail in new installs 2021-01-25 17:45:11 +01:00
Alice Gaudon 8b805a484b account view: normalize icons 2021-01-25 17:44:21 +01:00
Alice Gaudon 44f2c2979b Version 0.23.3 2021-01-25 17:28:19 +01:00
Alice Gaudon aed825c4d6 FormHelperComponent: don't flash empty previous form data 2021-01-25 17:26:42 +01:00
Alice Gaudon 5caa0be862 FormHelperComponent: add field ID prefix to prevent conflicts
Fixes #20
2021-01-25 17:26:42 +01:00
Alice Gaudon 744923dc78 Auth view: merge register forms in one with js switch 2021-01-25 17:25:07 +01:00
Alice Gaudon 4745ae4e17 Fix session id not available in websocket listeners
Fixes #21
2021-01-25 16:36:15 +01:00
Alice Gaudon 8b98c8cc59 AddNameToUsersMigration: fix can't work when db already has users 2021-01-25 16:22:51 +01:00
Alice Gaudon 4817563dc1 Make models and components available immediately after their migration 2021-01-25 16:21:24 +01:00
Alice Gaudon 8a25f214ab AuthGuard: always provide a string to pending account mail username 2021-01-25 14:37:50 +01:00
Alice Gaudon b211845f57 Mail: also debug log data 2021-01-25 14:36:27 +01:00
Alice Gaudon 27bce2da0a package.json: reorder build script to a more natural position 2021-01-25 14:07:42 +01:00
Alice Gaudon b9ac4d0f05 AddUsedToMagicLinksMigration: delete all magic links after install 2021-01-25 14:07:20 +01:00
Alice Gaudon 449922490f MagicLinkAuthMethod: do not interrupt auth with used magic links 2021-01-25 14:02:58 +01:00
Alice Gaudon a314c9a55a Version 0.23.2 2021-01-25 12:52:38 +01:00
Alice Gaudon ab5d64df3b package.json: fix script name conflict with life cycle scripts 2021-01-25 12:52:18 +01:00
Alice Gaudon d32f571925 Version 0.23.1 2021-01-25 12:47:54 +01:00
Alice Gaudon 359485170d UserNameComponent: ensure usage of user.name is optional
Fixes #19
2021-01-25 12:47:18 +01:00
Alice Gaudon e7d66e7c04 .gitignore: add config/local.* 2021-01-25 12:37:55 +01:00
Alice Gaudon 2a184e4ff5 Fix published npm package folder structure 2021-01-25 12:37:41 +01:00
Alice Gaudon 16dedb681a Version 0.23.0 2021-01-25 10:56:23 +01:00
Alice Gaudon 8993e5e5bb Upgrade dependencies 2021-01-25 10:53:56 +01:00
Alice Gaudon f1a8a4ba07 Express.d.ts: normalize session fields case 2021-01-25 10:53:43 +01:00
Alice Gaudon e4768141bc Simplify RedirectBackComponent into PreviousUrlComponent
Closes #12
2021-01-24 22:42:20 +01:00
Alice Gaudon 19c8b86ff8 Fix express body parsing middlewares corrupting AsyncLocalStorage
Closes #17
2021-01-24 16:35:33 +01:00
Alice Gaudon 1b8ff1428f Add persist session checkbox on login
Makes session not persistent by default
Closes #11
2021-01-24 16:33:33 +01:00
Alice Gaudon 5897b6bf36 Code cleanup: remove debug log from AccountController 2021-01-24 16:33:14 +01:00
Alice Gaudon f5b2015ae0 Code cleanup: normalize configuration file line endings 2021-01-24 16:33:08 +01:00
Alice Gaudon 24785c3e71 error.njk: add default string to display when there is no error_id
Related: #17
2021-01-23 18:10:35 +01:00
Alice Gaudon 1c71f66150 Clean logs from unwanted info and use \t as a delimiter to align
Closes #6
2021-01-22 16:07:27 +01:00
Alice Gaudon 94cb157520 Remove connect-redis that breaks context with RedisStore 2021-01-22 15:55:06 +01:00
Alice Gaudon 8fab93e709 Use AsyncLocalStorage to provide requestId context 2021-01-22 15:54:26 +01:00
Alice Gaudon 6f9ecaa9c4 Upgrade dependencies 2021-01-22 13:42:15 +01:00
Alice Gaudon 93c41ebd7e Authenticated.test: reorganize tests
Closes #15
2021-01-22 13:35:30 +01:00
Alice Gaudon 49168b5391 Add account management (email addresses management, password management)
Closes #8
Closes #9
2021-01-22 12:22:11 +01:00
Alice Gaudon 784f2c976c Rename base_url setting to public_url 2021-01-21 17:13:05 +01:00
Alice Gaudon 3e0a25874e Auth: fix middleware applied globally and explicitly log them 2021-01-21 15:45:20 +01:00
Alice Gaudon 2c66c66a39 logging: add status code description for redirections 2021-01-21 15:45:20 +01:00
Alice Gaudon 878f706f82 magic links: fix views titles 2021-01-21 11:18:38 +01:00
Alice Gaudon 4db7217876 Controller: add useMiddleware method 2020-12-30 14:10:58 +01:00
Alice Gaudon bdaf815aec Upgrade dependencies 2020-12-28 12:01:00 +01:00
Alice Gaudon 87b4facea0 Upgrade dependencies and update to express session new typings 2020-12-04 15:24:22 +01:00
Alice Gaudon 7be3e00c46 Authentication tests: add no username component tests
Closes #7
2020-11-16 12:13:49 +01:00
Alice Gaudon 01277ea910 Authentication tests: add authenticate with email and password tests 2020-11-16 11:44:04 +01:00
Alice Gaudon 70d80d1f0a AuthMethod: add weight to choose when no method was specified 2020-11-16 11:43:14 +01:00
Alice Gaudon a5ee9922ec Authentication tests: add authenticate with username and password tests 2020-11-15 15:51:52 +01:00
Alice Gaudon 35129cd4f1 PasswordAuthMethod: simplify bad password throw 2020-11-15 15:50:19 +01:00
Alice Gaudon f99c62a5d9 Increase login fail per ip throttle limit and jail time 2020-11-15 15:49:40 +01:00
Alice Gaudon 72fe0bbda8 Authentication tests: add authenticate with email (magic_link) tests 2020-11-15 15:23:24 +01:00
Alice Gaudon 7db3e0166a Authentication tests: fix Cannot register without username test 2020-11-15 15:21:54 +01:00
Alice Gaudon 124bc8785f MagicLinkUserNameComponent: allow null username 2020-11-15 15:21:26 +01:00
Alice Gaudon 6a65ec723d AuthController: use Validator system for unknown user on login 2020-11-15 15:18:57 +01:00
Alice Gaudon 6eacfdcffa Throttler: log jail triggers 2020-11-15 15:18:49 +01:00
Alice Gaudon da38fdaf72 tests: get rid of useless csrf.njk template 2020-11-15 15:15:21 +01:00
Alice Gaudon ef51d128f1 Authentication tests: refactor magic link following from mail 2020-11-15 14:21:11 +01:00
Alice Gaudon 0d0724c315 Authentication tests: add more tests to email registration 2020-11-15 14:16:17 +01:00
Alice Gaudon 683fe7262b MagicLinkAuthMethod: do not allow register for already existing email 2020-11-15 14:14:56 +01:00
Alice Gaudon c08d03c8fb MagicLinkUserNameComponent: fix validator property name for "username" 2020-11-15 14:13:57 +01:00
Alice Gaudon cecf28502e ModelComponent: fix validators not transferred to attached model 2020-11-15 14:13:35 +01:00
Alice Gaudon 42da8a68bb Validation: respond with http 400 instead of 401 2020-11-15 14:12:45 +01:00
Alice Gaudon b28e2b75b7 Authentication: Improve registration tests and fix register/login overlap 2020-11-15 12:20:57 +01:00
Alice Gaudon 698ace965f Add authentication tests for username registration 2020-11-14 18:16:58 +01:00
Alice Gaudon f8c4906a51 PasswordAuthMethod: fix findUserByIdentifier() 2020-11-14 18:16:05 +01:00
Alice Gaudon b75b227ca1 Add required username to magic link authentication and fix many errors 2020-11-14 17:24:57 +01:00
Alice Gaudon acc5233185 Error handling: transform single validation errors into a validation bag 2020-11-14 16:25:18 +01:00
Alice Gaudon 3d819f03c7 Simplify build cp command and add README.md to published package files 2020-11-12 16:56:03 +01:00
Alice Gaudon 9d50c5cc5f Rename project to swaf 2020-11-12 16:11:16 +01:00
Alice Gaudon efdd81b650 Auth: refactor to support multiple auth factors and add password factor 2020-11-11 19:30:30 +01:00
Alice Gaudon 1fce157104 logging: prevent full logging of errors for silent logs 2020-11-11 19:29:23 +01:00
Alice Gaudon 24d83c73ad Add basic development environment for testing purposes 2020-11-11 19:29:23 +01:00
Alice Gaudon b8905ea02b Move Controller.validate to static Validator.validate 2020-11-11 19:29:23 +01:00
Alice Gaudon bb8b44b5a3 ModelFactory: add hasComponent method 2020-11-11 19:29:23 +01:00
Alice Gaudon ead3c8ce1e Controller: wrap use() middlewares to handle async 2020-11-11 19:29:23 +01:00
Alice Gaudon 79c2f33000 Deprecate legacy migrations
Fix CreateUsersAndUserEmailsTableMigration


sq
2020-11-11 19:29:23 +01:00
Alice Gaudon 03d9826f93 Migration: remove `connection` parameter from query() method
Closes #5
2020-11-11 19:29:23 +01:00
Alice Gaudon f20da06d43 logging: also log ip address 2020-11-11 19:29:23 +01:00
Alice Gaudon a09e92dd96 logging: make silent errors actually silent 2020-11-04 12:52:07 +01:00
Alice Gaudon 570a831172 Mail: remove usage of non-existent config property 2020-11-04 12:11:30 +01:00
Alice Gaudon f15535dda9 Version 0.22.5 2020-11-04 11:55:52 +01:00
Alice Gaudon d741517cb9 AuthGuard: add separate before and after registration callbacks 2020-11-04 11:55:34 +01:00
Alice Gaudon 19066b3e67 Version 0.22.4 2020-11-03 17:50:26 +01:00
Alice Gaudon c966536950 NunjucksComponent: fix view loaders using cache when in dev env 2020-11-03 17:46:04 +01:00
Alice Gaudon 7a4c157074 Version 0.22.3 2020-11-03 11:14:26 +01:00
Alice Gaudon 7c2572cddc Fix validation errors not being flashed correctly 2020-11-03 11:14:13 +01:00
Alice Gaudon 6fa2683318 Version 0.22.2 2020-11-03 10:30:34 +01:00
Alice Gaudon 4d0c714dbd Render emails using NunjucksComponent's environment 2020-11-03 10:29:36 +01:00
Alice Gaudon cfb7bddca6 DropLegacyLogsTable: fix sql syntax 2020-11-02 19:32:56 +01:00
Alice Gaudon 0d94faf0a6 Version 0.22.0 2020-11-02 18:05:02 +01:00
Alice Gaudon 9d30b16536 yarn: rename "build_and_publish" script to friendlier "release" 2020-11-02 18:04:31 +01:00
Alice Gaudon f33c3f04eb Upgrade dependencies 2020-11-02 17:52:16 +01:00
Alice Gaudon 88e5e19730 Replace custom logging system with tslog 2020-11-02 17:50:12 +01:00
Alice Gaudon 93bff1fdca Nunjucks/globals: fix route() context 2020-10-02 12:13:48 +02:00
Alice Gaudon 8cf069fb28 Version 0.22.0-rc.23 2020-10-02 12:08:22 +02:00
Alice Gaudon 595a6d4066 ModelQuery: add create() and fix boolean serialization 2020-10-02 12:08:01 +02:00
Alice Gaudon 00c806aa0a Fix log level output 2020-10-02 11:11:01 +02:00
Alice Gaudon bb100b3c13 configuration: use .json5 instead of .ts 2020-10-01 16:16:36 +02:00
Alice Gaudon 36f835b226 Version 0.22.0-rc.21 2020-10-01 14:19:25 +02:00
Alice Gaudon 47367e2d79 Upgrade dependencies 2020-10-01 14:18:41 +02:00
Alice Gaudon a98c06fa92 eslint: add no-floating-promises 2020-10-01 14:18:31 +02:00
Alice Gaudon e37184e5ee Add user model to RequireAuth middlewares 2020-10-01 13:59:19 +02:00
Alice Gaudon f41a456524 Improve typing precision of CacheProvider.get() 2020-10-01 13:58:50 +02:00
Alice Gaudon 9d42167013 Version 0.22.0-rc.20 2020-09-28 14:16:26 +02:00
Alice Gaudon 79d704083a Add many eslint rules and fix all linting issues 2020-09-28 14:15:22 +02:00
Alice Gaudon 8210642684 Improve logging configuration structure 2020-09-25 22:19:13 +02:00
Alice Gaudon b736f5f6cb Improve middleware definition and cleanup code 2020-09-25 22:15:57 +02:00
Alice Gaudon 0d6f7c0d90 Add eslint 2020-09-25 11:48:39 +02:00
Alice Gaudon 5dc0bd710a Make nunjucks and static file server compatible with pkg 2020-09-24 22:42:55 +02:00
Alice Gaudon 87aae6bb33 Fix some nunjucks globals not properly set and make getCSRFToken dynamic 2020-09-23 16:11:51 +02:00
Alice Gaudon 47e0756930 Add websocketUrl view local 2020-09-23 12:31:19 +02:00
Alice Gaudon 79d3b51f90 Pass a whole Session to WebSocketListeners 2020-09-23 08:55:35 +02:00
Alice Gaudon 6305a69583 Version 0.22.0-rc.15 2020-09-23 08:47:16 +02:00
Alice Gaudon 2effaf13eb Add AuthComponent.getAuthGuard() 2020-09-23 08:46:37 +02:00
Alice Gaudon 75c2b72f57 Add Application.getComponent() 2020-09-23 08:46:21 +02:00
231 changed files with 17271 additions and 9719 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');
}
},
}

6
.gitignore vendored
View File

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

View File

@ -1 +1,33 @@
# WMS-Core
# Structure Web Application Framework
A NodeJS TypeScript web application framework (duh).
## /!\ Still in development! There are not near enough tests /!\
Use at your own risk. Also please feel free to contribute with issues, bug reports and pull requests.
## Features
### Application building
- Model, View, Controller
- Uses express
- Custom Middleware classes that enable advanced modularity
- Modular models (you can add components with some definition automation)
- Simple database migrations (raw sql queries for now)
- Nunjucks for the view template engine
- Mail template system using Nunjucks + MJML
- Beautiful logging thanks to `tslog`
### Databases
- MySQL (persistent data)
- Redis (cache, session)
- (more to come)
### Common systems
- Advanced modular multi-factor authentication system
- CSRF protection
- WebSocket server with Controller-style endpoint listeners
- WIP: automatic updates

62
config/default.json5 Normal file
View File

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

View File

@ -1,47 +0,0 @@
module.exports = {
app: {
name: 'Example App',
contact_email: 'contact@example.net'
},
log_level: "DEV",
db_log_level: "ERROR",
base_url: "http://localhost:4899",
public_websocket_url: "ws://localhost:4899",
listen_addr: '127.0.0.1',
port: 4899,
gitlab_webhook_token: 'default',
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "wms2",
create_database_automatically: false
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'wms'
},
session: {
secret: 'default',
cookie: {
secure: false,
maxAge: 30 * 24 * 3600 * 1000, // 30 days
}
},
mail: {
host: "127.0.0.1",
port: "1025",
secure: false,
username: "",
password: "",
allow_invalid_tls: true,
from: 'contact@example.net',
from_name: 'Example App',
},
view: {
cache: false
},
approval_mode: false,
}

28
config/production.json5 Normal file
View File

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

View File

@ -1,15 +0,0 @@
module.exports = {
log_level: "DEBUG",
db_log_level: "ERROR",
base_url: "https://watch-my.stream",
public_websocket_url: "wss://watch-my.stream",
session: {
cookie: {
secure: true
}
},
mail: {
secure: true,
allow_invalid_tls: false
}
}

18
config/test.json5 Normal file
View File

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

View File

@ -1,9 +0,0 @@
module.exports = {
mysql: {
host: "localhost",
user: "root",
password: "",
database: "wms2_core_test",
create_database_automatically: true
}
}

View File

@ -1,4 +1,9 @@
module.exports = {
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
}
},
transform: {
"^.+\\.ts$": "ts-jest"
},
@ -10,5 +15,6 @@ module.exports = {
testMatch: [
'**/test/**/*.test.ts'
],
testEnvironment: 'node'
};
testEnvironment: 'node',
resolver: "jest-ts-webcompat-resolver",
};

View File

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

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,50 +1,62 @@
import express, {NextFunction, Request, Response, Router} from 'express';
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError";
import {lib} from "nunjucks";
import Logger from "./Logger";
import WebSocketListener from "./WebSocketListener";
import ApplicationComponent from "./ApplicationComponent";
import Controller from "./Controller";
import MysqlConnectionManager from "./db/MysqlConnectionManager";
import Migration from "./db/Migration";
import {Type} from "./Utils";
import LogRequestsComponent from "./components/LogRequestsComponent";
import {ValidationBag} from "./db/Validator";
import config from "config";
import express, {NextFunction, Request, Response, Router} from 'express';
import * as fs from "fs";
import SecurityError from "./SecurityError";
import nunjucks from "nunjucks";
import * as path from "path";
import CacheProvider from "./CacheProvider";
import RedisComponent from "./components/RedisComponent";
import TemplateError = lib.TemplateError;
export default abstract class Application {
import ApplicationComponent from "./ApplicationComponent.js";
import CacheProvider from "./CacheProvider.js";
import {route, setPublicUrl} from "./common/Routing.js";
import FrontendToolsComponent from "./components/FrontendToolsComponent.js";
import LogRequestsComponent from "./components/LogRequestsComponent.js";
import RedisComponent from "./components/RedisComponent.js";
import Controller from "./Controller.js";
import Migration, {MigrationType} from "./db/Migration.js";
import MysqlConnectionManager from "./db/MysqlConnectionManager.js";
import {ValidationBag, ValidationError} from "./db/Validator.js";
import Extendable, {MissingComponentError} from "./Extendable.js";
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
import {logger, loggingContextMiddleware} from "./Logger.js";
import SecurityError from "./SecurityError.js";
import {doesFileExist, Type} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
import LazyLocalsCoreComponent from "./components/core/LazyLocalsCoreComponent.js";
export default abstract class Application implements Extendable<ApplicationComponent | WebSocketListener<Application>> {
private readonly version: string;
private coreVersion: string = 'unknown';
private readonly ignoreCommandLine: boolean;
private readonly controllers: Controller[] = [];
private readonly webSocketListeners: { [p: string]: WebSocketListener<any> } = {};
private readonly components: ApplicationComponent<any>[] = [];
private readonly webSocketListeners: { [p: string]: WebSocketListener<Application> } = {};
private readonly components: ApplicationComponent[] = [];
private cacheProvider?: CacheProvider;
private ready: boolean = false;
private started: boolean = false;
private busy: boolean = false;
protected constructor(version: string, ignoreCommandLine: boolean = false) {
this.version = version;
this.ignoreCommandLine = ignoreCommandLine;
setPublicUrl(config.get<string>('app.public_url'));
}
protected abstract getMigrations(): Type<Migration>[];
protected abstract getMigrations(): MigrationType<Migration>[];
protected abstract async init(): Promise<void>;
protected abstract init(): Promise<void>;
protected use(thing: Controller | WebSocketListener<this> | ApplicationComponent<any>) {
protected use(thing: Controller | WebSocketListener<this> | ApplicationComponent): void {
if (thing instanceof Controller) {
thing.setApp(this);
this.controllers.push(thing);
} else if (thing instanceof WebSocketListener) {
const path = thing.path();
this.webSocketListeners[path] = thing;
thing.init(this);
Logger.info(`Added websocket listener on ${path}`);
logger.info(`Added websocket listener on ${path}`);
} else {
thing.setApp(this);
this.components.push(thing);
@ -56,22 +68,61 @@ export default abstract class Application {
}
public async start(): Promise<void> {
Logger.info(`${config.get('app.name')} v${this.version} - hi`);
process.once('SIGINT', () => {
if (this.started) throw new Error('Application already started');
if (this.busy) throw new Error('Application busy');
this.busy = true;
// Load core version
const file = await this.isInNodeModules() ?
'node_modules/swaf/package.json' :
'package.json';
try {
this.coreVersion = JSON.parse(fs.readFileSync(file).toString()).version;
} catch (e) {
logger.warn('Couldn\'t determine coreVersion.', e);
}
logger.info(`${config.get('app.name')} v${this.version} | swaf v${this.coreVersion}`);
// Catch interrupt signals
const exitHandler = () => {
this.stop().catch(console.error);
});
};
process.once('exit', exitHandler);
process.once('SIGINT', exitHandler);
process.once('SIGUSR1', exitHandler);
process.once('SIGUSR2', exitHandler);
process.once('SIGTERM', exitHandler);
process.once('uncaughtException', exitHandler);
// Register migrations
MysqlConnectionManager.registerMigrations(this.getMigrations());
// Process command line
if (!this.ignoreCommandLine && await this.processCommandLine()) {
await this.stop();
return;
// Register and initialize all components and alike
this.use(new AppLocalsCoreComponents());
this.use(new LazyLocalsCoreComponent());
await this.init();
for (const component of this.components) {
await component.init?.();
}
// Register all components and alike
await this.init();
// Process command line
if (!this.ignoreCommandLine) {
let result: boolean;
try {
result = await this.processCommandLine();
} catch (err) {
logger.error(err);
process.exit(1);
}
if (result) {
this.started = true;
this.busy = false;
return;
}
}
// Security
if (process.env.NODE_ENV === 'production') {
@ -80,39 +131,53 @@ export default abstract class Application {
// Init express
const app = express();
// Logging context
app.use(loggingContextMiddleware);
// Routers
const initRouter = express.Router();
const handleRouter = express.Router();
app.use(initRouter);
app.use(handleRouter);
// Error handlers
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
// Transform single validation errors into a validation bag for convenience
if (err instanceof ValidationError) {
const bag = new ValidationBag();
bag.addMessage(err);
err = bag;
}
if (err instanceof ValidationBag) {
const bag = err;
res.format({
json: () => {
res.status(401);
res.status(400);
res.json({
status: 'error',
code: 401,
code: 400,
message: 'Invalid form data',
messages: err.getMessages(),
messages: bag.getMessages(),
});
},
text: () => {
res.status(401);
res.send('Error: ' + err.getMessages())
res.status(400);
res.send('Error: ' + bag.getMessages());
},
html: () => {
req.flash('validation', err.getMessages());
res.redirectBack();
req.flash('validation', bag.getMessages());
res.redirect(req.getPreviousUrl() || route('home'));
},
});
return;
}
let errorID: string = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error', err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError);
const errorId = LogRequestsComponent.logRequest(req, res, err, '500 Internal Error',
err instanceof BadRequestError || err instanceof ServiceUnavailableHttpError);
let httpError: HttpError;
@ -121,17 +186,24 @@ export default abstract class Application {
} else if (err instanceof TemplateError && err.cause instanceof HttpError) {
httpError = err.cause;
} else {
httpError = new ServerError('Internal server error.', err);
httpError = new ServerError('Internal server error.', err instanceof Error ? err : undefined);
}
res.status(httpError.errorCode);
res.format({
html: () => {
res.render('errors/' + httpError.errorCode + '.njk', {
const locals = {
error_code: httpError.errorCode,
error_message: httpError.message,
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: () => {
@ -140,50 +212,111 @@ export default abstract class Application {
code: httpError.errorCode,
message: httpError.message,
instructions: httpError.instructions,
error_id: errorID,
error_id: errorId,
});
},
default: () => {
res.type('txt').send(`${httpError.errorCode} - ${httpError.message}\n\n${httpError.instructions}\n\nError ID: ${errorID}`);
}
res.type('txt').send(`${httpError.errorCode} - ${httpError.message}\n\n${httpError.instructions}\n\nError ID: ${errorId}`);
},
});
});
// Start components
for (const component of this.components) {
await component.start(app);
}
// Components routes
for (const component of this.components) {
await component.init(initRouter);
await component.handle(handleRouter);
if (component.initRoutes) {
component.setCurrentRouter(initRouter);
await component.initRoutes(initRouter);
}
if (component.handleRoutes) {
component.setCurrentRouter(handleRouter);
await component.handleRoutes(handleRouter);
}
component.setCurrentRouter(null);
}
// Start components
for (const component of this.components) {
await component.start?.(app);
}
// Routes
this.routes(initRouter, handleRouter);
this.ready = true;
this.started = true;
this.busy = false;
}
protected async processCommandLine(): Promise<boolean> {
const args = process.argv;
// Flags
const flags = {
verbose: false,
fullHttpRequests: false,
watch: false,
};
let mainCommand: string | null = null;
const mainCommandArgs: string[] = [];
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--verbose':
Logger.verbose();
flags.verbose = true;
break;
case '--full-http-requests':
LogRequestsComponent.logFullHttpRequests();
flags.fullHttpRequests = true;
break;
case '--watch':
flags.watch = true;
break;
case 'migration':
await MysqlConnectionManager.migrationCommand(args.slice(i + 1));
return true;
case 'pre-compile-views':
if (mainCommand === null) mainCommand = args[i];
else throw new Error(`Only one main command can be used at once (${mainCommand},${args[i]})`);
break;
default:
Logger.warn('Unrecognized argument', args[i]);
return true;
if (mainCommand) {
mainCommandArgs.push(args[i]);
} else {
logger.fatal('Unrecognized argument', args[i]);
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;
}
@ -203,26 +336,31 @@ export default abstract class Application {
// Check security fields
for (const component of this.components) {
await component.checkSecuritySettings();
await component.checkSecuritySettings?.();
}
}
async stop(): Promise<void> {
Logger.info('Stopping application...');
public async stop(): Promise<void> {
if (this.started && !this.busy) {
this.busy = true;
logger.info('Stopping application...');
for (const component of this.components) {
await component.stop();
for (const component of this.components) {
await component.stop?.();
}
logger.info(`${this.constructor.name} stopped properly.`);
this.started = false;
this.busy = false;
}
Logger.info(`${this.constructor.name} v${this.version} - bye`);
}
private routes(initRouter: Router, handleRouter: Router) {
for (const controller of this.controllers) {
if (controller.hasGlobalHandlers()) {
if (controller.hasGlobalMiddlewares()) {
controller.setupGlobalHandlers(handleRouter);
Logger.info(`Registered global middlewares for controller ${controller.constructor.name}`);
logger.info(`Registered global middlewares for controller ${controller.constructor.name}`);
}
}
@ -231,7 +369,7 @@ export default abstract class Application {
initRouter.use(controller.getRoutesPrefix(), fileUploadFormRouter);
handleRouter.use(controller.getRoutesPrefix(), mainRouter);
Logger.info(`> Registered routes for controller ${controller.constructor.name}`);
logger.info(`> Registered routes for controller ${controller.constructor.name} at ${controller.getRoutesPrefix()}`);
}
handleRouter.use((req: Request) => {
@ -239,6 +377,45 @@ export default abstract class Application {
});
}
public getWebSocketListeners(): { [p: string]: WebSocketListener<Application> } {
return this.webSocketListeners;
}
public getCache(): CacheProvider | null {
return this.cacheProvider || null;
}
public getComponents(): ApplicationComponent[] {
return [...this.components];
}
public as<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C {
const module = this.components.find(component => component.constructor === type) ||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
if (!module) throw new Error(`This app doesn't have a ${type.name} component.`);
return module as C;
}
public asOptional<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): C | null {
const module = this.components.find(component => component.constructor === type) ||
Object.values(this.webSocketListeners).find(listener => listener.constructor === type);
return module ? module as C : null;
}
public has<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): boolean {
return !!this.asOptional(type);
}
public require<C extends ApplicationComponent | WebSocketListener<Application>>(type: Type<C>): void {
if (!this.has(type)) {
throw new MissingComponentError(type);
}
}
public async isInNodeModules(): Promise<boolean> {
return await doesFileExist('node_modules/swaf');
}
public isReady(): boolean {
return this.ready;
}
@ -247,11 +424,7 @@ export default abstract class Application {
return this.version;
}
public getWebSocketListeners(): { [p: string]: WebSocketListener<any> } {
return this.webSocketListeners;
public getCoreVersion(): string {
return this.coreVersion;
}
public getCache(): CacheProvider | undefined {
return this.cacheProvider;
}
}
}

View File

@ -1,41 +1,30 @@
import {Express, Router} from "express";
import Logger from "./Logger";
import {sleep} from "./Utils";
import Application from "./Application";
import config from "config";
import SecurityError from "./SecurityError";
import {Express, Router} from "express";
export default abstract class ApplicationComponent<T> {
private val?: T;
protected app?: Application;
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";
public async checkSecuritySettings(): Promise<void> {
}
export default abstract class ApplicationComponent {
private currentRouter?: Router;
private app?: Application;
public async start(app: Express): Promise<void> {
}
public async checkSecuritySettings?(): Promise<void>;
public async init(router: Router): Promise<void> {
}
public async init?(): Promise<void>;
public async handle(router: Router): Promise<void> {
}
public async initRoutes?(router: Router): Promise<void>;
public async stop(): Promise<void> {
public async handleRoutes?(router: Router): Promise<void>;
}
public async start?(expressApp: Express): Promise<void>;
protected export(val: T) {
this.val = val;
}
public async stop?(): Promise<void>;
public import(): T {
if (!this.val) throw 'Cannot import if nothing was exported.';
return this.val;
}
public setApp(app: Application) {
this.app = app;
public isReady(): boolean {
return true;
}
protected async prepare(name: string, prepare: () => Promise<void>): Promise<void> {
@ -46,29 +35,55 @@ export default abstract class ApplicationComponent<T> {
err = null;
} catch (e) {
err = e;
Logger.error(err, `${name} failed to prepare; retrying in 5s...`)
logger.error(err, `${name} failed to prepare; retrying in 5s...`);
await sleep(5000);
}
} while (err);
Logger.info(`${name} ready!`);
logger.info(`${name} ready!`);
}
protected async close(thingName: string, thing: any, fn: Function): Promise<void> {
protected async close(thingName: string, fn: (callback: (err?: Error | null) => void) => void): Promise<void> {
try {
await new Promise((resolve, reject) => fn.call(thing, (err: any) => {
await new Promise<void>((resolve, reject) => fn((err?: Error | null) => {
if (err) reject(err);
else resolve();
}));
Logger.info(`${thingName} closed.`);
logger.info(`${thingName} closed.`);
} catch (e) {
Logger.error(e, `An error occurred while closing the ${thingName}.`);
logger.error(e, `An error occurred while closing the ${thingName}.`);
}
}
protected checkSecurityConfigField(field: string) {
protected checkSecurityConfigField(field: string): void {
if (!config.has(field) || config.get<string>(field) === 'default') {
throw new SecurityError(`${field} field not configured.`);
}
}
protected use<M extends Middleware>(middleware: MiddlewareType<M>): void {
if (!this.currentRouter) throw new Error('Cannot call this method outside init() and handle().');
const instance = new middleware(this.getApp());
this.currentRouter.use(async (req, res, next) => {
try {
await instance.getRequestHandler()(req, res, next);
} catch (e) {
next(e);
}
});
}
public setCurrentRouter(router: Router | null): void {
this.currentRouter = router || undefined;
}
protected getApp(): Application {
if (!this.app) throw new Error('app field not initialized.');
return this.app;
}
public setApp(app: Application): void {
this.app = app;
}
}

View File

@ -1,5 +1,5 @@
export default interface CacheProvider {
get(key: string, defaultValue?: string): Promise<string | null>;
get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T>;
has(key: string): Promise<boolean>;
@ -11,4 +11,4 @@ export default interface CacheProvider {
* @param ttl in ms
*/
remember(key: string, value: string, ttl: number): Promise<void>;
}
}

View File

@ -1,56 +1,28 @@
import express, {IRouter, RequestHandler, Router} from "express";
import {PathParams} from "express-serve-static-core";
import config from "config";
import Logger from "./Logger";
import Validator, {FileError, ValidationBag} from "./db/Validator";
import FileUploadMiddleware from "./FileUploadMiddleware";
import * as querystring from "querystring";
import {ParsedUrlQueryInput} from "querystring";
import Application from "./Application.js";
import {registerRoute} from "./common/Routing.js";
import FileUploadMiddleware from "./FileUploadMiddleware.js";
import {logger} from "./Logger.js";
import Middleware, {MiddlewareType} from "./Middleware.js";
export default abstract class Controller {
private static readonly routes: { [p: string]: string } = {};
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 in params) {
if (params.hasOwnProperty(key)) {
path = path.replace(new RegExp(`:${key}\\??`), params[key]);
}
}
}
const queryStr = querystring.stringify(query);
return `${absolute ? config.get<string>('base_url') : ''}${path}` + (queryStr.length > 0 ? '?' + queryStr : '');
}
private readonly router: Router = express.Router();
private readonly fileUploadFormRouter: Router = express.Router();
private app?: Application;
public getGlobalHandlers(): RequestHandler[] {
public getGlobalMiddlewares(): Middleware[] {
return [];
}
public hasGlobalHandlers(): boolean {
return this.getGlobalHandlers().length > 0;
public hasGlobalMiddlewares(): boolean {
return this.getGlobalMiddlewares().length > 0;
}
public setupGlobalHandlers(router: Router): void {
for (const globalHandler of this.getGlobalHandlers()) {
router.use(this.wrap(globalHandler));
for (const middleware of this.getGlobalMiddlewares()) {
router.use(this.wrap(middleware.getRequestHandler()));
}
}
@ -60,10 +32,7 @@ export default abstract class Controller {
public abstract routes(): void;
public setupRoutes(): {
mainRouter: Router,
fileUploadFormRouter: Router
} {
public setupRoutes(): { mainRouter: Router, fileUploadFormRouter: Router } {
this.routes();
return {
mainRouter: this.router,
@ -71,23 +40,57 @@ export default abstract class Controller {
};
}
protected use(handler: RequestHandler) {
this.router.use(handler);
protected use(handler: RequestHandler): void {
this.router.use(this.wrap(handler));
logger.info('Installed anonymous middleware on ' + this.getRoutesPrefix());
}
protected get(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (RequestHandler | FileUploadMiddleware)[]) {
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(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('get', path, handler, routeName, ...middlewares);
}
protected post(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (RequestHandler | FileUploadMiddleware)[]) {
protected post(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('post', path, handler, routeName, ...middlewares);
}
protected put(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (RequestHandler | FileUploadMiddleware)[]) {
protected put(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('put', path, handler, routeName, ...middlewares);
}
protected delete(path: PathParams, handler: RequestHandler, routeName?: string, ...middlewares: (RequestHandler | FileUploadMiddleware)[]) {
protected delete(
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.handle('delete', path, handler, routeName, ...middlewares);
}
@ -96,14 +99,15 @@ export default abstract class Controller {
path: PathParams,
handler: RequestHandler,
routeName?: string,
...middlewares: (RequestHandler | FileUploadMiddleware)[]
...middlewares: (MiddlewareType<Middleware>)[]
): void {
this.registerRoutes(path, handler, routeName);
for (const middleware of middlewares) {
if (middleware instanceof FileUploadMiddleware) {
this.fileUploadFormRouter[action](path, this.wrap(FILE_UPLOAD_MIDDLEWARE(middleware)));
const instance = new middleware(this.getApp());
if (instance instanceof FileUploadMiddleware) {
this.fileUploadFormRouter[action](path, this.wrap(instance.getRequestHandler()));
} else {
this.router[action](path, this.wrap(middleware));
this.router[action](path, this.wrap(instance.getRequestHandler()));
}
}
this.router[action](path, this.wrap(handler));
@ -137,60 +141,24 @@ export default abstract class Controller {
routePath = (prefix !== '/' ? prefix : '') + path;
}
if (!Controller.routes[routeName]) {
if (typeof routePath === 'string') {
Logger.info(`Route ${routeName} has path ${routePath}`);
Controller.routes[routeName] = routePath;
} else {
Logger.warn(`Cannot assign path to route ${routeName}.`);
}
}
}
protected async validate(validationMap: { [p: string]: Validator<any> }, body: any): Promise<void> {
const bag = new ValidationBag();
for (const p in validationMap) {
if (validationMap.hasOwnProperty(p)) {
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;
}
}
export type RouteParams = { [p: string]: string } | string[] | string | number;
const FILE_UPLOAD_MIDDLEWARE: (fileUploadMiddleware: FileUploadMiddleware) => RequestHandler = (fileUploadMiddleware: FileUploadMiddleware) => {
return async (req, res, next) => {
const form = fileUploadMiddleware.formFactory();
try {
await new Promise<any>((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
return;
}
req.body = fields;
req.files = files;
resolve();
});
});
} catch (e) {
const bag = new ValidationBag();
const fileError = new FileError(e);
fileError.thingName = fileUploadMiddleware.defaultField;
bag.addMessage(fileError);
next(bag);
if (typeof routePath !== 'string') {
logger.warn(`Cannot assign path to route ${routeName}.`);
return;
}
next();
};
};
if (registerRoute(routeName, routePath)) {
logger.info(`Route ${routeName} has path ${routePath}`);
} else {
logger.warn(`Couldn't register ${routeName} for path ${routePath}`);
}
}
protected getApp(): Application {
if (!this.app) throw new Error('Application not initialized.');
return this.app;
}
public setApp(app: Application): void {
this.app = app;
}
}

20
src/Extendable.ts Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
import {WrappingError} from "./Utils";
import {WrappingError} from "./Utils.js";
export abstract class HttpError extends WrappingError {
public readonly instructions: string;
@ -8,97 +8,93 @@ export abstract class HttpError extends WrappingError {
this.instructions = instructions;
}
get name(): string {
public get name(): string {
return this.constructor.name;
}
abstract get errorCode(): number;
public abstract get errorCode(): number;
}
export class BadRequestError extends HttpError {
public readonly url: string;
constructor(message: string, instructions: string, url: string, cause?: Error) {
public constructor(message: string, instructions: string, url: string, cause?: Error) {
super(message, instructions, cause);
this.url = url;
}
get errorCode(): number {
public get errorCode(): number {
return 400;
}
}
export class UnauthorizedHttpError extends BadRequestError {
constructor(message: string, url: string, cause?: Error) {
public constructor(message: string, url: string, cause?: Error) {
super(message, '', url, cause);
}
get errorCode(): number {
public get errorCode(): number {
return 401;
}
}
export class ForbiddenHttpError extends BadRequestError {
constructor(thing: string, url: string, cause?: Error) {
public constructor(thing: string, url: string, cause?: Error) {
super(
`You don't have access to this ${thing}.`,
`${url} doesn't belong to *you*.`,
url,
cause
cause,
);
}
get errorCode(): number {
public get errorCode(): number {
return 403;
}
}
export class NotFoundHttpError extends BadRequestError {
constructor(thing: string, url: string, cause?: Error) {
public constructor(thing: string, url: string, cause?: Error) {
super(
`${thing.charAt(0).toUpperCase()}${thing.substr(1)} not found.`,
`${url} doesn't exist or was deleted.`,
url,
cause
cause,
);
}
get errorCode(): number {
public get errorCode(): number {
return 404;
}
}
export class TooManyRequestsHttpError extends BadRequestError {
constructor(retryIn: number, cause?: Error) {
public constructor(retryIn: number, jailName: string, cause?: Error) {
super(
`You're making too many requests!`,
`We need some rest. Please retry in ${Math.floor(retryIn / 1000)} seconds.`,
'',
cause
jailName,
cause,
);
}
get errorCode(): number {
public get errorCode(): number {
return 429;
}
}
export class ServerError extends HttpError {
constructor(message: string, cause?: Error) {
public constructor(message: string, cause?: Error) {
super(message, `Maybe you should contact us; see instructions below.`, cause);
}
get errorCode(): number {
public get errorCode(): number {
return 500;
}
}
export class ServiceUnavailableHttpError extends ServerError {
constructor(message: string, cause?: Error) {
super(message, cause);
}
get errorCode(): number {
public get errorCode(): number {
return 503;
}
}
}

View File

@ -1,136 +1,40 @@
import config from "config";
import {v4 as uuid} from "uuid";
import Log from "./models/Log";
import {bufferToUUID} from "./Utils";
import {AsyncLocalStorage} from "async_hooks";
import {RequestHandler} from "express";
import {nanoid} from "nanoid";
import {Logger as TsLogger} from "tslog";
export default class Logger {
private static logLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('log_level');
private static dbLogLevel: LogLevelKeys = <LogLevelKeys>config.get<string>('db_log_level');
private static verboseMode: boolean = false;
const requestIdStorage: AsyncLocalStorage<string> = new AsyncLocalStorage();
public static verbose() {
this.verboseMode = true;
this.logLevel = <LogLevelKeys>LogLevel[LogLevel[this.logLevel] + 1] || this.logLevel;
this.dbLogLevel = <LogLevelKeys>LogLevel[LogLevel[this.dbLogLevel] + 1] || this.dbLogLevel;
Logger.info('Verbose mode');
}
export const logger = new TsLogger({
requestId: (): string => {
return requestIdStorage.getStore() as string;
},
delimiter: '\t',
maskValuesOfKeys: [
'Authorization',
'password',
'password_confirmation',
'secret',
],
displayFunctionName: false,
displayFilePath: 'hidden',
});
public static isVerboseMode(): boolean {
return this.verboseMode;
}
export const loggingContextMiddleware: RequestHandler = (req, res, next) => {
requestIdStorage.run(nanoid(8), () => {
next();
});
};
public static silentError(error: Error, ...message: any[]): string {
return this.log('ERROR', message, error, true) || '';
}
export const preventContextCorruptionMiddleware = (delegate: RequestHandler): RequestHandler => (
req,
res,
next,
) => {
const data = requestIdStorage.getStore() as string;
public static error(error: Error, ...message: any[]): string {
return this.log('ERROR', message, error) || '';
}
public static warn(...message: any[]) {
this.log('WARN', message);
}
public static info(...message: any[]) {
this.log('INFO', message);
}
public static debug(...message: any[]) {
this.log('DEBUG', message);
}
public static dev(...message: any[]) {
this.log('DEV', message);
}
private static log(level: LogLevelKeys, message: any[], error?: Error, silent: boolean = false): string | null {
const levelIndex = LogLevel[level];
if (levelIndex <= LogLevel[this.logLevel]) {
if (error) {
if (levelIndex > LogLevel.ERROR) this.warn(`Wrong log level ${level} with attached error.`);
} else {
if (levelIndex <= LogLevel.ERROR) this.warn(`No error attached with log level ${level}.`);
}
const computedMsg = message.map(v => {
if (typeof v === 'string') {
return v;
} else {
return JSON.stringify(v, (key: string, value: any) => {
if (value instanceof Object) {
if (value.type === 'Buffer') {
return `Buffer<${Buffer.from(value.data).toString('hex')}>`;
} else if (value !== v) {
return `[object Object]`;
}
}
if (typeof value === 'string' && value.length > 96) {
return value.substr(0, 96) + '...';
}
return value;
}, 4);
}
}).join(' ');
const shouldSaveToDB = levelIndex <= LogLevel[this.dbLogLevel];
let output = `[${level}] `;
const pad = output.length;
const logID = Buffer.alloc(16);
uuid({}, logID);
let strLogID = bufferToUUID(logID);
if (shouldSaveToDB) output += `${strLogID} - `;
output += computedMsg.replace(/\n/g, '\n' + ' '.repeat(pad));
switch (level) {
case "ERROR":
if (silent || !error) {
console.error(output);
} else {
console.error(output, error);
}
break;
case "WARN":
console.warn(output);
break;
case "INFO":
console.info(output);
break;
case "DEBUG":
case "DEV":
console.debug(output);
break;
}
if (shouldSaveToDB) {
const log = Log.create({});
log.setLevel(level);
log.message = computedMsg;
log.setError(error);
log.setLogID(logID);
log.save().catch(err => {
if (!silent && err.message.indexOf('ECONNREFUSED') < 0) {
console.error({save_err: err, error});
}
});
}
return strLogID;
}
return null;
}
private constructor() {
}
}
export enum LogLevel {
ERROR,
WARN,
INFO,
DEBUG,
DEV,
}
export type LogLevelKeys = keyof typeof LogLevel;
delegate(req, res, (err?: unknown | 'router') => {
requestIdStorage.enterWith(data);
next(err);
});
};

View File

@ -1,140 +0,0 @@
import nodemailer, {SentMessageInfo, Transporter} from "nodemailer";
import config from "config";
import {Options} from "nodemailer/lib/mailer";
import nunjucks from 'nunjucks';
import * as util from "util";
import {WrappingError} from "./Utils";
import mjml2html from "mjml";
import Logger from "./Logger";
import Controller from "./Controller";
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);
}
Logger.info(`Mail ready to be distributed via ${config.get('mail.host')}:${config.get('mail.port')}`);
}
public static end() {
if (this.transporter) this.transporter.close();
}
public static parse(template: string, data: any, textOnly: boolean): string {
data.text = textOnly;
const nunjucksResult = nunjucks.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 template: MailTemplate;
private readonly options: Options = {};
private readonly data: { [p: string]: any };
constructor(template: MailTemplate, data: { [p: string]: any } = {}) {
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
Logger.dev('Send mail', this.options);
// Render email
this.options.html = Mail.parse('mails/' + this.template.template + '.mjml.njk', this.data, false);
this.options.text = Mail.parse('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: any) => string;
constructor(template: string, subject: (data: any) => string) {
this._template = template;
this.subject = subject;
}
public get template(): string {
return this._template;
}
public getSubject(data: any): string {
return `${config.get('app.name')} - ${this.subject(data)}`;
}
}
class MailError extends WrappingError {
constructor(message: string = 'An error occurred while sending mail.', cause?: Error) {
super(message, cause);
}
}

View File

@ -1,17 +1,30 @@
import config from "config";
import {MailTemplate} from "./Mail";
import MailTemplate from "./mail/MailTemplate.js";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',
data => data.type === 'register' ? 'Registration' : 'Login magic link'
data => data.type === 'register' ?
'Registration' :
'Login magic link',
);
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'account_review_notice',
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`,
);
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'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.`,
);

34
src/Middleware.ts Normal file
View File

@ -0,0 +1,34 @@
import {RequestHandler} from "express";
import {NextFunction, Request, Response} from "express-serve-static-core";
import Application from "./Application.js";
import {Type} from "./Utils.js";
export default abstract class Middleware {
public constructor(
protected readonly app: Application,
) {
}
protected abstract handle(req: Request, res: Response, next: NextFunction): Promise<void>;
public getRequestHandler(): RequestHandler {
return async (req, res, next): Promise<void> => {
try {
if (req.middlewares.find(m => m.constructor === this.constructor)) {
next();
} else {
req.middlewares.push(this);
return await this.handle(req, res, next);
}
} catch (e) {
next(e);
}
};
}
}
export interface MiddlewareType<M extends Middleware> extends Type<M> {
new(app: Application): M;
}

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

@ -5,4 +5,4 @@ export default class SecurityError implements Error {
public constructor(message: string) {
this.message = message;
}
}
}

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

171
src/TestApp.ts Normal file
View File

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

View File

@ -1,7 +1,8 @@
import {TooManyRequestsHttpError} from "./HttpError";
import {TooManyRequestsHttpError} from "./HttpError.js";
import {logger} from "./Logger.js";
export default class Throttler {
private static readonly throttles: { [throttleName: string]: Throttle } = {};
private static readonly throttles: Record<string, Throttle | undefined> = {};
/**
* Throttle function; will throw a TooManyRequestsHttpError when the threshold is reached.
@ -16,30 +17,39 @@ export default class Throttler {
* @param holdPeriod time in ms after each call before the threshold begins to decrease.
* @param jailPeriod time in ms for which the throttle will throw when it is triggered.
*/
public static throttle(action: string, max: number, resetPeriod: number, id: string, holdPeriod: number = 100, jailPeriod: number = 30 * 1000) {
public static throttle(
action: string,
max: number,
resetPeriod: number,
id: string,
holdPeriod: number = 100,
jailPeriod: number = 30 * 1000,
): void {
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);
}
private constructor() {
// Disable constructor
}
}
class Throttle {
private readonly jailName: string;
private readonly max: number;
private readonly resetPeriod: number;
private readonly holdPeriod: number;
private readonly jailPeriod: number;
private readonly triggers: {
[id: string]: {
count: number,
lastTrigger?: number,
jailed?: number;
}
} = {};
private readonly triggers: Record<string, {
count: number,
lastTrigger?: number,
jailed?: number;
} | undefined> = {};
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.resetPeriod = resetPeriod;
this.holdPeriod = holdPeriod;
@ -51,12 +61,10 @@ class Throttle {
let trigger = this.triggers[id];
if (!trigger) trigger = this.triggers[id] = {count: 0};
let currentDate = new Date().getTime();
const currentDate = new Date().getTime();
if (trigger.jailed && currentDate - trigger.jailed < this.jailPeriod) {
this.throw((trigger.jailed + this.jailPeriod) - currentDate);
return;
}
if (trigger.jailed && currentDate - trigger.jailed < this.jailPeriod)
return this.throw(trigger.jailed + this.jailPeriod - currentDate);
if (trigger.lastTrigger) {
let timeDiff = currentDate - trigger.lastTrigger;
@ -71,12 +79,15 @@ class Throttle {
if (trigger.count > this.max) {
trigger.jailed = currentDate;
this.throw((trigger.jailed + this.jailPeriod) - currentDate);
return;
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) {
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> {
return await new Promise(resolve => {
@ -18,25 +19,14 @@ export abstract class WrappingError extends Error {
}
}
get name(): string {
public get name(): string {
return this.constructor.name;
}
}
export function cryptoRandomDictionary(size: number, dictionary: string): string {
const randomBytes = crypto.randomBytes(size);
const output = new Array(size);
export type Type<T> = { new(...args: never[]): T };
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: any[]): T };
export function bufferToUUID(buffer: Buffer): string {
export function bufferToUuid(buffer: Buffer): string {
const chars = buffer.toString('hex');
let out = '';
let i = 0;
@ -48,12 +38,44 @@ export function bufferToUUID(buffer: Buffer): string {
return out;
}
export function getMethods<T>(obj: T): (string)[] {
let properties = new Set()
let currentObj = obj
export function getMethods<T extends { [p: string]: unknown }>(obj: T): string[] {
const properties = new Set<string>();
let currentObj: T | unknown = obj;
do {
Object.getOwnPropertyNames(currentObj).map(item => properties.add(item))
} while ((currentObj = Object.getPrototypeOf(currentObj)))
// @ts-ignore
return [...properties.keys()].filter(item => typeof obj[item] === 'function')
Object.getOwnPropertyNames(currentObj).map(item => properties.add(item));
currentObj = Object.getPrototypeOf(currentObj);
} while (currentObj);
return [...properties.keys()].filter(item => typeof obj[item] === 'function');
}
export async function listFilesRecursively(dir: string): Promise<string[]> {
const localFiles = await afs.readdir(dir);
const files: string[] = [];
for (const file of localFiles.map(file => path.join(dir, file))) {
const stat = await afs.stat(file);
if (stat.isDirectory()) {
files.push(...await listFilesRecursively(file));
} else {
files.push(file);
}
}
return files;
}
export async function doesFileExist(file: string): Promise<boolean> {
return await new Promise<boolean>((resolve, reject) => {
fs.stat(file, err => {
if (err) {
if (err.code === 'ENOENT') {
return resolve(false);
} else {
return reject(err);
}
} else {
return resolve(true);
}
});
});
}

View File

@ -1,6 +1,7 @@
import WebSocket from "ws";
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> {
private app!: T;
@ -15,5 +16,8 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract path(): string;
public abstract async handle(socket: WebSocket, request: IncomingMessage, session: Express.SessionData | null): Promise<void>;
}
public abstract handle(
socket: WebSocket,
request: IncomingMessage,
): 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 %}
<mj-section>
@ -38,4 +38,4 @@
If you believe that this is an error, please contact us via email.
{% endif %}
{% endblock %}
{% endblock %}

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 %}
<mj-section>
@ -63,4 +63,4 @@
Location: {{ geo }}
To authorize this log in, please follow this link: {{ link|safe }}
{% endif %}
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'mails/base_layout.mjml.njk' %}
{% extends 'mails/base_layout.mnjk' %}
{% block body %}
<mj-section>
@ -23,4 +23,4 @@
Username: {{ username }}
To review this account, please follow this link: {{ link|safe }}
{% endblock %}
{% endblock %}

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

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