264 Commits

Author SHA1 Message Date
michael 0a9dbbba9f Merge dev into master 2025-11-12 11:01:36 +00:00
root 81b7613c73 v0.5 Push 2025-11-12 10:48:25 +00:00
root fc778da204 DB-Skript Update 2025-11-06 19:32:50 +00:00
root d88edcd6ef Update 2025-11-06 11:07:57 +00:00
root 71ab82b592 Forgot Passaord 2025-11-05 19:46:57 +00:00
root 75c852625c Update Readme 2025-10-30 07:01:46 +00:00
root 3140a024e6 Update Readme 2025-10-30 07:01:06 +00:00
root ae3a3f93e8 y 2025-10-29 15:47:07 +00:00
root 36a6e2b66c User, Groups, Logs Update 2025-10-29 15:38:25 +00:00
root 4eea4b23bd Update 2025-10-28 16:07:38 +00:00
root e96cd63f33 Update 2025-10-28 14:09:42 +00:00
root d65585b821 User/Usergroup Pages first draft 2025-10-27 19:36:44 +00:00
root a76567969a Update 2025-10-27 14:14:57 +00:00
root f0b1ca4b68 Superuser 2025-10-24 18:39:32 +00:00
root fa19dbe504 Update 2025-10-23 06:58:14 +00:00
root 29d40c6f10 Merge dev into master 2025-10-23 06:52:28 +00:00
root 9133928401 Minor Fix 2025-10-23 06:52:10 +00:00
root 5e40b3105c Update 2025-10-22 19:38:42 +00:00
root 354e2844d1 Update 2025-10-22 19:35:27 +00:00
root 597d7ffb6f Release 0.4 2025-10-22 19:24:47 +00:00
root 7072a21ed8 Readme Update 2025-10-22 19:22:13 +00:00
root 754b023435 Update 2025-10-22 19:13:26 +00:00
root ac57b932c2 Update 2025-10-22 14:58:09 +00:00
root e607310689 Final Commit 2025-10-22 13:27:22 +00:00
root 1f8906bc3d Frontend Renewal 2025-10-22 13:13:48 +00:00
root d6ddf12c04 Update 2025-10-22 11:33:12 +00:00
root 994263dc45 Update 2025-10-21 19:32:17 +00:00
root 23d46e7c40 Update 2025-10-21 19:31:45 +00:00
root d8e0bdc814 Update 2025-10-21 19:31:04 +00:00
root f45a2525e6 Update 2025-10-21 16:16:25 +00:00
root 5852fc6616 Update 2025-10-21 10:55:28 +00:00
root aaf9afdc9f Update 2025-10-21 07:05:22 +00:00
root 98a6df5081 Update 2025-10-21 06:30:47 +00:00
root 2c3379026b y 2025-10-20 19:18:35 +00:00
root 84be8e5828 Update 2025-10-20 18:18:04 +00:00
root 1a5de83d50 Update 2025-10-20 12:29:34 +00:00
root 476b09499a Update 2025-10-20 07:51:36 +00:00
root 974462e983 Update 2025-10-16 20:00:39 +00:00
root 749f04c2c4 Update 2025-10-16 10:30:27 +00:00
root 7de4e85a38 Update 2025-10-16 10:11:52 +00:00
root 4266adc853 Logs Pagination 2025-10-16 06:51:09 +00:00
root f7be617e1d Update 2025-10-15 19:31:09 +00:00
root 4dd2ba9d9d Update Preos 2025-10-15 18:55:45 +00:00
root f8209c3712 New Frontend Preps 2025-10-15 14:57:39 +00:00
root d776b80fe0 Update 2025-10-15 14:45:04 +00:00
root dd89d8388e Update 2025-10-13 19:23:56 +00:00
root f5ec13701b Update 2025-10-13 19:23:15 +00:00
root bbdbf0176e Update 2025-10-13 19:22:15 +00:00
root 2fc90639f7 Update 2025-10-13 19:21:43 +00:00
root 5cf3d8fe70 Update Readme 2025-10-13 19:21:05 +00:00
root e3d802eeef Final Push 2025-10-13 18:44:15 +00:00
root ca59231480 Update 2025-10-12 20:42:37 +00:00
root 1d0ece6940 Update 2025-10-12 20:39:48 +00:00
root 5fcc7fdbf1 Update Dockerfile 2025-10-12 13:54:10 +00:00
root 3b12557a89 Update Dockerfile 2025-10-12 13:50:44 +00:00
root 666e4e67ad Update Dockerfile 2025-10-12 13:49:17 +00:00
root 11f4759e78 Merge feature/v03-notifications into dev 2025-10-12 13:33:50 +00:00
root de4a05a6e6 Update 2025-10-12 13:33:28 +00:00
root b682f3378c Merge feature/v03-notifications into dev 2025-10-11 19:08:46 +00:00
root 56891182d5 Portainer Update 2025-10-11 18:53:51 +00:00
root d25d34b8cd Portainer SSH Connection 2025-10-10 20:10:42 +00:00
root 016cb6671a Take all changes from b2 into b1 2025-10-10 10:36:42 +00:00
root 9313c01637 Update 2025-10-10 10:19:53 +00:00
root 96b1446292 Maintenance Page + Portainer Update 2025-10-10 10:18:35 +00:00
root 14b1459767 Update 2025-10-10 10:13:34 +00:00
root e2cc586584 Meine Änderungen 2025-10-10 09:36:07 +00:00
root c71025d717 Merge feature/v03-double_stack_remove into dev 2025-10-09 18:48:24 +00:00
root e8aeecec43 Maintenance Page / Double Stack Remove 2025-10-09 18:47:54 +00:00
root 4e63ae529e Maintenance Page and Doube Stack Find 2025-10-09 18:45:44 +00:00
root a8c01b9cb2 Merge feature/v03-notifications into dev 2025-10-09 18:21:58 +00:00
root b6579f511d Toast Notifications 2025-10-09 10:30:26 +00:00
root db46b366ab Sync README from dev 2025-10-09 08:57:36 +00:00
root efa732f347 Update 2025-10-09 08:57:13 +00:00
root a09a68ed39 Sync README from dev 2025-10-09 08:53:37 +00:00
root 81f7660768 Update 2025-10-09 08:52:55 +00:00
root a53a91caeb Update Readme 2025-10-09 08:52:14 +00:00
root 841a1713d2 Merge feature/v03-stack_filters into dev 2025-10-09 08:32:08 +00:00
root 737495d186 Minor frontend changes 2025-10-08 19:06:19 +00:00
root 6ed9b94d3a Stack Filter Begin 2025-10-08 15:03:42 +00:00
root 95336e4350 Merge feature/v03-stack_filters into dev 2025-10-02 19:15:07 +00:00
root 8a2b4a2ac5 Update 2025-10-02 19:14:48 +00:00
root cf0b51eb7d Merge feature/v03-stack_filters into dev 2025-10-02 19:13:40 +00:00
root 3875d275e0 Update Readme 2025-10-02 19:13:17 +00:00
root 3f68c6e39a Merge feature/v03-stack_filters into dev 2025-10-02 18:56:57 +00:00
root 53f1b55157 Update Readme 2025-10-02 18:56:30 +00:00
root dc9b92bdff Merge dev into master 2025-10-01 19:22:08 +00:00
root 6e7ec194af Merge feature/Logging into dev 2025-10-01 19:21:31 +00:00
root 3003084112 Update 2025-10-01 19:21:13 +00:00
root 4592daff36 Update 2025-10-01 19:20:49 +00:00
root 3c16c4d8e4 Update 2025-10-01 19:20:24 +00:00
root 959af0cdd4 Update 2025-10-01 19:17:46 +00:00
root 777ea4d74f Update 2025-10-01 19:17:29 +00:00
root a9228a2557 Update 2025-10-01 19:16:55 +00:00
root 2b745e3d78 Update 2025-10-01 19:16:34 +00:00
root eada55b8d7 Update 2025-10-01 19:15:29 +00:00
root b1f5ac03f6 Update 2025-10-01 19:10:10 +00:00
root 2c78ab966d Update 2025-10-01 19:08:53 +00:00
root cd003a12f8 Merge dev into master 2025-10-01 19:05:08 +00:00
root 752f8170ba Merge feature/Logging into dev 2025-10-01 19:04:39 +00:00
root be1890582c Update 2025-10-01 19:04:07 +00:00
root b74d49a836 Merge dev into master 2025-10-01 18:57:36 +00:00
root cba236b95f Merge feature/Logging into dev 2025-10-01 18:57:00 +00:00
root 040cd4d3cb Update 2025-10-01 18:56:20 +00:00
root 3649d2ee04 Update 2025-10-01 18:55:15 +00:00
root 0a6d17c64e Update 2025-10-01 18:53:33 +00:00
root 7a69dd0821 Update 2025-10-01 18:52:09 +00:00
root 4053db4a3e Merge dev into master 2025-10-01 11:47:17 +00:00
root 35d64d6765 Merge feature/Logging into dev 2025-10-01 11:46:24 +00:00
root 6000641e50 Update 2025-10-01 11:44:53 +00:00
root d349d31d02 Merge dev into master 2025-10-01 11:39:43 +00:00
root 4241bb3114 Merge feature/Logging into dev 2025-10-01 11:35:36 +00:00
root 08c37245e1 Update Readme 2025-10-01 11:35:05 +00:00
root ef33a0d669 Merge feature/Logging into dev 2025-10-01 11:32:33 +00:00
root ec2d998bc5 Update 2025-10-01 10:40:52 +00:00
root 7cb1201b51 Merge feature/Logging into dev 2025-10-01 08:53:27 +00:00
root 8fa5ffc49c Fix 2025-10-01 08:53:09 +00:00
root e10d0dec1c Merge feature/Logging into dev 2025-09-30 21:03:13 +00:00
root 5483ee9092 Update 2025-09-30 21:02:51 +00:00
root 02fe83d65c Merge feature/Logging into dev 2025-09-30 21:01:05 +00:00
root d99ffc1488 Logging fixes, redeploy selection, Logo 2025-09-30 20:58:11 +00:00
root 2b051d5085 Merge feature/Logging into dev 2025-09-30 09:20:15 +00:00
root f46ba1c820 Merge dev into master 2025-09-30 09:19:45 +00:00
root f14c3aa6dd Update Readme 2025-09-30 09:19:20 +00:00
root 044164a440 Database/LoggingExtended 2025-09-30 09:15:18 +00:00
root c37b046e39 Merge feature/Logging into dev 2025-09-30 09:08:57 +00:00
root cc7d80e569 Delete/Export/Pageination 2025-09-30 09:08:36 +00:00
root deb05814e4 Merge feature/Logging into dev 2025-09-30 08:42:24 +00:00
root 2b284195fb Update 2025-09-30 08:42:05 +00:00
root 24c98aff3e Merge feature/Logging into dev 2025-09-30 08:38:28 +00:00
root b33e9e587b Update Dockerbuild 2025-09-30 08:38:05 +00:00
root b1fdd074ec Merge feature/Logging into dev 2025-09-30 08:22:07 +00:00
root 3a961270d7 Update Reame 2025-09-30 07:09:00 +00:00
root 0a65f3e60f Logging Filter 2025-09-30 07:02:59 +00:00
root 38b96da4e7 db write 2025-09-29 19:18:12 +00:00
root 241a509a15 Log and Database 2025-09-29 19:04:26 +00:00
root 520e36fe85 Merge dev into master 2025-09-29 08:36:32 +00:00
root 1f248e3920 Merge feature/Logging into dev 2025-09-29 08:35:54 +00:00
root ba0905e2b9 Update 2025-09-29 08:19:15 +00:00
root 876acfa763 Update 2025-09-29 08:15:31 +00:00
root ccd7fffe5c Merge dev into master 2025-09-29 08:12:48 +00:00
root ee7ccd0a39 Merge feature/first-stack-display into dev 2025-09-29 08:12:18 +00:00
root 6687a86802 Update 2025-09-29 08:11:52 +00:00
root c7d63a28c4 Merge dev into master 2025-09-29 08:09:06 +00:00
root d91655aee9 Merge feature/first-stack-display into dev 2025-09-29 08:03:45 +00:00
root 8001a2be37 Update 2025-09-29 08:02:39 +00:00
root 7f241d352d Merge dev into master 2025-09-29 07:39:43 +00:00
root 32af9d2584 Merge feature/first-stack-display into dev 2025-09-29 07:39:13 +00:00
root 55a83267a6 Update 2025-09-29 07:38:42 +00:00
root dc56f0cbeb Update 2025-09-29 07:37:53 +00:00
root 66d975ae75 Update 2025-09-29 07:37:19 +00:00
root 571b185b9b Update 2025-09-29 07:36:14 +00:00
root 8c59fc5445 Update 2025-09-29 07:35:58 +00:00
root 6bf4f7aab4 Merge feature/first-stack-display into dev 2025-09-29 07:31:05 +00:00
root cd7933ebc7 Update 2025-09-29 07:30:23 +00:00
root 3b0bd3c632 Update 2025-09-29 07:26:32 +00:00
root 281aeb40a6 Update 2025-09-29 07:19:01 +00:00
root 429721d9da Update 2025-09-29 07:18:22 +00:00
root 47f632f8fc Update 2025-09-29 07:17:47 +00:00
root 2e7ebfe06d Update 2025-09-29 07:17:07 +00:00
root 03b3766a22 Update 2025-09-29 07:16:21 +00:00
root d1adcb1188 Update 2025-09-28 19:47:15 +00:00
root e38269e23e Merge dev into master 2025-09-28 19:45:37 +00:00
root 81f0e7eaf0 Merge feature/first-stack-display into dev 2025-09-28 19:44:37 +00:00
root 7980837eaf Update 2025-09-28 19:43:06 +00:00
root 31b506ceb8 Update 2025-09-28 19:42:04 +00:00
root 44e3754fcb Update 2025-09-28 19:35:18 +00:00
root 73d225cc79 Merge dev into master 2025-09-28 19:22:14 +00:00
root a1e20f188e Merge feature/first-stack-display into dev 2025-09-28 19:21:28 +00:00
root 421e6e47dd Update 2025-09-28 19:20:57 +00:00
root 79e420d3e3 Merge dev into master 2025-09-26 10:10:17 +00:00
root ee3b907cd6 Merge feature/first-stack-display into dev 2025-09-26 10:07:29 +00:00
root 960832af6b Update 2025-09-26 10:06:59 +00:00
root 3489ea55a2 Update 2025-09-26 09:28:34 +00:00
root ca148247cb Update 2025-09-25 11:48:52 +00:00
root bff6521658 Update 2025-09-25 11:47:18 +00:00
root ef8920cb26 Update 2025-09-25 11:38:11 +00:00
root 279ad3ffd1 Update 2025-09-25 11:34:16 +00:00
root dc7670abe9 Merge dev into master 2025-09-25 10:58:49 +00:00
root 15e2c5188d Merge feature/first-stack-display into dev 2025-09-25 10:58:10 +00:00
root 00429f7f9d Update 2025-09-25 10:57:14 +00:00
root be2bff5d4f Update 2025-09-25 10:35:55 +00:00
root adaef78a48 Update 2025-09-25 10:34:56 +00:00
root 0d70dda3c1 Update 2025-09-25 10:32:22 +00:00
root dff904c313 Update 2025-09-25 10:28:45 +00:00
Mboehmlaender 3ec22d0fe9 Delete scripts/docker-release.sh.bak 2025-09-25 12:19:30 +02:00
Mboehmlaender c67d9d45c9 Rename docker-release.sh to docker-release.sh.bak 2025-09-25 12:14:47 +02:00
root f0b8a44a40 Update 2025-09-25 10:13:54 +00:00
root 71aab3376f rabenNest7$ 2025-09-25 10:13:09 +00:00
Mboehmlaender 6988b45985 Delete docker-compose.dev.yml 2025-09-25 12:03:07 +02:00
root 22ddf3123e Update 2025-09-25 10:01:51 +00:00
root 1797d57264 Update 2025-09-25 10:00:27 +00:00
root 6e76fab66f Merge feature/first-stack-display into dev 2025-09-25 09:56:27 +00:00
root 8d1d04c1ae Update 2025-09-25 09:56:00 +00:00
root 5cf9853c7d Update 2025-09-25 09:55:09 +00:00
root 126daea89c Update 2025-09-25 09:51:18 +00:00
Mboehmlaender 1727e7536c Merge pull request #2 from Mboehmlaender/dev
Dev
2025-09-25 11:26:59 +02:00
Mboehmlaender cb205f09d2 Update index.js 2025-09-25 11:26:06 +02:00
root 8a015c509e Merge feature/first-stack-display into dev 2025-09-25 09:18:46 +00:00
root d49605001e Update 2025-09-25 09:18:19 +00:00
root 55a9c3327f Update 2025-09-25 08:20:39 +00:00
root 39057c33c9 Merge feature/first-stack-display into dev 2025-09-25 07:02:45 +00:00
root 6c702b971c Update 2025-09-25 07:00:39 +00:00
root fd9225f019 Merge feature/first-stack-display into dev 2025-09-25 06:48:22 +00:00
root 5c20088088 Update 2025-09-25 06:47:50 +00:00
root 9ecc7c9ee2 Update 2025-09-25 06:41:20 +00:00
root 9beb812bce Update 2025-09-25 06:39:14 +00:00
root 716cd13c53 Update 2025-09-25 06:37:44 +00:00
root 6cf882da14 Update 2025-09-24 15:46:21 +00:00
root d7c43567aa Update 2025-09-24 15:31:53 +00:00
root 51e022ae52 Update 2025-09-24 15:31:31 +00:00
root 2008ccc733 Update 2025-09-24 15:09:02 +00:00
root 1b9fa89405 Update 2025-09-24 15:05:45 +00:00
root e4b48d70f1 Update 2025-09-24 15:04:06 +00:00
root 2e93449b55 Update 2025-09-24 15:03:33 +00:00
root fc40306c3c Merge dev into master 2025-09-24 14:35:44 +00:00
root 74a43a6c89 Update 2025-09-24 14:35:20 +00:00
root c3e670d9b7 Update 2025-09-24 14:34:29 +00:00
root e48a91738e Merge dev into master 2025-09-24 14:13:19 +00:00
root a7f4ea7fea Update 2025-09-24 14:12:51 +00:00
root b257f6a679 Update 2025-09-24 14:12:17 +00:00
root 10fa988bed Update 2025-09-24 14:11:02 +00:00
root c4b93b5288 Update 2025-09-24 13:56:36 +00:00
root 9db8068206 Final commit 2025-09-24 13:25:25 +00:00
root 3d0914e763 Merge dev into master 2025-09-24 12:22:32 +00:00
root 2a12aae6be Update 2025-09-24 12:18:48 +00:00
root 80d5e47ac8 Update 2025-09-24 12:18:06 +00:00
root b05cd4f2ed Update 2025-09-24 12:13:38 +00:00
root 9d44d44b43 Update 2025-09-24 12:12:00 +00:00
root c44d7c7fca Update 2025-09-24 12:08:06 +00:00
root 97611f8ef4 Update 2025-09-24 10:41:53 +00:00
root 9dea25bbef Update 2025-09-24 10:06:07 +00:00
root 20a1a4c841 Update 2025-09-24 10:04:28 +00:00
root 16a932ec90 Update 2025-09-24 10:03:09 +00:00
root 6556fdde16 Update 2025-09-24 09:53:56 +00:00
root 2accfef465 Update 2025-09-24 09:53:01 +00:00
root dc4dc63ddd Update 2025-09-24 09:48:41 +00:00
root 430893d842 Update 2025-09-24 09:46:01 +00:00
root 1eb5a2f863 Update 2025-09-24 09:30:40 +00:00
root 2a18c46a7d Update 2025-09-24 09:19:16 +00:00
root cab8139815 Update 2025-09-24 09:18:55 +00:00
root 3eb5e08d7c Merge dev into master 2025-09-24 08:48:09 +00:00
root c03e30a118 Merge feature/first-stack-display into dev 2025-09-24 08:43:37 +00:00
root a9f9fecb26 Update 2025-09-24 08:43:09 +00:00
root 655b606541 Merge dev into master 2025-09-23 14:56:24 +00:00
root c8bc7f0f16 Merge feature/first-stack-display into dev 2025-09-23 14:55:57 +00:00
root b4d0772b76 Update 2025-09-23 14:55:36 +00:00
root feb50f95f9 Merge feature/first-stack-display into dev 2025-09-23 14:54:40 +00:00
root bec988773c Update 2025-09-23 14:53:08 +00:00
root 2ad16caf00 Merge feature/first-stack-display into dev 2025-09-23 14:52:18 +00:00
root 9b07879a98 Update 2025-09-23 14:51:52 +00:00
root 76e7412faa Set Version Tag 2025-09-23 14:43:57 +00:00
root 609d78feae Merge dev into master 2025-09-23 13:37:28 +00:00
root aa2d767dc7 Merge feature/first-stack-display into dev 2025-09-23 13:34:55 +00:00
root 90d359c14a Update Readme 2025-09-23 13:33:56 +00:00
root 282ac2d028 Design and Functionality 2025-09-23 13:32:09 +00:00
michael 6a6f6c6005 Test 2025-08-17 14:12:51 +00:00
michael b6f26c1143 Test 2025-08-17 14:10:56 +00:00
michael 63980fe4c4 Merge feature/first-stack-display into dev 2025-08-17 14:05:58 +00:00
michael 64c5a323b3 Redeploy-Funktion fertig 2025-08-17 13:59:34 +00:00
michael c3515ac714 Update der Ansicht 2025-08-17 07:19:32 +00:00
michael 028fee7794 Update der Ansicht 2025-08-17 07:17:53 +00:00
Mboehmlaender db72f561fe Add files via upload 2025-08-16 21:54:06 +02:00
michael 728fe710f0 Initial commit: Projektstruktur mit Backend & Frontend 2025-08-16 19:52:02 +00:00
michael 71f61b7bd2 Initial commit: Projektstruktur mit Backend & Frontend 2025-08-16 19:47:27 +00:00
161 changed files with 26098 additions and 21382 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
**/node_modules
npm-debug.log
.DS_Store
.git
.env
.vscode
.idea
frontend/build
frontend/dist
backend/.env
backend/node_modules
frontend/node_modules
backend/data/*.db*
+37
View File
@@ -0,0 +1,37 @@
node_modules
**/node_modules
npm-debug.log
.DS_Store
.git
.vscode
.idea
frontend/build
frontend/dist
backend/node_modules
backend/.env
frontend/node_modules
docker-compose.dev.yml
devscripts
backend/data/stackpulse.db
backend/data/stackpulse.db-shm
backend/data/stackpulse.db-wal
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
pnpm-lock.yaml
package-lock.json
yarn.lock
dist
dist-ssr
*.local
!.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
View File
-728
View File
@@ -1,728 +0,0 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/assets/images/favicon.png">
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.0">
<title>Stackpulse Dokumentation</title>
<link rel="stylesheet" href="/assets/stylesheets/main.618322db.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
<script>__md_scope=new URL("/",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
</head>
<body dir="ltr">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="/." title="Stackpulse Dokumentation" class="md-header__button md-logo" aria-label="Stackpulse Dokumentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
Stackpulse Dokumentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
</span>
</div>
</div>
</div>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
</label>
<nav class="md-search__options" aria-label="Search">
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</nav>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list" role="presentation"></ol>
</div>
</div>
</div>
</div>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="/." title="Stackpulse Dokumentation" class="md-nav__button md-logo" aria-label="Stackpulse Dokumentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
Stackpulse Dokumentation
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/." class="md-nav__link">
<span class="md-ellipsis">
Start
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2" >
<label class="md-nav__link" for="__nav_2" id="__nav_2_label" tabindex="0">
<span class="md-ellipsis">
Benutzerhandbuch
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_2_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_2">
<span class="md-nav__icon md-icon"></span>
Benutzerhandbuch
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/user-guide/" class="md-nav__link">
<span class="md-ellipsis">
Überblick
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/setup/" class="md-nav__link">
<span class="md-ellipsis">
Setup
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/stacks/" class="md-nav__link">
<span class="md-ellipsis">
Stacks
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/logs/" class="md-nav__link">
<span class="md-ellipsis">
Logs
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/maintenance/" class="md-nav__link">
<span class="md-ellipsis">
Wartung
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/users/" class="md-nav__link">
<span class="md-ellipsis">
Benutzer
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/user-guide/groups/" class="md-nav__link">
<span class="md-ellipsis">
Benutzergruppen & Rechte
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_3" >
<label class="md-nav__link" for="__nav_3" id="__nav_3_label" tabindex="0">
<span class="md-ellipsis">
Technik
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_3_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_3">
<span class="md-nav__icon md-icon"></span>
Technik
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/getting-started/" class="md-nav__link">
<span class="md-ellipsis">
Getting Started
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/architecture/" class="md-nav__link">
<span class="md-ellipsis">
Architektur
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/backend/" class="md-nav__link">
<span class="md-ellipsis">
Backend & API
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/frontend/" class="md-nav__link">
<span class="md-ellipsis">
Frontend
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/operations/" class="md-nav__link">
<span class="md-ellipsis">
Operations & Wartung
</span>
</a>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1>404 - Not found</h1>
</article>
</div>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
Made with
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
Material for MkDocs
</a>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"annotate": null, "base": "/", "features": [], "search": "/assets/javascripts/workers/search.7a47a382.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script src="/assets/javascripts/bundle.e71a0d61.min.js"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
# ===============================
# Stage 1: Frontend Build
# ===============================
FROM node:20-alpine AS frontend-build
# Arbeitsverzeichnis
WORKDIR /app/frontend
# Nur package.json & package-lock.json kopieren und Dependencies installieren
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Restliche Frontend-Dateien kopieren
COPY frontend/ ./
# Frontend Build erzeugen (statische Dateien)
RUN npm run build
# ===============================
# Stage 2: Backend + Frontend
# ===============================
FROM node:20-alpine AS runtime
#Install SSH-Client
RUN apk add --no-cache openssh-client
# Arbeitsverzeichnis
WORKDIR /app/backend
# Backend Dependencies installieren
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --only=production
# Backend-Code kopieren
COPY backend/ ./
COPY backend/docker-entrypoint.sh ./docker-entrypoint.sh
# public leeren
RUN rm -rf ./public/*
# frische Datenbank vorbereiten
RUN rm -rf ./data && mkdir -p ./data && chown node:node ./data
# EntryPoint vorbereiten
RUN chmod +x ./docker-entrypoint.sh && chown node:node ./docker-entrypoint.sh
# Inhalt von dist inklusive Unterordner direkt nach public kopieren
COPY --from=frontend-build /app/frontend/dist/. ./public/
# Environment
ENV NODE_ENV=production
# Ports
# Backend intern: 4001
# Frontend exposed: 5173
EXPOSE 5173
# Node User
USER node
# Container startet das Backend (liefert statisches Frontend)
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "index.js"]
+202
View File
@@ -0,0 +1,202 @@
![StackPulse Logo](assets/images/stackpulse.png)
# 📦 StackPulse ![Release](https://img.shields.io/badge/release-v0.5-blue.svg)
**StackPulse** ist eine kleine Web-App, die über die Portainer-API deine Docker-Stacks verwaltet und aktualisiert.
Aktuell funktioniert StackPulse nur mit der Business-Edition von Portainer. Die Communitiy-Edition wird in einem späteren Release implementiert!
Sie besteht aus einem **Backend (Node.js/Express)** und einem **Frontend (React/Tailwind)**.
Ziel:
- Übersicht über alle Stacks in deiner Portainer-Instanz
- Später: Updates, Deployments und Monitoring
- Bereitstellung als **Docker Image**, nutzbar über **docker-compose**
---
## 🚀 Features & Roadmap
<details>
<summary>✅ v0.1 Initial Release</summary>
- Projektstruktur mit Frontend & Backend
- Lokales Startskript (`scripts/start-dev.sh`)
- Frontend zeigt Stacks an (über Backend)
- API-Verbindung zu Portainer
- Stack Redeploy
- Bereitstellung eines Docker Images über GHCR
</details>
<details>
<summary>✅ v0.2 Release</summary>
### Backend
- [x] Anbindung einer SQLite-Datenbank
- [x] Logging der Redeploy-Aktionen in SQLite speichern
- [x] API-Endpunkte für Log-Abfragen
- [x] Funktionen für Pagination, Löschen und Export
### Frontend
- [x] Anzeige der Logs (inkl. Statusfarben)
- [x] UI-Komponenten für Log-Details
- [x] Filterfunktionen für die Logs
- [x] Pagination, Lösch- und Exportanzeigen
### Features
- [x] Selektive Auswahl: einzelne Stacks oder Services neu deployen
</details>
<details>
<summary>✅ v0.3 Release</summary>
### Backend
- [x] Automatische Datenbereinigung: Duplikate bei Stack-IDs erkennen & entfernen
- [x] Erweiterung der Logs
- [x] API für Filter & Suche (Stacks nach Name/Status abrufen)
### Frontend
- [x] Filter: Stacks nach Name oder Status durchsuchen
- [x] Benachrichtigungen im UI: erfolgreicher/fehlgeschlagener Redeploy (Toast)
- [x] Visualisierung der Datenbereinigung (Konflikt/Auto-Fix Meldungen)
### Features
- [x] Frontend-Filter für schnellere Navigation bei vielen Stacks
- [x] Echtzeit-Feedback im UI (Notifications)
- [x] Datenkonsistenz sichern: keine doppelten Stack-IDs mehr
</details>
<details>
<summary>✅ v0.4 Release</summary>
### Backend
- keine Änderungen
### Frontend
- [x] GUI komplett überarbeitet
- [x] Umstellung auf Material Tailwind
- [x] Dashboard von Creative Tim als neues Frontend
### Features
- [x] Neue, übersichtliche Oberfläche im Material Design
- [x] leicht erweiterbar durch vorgefertigte Komponenten
- [x] kleine Bugfixes
</details>
<details open>
<summary>✅ v0.5 Release</summary>
### 🛠️ Backend
- [x] Neue API-Endpunkte für Logging, Benutzer- und Gruppenverwaltung sowie Superuser-Registrierung
- [x] Authentifizierung und Log-In
- [x] Erweiterte Protokollierung und Datenbank-Migration
- [x] Präzisere Steuerung der globalen Benutzerrechte
### 💻 Frontend
- [x] Neue Log-In-Seite
- [x] Benutzer- und Gruppenverwaltung mit granularen Rechteeinstellungen
- [x] Erweiterte Log-Ausgabe mit Filtern und Suchoptionen
- [x] Konsolidierte und vereinheitlichte Ansichten im Dashboard
### ✨ Features
- [x] Vollständiges Authentifizierungs- und Berechtigungssystem
- [x] Erweiterte Logs in allen Bereichen
- [x] Speicherung von Server- und Superuser-Einstellungen in der Datenbank
</details>
<details open>
<summary>🟡 v0.6 In Entwicklung</summary>
### Backend
### Frontend
### Features
</details>
<details open>
<summary>🔮 Geplante Features (v0.6+)</summary>
- Notifications (z. B. via Webhooks oder Mail)
- Monitoring (Status, CPU/RAM)
- Verbesserte UI/UX
- Erweiterte Filterungen udn Sortierungen
- Multi-Server Verwaltung
- Integration Community Edition
</details>
---
## 🗂️ Projektstruktur
```bash
stackpulse/
├── backend/ # Node.js Backend mit Express
│ └── data # SQlite Datenbank
│ └── db # Datenbank-Integration
├── frontend/ # React Frontend mit Tailwind
├── scripts/ # Lokale Hilfsskripte (nicht Teil des Images)
│ └── start-dev.sh #Skript für den lokalen Start
├── Dockerfile # Multi-Stage Build für Frontend + Backend
├── docker-compose.yml
└── README.md
```
---
## 🔧 Lokaler Start
### 1. Dev-Server starten (Frontend + Backend)
```bash
./scripts/start-dev.sh
```
➡️ Danach:
- Frontend → http://Deine-Server-IP:5173
---
## 🐳 Docker-Setup
### Mit Compose starten
```bash
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
```
## 📋 Voraussetzungen
- Node.js >= 20
- Docker & Docker Compose
- Zugang zu einer Portainer-Instanz (API-Key erforderlich)
---
## 🤝 Mitmachen
1. Repo forken
2. Feature-Branch erstellen
3. PR gegen `dev` öffnen
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Danish` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.da=function(){this.pipeline.reset(),this.pipeline.add(e.da.trimmer,e.da.stopWordFilter,e.da.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.da.stemmer))},e.da.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.da.trimmer=e.trimmerSupport.generateTrimmer(e.da.wordCharacters),e.Pipeline.registerFunction(e.da.trimmer,"trimmer-da"),e.da.stemmer=function(){var r=e.stemmerSupport.Among,i=e.stemmerSupport.SnowballProgram,n=new function(){function e(){var e,r=f.cursor+3;if(d=f.limit,0<=r&&r<=f.limit){for(a=r;;){if(e=f.cursor,f.in_grouping(w,97,248)){f.cursor=e;break}if(f.cursor=e,e>=f.limit)return;f.cursor++}for(;!f.out_grouping(w,97,248);){if(f.cursor>=f.limit)return;f.cursor++}d=f.cursor,d<a&&(d=a)}}function n(){var e,r;if(f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(c,32),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del();break;case 2:f.in_grouping_b(p,97,229)&&f.slice_del()}}function t(){var e,r=f.limit-f.cursor;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.find_among_b(l,4)?(f.bra=f.cursor,f.limit_backward=e,f.cursor=f.limit-r,f.cursor>f.limit_backward&&(f.cursor--,f.bra=f.cursor,f.slice_del())):f.limit_backward=e)}function s(){var e,r,i,n=f.limit-f.cursor;if(f.ket=f.cursor,f.eq_s_b(2,"st")&&(f.bra=f.cursor,f.eq_s_b(2,"ig")&&f.slice_del()),f.cursor=f.limit-n,f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(m,5),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del(),i=f.limit-f.cursor,t(),f.cursor=f.limit-i;break;case 2:f.slice_from("løs")}}function o(){var e;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.out_grouping_b(w,97,248)?(f.bra=f.cursor,u=f.slice_to(u),f.limit_backward=e,f.eq_v_b(u)&&f.slice_del()):f.limit_backward=e)}var a,d,u,c=[new r("hed",-1,1),new r("ethed",0,1),new r("ered",-1,1),new r("e",-1,1),new r("erede",3,1),new r("ende",3,1),new r("erende",5,1),new r("ene",3,1),new r("erne",3,1),new r("ere",3,1),new r("en",-1,1),new r("heden",10,1),new r("eren",10,1),new r("er",-1,1),new r("heder",13,1),new r("erer",13,1),new r("s",-1,2),new r("heds",16,1),new r("es",16,1),new r("endes",18,1),new r("erendes",19,1),new r("enes",18,1),new r("ernes",18,1),new r("eres",18,1),new r("ens",16,1),new r("hedens",24,1),new r("erens",24,1),new r("ers",16,1),new r("ets",16,1),new r("erets",28,1),new r("et",-1,1),new r("eret",30,1)],l=[new r("gd",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("elig",1,1),new r("els",-1,1),new r("løst",-1,2)],w=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],p=[239,254,42,3,0,0,0,0,0,0,0,0,0,0,0,0,16],f=new i;this.setCurrent=function(e){f.setCurrent(e)},this.getCurrent=function(){return f.getCurrent()},this.stem=function(){var r=f.cursor;return e(),f.limit_backward=r,f.cursor=f.limit,n(),f.cursor=f.limit,t(),f.cursor=f.limit,s(),f.cursor=f.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return n.setCurrent(e),n.stem(),n.getCurrent()}):(n.setCurrent(e),n.stem(),n.getCurrent())}}(),e.Pipeline.registerFunction(e.da.stemmer,"stemmer-da"),e.da.stopWordFilter=e.generateStopWordFilter("ad af alle alt anden at blev blive bliver da de dem den denne der deres det dette dig din disse dog du efter eller en end er et for fra ham han hans har havde have hende hendes her hos hun hvad hvis hvor i ikke ind jeg jer jo kunne man mange med meget men mig min mine mit mod ned noget nogle nu når og også om op os over på selv sig sin sine sit skal skulle som sådan thi til ud under var vi vil ville vor være været".split(" ")),e.Pipeline.registerFunction(e.da.stopWordFilter,"stopWordFilter-da")}});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hi=function(){this.pipeline.reset(),this.pipeline.add(e.hi.trimmer,e.hi.stopWordFilter,e.hi.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.hi.stemmer))},e.hi.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿa-zA-Z-zA-0-9-",e.hi.trimmer=e.trimmerSupport.generateTrimmer(e.hi.wordCharacters),e.Pipeline.registerFunction(e.hi.trimmer,"trimmer-hi"),e.hi.stopWordFilter=e.generateStopWordFilter("अत अपना अपनी अपने अभी अंदर आदि आप इत्यादि इन इनका इन्हीं इन्हें इन्हों इस इसका इसकी इसके इसमें इसी इसे उन उनका उनकी उनके उनको उन्हीं उन्हें उन्हों उस उसके उसी उसे एक एवं एस ऐसे और कई कर करता करते करना करने करें कहते कहा का काफ़ी कि कितना किन्हें किन्हों किया किर किस किसी किसे की कुछ कुल के को कोई कौन कौनसा गया घर जब जहाँ जा जितना जिन जिन्हें जिन्हों जिस जिसे जीधर जैसा जैसे जो तक तब तरह तिन तिन्हें तिन्हों तिस तिसे तो था थी थे दबारा दिया दुसरा दूसरे दो द्वारा न नके नहीं ना निहायत नीचे ने पर पहले पूरा पे फिर बनी बही बहुत बाद बाला बिलकुल भी भीतर मगर मानो मे में यदि यह यहाँ यही या यिह ये रखें रहा रहे ऱ्वासा लिए लिये लेकिन व वग़ैरह वर्ग वह वहाँ वहीं वाले वुह वे वो सकता सकते सबसे सभी साथ साबुत साभ सारा से सो संग ही हुआ हुई हुए है हैं हो होता होती होते होना होने".split(" ")),e.hi.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.hi.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var t=i.toString().toLowerCase().replace(/^\s+/,"");return r.cut(t).split("|")},e.Pipeline.registerFunction(e.hi.stemmer,"stemmer-hi"),e.Pipeline.registerFunction(e.hi.stopWordFilter,"stopWordFilter-hi")}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hy=function(){this.pipeline.reset(),this.pipeline.add(e.hy.trimmer,e.hy.stopWordFilter)},e.hy.wordCharacters="[A-Za-z԰-֏ff-ﭏ]",e.hy.trimmer=e.trimmerSupport.generateTrimmer(e.hy.wordCharacters),e.Pipeline.registerFunction(e.hy.trimmer,"trimmer-hy"),e.hy.stopWordFilter=e.generateStopWordFilter("դու և եք էիր էիք հետո նաև նրանք որը վրա է որ պիտի են այս մեջ ն իր ու ի այդ որոնք այն կամ էր մի ես համար այլ իսկ էին ենք հետ ին թ էինք մենք նրա նա դուք եմ էի ըստ որպես ում".split(" ")),e.Pipeline.registerFunction(e.hy.stopWordFilter,"stopWordFilter-hy"),e.hy.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}(),e.Pipeline.registerFunction(e.hy.stemmer,"stemmer-hy")}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.ja=function(){this.pipeline.reset(),this.pipeline.add(e.ja.trimmer,e.ja.stopWordFilter,e.ja.stemmer),r?this.tokenizer=e.ja.tokenizer:(e.tokenizer&&(e.tokenizer=e.ja.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.ja.tokenizer))};var t=new e.TinySegmenter;e.ja.tokenizer=function(i){var n,o,s,p,a,u,m,l,c,f;if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t.toLowerCase()):t.toLowerCase()});for(o=i.toString().toLowerCase().replace(/^\s+/,""),n=o.length-1;n>=0;n--)if(/\S/.test(o.charAt(n))){o=o.substring(0,n+1);break}for(a=[],s=o.length,c=0,l=0;c<=s;c++)if(u=o.charAt(c),m=c-l,u.match(/\s/)||c==s){if(m>0)for(p=t.segment(o.slice(l,c)).filter(function(e){return!!e}),f=l,n=0;n<p.length;n++)r?a.push(new e.Token(p[n],{position:[f,p[n].length],index:a.length})):a.push(p[n]),f+=p[n].length;l=c+1}return a},e.ja.stemmer=function(){return function(e){return e}}(),e.Pipeline.registerFunction(e.ja.stemmer,"stemmer-ja"),e.ja.wordCharacters="一二三四五六七八九十百千万億兆一-龠々〆ヵヶぁ-んァ-ヴーア-ン゙a-zA-Z-zA-0-9-",e.ja.trimmer=e.trimmerSupport.generateTrimmer(e.ja.wordCharacters),e.Pipeline.registerFunction(e.ja.trimmer,"trimmer-ja"),e.ja.stopWordFilter=e.generateStopWordFilter("これ それ あれ この その あの ここ そこ あそこ こちら どこ だれ なに なん 何 私 貴方 貴方方 我々 私達 あの人 あのかた 彼女 彼 です あります おります います は が の に を で え から まで より も どの と し それで しかし".split(" ")),e.Pipeline.registerFunction(e.ja.stopWordFilter,"stopWordFilter-ja"),e.jp=e.ja,e.Pipeline.registerFunction(e.jp.stemmer,"stemmer-jp"),e.Pipeline.registerFunction(e.jp.trimmer,"trimmer-jp"),e.Pipeline.registerFunction(e.jp.stopWordFilter,"stopWordFilter-jp")}});
-1
View File
@@ -1 +0,0 @@
module.exports=require("./lunr.ja");
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.kn=function(){this.pipeline.reset(),this.pipeline.add(e.kn.trimmer,e.kn.stopWordFilter,e.kn.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.kn.stemmer))},e.kn.wordCharacters="ಀ-಄ಅ-ಔಕ-ಹಾ-ೌ಼-ಽೕ-ೖೝ-ೞೠ-ೡೢ-ೣ೤೥೦-೯ೱ-ೳ",e.kn.trimmer=e.trimmerSupport.generateTrimmer(e.kn.wordCharacters),e.Pipeline.registerFunction(e.kn.trimmer,"trimmer-kn"),e.kn.stopWordFilter=e.generateStopWordFilter("ಮತ್ತು ಈ ಒಂದು ರಲ್ಲಿ ಹಾಗೂ ಎಂದು ಅಥವಾ ಇದು ರ ಅವರು ಎಂಬ ಮೇಲೆ ಅವರ ತನ್ನ ಆದರೆ ತಮ್ಮ ನಂತರ ಮೂಲಕ ಹೆಚ್ಚು ನ ಆ ಕೆಲವು ಅನೇಕ ಎರಡು ಹಾಗು ಪ್ರಮುಖ ಇದನ್ನು ಇದರ ಸುಮಾರು ಅದರ ಅದು ಮೊದಲ ಬಗ್ಗೆ ನಲ್ಲಿ ರಂದು ಇತರ ಅತ್ಯಂತ ಹೆಚ್ಚಿನ ಸಹ ಸಾಮಾನ್ಯವಾಗಿ ನೇ ಹಲವಾರು ಹೊಸ ದಿ ಕಡಿಮೆ ಯಾವುದೇ ಹೊಂದಿದೆ ದೊಡ್ಡ ಅನ್ನು ಇವರು ಪ್ರಕಾರ ಇದೆ ಮಾತ್ರ ಕೂಡ ಇಲ್ಲಿ ಎಲ್ಲಾ ವಿವಿಧ ಅದನ್ನು ಹಲವು ರಿಂದ ಕೇವಲ ದ ದಕ್ಷಿಣ ಗೆ ಅವನ ಅತಿ ನೆಯ ಬಹಳ ಕೆಲಸ ಎಲ್ಲ ಪ್ರತಿ ಇತ್ಯಾದಿ ಇವು ಬೇರೆ ಹೀಗೆ ನಡುವೆ ಇದಕ್ಕೆ ಎಸ್ ಇವರ ಮೊದಲು ಶ್ರೀ ಮಾಡುವ ಇದರಲ್ಲಿ ರೀತಿಯ ಮಾಡಿದ ಕಾಲ ಅಲ್ಲಿ ಮಾಡಲು ಅದೇ ಈಗ ಅವು ಗಳು ಎ ಎಂಬುದು ಅವನು ಅಂದರೆ ಅವರಿಗೆ ಇರುವ ವಿಶೇಷ ಮುಂದೆ ಅವುಗಳ ಮುಂತಾದ ಮೂಲ ಬಿ ಮೀ ಒಂದೇ ಇನ್ನೂ ಹೆಚ್ಚಾಗಿ ಮಾಡಿ ಅವರನ್ನು ಇದೇ ಯ ರೀತಿಯಲ್ಲಿ ಜೊತೆ ಅದರಲ್ಲಿ ಮಾಡಿದರು ನಡೆದ ಆಗ ಮತ್ತೆ ಪೂರ್ವ ಆತ ಬಂದ ಯಾವ ಒಟ್ಟು ಇತರೆ ಹಿಂದೆ ಪ್ರಮಾಣದ ಗಳನ್ನು ಕುರಿತು ಯು ಆದ್ದರಿಂದ ಅಲ್ಲದೆ ನಗರದ ಮೇಲಿನ ಏಕೆಂದರೆ ರಷ್ಟು ಎಂಬುದನ್ನು ಬಾರಿ ಎಂದರೆ ಹಿಂದಿನ ಆದರೂ ಆದ ಸಂಬಂಧಿಸಿದ ಮತ್ತೊಂದು ಸಿ ಆತನ ".split(" ")),e.kn.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.kn.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var n=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(n).split("|")},e.Pipeline.registerFunction(e.kn.stemmer,"stemmer-kn"),e.Pipeline.registerFunction(e.kn.stopWordFilter,"stopWordFilter-kn")}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){e.multiLanguage=function(){for(var t=Array.prototype.slice.call(arguments),i=t.join("-"),r="",n=[],s=[],p=0;p<t.length;++p)"en"==t[p]?(r+="\\w",n.unshift(e.stopWordFilter),n.push(e.stemmer),s.push(e.stemmer)):(r+=e[t[p]].wordCharacters,e[t[p]].stopWordFilter&&n.unshift(e[t[p]].stopWordFilter),e[t[p]].stemmer&&(n.push(e[t[p]].stemmer),s.push(e[t[p]].stemmer)));var o=e.trimmerSupport.generateTrimmer(r);return e.Pipeline.registerFunction(o,"lunr-multi-trimmer-"+i),n.unshift(o),function(){this.pipeline.reset(),this.pipeline.add.apply(this.pipeline,n),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add.apply(this.searchPipeline,s))}}}});
File diff suppressed because one or more lines are too long
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Norwegian` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.no=function(){this.pipeline.reset(),this.pipeline.add(e.no.trimmer,e.no.stopWordFilter,e.no.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.no.stemmer))},e.no.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.no.trimmer=e.trimmerSupport.generateTrimmer(e.no.wordCharacters),e.Pipeline.registerFunction(e.no.trimmer,"trimmer-no"),e.no.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,i=new function(){function e(){var e,r=w.cursor+3;if(a=w.limit,0<=r||r<=w.limit){for(s=r;;){if(e=w.cursor,w.in_grouping(d,97,248)){w.cursor=e;break}if(e>=w.limit)return;w.cursor=e+1}for(;!w.out_grouping(d,97,248);){if(w.cursor>=w.limit)return;w.cursor++}a=w.cursor,a<s&&(a=s)}}function i(){var e,r,n;if(w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(m,29),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:n=w.limit-w.cursor,w.in_grouping_b(c,98,122)?w.slice_del():(w.cursor=w.limit-n,w.eq_s_b(1,"k")&&w.out_grouping_b(d,97,248)&&w.slice_del());break;case 3:w.slice_from("er")}}function t(){var e,r=w.limit-w.cursor;w.cursor>=a&&(e=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,w.find_among_b(u,2)?(w.bra=w.cursor,w.limit_backward=e,w.cursor=w.limit-r,w.cursor>w.limit_backward&&(w.cursor--,w.bra=w.cursor,w.slice_del())):w.limit_backward=e)}function o(){var e,r;w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(l,11),e?(w.bra=w.cursor,w.limit_backward=r,1==e&&w.slice_del()):w.limit_backward=r)}var s,a,m=[new r("a",-1,1),new r("e",-1,1),new r("ede",1,1),new r("ande",1,1),new r("ende",1,1),new r("ane",1,1),new r("ene",1,1),new r("hetene",6,1),new r("erte",1,3),new r("en",-1,1),new r("heten",9,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",12,1),new r("s",-1,2),new r("as",14,1),new r("es",14,1),new r("edes",16,1),new r("endes",16,1),new r("enes",16,1),new r("hetenes",19,1),new r("ens",14,1),new r("hetens",21,1),new r("ers",14,1),new r("ets",14,1),new r("et",-1,1),new r("het",25,1),new r("ert",-1,3),new r("ast",-1,1)],u=[new r("dt",-1,-1),new r("vt",-1,-1)],l=[new r("leg",-1,1),new r("eleg",0,1),new r("ig",-1,1),new r("eig",2,1),new r("lig",2,1),new r("elig",4,1),new r("els",-1,1),new r("lov",-1,1),new r("elov",7,1),new r("slov",7,1),new r("hetslov",9,1)],d=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],c=[119,125,149,1],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,i(),w.cursor=w.limit,t(),w.cursor=w.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return i.setCurrent(e),i.stem(),i.getCurrent()}):(i.setCurrent(e),i.stem(),i.getCurrent())}}(),e.Pipeline.registerFunction(e.no.stemmer,"stemmer-no"),e.no.stopWordFilter=e.generateStopWordFilter("alle at av bare begge ble blei bli blir blitt både båe da de deg dei deim deira deires dem den denne der dere deres det dette di din disse ditt du dykk dykkar då eg ein eit eitt eller elles en enn er et ett etter for fordi fra før ha hadde han hans har hennar henne hennes her hjå ho hoe honom hoss hossen hun hva hvem hver hvilke hvilken hvis hvor hvordan hvorfor i ikke ikkje ikkje ingen ingi inkje inn inni ja jeg kan kom korleis korso kun kunne kva kvar kvarhelst kven kvi kvifor man mange me med medan meg meget mellom men mi min mine mitt mot mykje ned no noe noen noka noko nokon nokor nokre nå når og også om opp oss over på samme seg selv si si sia sidan siden sin sine sitt sjøl skal skulle slik so som som somme somt så sånn til um upp ut uten var vart varte ved vere verte vi vil ville vore vors vort vår være være vært å".split(" ")),e.Pipeline.registerFunction(e.no.stopWordFilter,"stopWordFilter-no")}});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sa=function(){this.pipeline.reset(),this.pipeline.add(e.sa.trimmer,e.sa.stopWordFilter,e.sa.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sa.stemmer))},e.sa.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿ꣠-꣱ꣲ-ꣷ꣸-ꣻ꣼-ꣽꣾ-ꣿᆰ0-ᆰ9",e.sa.trimmer=e.trimmerSupport.generateTrimmer(e.sa.wordCharacters),e.Pipeline.registerFunction(e.sa.trimmer,"trimmer-sa"),e.sa.stopWordFilter=e.generateStopWordFilter('तथा अयम्‌ एकम्‌ इत्यस्मिन्‌ तथा तत्‌ वा अयम्‌ इत्यस्य ते आहूत उपरि तेषाम्‌ किन्तु तेषाम्‌ तदा इत्यनेन अधिकः इत्यस्य तत्‌ केचन बहवः द्वि तथा महत्वपूर्णः अयम्‌ अस्य विषये अयं अस्ति तत्‌ प्रथमः विषये इत्युपरि इत्युपरि इतर अधिकतमः अधिकः अपि सामान्यतया ठ इतरेतर नूतनम्‌ द न्यूनम्‌ कश्चित्‌ वा विशालः द सः अस्ति तदनुसारम् तत्र अस्ति केवलम्‌ अपि अत्र सर्वे विविधाः तत्‌ बहवः यतः इदानीम्‌ द दक्षिण इत्यस्मै तस्य उपरि नथ अतीव कार्यम्‌ सर्वे एकैकम्‌ इत्यादि। एते सन्ति उत इत्थम्‌ मध्ये एतदर्थं . स कस्य प्रथमः श्री. करोति अस्मिन् प्रकारः निर्मिता कालः तत्र कर्तुं समान अधुना ते सन्ति स एकः अस्ति सः अर्थात् तेषां कृते . स्थितम् विशेषः अग्रिम तेषाम्‌ समान स्रोतः ख म समान इदानीमपि अधिकतया करोतु ते समान इत्यस्य वीथी सह यस्मिन् कृतवान्‌ धृतः तदा पुनः पूर्वं सः आगतः किम्‌ कुल इतर पुरा मात्रा स विषये उ अतएव अपि नगरस्य उपरि यतः प्रतिशतं कतरः कालः साधनानि भूत तथापि जात सम्बन्धि अन्यत्‌ ग अतः अस्माकं स्वकीयाः अस्माकं इदानीं अन्तः इत्यादयः भवन्तः इत्यादयः एते एताः तस्य अस्य इदम् एते तेषां तेषां तेषां तान् तेषां तेषां तेषां समानः सः एकः च तादृशाः बहवः अन्ये च वदन्ति यत् कियत् कस्मै कस्मै यस्मै यस्मै यस्मै यस्मै न अतिनीचः किन्तु प्रथमं सम्पूर्णतया ततः चिरकालानन्तरं पुस्तकं सम्पूर्णतया अन्तः किन्तु अत्र वा इह इव श्रद्धाय अवशिष्यते परन्तु अन्ये वर्गाः सन्ति ते सन्ति शक्नुवन्ति सर्वे मिलित्वा सर्वे एकत्र"'.split(" ")),e.sa.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.sa.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var i=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(i).split("|")},e.Pipeline.registerFunction(e.sa.stemmer,"stemmer-sa"),e.Pipeline.registerFunction(e.sa.stopWordFilter,"stopWordFilter-sa")}});
@@ -1 +0,0 @@
!function(r,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(r.lunr)}(this,function(){return function(r){r.stemmerSupport={Among:function(r,t,i,s){if(this.toCharArray=function(r){for(var t=r.length,i=new Array(t),s=0;s<t;s++)i[s]=r.charCodeAt(s);return i},!r&&""!=r||!t&&0!=t||!i)throw"Bad Among initialisation: s:"+r+", substring_i: "+t+", result: "+i;this.s_size=r.length,this.s=this.toCharArray(r),this.substring_i=t,this.result=i,this.method=s},SnowballProgram:function(){var r;return{bra:0,ket:0,limit:0,cursor:0,limit_backward:0,setCurrent:function(t){r=t,this.cursor=0,this.limit=t.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},getCurrent:function(){var t=r;return r=null,t},in_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},in_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},out_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e>s||e<i)return this.cursor++,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},out_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e>s||e<i)return this.cursor--,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},eq_s:function(t,i){if(this.limit-this.cursor<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor+s)!=i.charCodeAt(s))return!1;return this.cursor+=t,!0},eq_s_b:function(t,i){if(this.cursor-this.limit_backward<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor-t+s)!=i.charCodeAt(s))return!1;return this.cursor-=t,!0},find_among:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=l;m<_.s_size;m++){if(n+l==u){f=-1;break}if(f=r.charCodeAt(n+l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n+_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n+_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},find_among_b:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit_backward,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=_.s_size-1-l;m>=0;m--){if(n-l==u){f=-1;break}if(f=r.charCodeAt(n-1-l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n-_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n-_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},replace_s:function(t,i,s){var e=s.length-(i-t),n=r.substring(0,t),u=r.substring(i);return r=n+s+u,this.limit+=e,this.cursor>=i?this.cursor+=e:this.cursor>t&&(this.cursor=t),e},slice_check:function(){if(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>r.length)throw"faulty slice operation"},slice_from:function(r){this.slice_check(),this.replace_s(this.bra,this.ket,r)},slice_del:function(){this.slice_from("")},insert:function(r,t,i){var s=this.replace_s(r,t,i);r<=this.bra&&(this.bra+=s),r<=this.ket&&(this.ket+=s)},slice_to:function(){return this.slice_check(),r.substring(this.bra,this.ket)},eq_v_b:function(r){return this.eq_s_b(r.length,r)}}}},r.trimmerSupport={generateTrimmer:function(r){var t=new RegExp("^[^"+r+"]+"),i=new RegExp("[^"+r+"]+$");return function(r){return"function"==typeof r.update?r.update(function(r){return r.replace(t,"").replace(i,"")}):r.replace(t,"").replace(i,"")}}}}});
-18
View File
@@ -1,18 +0,0 @@
/*!
* Lunr languages, `Swedish` language
* https://github.com/MihaiValentin/lunr-languages
*
* Copyright 2014, Mihai Valentin
* http://www.mozilla.org/MPL/
*/
/*!
* based on
* Snowball JavaScript Library v0.3
* http://code.google.com/p/urim/
* http://snowball.tartarus.org/
*
* Copyright 2010, Oleg Mazko
* http://www.mozilla.org/MPL/
*/
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sv=function(){this.pipeline.reset(),this.pipeline.add(e.sv.trimmer,e.sv.stopWordFilter,e.sv.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sv.stemmer))},e.sv.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.sv.trimmer=e.trimmerSupport.generateTrimmer(e.sv.wordCharacters),e.Pipeline.registerFunction(e.sv.trimmer,"trimmer-sv"),e.sv.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,t=new function(){function e(){var e,r=w.cursor+3;if(o=w.limit,0<=r||r<=w.limit){for(a=r;;){if(e=w.cursor,w.in_grouping(l,97,246)){w.cursor=e;break}if(w.cursor=e,w.cursor>=w.limit)return;w.cursor++}for(;!w.out_grouping(l,97,246);){if(w.cursor>=w.limit)return;w.cursor++}o=w.cursor,o<a&&(o=a)}}function t(){var e,r=w.limit_backward;if(w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(u,37),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.in_grouping_b(d,98,121)&&w.slice_del()}}function i(){var e=w.limit_backward;w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.find_among_b(c,7)&&(w.cursor=w.limit,w.ket=w.cursor,w.cursor>w.limit_backward&&(w.bra=--w.cursor,w.slice_del())),w.limit_backward=e)}function s(){var e,r;if(w.cursor>=o){if(r=w.limit_backward,w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(m,5))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.slice_from("lös");break;case 3:w.slice_from("full")}w.limit_backward=r}}var a,o,u=[new r("a",-1,1),new r("arna",0,1),new r("erna",0,1),new r("heterna",2,1),new r("orna",0,1),new r("ad",-1,1),new r("e",-1,1),new r("ade",6,1),new r("ande",6,1),new r("arne",6,1),new r("are",6,1),new r("aste",6,1),new r("en",-1,1),new r("anden",12,1),new r("aren",12,1),new r("heten",12,1),new r("ern",-1,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",18,1),new r("or",-1,1),new r("s",-1,2),new r("as",21,1),new r("arnas",22,1),new r("ernas",22,1),new r("ornas",22,1),new r("es",21,1),new r("ades",26,1),new r("andes",26,1),new r("ens",21,1),new r("arens",29,1),new r("hetens",29,1),new r("erns",21,1),new r("at",-1,1),new r("andet",-1,1),new r("het",-1,1),new r("ast",-1,1)],c=[new r("dd",-1,-1),new r("gd",-1,-1),new r("nn",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1),new r("tt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("els",-1,1),new r("fullt",-1,3),new r("löst",-1,2)],l=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,24,0,32],d=[119,127,149],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,t(),w.cursor=w.limit,i(),w.cursor=w.limit,s(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return t.setCurrent(e),t.stem(),t.getCurrent()}):(t.setCurrent(e),t.stem(),t.getCurrent())}}(),e.Pipeline.registerFunction(e.sv.stemmer,"stemmer-sv"),e.sv.stopWordFilter=e.generateStopWordFilter("alla allt att av blev bli blir blivit de dem den denna deras dess dessa det detta dig din dina ditt du där då efter ej eller en er era ert ett från för ha hade han hans har henne hennes hon honom hur här i icke ingen inom inte jag ju kan kunde man med mellan men mig min mina mitt mot mycket ni nu när någon något några och om oss på samma sedan sig sin sina sitta själv skulle som så sådan sådana sådant till under upp ut utan vad var vara varför varit varje vars vart vem vi vid vilka vilkas vilken vilket vår våra vårt än är åt över".split(" ")),e.Pipeline.registerFunction(e.sv.stopWordFilter,"stopWordFilter-sv")}});
-1
View File
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.ta=function(){this.pipeline.reset(),this.pipeline.add(e.ta.trimmer,e.ta.stopWordFilter,e.ta.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.ta.stemmer))},e.ta.wordCharacters="஀-உஊ-ஏஐ-ஙச-ட஠-னப-யர-ஹ஺-ிீ-௉ொ-௏ௐ-௙௚-௟௠-௩௪-௯௰-௹௺-௿a-zA-Z-zA-0-9-",e.ta.trimmer=e.trimmerSupport.generateTrimmer(e.ta.wordCharacters),e.Pipeline.registerFunction(e.ta.trimmer,"trimmer-ta"),e.ta.stopWordFilter=e.generateStopWordFilter("அங்கு அங்கே அது அதை அந்த அவர் அவர்கள் அவள் அவன் அவை ஆக ஆகவே ஆகையால் ஆதலால் ஆதலினால் ஆனாலும் ஆனால் இங்கு இங்கே இது இதை இந்த இப்படி இவர் இவர்கள் இவள் இவன் இவை இவ்வளவு உனக்கு உனது உன் உன்னால் எங்கு எங்கே எது எதை எந்த எப்படி எவர் எவர்கள் எவள் எவன் எவை எவ்வளவு எனக்கு எனது எனவே என் என்ன என்னால் ஏது ஏன் தனது தன்னால் தானே தான் நாங்கள் நாம் நான் நீ நீங்கள்".split(" ")),e.ta.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.ta.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.ta.stemmer,"stemmer-ta"),e.Pipeline.registerFunction(e.ta.stopWordFilter,"stopWordFilter-ta")}});
-1
View File
@@ -1 +0,0 @@
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.te=function(){this.pipeline.reset(),this.pipeline.add(e.te.trimmer,e.te.stopWordFilter,e.te.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.te.stemmer))},e.te.wordCharacters="ఀ-ఄఅ-ఔక-హా-ౌౕ-ౖౘ-ౚౠ-ౡౢ-ౣ౦-౯౸-౿఼ఽ్ౝ౷౤౥",e.te.trimmer=e.trimmerSupport.generateTrimmer(e.te.wordCharacters),e.Pipeline.registerFunction(e.te.trimmer,"trimmer-te"),e.te.stopWordFilter=e.generateStopWordFilter("అందరూ అందుబాటులో అడగండి అడగడం అడ్డంగా అనుగుణంగా అనుమతించు అనుమతిస్తుంది అయితే ఇప్పటికే ఉన్నారు ఎక్కడైనా ఎప్పుడు ఎవరైనా ఎవరో ఏ ఏదైనా ఏమైనప్పటికి ఒక ఒకరు కనిపిస్తాయి కాదు కూడా గా గురించి చుట్టూ చేయగలిగింది తగిన తర్వాత దాదాపు దూరంగా నిజంగా పై ప్రకారం ప్రక్కన మధ్య మరియు మరొక మళ్ళీ మాత్రమే మెచ్చుకో వద్ద వెంట వేరుగా వ్యతిరేకంగా సంబంధం".split(" ")),e.te.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.te.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.te.stemmer,"stemmer-te"),e.Pipeline.registerFunction(e.te.stopWordFilter,"stopWordFilter-te")}});
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.th=function(){this.pipeline.reset(),this.pipeline.add(e.th.trimmer),r?this.tokenizer=e.th.tokenizer:(e.tokenizer&&(e.tokenizer=e.th.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.th.tokenizer))},e.th.wordCharacters="[฀-๿]",e.th.trimmer=e.trimmerSupport.generateTrimmer(e.th.wordCharacters),e.Pipeline.registerFunction(e.th.trimmer,"trimmer-th");var t=e.wordcut;t.init(),e.th.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t):t});var n=i.toString().replace(/^\s+/,"");return t.cut(n).split("|")}}});
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.vi=function(){this.pipeline.reset(),this.pipeline.add(e.vi.stopWordFilter,e.vi.trimmer)},e.vi.wordCharacters="[A-Za-ẓ̀͐́͑̉̃̓ÂâÊêÔôĂ-ăĐ-đƠ-ơƯ-ư]",e.vi.trimmer=e.trimmerSupport.generateTrimmer(e.vi.wordCharacters),e.Pipeline.registerFunction(e.vi.trimmer,"trimmer-vi"),e.vi.stopWordFilter=e.generateStopWordFilter("là cái nhưng mà".split(" "))}});
-1
View File
@@ -1 +0,0 @@
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r(require("@node-rs/jieba")):r()(e.lunr)}(this,function(e){return function(r,t){if(void 0===r)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===r.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var i="2"==r.version[0];r.zh=function(){this.pipeline.reset(),this.pipeline.add(r.zh.trimmer,r.zh.stopWordFilter,r.zh.stemmer),i?this.tokenizer=r.zh.tokenizer:(r.tokenizer&&(r.tokenizer=r.zh.tokenizer),this.tokenizerFn&&(this.tokenizerFn=r.zh.tokenizer))},r.zh.tokenizer=function(n){if(!arguments.length||null==n||void 0==n)return[];if(Array.isArray(n))return n.map(function(e){return i?new r.Token(e.toLowerCase()):e.toLowerCase()});t&&e.load(t);var o=n.toString().trim().toLowerCase(),s=[];e.cut(o,!0).forEach(function(e){s=s.concat(e.split(" "))}),s=s.filter(function(e){return!!e});var u=0;return s.map(function(e,t){if(i){var n=o.indexOf(e,u),s={};return s.position=[n,e.length],s.index=t,u=n,new r.Token(e,s)}return e})},r.zh.wordCharacters="\\w一-龥",r.zh.trimmer=r.trimmerSupport.generateTrimmer(r.zh.wordCharacters),r.Pipeline.registerFunction(r.zh.trimmer,"trimmer-zh"),r.zh.stemmer=function(){return function(e){return e}}(),r.Pipeline.registerFunction(r.zh.stemmer,"stemmer-zh"),r.zh.stopWordFilter=r.generateStopWordFilter("的 一 不 在 人 有 是 为 為 以 于 於 上 他 而 后 後 之 来 來 及 了 因 下 可 到 由 这 這 与 與 也 此 但 并 並 个 個 其 已 无 無 小 我 们 們 起 最 再 今 去 好 只 又 或 很 亦 某 把 那 你 乃 它 吧 被 比 别 趁 当 當 从 從 得 打 凡 儿 兒 尔 爾 该 該 各 给 給 跟 和 何 还 還 即 几 幾 既 看 据 據 距 靠 啦 另 么 麽 每 嘛 拿 哪 您 凭 憑 且 却 卻 让 讓 仍 啥 如 若 使 谁 誰 虽 雖 随 隨 同 所 她 哇 嗡 往 些 向 沿 哟 喲 用 咱 则 則 怎 曾 至 致 着 著 诸 諸 自".split(" ")),r.Pipeline.registerFunction(r.zh.stopWordFilter,"stopWordFilter-zh")}});
-206
View File
@@ -1,206 +0,0 @@
/**
* export the module via AMD, CommonJS or as a browser global
* Export code from https://github.com/umdjs/umd/blob/master/returnExports.js
*/
;(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(factory)
} else if (typeof exports === 'object') {
/**
* Node. Does not work with strict CommonJS, but
* only CommonJS-like environments that support module.exports,
* like Node.
*/
module.exports = factory()
} else {
// Browser globals (root is window)
factory()(root.lunr);
}
}(this, function () {
/**
* Just return a value to define the module export.
* This example returns an object, but the module
* can return a function as the exported value.
*/
return function(lunr) {
// TinySegmenter 0.1 -- Super compact Japanese tokenizer in Javascript
// (c) 2008 Taku Kudo <taku@chasen.org>
// TinySegmenter is freely distributable under the terms of a new BSD licence.
// For details, see http://chasen.org/~taku/software/TinySegmenter/LICENCE.txt
function TinySegmenter() {
var patterns = {
"[一二三四五六七八九十百千万億兆]":"M",
"[一-龠々〆ヵヶ]":"H",
"[ぁ-ん]":"I",
"[ァ-ヴーア-ン゙ー]":"K",
"[a-zA-Z-zA-]":"A",
"[0-9-]":"N"
}
this.chartype_ = [];
for (var i in patterns) {
var regexp = new RegExp(i);
this.chartype_.push([regexp, patterns[i]]);
}
this.BIAS__ = -332
this.BC1__ = {"HH":6,"II":2461,"KH":406,"OH":-1378};
this.BC2__ = {"AA":-3267,"AI":2744,"AN":-878,"HH":-4070,"HM":-1711,"HN":4012,"HO":3761,"IA":1327,"IH":-1184,"II":-1332,"IK":1721,"IO":5492,"KI":3831,"KK":-8741,"MH":-3132,"MK":3334,"OO":-2920};
this.BC3__ = {"HH":996,"HI":626,"HK":-721,"HN":-1307,"HO":-836,"IH":-301,"KK":2762,"MK":1079,"MM":4034,"OA":-1652,"OH":266};
this.BP1__ = {"BB":295,"OB":304,"OO":-125,"UB":352};
this.BP2__ = {"BO":60,"OO":-1762};
this.BQ1__ = {"BHH":1150,"BHM":1521,"BII":-1158,"BIM":886,"BMH":1208,"BNH":449,"BOH":-91,"BOO":-2597,"OHI":451,"OIH":-296,"OKA":1851,"OKH":-1020,"OKK":904,"OOO":2965};
this.BQ2__ = {"BHH":118,"BHI":-1159,"BHM":466,"BIH":-919,"BKK":-1720,"BKO":864,"OHH":-1139,"OHM":-181,"OIH":153,"UHI":-1146};
this.BQ3__ = {"BHH":-792,"BHI":2664,"BII":-299,"BKI":419,"BMH":937,"BMM":8335,"BNN":998,"BOH":775,"OHH":2174,"OHM":439,"OII":280,"OKH":1798,"OKI":-793,"OKO":-2242,"OMH":-2402,"OOO":11699};
this.BQ4__ = {"BHH":-3895,"BIH":3761,"BII":-4654,"BIK":1348,"BKK":-1806,"BMI":-3385,"BOO":-12396,"OAH":926,"OHH":266,"OHK":-2036,"ONN":-973};
this.BW1__ = {",と":660,",同":727,"B1あ":1404,"B1同":542,"、と":660,"、同":727,"」と":1682,"あっ":1505,"いう":1743,"いっ":-2055,"いる":672,"うし":-4817,"うん":665,"から":3472,"がら":600,"こう":-790,"こと":2083,"こん":-1262,"さら":-4143,"さん":4573,"した":2641,"して":1104,"すで":-3399,"そこ":1977,"それ":-871,"たち":1122,"ため":601,"った":3463,"つい":-802,"てい":805,"てき":1249,"でき":1127,"です":3445,"では":844,"とい":-4915,"とみ":1922,"どこ":3887,"ない":5713,"なっ":3015,"など":7379,"なん":-1113,"にし":2468,"には":1498,"にも":1671,"に対":-912,"の一":-501,"の中":741,"ませ":2448,"まで":1711,"まま":2600,"まる":-2155,"やむ":-1947,"よっ":-2565,"れた":2369,"れで":-913,"をし":1860,"を見":731,"亡く":-1886,"京都":2558,"取り":-2784,"大き":-2604,"大阪":1497,"平方":-2314,"引き":-1336,"日本":-195,"本当":-2423,"毎日":-2113,"目指":-724,"B1あ":1404,"B1同":542,"」と":1682};
this.BW2__ = {"..":-11822,"11":-669,"――":-5730,"−−":-13175,"いう":-1609,"うか":2490,"かし":-1350,"かも":-602,"から":-7194,"かれ":4612,"がい":853,"がら":-3198,"きた":1941,"くな":-1597,"こと":-8392,"この":-4193,"させ":4533,"され":13168,"さん":-3977,"しい":-1819,"しか":-545,"した":5078,"して":972,"しな":939,"その":-3744,"たい":-1253,"たた":-662,"ただ":-3857,"たち":-786,"たと":1224,"たは":-939,"った":4589,"って":1647,"っと":-2094,"てい":6144,"てき":3640,"てく":2551,"ては":-3110,"ても":-3065,"でい":2666,"でき":-1528,"でし":-3828,"です":-4761,"でも":-4203,"とい":1890,"とこ":-1746,"とと":-2279,"との":720,"とみ":5168,"とも":-3941,"ない":-2488,"なが":-1313,"など":-6509,"なの":2614,"なん":3099,"にお":-1615,"にし":2748,"にな":2454,"によ":-7236,"に対":-14943,"に従":-4688,"に関":-11388,"のか":2093,"ので":-7059,"のに":-6041,"のの":-6125,"はい":1073,"はが":-1033,"はず":-2532,"ばれ":1813,"まし":-1316,"まで":-6621,"まれ":5409,"めて":-3153,"もい":2230,"もの":-10713,"らか":-944,"らし":-1611,"らに":-1897,"りし":651,"りま":1620,"れた":4270,"れて":849,"れば":4114,"ろう":6067,"われ":7901,"を通":-11877,"んだ":728,"んな":-4115,"一人":602,"一方":-1375,"一日":970,"一部":-1051,"上が":-4479,"会社":-1116,"出て":2163,"分の":-7758,"同党":970,"同日":-913,"大阪":-2471,"委員":-1250,"少な":-1050,"年度":-8669,"年間":-1626,"府県":-2363,"手権":-1982,"新聞":-4066,"日新":-722,"日本":-7068,"日米":3372,"曜日":-601,"朝鮮":-2355,"本人":-2697,"東京":-1543,"然と":-1384,"社会":-1276,"立て":-990,"第に":-1612,"米国":-4268,"11":-669};
this.BW3__ = {"あた":-2194,"あり":719,"ある":3846,"い.":-1185,"い。":-1185,"いい":5308,"いえ":2079,"いく":3029,"いた":2056,"いっ":1883,"いる":5600,"いわ":1527,"うち":1117,"うと":4798,"えと":1454,"か.":2857,"か。":2857,"かけ":-743,"かっ":-4098,"かに":-669,"から":6520,"かり":-2670,"が,":1816,"が、":1816,"がき":-4855,"がけ":-1127,"がっ":-913,"がら":-4977,"がり":-2064,"きた":1645,"けど":1374,"こと":7397,"この":1542,"ころ":-2757,"さい":-714,"さを":976,"し,":1557,"し、":1557,"しい":-3714,"した":3562,"して":1449,"しな":2608,"しま":1200,"す.":-1310,"す。":-1310,"する":6521,"ず,":3426,"ず、":3426,"ずに":841,"そう":428,"た.":8875,"た。":8875,"たい":-594,"たの":812,"たり":-1183,"たる":-853,"だ.":4098,"だ。":4098,"だっ":1004,"った":-4748,"って":300,"てい":6240,"てお":855,"ても":302,"です":1437,"でに":-1482,"では":2295,"とう":-1387,"とし":2266,"との":541,"とも":-3543,"どう":4664,"ない":1796,"なく":-903,"など":2135,"に,":-1021,"に、":-1021,"にし":1771,"にな":1906,"には":2644,"の,":-724,"の、":-724,"の子":-1000,"は,":1337,"は、":1337,"べき":2181,"まし":1113,"ます":6943,"まっ":-1549,"まで":6154,"まれ":-793,"らし":1479,"られ":6820,"るる":3818,"れ,":854,"れ、":854,"れた":1850,"れて":1375,"れば":-3246,"れる":1091,"われ":-605,"んだ":606,"んで":798,"カ月":990,"会議":860,"入り":1232,"大会":2217,"始め":1681,"市":965,"新聞":-5055,"日,":974,"日、":974,"社会":2024,"カ月":990};
this.TC1__ = {"AAA":1093,"HHH":1029,"HHM":580,"HII":998,"HOH":-390,"HOM":-331,"IHI":1169,"IOH":-142,"IOI":-1015,"IOM":467,"MMH":187,"OOI":-1832};
this.TC2__ = {"HHO":2088,"HII":-1023,"HMM":-1154,"IHI":-1965,"KKH":703,"OII":-2649};
this.TC3__ = {"AAA":-294,"HHH":346,"HHI":-341,"HII":-1088,"HIK":731,"HOH":-1486,"IHH":128,"IHI":-3041,"IHO":-1935,"IIH":-825,"IIM":-1035,"IOI":-542,"KHH":-1216,"KKA":491,"KKH":-1217,"KOK":-1009,"MHH":-2694,"MHM":-457,"MHO":123,"MMH":-471,"NNH":-1689,"NNO":662,"OHO":-3393};
this.TC4__ = {"HHH":-203,"HHI":1344,"HHK":365,"HHM":-122,"HHN":182,"HHO":669,"HIH":804,"HII":679,"HOH":446,"IHH":695,"IHO":-2324,"IIH":321,"III":1497,"IIO":656,"IOO":54,"KAK":4845,"KKA":3386,"KKK":3065,"MHH":-405,"MHI":201,"MMH":-241,"MMM":661,"MOM":841};
this.TQ1__ = {"BHHH":-227,"BHHI":316,"BHIH":-132,"BIHH":60,"BIII":1595,"BNHH":-744,"BOHH":225,"BOOO":-908,"OAKK":482,"OHHH":281,"OHIH":249,"OIHI":200,"OIIH":-68};
this.TQ2__ = {"BIHH":-1401,"BIII":-1033,"BKAK":-543,"BOOO":-5591};
this.TQ3__ = {"BHHH":478,"BHHM":-1073,"BHIH":222,"BHII":-504,"BIIH":-116,"BIII":-105,"BMHI":-863,"BMHM":-464,"BOMH":620,"OHHH":346,"OHHI":1729,"OHII":997,"OHMH":481,"OIHH":623,"OIIH":1344,"OKAK":2792,"OKHH":587,"OKKA":679,"OOHH":110,"OOII":-685};
this.TQ4__ = {"BHHH":-721,"BHHM":-3604,"BHII":-966,"BIIH":-607,"BIII":-2181,"OAAA":-2763,"OAKK":180,"OHHH":-294,"OHHI":2446,"OHHO":480,"OHIH":-1573,"OIHH":1935,"OIHI":-493,"OIIH":626,"OIII":-4007,"OKAK":-8156};
this.TW1__ = {"につい":-4681,"東京都":2026};
this.TW2__ = {"ある程":-2049,"いった":-1256,"ころが":-2434,"しょう":3873,"その後":-4430,"だって":-1049,"ていた":1833,"として":-4657,"ともに":-4517,"もので":1882,"一気に":-792,"初めて":-1512,"同時に":-8097,"大きな":-1255,"対して":-2721,"社会党":-3216};
this.TW3__ = {"いただ":-1734,"してい":1314,"として":-4314,"につい":-5483,"にとっ":-5989,"に当た":-6247,"ので,":-727,"ので、":-727,"のもの":-600,"れから":-3752,"十二月":-2287};
this.TW4__ = {"いう.":8576,"いう。":8576,"からな":-2348,"してい":2958,"たが,":1516,"たが、":1516,"ている":1538,"という":1349,"ました":5543,"ません":1097,"ようと":-4258,"よると":5865};
this.UC1__ = {"A":484,"K":93,"M":645,"O":-505};
this.UC2__ = {"A":819,"H":1059,"I":409,"M":3987,"N":5775,"O":646};
this.UC3__ = {"A":-1370,"I":2311};
this.UC4__ = {"A":-2643,"H":1809,"I":-1032,"K":-3450,"M":3565,"N":3876,"O":6646};
this.UC5__ = {"H":313,"I":-1238,"K":-799,"M":539,"O":-831};
this.UC6__ = {"H":-506,"I":-253,"K":87,"M":247,"O":-387};
this.UP1__ = {"O":-214};
this.UP2__ = {"B":69,"O":935};
this.UP3__ = {"B":189};
this.UQ1__ = {"BH":21,"BI":-12,"BK":-99,"BN":142,"BO":-56,"OH":-95,"OI":477,"OK":410,"OO":-2422};
this.UQ2__ = {"BH":216,"BI":113,"OK":1759};
this.UQ3__ = {"BA":-479,"BH":42,"BI":1913,"BK":-7198,"BM":3160,"BN":6427,"BO":14761,"OI":-827,"ON":-3212};
this.UW1__ = {",":156,"、":156,"「":-463,"あ":-941,"う":-127,"が":-553,"き":121,"こ":505,"で":-201,"と":-547,"ど":-123,"に":-789,"の":-185,"は":-847,"も":-466,"や":-470,"よ":182,"ら":-292,"り":208,"れ":169,"を":-446,"ん":-137,"・":-135,"主":-402,"京":-268,"区":-912,"午":871,"国":-460,"大":561,"委":729,"市":-411,"日":-141,"理":361,"生":-408,"県":-386,"都":-718,"「":-463,"・":-135};
this.UW2__ = {",":-829,"、":-829,"":892,"「":-645,"」":3145,"あ":-538,"い":505,"う":134,"お":-502,"か":1454,"が":-856,"く":-412,"こ":1141,"さ":878,"ざ":540,"し":1529,"す":-675,"せ":300,"そ":-1011,"た":188,"だ":1837,"つ":-949,"て":-291,"で":-268,"と":-981,"ど":1273,"な":1063,"に":-1764,"の":130,"は":-409,"ひ":-1273,"べ":1261,"ま":600,"も":-1263,"や":-402,"よ":1639,"り":-579,"る":-694,"れ":571,"を":-2516,"ん":2095,"ア":-587,"カ":306,"キ":568,"ッ":831,"三":-758,"不":-2150,"世":-302,"中":-968,"主":-861,"事":492,"人":-123,"会":978,"保":362,"入":548,"初":-3025,"副":-1566,"北":-3414,"区":-422,"大":-1769,"天":-865,"太":-483,"子":-1519,"学":760,"実":1023,"小":-2009,"市":-813,"年":-1060,"強":1067,"手":-1519,"揺":-1033,"政":1522,"文":-1355,"新":-1682,"日":-1815,"明":-1462,"最":-630,"朝":-1843,"本":-1650,"東":-931,"果":-665,"次":-2378,"民":-180,"気":-1740,"理":752,"発":529,"目":-1584,"相":-242,"県":-1165,"立":-763,"第":810,"米":509,"自":-1353,"行":838,"西":-744,"見":-3874,"調":1010,"議":1198,"込":3041,"開":1758,"間":-1257,"「":-645,"」":3145,"ッ":831,"ア":-587,"カ":306,"キ":568};
this.UW3__ = {",":4889,"1":-800,"":-1723,"、":4889,"々":-2311,"":5827,"」":2670,"〓":-3573,"あ":-2696,"い":1006,"う":2342,"え":1983,"お":-4864,"か":-1163,"が":3271,"く":1004,"け":388,"げ":401,"こ":-3552,"ご":-3116,"さ":-1058,"し":-395,"す":584,"せ":3685,"そ":-5228,"た":842,"ち":-521,"っ":-1444,"つ":-1081,"て":6167,"で":2318,"と":1691,"ど":-899,"な":-2788,"に":2745,"の":4056,"は":4555,"ひ":-2171,"ふ":-1798,"へ":1199,"ほ":-5516,"ま":-4384,"み":-120,"め":1205,"も":2323,"や":-788,"よ":-202,"ら":727,"り":649,"る":5905,"れ":2773,"わ":-1207,"を":6620,"ん":-518,"ア":551,"グ":1319,"ス":874,"ッ":-1350,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278,"・":-3794,"一":-1619,"下":-1759,"世":-2087,"両":3815,"中":653,"主":-758,"予":-1193,"二":974,"人":2742,"今":792,"他":1889,"以":-1368,"低":811,"何":4265,"作":-361,"保":-2439,"元":4858,"党":3593,"全":1574,"公":-3030,"六":755,"共":-1880,"円":5807,"再":3095,"分":457,"初":2475,"別":1129,"前":2286,"副":4437,"力":365,"動":-949,"務":-1872,"化":1327,"北":-1038,"区":4646,"千":-2309,"午":-783,"協":-1006,"口":483,"右":1233,"各":3588,"合":-241,"同":3906,"和":-837,"員":4513,"国":642,"型":1389,"場":1219,"外":-241,"妻":2016,"学":-1356,"安":-423,"実":-1008,"家":1078,"小":-513,"少":-3102,"州":1155,"市":3197,"平":-1804,"年":2416,"広":-1030,"府":1605,"度":1452,"建":-2352,"当":-3885,"得":1905,"思":-1291,"性":1822,"戸":-488,"指":-3973,"政":-2013,"教":-1479,"数":3222,"文":-1489,"新":1764,"日":2099,"旧":5792,"昨":-661,"時":-1248,"曜":-951,"最":-937,"月":4125,"期":360,"李":3094,"村":364,"東":-805,"核":5156,"森":2438,"業":484,"氏":2613,"民":-1694,"決":-1073,"法":1868,"海":-495,"無":979,"物":461,"特":-3850,"生":-273,"用":914,"町":1215,"的":7313,"直":-1835,"省":792,"県":6293,"知":-1528,"私":4231,"税":401,"立":-960,"第":1201,"米":7767,"系":3066,"約":3663,"級":1384,"統":-4229,"総":1163,"線":1255,"者":6457,"能":725,"自":-2869,"英":785,"見":1044,"調":-562,"財":-733,"費":1777,"車":1835,"軍":1375,"込":-1504,"通":-1136,"選":-681,"郎":1026,"郡":4404,"部":1200,"金":2163,"長":421,"開":-1432,"間":1302,"関":-1282,"雨":2009,"電":-1045,"非":2066,"駅":1620,"":-800,"」":2670,"・":-3794,"ッ":-1350,"ア":551,"グ":1319,"ス":874,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278};
this.UW4__ = {",":3930,".":3508,"―":-4841,"、":3930,"。":3508,"":4999,"「":1895,"」":3798,"〓":-5156,"あ":4752,"い":-3435,"う":-640,"え":-2514,"お":2405,"か":530,"が":6006,"き":-4482,"ぎ":-3821,"く":-3788,"け":-4376,"げ":-4734,"こ":2255,"ご":1979,"さ":2864,"し":-843,"じ":-2506,"す":-731,"ず":1251,"せ":181,"そ":4091,"た":5034,"だ":5408,"ち":-3654,"っ":-5882,"つ":-1659,"て":3994,"で":7410,"と":4547,"な":5433,"に":6499,"ぬ":1853,"ね":1413,"の":7396,"は":8578,"ば":1940,"ひ":4249,"び":-4134,"ふ":1345,"へ":6665,"べ":-744,"ほ":1464,"ま":1051,"み":-2082,"む":-882,"め":-5046,"も":4169,"ゃ":-2666,"や":2795,"ょ":-1544,"よ":3351,"ら":-2922,"り":-9726,"る":-14896,"れ":-2613,"ろ":-4570,"わ":-1783,"を":13150,"ん":-2352,"カ":2145,"コ":1789,"セ":1287,"ッ":-724,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637,"・":-4371,"ー":-11870,"一":-2069,"中":2210,"予":782,"事":-190,"井":-1768,"人":1036,"以":544,"会":950,"体":-1286,"作":530,"側":4292,"先":601,"党":-2006,"共":-1212,"内":584,"円":788,"初":1347,"前":1623,"副":3879,"力":-302,"動":-740,"務":-2715,"化":776,"区":4517,"協":1013,"参":1555,"合":-1834,"和":-681,"員":-910,"器":-851,"回":1500,"国":-619,"園":-1200,"地":866,"場":-1410,"塁":-2094,"士":-1413,"多":1067,"大":571,"子":-4802,"学":-1397,"定":-1057,"寺":-809,"小":1910,"屋":-1328,"山":-1500,"島":-2056,"川":-2667,"市":2771,"年":374,"庁":-4556,"後":456,"性":553,"感":916,"所":-1566,"支":856,"改":787,"政":2182,"教":704,"文":522,"方":-856,"日":1798,"時":1829,"最":845,"月":-9066,"木":-485,"来":-442,"校":-360,"業":-1043,"氏":5388,"民":-2716,"気":-910,"沢":-939,"済":-543,"物":-735,"率":672,"球":-1267,"生":-1286,"産":-1101,"田":-2900,"町":1826,"的":2586,"目":922,"省":-3485,"県":2997,"空":-867,"立":-2112,"第":788,"米":2937,"系":786,"約":2171,"経":1146,"統":-1169,"総":940,"線":-994,"署":749,"者":2145,"能":-730,"般":-852,"行":-792,"規":792,"警":-1184,"議":-244,"谷":-1000,"賞":730,"車":-1481,"軍":1158,"輪":-1433,"込":-3370,"近":929,"道":-1291,"選":2596,"郎":-4866,"都":1192,"野":-1100,"銀":-2213,"長":357,"間":-2344,"院":-2297,"際":-2604,"電":-878,"領":-1659,"題":-792,"館":-1984,"首":1749,"高":2120,"「":1895,"」":3798,"・":-4371,"ッ":-724,"ー":-11870,"カ":2145,"コ":1789,"セ":1287,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637};
this.UW5__ = {",":465,".":-299,"1":-514,"E2":-32768,"]":-2762,"、":465,"。":-299,"「":363,"あ":1655,"い":331,"う":-503,"え":1199,"お":527,"か":647,"が":-421,"き":1624,"ぎ":1971,"く":312,"げ":-983,"さ":-1537,"し":-1371,"す":-852,"だ":-1186,"ち":1093,"っ":52,"つ":921,"て":-18,"で":-850,"と":-127,"ど":1682,"な":-787,"に":-1224,"の":-635,"は":-578,"べ":1001,"み":502,"め":865,"ゃ":3350,"ょ":854,"り":-208,"る":429,"れ":504,"わ":419,"を":-1264,"ん":327,"イ":241,"ル":451,"ン":-343,"中":-871,"京":722,"会":-1153,"党":-654,"務":3519,"区":-901,"告":848,"員":2104,"大":-1296,"学":-548,"定":1785,"嵐":-1304,"市":-2991,"席":921,"年":1763,"思":872,"所":-814,"挙":1618,"新":-1682,"日":218,"月":-4353,"査":932,"格":1356,"機":-1508,"氏":-1347,"田":240,"町":-3912,"的":-3149,"相":1319,"省":-1052,"県":-4003,"研":-997,"社":-278,"空":-813,"統":1955,"者":-2233,"表":663,"語":-1073,"議":1219,"選":-1018,"郎":-368,"長":786,"間":1191,"題":2368,"館":-689,"":-514,"E2":-32768,"「":363,"イ":241,"ル":451,"ン":-343};
this.UW6__ = {",":227,".":808,"1":-270,"E1":306,"、":227,"。":808,"あ":-307,"う":189,"か":241,"が":-73,"く":-121,"こ":-200,"じ":1782,"す":383,"た":-428,"っ":573,"て":-1014,"で":101,"と":-105,"な":-253,"に":-149,"の":-417,"は":-236,"も":-206,"り":187,"る":-135,"を":195,"ル":-673,"ン":-496,"一":-277,"中":201,"件":-800,"会":624,"前":302,"区":1792,"員":-1212,"委":798,"学":-960,"市":887,"広":-695,"後":535,"業":-697,"相":753,"社":-507,"福":974,"空":-822,"者":1811,"連":463,"郎":1082,"":-270,"E1":306,"ル":-673,"ン":-496};
return this;
}
TinySegmenter.prototype.ctype_ = function(str) {
for (var i in this.chartype_) {
if (str.match(this.chartype_[i][0])) {
return this.chartype_[i][1];
}
}
return "O";
}
TinySegmenter.prototype.ts_ = function(v) {
if (v) { return v; }
return 0;
}
TinySegmenter.prototype.segment = function(input) {
if (input == null || input == undefined || input == "") {
return [];
}
var result = [];
var seg = ["B3","B2","B1"];
var ctype = ["O","O","O"];
var o = input.split("");
for (i = 0; i < o.length; ++i) {
seg.push(o[i]);
ctype.push(this.ctype_(o[i]))
}
seg.push("E1");
seg.push("E2");
seg.push("E3");
ctype.push("O");
ctype.push("O");
ctype.push("O");
var word = seg[3];
var p1 = "U";
var p2 = "U";
var p3 = "U";
for (var i = 4; i < seg.length - 3; ++i) {
var score = this.BIAS__;
var w1 = seg[i-3];
var w2 = seg[i-2];
var w3 = seg[i-1];
var w4 = seg[i];
var w5 = seg[i+1];
var w6 = seg[i+2];
var c1 = ctype[i-3];
var c2 = ctype[i-2];
var c3 = ctype[i-1];
var c4 = ctype[i];
var c5 = ctype[i+1];
var c6 = ctype[i+2];
score += this.ts_(this.UP1__[p1]);
score += this.ts_(this.UP2__[p2]);
score += this.ts_(this.UP3__[p3]);
score += this.ts_(this.BP1__[p1 + p2]);
score += this.ts_(this.BP2__[p2 + p3]);
score += this.ts_(this.UW1__[w1]);
score += this.ts_(this.UW2__[w2]);
score += this.ts_(this.UW3__[w3]);
score += this.ts_(this.UW4__[w4]);
score += this.ts_(this.UW5__[w5]);
score += this.ts_(this.UW6__[w6]);
score += this.ts_(this.BW1__[w2 + w3]);
score += this.ts_(this.BW2__[w3 + w4]);
score += this.ts_(this.BW3__[w4 + w5]);
score += this.ts_(this.TW1__[w1 + w2 + w3]);
score += this.ts_(this.TW2__[w2 + w3 + w4]);
score += this.ts_(this.TW3__[w3 + w4 + w5]);
score += this.ts_(this.TW4__[w4 + w5 + w6]);
score += this.ts_(this.UC1__[c1]);
score += this.ts_(this.UC2__[c2]);
score += this.ts_(this.UC3__[c3]);
score += this.ts_(this.UC4__[c4]);
score += this.ts_(this.UC5__[c5]);
score += this.ts_(this.UC6__[c6]);
score += this.ts_(this.BC1__[c2 + c3]);
score += this.ts_(this.BC2__[c3 + c4]);
score += this.ts_(this.BC3__[c4 + c5]);
score += this.ts_(this.TC1__[c1 + c2 + c3]);
score += this.ts_(this.TC2__[c2 + c3 + c4]);
score += this.ts_(this.TC3__[c3 + c4 + c5]);
score += this.ts_(this.TC4__[c4 + c5 + c6]);
// score += this.ts_(this.TC5__[c4 + c5 + c6]);
score += this.ts_(this.UQ1__[p1 + c1]);
score += this.ts_(this.UQ2__[p2 + c2]);
score += this.ts_(this.UQ3__[p3 + c3]);
score += this.ts_(this.BQ1__[p2 + c2 + c3]);
score += this.ts_(this.BQ2__[p2 + c3 + c4]);
score += this.ts_(this.BQ3__[p3 + c2 + c3]);
score += this.ts_(this.BQ4__[p3 + c3 + c4]);
score += this.ts_(this.TQ1__[p2 + c1 + c2 + c3]);
score += this.ts_(this.TQ2__[p2 + c2 + c3 + c4]);
score += this.ts_(this.TQ3__[p3 + c1 + c2 + c3]);
score += this.ts_(this.TQ4__[p3 + c2 + c3 + c4]);
var p = "O";
if (score > 0) {
result.push(word);
word = "";
p = "B";
}
p1 = p2;
p2 = p3;
p3 = p;
word += seg[i];
}
result.push(word);
return result;
}
lunr.TinySegmenter = TinySegmenter;
};
}));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CACA,yDAAA,CACA,4DAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAzEA,iBCiBF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CCjDE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD6CN,CCvDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoDN,CC9DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2DN,CCrEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkEN,CC5EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyEN,CCnFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgFN,CC1FE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuFN,CCjGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8FN,CCxGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqGN,CC/GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4GN,CCtHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmHN,CC7HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD6HN,CCpIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDoIN,CC3IE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2IN,CClJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkJN,CCzJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDsJN,CE3JE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwJN,CEnKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgKN,CE3KE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwKN,CEnLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgLN,CE3LE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwLN,CEnME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgMN,CE3ME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwMN,CEnNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgNN,CE3NE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwNN,CEnOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgON,CE3OE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwON,CEnPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmPN,CE3PE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2PN,CEnQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmQN,CE3QE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2QN,CEnRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgRN,CE3RE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwRN,CEnSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF4RN,CE5SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFqSN,CEtRE,sEACE,4BFyRJ,CE1RE,+DACE,4BF6RJ,CE9RE,iEACE,4BFiSJ,CElSE,gEACE,4BFqSJ,CEtSE,iEACE,4BFySJ,CEhSA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BFiSF,CE9RE,yCACE,+BFgSJ,CE7RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFiSN,CG7MI,mCD1EA,+CACE,8CF0RJ,CEvRI,qDACE,8CFyRN,CEpRE,iEACE,mCFsRJ,CACF,CGxNI,sCDvDA,uCACE,oCFkRJ,CACF,CEzQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BF0QF,CEvQE,yCACE,+BFyQJ,CEtQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF0QN,CEnQE,yCACE,6CFqQJ,CG9NI,0CDhCA,8CACE,gDFiQJ,CACF,CGnOI,0CDvBA,iFACE,6CF6PJ,CACF,CG3PI,sCDKA,uCACE,6CFyPJ,CACF","file":"palette.css"}
+6
View File
@@ -0,0 +1,6 @@
PORTAINER_URL=Your_Portainer_Server_Adress (optional)
PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
SUPERUSER_USERNAME=Your_Superuser_Username (optional)
SUPERUSER_EMAIL=Your_Superuser_Email (optional)
SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
+300
View File
@@ -0,0 +1,300 @@
import crypto from 'crypto';
import { db } from '../db/index.js';
export const SUPERUSER_GROUP_NAME = 'superuser';
export const AVATAR_COLORS = [
'bg-arcticBlue-600',
'bg-copperRust-500',
'bg-sunsetCoral-600',
'bg-mintTea-400',
'bg-lavenderSmoke-500',
'bg-emeraldMist-500',
'bg-roseQuartz-500',
'bg-auroraTeal-500',
'bg-citrusPunch-500',
'bg-mossGreen-400'
];
export const DEFAULT_AVATAR_COLOR = 'bg-mossGreen-500';
const AVATAR_COLOR_SET = new Set([...AVATAR_COLORS, DEFAULT_AVATAR_COLOR]);
export const pickRandomAvatarColor = () => {
if (!Array.isArray(AVATAR_COLORS) || AVATAR_COLORS.length === 0) {
return DEFAULT_AVATAR_COLOR;
}
const index = Math.floor(Math.random() * AVATAR_COLORS.length);
return AVATAR_COLORS[index] ?? DEFAULT_AVATAR_COLOR;
};
export const normalizeAvatarColor = (value) => {
if (!value) return null;
const candidate = String(value).trim();
return AVATAR_COLOR_SET.has(candidate) ? candidate : null;
};
const selectGroupByName = db.prepare('SELECT * FROM user_groups WHERE name = ?');
const insertGroup = db.prepare('INSERT INTO user_groups (name, description) VALUES (?, ?)');
const selectSuperuser = db.prepare(`
SELECT u.*
FROM users u
INNER JOIN user_group_memberships m ON m.user_id = u.id
INNER JOIN user_groups g ON g.id = m.group_id
WHERE g.name = ?
LIMIT 1
`);
const selectUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
const selectUserById = db.prepare('SELECT * FROM users WHERE id = ?');
const updateLastLogin = db.prepare(`
UPDATE users
SET last_login = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const insertUser = db.prepare(`
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateUser = db.prepare(`
UPDATE users
SET username = ?, email = ?, password_hash = ?, password_salt = ?, is_active = 1, avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const insertMembership = db.prepare(`
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
VALUES (?, ?)
`);
const removeMembershipsForGroup = db.prepare(`
DELETE FROM user_group_memberships
WHERE group_id = ?
`);
const selectUserIdsForGroup = db.prepare(`
SELECT u.id
FROM users u
INNER JOIN user_group_memberships m ON m.user_id = u.id
WHERE m.group_id = ?
`);
const deleteUserById = db.prepare('DELETE FROM users WHERE id = ?');
const deleteGroupById = db.prepare('DELETE FROM user_groups WHERE id = ?');
const countSuperusers = db.prepare(`
SELECT COUNT(*) as count
FROM user_group_memberships m
INNER JOIN user_groups g ON g.id = m.group_id
WHERE g.name = ?
`);
const creationTransaction = db.transaction(({ username, email, passwordHash, passwordSalt, groupId, avatarColor }) => {
const existingUser = selectUserByUsername.get(username) || selectUserByEmail.get(email);
let userId;
if (existingUser) {
const existingColor = normalizeAvatarColor(existingUser.avatar_color);
const normalizedNewColor = normalizeAvatarColor(avatarColor);
const colorToPersist = existingColor || normalizedNewColor || DEFAULT_AVATAR_COLOR;
updateUser.run(username, email, passwordHash, passwordSalt, colorToPersist, existingUser.id);
userId = existingUser.id;
} else {
const colorToPersist = normalizeAvatarColor(avatarColor) || DEFAULT_AVATAR_COLOR;
const result = insertUser.run(username, email, passwordHash, passwordSalt, colorToPersist);
userId = result.lastInsertRowid;
}
removeMembershipsForGroup.run(groupId);
insertMembership.run(userId, groupId);
return selectUserById.get(userId);
});
export function hashPassword(password) {
if (!password || typeof password !== 'string') {
const err = new Error('INVALID_PASSWORD');
err.code = 'INVALID_PASSWORD';
throw err;
}
const trimmed = password.trim();
if (!trimmed) {
const err = new Error('INVALID_PASSWORD');
err.code = 'INVALID_PASSWORD';
throw err;
}
if (trimmed.length < 8) {
const err = new Error('PASSWORD_TOO_SHORT');
err.code = 'PASSWORD_TOO_SHORT';
throw err;
}
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
return { salt, hash };
}
export function verifyPassword(password, hash, salt) {
if (!hash || !salt) return false;
if (!password || typeof password !== 'string') return false;
const trimmed = password.trim();
if (!trimmed) return false;
const derived = crypto.pbkdf2Sync(trimmed, salt, 120000, 64, 'sha512').toString('hex');
const storedBuffer = Buffer.from(hash, 'hex');
const derivedBuffer = Buffer.from(derived, 'hex');
if (storedBuffer.length !== derivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(storedBuffer, derivedBuffer);
}
function ensureGroup(name, description = null) {
let group = selectGroupByName.get(name);
if (group) return group;
insertGroup.run(name, description);
group = selectGroupByName.get(name);
return group;
}
export function hasSuperuser() {
const { count } = countSuperusers.get(SUPERUSER_GROUP_NAME);
return count > 0;
}
export function findUserByIdentifier(identifier) {
const value = String(identifier || '').trim();
if (!value) return null;
if (value.includes('@')) {
return selectUserByEmail.get(value.toLowerCase());
}
return selectUserByUsername.get(value);
}
export function findUserById(id) {
if (!id) return null;
return selectUserById.get(id);
}
export function markUserLogin(userId) {
if (!userId) return;
updateLastLogin.run(userId);
}
function createOrUpdateSuperuser({ username, email, password }) {
const normalizedUsername = String(username || '').trim();
const normalizedEmail = String(email || '').trim().toLowerCase();
if (!normalizedUsername) {
throw new Error('USERNAME_REQUIRED');
}
if (!normalizedEmail || !normalizedEmail.includes('@')) {
throw new Error('EMAIL_INVALID');
}
const { hash, salt } = hashPassword(password);
const group = ensureGroup(SUPERUSER_GROUP_NAME, 'System Superuser');
const avatarColor = pickRandomAvatarColor();
const user = creationTransaction({
username: normalizedUsername,
email: normalizedEmail,
passwordHash: hash,
passwordSalt: salt,
groupId: group.id,
avatarColor
});
const persistedColor = normalizeAvatarColor(user.avatar_color) || avatarColor || DEFAULT_AVATAR_COLOR;
return {
id: user.id,
username: user.username,
email: user.email,
avatarColor: persistedColor
};
}
const deletionTransaction = db.transaction((groupId) => {
const members = selectUserIdsForGroup.all(groupId);
members.forEach(({ id }) => {
deleteUserById.run(id);
});
const { changes: groupChanges } = deleteGroupById.run(groupId);
return {
usersRemoved: members.length,
groupRemoved: groupChanges > 0
};
});
export function ensureSuperuserFromEnv() {
const username = process.env.SUPERUSER_USERNAME;
const email = process.env.SUPERUSER_EMAIL;
const password = process.env.SUPERUSER_PASSWORD;
if (hasSuperuser()) {
if (username || email || password) {
console.log('️ Superuser existiert bereits - Umgebungsvariablen werden ignoriert.');
}
return false;
}
if (!username || !email || !password) {
return false;
}
try {
createOrUpdateSuperuser({ username, email, password });
console.log('✅ Superuser aus Umgebungsvariablen angelegt.');
return true;
} catch (error) {
console.error('⚠️ Superuser konnte nicht aus Umgebungsvariablen erzeugt werden:', error.message);
return false;
}
}
export function registerSuperuser({ username, email, password }) {
if (hasSuperuser()) {
const err = new Error('SUPERUSER_ALREADY_EXISTS');
err.code = 'SUPERUSER_ALREADY_EXISTS';
throw err;
}
try {
return createOrUpdateSuperuser({ username, email, password });
} catch (error) {
if (error.message === 'USERNAME_REQUIRED' || error.message === 'EMAIL_INVALID' || error.message === 'PASSWORD_TOO_SHORT') {
error.code = error.message;
}
throw error;
}
}
export function removeSuperuser() {
const group = selectGroupByName.get(SUPERUSER_GROUP_NAME);
if (!group) {
const error = new Error('SUPERUSER_NOT_FOUND');
error.code = 'SUPERUSER_NOT_FOUND';
throw error;
}
return deletionTransaction(group.id);
}
export function getSuperuserSummary() {
const user = selectSuperuser.get(SUPERUSER_GROUP_NAME);
if (!user) return null;
return {
id: user.id,
username: user.username,
email: user.email
};
}
+1
View File
@@ -0,0 +1 @@
stackpulse.db
+200
View File
@@ -0,0 +1,200 @@
-- Stackpulse database schema blueprint
-- This document captures the expected structure of all tables, indexes and core seed requirements.
CREATE TABLE event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
category TEXT NOT NULL,
event_type TEXT,
action TEXT,
status TEXT,
severity TEXT,
entity_type TEXT,
entity_id TEXT,
entity_name TEXT,
actor_type TEXT,
actor_id TEXT,
actor_name TEXT,
source TEXT,
context_type TEXT,
context_id TEXT,
context_label TEXT,
message TEXT,
metadata TEXT
);
CREATE INDEX idx_event_logs_timestamp ON event_logs (timestamp DESC);
CREATE INDEX idx_event_logs_category ON event_logs (category);
CREATE INDEX idx_event_logs_event_type ON event_logs (event_type);
CREATE INDEX idx_event_logs_entity ON event_logs (entity_type, entity_id);
CREATE INDEX idx_event_logs_context ON event_logs (context_type, context_id);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT,
avatar_color TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_login DATETIME,
security_phrase_content TEXT,
security_phrase_iv TEXT,
security_phrase_tag TEXT,
security_phrase_downloaded_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
avatar_color TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_group_memberships (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
CREATE TABLE permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_group_permissions (
group_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, permission_id),
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
CREATE TABLE permission_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL,
key TEXT NOT NULL,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(section_id, key),
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
);
CREATE TABLE permission_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL,
group_id INTEGER,
key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
default_level TEXT NOT NULL,
available_levels TEXT NOT NULL,
is_required INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
);
CREATE TABLE permission_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
permission_id INTEGER NOT NULL,
depends_on_permission_id INTEGER NOT NULL,
required_level TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
UNIQUE(permission_id, depends_on_permission_id)
);
CREATE INDEX idx_permission_dependencies_required
ON permission_dependencies (permission_id, depends_on_permission_id);
CREATE TABLE group_permission_values (
group_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
level TEXT NOT NULL,
effective_level TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, permission_id),
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
);
CREATE TABLE servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_server_permission_overrides (
user_id INTEGER NOT NULL,
server_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
change_type TEXT NOT NULL CHECK (change_type IN ('ADD', 'REMOVE')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, server_id, permission_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
CREATE INDEX idx_user_server_permission_overrides_user_server
ON user_server_permission_overrides (user_id, server_id);
CREATE TABLE server_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE,
key_cipher TEXT NOT NULL,
key_iv TEXT NOT NULL,
key_tag TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE TABLE user_settings (
user_id INTEGER NOT NULL,
setting_key TEXT NOT NULL,
setting_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, setting_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Core seed expectations (keys only maintained via schemaEnsure)
-- permission_sections: stacks, logs, users, user-groups, maintenance
-- permission_items: includes users-security-phrase (available_levels ["full","none"])
+14
View File
@@ -0,0 +1,14 @@
import { ensureDatabaseSchema } from './schemaEnsure.js';
const run = async () => {
try {
ensureDatabaseSchema();
console.log('✅ Datenbankschema abgeglichen.');
process.exit(0);
} catch (error) {
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
process.exit(1);
}
};
run();
+18
View File
@@ -0,0 +1,18 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, '..', 'data');
const dbFile = path.join(dataDir, 'stackpulse.db');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbFile);
db.pragma('journal_mode = WAL');
export { db, dbFile };
+738
View File
@@ -0,0 +1,738 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { db } from './index.js';
const BLUEPRINT_FILENAME = 'dbs';
const PERMISSION_BLUEPRINT = [
{
key: 'stacks',
title: 'Stacks',
sortOrder: 0,
hasNavigation: 0,
items: [
{
key: 'stacks-redeploy-single',
label: 'Redeploy einzeln',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: []
},
{
key: 'stacks-redeploy-selection',
label: 'Redeploy Auswahl',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: []
},
{
key: 'stacks-redeploy-all',
label: 'Redeploy Alle',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: []
}
]
},
{
key: 'logs',
title: 'Logs',
sortOrder: 1,
hasNavigation: 1,
items: [
{
key: 'logs-access',
label: 'Bereich & Navigation',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
isRequired: 1,
dependencies: []
},
{
key: 'logs-export',
label: 'Logs Exportieren',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
]
},
{
key: 'logs-delete',
label: 'Logs löschen',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'logs-access', requiredLevel: '!=none' }
]
}
]
},
{
key: 'users',
title: 'Benutzer',
sortOrder: 2,
hasNavigation: 1,
items: [
{
key: 'users-access',
label: 'Bereich & Navigation',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
isRequired: 1,
dependencies: []
},
{
key: 'users-edit',
label: 'Benutzer bearbeiten',
sortOrder: 1,
defaultLevel: 'read',
levels: ['full', 'read'],
dependencies: [
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
]
},
{
key: 'users-delete',
label: 'Benutzer löschen',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
]
},
{
key: 'users-security-phrase',
label: 'Sicherheitsschlüssel',
sortOrder: 3,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'users-access', requiredLevel: '!=none' }
]
}
]
},
{
key: 'user-groups',
title: 'Benutzergruppen',
sortOrder: 3,
hasNavigation: 1,
items: [
{
key: 'user-groups-access',
label: 'Bereich & Navigation',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
isRequired: 1,
dependencies: []
},
{
key: 'user-groups-edit',
label: 'Benutzergruppen bearbeiten',
sortOrder: 1,
defaultLevel: 'read',
levels: ['full', 'read'],
dependencies: [
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
]
},
{
key: 'user-groups-delete',
label: 'Benutzergruppen löschen',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'user-groups-access', requiredLevel: '!=none' }
]
}
]
},
{
key: 'maintenance',
title: 'Wartung',
sortOrder: 4,
hasNavigation: 1,
items: [
{
key: 'maintenance-access',
label: 'Bereich & Navigation',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
isRequired: 1,
dependencies: []
}
],
groups: [
{
key: 'maintenance-server-group',
title: 'Server',
sortOrder: 0,
items: [
{
key: 'maintenance-server-manage',
label: 'Server-Sektion',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'read', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
]
},
{
key: 'maintenance-server-delete',
label: 'Server löschen',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
{ dependsOnKey: 'maintenance-server-manage', requiredLevel: '!=none' }
]
}
]
},
{
key: 'maintenance-superuser-group',
title: 'Superuser',
sortOrder: 1,
items: [
{
key: 'maintenance-superuser-delete',
label: 'Superuser löschen',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
]
}
]
},
{
key: 'maintenance-portainer-group',
title: 'Portainer',
sortOrder: 2,
items: [
{
key: 'maintenance-portainer',
label: 'Portainer-Sektion',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
]
},
{
key: 'maintenance-ssh-update',
label: 'SSH/Update-Skript',
sortOrder: 1,
defaultLevel: 'none',
levels: ['full', 'read', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
]
},
{
key: 'maintenance-update',
label: 'Update durchführen',
sortOrder: 2,
defaultLevel: 'none',
levels: ['full', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' },
{ dependsOnKey: 'maintenance-portainer', requiredLevel: '!=none' }
]
}
]
},
{
key: 'maintenance-duplicates-group',
title: 'Doppelte Stacks',
sortOrder: 3,
items: [
{
key: 'maintenance-duplicates',
label: 'Doppelte Stacks',
sortOrder: 0,
defaultLevel: 'none',
levels: ['full', 'read', 'none'],
dependencies: [
{ dependsOnKey: 'maintenance-access', requiredLevel: '!=none' }
]
}
]
}
]
}
];
const TABLE_REGEX = /^CREATE TABLE\s+([^\s(]+)\s*\(/i;
const INDEX_REGEX = /^CREATE INDEX\s+([^\s(]+)\s+ON\s+([^\s(]+)\s*\(/i;
const COLUMN_SKIP_PREFIXES = ['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK', 'CONSTRAINT'];
const normalizeIdentifier = (value) => value.replace(/`|"/g, '').trim();
const loadBlueprintStatements = () => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const filePath = path.join(__dirname, BLUEPRINT_FILENAME);
const content = fs.readFileSync(filePath, 'utf8');
const cleanContent = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('--'))
.join('\n');
return cleanContent
.split(';')
.map((statement) => statement.trim())
.filter(Boolean);
};
const parseTableDefinitions = (statements) => {
const tables = new Map();
statements.forEach((statement) => {
const match = statement.match(TABLE_REGEX);
if (!match) {
return;
}
const tableName = normalizeIdentifier(match[1]);
const body = statement.slice(statement.indexOf('(') + 1, statement.lastIndexOf(')'));
const lines = body
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const columns = [];
lines.forEach((line) => {
const cleaned = line.replace(/,$/, '');
const upper = cleaned.toUpperCase();
if (COLUMN_SKIP_PREFIXES.some((prefix) => upper.startsWith(prefix))) {
return;
}
const [rawName, ...restParts] = cleaned.split(/\s+/);
if (!rawName || restParts.length === 0) {
return;
}
const columnName = normalizeIdentifier(rawName);
const definition = restParts.join(' ');
columns.push({ name: columnName, definition });
});
tables.set(tableName, {
createStatement: statement,
columns
});
});
return tables;
};
const parseIndexStatements = (statements) => {
const indexes = [];
statements.forEach((statement) => {
const match = statement.match(INDEX_REGEX);
if (!match) {
return;
}
const indexName = normalizeIdentifier(match[1]);
indexes.push({
name: indexName,
statement
});
});
return indexes;
};
const tableExists = (tableName) => {
const result = db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`)
.get(tableName);
return Boolean(result);
};
const dropDeprecatedArtifacts = () => {
const deprecatedTables = ['user_endpoint_permission_overrides', 'endpoints'];
deprecatedTables.forEach((table) => {
if (tableExists(table)) {
db.exec(`DROP TABLE IF EXISTS ${table}`);
console.log(`️ Veraltete Tabelle ${table} entfernt`);
}
});
};
const getExistingColumns = (tableName) => {
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all();
return new Set(pragma.map((column) => column.name));
};
const indexExists = (indexName) => {
const result = db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1`)
.get(indexName);
return Boolean(result);
};
const ensureTablesAndColumns = (tables) => {
tables.forEach(({ createStatement, columns }, tableName) => {
if (!tableExists(tableName)) {
const statementWithGuard = createStatement.replace(/^CREATE TABLE/i, 'CREATE TABLE IF NOT EXISTS');
db.exec(`${statementWithGuard};`);
console.log(`️ Tabelle ${tableName} angelegt`);
return;
}
const existingColumns = getExistingColumns(tableName);
const missingColumns = columns.filter((column) => !existingColumns.has(column.name));
missingColumns.forEach((column) => {
try {
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.definition};`);
console.log(`️ Column ${column.name} added to ${tableName}`);
} catch (error) {
console.warn(`⚠️ Column ${column.name} could not be added to ${tableName}: ${error.message}`);
}
});
});
};
const ensureIndexes = (indexes) => {
indexes.forEach(({ name, statement }) => {
if (indexExists(name)) {
return;
}
const statementWithGuard = statement.replace(/^CREATE INDEX/i, 'CREATE INDEX IF NOT EXISTS');
db.exec(`${statementWithGuard};`);
console.log(`️ Index ${name} angelegt`);
});
};
const ensurePermissionSeeds = () => {
const selectSection = db.prepare(`
SELECT id, title, sort_order, has_navigation_flag
FROM permission_sections
WHERE key = ?
LIMIT 1
`);
const insertSection = db.prepare(`
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateSection = db.prepare(`
UPDATE permission_sections
SET title = ?, sort_order = ?, has_navigation_flag = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectGroup = db.prepare(`
SELECT id, title, sort_order
FROM permission_groups
WHERE section_id = ? AND key = ?
LIMIT 1
`);
const insertGroup = db.prepare(`
INSERT INTO permission_groups (section_id, key, title, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateGroup = db.prepare(`
UPDATE permission_groups
SET title = ?, sort_order = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectItem = db.prepare(`
SELECT
id,
section_id,
group_id,
label,
sort_order,
default_level,
available_levels,
is_required
FROM permission_items
WHERE key = ?
LIMIT 1
`);
const insertItem = db.prepare(`
INSERT INTO permission_items (
section_id,
group_id,
key,
label,
sort_order,
default_level,
available_levels,
is_required,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
`);
const updateItem = db.prepare(`
UPDATE permission_items
SET
section_id = ?,
group_id = ?,
label = ?,
sort_order = ?,
default_level = ?,
available_levels = ?,
is_required = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectDependency = db.prepare(`
SELECT id, required_level
FROM permission_dependencies
WHERE permission_id = ? AND depends_on_permission_id = ?
LIMIT 1
`);
const insertDependency = db.prepare(`
INSERT INTO permission_dependencies (
permission_id,
depends_on_permission_id,
required_level,
created_at,
updated_at
) VALUES (
?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
`);
const updateDependency = db.prepare(`
UPDATE permission_dependencies
SET required_level = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const itemKeyToId = new Map();
PERMISSION_BLUEPRINT.forEach((section, sectionIndex) => {
const existingSection = selectSection.get(section.key);
let sectionId;
if (!existingSection) {
const result = insertSection.run(
section.key,
section.title,
section.sortOrder ?? sectionIndex,
section.hasNavigation ? 1 : 0
);
sectionId = Number(result.lastInsertRowid);
console.log(`️ Berechtigungs-Sektion ${section.key} angelegt`);
} else {
updateSection.run(
section.title,
section.sortOrder ?? sectionIndex,
section.hasNavigation ? 1 : 0,
existingSection.id
);
sectionId = existingSection.id;
if (
existingSection.title !== section.title ||
(existingSection.sort_order ?? sectionIndex) !== (section.sortOrder ?? sectionIndex) ||
Number(existingSection.has_navigation_flag) !== (section.hasNavigation ? 1 : 0)
) {
console.log(`️ Berechtigungs-Sektion ${section.key} aktualisiert`);
}
}
const sectionItems = Array.isArray(section.items) ? section.items : [];
sectionItems.forEach((item, itemIdx) => {
const existingItem = selectItem.get(item.key);
const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
const levelOptions = Array.isArray(item.levels) && item.levels.length
? item.levels
: ['full', 'read', 'none'];
const availableLevels = JSON.stringify(levelOptions);
const isRequired = item.isRequired ? 1 : 0;
if (!existingItem) {
const result = insertItem.run(
sectionId,
null,
item.key,
item.label,
sortOrder,
item.defaultLevel || 'none',
availableLevels,
isRequired
);
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
console.log(`️ Berechtigung ${item.key} angelegt`);
} else {
const hasChanges =
existingItem.section_id !== sectionId ||
existingItem.group_id !== null ||
existingItem.label !== item.label ||
(existingItem.sort_order ?? sortOrder) !== sortOrder ||
existingItem.default_level !== (item.defaultLevel || 'none') ||
existingItem.available_levels !== availableLevels ||
Number(existingItem.is_required) !== isRequired;
updateItem.run(
sectionId,
null,
item.label,
sortOrder,
item.defaultLevel || 'none',
availableLevels,
isRequired,
existingItem.id
);
itemKeyToId.set(item.key, existingItem.id);
if (hasChanges) {
console.log(`️ Berechtigung ${item.key} aktualisiert`);
}
}
});
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
sectionGroups.forEach((group, groupIdx) => {
const existingGroup = selectGroup.get(sectionId, group.key);
const sortOrder = typeof group.sortOrder === 'number' ? group.sortOrder : groupIdx;
let groupId;
if (!existingGroup) {
const result = insertGroup.run(sectionId, group.key, group.title, sortOrder);
groupId = Number(result.lastInsertRowid);
console.log(`️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} angelegt`);
} else {
const hasGroupChanges =
existingGroup.title !== group.title ||
(existingGroup.sort_order ?? sortOrder) !== sortOrder;
updateGroup.run(group.title, sortOrder, existingGroup.id);
groupId = existingGroup.id;
if (hasGroupChanges) {
console.log(`️ Berechtigungs-Gruppe ${group.key} in Sektion ${section.key} aktualisiert`);
}
}
const groupItems = Array.isArray(group.items) ? group.items : [];
groupItems.forEach((item, itemIdx) => {
const existingItem = selectItem.get(item.key);
const itemSortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : itemIdx;
const levelOptions = Array.isArray(item.levels) && item.levels.length
? item.levels
: ['full', 'read', 'none'];
const availableLevels = JSON.stringify(levelOptions);
const isRequired = item.isRequired ? 1 : 0;
if (!existingItem) {
const result = insertItem.run(
sectionId,
groupId,
item.key,
item.label,
itemSortOrder,
item.defaultLevel || 'none',
availableLevels,
isRequired
);
itemKeyToId.set(item.key, Number(result.lastInsertRowid));
console.log(`️ Berechtigung ${item.key} in Gruppe ${group.key} angelegt`);
} else {
const hasChanges =
existingItem.section_id !== sectionId ||
existingItem.group_id !== groupId ||
existingItem.label !== item.label ||
(existingItem.sort_order ?? itemSortOrder) !== itemSortOrder ||
existingItem.default_level !== (item.defaultLevel || 'none') ||
existingItem.available_levels !== availableLevels ||
Number(existingItem.is_required) !== isRequired;
updateItem.run(
sectionId,
groupId,
item.label,
itemSortOrder,
item.defaultLevel || 'none',
availableLevels,
isRequired,
existingItem.id
);
itemKeyToId.set(item.key, existingItem.id);
if (hasChanges) {
console.log(`️ Berechtigung ${item.key} in Gruppe ${group.key} aktualisiert`);
}
}
});
});
});
PERMISSION_BLUEPRINT.forEach((section) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
sectionItems.forEach((item) => {
const permissionId = itemKeyToId.get(item.key);
if (!permissionId) return;
(item.dependencies || []).forEach((dependency) => {
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
if (!dependsId) return;
const existing = selectDependency.get(permissionId, dependsId);
const requiredLevel = dependency.requiredLevel ?? null;
if (!existing) {
insertDependency.run(permissionId, dependsId, requiredLevel);
console.log(
`️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
);
} else if (existing.required_level !== requiredLevel) {
updateDependency.run(requiredLevel, existing.id);
console.log(
`️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
);
}
});
});
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
sectionGroups.forEach((group) => {
const groupItems = Array.isArray(group.items) ? group.items : [];
groupItems.forEach((item) => {
const permissionId = itemKeyToId.get(item.key);
if (!permissionId) return;
(item.dependencies || []).forEach((dependency) => {
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
if (!dependsId) return;
const requiredLevel = dependency.requiredLevel ?? null;
const existing = selectDependency.get(permissionId, dependsId);
if (!existing) {
insertDependency.run(permissionId, dependsId, requiredLevel);
console.log(
`️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} angelegt`
);
} else if (existing.required_level !== requiredLevel) {
updateDependency.run(requiredLevel, existing.id);
console.log(
`️ Abhängigkeit ${item.key} -> ${dependency.dependsOnKey} aktualisiert`
);
}
});
});
});
});
};
export const ensureDatabaseSchema = () => {
try {
const statements = loadBlueprintStatements();
const tableDefinitions = parseTableDefinitions(statements);
const indexDefinitions = parseIndexStatements(statements);
dropDeprecatedArtifacts();
ensureTablesAndColumns(tableDefinitions);
ensureIndexes(indexDefinitions);
ensurePermissionSeeds();
} catch (error) {
console.error('⚠️ Schema-Abgleich fehlgeschlagen:', error);
}
};
+61
View File
@@ -0,0 +1,61 @@
import { db } from './index.js';
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
const setSettingStmt = db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (@key, @value, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
export function getSetting(key) {
try {
return getSettingStmt.get(key) || null;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
return null;
}
}
export function setSetting(key, value) {
try {
setSettingStmt.run({ key, value });
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
return false;
}
}
export function deleteSetting(key) {
try {
deleteSettingStmt.run(key);
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
return false;
}
}
export function getJsonSetting(key, defaultValue = null) {
const row = getSetting(key);
if (!row || row.value === null || row.value === undefined) return defaultValue;
try {
return JSON.parse(row.value);
} catch (err) {
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
return defaultValue;
}
}
export function setJsonSetting(key, value) {
try {
const payload = value === undefined ? null : JSON.stringify(value);
return setSetting(key, payload);
} catch (err) {
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
return false;
}
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "🔧 Überprüfe Datenbankschema..."
node db/ensure.js
echo "✅ Schema-Abgleich abgeschlossen. Starte Anwendung..."
exec "$@"
+342
View File
@@ -0,0 +1,342 @@
import { db } from '../db/index.js';
import {
normalizeAvatarColor,
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME,
pickRandomAvatarColor
} from '../auth/superuser.js';
import { logEvent } from '../logging/eventLogs.js';
const selectGroupsWithMembers = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
GROUP BY g.id
ORDER BY g.name COLLATE NOCASE
`);
const selectGroupWithMembersById = db.prepare(`
SELECT
g.id,
g.name,
g.description,
g.avatar_color,
g.created_at,
g.updated_at,
COUNT(DISTINCT u.id) AS member_count,
GROUP_CONCAT(u.id || '::' || u.username, '|||') AS member_pairs
FROM user_groups g
LEFT JOIN user_group_memberships m ON m.group_id = g.id
LEFT JOIN users u ON u.id = m.user_id
WHERE g.id = ?
GROUP BY g.id
`);
const selectGroupByIdBasic = db.prepare(`
SELECT id, name, description, avatar_color
FROM user_groups
WHERE id = ?
`);
const selectGroupIdByName = db.prepare(`
SELECT id
FROM user_groups
WHERE lower(name) = lower(?)
LIMIT 1
`);
const insertGroupStatement = db.prepare(`
INSERT INTO user_groups (name, description, avatar_color, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateGroupStatement = db.prepare(`
UPDATE user_groups
SET name = ?,
description = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const countGroupMembersStatement = db.prepare(`
SELECT COUNT(*) AS member_count
FROM user_group_memberships
WHERE group_id = ?
`);
const deleteGroupStatement = db.prepare(`
DELETE FROM user_groups
WHERE id = ?
`);
const parseMembers = (rawPairs) => {
if (!rawPairs) {
return [];
}
return rawPairs
.split('|||')
.map((entry) => {
const [idPart, usernamePart] = entry.split('::');
const numericId = Number(idPart);
return {
id: Number.isFinite(numericId) ? numericId : idPart,
username: (usernamePart || '').trim()
};
})
.filter((member) => member.username);
};
const sanitizeGroup = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
avatarColor: row.avatar_color || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
memberCount: Number(row.member_count) || 0,
members: parseMembers(row.member_pairs)
});
export function listGroups() {
const rows = selectGroupsWithMembers.all();
return rows.map(sanitizeGroup);
}
export function getGroupById(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return null;
}
const row = selectGroupWithMembersById.get(numericId);
return row ? sanitizeGroup(row) : null;
}
export function createGroup({ name, description }) {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const existing = selectGroupIdByName.get(normalizedName);
if (existing) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
const normalizedDescription = typeof description === 'string' ? description.trim() : null;
const avatarColor = pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const insertResult = insertGroupStatement.run(normalizedName, normalizedDescription || null, avatarColor);
const groupId = Number(insertResult.lastInsertRowid);
const row = selectGroupWithMembersById.get(groupId);
const group = row ? sanitizeGroup(row) : null;
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(groupId),
entityName: group?.name ?? normalizedName,
message: `Benutzergruppe "${normalizedName}" angelegt`,
metadata: {
description: group?.description ?? normalizedDescription ?? null,
avatarColor
}
});
return group;
}
export function updateGroupDetails(groupId, { name, description, avatarColor } = {}) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const normalizedName = typeof name === 'string' ? name.trim() : existingRow.name || '';
if (!normalizedName) {
const error = new Error('GROUP_NAME_REQUIRED');
error.code = 'GROUP_NAME_REQUIRED';
throw error;
}
const nameRow = selectGroupIdByName.get(normalizedName);
if (nameRow && Number(nameRow.id) !== numericId) {
const error = new Error('GROUP_NAME_TAKEN');
error.code = 'GROUP_NAME_TAKEN';
throw error;
}
let normalizedDescription;
if (description === undefined) {
normalizedDescription = existingRow.description ?? null;
} else if (description === null) {
normalizedDescription = null;
} else {
const trimmedDescription = typeof description === 'string' ? description.trim() : '';
normalizedDescription = trimmedDescription ? trimmedDescription : null;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
if (name !== undefined) {
const incomingName = typeof name === 'string' ? name.trim() : '';
if (incomingName && incomingName !== existingRow.name) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
if (description !== undefined) {
const existingDescriptionComparable = existingRow.description === null || existingRow.description === undefined
? ''
: String(existingRow.description).trim();
const incomingDescriptionComparable = description === null
? ''
: typeof description === 'string'
? description.trim()
: '';
if (incomingDescriptionComparable !== existingDescriptionComparable) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
}
// Enforce original values for protected fields
normalizedDescription = existingRow.description ?? null;
}
let colorToPersist = existingRow.avatar_color || null;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalized = normalizeAvatarColor(candidate);
if (!normalized) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalized;
}
}
updateGroupStatement.run(
isSuperuserGroup ? existingRow.name : normalizedName,
normalizedDescription,
colorToPersist,
numericId
);
const updatedGroup = getGroupById(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: updatedGroup?.name ?? normalizedName,
message: `Benutzergruppe "${updatedGroup?.name ?? normalizedName}" aktualisiert`,
metadata: {
previous: {
name: existingRow.name,
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null
},
current: updatedGroup
? {
name: updatedGroup.name,
description: updatedGroup.description ?? null,
avatarColor: updatedGroup.avatarColor ?? null,
memberCount: updatedGroup.memberCount ?? 0
}
: {
name: normalizedName,
description: normalizedDescription ?? null,
avatarColor: colorToPersist
}
}
});
return updatedGroup;
}
export function deleteGroup(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const existingRow = selectGroupByIdBasic.get(numericId);
if (!existingRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
const isSuperuserGroup = (existingRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
if (isSuperuserGroup) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const { member_count: memberCount } = countGroupMembersStatement.get(numericId);
if (Number(memberCount) > 0) {
const error = new Error('GROUP_HAS_MEMBERS');
error.code = 'GROUP_HAS_MEMBERS';
error.memberCount = Number(memberCount);
throw error;
}
deleteGroupStatement.run(numericId);
logEvent({
category: 'benutzergruppe',
eventType: 'gruppe-gelöscht',
action: 'löschen',
status: 'erfolgreich',
entityType: 'gruppe',
entityId: String(numericId),
entityName: existingRow.name ?? `ID ${numericId}`,
message: `Benutzergruppe "${existingRow.name ?? numericId}" gelöscht`,
metadata: {
description: existingRow.description ?? null,
avatarColor: existingRow.avatar_color ?? null,
memberCountBeforeDelete: Number(memberCount)
}
});
}
-1429
View File
File diff suppressed because it is too large Load Diff
+3612
View File
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
import { db } from '../db/index.js';
const valueToArray = (value) => {
if (value === undefined || value === null) return [];
const base = Array.isArray(value) ? value : [value];
return base
.flatMap((entry) => String(entry).split(','))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
};
const singleValue = (value) => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
const serializeJson = (value) => {
if (value === undefined || value === null || value === '') return null;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (err) {
console.error('⚠️ Konnte JSON nicht serialisieren:', err.message);
return JSON.stringify({ serializationError: true });
}
};
const appendInFilter = (filters, params, values, column, prefix) => {
if (!values.length) return;
const placeholders = values.map((_, idx) => {
const key = `${prefix}${idx}`;
params[key] = values[idx];
return `@${key}`;
});
filters.push(`${column} IN (${placeholders.join(', ')})`);
};
export function buildEventLogFilter(queryParams = {}) {
const filters = [];
const params = {};
const ids = valueToArray(queryParams.ids ?? queryParams.id)
.map((entry) => Number(entry))
.filter((value) => Number.isFinite(value));
if (ids.length) {
appendInFilter(filters, params, ids, 'id', 'id');
}
const categories = valueToArray(queryParams.categories ?? queryParams.category);
appendInFilter(filters, params, categories, 'category', 'category');
const statuses = valueToArray(queryParams.statuses ?? queryParams.status);
appendInFilter(filters, params, statuses, 'status', 'status');
const actions = valueToArray(queryParams.actions ?? queryParams.action);
appendInFilter(filters, params, actions, 'action', 'action');
const eventTypes = [
...valueToArray(queryParams.eventTypes ?? queryParams.eventType),
...valueToArray(queryParams.redeployTypes ?? queryParams.redeployType)
];
if (eventTypes.length) {
const unique = Array.from(new Set(eventTypes));
appendInFilter(filters, params, unique, 'event_type', 'eventType');
}
const severities = valueToArray(queryParams.severities ?? queryParams.severity);
appendInFilter(filters, params, severities, 'severity', 'severity');
const entityTypes = valueToArray(queryParams.entityTypes ?? queryParams.entityType);
appendInFilter(filters, params, entityTypes, 'entity_type', 'entityType');
const entityIds = valueToArray(queryParams.entityIds ?? queryParams.entityId);
appendInFilter(filters, params, entityIds, 'entity_id', 'entityId');
const actorTypes = valueToArray(queryParams.actorTypes ?? queryParams.actorType);
appendInFilter(filters, params, actorTypes, 'actor_type', 'actorType');
const actorIds = valueToArray(queryParams.actorIds ?? queryParams.actorId);
appendInFilter(filters, params, actorIds, 'actor_id', 'actorId');
const sources = valueToArray(queryParams.sources ?? queryParams.source);
appendInFilter(filters, params, sources, 'source', 'source');
const contextTypes = valueToArray(queryParams.contextTypes ?? queryParams.contextType);
appendInFilter(filters, params, contextTypes, 'context_type', 'contextType');
const contextIds = valueToArray(queryParams.contextIds ?? queryParams.contextId);
appendInFilter(filters, params, contextIds, 'context_id', 'contextId');
const legacyStackIds = valueToArray(queryParams.stackIds ?? queryParams.stackId);
if (legacyStackIds.length) {
const placeholders = legacyStackIds.map((_, idx) => {
const key = `legacyStackId${idx}`;
params[key] = legacyStackIds[idx];
return `@${key}`;
});
filters.push(`(entity_type = 'stack' AND entity_id IN (${placeholders.join(', ')}))`);
}
const serverContexts = valueToArray(queryParams.servers ?? queryParams.server);
if (serverContexts.length) {
const placeholders = serverContexts.map((_, idx) => {
const key = `serverContext${idx}`;
params[key] = serverContexts[idx];
return `@${key}`;
});
filters.push(`(context_type = 'server' AND context_id IN (${placeholders.join(', ')}))`);
}
const messageQuery = singleValue(queryParams.message ?? queryParams.text);
if (messageQuery && String(messageQuery).trim()) {
filters.push('message LIKE @message');
params.message = `%${String(messageQuery).trim()}%`;
}
const search = singleValue(queryParams.search ?? queryParams.q);
if (search && String(search).trim()) {
filters.push('(message LIKE @search OR entity_name LIKE @search OR metadata LIKE @search)');
params.search = `%${String(search).trim()}%`;
}
const from = singleValue(queryParams.from ?? queryParams.start);
if (from) {
filters.push('timestamp >= @from');
params.from = from;
}
const to = singleValue(queryParams.to ?? queryParams.end);
if (to) {
filters.push('timestamp <= @to');
params.to = to;
}
return {
whereClause: filters.length ? `WHERE ${filters.join(' AND ')}` : '',
params
};
}
const insertEventLogStmt = db.prepare(`
INSERT INTO event_logs (
timestamp,
category,
event_type,
action,
status,
severity,
entity_type,
entity_id,
entity_name,
actor_type,
actor_id,
actor_name,
source,
context_type,
context_id,
context_label,
message,
metadata
)
VALUES (
COALESCE(@timestamp, CURRENT_TIMESTAMP),
@category,
@eventType,
@action,
@status,
@severity,
@entityType,
@entityId,
@entityName,
@actorType,
@actorId,
@actorName,
@source,
@contextType,
@contextId,
@contextLabel,
@message,
@metadata
)
`);
export function logEvent(event = {}) {
if (!event.category) {
console.error('⚠️ logEvent wurde ohne Kategorie aufgerufen');
return;
}
try {
insertEventLogStmt.run({
timestamp: event.timestamp ?? null,
category: event.category,
eventType: event.eventType ?? null,
action: event.action ?? null,
status: event.status ?? null,
severity: event.severity ?? null,
entityType: event.entityType ?? null,
entityId: event.entityId !== undefined && event.entityId !== null ? String(event.entityId) : null,
entityName: event.entityName ?? null,
actorType: event.actorType ?? null,
actorId: event.actorId !== undefined && event.actorId !== null ? String(event.actorId) : null,
actorName: event.actorName ?? null,
source: event.source ?? null,
contextType: event.contextType ?? null,
contextId: event.contextId !== undefined && event.contextId !== null ? String(event.contextId) : null,
contextLabel: event.contextLabel ?? null,
message: event.message ?? null,
metadata: serializeJson(event.metadata)
});
} catch (err) {
console.error('❌ Fehler beim Speichern des Event-Logs:', err.message);
}
}
export function deleteEventLogById(id) {
const stmt = db.prepare('DELETE FROM event_logs WHERE id = ?');
const info = stmt.run(id);
return info.changes || 0;
}
export function deleteEventLogsByFilters(queryParams = {}) {
const { whereClause, params } = buildEventLogFilter(queryParams);
const stmt = db.prepare(`DELETE FROM event_logs ${whereClause}`);
const info = stmt.run(params);
return info.changes || 0;
}
export function exportEventLogsByFilters(queryParams = {}, format = 'txt') {
const { whereClause, params } = buildEventLogFilter(queryParams);
const rows = db.prepare(`
SELECT
id,
timestamp,
category,
event_type AS eventType,
action,
status,
severity,
entity_type AS entityType,
entity_id AS entityId,
entity_name AS entityName,
actor_type AS actorType,
actor_id AS actorId,
actor_name AS actorName,
source,
context_type AS contextType,
context_id AS contextId,
context_label AS contextLabel,
message,
metadata
FROM event_logs
${whereClause}
ORDER BY datetime(timestamp) DESC
`).all(params);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
if (format === 'sql') {
const columns = [
'id',
'timestamp',
'category',
'event_type',
'action',
'status',
'severity',
'entity_type',
'entity_id',
'entity_name',
'actor_type',
'actor_id',
'actor_name',
'source',
'context_type',
'context_id',
'context_label',
'message',
'metadata'
];
const statements = rows.map((row) => {
const values = columns.map((column) => {
const key = {
event_type: 'eventType',
entity_type: 'entityType',
entity_id: 'entityId',
entity_name: 'entityName',
actor_type: 'actorType',
actor_id: 'actorId',
actor_name: 'actorName',
context_type: 'contextType',
context_id: 'contextId',
context_label: 'contextLabel'
}[column] ?? column;
const value = row[key];
if (value === null || value === undefined) return 'NULL';
return `'${String(value).replace(/'/g, "''")}'`;
});
return `INSERT INTO event_logs (${columns.join(', ')}) VALUES (${values.join(', ')});`;
});
return {
filename: `event-logs-${timestamp}.sql`,
contentType: 'application/sql; charset=utf-8',
content: statements.join('\n')
};
}
const lines = rows.map((row) => {
const parts = [
`[${row.id}]`,
row.timestamp,
`Kategorie: ${row.category}`,
row.eventType ? `Typ: ${row.eventType}` : null,
row.action ? `Aktion: ${row.action}` : null,
row.status ? `Status: ${row.status}` : null,
row.entityName ? `Entität: ${row.entityName}${row.entityId ? ` (#${row.entityId})` : ''}` : row.entityId ? `Entität-ID: ${row.entityId}` : null,
row.contextType ? `Kontext: ${row.contextType}${row.contextId ? ` (#${row.contextId}${row.contextLabel ? ` ${row.contextLabel}` : ''})` : ''}` : null,
row.message ? `Nachricht: ${row.message}` : null,
row.metadata ? `Metadaten: ${row.metadata}` : null
].filter(Boolean);
return parts.join(' | ');
});
return {
filename: `event-logs-${timestamp}.txt`,
contentType: 'text/plain; charset=utf-8',
content: lines.join('\n')
};
}
+90
View File
@@ -0,0 +1,90 @@
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
import { logEvent } from '../logging/eventLogs.js';
const MAINTENANCE_KEY = 'maintenance_mode';
const DEFAULT_STATE = {
active: false,
message: null,
activatedAt: null,
updatedAt: null,
extra: null
};
let maintenanceState = loadState();
function loadState() {
const saved = getJsonSetting(MAINTENANCE_KEY, null);
if (!saved) {
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
setJsonSetting(MAINTENANCE_KEY, initial);
return initial;
}
return {
...DEFAULT_STATE,
...saved
};
}
function persistState(state) {
maintenanceState = {
...DEFAULT_STATE,
...state,
updatedAt: new Date().toISOString()
};
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
return maintenanceState;
}
export function getMaintenanceState() {
return { ...maintenanceState };
}
export function isMaintenanceModeActive() {
return Boolean(maintenanceState?.active);
}
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'Wartungsmodus',
action: 'aktivieren',
status: 'gestartet',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus aktiviert',
source: 'system'
});
const now = new Date().toISOString();
return persistState({
active: true,
message,
extra,
activatedAt: now
});
}
export function deactivateMaintenanceMode({ message = null } = {}) {
logEvent({
category: 'wartung',
eventType: 'wartungsmodus',
action: 'deaktivieren',
status: 'erfolgreich',
entityType: 'system',
entityId: 'wartung',
entityName: 'StackPulse Wartung',
contextType: 'System',
contextId: 'System',
message: 'Wartungsmodus deaktiviert',
source: 'system'
});
return persistState({
active: false,
message,
extra: null,
activatedAt: null
});
}
+1956
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"migrate": "node db/ensure.js"
},
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^9.6.0",
"dockerode": "^4.0.7",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"socket.io": "^4.8.1",
"@scure/bip39": "^2.0.0"
}
}
+375
View File
@@ -0,0 +1,375 @@
import { db } from '../db/index.js';
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2
};
const selectSections = db.prepare(`
SELECT id, key, title, sort_order, has_navigation_flag
FROM permission_sections
ORDER BY sort_order ASC, id ASC
`);
const selectGroups = db.prepare(`
SELECT id, section_id, key, title, sort_order
FROM permission_groups
ORDER BY section_id ASC, sort_order ASC, id ASC
`);
const selectItems = db.prepare(`
SELECT
id,
section_id,
group_id,
key,
label,
sort_order,
default_level,
available_levels,
is_required
FROM permission_items
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
`);
const selectDependencies = db.prepare(`
SELECT permission_id, depends_on_permission_id, required_level
FROM permission_dependencies
`);
const selectPermissionItemsForMap = db.prepare(`
SELECT id, key, available_levels, default_level
FROM permission_items
`);
const selectGroupPermissionValues = db.prepare(`
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
FROM group_permission_values gp
JOIN permission_items pi ON pi.id = gp.permission_id
WHERE gp.group_id = ?
`);
const deleteGroupPermissionValuesStmt = db.prepare(`
DELETE FROM group_permission_values
WHERE group_id = ?
`);
const insertGroupPermissionValueStmt = db.prepare(`
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const parseAvailableLevels = (raw) => {
if (typeof raw !== 'string' || !raw.trim()) {
return ['full', 'read', 'none'];
}
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
return parsed;
}
} catch (error) {
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
}
return ['full', 'read', 'none'];
};
const getLevelPriority = (level) => {
if (!level) return 0;
return LEVEL_PRIORITY[level] ?? 0;
};
let cachedPermissionItems = null;
const getPermissionItems = () => {
if (!cachedPermissionItems) {
cachedPermissionItems = selectPermissionItemsForMap.all();
}
return cachedPermissionItems;
};
const resetPermissionItemsCache = () => {
cachedPermissionItems = null;
};
export function getPermissionStructure() {
const sections = selectSections.all();
const groups = selectGroups.all();
const items = selectItems.all();
const dependencies = selectDependencies.all();
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
const dependenciesByItemId = new Map();
dependencies.forEach((dependency) => {
const list = dependenciesByItemId.get(dependency.permission_id) || [];
list.push(dependency);
dependenciesByItemId.set(dependency.permission_id, list);
});
const groupsBySectionId = new Map();
groups.forEach((group) => {
const list = groupsBySectionId.get(group.section_id) || [];
list.push(group);
groupsBySectionId.set(group.section_id, list);
});
const itemsBySectionId = new Map();
const itemsByGroupId = new Map();
items.forEach((item) => {
if (item.group_id) {
const list = itemsByGroupId.get(item.group_id) || [];
list.push(item);
itemsByGroupId.set(item.group_id, list);
} else {
const list = itemsBySectionId.get(item.section_id) || [];
list.push(item);
itemsBySectionId.set(item.section_id, list);
}
});
const formatItem = (item) => {
const availableLevels = parseAvailableLevels(item.available_levels);
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
const formattedDependencies = dependencyEntries
.map((dependency) => {
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
if (!dependsOnKey) {
return null;
}
return {
key: dependsOnKey,
requiredLevel: dependency.required_level || null
};
})
.filter(Boolean);
return {
key: item.key,
label: item.label,
sortOrder: item.sort_order ?? 0,
defaultLevel: item.default_level || 'none',
availableLevels,
isRequired: Boolean(item.is_required),
dependencies: formattedDependencies
};
};
const formattedSections = sections.map((section) => {
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
key: group.key,
title: group.title,
sortOrder: group.sort_order ?? 0,
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
}));
return {
key: section.key,
title: section.title,
sortOrder: section.sort_order ?? 0,
hasNavigation: Boolean(section.has_navigation_flag),
items: sectionItems,
groups: sectionGroups
};
});
return formattedSections;
}
export function getPermissionValuesByGroup(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return {};
}
const rows = selectGroupPermissionValues.all(numericId);
const values = {};
rows.forEach((row) => {
if (row?.key && typeof row.level === 'string') {
values[row.key] = row.level;
}
});
return values;
}
export function clearGroupPermissionValues(groupId) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return;
}
deleteGroupPermissionValuesStmt.run(numericId);
}
export function saveGroupPermissionValues(groupId, values = {}) {
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
const error = new Error('PERMISSION_INVALID_PAYLOAD');
error.code = 'PERMISSION_INVALID_PAYLOAD';
throw error;
}
const items = getPermissionItems();
const itemMap = new Map();
items.forEach((item) => {
itemMap.set(item.key, {
id: item.id,
key: item.key,
defaultLevel: item.default_level || 'none',
availableLevels: parseAvailableLevels(item.available_levels)
});
});
const entries = Object.entries(values);
const normalizedEntries = [];
entries.forEach(([key, rawValue]) => {
const item = itemMap.get(key);
if (!item) {
const error = new Error('PERMISSION_UNKNOWN_KEY');
error.code = 'PERMISSION_UNKNOWN_KEY';
error.permissionKey = key;
throw error;
}
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
if (!level) {
level = 'none';
}
if (!item.availableLevels.includes(level)) {
const error = new Error('PERMISSION_INVALID_LEVEL');
error.code = 'PERMISSION_INVALID_LEVEL';
error.permissionKey = key;
error.level = level;
throw error;
}
normalizedEntries.push({ item, level });
});
const getSubmittedLevel = (permissionKey) => {
const entry = normalizedEntries.find((entry) => entry.item.key === permissionKey);
if (entry) {
return entry.level;
}
const fallback = itemMap.get(permissionKey);
return fallback ? fallback.defaultLevel || 'none' : 'none';
};
const serverManageLevel = getSubmittedLevel('maintenance-server-manage');
const serverDeleteLevel = getSubmittedLevel('maintenance-server-delete');
if (getLevelPriority(serverDeleteLevel) >= getLevelPriority('full') &&
getLevelPriority(serverManageLevel) < getLevelPriority('full')) {
const error = new Error('PERMISSION_DEPENDENCY_LEVEL');
error.code = 'PERMISSION_DEPENDENCY_LEVEL';
error.permissionKey = 'maintenance-server-delete';
throw error;
}
const runSave = db.transaction(() => {
deleteGroupPermissionValuesStmt.run(numericId);
normalizedEntries.forEach(({ item, level }) => {
if (level === (item.defaultLevel || 'none')) {
return;
}
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
});
});
runSave();
resetPermissionItemsCache();
return getPermissionValuesByGroup(numericId);
}
export function getDefaultPermissionMap() {
const items = getPermissionItems();
const defaults = {};
items.forEach((item) => {
defaults[item.key] = item.default_level || 'none';
});
return defaults;
}
export function getSuperuserPermissionMap() {
const items = getPermissionItems();
const result = {};
items.forEach((item) => {
const available = parseAvailableLevels(item.available_levels);
let chosen = 'full';
if (!available.includes('full')) {
chosen = available.reduce(
(best, candidate) =>
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
'none'
);
}
result[item.key] = chosen;
});
return result;
}
export function getEffectivePermissionsForGroup(groupId) {
const defaults = getDefaultPermissionMap();
const numericId = Number(groupId);
if (!Number.isFinite(numericId) || numericId <= 0) {
return defaults;
}
const overrides = getPermissionValuesByGroup(numericId);
const map = { ...defaults };
Object.keys(overrides).forEach((key) => {
const value = overrides[key];
if (typeof value === 'string') {
map[key] = value;
}
});
return map;
}
export function getEffectivePermissionsForGroups(groupIds = []) {
const defaults = getDefaultPermissionMap();
const finalMap = { ...defaults };
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
iterableIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.forEach((groupId) => {
const map = getEffectivePermissionsForGroup(groupId);
Object.entries(map).forEach(([key, level]) => {
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
finalMap[key] = level;
}
});
});
return finalMap;
}
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
if (!permissionKey) {
return true;
}
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
return true;
}
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
return getLevelPriority(current) >= getLevelPriority(required);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+44
View File
@@ -0,0 +1,44 @@
<!--
=========================================================
* Material Tailwind Dashboard React - v2.1.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-tailwind-dashboard-react
* Copyright 2023 Creative Tim (https://www.creative-tim.com)
* Licensed under MIT (https://github.com/creativetimofficial/material-tailwind-dashboard-react/blob/main/LICENSE.md)
* Coded by Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/img/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Material Tailwind Dashboard React | By Creative Tim</title>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-71db1823.js"></script>
<link rel="stylesheet" href="/assets/index-dad5f6fa.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+373
View File
@@ -0,0 +1,373 @@
import crypto from 'crypto';
import { db } from '../db/index.js';
import { hasSuperuser } from '../auth/superuser.js';
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
const insertServer = db.prepare(`
INSERT INTO servers (name, url)
VALUES (?, ?)
`);
const updateServer = db.prepare(`
UPDATE servers
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
const upsertApiKey = db.prepare(`
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(server_id) DO UPDATE SET
key_cipher = excluded.key_cipher,
key_iv = excluded.key_iv,
key_tag = excluded.key_tag,
updated_at = CURRENT_TIMESTAMP
`);
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
const API_KEY_SECRET = crypto.createHash('sha256')
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
.digest();
function encryptApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') return null;
const trimmed = apiKey.trim();
if (!trimmed) return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString('base64'),
content: encrypted.toString('base64'),
tag: tag.toString('base64')
};
}
function decryptApiKey(row) {
if (!row) return '';
try {
const iv = Buffer.from(row.key_iv, 'base64');
const content = Buffer.from(row.key_cipher, 'base64');
const tag = Buffer.from(row.key_tag, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
return decrypted.toString('utf8');
} catch (error) {
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
return '';
}
}
function normalizeUrl(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
const normalized = new URL(candidate);
const pathname = normalized.pathname.replace(/\/+$/, '');
normalized.pathname = pathname || '/';
normalized.hash = '';
return normalized.toString().replace(/\/$/, '');
} catch (err) {
return '';
}
}
function deriveServerName(url) {
try {
const parsed = new URL(url);
return parsed.hostname || url;
} catch (err) {
return url;
}
}
function ensureServer({ name, url }) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) {
const error = new Error('SERVER_URL_REQUIRED');
error.code = 'SERVER_URL_REQUIRED';
throw error;
}
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
if (!normalizedName) {
const error = new Error('SERVER_NAME_REQUIRED');
error.code = 'SERVER_NAME_REQUIRED';
throw error;
}
const existing = selectServerByUrl.get(normalizedUrl);
if (existing) {
if (existing.name !== normalizedName) {
updateServer.run(normalizedName, normalizedUrl, existing.id);
const updated = selectServerById.get(existing.id);
console.log(`️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
return updated;
}
return existing;
}
const result = insertServer.run(normalizedName, normalizedUrl);
const created = selectServerById.get(result.lastInsertRowid);
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
return created;
}
function setServerApiKey({ serverId, apiKey }) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = selectServerById.get(id);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
if (!normalizedKey) {
const error = new Error('API_KEY_REQUIRED');
error.code = 'API_KEY_REQUIRED';
throw error;
}
const encrypted = encryptApiKey(normalizedKey);
if (!encrypted) {
const error = new Error('API_KEY_ENCRYPT_FAILED');
error.code = 'API_KEY_ENCRYPT_FAILED';
throw error;
}
const existing = selectApiKeyByServerId.get(id);
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
console.log(`${existing ? '️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
return {
serverId: server.id,
updatedAt: new Date().toISOString()
};
}
function getActiveApiKey() {
const firstServer = selectFirstServerStmt.get();
if (firstServer) {
const row = selectApiKeyByServerId.get(firstServer.id);
const key = decryptApiKey(row);
if (key) return key;
}
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
return envKey || '';
}
function getActiveServerUrl() {
const firstServer = selectFirstServerStmt.get();
if (firstServer?.url) {
const normalizedUrl = normalizeUrl(firstServer.url);
if (normalizedUrl) {
return normalizedUrl;
}
}
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
return normalizedEnvUrl || '';
}
function hasServer() {
const { count } = countServersStmt.get();
return count > 0;
}
function removeServer(serverId) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = selectServerById.get(id);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const existingApiKey = selectApiKeyByServerId.get(id);
deleteServerStmt.run(id);
if (existingApiKey) {
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
}
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
return {
server,
removed: true
};
}
function hasApiKey() {
const { count } = countApiKeysStmt.get();
return count > 0;
}
function hasCompleteSetup() {
return hasSuperuser() && hasServer() && hasApiKey();
}
function ensureDefaultsFromEnv() {
const envServerUrlRaw = process.env.PORTAINER_URL;
const envServerName = process.env.PORTAINER_SERVER_NAME;
let server = null;
if (envServerUrlRaw) {
try {
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
} catch (error) {
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
}
}
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
if (envApiKeyRaw.trim()) {
const targetServer = server || selectFirstServerStmt.get();
if (targetServer) {
try {
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
} catch (error) {
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
}
}
}
}
function getSetupStatus() {
const servers = selectAllServers.all();
const apiKeyRecords = selectAllApiKeys.all();
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
const envServerName = rawEnvServerName.trim();
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
const envServerUrl = rawEnvServerUrl.trim();
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
const envApiKeyTrimmed = rawEnvApiKey.trim();
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
const envSuperuserEmail = envSuperuserEmailRaw.trim();
const serverRequired = servers.length === 0;
const apiKeyItems = servers.map((server) => {
const keyMeta = apiKeyMap.get(server.id) || null;
return {
serverId: server.id,
serverName: server.name,
hasKey: Boolean(keyMeta),
updatedAt: keyMeta?.updated_at ?? null
};
});
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
const superuserExists = hasSuperuser();
const setupComplete = superuserExists && !serverRequired && !apiKeyRequired;
return {
superuser: {
exists: superuserExists,
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
env: {
username: envSuperuserUsernameRaw,
email: envSuperuserEmailRaw,
password: envSuperuserPasswordRaw
}
},
servers: {
count: servers.length,
items: servers,
requireInput: serverRequired,
envProvided: Boolean(envServerUrl)
},
apiKeys: {
count: apiKeyCount,
items: apiKeyItems,
requireInput: apiKeyRequired,
envProvided: envApiKeyProvided,
envValue: rawEnvApiKey
},
requirements: {
superuser: !superuserExists,
server: serverRequired,
apiKey: apiKeyRequired
},
setupComplete,
envDefaults: {
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
serverNameFromEnv: rawEnvServerName,
serverUrl: envServerUrl,
apiKeyProvided: envApiKeyProvided,
apiKeyValue: rawEnvApiKey,
superuserUsername: envSuperuserUsernameRaw,
superuserEmail: envSuperuserEmailRaw,
superuserPassword: envSuperuserPasswordRaw
}
};
}
function completeSetup({ server: serverInput }) {
let serverRecord = null;
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
serverRecord = ensureServer(serverInput);
} else {
serverRecord = selectFirstServerStmt.get();
}
if (!serverRecord) {
const error = new Error('SERVER_DETAILS_REQUIRED');
error.code = 'SERVER_DETAILS_REQUIRED';
throw error;
}
return {
server: serverRecord
};
}
export {
ensureDefaultsFromEnv,
ensureServer,
setServerApiKey,
getActiveApiKey,
getActiveServerUrl,
hasServer,
hasApiKey,
hasCompleteSetup,
getSetupStatus,
completeSetup,
removeServer
};
+987
View File
@@ -0,0 +1,987 @@
import { db } from '../db/index.js';
import { logEvent } from '../logging/eventLogs.js';
import {
generateSecurityPhraseWords,
encryptSecurityPhrase,
decryptSecurityPhrase,
canonicalizeWords,
canonicalizePhraseInput
} from './securityPhrase.js';
import {
hashPassword,
normalizeAvatarColor,
pickRandomAvatarColor,
DEFAULT_AVATAR_COLOR,
SUPERUSER_GROUP_NAME
} from '../auth/superuser.js';
const selectUsersWithGroups = db.prepare(`
SELECT
u.id,
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
u.security_phrase_downloaded_at,
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
FROM users u
LEFT JOIN user_group_memberships m ON m.user_id = u.id
LEFT JOIN user_groups g ON g.id = m.group_id
GROUP BY u.id
ORDER BY u.username COLLATE NOCASE
`);
const selectUserWithGroupsById = db.prepare(`
SELECT
u.id,
u.username,
u.email,
u.is_active,
u.avatar_color,
u.last_login,
u.created_at,
u.updated_at,
u.security_phrase_content,
u.security_phrase_iv,
u.security_phrase_tag,
u.security_phrase_downloaded_at,
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
FROM users u
LEFT JOIN user_group_memberships m ON m.user_id = u.id
LEFT JOIN user_groups g ON g.id = m.group_id
WHERE u.id = ?
GROUP BY u.id
`);
const selectUserCredentialsById = db.prepare(`
SELECT id, username, email, password_hash, password_salt, avatar_color
FROM users
WHERE id = ?
`);
const selectUserByUsername = db.prepare('SELECT id FROM users WHERE username = ?');
const selectUserByEmail = db.prepare('SELECT id FROM users WHERE email = ?');
const deleteMembershipsByUser = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const insertMembershipForUser = db.prepare(`
INSERT OR IGNORE INTO user_group_memberships (user_id, group_id)
VALUES (?, ?)
`);
const selectSecurityPhraseByUsername = db.prepare(`
SELECT
id,
username,
is_active,
security_phrase_content AS content,
security_phrase_iv AS iv,
security_phrase_tag AS tag,
security_phrase_downloaded_at AS downloaded_at
FROM users
WHERE lower(username) = lower(?)
LIMIT 1
`);
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
const isUserInGroupStatement = db.prepare(`
SELECT 1 AS has_membership
FROM user_group_memberships m
INNER JOIN user_groups g ON g.id = m.group_id
WHERE m.user_id = ? AND lower(g.name) = lower(?)
LIMIT 1
`);
const insertUserStatement = db.prepare(`
INSERT INTO users (
username,
email,
password_hash,
password_salt,
avatar_color,
is_active,
security_phrase_content,
security_phrase_iv,
security_phrase_tag,
security_phrase_downloaded_at,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const updateUserCoreStatement = db.prepare(`
UPDATE users
SET username = ?,
email = ?,
password_hash = ?,
password_salt = ?,
avatar_color = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateUserActiveStatement = db.prepare(`
UPDATE users
SET is_active = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const updateUserPasswordStatement = db.prepare(`
UPDATE users
SET password_hash = ?,
password_salt = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const parseGroupPairs = (rawPairs) => {
if (!rawPairs) {
return [];
}
return rawPairs
.split('|||')
.map((entry) => {
const [idPart, namePart] = entry.split('::');
const groupName = String(namePart || '').trim();
if (!groupName) {
return null;
}
const numericId = Number(idPart);
return {
id: Number.isFinite(numericId) ? numericId : null,
name: groupName
};
})
.filter(Boolean);
};
const sanitizeUserRecord = (row) => ({
id: row.id,
username: row.username,
email: row.email || null,
isActive: Boolean(row.is_active),
avatarColor: row.avatar_color || null,
lastLogin: row.last_login || null,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
groups: parseGroupPairs(row.group_pairs),
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
});
export function listUsers() {
const rows = selectUsersWithGroups.all();
return rows.map(sanitizeUserRecord);
}
export function getUserById(userId) {
const row = selectUserWithGroupsById.get(userId);
return row ? sanitizeUserRecord(row) : null;
}
const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
deleteMembershipsByUser.run(userId);
groupIds.forEach((groupId) => {
insertMembershipForUser.run(userId, groupId);
});
});
const insertUserWithGroups = db.transaction(({
username,
email,
passwordHash,
passwordSalt,
avatarColor,
groupId,
securityPhrase
}) => {
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
? {
content: securityPhrase.content ?? null,
iv: securityPhrase.iv ?? null,
tag: securityPhrase.tag ?? null
}
: { content: null, iv: null, tag: null };
const result = insertUserStatement.run(
username,
email,
passwordHash,
passwordSalt,
avatarColor,
phraseToPersist.content,
phraseToPersist.iv,
phraseToPersist.tag
);
const userId = Number(result.lastInsertRowid);
applyUserGroupAssignments(userId, [groupId]);
return userId;
});
const selectSecurityPhraseByUserId = db.prepare(`
SELECT
id,
username,
security_phrase_content AS content,
security_phrase_iv AS iv,
security_phrase_tag AS tag,
security_phrase_downloaded_at AS downloaded_at
FROM users
WHERE id = ?
`);
const updateSecurityPhraseStatement = db.prepare(`
UPDATE users
SET
security_phrase_content = ?,
security_phrase_iv = ?,
security_phrase_tag = ?,
security_phrase_downloaded_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const markSecurityPhraseDownloadedStatement = db.prepare(`
UPDATE users
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
SELECT id
FROM users
WHERE security_phrase_content IS NULL
OR security_phrase_iv IS NULL
OR security_phrase_tag IS NULL
`);
const resolveActorFields = (actor) => {
if (!actor || actor.id === undefined || actor.id === null) {
return {};
}
const name = actor.username || actor.email || `User ${actor.id}`;
return {
actorType: 'user',
actorId: String(actor.id),
actorName: name
};
};
const normalizeEmail = (value) => {
if (!value) return null;
const trimmed = String(value).trim();
if (!trimmed) {
return null;
}
return trimmed.toLowerCase();
};
export function getUserSecurityPhrase(userId, options = {}) {
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const { actor = null, allowAutoGenerate = true } = options;
const record = selectSecurityPhraseByUserId.get(numericUserId);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const words = decryptSecurityPhrase(record);
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
}
return {
userId: numericUserId,
username: record.username,
words,
downloadedAt: record.downloaded_at || null
};
}
export function renewUserSecurityPhrase(userId, options = {}) {
const actor = options.actor || null;
const suppressLog = Boolean(options.suppressLog);
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
const words = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
updateSecurityPhraseStatement.run(
encryptedPhrase.content,
encryptedPhrase.iv,
encryptedPhrase.tag,
numericUserId
);
if (!suppressLog) {
logEvent({
category: 'benutzer',
eventType: 'benutzer-sicherheitsschluessel-erneuert',
action: 'sicherheitsschluessel-erneuern',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: existingUser.username ?? `ID ${numericUserId}`,
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
metadata: {
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
},
...resolveActorFields(actor)
});
}
return {
userId: numericUserId,
username: existingUser.username,
words,
downloadedAt: null
};
}
export function markSecurityPhraseDownloaded(userId, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const record = selectSecurityPhraseByUserId.get(numericUserId);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
markSecurityPhraseDownloadedStatement.run(numericUserId);
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
const downloadedAt = updatedRecord?.downloaded_at || null;
logEvent({
category: 'benutzer',
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
action: 'sicherheitsschluessel-herunterladen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: record.username ?? `ID ${numericUserId}`,
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
metadata: {
downloadedAt
},
...resolveActorFields(actor)
});
return {
userId: numericUserId,
username: record.username,
downloadedAt
};
}
export function verifySecurityPhraseForUsername(username, phraseInput) {
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
if (!record) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const candidateCanonical = canonicalizePhraseInput(phraseInput);
if (!candidateCanonical) {
const error = new Error('PHRASE_REQUIRED');
error.code = 'PHRASE_REQUIRED';
throw error;
}
const words = decryptSecurityPhrase(record);
if (!Array.isArray(words) || words.length === 0) {
const error = new Error('PHRASE_NOT_INITIALIZED');
error.code = 'PHRASE_NOT_INITIALIZED';
throw error;
}
const storedCanonical = canonicalizeWords(words);
if (!storedCanonical) {
const error = new Error('PHRASE_NOT_INITIALIZED');
error.code = 'PHRASE_NOT_INITIALIZED';
throw error;
}
if (storedCanonical !== candidateCanonical) {
const error = new Error('PHRASE_MISMATCH');
error.code = 'PHRASE_MISMATCH';
throw error;
}
return {
userId: record.id,
username: record.username,
isActive: Boolean(record.is_active),
words
};
}
export function setUserPassword(userId, newPassword, options = {}) {
const actor = options.actor || null;
const resetMethod = options.resetMethod || 'security-phrase';
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
let passwordHash;
let passwordSalt;
try {
const hashed = hashPassword(newPassword);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
} catch (err) {
if (err && err.code) {
throw err;
}
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-passwort-zurueckgesetzt',
action: 'passwort-zuruecksetzen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
metadata: {
resetMethod
},
...resolveActorFields(actor)
});
return updated;
}
export function ensureSecurityPhrasesForExistingUsers() {
const rows = selectUsersMissingSecurityPhraseStatement.all();
if (!Array.isArray(rows) || rows.length === 0) {
return 0;
}
let processed = 0;
const initialize = db.transaction((users) => {
users.forEach((entry) => {
if (!entry || entry.id === undefined || entry.id === null) {
return;
}
const words = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
updateSecurityPhraseStatement.run(
encryptedPhrase.content,
encryptedPhrase.iv,
encryptedPhrase.tag,
entry.id
);
processed += 1;
});
});
initialize(rows);
return processed;
}
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
const actor = options.actor || null;
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const numericGroupId = Number(groupId);
if (!Number.isFinite(numericGroupId) || numericGroupId <= 0) {
const error = new Error('INVALID_GROUP_ID');
error.code = 'INVALID_GROUP_ID';
throw error;
}
const groupRow = selectGroupById.get(numericGroupId);
if (!groupRow) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
throw error;
}
if ((groupRow.name || '').toLowerCase() === SUPERUSER_GROUP_NAME) {
const error = new Error('GROUP_SUPERUSER_PROTECTED');
error.code = 'GROUP_SUPERUSER_PROTECTED';
throw error;
}
const normalizedEmail = normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
const existingUsername = selectUserByUsername.get(normalizedUsername);
if (existingUsername) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
if (normalizedEmail) {
const existingEmail = selectUserByEmail.get(normalizedEmail);
if (existingEmail) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash;
let passwordSalt;
try {
const hashed = hashPassword(password);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
} catch (err) {
if (err && err.code) {
throw err;
}
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
const securityPhraseWords = generateSecurityPhraseWords(8);
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
const securityPhrasePayload = {
content: encryptedPhrase.content,
iv: encryptedPhrase.iv,
tag: encryptedPhrase.tag
};
const userId = insertUserWithGroups({
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
passwordSalt,
avatarColor: avatarColorToPersist,
groupId: numericGroupId,
securityPhrase: securityPhrasePayload
});
const userRecord = getUserById(userId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-angelegt',
action: 'anlegen',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(userRecord?.id ?? userId),
entityName: userRecord?.username ?? normalizedUsername,
message: `Benutzer "${normalizedUsername}" angelegt`,
metadata: {
email: userRecord?.email ?? normalizedEmail ?? null,
primaryGroupId: numericGroupId,
primaryGroupName: groupRow.name
},
...resolveActorFields(actor)
});
return userRecord;
}
export function updateUserGroups(userId, groupIds, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = getUserById(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const normalizedGroupIds = Array.isArray(groupIds)
? Array.from(
new Set(
groupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
)
: [];
const missingGroupIds = [];
const groupDetails = [];
normalizedGroupIds.forEach((groupId) => {
const groupRow = selectGroupById.get(groupId);
if (!groupRow) {
missingGroupIds.push(groupId);
} else {
groupDetails.push({
id: groupId,
name: groupRow.name
});
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-gruppen-aktualisiert',
action: 'gruppe-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: `Gruppenzuordnung für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktualisiert`,
metadata: {
previousGroups: (existingUser.groups || []).map((group) => ({ id: group.id, name: group.name })),
groups: updated?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? groupDetails
},
...resolveActorFields(actor)
});
return updated;
}
export function updateUserDetails(userId, { username, email, password, avatarColor, groupId, groupIds } = {}, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const previousUserRecord = getUserById(numericUserId);
const normalizedUsername = typeof username === 'string' ? username.trim() : existingUser.username;
if (!normalizedUsername) {
const error = new Error('USERNAME_REQUIRED');
error.code = 'USERNAME_REQUIRED';
throw error;
}
const usernameRow = selectUserByUsername.get(normalizedUsername);
if (usernameRow && Number(usernameRow.id) !== numericUserId) {
const error = new Error('USERNAME_TAKEN');
error.code = 'USERNAME_TAKEN';
throw error;
}
const normalizedEmail = email === undefined ? existingUser.email : normalizeEmail(email);
if (normalizedEmail && !normalizedEmail.includes('@')) {
const error = new Error('INVALID_EMAIL');
error.code = 'INVALID_EMAIL';
throw error;
}
if (normalizedEmail) {
const emailRow = selectUserByEmail.get(normalizedEmail);
if (emailRow && Number(emailRow.id) !== numericUserId) {
const error = new Error('EMAIL_TAKEN');
error.code = 'EMAIL_TAKEN';
throw error;
}
}
let passwordHash = existingUser.password_hash;
let passwordSalt = existingUser.password_salt;
if (typeof password === 'string') {
const trimmedPassword = password.trim();
if (trimmedPassword.length > 0) {
const hashed = hashPassword(trimmedPassword);
passwordHash = hashed.hash;
passwordSalt = hashed.salt;
}
} else if (password !== undefined && password !== null) {
const error = new Error('INVALID_PASSWORD');
error.code = 'INVALID_PASSWORD';
throw error;
}
let colorToPersist = existingUser.avatar_color || DEFAULT_AVATAR_COLOR;
if (avatarColor !== undefined) {
const candidate = String(avatarColor || '').trim();
if (!candidate) {
colorToPersist = DEFAULT_AVATAR_COLOR;
} else {
const normalizedColor = normalizeAvatarColor(candidate);
if (!normalizedColor) {
const error = new Error('INVALID_AVATAR_COLOR');
error.code = 'INVALID_AVATAR_COLOR';
throw error;
}
colorToPersist = normalizedColor;
}
}
const shouldUpdateGroups = groupId !== undefined || groupIds !== undefined;
let normalizedGroupIds = null;
let nextGroups = null;
if (shouldUpdateGroups) {
const incomingGroupIds = Array.isArray(groupIds)
? groupIds
: groupId !== undefined && groupId !== null
? [groupId]
: [];
normalizedGroupIds = Array.from(
new Set(
incomingGroupIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
)
);
const missingGroupIds = [];
nextGroups = [];
normalizedGroupIds.forEach((value) => {
const groupRow = selectGroupById.get(value);
if (!groupRow) {
missingGroupIds.push(value);
} else {
nextGroups.push({ id: value, name: groupRow.name });
}
});
if (missingGroupIds.length > 0) {
const error = new Error('GROUP_NOT_FOUND');
error.code = 'GROUP_NOT_FOUND';
error.missingGroupIds = missingGroupIds;
throw error;
}
}
const performUpdate = db.transaction(() => {
updateUserCoreStatement.run(
normalizedUsername,
normalizedEmail,
passwordHash,
passwordSalt,
colorToPersist,
numericUserId
);
if (normalizedGroupIds !== null) {
applyUserGroupAssignments(numericUserId, normalizedGroupIds);
}
});
performUpdate();
const updatedUser = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: 'benutzer-aktualisiert',
action: 'aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updatedUser?.username ?? normalizedUsername,
message: `Benutzer "${updatedUser?.username ?? normalizedUsername}" aktualisiert`,
metadata: {
email: updatedUser?.email ?? normalizedEmail ?? null,
groupsUpdated: normalizedGroupIds !== null
? (updatedUser?.groups?.map((group) => ({ id: group.id, name: group.name })) ?? nextGroups)
: undefined,
previousGroups: normalizedGroupIds !== null
? (previousUserRecord?.groups || []).map((group) => ({ id: group.id, name: group.name }))
: undefined,
avatarColor: updatedUser?.avatarColor ?? colorToPersist
},
...resolveActorFields(actor)
});
return updatedUser;
}
const deleteMembershipsStatement = db.prepare(`
DELETE FROM user_group_memberships
WHERE user_id = ?
`);
const deleteUserStatement = db.prepare(`
DELETE FROM users
WHERE id = ?
`);
export function deleteUser(userId, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const performDelete = db.transaction(() => {
deleteMembershipsStatement.run(numericUserId);
deleteUserStatement.run(numericUserId);
});
performDelete();
logEvent({
category: 'benutzer',
eventType: 'benutzer-gelöscht',
action: 'gelöscht',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: existingUser.username ?? `ID ${numericUserId}`,
message: `Benutzer "${existingUser.username ?? numericUserId}" gelöscht`,
metadata: {
email: existingUser.email ?? null
},
...resolveActorFields(actor)
});
}
export function updateUserActiveStatus(userId, isActive, options = {}) {
const actor = options.actor || null;
const numericUserId = Number(userId);
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
const error = new Error('INVALID_USER_ID');
error.code = 'INVALID_USER_ID';
throw error;
}
const existingUser = selectUserCredentialsById.get(numericUserId);
if (!existingUser) {
const error = new Error('USER_NOT_FOUND');
error.code = 'USER_NOT_FOUND';
throw error;
}
const superuserMembership = isUserInGroupStatement.get(numericUserId, SUPERUSER_GROUP_NAME);
if (superuserMembership && superuserMembership.has_membership && !isActive) {
const error = new Error('USER_SUPERUSER_PROTECTED');
error.code = 'USER_SUPERUSER_PROTECTED';
throw error;
}
const normalizedIsActive = isActive ? 1 : 0;
updateUserActiveStatement.run(normalizedIsActive, numericUserId);
const updated = getUserById(numericUserId);
logEvent({
category: 'benutzer',
eventType: normalizedIsActive ? 'benutzer-aktiviert' : 'benutzer-deaktiviert',
action: 'status-aktualisieren',
status: 'erfolgreich',
entityType: 'benutzer',
entityId: String(numericUserId),
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
message: normalizedIsActive
? `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" aktiviert`
: `Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" deaktiviert`,
metadata: {
isActive: Boolean(normalizedIsActive)
},
...resolveActorFields(actor)
});
return updated;
}
+136
View File
@@ -0,0 +1,136 @@
import crypto from 'crypto';
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
const WORD_LIST = englishWordlist;
const SECURITY_PHRASE_KEY = crypto
.createHash('sha256')
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
.digest();
const encryptValue = (value) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString('base64'),
content: encrypted.toString('base64'),
tag: tag.toString('base64')
};
};
const decryptValue = ({ content, iv, tag }) => {
if (!content || !iv || !tag) {
return '';
}
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(content, 'base64')),
decipher.final()
]);
return decrypted.toString('utf8');
};
export const generateSecurityPhraseWords = (count = 8) => {
const words = [];
for (let i = 0; i < count; i += 1) {
const index = crypto.randomInt(0, WORD_LIST.length);
words.push(WORD_LIST[index]);
}
return words;
};
export const encodeWords = (words = []) => {
return words
.map((word) => {
const index = WORD_LIST.indexOf(word);
if (index === -1) {
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
}
return String(index);
})
.join('-');
};
export const decodeWords = (encoded) => {
if (!encoded) {
return [];
}
return encoded.split('-').map((entry) => {
const index = Number(entry);
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
}
return WORD_LIST[index];
});
};
export const encryptSecurityPhrase = (words) => {
const encoded = encodeWords(words);
const encrypted = encryptValue(encoded);
return {
encrypted,
encoded
};
};
export const decryptSecurityPhrase = (record) => {
if (!record) {
return [];
}
const encoded = decryptValue(record);
if (!encoded) {
return [];
}
return decodeWords(encoded);
};
export const formatPhraseForDisplay = (words) => {
const rows = [];
for (let i = 0; i < words.length; i += 4) {
rows.push(words.slice(i, i + 4));
}
return rows;
};
const normalizeWord = (value) => {
if (!value && value !== 0) {
return '';
}
return String(value).trim().toLowerCase();
};
export const canonicalizeWords = (words = []) => {
return words
.map(normalizeWord)
.filter((word) => word.length > 0)
.join('');
};
export const extractWordsFromString = (input) => {
if (!input && input !== 0) {
return [];
}
const normalized = String(input)
.replace(/[\r\n\t]+/g, ' ')
.trim()
.toLowerCase();
if (!normalized) {
return [];
}
return normalized.split(/\s+/).filter(Boolean);
};
export const canonicalizePhraseInput = (input) => {
if (Array.isArray(input)) {
return canonicalizeWords(input);
}
return canonicalizeWords(extractWordsFromString(input));
};
export default WORD_LIST;
+20
View File
@@ -0,0 +1,20 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "4001:4001"
volumes:
- stackpulse_data:/app/backend/data
restart: unless-stopped
environment:
- PORTAINER_URL=Your_Portainer_Server_Adress (optional)
- PORTAINER_API_KEY=Your_Portainer_API_Key (optional)
- SUPERUSER_USERNAME=Your_Superuser_Username (optional)
- SUPERUSER_EMAIL=Your_Superuser_Email (optional)
- SUPERUSER_PASSWORD=Your_Superuser_Password (optional)
- SELF_STACK_ID=Your_StackPulse_Stack_ID (optional)
volumes:
stackpulse_data:
+37 -1081
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
},
},
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "material-tailwind-dashboard-react",
"private": true,
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "1.7.5",
"socket.io-client": "4.8.1",
"@heroicons/react": "2.0.18",
"@material-tailwind/react": "2.1.4",
"apexcharts": "3.44.0",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-dom": "18.2.0",
"react-router-dom": "6.17.0"
},
"devDependencies": {
"@types/react": "18.2.31",
"@types/react-dom": "18.2.14",
"@vitejs/plugin-react": "4.1.0",
"autoprefixer": "10.4.16",
"postcss": "8.4.31",
"prettier": "3.0.3",
"prettier-plugin-tailwindcss": "0.5.6",
"tailwindcss": "3.3.4",
"vite": "4.5.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
tailwindConfig: "./tailwind.config.cjs",
plugins: [require("prettier-plugin-tailwindcss")],
};
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { renderToString } from 'react-dom/server';
import pkg from '@material-tailwind/react';
const { Select, Option } = pkg;
const element = React.createElement(Select, {
label: 'Test',
value: 'b',
selected: (el) => el?.props.children,
onChange: () => {},
children: [
React.createElement(Option, { key: 'a', value: 'a' }, 'Alpha'),
React.createElement(Option, { key: 'b', value: 'b' }, 'Beta')
]
});
console.log(renderToString(element));
+16
View File
@@ -0,0 +1,16 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { Dashboard, Auth } from "@/layouts";
import Setup from "@/pages/setup/setup";
function App() {
return (
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/auth/*" element={<Auth />} />
<Route path="/setup" element={<Setup />} />
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
</Routes>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+172
View File
@@ -0,0 +1,172 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2,
};
const AuthContext = createContext(null);
AuthContext.displayName = "AuthContext";
const normalizePermissions = (raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return Object.entries(raw).reduce((acc, [key, value]) => {
if (typeof key !== "string" || !key) {
return acc;
}
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
acc[key] = normalizedValue || "none";
return acc;
}, {});
};
export function AuthProvider({ children }) {
const [state, setState] = useState({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: false,
});
const setSession = useCallback((payload) => {
const user = payload?.user ?? null;
const permissions = normalizePermissions(payload?.permissions);
setState({
user,
permissions,
loading: false,
error: null,
lastErrorCode: null,
initialized: true,
});
}, []);
const clearSession = useCallback(() => {
setState((prev) => ({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: prev.initialized || true,
}));
}, []);
const refreshSession = useCallback(async () => {
setState((prev) => ({
...prev,
loading: true,
error: null,
lastErrorCode: null,
}));
try {
const response = await fetch("/api/auth/session", {
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setState({
user: null,
permissions: {},
loading: false,
error: payload?.error ?? null,
lastErrorCode: payload?.error ?? null,
initialized: true,
});
return { ok: false, status: response.status, error: payload?.error ?? null };
}
setSession(payload);
return { ok: true, data: payload };
} catch (err) {
const message = err?.message || "NETWORK_ERROR";
setState({
user: null,
permissions: {},
loading: false,
error: message,
lastErrorCode: "NETWORK_ERROR",
initialized: true,
});
return { ok: false, error: message };
}
}, [setSession]);
const logout = useCallback(async () => {
let capturedError = null;
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
logoutError.code = payload?.error || `STATUS_${response.status}`;
throw logoutError;
}
} catch (err) {
capturedError = err;
} finally {
clearSession();
}
if (capturedError) {
throw capturedError;
}
}, [clearSession]);
const hasPermission = useCallback(
(permissionKey, requiredLevel = "full") => {
if (!permissionKey) {
return true;
}
if (state.user?.isSuperuser) {
return true;
}
const current = state.permissions?.[permissionKey] ?? "none";
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
return currentPriority >= requiredPriority;
},
[state.permissions, state.user]
);
const value = useMemo(
() => ({
user: state.user,
permissions: state.permissions,
loading: state.loading,
error: state.error,
lastErrorCode: state.lastErrorCode,
initialized: state.initialized,
isAuthenticated: Boolean(state.user),
refreshSession,
setSession,
clearSession,
logout,
hasPermission,
}),
[state, refreshSession, setSession, clearSession, logout, hasPermission]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
@@ -0,0 +1,223 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
import { useAuth } from "@/components/AuthProvider.jsx";
const MaintenanceContext = createContext(null);
const INITIAL_STATE = {
maintenance: null,
update: null,
script: null,
ssh: null,
loading: true,
error: "",
lastUpdated: null
};
export default function MaintenanceProvider({ children }) {
const { isAuthenticated, hasPermission, user } = useAuth();
const canAccessMaintenance = Boolean(isAuthenticated && (user?.isSuperuser || hasPermission("maintenance-access", "read")));
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
const applyState = useCallback((partial) => {
setState((prev) => ({
...prev,
...partial
}));
}, []);
const fetchConfig = useCallback(async () => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return null;
}
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
const data = response.data ?? {};
applyState({
maintenance: data.maintenance ?? null,
update: data.update ?? null,
script: data.script ?? null,
ssh: data.ssh ?? null,
loading: false,
error: "",
lastUpdated: new Date()
});
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState, canAccessMaintenance]);
const refreshUpdateStatus = useCallback(async () => {
if (!canAccessMaintenance) {
return;
}
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
setState((prev) => ({
...prev,
maintenance: data.maintenance ?? prev.maintenance,
update: data.update ?? prev.update,
error: ""
}));
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, [canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
applyState({ loading: false, error: "" });
return;
}
fetchConfig();
}, [applyState, fetchConfig, canAccessMaintenance]);
useEffect(() => {
if (!canAccessMaintenance) {
return undefined;
}
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
refreshUpdateStatus();
}, 5000);
pollingRef.current = intervalId;
return () => clearInterval(intervalId);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return undefined;
}, [canAccessMaintenance, state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (canAccessMaintenance && !currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [canAccessMaintenance, state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
await refreshUpdateStatus();
}, [refreshUpdateStatus]);
const saveScript = useCallback(async (script) => {
await axios.put("/api/maintenance/update-script", { script });
await fetchConfig();
}, [fetchConfig]);
const resetScript = useCallback(async () => {
await axios.delete("/api/maintenance/update-script");
await fetchConfig();
}, [fetchConfig]);
const saveSshConfig = useCallback(async (config) => {
await axios.put("/api/maintenance/ssh-config", config);
await fetchConfig();
}, [fetchConfig]);
const deleteSshConfig = useCallback(async () => {
await axios.delete("/api/maintenance/ssh-config");
await fetchConfig();
}, [fetchConfig]);
const setMaintenanceMode = useCallback(async ({ active, message } = {}) => {
const payload = { active: Boolean(active) };
if (message !== undefined) {
payload.message = message;
}
const response = await axios.post("/api/maintenance/mode", payload);
const nextMaintenance = response.data?.maintenance ?? null;
applyState({ maintenance: nextMaintenance, error: "" });
return nextMaintenance;
}, [applyState]);
const testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
const fetchSuperuserStatus = useCallback(async () => {
const response = await axios.get("/api/auth/superuser/status");
const data = response.data ?? {};
return {
exists: Boolean(data.exists),
user: data.user ?? null
};
}, []);
const fetchSetupStatus = useCallback(async () => {
const response = await axios.get("/api/setup/status");
return response.data ?? {};
}, []);
const deleteSetupServer = useCallback(async (serverId) => {
const response = await axios.delete(`/api/setup/servers/${serverId}`);
return response.data ?? { success: false };
}, []);
const updateSetupApiKey = useCallback(async (serverId, apiKey) => {
const response = await axios.put(`/api/setup/servers/${serverId}/api-key`, { apiKey });
return response.data ?? { success: false };
}, []);
const updateSelfStackId = useCallback(async (selfStackId) => {
const response = await axios.put("/api/setup/self-stack", { selfStackId });
return response.data ?? { success: false };
}, []);
const removeSuperuserAccount = useCallback(async () => {
const response = await axios.delete("/api/auth/superuser");
return response.data ?? { success: false };
}, []);
const value = useMemo(() => ({
maintenance: state.maintenance,
update: state.update,
script: state.script,
ssh: state.ssh,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
refreshConfig: fetchConfig,
refreshUpdateStatus,
setMaintenanceMode,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupServer,
updateSetupApiKey,
updateSelfStackId,
removeSuperuserAccount
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupServer, updateSetupApiKey, updateSelfStackId, removeSuperuserAccount]);
return (
<MaintenanceContext.Provider value={value}>
{children}
</MaintenanceContext.Provider>
);
}
export function useMaintenance() {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenance must be used within a MaintenanceProvider');
}
return context;
}
+253
View File
@@ -0,0 +1,253 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
const DEFAULT_PER_PAGE_OPTIONS = [
{ value: "10", label: "10" },
{ value: "25", label: "25" },
{ value: "50", label: "50" },
{ value: "100", label: "100" },
{ value: "all", label: "Alle" }
];
const DEFAULT_PER_PAGE = "50";
const PageContext = createContext(null);
const normalizeNumber = (value, fallback) => {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const fallbackNumeric = Number(fallback);
if (Number.isFinite(fallbackNumeric) && fallbackNumeric > 0) {
return fallbackNumeric;
}
return 1;
};
export function PageProvider({
children,
initialPage = 1,
initialPerPage = DEFAULT_PER_PAGE,
perPageOptions = DEFAULT_PER_PAGE_OPTIONS
}) {
const [page, setPage] = useState(initialPage);
const [perPage, setPerPage] = useState(initialPerPage);
const [totalItems, setTotalItems] = useState(0);
const [visibleItems, setVisibleItems] = useState(0);
const initialPageRef = useRef(initialPage);
const initialPerPageRef = useRef(initialPerPage);
const validPerPageValues = useMemo(() => new Set(
perPageOptions.map((option) => String(option.value))
), [perPageOptions]);
const perPageIsAll = perPage === "all";
const resolvePerPageNumber = useCallback(() => {
if (perPageIsAll) {
return null;
}
return normalizeNumber(perPage, initialPerPage);
}, [perPage, perPageIsAll, initialPerPage]);
const updateTotals = useCallback((total, visible) => {
setTotalItems(Math.max(0, Number(total) || 0));
setVisibleItems(Math.max(0, Number(visible) || 0));
}, []);
const resetPagination = useCallback(() => {
setPage(initialPageRef.current ?? 1);
setPerPage(initialPerPageRef.current ?? DEFAULT_PER_PAGE);
setTotalItems(0);
setVisibleItems(0);
}, []);
const setPerPageValue = useCallback((value, { resetPage = true } = {}) => {
if (value === undefined || value === null) return;
const nextValue = String(value);
if (!validPerPageValues.has(nextValue)) {
return;
}
setPerPage(nextValue);
if (resetPage) {
setPage(1);
}
}, [validPerPageValues]);
const handlePerPageChange = useCallback((eventOrValue) => {
const nextValue = typeof eventOrValue === "string"
? eventOrValue
: eventOrValue?.target?.value;
setPerPageValue(nextValue, { resetPage: true });
}, [setPerPageValue]);
const handlePageChange = useCallback((nextPage) => {
if (perPageIsAll) return;
const numericPage = Number(nextPage);
if (!Number.isFinite(numericPage) || numericPage < 1) {
return;
}
const perPageNumber = resolvePerPageNumber();
if (!perPageNumber) {
return;
}
const maxPages = Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
if (numericPage > maxPages) {
return;
}
setPage(numericPage);
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
const totalPages = useMemo(() => {
if (perPageIsAll) {
return 1;
}
const perPageNumber = resolvePerPageNumber();
if (!perPageNumber) {
return 1;
}
return Math.max(1, Math.ceil((totalItems || 0) / perPageNumber));
}, [perPageIsAll, resolvePerPageNumber, totalItems]);
const rangeStart = useMemo(() => {
if (!totalItems) {
return 0;
}
if (perPageIsAll) {
return 1;
}
const perPageNumber = resolvePerPageNumber();
return (page - 1) * perPageNumber + 1;
}, [totalItems, perPageIsAll, resolvePerPageNumber, page]);
const rangeEnd = useMemo(() => {
if (!totalItems) {
return 0;
}
if (perPageIsAll) {
return totalItems;
}
const perPageNumber = resolvePerPageNumber();
const estimatedEnd = (page - 1) * perPageNumber + (visibleItems || perPageNumber);
return Math.min(totalItems, estimatedEnd);
}, [totalItems, perPageIsAll, resolvePerPageNumber, page, visibleItems]);
const summaryLabel = useMemo(() => {
if (!totalItems) {
return "Keine Einträge";
}
if (perPageIsAll) {
return `Zeige alle ${totalItems.toLocaleString()} Einträge`;
}
return `Zeige ${rangeStart.toLocaleString()} ${rangeEnd.toLocaleString()} von ${totalItems.toLocaleString()} Einträgen`;
}, [totalItems, perPageIsAll, rangeStart, rangeEnd]);
const value = useMemo(() => ({
page,
perPage,
perPageOptions,
perPageIsAll,
totalItems,
visibleItems,
totalPages,
rangeStart,
rangeEnd,
summaryLabel,
setPage,
setPerPage: setPerPageValue,
setTotals: updateTotals,
setTotalItems,
setVisibleItems,
handlePageChange,
handlePerPageChange,
validPerPageValues,
resetPagination
}), [
page,
perPage,
perPageOptions,
perPageIsAll,
totalItems,
visibleItems,
totalPages,
rangeStart,
rangeEnd,
summaryLabel,
updateTotals,
setPerPageValue,
handlePageChange,
handlePerPageChange,
validPerPageValues,
resetPagination
]);
return (
<PageContext.Provider value={value}>
{children}
</PageContext.Provider>
);
}
export function usePage() {
const context = useContext(PageContext);
if (!context) {
throw new Error("usePage must be used within a PageProvider");
}
return context;
}
export function PaginationControls({ disabled = false }) {
const {
summaryLabel,
perPageIsAll,
page,
totalPages,
handlePageChange
} = usePage();
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<span>{summaryLabel}</span>
{!perPageIsAll && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={disabled || page <= 1}
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
<path d="M11.03 3.97a.75.75 0 0 1 0 1.06l-6.22 6.22H21a.75.75 0 0 1 0 1.5H4.81l6.22 6.22a.75.75 0 1 1-1.06 1.06l-7.5-7.5a.75.75 0 0 1 0-1.06l7.5-7.5a.75.75 0 0 1 1.06 0Z" />
</svg>
</button>
<p className="text-slate-600">
Seite <strong className="text-slate-800">{page}</strong> /&nbsp;
<strong className="text-slate-800">{totalPages}</strong>
</p>
<button
onClick={() => handlePageChange(page + 1)}
disabled={disabled || page >= totalPages}
className="rounded-md border border-slate-300 p-2.5 text-center text-sm transition-all shadow-sm hover:shadow-lg text-slate-600 hover:text-white hover:bg-slate-800 hover:border-slate-800 focus:text-white focus:bg-slate-800 focus:border-slate-800 active:border-slate-800 active:text-white active:bg-slate-800 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="h-4 w-4">
<path d="M12.97 3.97a.75.75 0 0 1 1.06 0l7.5 7.5a.75.75 0 0 1 0 1.06l-7.5 7.5a.75.75 0 1 1-1.06-1.06l6.22-6.22H3a.75.75 0 0 1 0-1.5h16.19l-6.22-6.22a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
)}
</div>
);
}
export const PAGE_DEFAULTS = {
perPage: DEFAULT_PER_PAGE,
options: DEFAULT_PER_PAGE_OPTIONS
};
export default PageProvider;
@@ -0,0 +1,58 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography, Card, CardBody } from "@material-tailwind/react";
import { useAuth } from "@/components/AuthProvider.jsx";
export function PermissionGate({
permission,
requiredLevel = "full",
loadingFallback = null,
children,
}) {
const { initialized, loading, hasPermission } = useAuth();
if (!permission) {
return children;
}
if (!initialized || loading) {
return (
loadingFallback ?? (
<div className="flex min-h-[200px] items-center justify-center">
<Typography variant="small" color="blue-gray">
Berechtigungen werden geladen
</Typography>
</div>
)
);
}
if (hasPermission(permission, requiredLevel)) {
return children;
}
return (
<div className="mt-12 flex items-center justify-center px-4">
<Card className="max-w-xl border border-blue-gray-100 shadow-sm">
<CardBody className="space-y-3 text-center">
<Typography variant="h5" color="blue-gray">
Kein Zugriff auf diesen Bereich
</Typography>
<Typography variant="small" className="text-blue-gray-500">
Für diese Funktion wird die Berechtigung <code>{permission}</code> benötigt.
</Typography>
</CardBody>
</Card>
</div>
);
}
PermissionGate.propTypes = {
permission: PropTypes.string,
requiredLevel: PropTypes.string,
loadingFallback: PropTypes.node,
children: PropTypes.node.isRequired,
};
export default PermissionGate;
+121
View File
@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
accent: "bg-mossGreen-400"
},
warning: {
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
accent: "bg-warmAmberGlow-400"
},
error: {
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
accent: "bg-sunsetCoral-400"
},
info: {
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
accent: "bg-arcticBlue-400"
}
};
const DEFAULT_DURATION = 6000;
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timeoutsRef = useRef(new Map());
const dismissToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const handle = timeoutsRef.current.get(id);
if (handle && typeof window !== "undefined") {
window.clearTimeout(handle);
}
timeoutsRef.current.delete(id);
}, []);
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
setToasts((prev) => {
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
return [
...withoutExisting,
{
id: toastId,
title,
description,
variant: normalizedVariant
}
];
});
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
const timeoutHandle = window.setTimeout(() => {
dismissToast(toastId);
}, duration);
const existingHandle = timeoutsRef.current.get(toastId);
if (existingHandle) {
window.clearTimeout(existingHandle);
}
timeoutsRef.current.set(toastId, timeoutHandle);
}
return toastId;
}, [dismissToast]);
useEffect(() => {
return () => {
if (typeof window === "undefined") return;
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
timeoutsRef.current.clear();
};
}, []);
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
{toasts.map((toast) => {
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
return (
<div
key={toast.id}
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
>
<div className="flex items-start gap-3">
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
<div className="flex-1">
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
</div>
<button
type="button"
onClick={() => dismissToast(toast.id)}
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
>
Schließen
</button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import PropTypes from "prop-types";
export const MaterialTailwind = React.createContext(null);
MaterialTailwind.displayName = "MaterialTailwindContext";
export function reducer(state, action) {
switch (action.type) {
case "OPEN_SIDENAV": {
return { ...state, openSidenav: action.value };
}
case "SIDENAV_TYPE": {
return { ...state, sidenavType: action.value };
}
case "SIDENAV_COLOR": {
return { ...state, sidenavColor: action.value };
}
case "TRANSPARENT_NAVBAR": {
return { ...state, transparentNavbar: action.value };
}
case "FIXED_NAVBAR": {
return { ...state, fixedNavbar: action.value };
}
case "OPEN_CONFIGURATOR": {
return { ...state, openConfigurator: action.value };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
export function MaterialTailwindControllerProvider({ children }) {
const initialState = {
openSidenav: false,
sidenavColor: "gray",
sidenavType: "white",
transparentNavbar: false,
fixedNavbar: true,
openConfigurator: false,
};
const [controller, dispatch] = React.useReducer(reducer, initialState);
const value = React.useMemo(
() => [controller, dispatch],
[controller, dispatch]
);
return (
<MaterialTailwind.Provider value={value}>
{children}
</MaterialTailwind.Provider>
);
}
export function useMaterialTailwindController() {
const context = React.useContext(MaterialTailwind);
if (!context) {
throw new Error(
"useMaterialTailwindController should be used inside the MaterialTailwindControllerProvider."
);
}
return context;
}
MaterialTailwindControllerProvider.displayName = "/src/context/index.jsx";
MaterialTailwindControllerProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const setOpenSidenav = (dispatch, value) =>
dispatch({ type: "OPEN_SIDENAV", value });
export const setSidenavType = (dispatch, value) =>
dispatch({ type: "SIDENAV_TYPE", value });
export const setSidenavColor = (dispatch, value) =>
dispatch({ type: "SIDENAV_COLOR", value });
export const setTransparentNavbar = (dispatch, value) =>
dispatch({ type: "TRANSPARENT_NAVBAR", value });
export const setFixedNavbar = (dispatch, value) =>
dispatch({ type: "FIXED_NAVBAR", value });
export const setOpenConfigurator = (dispatch, value) =>
dispatch({ type: "OPEN_CONFIGURATOR", value });
+61
View File
@@ -0,0 +1,61 @@
export const chartsConfig = {
chart: {
toolbar: {
show: false,
},
},
title: {
show: "",
},
dataLabels: {
enabled: false,
},
xaxis: {
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
labels: {
style: {
colors: "#37474f",
fontSize: "13px",
fontFamily: "inherit",
fontWeight: 300,
},
},
},
yaxis: {
labels: {
style: {
colors: "#37474f",
fontSize: "13px",
fontFamily: "inherit",
fontWeight: 300,
},
},
},
grid: {
show: true,
borderColor: "#dddddd",
strokeDashArray: 5,
xaxis: {
lines: {
show: true,
},
},
padding: {
top: 5,
right: 20,
},
},
fill: {
opacity: 0.8,
},
tooltip: {
theme: "dark",
},
};
export default chartsConfig;
+1
View File
@@ -0,0 +1 @@
export * from "@/configs/charts-config";
+52
View File
@@ -0,0 +1,52 @@
export const authorsTableData = [
{
img: "/img/team-2.jpeg",
name: "John Michael",
email: "john@creative-tim.com",
job: ["Manager", "Organization"],
online: true,
date: "23/04/18",
},
{
img: "/img/team-1.jpeg",
name: "Alexa Liras",
email: "alexa@creative-tim.com",
job: ["Programator", "Developer"],
online: false,
date: "11/01/19",
},
{
img: "/img/team-4.jpeg",
name: "Laurent Perrier",
email: "laurent@creative-tim.com",
job: ["Executive", "Projects"],
online: true,
date: "19/09/17",
},
{
img: "/img/team-3.jpeg",
name: "Michael Levi",
email: "michael@creative-tim.com",
job: ["Programator", "Developer"],
online: true,
date: "24/12/08",
},
{
img: "/img/bruce-mars.jpeg",
name: "Bruce Mars",
email: "bruce@creative-tim.com",
job: ["Manager", "Executive"],
online: false,
date: "04/10/21",
},
{
img: "/img/team-2.jpeg",
name: "Alexander",
email: "alexander@creative-tim.com",
job: ["Programator", "Developer"],
online: false,
date: "14/09/20",
},
];
export default authorsTableData;
+14
View File
@@ -0,0 +1,14 @@
export const AVATAR_COLORS = [
"bg-arcticBlue-600",
"bg-copperRust-500",
"bg-sunsetCoral-600",
"bg-mintTea-400",
"bg-lavenderSmoke-500",
"bg-emeraldMist-500",
"bg-roseQuartz-500",
"bg-auroraTeal-500",
"bg-citrusPunch-500",
"bg-mossGreen-400"
];
export const DEFAULT_AVATAR_COLOR = "bg-mossGreen-500";
+29
View File
@@ -0,0 +1,29 @@
export const conversationsData = [
{
img: "/img/team-1.jpeg",
name: "Sophie B.",
message: "Hi! I need more information...",
},
{
img: "/img/team-2.jpeg",
name: "Alexander",
message: "Awesome work, can you...",
},
{
img: "/img/team-3.jpeg",
name: "Ivanna",
message: "About files I can...",
},
{
img: "/img/team-4.jpeg",
name: "Peterson",
message: "Have a great afternoon...",
},
{
img: "/img/bruce-mars.jpeg",
name: "Bruce Mars",
message: "Hi! I need more information...",
},
];
export default conversationsData;
+8
View File
@@ -0,0 +1,8 @@
export * from "@/data/statistics-cards-data";
export * from "@/data/statistics-charts-data";
export * from "@/data/projects-table-data";
export * from "@/data/orders-overview-data";
export * from "@/data/platform-settings-data";
export * from "@/data/conversations-data";
export * from "@/data/projects-data";
export * from "@/data/authors-table-data";
+49
View File
@@ -0,0 +1,49 @@
import {
BellIcon,
PlusCircleIcon,
ShoppingCartIcon,
CreditCardIcon,
LockOpenIcon,
BanknotesIcon,
} from "@heroicons/react/24/solid";
export const ordersOverviewData = [
{
icon: BellIcon,
color: "text-blue-gray-300",
title: "$2400, Design changes",
description: "22 DEC 7:20 PM",
},
{
icon: PlusCircleIcon,
color: "text-blue-gray-300",
title: "New order #1832412",
description: "21 DEC 11 PM",
},
{
icon: ShoppingCartIcon,
color: "text-blue-gray-300",
title: "Server payments for April",
description: "21 DEC 9:34 PM",
},
{
icon: CreditCardIcon,
color: "text-blue-gray-300",
title: "New card added for order #4395133",
description: "20 DEC 2:20 AM",
},
{
icon: LockOpenIcon,
color: "text-blue-gray-300",
title: "Unlock packages for development",
description: "18 DEC 4:54 AM",
},
{
icon: BanknotesIcon,
color: "text-blue-gray-300",
title: "New order #9583120",
description: "17 DEC",
},
];
export default ordersOverviewData;

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