560 Commits
Author SHA1 Message Date
M_Viper 8a95c68c5e Upload via GUI (1 Datei) 2026-11-08 20:31:00 +00:00
M_Viper 7c1a6d9537 Upload via GUI (2911 Dateien) 2026-11-08 20:20:32 +00:00
M_Viper f018c21308 Upload via GUI (2911 Dateien) 2026-11-08 20:20:21 +00:00
M_Viper a9738c76e7 Upload via GUI (2911 Dateien) 2026-11-08 20:20:20 +00:00
M_Viper 71558a19f9 Upload via GUI (2911 Dateien) 2026-11-08 20:20:18 +00:00
M_Viper f83870e025 Upload via GUI (2911 Dateien) 2026-11-08 20:20:17 +00:00
M_Viper 480ce4fd70 Upload via GUI (2911 Dateien) 2026-11-08 20:20:14 +00:00
M_Viper 585a8bab60 Upload via GUI (2911 Dateien) 2026-11-08 20:20:12 +00:00
M_Viper b378a1efc5 Upload via GUI (2911 Dateien) 2026-11-08 20:20:08 +00:00
M_Viper 24126c5f71 Upload via GUI (2911 Dateien) 2026-11-08 20:20:03 +00:00
M_Viper 4fd5b1db56 Upload via GUI (2911 Dateien) 2026-11-08 20:19:59 +00:00
M_Viper bff55aa262 Upload via GUI (2911 Dateien) 2026-11-08 20:19:55 +00:00
M_Viper 47d3336e1f Upload via GUI (2911 Dateien) 2026-11-08 20:19:54 +00:00
M_Viper bbb8aa97fc Upload via GUI (2911 Dateien) 2026-11-08 20:19:53 +00:00
M_Viper 6f81dab3e7 Upload via GUI (2911 Dateien) 2026-11-08 20:19:49 +00:00
M_Viper 8e6a2e1119 Upload via GUI (2911 Dateien) 2026-11-08 20:19:47 +00:00
M_Viper 5f12a14f37 Upload via GUI (2911 Dateien) 2026-11-08 20:19:46 +00:00
M_Viper c6b662a945 Upload via GUI (2911 Dateien) 2026-11-08 20:19:43 +00:00
M_Viper c5110a9670 Upload via GUI (2911 Dateien) 2026-11-08 20:19:40 +00:00
M_Viper 726b2f9a31 Upload via GUI (2911 Dateien) 2026-11-08 20:19:34 +00:00
M_Viper e001beae56 Upload via GUI (2911 Dateien) 2026-11-08 20:19:31 +00:00
M_Viper 772f7e90c3 Upload via GUI (2911 Dateien) 2026-11-08 20:19:24 +00:00
M_Viper 904ad673fb Upload via GUI (2911 Dateien) 2026-11-08 20:19:18 +00:00
M_Viper e5324372af Upload via GUI (2911 Dateien) 2026-11-08 20:19:13 +00:00
M_Viper 8ee6d11c44 Upload via GUI (2911 Dateien) 2026-11-08 20:19:11 +00:00
M_Viper a974be66ca Upload via GUI (2911 Dateien) 2026-11-08 20:19:07 +00:00
M_Viper bb4c11019e Upload via GUI (2911 Dateien) 2026-11-08 20:19:05 +00:00
M_Viper 14e3083729 Upload via GUI (2911 Dateien) 2026-11-08 20:19:03 +00:00
M_Viper f9dacda4e9 Upload via GUI (2911 Dateien) 2026-11-08 20:19:01 +00:00
M_Viper bcbc449590 Upload via GUI (2911 Dateien) 2026-11-08 20:18:57 +00:00
M_Viper 4ff3d4d9b6 Upload via GUI (2911 Dateien) 2026-11-08 20:18:52 +00:00
M_Viper cd6e03ce1d Upload via GUI (2911 Dateien) 2026-11-08 20:18:50 +00:00
M_Viper 171be7e321 Upload via GUI (2911 Dateien) 2026-11-08 20:18:46 +00:00
M_Viper 7e7b8859d7 Upload via GUI (2911 Dateien) 2026-11-08 20:18:45 +00:00
M_Viper 2cccebd947 Upload via GUI (2911 Dateien) 2026-11-08 20:18:41 +00:00
M_Viper d106ca0a1e Upload via GUI (2911 Dateien) 2026-11-08 20:18:39 +00:00
M_Viper 90cf811ba0 Upload via GUI (2911 Dateien) 2026-11-08 20:18:33 +00:00
M_Viper a929fbe2be Upload via GUI (2911 Dateien) 2026-11-08 20:18:31 +00:00
M_Viper 02764b0088 Upload via GUI (2911 Dateien) 2026-11-08 20:18:21 +00:00
M_Viper 1a79469da9 Upload via GUI (2911 Dateien) 2026-11-08 20:18:15 +00:00
M_Viper 824ae8b303 Upload via GUI (2911 Dateien) 2026-11-08 20:18:13 +00:00
M_Viper 1d57a58f16 Upload via GUI (2911 Dateien) 2026-11-08 20:18:07 +00:00
M_Viper 166592697c Upload via GUI (2911 Dateien) 2026-11-08 20:18:01 +00:00
M_Viper 20db5e0cbd Upload via GUI (2911 Dateien) 2026-11-08 20:17:58 +00:00
M_Viper 4047e2a5c3 Upload via GUI (2911 Dateien) 2026-11-08 20:17:53 +00:00
M_Viper 79826ca69e Upload via GUI (2911 Dateien) 2026-11-08 20:17:47 +00:00
M_Viper 7bdbeee6c4 Upload via GUI (2911 Dateien) 2026-11-08 20:17:46 +00:00
M_Viper 2c04cc0aa6 Upload via GUI (2911 Dateien) 2026-11-08 20:17:43 +00:00
M_Viper 88f25d82ad Upload via GUI (2911 Dateien) 2026-11-08 20:17:37 +00:00
M_Viper e12f80b65a Upload via GUI (2911 Dateien) 2026-11-08 20:17:36 +00:00
M_Viper e6c0ba5af2 Upload via GUI (2911 Dateien) 2026-11-08 20:17:34 +00:00
M_Viper 08d543cd12 Upload via GUI (2911 Dateien) 2026-11-08 20:17:32 +00:00
M_Viper 77c6b1af9e Upload via GUI (2911 Dateien) 2026-11-08 20:17:26 +00:00
M_Viper 19e7868254 Upload via GUI (2911 Dateien) 2026-11-08 20:17:22 +00:00
M_Viper e083638e97 Upload via GUI (2911 Dateien) 2026-11-08 20:17:20 +00:00
M_Viper e6d6bff5b3 Upload via GUI (2911 Dateien) 2026-11-08 20:17:16 +00:00
M_Viper acb40edb09 Upload via GUI (2911 Dateien) 2026-11-08 20:17:14 +00:00
M_Viper 02616d0081 Upload via GUI (2911 Dateien) 2026-11-08 20:17:08 +00:00
M_Viper dbfb52ce9d Upload via GUI (2911 Dateien) 2026-11-08 20:17:03 +00:00
M_Viper 10b69f9c8c Upload via GUI (2911 Dateien) 2026-11-08 20:16:57 +00:00
M_Viper a7644de272 Upload via GUI (2911 Dateien) 2026-11-08 20:16:51 +00:00
M_Viper ad3e8c9bf0 Upload via GUI (2911 Dateien) 2026-11-08 20:16:46 +00:00
M_Viper 6334111b40 Upload via GUI (2911 Dateien) 2026-11-08 20:16:40 +00:00
M_Viper e005388abc Upload via GUI (2911 Dateien) 2026-11-08 20:16:33 +00:00
M_Viper 5eb3e23d8c Upload via GUI (2911 Dateien) 2026-11-08 20:16:26 +00:00
M_Viper 0960e1a59e Upload via GUI (2911 Dateien) 2026-11-08 20:16:20 +00:00
M_Viper 24de50940e Upload via GUI (2911 Dateien) 2026-11-08 20:16:14 +00:00
M_Viper d31f33516b Upload via GUI (2911 Dateien) 2026-11-08 20:16:10 +00:00
M_Viper 545190f4e3 Upload via GUI (2911 Dateien) 2026-11-08 20:16:09 +00:00
M_Viper 1a78d84452 Upload via GUI (2911 Dateien) 2026-11-08 20:16:07 +00:00
M_Viper bd4f68e81e Upload via GUI (2911 Dateien) 2026-11-08 20:16:05 +00:00
M_Viper 6ebff18d52 Upload via GUI (2911 Dateien) 2026-11-08 20:15:59 +00:00
M_Viper 4ed45505df Upload via GUI (2911 Dateien) 2026-11-08 20:15:57 +00:00
M_Viper 7b95af41dc Upload via GUI (2911 Dateien) 2026-11-08 20:15:56 +00:00
M_Viper 4d45ff8a6b Upload via GUI (2911 Dateien) 2026-11-08 20:15:51 +00:00
M_Viper b1a5efc7dd Upload via GUI (2911 Dateien) 2026-11-08 20:15:46 +00:00
M_Viper ce9a0a05ab Upload via GUI (2911 Dateien) 2026-11-08 20:15:39 +00:00
M_Viper fc387c41e4 Upload via GUI (2911 Dateien) 2026-11-08 20:15:33 +00:00
M_Viper 82a1bd79ce Upload via GUI (2911 Dateien) 2026-11-08 20:15:26 +00:00
M_Viper d049f53409 Upload via GUI (2911 Dateien) 2026-11-08 20:15:19 +00:00
M_Viper b0d3ad012a Upload via GUI (2911 Dateien) 2026-11-08 20:15:18 +00:00
M_Viper 494c8e6604 Upload via GUI (2911 Dateien) 2026-11-08 20:15:16 +00:00
M_Viper 5c5e01208a Upload via GUI (2911 Dateien) 2026-11-08 20:15:14 +00:00
M_Viper 1a97e172fb Upload via GUI (2911 Dateien) 2026-11-08 20:15:13 +00:00
M_Viper 5ed3bb6280 Upload via GUI (2911 Dateien) 2026-11-08 20:15:10 +00:00
M_Viper d64d036a7e Upload via GUI (2911 Dateien) 2026-11-08 20:15:08 +00:00
M_Viper 8970f692a1 Upload via GUI (2911 Dateien) 2026-11-08 20:15:00 +00:00
M_Viper 0a2df40fc5 Upload via GUI (2911 Dateien) 2026-11-08 20:14:51 +00:00
M_Viper dbe4a82d8d Upload via GUI (2911 Dateien) 2026-11-08 20:14:44 +00:00
M_Viper 20cb46537a Upload via GUI (2911 Dateien) 2026-11-08 20:14:37 +00:00
M_Viper 8f401fba62 Upload via GUI (2911 Dateien) 2026-11-08 20:14:31 +00:00
M_Viper 0ae0b6c0e9 Upload via GUI (2911 Dateien) 2026-11-08 20:14:29 +00:00
M_Viper 66c042dcb3 Upload via GUI (2911 Dateien) 2026-11-08 20:14:26 +00:00
M_Viper 234fc004cb Upload via GUI (2911 Dateien) 2026-11-08 20:14:24 +00:00
M_Viper cefad76767 Upload via GUI (2911 Dateien) 2026-11-08 20:14:22 +00:00
M_Viper ebec1fb6af Upload via GUI (2911 Dateien) 2026-11-08 20:14:19 +00:00
M_Viper 6b377badb4 Upload via GUI (2911 Dateien) 2026-11-08 20:14:17 +00:00
M_Viper 2258341cf3 Upload via GUI (2911 Dateien) 2026-11-08 20:14:13 +00:00
M_Viper 434793c7f8 Upload via GUI (2911 Dateien) 2026-11-08 20:14:07 +00:00
M_Viper bf68c34eb8 Upload via GUI (2911 Dateien) 2026-11-08 20:14:02 +00:00
M_Viper 342169c48a Upload via GUI (2911 Dateien) 2026-11-08 20:13:56 +00:00
M_Viper 702ada398b Upload via GUI (2911 Dateien) 2026-11-08 20:13:50 +00:00
M_Viper a8fd665ca0 Upload via GUI (2911 Dateien) 2026-11-08 20:13:44 +00:00
M_Viper d22c023ec7 Upload via GUI (2911 Dateien) 2026-11-08 20:13:38 +00:00
M_Viper 6ffb05f4ca Upload via GUI (2911 Dateien) 2026-11-08 20:13:33 +00:00
M_Viper 6155584b15 Upload via GUI (2911 Dateien) 2026-11-08 20:13:27 +00:00
M_Viper ea5e5ec519 Upload via GUI (2911 Dateien) 2026-11-08 20:13:21 +00:00
M_Viper 0a09b2d34e Upload via GUI (2911 Dateien) 2026-11-08 20:13:14 +00:00
M_Viper f8b982e736 Upload via GUI (2911 Dateien) 2026-11-08 20:13:08 +00:00
M_Viper 8ddf5ec278 Upload via GUI (2911 Dateien) 2026-11-08 20:13:02 +00:00
M_Viper f8d64cc1ae Upload via GUI (2911 Dateien) 2026-11-08 20:12:56 +00:00
M_Viper eef71d1d76 Upload via GUI (2911 Dateien) 2026-11-08 20:12:49 +00:00
M_Viper 072520b01b Upload via GUI (2911 Dateien) 2026-11-08 20:12:42 +00:00
M_Viper 264e0d6440 Upload via GUI (2911 Dateien) 2026-11-08 20:12:35 +00:00
M_Viper e90c183a83 Upload via GUI (2911 Dateien) 2026-11-08 20:12:33 +00:00
M_Viper ec0ec5440f Upload via GUI (2911 Dateien) 2026-11-08 20:12:27 +00:00
M_Viper 4c3f876663 Upload via GUI (66 Dateien) 2026-11-08 20:11:03 +00:00
M_Viper 8f62cdba45 Upload via GUI (66 Dateien) 2026-11-08 20:10:59 +00:00
M_Viper 9642b9690a Upload via GUI (38 Dateien) 2026-11-08 20:10:04 +00:00
M_Viper fa04ebefb7 Upload via GUI (38 Dateien) 2026-11-08 20:10:01 +00:00
M_Viper fe3c8fa7c5 Upload via GUI (30 Dateien) 2026-11-08 20:09:19 +00:00
M_Viper 4ad8fe8349 Upload via GUI (34 Dateien) 2026-11-08 20:09:05 +00:00
M_Viper 5e81baa076 Upload via GUI (26 Dateien) 2026-11-08 20:08:29 +00:00
M_Viper 3a77b15e32 Upload via GUI (13 Dateien) 2026-11-08 20:08:11 +00:00
M_Viper 009079553b Upload via GUI (13 Dateien) 2026-11-08 20:08:09 +00:00
M_Viper 6369bacbc7 Upload via GUI (74 Dateien) 2026-11-08 20:07:43 +00:00
M_Viper 0d285ae37e Upload via GUI (74 Dateien) 2026-11-08 20:07:37 +00:00
M_Viper 33705e71a1 Upload via GUI (23 Dateien) 2026-11-08 20:06:09 +00:00
M_Viper 3461f5391b Upload via GUI (452 Dateien) 2026-11-08 20:05:46 +00:00
M_Viper 818a753921 Upload via GUI (452 Dateien) 2026-11-08 20:05:40 +00:00
M_Viper 096873bf99 Upload via GUI (452 Dateien) 2026-11-08 20:05:35 +00:00
M_Viper 737165bb8f Upload via GUI (452 Dateien) 2026-11-08 20:05:27 +00:00
M_Viper 371847fb98 Upload via GUI (452 Dateien) 2026-11-08 20:05:19 +00:00
M_Viper 8056c37b7e Upload via GUI (452 Dateien) 2026-11-08 20:05:11 +00:00
M_Viper 27e3836c2e Upload via GUI (452 Dateien) 2026-11-08 20:05:05 +00:00
M_Viper be95ee2046 Upload via GUI (452 Dateien) 2026-11-08 20:04:59 +00:00
M_Viper b6aaa6ec1f Upload via GUI (452 Dateien) 2026-11-08 20:04:53 +00:00
M_Viper f9ce1fb36a Upload via GUI (452 Dateien) 2026-11-08 20:04:51 +00:00
M_Viper 4e38fa8b4f Upload via GUI (452 Dateien) 2026-11-08 20:04:49 +00:00
M_Viper 088b237669 Upload via GUI (452 Dateien) 2026-11-08 20:04:47 +00:00
M_Viper 61099abb8b Upload via GUI (452 Dateien) 2026-11-08 20:04:43 +00:00
M_Viper 494134a8ea Upload via GUI (2911 Dateien) 2026-11-05 21:31:49 +00:00
M_Viper 82287f3778 Upload via GUI (2911 Dateien) 2026-11-05 21:31:44 +00:00
M_Viper e5bc238c58 Upload via GUI (2911 Dateien) 2026-11-05 21:31:42 +00:00
M_Viper b6bb4245f8 Upload via GUI (2911 Dateien) 2026-11-05 21:31:39 +00:00
M_Viper 2810202c97 Upload via GUI (2911 Dateien) 2026-11-05 21:31:34 +00:00
M_Viper 79d70af395 Upload via GUI (2911 Dateien) 2026-11-05 21:31:32 +00:00
M_Viper a5811300dc Upload via GUI (2911 Dateien) 2026-11-05 21:31:29 +00:00
M_Viper f904378396 Upload via GUI (2911 Dateien) 2026-11-05 21:31:27 +00:00
M_Viper 5f5f206880 Upload via GUI (2911 Dateien) 2026-11-05 21:31:22 +00:00
M_Viper df50937f36 Upload via GUI (2911 Dateien) 2026-11-05 21:31:19 +00:00
M_Viper ca6a786c8e Upload via GUI (2911 Dateien) 2026-11-05 21:31:16 +00:00
M_Viper e688ef82c9 Upload via GUI (2911 Dateien) 2026-11-05 21:31:15 +00:00
M_Viper 1ebdcbb12b Upload via GUI (2911 Dateien) 2026-11-05 21:31:14 +00:00
M_Viper b9645ddf3e Upload via GUI (2911 Dateien) 2026-11-05 21:31:11 +00:00
M_Viper 3e6be3817d Upload via GUI (2911 Dateien) 2026-11-05 21:31:09 +00:00
M_Viper fb9ff17af5 Upload via GUI (2911 Dateien) 2026-11-05 21:31:08 +00:00
M_Viper 65cf2623d7 Upload via GUI (2911 Dateien) 2026-11-05 21:31:05 +00:00
M_Viper e7cbd5ef5c Upload via GUI (2911 Dateien) 2026-11-05 21:31:03 +00:00
M_Viper 7698400a26 Upload via GUI (2911 Dateien) 2026-11-05 21:31:01 +00:00
M_Viper 2dda10a88d Upload via GUI (2911 Dateien) 2026-11-05 21:30:59 +00:00
M_Viper 14fe13220e Upload via GUI (2911 Dateien) 2026-11-05 21:30:56 +00:00
M_Viper 9f762311e7 Upload via GUI (2911 Dateien) 2026-11-05 21:30:53 +00:00
M_Viper 78182c0fae Upload via GUI (2911 Dateien) 2026-11-05 21:30:50 +00:00
M_Viper 76c472693a Upload via GUI (2911 Dateien) 2026-11-05 21:30:48 +00:00
M_Viper 5a0a4a2d3a Upload via GUI (2911 Dateien) 2026-11-05 21:30:44 +00:00
M_Viper 8305614541 Upload via GUI (2911 Dateien) 2026-11-05 21:30:43 +00:00
M_Viper 5ac4fa63d4 Upload via GUI (2911 Dateien) 2026-11-05 21:30:41 +00:00
M_Viper 840f88cdbe Upload via GUI (2911 Dateien) 2026-11-05 21:30:39 +00:00
M_Viper f9a49240de Upload via GUI (2911 Dateien) 2026-11-05 21:30:36 +00:00
M_Viper b8cd9484f4 Upload via GUI (2911 Dateien) 2026-11-05 21:30:29 +00:00
M_Viper 95693b4b19 Upload via GUI (2911 Dateien) 2026-11-05 21:30:27 +00:00
M_Viper 5134b0641e Upload via GUI (2911 Dateien) 2026-11-05 21:30:24 +00:00
M_Viper a492235a57 Upload via GUI (2911 Dateien) 2026-11-05 21:30:22 +00:00
M_Viper 0d5636c2f2 Upload via GUI (2911 Dateien) 2026-11-05 21:30:18 +00:00
M_Viper 5c11239567 Upload via GUI (2911 Dateien) 2026-11-05 21:30:16 +00:00
M_Viper 0d06854f17 Upload via GUI (2911 Dateien) 2026-11-05 21:30:11 +00:00
M_Viper f8341c8c39 Upload via GUI (2911 Dateien) 2026-11-05 21:30:08 +00:00
M_Viper 98bab6de35 Upload via GUI (2911 Dateien) 2026-11-05 21:30:02 +00:00
M_Viper 0f81803257 Upload via GUI (2911 Dateien) 2026-11-05 21:29:56 +00:00
M_Viper 8b54fc3e4b Upload via GUI (2911 Dateien) 2026-11-05 21:29:54 +00:00
M_Viper 30a2fd4ba1 Upload via GUI (2911 Dateien) 2026-11-05 21:29:50 +00:00
M_Viper 445e078280 Upload via GUI (2911 Dateien) 2026-11-05 21:29:44 +00:00
M_Viper 0e800925a3 Upload via GUI (2911 Dateien) 2026-11-05 21:29:41 +00:00
M_Viper e3cf6336c1 Upload via GUI (2911 Dateien) 2026-11-05 21:29:36 +00:00
M_Viper 56606bab71 Upload via GUI (2911 Dateien) 2026-11-05 21:29:30 +00:00
M_Viper 65692f87c4 Upload via GUI (2911 Dateien) 2026-11-05 21:29:29 +00:00
M_Viper 951a108c70 Upload via GUI (2911 Dateien) 2026-11-05 21:29:26 +00:00
M_Viper 56c9dc12ad Upload via GUI (2911 Dateien) 2026-11-05 21:29:21 +00:00
M_Viper 5871e9bdd3 Upload via GUI (2911 Dateien) 2026-11-05 21:29:20 +00:00
M_Viper 6e9340d902 Upload via GUI (2911 Dateien) 2026-11-05 21:29:17 +00:00
M_Viper fa739ec156 Upload via GUI (2911 Dateien) 2026-11-05 21:29:14 +00:00
M_Viper 2a1e8bc530 Upload via GUI (2911 Dateien) 2026-11-05 21:29:08 +00:00
M_Viper 66feb71887 Upload via GUI (2911 Dateien) 2026-11-05 21:29:02 +00:00
M_Viper 3f62821329 Upload via GUI (2911 Dateien) 2026-11-05 21:29:00 +00:00
M_Viper 1066989c69 Upload via GUI (2911 Dateien) 2026-11-05 21:28:55 +00:00
M_Viper 75f6e7621c Upload via GUI (2911 Dateien) 2026-11-05 21:28:54 +00:00
M_Viper bae289fd25 Upload via GUI (2911 Dateien) 2026-11-05 21:28:48 +00:00
M_Viper 442071624a Upload via GUI (2911 Dateien) 2026-11-05 21:28:43 +00:00
M_Viper 0c4c09d3cc Upload via GUI (2911 Dateien) 2026-11-05 21:28:37 +00:00
M_Viper a4160abafa Upload via GUI (2911 Dateien) 2026-11-05 21:28:32 +00:00
M_Viper c0ebdb6e95 Upload via GUI (2911 Dateien) 2026-11-05 21:28:26 +00:00
M_Viper a8fd91dc04 Upload via GUI (2911 Dateien) 2026-11-05 21:28:22 +00:00
M_Viper e34c42b8b9 Upload via GUI (2911 Dateien) 2026-11-05 21:28:17 +00:00
M_Viper b1a48e9461 Upload via GUI (2911 Dateien) 2026-11-05 21:28:11 +00:00
M_Viper 2dffe704db Upload via GUI (2911 Dateien) 2026-11-05 21:28:05 +00:00
M_Viper ed0f00beaa Upload via GUI (2911 Dateien) 2026-11-05 21:28:01 +00:00
M_Viper e0f393adae Upload via GUI (2911 Dateien) 2026-11-05 21:27:57 +00:00
M_Viper 41508bff9a Upload via GUI (2911 Dateien) 2026-11-05 21:27:56 +00:00
M_Viper 125c9e3b22 Upload via GUI (2911 Dateien) 2026-11-05 21:27:54 +00:00
M_Viper b7eac0c3a2 Upload via GUI (2911 Dateien) 2026-11-05 21:27:52 +00:00
M_Viper bd1b2a952c Upload via GUI (2911 Dateien) 2026-11-05 21:27:47 +00:00
M_Viper 1c6c0f6765 Upload via GUI (2911 Dateien) 2026-11-05 21:27:45 +00:00
M_Viper b021ab6df3 Upload via GUI (2911 Dateien) 2026-11-05 21:27:44 +00:00
M_Viper 8c2cb344fa Upload via GUI (2911 Dateien) 2026-11-05 21:27:39 +00:00
M_Viper 95eede76f5 Upload via GUI (2911 Dateien) 2026-11-05 21:27:34 +00:00
M_Viper fcfc1802e0 Upload via GUI (2911 Dateien) 2026-11-05 21:27:27 +00:00
M_Viper aa76dc60cd Upload via GUI (2911 Dateien) 2026-11-05 21:27:21 +00:00
M_Viper 6aa78b4490 Upload via GUI (2911 Dateien) 2026-11-05 21:27:16 +00:00
M_Viper 4b27705cf0 Upload via GUI (2911 Dateien) 2026-11-05 21:27:10 +00:00
M_Viper dcd954b5bf Upload via GUI (2911 Dateien) 2026-11-05 21:27:09 +00:00
M_Viper ccc9c20342 Upload via GUI (2911 Dateien) 2026-11-05 21:27:08 +00:00
M_Viper 7b47beef5b Upload via GUI (2911 Dateien) 2026-11-05 21:27:05 +00:00
M_Viper a36e734153 Upload via GUI (2911 Dateien) 2026-11-05 21:27:04 +00:00
M_Viper a55c2ff0c6 Upload via GUI (2911 Dateien) 2026-11-05 21:27:02 +00:00
M_Viper 9c0bf716f6 Upload via GUI (2911 Dateien) 2026-11-05 21:27:00 +00:00
M_Viper 9e348ba9c4 Upload via GUI (2911 Dateien) 2026-11-05 21:26:55 +00:00
M_Viper 690d7cd626 Upload via GUI (2911 Dateien) 2026-11-05 21:26:46 +00:00
M_Viper da93eb4c09 Upload via GUI (2911 Dateien) 2026-11-05 21:26:41 +00:00
M_Viper 65b4be9181 Upload via GUI (2911 Dateien) 2026-11-05 21:26:35 +00:00
M_Viper 527d22d148 Upload via GUI (2911 Dateien) 2026-11-05 21:26:30 +00:00
M_Viper fc4fe874f7 Upload via GUI (2911 Dateien) 2026-11-05 21:26:28 +00:00
M_Viper ba426e9cbd Upload via GUI (2911 Dateien) 2026-11-05 21:26:26 +00:00
M_Viper 36fddc2289 Upload via GUI (2911 Dateien) 2026-11-05 21:26:23 +00:00
M_Viper aabfaaa965 Upload via GUI (2911 Dateien) 2026-11-05 21:26:22 +00:00
M_Viper a4e83126b6 Upload via GUI (2911 Dateien) 2026-11-05 21:26:19 +00:00
M_Viper e0371381ec Upload via GUI (2911 Dateien) 2026-11-05 21:26:17 +00:00
M_Viper 4d96260fcf Upload via GUI (2911 Dateien) 2026-11-05 21:26:13 +00:00
M_Viper c8b706487f Upload via GUI (2911 Dateien) 2026-11-05 21:26:09 +00:00
M_Viper 4131696cff Upload via GUI (2911 Dateien) 2026-11-05 21:26:04 +00:00
M_Viper 1490401017 Upload via GUI (2911 Dateien) 2026-11-05 21:25:59 +00:00
M_Viper c98f358a7d Upload via GUI (2911 Dateien) 2026-11-05 21:25:52 +00:00
M_Viper 94d872dea3 Upload via GUI (2911 Dateien) 2026-11-05 21:25:46 +00:00
M_Viper 94a0936483 Upload via GUI (2911 Dateien) 2026-11-05 21:25:41 +00:00
M_Viper a502c48b77 Upload via GUI (2911 Dateien) 2026-11-05 21:25:34 +00:00
M_Viper 5946ea5372 Upload via GUI (2911 Dateien) 2026-11-05 21:25:26 +00:00
M_Viper d1c38200c4 Upload via GUI (2911 Dateien) 2026-11-05 21:25:20 +00:00
M_Viper 2bad52cf43 Upload via GUI (2911 Dateien) 2026-11-05 21:25:14 +00:00
M_Viper e175802f93 Upload via GUI (2911 Dateien) 2026-11-05 21:25:08 +00:00
M_Viper 5347501ced Upload via GUI (2911 Dateien) 2026-11-05 21:25:02 +00:00
M_Viper 659c41714b Upload via GUI (2911 Dateien) 2026-11-05 21:24:56 +00:00
M_Viper 19b47793f6 Upload via GUI (2911 Dateien) 2026-11-05 21:24:50 +00:00
M_Viper d7f8ea2388 Upload via GUI (2911 Dateien) 2026-11-05 21:24:44 +00:00
M_Viper e2d080fae9 Upload via GUI (2911 Dateien) 2026-11-05 21:24:39 +00:00
M_Viper c5910eddfe Upload via GUI (2911 Dateien) 2026-11-05 21:24:36 +00:00
M_Viper 353b7797f8 Upload via GUI (2911 Dateien) 2026-11-05 21:24:29 +00:00
M_Viper 4c573c46a1 Upload via GUI (13 Dateien) 2026-11-05 21:24:12 +00:00
M_Viper 406f3a211e Upload via GUI (13 Dateien) 2026-11-05 21:24:10 +00:00
M_Viper 84448ad45e Upload via GUI (65 Dateien) 2026-11-05 21:23:54 +00:00
M_Viper 95faa40e98 Upload via GUI (65 Dateien) 2026-11-05 21:23:51 +00:00
M_Viper fba54f356c Upload via GUI (38 Dateien) 2026-11-05 21:23:38 +00:00
M_Viper 11ee64da8a Upload via GUI (38 Dateien) 2026-11-05 21:23:36 +00:00
M_Viper 871bfbd8a2 Upload via GUI (26 Dateien) 2026-11-05 21:23:14 +00:00
M_Viper 2e783e018b Upload via GUI (74 Dateien) 2026-11-05 21:22:55 +00:00
M_Viper dee5c631b9 Upload via GUI (74 Dateien) 2026-11-05 21:22:50 +00:00
M_Viper c0b4092498 Upload via GUI (34 Dateien) 2026-11-05 21:22:26 +00:00
M_Viper 752b42245a Upload via GUI (452 Dateien) 2026-11-05 21:22:06 +00:00
M_Viper ded90af175 Upload via GUI (452 Dateien) 2026-11-05 21:22:03 +00:00
M_Viper a7577a234d Upload via GUI (452 Dateien) 2026-11-05 21:21:59 +00:00
M_Viper 2277cf5493 Upload via GUI (452 Dateien) 2026-11-05 21:21:55 +00:00
M_Viper 32f5e780d6 Upload via GUI (452 Dateien) 2026-11-05 21:21:52 +00:00
M_Viper 10578475f0 Upload via GUI (452 Dateien) 2026-11-05 21:21:48 +00:00
M_Viper e90c1a4563 Upload via GUI (452 Dateien) 2026-11-05 21:21:44 +00:00
M_Viper 0cd88ac2f9 Upload via GUI (452 Dateien) 2026-11-05 21:21:43 +00:00
M_Viper 152c5d1401 Upload via GUI (452 Dateien) 2026-11-05 21:21:40 +00:00
M_Viper f6ad05f5e5 Upload via GUI (452 Dateien) 2026-11-05 21:21:37 +00:00
M_Viper cc035349e5 Upload via GUI (452 Dateien) 2026-11-05 21:21:35 +00:00
M_Viper 09da839779 Upload via GUI (452 Dateien) 2026-11-05 21:21:31 +00:00
M_Viper 7c664f0a6b Upload via GUI (452 Dateien) 2026-11-05 21:21:27 +00:00
M_Viper cdec2d346b Upload via GUI (2911 Dateien) 2026-11-04 08:09:55 +00:00
M_Viper fb61ebc2c3 Upload via GUI (2911 Dateien) 2026-11-04 08:09:47 +00:00
M_Viper ce1e6ef028 Upload via GUI (2911 Dateien) 2026-11-04 08:09:38 +00:00
M_Viper fe1e7713fa Upload via GUI (2911 Dateien) 2026-11-04 08:09:31 +00:00
M_Viper 1092cfce0d Upload via GUI (2911 Dateien) 2026-11-04 08:09:22 +00:00
M_Viper 29fdc7afce Upload via GUI (2911 Dateien) 2026-11-04 08:09:10 +00:00
M_Viper 9770ed381f Upload via GUI (2911 Dateien) 2026-11-04 08:09:00 +00:00
M_Viper 14ebc63be5 Upload via GUI (2911 Dateien) 2026-11-04 08:08:48 +00:00
M_Viper 5abc50c6d7 Upload via GUI (2911 Dateien) 2026-11-04 08:08:34 +00:00
M_Viper 9e2e4c95f5 Upload via GUI (2911 Dateien) 2026-11-04 08:08:19 +00:00
M_Viper 29caa7f2e2 Upload via GUI (2911 Dateien) 2026-11-04 08:08:16 +00:00
M_Viper aa8c6c0d19 Upload via GUI (2911 Dateien) 2026-11-04 08:08:11 +00:00
M_Viper 6187cec8bf Upload via GUI (2911 Dateien) 2026-11-04 08:08:08 +00:00
M_Viper 63beec4877 Upload via GUI (2911 Dateien) 2026-11-04 08:07:53 +00:00
M_Viper 79e293e9e0 Upload via GUI (2911 Dateien) 2026-11-04 08:07:50 +00:00
M_Viper 497acf5557 Upload via GUI (2911 Dateien) 2026-11-04 08:07:46 +00:00
M_Viper 14ae2fd936 Upload via GUI (2911 Dateien) 2026-11-04 08:07:34 +00:00
M_Viper af87466328 Upload via GUI (2911 Dateien) 2026-11-04 08:07:22 +00:00
M_Viper 14fb5ec0fe Upload via GUI (2911 Dateien) 2026-11-04 08:07:10 +00:00
M_Viper 122876dded Upload via GUI (2911 Dateien) 2026-11-04 08:06:36 +00:00
M_Viper 8a75966077 Upload via GUI (2911 Dateien) 2026-11-04 08:06:11 +00:00
M_Viper d3bf15fbce Upload via GUI (2911 Dateien) 2026-11-04 08:05:59 +00:00
M_Viper 490f60dd37 Upload via GUI (2911 Dateien) 2026-11-04 08:05:56 +00:00
M_Viper da84410644 Upload via GUI (2911 Dateien) 2026-11-04 08:05:52 +00:00
M_Viper 5435d2c2ef Upload via GUI (2911 Dateien) 2026-11-04 08:05:46 +00:00
M_Viper 531ec38860 Upload via GUI (2911 Dateien) 2026-11-04 08:05:44 +00:00
M_Viper 02294d3161 Upload via GUI (2911 Dateien) 2026-11-04 08:05:33 +00:00
M_Viper db81add5ac Upload via GUI (2911 Dateien) 2026-11-04 08:05:29 +00:00
M_Viper 3391a3a69f Upload via GUI (2911 Dateien) 2026-11-04 08:05:14 +00:00
M_Viper 4c0f87b6c6 Upload via GUI (2911 Dateien) 2026-11-04 08:04:58 +00:00
M_Viper 14794b146c Upload via GUI (2911 Dateien) 2026-11-04 08:04:47 +00:00
M_Viper df69e259eb Upload via GUI (2911 Dateien) 2026-11-04 08:04:35 +00:00
M_Viper f107976fda Upload via GUI (2911 Dateien) 2026-11-04 08:04:22 +00:00
M_Viper 0abf939ef5 Upload via GUI (2911 Dateien) 2026-11-04 08:04:15 +00:00
M_Viper fa5e25ae57 Upload via GUI (2911 Dateien) 2026-11-04 08:04:07 +00:00
M_Viper 3831cf67e9 Upload via GUI (2911 Dateien) 2026-11-04 08:03:56 +00:00
M_Viper 07bd3e8b24 Upload via GUI (2911 Dateien) 2026-11-04 08:03:50 +00:00
M_Viper b2204b2ea5 Upload via GUI (2911 Dateien) 2026-11-04 08:03:40 +00:00
M_Viper 86bc8228d3 Upload via GUI (2911 Dateien) 2026-11-04 08:03:34 +00:00
M_Viper e2e8c1482f Upload via GUI (2911 Dateien) 2026-11-04 08:03:23 +00:00
M_Viper 4554cbbf0d Upload via GUI (2911 Dateien) 2026-11-04 08:03:13 +00:00
M_Viper bff7b65b6e Upload via GUI (2911 Dateien) 2026-11-04 08:02:58 +00:00
M_Viper e96f783c24 Upload via GUI (2911 Dateien) 2026-11-04 08:02:45 +00:00
M_Viper fe5907b255 Upload via GUI (2911 Dateien) 2026-11-04 08:02:31 +00:00
M_Viper 3d0e0717a6 Upload via GUI (2911 Dateien) 2026-11-04 08:02:16 +00:00
M_Viper 348ab56c90 Upload via GUI (2911 Dateien) 2026-11-04 08:01:58 +00:00
M_Viper 6622761f82 Upload via GUI (2911 Dateien) 2026-11-04 08:01:42 +00:00
M_Viper 20496b16d2 Upload via GUI (2911 Dateien) 2026-11-04 08:01:31 +00:00
M_Viper 54ff35884d Upload via GUI (2911 Dateien) 2026-11-04 08:01:20 +00:00
M_Viper 72eddf433d Upload via GUI (2911 Dateien) 2026-11-04 08:01:02 +00:00
M_Viper 1cb8f5c3b4 Upload via GUI (2911 Dateien) 2026-11-04 08:00:47 +00:00
M_Viper c18238f29d Upload via GUI (2911 Dateien) 2026-11-04 08:00:30 +00:00
M_Viper b6ee3160db Upload via GUI (2911 Dateien) 2026-11-04 08:00:14 +00:00
M_Viper d61e9b0166 Upload via GUI (2911 Dateien) 2026-11-04 08:00:03 +00:00
M_Viper cfcb4c5e10 Upload via GUI (2911 Dateien) 2026-11-04 07:59:52 +00:00
M_Viper a8c7d38c74 Upload via GUI (2911 Dateien) 2026-11-04 07:59:44 +00:00
M_Viper 3387bac00a Upload via GUI (2911 Dateien) 2026-11-04 07:59:41 +00:00
M_Viper 5ac7c2e1b1 Upload via GUI (2911 Dateien) 2026-11-04 07:59:34 +00:00
M_Viper 4520d435e0 Upload via GUI (74 Dateien) 2026-11-04 07:58:10 +00:00
M_Viper 8bf6bb92ba Upload via GUI (74 Dateien) 2026-11-04 07:58:05 +00:00
M_Viper 79c6424c77 Upload via GUI (396 Dateien) 2026-11-04 07:56:07 +00:00
M_Viper 3a408cd8dc Upload via GUI (396 Dateien) 2026-11-04 07:56:02 +00:00
M_Viper fad8536292 Upload via GUI (396 Dateien) 2026-11-04 07:55:51 +00:00
M_Viper d1aae0f2ce Upload via GUI (396 Dateien) 2026-11-04 07:55:43 +00:00
M_Viper 58a8fab9f5 Upload via GUI (396 Dateien) 2026-11-04 07:55:36 +00:00
M_Viper 65d42108db Upload via GUI (396 Dateien) 2026-11-04 07:55:30 +00:00
M_Viper b207fa4944 Upload via GUI (396 Dateien) 2026-11-04 07:55:25 +00:00
M_Viper 27b3e4dbaf Upload via GUI (396 Dateien) 2026-11-04 07:55:19 +00:00
M_Viper c55cc54ba2 Upload via GUI (396 Dateien) 2026-11-04 07:55:18 +00:00
M_Viper 747ecbc618 Upload via GUI (396 Dateien) 2026-11-04 07:55:16 +00:00
M_Viper 8b64172140 Upload via GUI (23 Dateien) 2026-11-04 07:55:00 +00:00
M_Viper 44d3b7308d Upload via GUI (1 Datei) 2026-11-01 11:19:11 +00:00
M_Viper bb2963b55d Upload via GUI (38 Dateien) 2026-11-01 11:18:42 +00:00
M_Viper 950331de90 Upload via GUI (38 Dateien) 2026-11-01 11:18:38 +00:00
M_Viper ba7fc2dd1d Upload via GUI (49 Dateien) 2026-11-01 11:18:05 +00:00
M_Viper c3b9948fa2 Upload via GUI (30 Dateien) 2026-11-01 11:17:41 +00:00
M_Viper 53f8bf0577 Upload via GUI (32 Dateien) 2026-11-01 11:17:07 +00:00
M_Viper e1c6f8dfca Upload via GUI (24 Dateien) 2026-11-01 11:16:47 +00:00
M_Viper 257fbc1e55 Upload via GUI (364 Dateien) 2026-11-01 11:16:18 +00:00
M_Viper 25beaea671 Upload via GUI (364 Dateien) 2026-11-01 11:16:11 +00:00
M_Viper 323d49e13e Upload via GUI (364 Dateien) 2026-11-01 11:16:05 +00:00
M_Viper 4319ceec47 Upload via GUI (364 Dateien) 2026-11-01 11:15:59 +00:00
M_Viper c1a170563d Upload via GUI (364 Dateien) 2026-11-01 11:15:52 +00:00
M_Viper aeae758b4d Upload via GUI (364 Dateien) 2026-11-01 11:15:44 +00:00
M_Viper 9b1b7c0d6e Upload via GUI (364 Dateien) 2026-11-01 11:15:37 +00:00
M_Viper 0945d61744 Upload via GUI (364 Dateien) 2026-11-01 11:15:35 +00:00
M_Viper 187b7b5c9e Upload via GUI (364 Dateien) 2026-11-01 11:15:31 +00:00
M_Viper ba32f72275 Upload via GUI (23 Dateien) 2026-11-01 11:15:09 +00:00
M_Viper c57918c86f Upload via GUI (360 Dateien) 2026-10-30 21:50:30 +00:00
M_Viper 515db6b5e2 Upload via GUI (360 Dateien) 2026-10-30 21:50:25 +00:00
M_Viper b322e13aef Upload via GUI (360 Dateien) 2026-10-30 21:50:21 +00:00
M_Viper 4e1e6d85f8 Upload via GUI (360 Dateien) 2026-10-30 21:50:16 +00:00
M_Viper 2ee9be11c0 Upload via GUI (360 Dateien) 2026-10-30 21:50:10 +00:00
M_Viper fa7cb047a9 Upload via GUI (360 Dateien) 2026-10-30 21:50:04 +00:00
M_Viper 91ae4659b5 Upload via GUI (360 Dateien) 2026-10-30 21:49:58 +00:00
M_Viper aed0db39ba Upload via GUI (360 Dateien) 2026-10-30 21:49:57 +00:00
M_Viper 07295c8b77 Upload via GUI (360 Dateien) 2026-10-30 21:49:53 +00:00
M_Viper 2837074176 Upload via GUI (1 Datei) 2026-10-28 07:55:34 +00:00
M_Viper e18614672f Upload via GUI (346 Dateien) 2026-10-28 07:55:07 +00:00
M_Viper c7f79db919 Upload via GUI (346 Dateien) 2026-10-28 07:55:02 +00:00
M_Viper a165f1e20f Upload via GUI (346 Dateien) 2026-10-28 07:54:56 +00:00
M_Viper a86036c657 Upload via GUI (346 Dateien) 2026-10-28 07:54:52 +00:00
M_Viper f3ba8c20b1 Upload via GUI (346 Dateien) 2026-10-28 07:54:47 +00:00
M_Viper 6343e06db5 Upload via GUI (346 Dateien) 2026-10-28 07:54:42 +00:00
M_Viper 38646692bf Upload via GUI (346 Dateien) 2026-10-28 07:54:38 +00:00
M_Viper 4045ab2b95 Upload via GUI (346 Dateien) 2026-10-28 07:54:37 +00:00
M_Viper f04a6c9806 Upload via GUI (346 Dateien) 2026-10-28 07:54:36 +00:00
M_Viper 98f0f6a1d0 Upload via GUI (1 Datei) 2026-10-26 21:33:17 +00:00
M_Viper 22f9d57240 Upload via GUI (37 Dateien) 2026-10-26 21:32:45 +00:00
M_Viper f6faac2193 Upload via GUI (37 Dateien) 2026-10-26 21:32:42 +00:00
M_Viper 0e4da219f3 Upload via GUI (32 Dateien) 2026-10-26 21:32:05 +00:00
M_Viper 4a7a2e665e Upload via GUI (4909 Dateien) 2026-10-26 21:31:42 +00:00
M_Viper a322212ef5 Upload via GUI (4909 Dateien) 2026-10-26 21:31:35 +00:00
M_Viper 1d5cb064a4 Upload via GUI (4909 Dateien) 2026-10-26 21:31:33 +00:00
M_Viper 9553f62399 Upload via GUI (4909 Dateien) 2026-10-26 21:31:31 +00:00
M_Viper b268bf7fae Upload via GUI (4909 Dateien) 2026-10-26 21:31:29 +00:00
M_Viper fe7b797f03 Upload via GUI (4909 Dateien) 2026-10-26 21:31:26 +00:00
M_Viper 960dc81511 Upload via GUI (4909 Dateien) 2026-10-26 21:31:20 +00:00
M_Viper cf023d1427 Upload via GUI (4909 Dateien) 2026-10-26 21:31:16 +00:00
M_Viper 02e79b6f73 Upload via GUI (4909 Dateien) 2026-10-26 21:31:13 +00:00
M_Viper d902255b51 Upload via GUI (4909 Dateien) 2026-10-26 21:31:12 +00:00
M_Viper aa3f1325fc Upload via GUI (4909 Dateien) 2026-10-26 21:31:11 +00:00
M_Viper ce15ed76d7 Upload via GUI (4909 Dateien) 2026-10-26 21:31:08 +00:00
M_Viper a555fee640 Upload via GUI (4909 Dateien) 2026-10-26 21:31:06 +00:00
M_Viper b565683729 Upload via GUI (4909 Dateien) 2026-10-26 21:31:04 +00:00
M_Viper cf2d0aab30 Upload via GUI (4909 Dateien) 2026-10-26 21:31:01 +00:00
M_Viper dbd062d37f Upload via GUI (4909 Dateien) 2026-10-26 21:31:00 +00:00
M_Viper 17d28b31b2 Upload via GUI (4909 Dateien) 2026-10-26 21:30:57 +00:00
M_Viper a043389b89 Upload via GUI (4909 Dateien) 2026-10-26 21:30:56 +00:00
M_Viper 7e1e29b73e Upload via GUI (4909 Dateien) 2026-10-26 21:30:53 +00:00
M_Viper 08eab8c0cf Upload via GUI (4909 Dateien) 2026-10-26 21:30:50 +00:00
M_Viper 80da72d86e Upload via GUI (4909 Dateien) 2026-10-26 21:30:46 +00:00
M_Viper b6be4b9bf7 Upload via GUI (4909 Dateien) 2026-10-26 21:30:44 +00:00
M_Viper 8dfcbbe9f3 Upload via GUI (4909 Dateien) 2026-10-26 21:30:40 +00:00
M_Viper d75073645a Upload via GUI (4909 Dateien) 2026-10-26 21:30:38 +00:00
M_Viper 6714833377 Upload via GUI (4909 Dateien) 2026-10-26 21:30:34 +00:00
M_Viper 16798f664f Upload via GUI (4909 Dateien) 2026-10-26 21:30:30 +00:00
M_Viper 231684a369 Upload via GUI (4909 Dateien) 2026-10-26 21:30:25 +00:00
M_Viper b04a9666da Upload via GUI (4909 Dateien) 2026-10-26 21:30:20 +00:00
M_Viper 38e6ffa45a Upload via GUI (4909 Dateien) 2026-10-26 21:30:15 +00:00
M_Viper e22caa9a75 Upload via GUI (4909 Dateien) 2026-10-26 21:30:09 +00:00
M_Viper 98870587f9 Upload via GUI (4909 Dateien) 2026-10-26 21:30:04 +00:00
M_Viper 0d13814c74 Upload via GUI (4909 Dateien) 2026-10-26 21:29:59 +00:00
M_Viper 35ecfbd4dc Upload via GUI (4909 Dateien) 2026-10-26 21:29:58 +00:00
M_Viper bc2d087943 Upload via GUI (4909 Dateien) 2026-10-26 21:29:54 +00:00
M_Viper 2ce5bab309 Upload via GUI (4909 Dateien) 2026-10-26 21:29:48 +00:00
M_Viper 0294fa50ce Upload via GUI (4909 Dateien) 2026-10-26 21:29:43 +00:00
M_Viper b344da0825 Upload via GUI (4909 Dateien) 2026-10-26 21:29:41 +00:00
M_Viper b2b16a93f0 Upload via GUI (4909 Dateien) 2026-10-26 21:29:37 +00:00
M_Viper 973e696b4d Upload via GUI (4909 Dateien) 2026-10-26 21:29:36 +00:00
M_Viper 1b5374fd28 Upload via GUI (4909 Dateien) 2026-10-26 21:29:32 +00:00
M_Viper 202a8ab1ba Upload via GUI (4909 Dateien) 2026-10-26 21:29:27 +00:00
M_Viper 77799a9fbd Upload via GUI (4909 Dateien) 2026-10-26 21:29:26 +00:00
M_Viper 96507acfc2 Upload via GUI (4909 Dateien) 2026-10-26 21:29:24 +00:00
M_Viper 2b5bc2d21c Upload via GUI (4909 Dateien) 2026-10-26 21:29:20 +00:00
M_Viper cd141db39e Upload via GUI (4909 Dateien) 2026-10-26 21:29:15 +00:00
M_Viper 7bfb66d31d Upload via GUI (4909 Dateien) 2026-10-26 21:29:11 +00:00
M_Viper 7bf581672a Upload via GUI (4909 Dateien) 2026-10-26 21:29:09 +00:00
M_Viper 8bc4560e7a Upload via GUI (4909 Dateien) 2026-10-26 21:29:02 +00:00
M_Viper 9e89c4af07 Upload via GUI (4909 Dateien) 2026-10-26 21:28:57 +00:00
M_Viper d6e4eb327a Upload via GUI (4909 Dateien) 2026-10-26 21:28:52 +00:00
M_Viper f01877150c Upload via GUI (4909 Dateien) 2026-10-26 21:28:47 +00:00
M_Viper 47eb477f40 Upload via GUI (4909 Dateien) 2026-10-26 21:28:42 +00:00
M_Viper f795bd66ea Upload via GUI (4909 Dateien) 2026-10-26 21:28:36 +00:00
M_Viper 506773c1be Upload via GUI (4909 Dateien) 2026-10-26 21:28:31 +00:00
M_Viper aa823f9130 Upload via GUI (4909 Dateien) 2026-10-26 21:28:26 +00:00
M_Viper 7162449ca1 Upload via GUI (4909 Dateien) 2026-10-26 21:28:24 +00:00
M_Viper a8e146b297 Upload via GUI (4909 Dateien) 2026-10-26 21:28:22 +00:00
M_Viper 00cbd3f38e Upload via GUI (4909 Dateien) 2026-10-26 21:28:17 +00:00
M_Viper 2b158200a7 Upload via GUI (4909 Dateien) 2026-10-26 21:28:12 +00:00
M_Viper a63da1cf57 Upload via GUI (4909 Dateien) 2026-10-26 21:28:08 +00:00
M_Viper 43d61851fa Upload via GUI (4909 Dateien) 2026-10-26 21:28:02 +00:00
M_Viper 4c32dd691a Upload via GUI (4909 Dateien) 2026-10-26 21:27:58 +00:00
M_Viper 92d5d9ba42 Upload via GUI (4909 Dateien) 2026-10-26 21:27:53 +00:00
M_Viper c27991619c Upload via GUI (4909 Dateien) 2026-10-26 21:27:48 +00:00
M_Viper 2a99060740 Upload via GUI (4909 Dateien) 2026-10-26 21:27:43 +00:00
M_Viper 078ab743bc Upload via GUI (4909 Dateien) 2026-10-26 21:27:38 +00:00
M_Viper 59c236ebcd Upload via GUI (4909 Dateien) 2026-10-26 21:27:33 +00:00
M_Viper 6d97fb4f3e Upload via GUI (4909 Dateien) 2026-10-26 21:27:28 +00:00
M_Viper c494839ffc Upload via GUI (4909 Dateien) 2026-10-26 21:27:27 +00:00
M_Viper 5d1cd45f55 Upload via GUI (4909 Dateien) 2026-10-26 21:27:22 +00:00
M_Viper a8d6374eb8 Upload via GUI (4909 Dateien) 2026-10-26 21:27:17 +00:00
M_Viper d7a48a0a7c Upload via GUI (4909 Dateien) 2026-10-26 21:27:12 +00:00
M_Viper 9c78a9fe95 Upload via GUI (4909 Dateien) 2026-10-26 21:27:11 +00:00
M_Viper 35f7ddde34 Upload via GUI (4909 Dateien) 2026-10-26 21:27:09 +00:00
M_Viper 6c89ddddb9 Upload via GUI (4909 Dateien) 2026-10-26 21:27:05 +00:00
M_Viper def896d930 Upload via GUI (4909 Dateien) 2026-10-26 21:27:00 +00:00
M_Viper 922cbddabe Upload via GUI (4909 Dateien) 2026-10-26 21:26:55 +00:00
M_Viper 5dc3502ab0 Upload via GUI (4909 Dateien) 2026-10-26 21:26:50 +00:00
M_Viper c78c3a1e71 Upload via GUI (4909 Dateien) 2026-10-26 21:26:45 +00:00
M_Viper 905dbe411a Upload via GUI (4909 Dateien) 2026-10-26 21:26:41 +00:00
M_Viper c1c598318e Upload via GUI (4909 Dateien) 2026-10-26 21:26:36 +00:00
M_Viper 7a367e7098 Upload via GUI (4909 Dateien) 2026-10-26 21:26:35 +00:00
M_Viper e3bc3763d4 Upload via GUI (4909 Dateien) 2026-10-26 21:26:33 +00:00
M_Viper d0b25ccc78 Upload via GUI (4909 Dateien) 2026-10-26 21:26:28 +00:00
M_Viper 15579292a5 Upload via GUI (4909 Dateien) 2026-10-26 21:26:23 +00:00
M_Viper a6d5af2c79 Upload via GUI (4909 Dateien) 2026-10-26 21:26:18 +00:00
M_Viper cd47f2eb6c Upload via GUI (4909 Dateien) 2026-10-26 21:26:15 +00:00
M_Viper 37311cfbc6 Upload via GUI (4909 Dateien) 2026-10-26 21:26:14 +00:00
M_Viper 96355ce691 Upload via GUI (4909 Dateien) 2026-10-26 21:26:11 +00:00
M_Viper 5a9d525f7f Upload via GUI (4909 Dateien) 2026-10-26 21:26:06 +00:00
M_Viper 79c73e20c5 Upload via GUI (4909 Dateien) 2026-10-26 21:26:01 +00:00
M_Viper 86c76fc526 Upload via GUI (4909 Dateien) 2026-10-26 21:25:56 +00:00
M_Viper 40cdfdc6ad Upload via GUI (4909 Dateien) 2026-10-26 21:25:51 +00:00
M_Viper 19fbac7d27 Upload via GUI (4909 Dateien) 2026-10-26 21:25:46 +00:00
M_Viper ed8468a1bf Upload via GUI (4909 Dateien) 2026-10-26 21:25:41 +00:00
M_Viper 33517dd296 Upload via GUI (4909 Dateien) 2026-10-26 21:25:36 +00:00
M_Viper 4a2c0a1227 Upload via GUI (4909 Dateien) 2026-10-26 21:25:32 +00:00
M_Viper 5ee22eb72c Upload via GUI (4909 Dateien) 2026-10-26 21:25:27 +00:00
M_Viper 9681ffec34 Upload via GUI (4909 Dateien) 2026-10-26 21:25:21 +00:00
M_Viper d846b979e4 Upload via GUI (4909 Dateien) 2026-10-26 21:25:16 +00:00
M_Viper 613b5a10cb Upload via GUI (4909 Dateien) 2026-10-26 21:25:10 +00:00
M_Viper a6c66ab5a1 Upload via GUI (4909 Dateien) 2026-10-26 21:25:06 +00:00
M_Viper 99fba6b10d Upload via GUI (4909 Dateien) 2026-10-26 21:25:03 +00:00
M_Viper bd0b87ac68 Upload via GUI (4909 Dateien) 2026-10-26 21:25:02 +00:00
M_Viper 162bd68a86 Upload via GUI (4909 Dateien) 2026-10-26 21:25:00 +00:00
M_Viper 6c47f8d2e2 Upload via GUI (4909 Dateien) 2026-10-26 21:24:58 +00:00
M_Viper cd689d1b88 Upload via GUI (4909 Dateien) 2026-10-26 21:24:53 +00:00
M_Viper 832e97a413 Upload via GUI (4909 Dateien) 2026-10-26 21:24:52 +00:00
M_Viper 64e4d524c1 Upload via GUI (4909 Dateien) 2026-10-26 21:24:50 +00:00
M_Viper cde1559f63 Upload via GUI (4909 Dateien) 2026-10-26 21:24:47 +00:00
M_Viper 4d1d870c56 Upload via GUI (4909 Dateien) 2026-10-26 21:24:43 +00:00
M_Viper f2ce2f16f9 Upload via GUI (4909 Dateien) 2026-10-26 21:24:38 +00:00
M_Viper becf3f292b Upload via GUI (4909 Dateien) 2026-10-26 21:24:33 +00:00
M_Viper 64f552f81a Upload via GUI (4909 Dateien) 2026-10-26 21:24:27 +00:00
M_Viper d190825ef7 Upload via GUI (4909 Dateien) 2026-10-26 21:24:21 +00:00
M_Viper f7b161fee8 Upload via GUI (4909 Dateien) 2026-10-26 21:24:20 +00:00
M_Viper d1df52e1d2 Upload via GUI (4909 Dateien) 2026-10-26 21:24:18 +00:00
M_Viper 1914c0656b Upload via GUI (4909 Dateien) 2026-10-26 21:24:17 +00:00
M_Viper 271ca38245 Upload via GUI (4909 Dateien) 2026-10-26 21:24:15 +00:00
M_Viper 3644fb4fef Upload via GUI (4909 Dateien) 2026-10-26 21:24:13 +00:00
M_Viper fab35bcd85 Upload via GUI (4909 Dateien) 2026-10-26 21:24:12 +00:00
M_Viper e1f4a70519 Upload via GUI (4909 Dateien) 2026-10-26 21:24:08 +00:00
M_Viper 1877562632 Upload via GUI (4909 Dateien) 2026-10-26 21:24:03 +00:00
M_Viper 3d09e447b0 Upload via GUI (4909 Dateien) 2026-10-26 21:23:58 +00:00
M_Viper 4511da92cb Upload via GUI (4909 Dateien) 2026-10-26 21:23:52 +00:00
M_Viper 95a1bdf458 Upload via GUI (4909 Dateien) 2026-10-26 21:23:47 +00:00
M_Viper d502e76aaa Upload via GUI (4909 Dateien) 2026-10-26 21:23:45 +00:00
M_Viper a4543205ed Upload via GUI (4909 Dateien) 2026-10-26 21:23:43 +00:00
M_Viper dc34a7b505 Upload via GUI (4909 Dateien) 2026-10-26 21:23:40 +00:00
M_Viper b1ce163bb7 Upload via GUI (4909 Dateien) 2026-10-26 21:23:38 +00:00
M_Viper fcaae83001 Upload via GUI (4909 Dateien) 2026-10-26 21:23:36 +00:00
M_Viper 13d82ad39f Upload via GUI (4909 Dateien) 2026-10-26 21:23:34 +00:00
M_Viper 32bdda2301 Upload via GUI (4909 Dateien) 2026-10-26 21:23:30 +00:00
M_Viper 9a90206df5 Upload via GUI (4909 Dateien) 2026-10-26 21:23:25 +00:00
M_Viper 2ecec2bef6 Upload via GUI (4909 Dateien) 2026-10-26 21:23:21 +00:00
M_Viper 6b5fd653e4 Upload via GUI (4909 Dateien) 2026-10-26 21:23:16 +00:00
M_Viper 3f46fcda4f Upload via GUI (4909 Dateien) 2026-10-26 21:23:11 +00:00
M_Viper d67f61fe9c Upload via GUI (4909 Dateien) 2026-10-26 21:23:06 +00:00
M_Viper 955f8c3e46 Upload via GUI (4909 Dateien) 2026-10-26 21:23:01 +00:00
M_Viper 918f2bbb24 Upload via GUI (4909 Dateien) 2026-10-26 21:22:56 +00:00
M_Viper fc070f4abc Upload via GUI (4909 Dateien) 2026-10-26 21:22:51 +00:00
M_Viper fc2c7ef5ea Upload via GUI (4909 Dateien) 2026-10-26 21:22:46 +00:00
M_Viper 54d0c48bd4 Upload via GUI (4909 Dateien) 2026-10-26 21:22:41 +00:00
M_Viper 728d428c78 Upload via GUI (4909 Dateien) 2026-10-26 21:22:36 +00:00
M_Viper b0b0d00dbe Upload via GUI (4909 Dateien) 2026-10-26 21:22:32 +00:00
M_Viper 1a32c6f44f Upload via GUI (4909 Dateien) 2026-10-26 21:22:27 +00:00
M_Viper ccf6951d2c Upload via GUI (4909 Dateien) 2026-10-26 21:22:22 +00:00
M_Viper 3c13667b34 Upload via GUI (4909 Dateien) 2026-10-26 21:22:16 +00:00
M_Viper 09e8db6f9b Upload via GUI (4909 Dateien) 2026-10-26 21:22:12 +00:00
M_Viper 3cec3d9d13 Upload via GUI (4909 Dateien) 2026-10-26 21:22:10 +00:00
M_Viper d358695cdb Upload via GUI (4909 Dateien) 2026-10-26 21:22:04 +00:00
M_Viper 5b09226ae1 Upload via GUI (22 Dateien) 2026-10-26 21:21:24 +00:00
M_Viper 242bd070da Upload via GUI (12 Dateien) 2026-10-26 21:21:11 +00:00
M_Viper 2cbee9af5a Upload via GUI (12 Dateien) 2026-10-26 21:21:10 +00:00
M_Viper 7dac91fa35 Upload via GUI (74 Dateien) 2026-10-26 21:20:34 +00:00
M_Viper 5ffdb4fc1b Upload via GUI (74 Dateien) 2026-10-26 21:20:30 +00:00
M_Viper 72925ad504 Upload via GUI (14 Dateien) 2026-10-26 21:20:03 +00:00
M_Viper d3ce16a51d Upload via GUI (23 Dateien) 2026-10-26 21:19:46 +00:00
M_Viper b6f4370f9c Upload via GUI (340 Dateien) 2026-10-26 21:19:30 +00:00
M_Viper 2452849c74 Upload via GUI (340 Dateien) 2026-10-26 21:19:25 +00:00
M_Viper 12699f0918 Upload via GUI (340 Dateien) 2026-10-26 21:19:21 +00:00
M_Viper d6df75ad64 Upload via GUI (340 Dateien) 2026-10-26 21:19:17 +00:00
M_Viper 2bc706cb09 Upload via GUI (340 Dateien) 2026-10-26 21:19:14 +00:00
M_Viper a7aeec1b26 Upload via GUI (340 Dateien) 2026-10-26 21:19:10 +00:00
M_Viper ecabba060c Upload via GUI (340 Dateien) 2026-10-26 21:19:07 +00:00
M_Viper 0df18c6747 Upload via GUI (340 Dateien) 2026-10-26 21:19:02 +00:00
M_Viper ce27492b24 Upload via GUI (340 Dateien) 2026-10-26 21:19:00 +00:00
M_Viper 9acec37e18 Upload via GUI (1 Datei) 2026-10-25 20:22:13 +00:00
M_Viper b976f394f5 Upload via GUI (14 Dateien) 2026-10-25 20:21:12 +00:00
M_Viper e3c3b7df1c Upload via GUI (1 Datei) 2026-10-25 18:01:49 +00:00
526 changed files with 46662 additions and 5279 deletions
+5 -1
View File
@@ -13,7 +13,7 @@ RewriteRule ^ - [F,L]
# RewriteRule ^ - [F,L] # RewriteRule ^ - [F,L]
# Clickjacking-Schutz # Clickjacking-Schutz
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
# Schützt interne Verzeichnisse vor direktem Web-Zugriff. # Schützt interne Verzeichnisse vor direktem Web-Zugriff.
@@ -44,6 +44,10 @@ RewriteCond %{REQUEST_URI} \.(sqlite3?|db|sql|log|bak|old|orig|swp|md|sh|bat|ps1
RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC] RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC]
RewriteRule ^ - [F,L] RewriteRule ^ - [F,L]
# Übrig gebliebene alte Ordner eines Updates (…alt-JJJJMMTT-HHMMSS) nicht ausliefern
RewriteCond %{REQUEST_URI} \.alt-[0-9]{8}-[0-9]{6}(/|$) [NC]
RewriteRule ^ - [F,L]
# version.php nur intern zugänglich # version.php nur intern zugänglich
RewriteCond %{REQUEST_URI} ^/version\.php$ [NC] RewriteCond %{REQUEST_URI} ^/version\.php$ [NC]
RewriteRule ^ - [F,L] RewriteRule ^ - [F,L]
+63 -5
View File
@@ -5,7 +5,12 @@ declare(strict_types=1);
* *
* POST /api/public/v1/tickets.php Ticket anlegen (Recht "Tickets anlegen") * POST /api/public/v1/tickets.php Ticket anlegen (Recht "Tickets anlegen")
* JSON: { "name", "email", "subject", "message", "helpTopicId"?, "departmentId"?, "priorityId"? } * JSON: { "name", "email", "subject", "message", "helpTopicId"?, "departmentId"?, "priorityId"? }
* POST /api/public/v1/tickets.php Ticket lösen (Recht "Tickets anlegen")
* JSON: { "action": "resolve", "number", "email", "message"? } nur für Tickets, die über die API angelegt wurden, mit passender E-Mail-Adresse
* GET /api/public/v1/tickets.php?number=…&email=… Status + öffentlicher Verlauf (Recht "Tickets lesen") * GET /api/public/v1/tickets.php?number=…&email=… Status + öffentlicher Verlauf (Recht "Tickets lesen")
* GET /api/public/v1/tickets.php?topics=1 Öffentliche Hilfethemen (Recht "Tickets anlegen")
* GET /api/public/v1/tickets.php?form=1&helpTopicId=… Kundenformular samt eigener Felder (Recht "Tickets anlegen")
* Beim Anlegen nimmt "fields": { "<Feld-ID>": "Wert", … } die Angaben dieses Formulars entgegen (Pflichtfelder werden geprüft).
* *
* Authentifizierung: Header "X-API-Key: dh_…" oder "Authorization: Bearer dh_…". * Authentifizierung: Header "X-API-Key: dh_…" oder "Authorization: Bearer dh_…".
* Schlüssel verwaltet ein Administrator unter Verwalten → API-Schlüssel. * Schlüssel verwaltet ein Administrator unter Verwalten → API-Schlüssel.
@@ -32,19 +37,50 @@ $key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? ''));
if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1]; if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1];
$pdo = get_db(); $pdo = get_db();
$apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null; $apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null;
if (!$apiKey) public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).'); if (!$apiKey) {
if ($key !== '' && api_key_ip_blocked($pdo, $key, client_ip())) {
public_api_fail(401, 'IP_NOT_ALLOWED', 'Der Schlüssel ist gültig, aber für die IP-Adresse ' . client_ip() . ' nicht freigegeben. Diese Adresse bei „Erlaubte IPs“ des Schlüssels eintragen (oder das Feld leer lassen).');
}
public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).');
}
if ($method === 'POST') { if ($method === 'POST') {
if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.'); if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
$raw = file_get_contents('php://input') ?: '';
// Normale Anfragen höchstens 200 KB; mit Dateianhängen (Base64) mehr: bis zu 10 MB Dateien, Base64 macht rund ein Drittel mehr daraus
if (strlen($raw) > 200000 && (strlen($raw) > 200000 + (int) (PUBLIC_ATTACHMENT_TOTAL_BYTES * 1.4) || !str_contains($raw, '"attachments"'))) {
public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
}
$body = json_decode($raw, true);
if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
if (($body['action'] ?? '') === 'resolve') {
// Ein Fremdsystem meldet Entwarnung (zum Beispiel Minecraft-Server wieder erreichbar): Ticket auf „Gelöst“ setzen.
// Nur Tickets, die über die API angelegt wurden, und nur mit der E-Mail-Adresse des Anfragenden.
$number = trim((string) ($body['number'] ?? ''));
$email = strtolower(trim((string) ($body['email'] ?? '')));
if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Felder number und email sind erforderlich.');
$stmt = $pdo->prepare("SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ? AND source = 'api'");
$stmt->execute([$number, $email]);
$ticket = $stmt->fetch();
if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.');
$note = trim(strip_tags((string) ($body['message'] ?? '')));
if ($note === '') $note = 'Das Fremdsystem meldet Entwarnung.';
$note = mb_substr($note, 0, 2000);
if (!in_array($ticket['status'], ['resolved', 'closed'], true)) {
$now = db_now();
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticket['id'], $note, $now]);
$pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticket['id']]);
log_activity($pdo, (int) $ticket['id'], null, 'Über die API gelöst: ' . $note);
try { webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => (string) $ticket['status']]); } catch (Throwable $e) { /* nachrangig */ }
}
echo json_encode(['number' => $number, 'status' => 'resolved'], JSON_UNESCAPED_UNICODE);
exit;
}
// Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute // Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute
$recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?"); $recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?");
$recent->execute([date('Y-m-d H:i:s', time() - 60)]); $recent->execute([date('Y-m-d H:i:s', time() - 60)]);
if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.'); if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.');
$raw = file_get_contents('php://input') ?: '';
if (strlen($raw) > 200000) public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
$body = json_decode($raw, true);
if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
try { try {
$created = public_ticket_create($pdo, $body, client_ip()); $created = public_ticket_create($pdo, $body, client_ip());
} catch (AdminModelException $e) { } catch (AdminModelException $e) {
@@ -55,6 +91,28 @@ if ($method === 'POST') {
exit; exit;
} }
// GET ?topics=1 (Hilfethemen) und GET ?form=1&helpTopicId=…|departmentId=… (Kundenformular mit eigenen Feldern) für Website-Formulare.
// Das ist dasselbe, was Kunden im Kundenportal sehen; es braucht das Recht "Tickets anlegen".
if (isset($_GET['topics']) || isset($_GET['form'])) {
if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
if (isset($_GET['topics'])) {
echo json_encode(['topics' => array_map(fn(array $t) => [
'id' => (int) $t['id'], 'name' => (string) $t['name'], 'path' => (string) $t['path'], 'depth' => (int) $t['depth'],
'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null, 'departmentId' => (int) $t['department_id'],
], help_topic_options($pdo, true))], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$topicId = am_int_or_null($_GET, 'helpTopicId');
$deptId = am_int_or_null($_GET, 'departmentId');
$def = public_form_definition($pdo, $topicId, $topicId === null ? ($deptId ?: (int) get_setting('default_department_id', '1')) : $deptId);
} catch (AdminModelException $e) {
public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
echo json_encode(['form' => public_form_payload($def)], JSON_UNESCAPED_UNICODE);
exit;
}
// GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden) // GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden)
if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.'); if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.');
$number = trim((string) ($_GET['number'] ?? '')); $number = trim((string) ($_GET['number'] ?? ''));
+9 -1
View File
@@ -6,11 +6,18 @@ api_require_method('GET');
$staff = api_require_staff(); $staff = api_require_staff();
$pdo = get_db(); $pdo = get_db();
/** Echter Stand für den Zähler: Ticket-IDs, die jetzt Aufmerksamkeit brauchen (nicht zugewiesen / mir gehörend), plus Anzahlen. */
function api_attention(PDO $pdo, array $staff): array
{
$a = staff_attention_tickets($pdo, $staff);
return ['unassigned' => $a['unassigned'], 'mine' => $a['mine'], 'unassignedCount' => count($a['unassigned']), 'mineCount' => count($a['mine'])];
}
$since = trim($_GET['since'] ?? ''); $since = trim($_GET['since'] ?? '');
if ($since === '') { if ($since === '') {
// Erster Aufruf nach Login: nur die Serverzeit als Referenzpunkt liefern, noch nichts zählen // Erster Aufruf nach Login: nur die Serverzeit als Referenzpunkt liefern, noch nichts zählen
// (verhindert Uhrzeit-Abweichungen zwischen Client und Server bei der Baseline). // (verhindert Uhrzeit-Abweichungen zwischen Client und Server bei der Baseline).
api_json(['newReplies' => 0, 'newUnassigned' => 0, 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]); api_json(['newReplies' => 0, 'newUnassigned' => 0, 'attention' => api_attention($pdo, $staff), 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]);
} }
if (strtotime($since) === false) { if (strtotime($since) === false) {
api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.'); api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.');
@@ -36,6 +43,7 @@ $newUnassigned = (int) $stmt->fetchColumn();
api_json([ api_json([
'newReplies' => $newReplies, 'newReplies' => $newReplies,
'newUnassigned' => $newUnassigned, 'newUnassigned' => $newUnassigned,
'attention' => api_attention($pdo, $staff),
'serverTime' => db_now(), 'serverTime' => db_now(),
'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [], 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [],
]); ]);
+2 -2
View File
@@ -3,14 +3,14 @@ declare(strict_types=1);
require_once __DIR__ . '/common.php'; require_once __DIR__ . '/common.php';
/** /**
* Update-Prüfung der Apps: GET ?platform=android|windows liefert die aktuell veröffentlichte Version, * Update-Prüfung der Apps und der Chrome-Erweiterung: GET ?platform=android|windows|chrome liefert die aktuell veröffentlichte Version,
* mit &download=1 die Installationsdatei. Die Quelle ist die Plugin-Webseite (Zugangsschlüssel bleibt auf dem Server). * mit &download=1 die Installationsdatei. Die Quelle ist die Plugin-Webseite (Zugangsschlüssel bleibt auf dem Server).
*/ */
api_require_method('GET'); api_require_method('GET');
api_require_staff(); api_require_staff();
$platform = (string) ($_GET['platform'] ?? ''); $platform = (string) ($_GET['platform'] ?? '');
if (!in_array($platform, ['android', 'windows'], true)) api_error(400, 'bad_platform', 'Unbekannte Plattform.'); if (!in_array($platform, ['android', 'windows', 'chrome'], true)) api_error(400, 'bad_platform', 'Unbekannte Plattform.');
$info = app_publisher_info($platform); $info = app_publisher_info($platform);
if (!$info) api_json(['available' => false, 'platform' => $platform]); if (!$info) api_json(['available' => false, 'platform' => $platform]);
@@ -63,8 +63,8 @@ if (!in_array($newStatus, AVAILABILITY_STATUSES, true)) {
// Ein Admin darf den Status eines anderen Mitarbeiters setzen (staffId), alle anderen nur den eigenen // Ein Admin darf den Status eines anderen Mitarbeiters setzen (staffId), alle anderen nur den eigenen
$targetId = (int) $staff['id']; $targetId = (int) $staff['id'];
if (isset($body['staffId']) && (int) $body['staffId'] !== $targetId) { if (isset($body['staffId']) && (int) $body['staffId'] !== $targetId) {
if (($staff['role'] ?? '') !== 'admin') { if (!has_permission($staff, 'manage_availability')) {
api_error(403, 'FORBIDDEN', 'Nur Administratoren dürfen den Status anderer Mitarbeiter ändern.'); api_error(403, 'FORBIDDEN', 'Für den Status anderer Mitarbeiter fehlt die erforderliche Berechtigung.');
} }
$targetId = (int) $body['staffId']; $targetId = (int) $body['staffId'];
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1'); $exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
+6 -2
View File
@@ -4,7 +4,7 @@ require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST'); api_require_method('GET', 'POST');
$staff = api_require_staff(); $staff = api_require_staff();
api_require_admin($staff); api_require_admin_or_permission($staff, 'manage_banlist');
$pdo = get_db(); $pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') { if ($_SERVER['REQUEST_METHOD'] === 'GET') {
@@ -31,8 +31,12 @@ $action = $body['action'] ?? '';
function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void
{ {
$col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email'); $col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email');
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") $before = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
$before->execute([$value]);
$rows = $before->fetchAll();
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
->execute([db_now(), db_now(), $value]); ->execute([db_now(), db_now(), $value]);
ticket_sla_transition_rows($pdo, $rows, 'closed', (int) ($GLOBALS['__api_staff']['id'] ?? 0) ?: null);
} }
if ($action === 'create') { if ($action === 'create') {
+38 -9
View File
@@ -9,7 +9,24 @@ header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store'); header('Cache-Control: no-store');
header('Pragma: no-cache'); header('Pragma: no-cache');
const MOBILE_API_STATUSES = ['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed']; // Browser-Erweiterungen (Chrome/Edge/Brave: chrome-extension://…, Firefox: moz-extension://…) dürfen die API aufrufen. Ohne diese Antwort hängt der
// Zugriff davon ab, ob der Browser die Server-Berechtigung der Erweiterung schon angewendet hat (nach der ersten Erlaubnis oft erst nach einem Neustart).
// Es werden keine Cookies gesendet; wer die API nutzt, braucht weiterhin das Token bzw. Passwort.
$__origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
if ($__origin !== '' && preg_match('#^(chrome|moz)-extension://[a-z0-9-]{8,64}$#', $__origin)) {
header('Access-Control-Allow-Origin: ' . $__origin);
header('Vary: Origin');
header('Access-Control-Allow-Headers: Authorization, X-DeskHub-Authorization, Content-Type, Cache-Control, Accept');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Max-Age: 600');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
}
unset($__origin);
const MOBILE_API_STATUSES =['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
set_exception_handler(function (Throwable $error): void { set_exception_handler(function (Throwable $error): void {
error_log('Mobile Staff API: ' . $error->getMessage()); error_log('Mobile Staff API: ' . $error->getMessage());
@@ -153,10 +170,7 @@ function api_require_staff(): array
function api_staff_payload(array $staff): array function api_staff_payload(array $staff): array
{ {
$permissionNames = array_merge([ $permissionNames = array_keys(permission_labels()); // alle Rechte des Katalogs (includes/permissions.php)
'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
'create_ticket', 'view_reports',
], array_keys(admin_section_permissions()));
$permissions = []; $permissions = [];
foreach ($permissionNames as $permission) { foreach ($permissionNames as $permission) {
$permissions[$permission] = has_permission($staff, $permission); $permissions[$permission] = has_permission($staff, $permission);
@@ -248,6 +262,8 @@ function api_ticket_summary(array $ticket): array
'requesterName' => $ticket['requester_name'], 'requesterName' => $ticket['requester_name'],
'requesterEmail' => $ticket['requester_email'], 'requesterEmail' => $ticket['requester_email'],
'status' => $ticket['status'], 'status' => $ticket['status'],
'resolutionCode' => $ticket['resolution_code'] ?? null,
'resolutionNote' => $ticket['resolution_note'] ?? null,
'priority' => [ 'priority' => [
'id' => (int) $ticket['priority_id'], 'id' => (int) $ticket['priority_id'],
'name' => $ticket['priority_name'], 'name' => $ticket['priority_name'],
@@ -261,14 +277,16 @@ function api_ticket_summary(array $ticket): array
'id' => (int) $ticket['assigned_staff_id'], 'id' => (int) $ticket['assigned_staff_id'],
'name' => $ticket['staff_name'], 'name' => $ticket['staff_name'],
], ],
'isOverdue' => !empty($ticket['resolution_due_at']) 'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']),
'slaPausedAt' => $ticket['sla_paused_at'] ?? null,
'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at'])
&& strtotime($ticket['resolution_due_at']) < time() && strtotime($ticket['resolution_due_at']) < time()
&& !in_array($ticket['status'], ['resolved', 'closed'], true), )),
// Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min // Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
// (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im // (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
// Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus // Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
// den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format). // den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null, 'dueLabel' => in_array($ticket['status'], ['resolved', 'closed'], true) ? null : (!empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null)),
'isSpam' => !empty($ticket['is_spam']), 'isSpam' => !empty($ticket['is_spam']),
'createdAt' => $ticket['created_at'], 'createdAt' => $ticket['created_at'],
'updatedAt' => $ticket['updated_at'], 'updatedAt' => $ticket['updated_at'],
@@ -304,7 +322,7 @@ function api_ticket_collaborators(PDO $pdo, int $ticketId): array
return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll()); return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll());
} }
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): int
{ {
$permission = $type === 'note' ? 'post_note' : 'reply_ticket'; $permission = $type === 'note' ? 'post_note' : 'reply_ticket';
api_require_permission($staff, $permission, (int) $ticket['department_id']); api_require_permission($staff, $permission, (int) $ticket['department_id']);
@@ -352,6 +370,9 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
} }
if (in_array($newStatus, ['resolved', 'closed'], true)) { if (in_array($newStatus, ['resolved', 'closed'], true)) {
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) {
api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
}
} }
} }
$now = db_now(); $now = db_now();
@@ -370,15 +391,23 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
} else { } else {
$firstResponse = empty($ticket['first_responded_at']); $firstResponse = empty($ticket['first_responded_at']);
$autoAssign = empty($ticket['assigned_staff_id']); $autoAssign = empty($ticket['assigned_staff_id']);
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
$sql = 'UPDATE tickets SET status = ?, updated_at = ?' $sql = 'UPDATE tickets SET status = ?, updated_at = ?'
. ($terminal ? ', resolution_code = ?, resolution_note = ?' : '')
. ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '')
. ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '')
. ($firstResponse ? ', first_responded_at = ?' : '') . ($firstResponse ? ', first_responded_at = ?' : '')
. ($autoAssign ? ', assigned_staff_id = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '')
. ' WHERE id = ?'; . ' WHERE id = ?';
$args = [$newStatus, $now]; $args = [$newStatus, $now];
if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); }
if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now;
if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now;
if ($firstResponse) $args[] = $now; if ($firstResponse) $args[] = $now;
if ($autoAssign) $args[] = $staff['id']; if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id']; $args[] = $ticket['id'];
$pdo->prepare($sql)->execute($args); $pdo->prepare($sql)->execute($args);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)'); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
} }
+2 -2
View File
@@ -18,7 +18,7 @@ $myOpen = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets "SELECT COUNT(*) FROM tickets
WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ? WHERE status NOT IN ('resolved','closed') AND (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at IS NOT NULL AND resolution_due_at < ?))
AND status NOT IN ('resolved','closed'){$deptFilter}" AND status NOT IN ('resolved','closed'){$deptFilter}"
); );
$stmt->execute([db_now(), ...$deptParams]); $stmt->execute([db_now(), ...$deptParams]);
@@ -86,7 +86,7 @@ if ($departmentIds === null) {
"SELECT d.id, d.name, "SELECT d.id, d.name,
COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count, COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count,
COUNT(CASE WHEN t.status NOT IN ('resolved','closed') COUNT(CASE WHEN t.status NOT IN ('resolved','closed')
AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?))
THEN 1 END) AS overdue_count THEN 1 END) AS overdue_count
FROM departments d FROM departments d
LEFT JOIN tickets t ON t.department_id = d.id LEFT JOIN tickets t ON t.department_id = d.id
+4 -2
View File
@@ -118,7 +118,7 @@ if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein
if ($action === 'typing') { if ($action === 'typing') {
if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
$pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?') $pdo->prepare('UPDATE tickets SET typing_staff_until = ?, updated_at = updated_at WHERE id = ?')
->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]); ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
} }
api_json(['ok' => true]); api_json(['ok' => true]);
@@ -151,6 +151,7 @@ if ($action === 'send') {
if ($autoAssign) $args[] = $staff['id']; if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id']; $args[] = $ticket['id'];
$pdo->prepare($sql)->execute($args); $pdo->prepare($sql)->execute($args);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'answered', (int) $staff['id']);
if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)'); if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)');
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
@@ -167,8 +168,9 @@ if ($action === 'end') {
} }
$now = db_now(); $now = db_now();
$pdo->prepare( $pdo->prepare(
"UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" "UPDATE tickets SET status='closed', resolution_code='other', resolution_note='Live-Chat beendet', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]); )->execute([$now, $now, $ticket['id']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', (int) $staff['id']);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)'); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)');
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
+7 -2
View File
@@ -12,8 +12,10 @@ $totpCode = trim((string) ($body['totpCode'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') { if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.'); api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
} }
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15) $ipOk = rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15);
|| !rate_limit_check('mobile_staff_login_email', strtolower($email), 5, 15)) { $accountOk = !$ipOk || rate_limit_check('mobile_staff_login_email', strtolower($email), login_guard_max_attempts(), login_guard_lock_minutes());
if (!$ipOk || !$accountOk) {
if (!$accountOk) login_guard_locked($email, 'app'); // das IP-Limit allein sperrt kein Konto und löst keine Mail an den Kontoinhaber aus
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.'); api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
} }
@@ -22,6 +24,7 @@ $stmt = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_
$stmt->execute([$email]); $stmt->execute([$email]);
$staff = $stmt->fetch(); $staff = $stmt->fetch();
if (!$staff || !password_verify($password, $staff['password_hash'])) { if (!$staff || !password_verify($password, $staff['password_hash'])) {
login_guard_failed($staff ?: null, $email, 'app', $staff ? 'Falsches Passwort' : 'Unbekannte E-Mail-Adresse');
api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.'); api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
} }
if (!empty($staff['totp_enabled'])) { if (!empty($staff['totp_enabled'])) {
@@ -29,6 +32,7 @@ if (!empty($staff['totp_enabled'])) {
api_json(['requiresTotp' => true], 202); api_json(['requiresTotp' => true], 202);
} }
if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) { if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
login_guard_failed($staff, $email, 'app', 'Falscher 2FA-Code');
api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.'); api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
} }
} }
@@ -37,6 +41,7 @@ rate_limit_reset('mobile_staff_login_email', strtolower($email));
rate_limit_reset('mobile_staff_login_ip', client_ip()); rate_limit_reset('mobile_staff_login_ip', client_ip());
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null); $issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
audit_log((int) $staff['id'], 'Mobile App: Anmeldung'); audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
login_guard_success($staff, 'app', isset($body['deviceName']) ? (string) $body['deviceName'] : null);
api_json([ api_json([
'token' => $issued['token'], 'token' => $issued['token'],
'expiresAt' => $issued['expires_at'], 'expiresAt' => $issued['expires_at'],
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/../../../includes/mail_fetch_manual.php';
/**
* E-Mail-Abruf von Hand starten (wie der Knopf in der Ticketliste der Weboberfläche). POST ohne Inhalt.
* Antwort: ok (ohne Fehler), level (success|info|error), message (fertiger Text), processed, newTickets, replies, errors.
*/
api_require_method('POST');
$staff = api_require_staff();
api_require_permission($staff, 'fetch_mail');
$pdo = get_db();
$r = mail_fetch_manual($pdo);
audit_log((int) $staff['id'], 'E-Mail-Abruf von Hand gestartet über die App (' . $r['processed'] . ' E-Mail(s))');
api_json([
'ok' => $r['ok'],
'level' => $r['level'],
'message' => $r['message'],
'processed' => $r['processed'],
'newTickets' => $r['new_tickets'],
'replies' => $r['replies'],
'errors' => $r['errors'],
'available' => mail_fetch_available($pdo),
]);
+1 -1
View File
@@ -22,7 +22,7 @@ $action = $body['action'] ?? '';
try { try {
if ($action === 'save') { if ($action === 'save') {
$id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared'])); $id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared']) && has_permission($staff, 'share_views'));
api_json(['id' => $id]); api_json(['id' => $id]);
} }
if ($action === 'delete') { if ($action === 'delete') {
@@ -38,7 +38,7 @@ if ($action === 'save') {
$cfg = report_clean_config($pdo, (array) ($body['config'] ?? [])); $cfg = report_clean_config($pdo, (array) ($body['config'] ?? []));
$now = db_now(); $now = db_now();
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)') $pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($body['isShared']) ? 1 : 0, $now, $now]); ->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), (!empty($body['isShared']) && has_permission($staff, 'share_views')) ? 1 : 0, $now, $now]);
api_json(['id' => (int) $pdo->lastInsertId()]); api_json(['id' => (int) $pdo->lastInsertId()]);
} }
if ($action === 'delete') { if ($action === 'delete') {
+15 -5
View File
@@ -9,10 +9,7 @@ $pdo = get_db();
// Ticket-Rechte + alle Adminportal-Bereichsrechte (admin_section_permissions(), includes/functions.php) – // Ticket-Rechte + alle Adminportal-Bereichsrechte (admin_section_permissions(), includes/functions.php) –
// zusammen ergeben sie das vollständige Set, das über eine benutzerdefinierte Rolle vergeben werden kann. // zusammen ergeben sie das vollständige Set, das über eine benutzerdefinierte Rolle vergeben werden kann.
$rolePermissionKeys = array_merge([ $rolePermissionKeys = array_keys(permission_labels());
'reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note',
'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
], array_keys(admin_section_permissions()));
if ($_SERVER['REQUEST_METHOD'] === 'GET') { if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$roles = $pdo->query( $roles = $pdo->query(
@@ -21,11 +18,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
api_json([ api_json([
'permissionKeys' => $rolePermissionKeys, 'permissionKeys' => $rolePermissionKeys,
'permissionLabels' => permission_labels(),
'permissionGroups' => array_values(array_map(fn($g) => ['title' => $g['title'], 'keys' => array_keys($g['items'])], permission_catalog())),
'defaultAgentPermissions' => default_permissions('agent'), 'defaultAgentPermissions' => default_permissions('agent'),
'items' => array_map(fn(array $r) => [ 'items' => array_map(fn(array $r) => [
'id' => (int) $r['id'], 'id' => (int) $r['id'],
'name' => $r['name'], 'name' => $r['name'],
'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [], 'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [],
'effectivePermissions' => permission_effective(json_decode($r['permissions'] ?? '{}', true) ?? []),
'staffCount' => (int) $r['staff_count'], 'staffCount' => (int) $r['staff_count'],
'isActive' => (bool) ($r['is_active'] ?? 1), 'isActive' => (bool) ($r['is_active'] ?? 1),
'notes' => $r['notes'] ?? null, 'notes' => $r['notes'] ?? null,
@@ -44,9 +44,19 @@ if ($action === 'save') {
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.'); if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
$incoming = (array) ($body['permissions'] ?? []); $incoming = (array) ($body['permissions'] ?? []);
// Rechte, die eine ältere App-Version nicht kennt (und deshalb nicht mitschickt), bleiben unverändert bzw. bekommen den Standardwert
$existingPerms = default_permissions('agent');
if ($id) {
$ep = $pdo->prepare('SELECT permissions FROM roles WHERE id = ?');
$ep->execute([$id]);
$existingPerms = array_merge($existingPerms, json_decode((string) $ep->fetchColumn(), true) ?: []);
}
$perms = []; $perms = [];
foreach ($rolePermissionKeys as $p) { foreach ($rolePermissionKeys as $p) {
$perms[$p] = !empty($incoming[$p]); $want = array_key_exists($p, $incoming) ? !empty($incoming[$p]) : !empty($existingPerms[$p]);
// Wer kein echter Administrator ist, kann keine Rechte vergeben, die er selbst nicht hat
if ($staff['role'] !== 'admin' && $want && !has_permission($staff, $p)) $want = !empty($existingPerms[$p]) && $id > 0;
$perms[$p] = $want;
} }
$json = json_encode($perms); $json = json_encode($perms);
$dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?'); $dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
+2 -2
View File
@@ -17,7 +17,7 @@ $staff = api_require_staff();
$pdo = get_db(); $pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
api_require_admin($staff); api_require_admin_or_permission($staff, 'manage_status_page');
$b = api_request_body(); $b = api_request_body();
$action = (string) ($b['action'] ?? ''); $action = (string) ($b['action'] ?? '');
if ($action === 'set_component') { if ($action === 'set_component') {
@@ -39,4 +39,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
api_error(422, 'UNKNOWN_ACTION', 'Unbekannte Aktion.'); api_error(422, 'UNKNOWN_ACTION', 'Unbekannte Aktion.');
} }
} }
api_json(['status' => status_summary($pdo)] + ['canManage' => $staff['role'] === 'admin']); api_json(['status' => status_summary($pdo)] + ['canManage' => has_permission($staff, 'manage_status_page')]);
+4 -1
View File
@@ -40,8 +40,9 @@ $threadStmt = $pdo->prepare(
ORDER BY tt.created_at ASC, tt.id ASC' ORDER BY tt.created_at ASC, tt.id ASC'
); );
$threadStmt->execute([$ticket['id']]); $threadStmt->execute([$ticket['id']]);
// Eingebettete Bilder einer Mail (Logos, Fotos im Text) gehören zur Darstellung der Nachricht und sind keine Anhänge
$attachmentStmt = $pdo->prepare( $attachmentStmt = $pdo->prepare(
'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id' 'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ?' . (ticket_mail_html_ready($pdo) ? ' AND COALESCE(is_inline, 0) = 0' : '') . ' ORDER BY id'
); );
$commentStmt = $pdo->prepare( $commentStmt = $pdo->prepare(
'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name 'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name
@@ -88,6 +89,8 @@ if ($ticket['source'] === 'chat' && $ticket['status'] !== 'closed') {
} }
} }
ticket_ack($pdo, (int) $ticket['id'], (int) $staff['id']); // gesehen: verschwindet aus dem Zähler der Apps
$summary = api_ticket_summary($ticket); $summary = api_ticket_summary($ticket);
$summary['source'] = $ticket['source']; $summary['source'] = $ticket['source'];
$summary['firstResponseDueAt'] = $ticket['first_response_due_at']; $summary['firstResponseDueAt'] = $ticket['first_response_due_at'];
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/../../../includes/mail_reload.php';
/**
* Lädt Anhänge, eingebettete Bilder und die Formatierung der Original-E-Mail eines Tickets nach (wie der Knopf im Ticket der
* Weboberfläche). POST {"number": "260101-0001"}.
*/
api_require_method('POST');
$staff = api_require_staff();
$body = api_request_body();
$number = trim((string) ($body['number'] ?? ''));
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
$pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number);
api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']);
if ($ticket['source'] !== 'email') api_error(422, 'EMAIL_TICKET_REQUIRED', 'Nur E-Mail-Tickets haben eine Original-E-Mail.');
$r = reload_ticket_mail_attachments($pdo, $ticket, (int) $staff['id']);
if ($r['error']) api_error(422, 'MAIL_RELOAD_FAILED', $r['error']);
api_json([
'added' => $r['added'],
'inlineImages' => $r['inline'],
'formatting' => $r['html'] > 0,
'skipped' => $r['skipped'],
'alreadyThere' => $r['existing'],
'failed' => $r['failed'],
]);
@@ -16,5 +16,7 @@ if ($number === '') {
} }
$pdo = get_db(); $pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number); $ticket = api_find_ticket($pdo, $staff, $number);
$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice); $resolutionCode = isset($body['resolutionCode']) ? trim((string) $body['resolutionCode']) : null;
$resolutionNote = trim((string) ($body['resolutionNote'] ?? ''));
$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice, $resolutionCode, $resolutionNote);
api_json(['threadId' => $threadId], 201); api_json(['threadId' => $threadId], 201);
@@ -12,8 +12,8 @@ require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST'); api_require_method('GET', 'POST');
$staff = api_require_staff(); $staff = api_require_staff();
if ($staff['role'] !== 'admin') { if (!has_permission($staff, 'manage_automation')) {
api_error(403, 'ADMIN_REQUIRED', 'Nur Administratoren können Ticket-Regeln verwalten.'); api_error(403, 'FORBIDDEN', 'Für Ticket-Regeln fehlt die erforderliche Berechtigung.');
} }
$pdo = get_db(); $pdo = get_db();
@@ -27,6 +27,6 @@ if ($holderId !== (int) $staff['id']) {
$n->execute([$holderId]); $n->execute([$holderId]);
$name = (string) ($n->fetchColumn() ?: 'einem anderen Mitarbeiter'); $name = (string) ($n->fetchColumn() ?: 'einem anderen Mitarbeiter');
} }
$pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], $name !== '' ? "Sperre von \"{$name}\" durch Admin aufgehoben" : 'Sperre manuell aufgehoben'); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], $name !== '' ? "Sperre von \"{$name}\" durch Admin aufgehoben" : 'Sperre manuell aufgehoben');
api_json(['ok' => true, 'unlocked' => true]); api_json(['ok' => true, 'unlocked' => true]);
@@ -29,6 +29,12 @@ if (array_key_exists('status', $body) && $body['status'] !== $ticket['status'])
api_error(422, 'INVALID_STATUS', 'Der Status ist ungültig.'); api_error(422, 'INVALID_STATUS', 'Der Status ist ungültig.');
} }
api_require_permission($staff, in_array($status, ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket', (int) $ticket['department_id']); api_require_permission($staff, in_array($status, ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket', (int) $ticket['department_id']);
if (in_array($status, ['resolved', 'closed'], true)) {
$resolutionCode = trim((string) ($body['resolutionCode'] ?? ''));
if (!isset(ticket_resolution_reasons()[$resolutionCode])) api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
$updates[] = 'resolution_code = ?'; $params[] = $resolutionCode;
$updates[] = 'resolution_note = ?'; $params[] = trim((string) ($body['resolutionNote'] ?? ''));
}
$updates[] = 'status = ?'; $updates[] = 'status = ?';
$params[] = $status; $params[] = $status;
if ($status === 'resolved' && empty($ticket['resolved_at'])) { if ($status === 'resolved' && empty($ticket['resolved_at'])) {
@@ -90,6 +96,7 @@ $params[] = $now;
$params[] = $ticket['id']; $params[] = $ticket['id'];
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params); $pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params);
if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) { if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) {
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $body['status'], (int) $staff['id']);
[$label] = status_label((string) $body['status']); [$label] = status_label((string) $body['status']);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)'); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)');
} }
+3 -3
View File
@@ -70,13 +70,13 @@ if ($sla !== '') {
$now = db_now(); $now = db_now();
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600); $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
if ($sla === 'overdue') { if ($sla === 'overdue') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; $where[] = "t.resolution_due_at IS NOT NULL AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?)) AND t.status NOT IN ('resolved','closed')";
$params[] = $now; $params[] = $now;
} elseif ($sla === 'at_risk') { } elseif ($sla === 'at_risk') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; $where[] = "t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
array_push($params, $now, $riskThreshold); array_push($params, $now, $riskThreshold);
} elseif ($sla === 'on_time') { } elseif ($sla === 'on_time') {
$where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')"; $where[] = "t.sla_paused_at IS NULL AND (t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
$params[] = $riskThreshold; $params[] = $riskThreshold;
} }
} }
+22
View File
@@ -1134,3 +1134,25 @@ body.staff-login-page {
@media (min-width: 821px) { @media (min-width: 821px) {
html.sidebar-closed .sidebar .lang-row { display: none; } html.sidebar-closed .sidebar .lang-row { display: none; }
} }
/* ── Seitenleiste unten: Benutzerkarte, Symbolleiste, Sprache ───────────── */
.sidebar-footer .sf-user { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
.sidebar-footer .sf-avatar { width:38px; height:38px; border-radius:50%; object-fit:cover; flex-shrink:0; border:2px solid #2a3744; }
.sidebar-footer .sf-avatar-letter { display:flex; align-items:center; justify-content:center; background:rgba(15,111,106,.3); color:#8bc4c2; font-weight:700; font-size:1rem; }
.sidebar-footer .sf-who { min-width:0; display:flex; flex-direction:column; gap:1px; align-items:flex-start; }
.sidebar-footer .sf-name { font-size:.86rem; font-weight:700; color:#fff; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sidebar-footer .sf-role { font-size:.72rem; color:#7c8da0; }
.sidebar-footer .sf-vac { margin-top:3px; font-size:.68rem; font-weight:600; color:#fbbf24; background:rgba(251,191,36,.14); border-radius:10px; padding:1px 8px; }
.sidebar-footer .sf-actions { display:grid; grid-template-columns:repeat(4, 1fr); gap:6px; margin-bottom:10px; }
.sidebar-footer .sf-btn { position:relative; display:flex; align-items:center; justify-content:center; height:34px; border-radius:9px; background:rgba(255,255,255,.06); color:#a9b6c3; text-decoration:none; transition:background .15s, color .15s; }
.sidebar-footer .sf-btn:hover { background:rgba(255,255,255,.14); color:#fff; }
.sidebar-footer .sf-btn-out:hover { background:rgba(220,38,38,.28); color:#fecaca; }
.sidebar-footer .sf-btn:focus-visible { outline:2px solid #8bc4c2; outline-offset:1px; }
.sidebar-footer .sf-badge { position:absolute; top:-5px; right:-4px; min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:#dc2626; color:#fff; font-size:.62rem; font-weight:700; display:flex; align-items:center; justify-content:center; line-height:1; box-shadow:0 0 0 2px #1b2733; }
.sidebar-footer .lang-row { display:flex; justify-content:center; }
.sidebar-footer .lang-row .lang-switch { gap:4px !important; padding:3px; background:rgba(255,255,255,.05); border-radius:9px; }
.sidebar-footer .lang-row .lang-switch a { padding:2px !important; border-radius:6px !important; outline-offset:0 !important; }
.sidebar-footer .lang-row .lang-switch img { width:22px !important; height:15px !important; }
html.sidebar-closed .sidebar .sidebar-footer .sf-actions { grid-template-columns:1fr; }
html.sidebar-closed .sidebar .sidebar-footer .sf-btn { width:34px; margin:0 auto; }
html.sidebar-closed .sidebar .sidebar-footer .sf-user { justify-content:center; }
+70
View File
@@ -0,0 +1,70 @@
/* Passkeys im Browser: Anmeldung (staff/login.php) und Registrierung (Profil → Sicherheit). Spricht mit /staff/passkey.php. */
(function () {
'use strict';
function b64uToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
var bin = atob(s), buf = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64u(buf) {
var b = new Uint8Array(buf), s = '';
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function post(csrf, fields) {
var fd = new FormData();
fd.append('csrf_token', csrf);
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
return fetch('/staff/passkey.php', { method: 'POST', body: fd, credentials: 'same-origin' }).then(function (r) {
return r.json().catch(function () { return { ok: false, error: 'Unerwartete Antwort des Servers.' }; });
});
}
function supported() {
return !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
}
function friendly(e) {
if (e && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return 'Abgebrochen oder abgelaufen. Bitte noch einmal versuchen.';
if (e && e.name === 'InvalidStateError') return 'Dieses Gerät ist schon als Passkey registriert.';
return (e && e.message) ? e.message : 'Der Passkey konnte nicht verwendet werden.';
}
window.DeskHubPasskey = {
supported: supported,
login: function (csrf) {
return post(csrf, { action: 'login_options' }).then(function (r) {
if (!r.ok) throw new Error(r.error);
var o = r.options;
return navigator.credentials.get({ publicKey: { challenge: b64uToBuf(o.challenge), rpId: o.rpId, timeout: o.timeout, userVerification: o.userVerification, allowCredentials: [] } });
}).then(function (c) {
var rs = c.response;
return post(csrf, { action: 'login_verify', credential: JSON.stringify({ id: c.id, response: {
clientDataJSON: bufToB64u(rs.clientDataJSON), authenticatorData: bufToB64u(rs.authenticatorData), signature: bufToB64u(rs.signature),
userHandle: rs.userHandle ? bufToB64u(rs.userHandle) : ''
} }) });
}).then(function (r) {
if (!r.ok) throw new Error(r.error);
return r;
}).catch(function (e) { throw new Error(friendly(e)); });
},
register: function (csrf, name, password) {
return post(csrf, { action: 'register_options', password: password }).then(function (r) {
if (!r.ok) throw new Error(r.error);
var o = r.options;
o.challenge = b64uToBuf(o.challenge);
o.user.id = b64uToBuf(o.user.id);
o.excludeCredentials = (o.excludeCredentials || []).map(function (c) { return { type: c.type, id: b64uToBuf(c.id) }; });
return navigator.credentials.create({ publicKey: o });
}).then(function (c) {
var rs = c.response;
return post(csrf, { action: 'register_verify', name: name, credential: JSON.stringify({ id: c.id, response: {
clientDataJSON: bufToB64u(rs.clientDataJSON), attestationObject: bufToB64u(rs.attestationObject)
} }) });
}).then(function (r) {
if (!r.ok) throw new Error(r.error);
return r;
}).catch(function (e) { throw new Error(friendly(e)); });
}
};
})();
+27
View File
@@ -198,6 +198,33 @@
imgBtn.addEventListener('click', () => fileInput.click()); imgBtn.addEventListener('click', () => fileInput.click());
toolbar.appendChild(imgBtn); toolbar.appendChild(imgBtn);
toolbar.appendChild(fileInput); toolbar.appendChild(fileInput);
// Bild aus der Zwischenablage (Strg + V, zum Beispiel ein Screenshot) oder per Ziehen in das Feld: derselbe Weg wie der Knopf.
// Enthält die Zwischenablage auch Text (Kopie aus Word, Webseite), bleibt das normale Einfügen unverändert.
const imageFrom = (list) => Array.from(list || []).find((f) => /^image\/(png|jpe?g|gif)$/i.test(f.type));
const uploadPicked = (file) => {
const ext = file.type.toLowerCase() === 'image/jpeg' ? 'jpg' : file.type.split('/')[1].toLowerCase();
const named = /\.(png|jpe?g|gif)$/i.test(file.name || '') ? file : new File([file], 'screenshot-' + Date.now() + '.' + ext, { type: file.type });
const dt = new DataTransfer();
dt.items.add(named);
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change'));
};
editable.addEventListener('paste', (e) => {
const cd = e.clipboardData;
if (!cd || Array.from(cd.types || []).some((t) => t === 'text/plain' || t === 'text/html')) return;
const img = imageFrom(cd.files);
if (!img) return;
e.preventDefault();
uploadPicked(img);
});
editable.addEventListener('dragover', (e) => { if (Array.from((e.dataTransfer && e.dataTransfer.types) || []).includes('Files')) e.preventDefault(); });
editable.addEventListener('drop', (e) => {
const img = imageFrom(e.dataTransfer && e.dataTransfer.files);
if (!img) return;
e.preventDefault();
uploadPicked(img);
});
} }
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; }); editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
@@ -0,0 +1,204 @@
/**
* Tastenkürzel im Mitarbeiterbereich.
*
* Ticketliste: j / k = Ticket wählen, Enter oder o = öffnen, r = öffnen und gleich antworten, x = Auswahlfeld, / = Suche, n = neues Ticket
* Ticket: j / k = nächste / vorherige Nachricht, r = Antwort schreiben, u = zurück zur Liste, / = Suche in der Liste
* Überall: ? = Übersicht, Esc = schließen. Strg + Enter im Antwortfeld sendet die Antwort.
*
* Die Kürzel gelten nur, wenn kein Eingabefeld aktiv ist und keine Strg-, Alt- oder Meta-Taste gedrückt wird. Wer sie nicht möchte,
* schaltet sie in der Übersicht (?) ab; die Wahl bleibt im Browser (localStorage).
*/
(function () {
'use strict';
if (window.__deskhubShortcuts) return;
window.__deskhubShortcuts = true;
var EN = /^en/i.test(document.documentElement.lang || '');
// Die Seite wird serverseitig übersetzt, Texte aus diesem Skript nicht: deshalb hier die englische Fassung
var TXT = EN ? {
title: 'Keyboard shortcuts', nextMsg: 'Next / previous message', nextTicket: 'Next / previous ticket', open: 'Open the selected ticket (list)',
reply: 'Reply (in the list: open and select the reply field)', tick: 'Tick the selected ticket (list)', search: 'Search', create: 'New ticket',
back: 'Back to the ticket list (in a ticket)', send: 'Send the reply (in the reply field)', help: 'This overview', use: 'Use keyboard shortcuts (this browser only)',
enter: 'Enter or o', ctrl: 'Ctrl + Enter'
} : {
title: 'Tastenkürzel', nextMsg: 'Nächste / vorherige Nachricht', nextTicket: 'Nächstes / vorheriges Ticket', open: 'Gewähltes Ticket öffnen (Liste)',
reply: 'Antworten (in der Liste: öffnen und Antwortfeld wählen)', tick: 'Gewähltes Ticket ankreuzen (Liste)', search: 'Suche', create: 'Neues Ticket',
back: 'Zurück zur Ticketliste (im Ticket)', send: 'Antwort senden (im Antwortfeld)', help: 'Diese Übersicht', use: 'Tastenkürzel verwenden (nur in diesem Browser)',
enter: 'Enter oder o', ctrl: 'Strg + Enter'
};
var KEY = 'deskhub_shortcuts_off';
var path = location.pathname;
var onList = /\/staff\/tickets\.php$/.test(path);
var onTicket = /\/staff\/ticket_view\.php$/.test(path);
var cursor = -1;
var help = null;
function disabled() { try { return localStorage.getItem(KEY) === '1'; } catch (e) { return false; } }
function setDisabled(v) { try { if (v) localStorage.setItem(KEY, '1'); else localStorage.removeItem(KEY); } catch (e) { /* ohne Speicher */ } }
function typing(el) {
if (!el) return false;
var tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
}
function rows() { return Array.prototype.slice.call(document.querySelectorAll('table tbody tr')).filter(function (tr) { return tr.querySelector('a.ticket-no'); }); }
function messages() { return Array.prototype.slice.call(document.querySelectorAll('.thread-msg')); }
function mark(list, i) {
list.forEach(function (el) { el.classList.remove('kb-active'); });
if (i < 0 || i >= list.length) return;
var el = list[i];
el.classList.add('kb-active');
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
function move(list, delta) {
if (!list.length) return;
var current = list.findIndex(function (el) { return el.classList.contains('kb-active'); });
cursor = current < 0 ? (delta > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, current + delta));
mark(list, cursor);
}
function activeRow() { return rows().filter(function (tr) { return tr.classList.contains('kb-active'); })[0] || null; }
function focusReply() {
var field = document.getElementById('message-field');
if (!field) return false;
var wrap = field.parentNode;
var editable = wrap && wrap.querySelector('[contenteditable]');
var target = editable || field;
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.focus();
return true;
}
function closeHelp() { if (help) { help.remove(); help = null; } }
function showHelp() {
if (help) return closeHelp();
var items = [
['j / k', onTicket ? TXT.nextMsg : TXT.nextTicket],
[TXT.enter, TXT.open],
['r', TXT.reply],
['x', TXT.tick],
['/', TXT.search],
['n', TXT.create],
['u', TXT.back],
[TXT.ctrl, TXT.send],
['?', TXT.help],
];
help = document.createElement('div');
help.setAttribute('role', 'dialog');
help.setAttribute('aria-label', TXT.title);
help.style.cssText = 'position:fixed;inset:0;z-index:2147483600;background:rgba(15,23,42,.55);display:flex;align-items:center;justify-content:center;padding:16px';
var box = document.createElement('div');
box.style.cssText = 'background:var(--surface,#fff);color:var(--ink,#0f172a);border-radius:12px;padding:20px 24px;max-width:460px;width:100%;box-shadow:0 20px 50px rgba(0,0,0,.35);font:14px/1.5 system-ui,sans-serif';
var h = document.createElement('div');
h.style.cssText = 'font-weight:700;font-size:1.05rem;margin-bottom:10px';
h.textContent = TXT.title;
box.appendChild(h);
items.forEach(function (it) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;gap:12px;padding:4px 0;border-top:1px solid rgba(148,163,184,.25)';
var k = document.createElement('kbd');
k.style.cssText = 'min-width:104px;font:600 12px ui-monospace,monospace;background:rgba(148,163,184,.2);border-radius:5px;padding:2px 7px;align-self:flex-start';
k.textContent = it[0];
var d = document.createElement('span');
d.textContent = it[1];
row.appendChild(k);
row.appendChild(d);
box.appendChild(row);
});
var label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:14px;font-weight:400';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.style.width = 'auto';
cb.checked = !disabled();
cb.addEventListener('change', function () { setDisabled(!cb.checked); });
label.appendChild(cb);
label.appendChild(document.createTextNode(TXT.use));
box.appendChild(label);
help.appendChild(box);
help.addEventListener('click', function (e) { if (e.target === help) closeHelp(); });
document.body.appendChild(help);
}
// Auffälliger Rahmen für die gewählte Zeile bzw. Nachricht
var style = document.createElement('style');
style.textContent = '.kb-active{outline:2px solid var(--primary,#0f6f6a);outline-offset:-2px;background:rgba(15,111,106,.07)}';
document.head.appendChild(style);
document.addEventListener('keydown', function (e) {
// Strg + Enter im Antwortfeld sendet die öffentliche Antwort (unabhängig von den Einzeltasten)
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && onTicket && !disabled()) {
var editable = e.target && e.target.closest ? e.target.closest('.richtext-editable') : null;
if (editable) {
var form = editable.closest('form');
var action = form && form.querySelector('#form-action');
if (form && action && action.value === 'reply') {
e.preventDefault();
var ta = form.querySelector('textarea[data-richtext]');
if (ta) ta.value = editable.innerHTML;
if (form.requestSubmit) form.requestSubmit(); else form.submit();
}
}
return;
}
if (e.key === 'Escape') { if (help) { closeHelp(); e.preventDefault(); } else if (typing(e.target)) e.target.blur(); return; }
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (typing(e.target)) return;
if (e.key === '?') { e.preventDefault(); showHelp(); return; }
if (disabled() || help) return;
switch (e.key) {
case 'j': case 'k':
if (onList) { e.preventDefault(); move(rows(), e.key === 'j' ? 1 : -1); }
else if (onTicket) { e.preventDefault(); move(messages(), e.key === 'j' ? 1 : -1); }
break;
case 'Enter': case 'o': {
var row = onList ? activeRow() : null;
var link = row && row.querySelector('a.ticket-no');
if (link) { e.preventDefault(); location.href = link.href; }
break;
}
case 'x': {
var r = onList ? activeRow() : null;
var box = r && r.querySelector('input.ticket-check');
if (box) { e.preventDefault(); box.checked = !box.checked; box.dispatchEvent(new Event('change', { bubbles: true })); }
break;
}
case 'r':
if (onTicket) { if (focusReply()) e.preventDefault(); }
else if (onList) {
var rr = activeRow();
var a = rr && rr.querySelector('a.ticket-no');
if (a) { e.preventDefault(); location.href = a.href + '#reply'; }
}
break;
case '/': {
var search = document.querySelector('input[name="q"]');
if (search) { e.preventDefault(); search.focus(); search.select(); }
else { e.preventDefault(); location.href = '/staff/tickets.php#search'; }
break;
}
case 'n':
e.preventDefault();
location.href = '/staff/ticket_create.php';
break;
case 'u':
if (onTicket) { e.preventDefault(); location.href = '/staff/tickets.php'; }
break;
}
});
document.addEventListener('DOMContentLoaded', function () {
if (disabled()) return;
if (location.hash === '#reply' && onTicket) setTimeout(focusReply, 150);
if (location.hash === '#search' && onList) {
var s = document.querySelector('input[name="q"]');
if (s) { s.focus(); s.select(); }
}
});
})();
@@ -0,0 +1,98 @@
/**
* Bildvorschau für Anhänge im Ticket: Ein Klick auf ein Vorschaubild zeigt das Bild groß über der Seite, ohne es herunterzuladen.
* Pfeil links/rechts (oder die Knöpfe) blättern durch alle Bilder des Tickets, Esc oder ein Klick daneben schließt. Ohne JavaScript
* öffnet der Link das Bild wie bisher in einem neuen Tab.
*/
(function () {
'use strict';
var links = Array.prototype.slice.call(document.querySelectorAll('a[data-lightbox]'));
if (!links.length) return;
var EN = /^en/i.test(document.documentElement.lang || '');
var TXT = EN ? { dialog: 'Image preview', prev: 'Previous image (←)', next: 'Next image (→)', download: '⬇ Download', close: '✕ Close', closeTitle: 'Close (Esc)' }
: { dialog: 'Bildvorschau', prev: 'Vorheriges Bild (←)', next: 'Nächstes Bild (→)', download: '⬇ Herunterladen', close: '✕ Schließen', closeTitle: 'Schließen (Esc)' };
var overlay = null, img = null, caption = null, counter = null, index = 0;
function build() {
overlay = document.createElement('div');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', TXT.dialog);
overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483500;background:rgba(2,6,23,.88);display:none;flex-direction:column;align-items:center;justify-content:center;padding:16px;gap:10px';
img = document.createElement('img');
img.alt = '';
img.style.cssText = 'max-width:96vw;max-height:80vh;object-fit:contain;border-radius:6px;background:#fff;box-shadow:0 10px 40px rgba(0,0,0,.5)';
caption = document.createElement('div');
caption.style.cssText = 'color:#e2e8f0;font:14px system-ui,sans-serif;text-align:center;max-width:90vw;word-break:break-all';
counter = document.createElement('span');
counter.style.cssText = 'color:#94a3b8;margin-left:8px';
var bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:center';
function btn(label, title, fn) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = label;
b.title = title;
b.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;cursor:pointer;font:600 14px system-ui,sans-serif';
b.addEventListener('click', function (e) { e.stopPropagation(); fn(); });
return b;
}
var download = document.createElement('a');
download.textContent = TXT.download;
download.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;text-decoration:none;font:600 14px system-ui,sans-serif';
download.addEventListener('click', function (e) { e.stopPropagation(); });
overlay._download = download;
bar.appendChild(btn('‹', TXT.prev, function () { show(index - 1); }));
bar.appendChild(btn('›', TXT.next, function () { show(index + 1); }));
bar.appendChild(download);
bar.appendChild(btn(TXT.close, TXT.closeTitle, close));
caption.appendChild(document.createTextNode(''));
overlay.appendChild(img);
overlay.appendChild(caption);
overlay.appendChild(bar);
overlay.addEventListener('click', close);
document.body.appendChild(overlay);
}
function show(i) {
if (!links.length) return;
index = (i + links.length) % links.length;
var a = links[index];
img.src = a.getAttribute('href');
img.alt = a.getAttribute('title') || '';
caption.textContent = a.getAttribute('title') || '';
counter.textContent = links.length > 1 ? '(' + (index + 1) + ' / ' + links.length + ')' : '';
caption.appendChild(counter);
overlay._download.href = a.getAttribute('data-download') || a.getAttribute('href');
overlay._download.setAttribute('download', a.getAttribute('title') || '');
}
function open(i) {
if (!overlay) build();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
show(i);
}
function close() {
if (!overlay) return;
overlay.style.display = 'none';
img.removeAttribute('src');
document.body.style.overflow = '';
}
links.forEach(function (a, i) {
a.addEventListener('click', function (e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return; // neuer Tab bleibt möglich
e.preventDefault();
open(i);
});
});
document.addEventListener('keydown', function (e) {
if (!overlay || overlay.style.display === 'none') return;
if (e.key === 'Escape') { e.preventDefault(); close(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(index - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); show(index + 1); }
});
})();
@@ -52,6 +52,7 @@ function run_auto_close_tickets(PDO $pdo, bool $verbose = false): array
foreach ($tickets as $t) { foreach ($tickets as $t) {
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?") $pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")
->execute([$now, $now, $t['id']]); ->execute([$now, $now, $t['id']]);
ticket_sla_status_transition($pdo, (int) $t['id'], (string) $t['status'], 'closed');
log_activity($pdo, (int) $t['id'], null, "Automatisch geschlossen (keine Kundenreaktion seit {$days} Tagen)"); log_activity($pdo, (int) $t['id'], null, "Automatisch geschlossen (keine Kundenreaktion seit {$days} Tagen)");
$closed++; $closed++;
if ($verbose) { if ($verbose) {
+7 -1
View File
@@ -147,7 +147,7 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
} }
$sql = 'SELECT t.*, d.name AS department_name, d.email AS department_email, $sql = 'SELECT t.*, d.name AS department_name, d.email AS department_email,
p.name AS priority_name, s.name AS staff_name, s.email AS staff_email p.name AS priority_name, s.name AS staff_name, s.email AS staff_email, s.is_on_vacation AS staff_on_vacation
FROM tickets t FROM tickets t
JOIN departments d ON d.id = t.department_id JOIN departments d ON d.id = t.department_id
JOIN priorities p ON p.id = t.priority_id JOIN priorities p ON p.id = t.priority_id
@@ -182,6 +182,7 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
if ($rule['action_set_status']) { if ($rule['action_set_status']) {
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?') $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')
->execute([$rule['action_set_status'], $now, $ticket['id']]); ->execute([$rule['action_set_status'], $now, $ticket['id']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $rule['action_set_status']);
$changes[] = 'Status gesetzt auf: ' . $rule['action_set_status']; $changes[] = 'Status gesetzt auf: ' . $rule['action_set_status'];
} }
@@ -201,6 +202,11 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
} }
// Zugewiesenen Mitarbeiter benachrichtigen // Zugewiesenen Mitarbeiter benachrichtigen
if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) {
// Bearbeiter im Urlaub: die Vertretung bekommt die Mail
$escTo = vacation_expand_ids($pdo, [(int) $ticket['assigned_staff_id']]);
$ticket['staff_email'] = $escTo ? (staff_emails_by_ids($pdo, [$escTo[0]])[0] ?? '') : '';
}
if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) { if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) {
$subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}"; $subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}";
$inner = email_p('Die Eskalationsregel <strong>' . e($rule['name']) . '</strong> wurde für folgendes Ticket ausgelöst:') $inner = email_p('Die Eskalationsregel <strong>' . e($rule['name']) . '</strong> wurde für folgendes Ticket ausgelöst:')
+17 -12
View File
@@ -32,6 +32,7 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
"SELECT t.*, d.email AS department_email, d.name AS department_name FROM tickets t "SELECT t.*, d.email AS department_email, d.name AS department_name FROM tickets t
JOIN departments d ON d.id = t.department_id JOIN departments d ON d.id = t.department_id
WHERE t.status NOT IN ('resolved','closed') WHERE t.status NOT IN ('resolved','closed')
AND t.sla_paused_at IS NULL
AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at IS NOT NULL
AND t.resolution_due_at < ?" AND t.resolution_due_at < ?"
); );
@@ -43,18 +44,12 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
foreach ($overdue as $t) { foreach ($overdue as $t) {
if (!$t['is_overdue']) { if (!$t['is_overdue']) {
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$t['id']]); $pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$t['id']]);
ticket_sla_log_overdue($pdo, (int) $t['id'], 'resolution', (string) $t['resolution_due_at']);
log_activity($pdo, $t['id'], null, 'SLA überschritten – Ticket als überfällig markiert'); log_activity($pdo, $t['id'], null, 'SLA überschritten – Ticket als überfällig markiert');
if ($t['department_email']) { // Nur die Betroffenen bekommen eine Mail (Bearbeiter bzw. Abteilung), nicht die Postfach-Adresse der Abteilung und nicht alle Admins
$subject = "SLA überschritten: Ticket #{$t['ticket_number']}"; notify_overdue_ticket_staff($t);
$inner = email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
. email_badge('Ticket', '#' . $t['ticket_number'], '#b91c1c', '#fee2e2')
. email_p('<strong>Betreff:</strong> ' . e($t['subject']), '24px')
. email_button('Ticket ansehen', e(base_url() . '/staff/ticket_view.php?n=' . urlencode($t['ticket_number'])), '#dc2626');
email_send_and_log($t['department_email'], $subject, email_wrap($inner), null, 'SLA-überschritten-Mail (Abteilung)', $t['ticket_number']);
}
notify_admins_overdue_ticket($t);
fcm_notify_overdue($t); // Push auf die Android-App fcm_notify_overdue($t); // Push auf die Android-App
$newlyOverdue++; $newlyOverdue++;
if ($verbose) { if ($verbose) {
@@ -63,14 +58,24 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
} }
} }
$responseStmt = $pdo->prepare("SELECT id, first_response_due_at FROM tickets
WHERE first_responded_at IS NULL AND first_response_due_at IS NOT NULL AND first_response_due_at < ?
AND sla_paused_at IS NULL AND status NOT IN ('resolved','closed')");
$responseStmt->execute([$now]);
foreach ($responseStmt->fetchAll() as $ticket) ticket_sla_log_overdue($pdo, (int) $ticket['id'], 'first_response', (string) $ticket['first_response_due_at']);
// Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen // Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen
$pdo->exec("UPDATE tickets SET is_overdue = 0 WHERE status IN ('resolved','closed') AND is_overdue = 1"); $pdo->exec("UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE status IN ('resolved','closed') AND is_overdue = 1");
// Erinnerung an Bearbeiter: Ticket wartet seit mehreren Arbeitsstunden auf Antwort, bevor die Frist reißt
$waitReminders = wait_reminders_run($pdo, $now);
foreach ($waitReminders['log'] as $line) $log[] = $line;
set_setting('cron_check_sla_last_run', $now); set_setting('cron_check_sla_last_run', $now);
$log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt."; $log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt.";
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'log' => $log]; return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'waitReminders' => $waitReminders['sent'], 'log' => $log];
} }
if (php_sapi_name() === 'cli') { if (php_sapi_name() === 'cli') {
+30 -8
View File
@@ -28,6 +28,7 @@ if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_respons
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/mail_text.php'; require_once __DIR__ . '/../includes/mail_text.php';
require_once __DIR__ . '/../includes/mail_threading.php';
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */ /** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
@@ -175,6 +176,14 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
// Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar. // Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar.
$sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder']; $sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder'];
// Mails, die DeskHub selbst verschickt hat (zum Beispiel SLA-Meldungen an eine Postfach-Adresse), dürfen keine Tickets erzeugen
if (mail_is_own_system_mail($pdo, (string) $fromEmail, (string) @imap_fetchheader($mbox, $emailNum))) {
$log[] = "[$label] Mail von {$fromEmail} ignoriert (eigene Systemmail, würde sonst ein Ticket erzeugen): {$subject}";
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++;
continue;
}
// Nachrichtentext: Text-Teil bevorzugt, bei reinen HTML-Mails (Newsletter) nur der sichtbare Text ohne CSS und Skripte // Nachrichtentext: Text-Teil bevorzugt, bei reinen HTML-Mails (Newsletter) nur der sichtbare Text ohne CSS und Skripte
$structure = @imap_fetchstructure($mbox, $emailNum); $structure = @imap_fetchstructure($mbox, $emailNum);
$body = $structure ? mail_best_text($structure, fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no)) : ''; $body = $structure ? mail_best_text($structure, fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no)) : '';
@@ -186,20 +195,27 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
if (trim($body) === '') $body = '(kein Inhalt)'; if (trim($body) === '') $body = '(kein Inhalt)';
// ── Antwort auf bestehendes Ticket? ────────────────────────────────── // ── Antwort auf bestehendes Ticket? ──────────────────────────────────
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) { // Zuordnung über die Nummer im Betreff, eine andere Nummer (früheres Ticketsystem) oder die Kopfzeilen In-Reply-To/References
$ticketNumber = $m[1]; $replyMatch = mail_find_ticket_for_reply($pdo, $subject, (string) @imap_fetchheader($mbox, $emailNum), (string) $fromEmail);
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?'); if ($replyMatch) {
$stmt->execute([$ticketNumber]); $ticket = $replyMatch['ticket'];
$ticket = $stmt->fetch(); $ticketNumber = (string) $ticket['ticket_number'];
$replyVia = ['subject' => '', 'legacy' => ' – zugeordnet über die Nummer im Betreff', 'header' => ' – zugeordnet über die Mail-Kopfzeilen'][$replyMatch['via']] ?? '';
if ($ticket) { if ($ticket) {
$now = db_now(); $now = db_now();
$pdo->prepare( $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)' 'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->execute([$ticket['id'], 'customer', $body, $now]); )->execute([$ticket['id'], 'customer', $body, $now]);
$replyThreadId = (int) $pdo->lastInsertId();
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $replyThreadId]);
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $replyThreadId, (int) $ticket['id'], $label, $log, false, $htmlBody !== null);
$newStatus = in_array($ticket['status'], ['answered', 'resolved']) ? 'open' : $ticket['status']; $newStatus = in_array($ticket['status'], ['answered', 'resolved', 'pending']) ? 'open' : $ticket['status'];
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]); $pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus);
log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen'); log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen');
notify_new_reply_channels($ticket, $body); notify_new_reply_channels($ticket, $body);
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']); webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']);
@@ -207,7 +223,7 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)' 'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
)->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]); )->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]);
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail})."; $log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}){$replyVia}.";
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++; $processed++;
continue; continue;
@@ -294,6 +310,11 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
$pdo->prepare( $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)' 'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->execute([$ticketId, 'customer', $body, $now]); )->execute([$ticketId, 'customer', $body, $now]);
$firstThreadId = (int) $pdo->lastInsertId();
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $firstThreadId]);
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $firstThreadId, $ticketId, $label, $log, false, $htmlBody !== null);
log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt'); log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt');
$log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail})."; $log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail}).";
@@ -356,7 +377,8 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
return ['processed' => $processed, 'log' => $log]; return ['processed' => $processed, 'log' => $log];
} }
if (php_sapi_name() === 'cli') { // Nur starten, wenn diese Datei selbst aufgerufen wird (Cronjob), nicht wenn sie von einer anderen Datei eingebunden wird (Abruf von Hand, Tests)
if (php_sapi_name() === 'cli' && isset($_SERVER['argv'][0]) && realpath((string) $_SERVER['argv'][0]) === realpath(__FILE__)) {
$result = run_fetch_emails(get_db(), true); $result = run_fetch_emails(get_db(), true);
foreach ($result['log'] as $line) { foreach ($result['log'] as $line) {
echo $line . "\n"; echo $line . "\n";
@@ -0,0 +1,24 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/process_mail_queue.php
*
* Versucht Mails erneut zu senden, die wegen einer Störung (SMTP-Server nicht erreichbar, falscher Port …) nicht rausgingen.
* Läuft alle paar Minuten, z. B. per Cronjob:
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/process_mail_queue.php
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, failed:int, retry:int, gave_up:int, log:string[]} */
function run_process_mail_queue(PDO $pdo, bool $verbose = false): array
{
$r = mail_queue_run($pdo);
set_setting('cron_process_mail_queue_last_run', db_now());
$r['log'] = ['[' . date('Y-m-d H:i:s') . "] Mail-Warteschlange: {$r['sent']} gesendet, {$r['retry']} später erneut, {$r['gave_up']} aufgegeben."];
return $r;
}
if (php_sapi_name() === 'cli') {
foreach (run_process_mail_queue(get_db(), true)['log'] as $line) echo $line . "\n";
}
+3
View File
@@ -29,6 +29,9 @@ function run_plugins_cron(PDO $pdo, bool $verbose = false): array
} }
if (php_sapi_name() === 'cli') { if (php_sapi_name() === 'cli') {
// Der echte Cronjob lebt: immer vermerken, auch wenn der Lauf unten übersprungen wird (bei viel Besuch hat meist schon der eingebaute
// Hintergrund-Betrieb die Minute belegt; sonst stünde in der Plugin-Anzeige dauerhaft „bisher kein Lauf“)
set_setting('cron_run_plugins_last_cli', db_now());
// Läuft zeitgleich der eingebaute Hintergrund-Betrieb, nicht doppelt ausführen (gleiche Sperre wie dort) // Läuft zeitgleich der eingebaute Hintergrund-Betrieb, nicht doppelt ausführen (gleiche Sperre wie dort)
if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim(get_db(), 'cron_run_plugins_last_run', 50)) { if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim(get_db(), 'cron_run_plugins_last_run', 50)) {
foreach (run_plugins_cron(get_db(), true)['log'] as $line) echo $line . "\n"; foreach (run_plugins_cron(get_db(), true)['log'] as $line) echo $line . "\n";
@@ -0,0 +1,22 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/send_report_schedules.php
*
* Versendet gespeicherte Berichte nach Zeitplan per E-Mail (Bericht-Baukasten → „Versand per E-Mail“). Läuft alle paar Minuten, z. B.:
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_report_schedules.php
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, failed:int, log:string[]} */
function run_send_report_schedules(PDO $pdo, bool $verbose = false): array
{
$r = report_schedules_run_due($pdo);
set_setting('cron_send_report_schedules_last_run', db_now());
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Berichte per E-Mail: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
}
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
foreach (run_send_report_schedules(get_db(), true)['log'] as $line) echo $line . "\n";
}
@@ -0,0 +1,22 @@
<?php
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/send_scheduled_replies.php
*
* Sendet zeitversetzt geplante Antworten, sobald ihr Zeitpunkt erreicht ist. Läuft jede Minute, z. B. per Cronjob:
* * * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_scheduled_replies.php
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, failed:int, log:string[]} */
function run_send_scheduled_replies(PDO $pdo, bool $verbose = false): array
{
$r = scheduled_replies_run_due($pdo);
set_setting('cron_send_scheduled_replies_last_run', db_now());
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Geplante Antworten: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
}
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
foreach (run_send_scheduled_replies(get_db(), true)['log'] as $line) echo $line . "\n";
}
+579
View File
@@ -1542,6 +1542,255 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
} }
} }
// Migration 0055: Status-Komponenten lassen sich für Anzeige und Prüfungen aktivieren/deaktivieren.
if (!isset($done['php_0055_status_component_enabled'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$hasEnabled = false;
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_enabled') $hasEnabled = true;
if (!$hasEnabled) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_enabled TINYINT NOT NULL DEFAULT 1');
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryStmt->execute(['Apps']);
$gameCategoryId = $categoryStmt->fetchColumn();
if (!$gameCategoryId) {
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
$gameCategoryId = (int) $pdo->lastInsertId();
}
$minecraftStmt = $pdo->query("SELECT id, category_id, category FROM status_components WHERE LOWER(name) LIKE '%minecraft%' ORDER BY id LIMIT 1");
$minecraft = $minecraftStmt->fetch(PDO::FETCH_ASSOC);
if (!$minecraft) {
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
$insert->execute(['Minecraft-Server', 'Minecraft Java – IP-Adresse oder Domain und Port eintragen.', 'operational', $sortOrder, db_now(), (int) $gameCategoryId, 'tcp']);
} elseif (empty($minecraft['category_id']) && trim((string) ($minecraft['category'] ?? '')) === '') {
$pdo->prepare('UPDATE status_components SET category_id = ? WHERE id = ?')->execute([(int) $gameCategoryId, (int) $minecraft['id']]);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0055_status_component_enabled']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0055_status_component_enabled';
} catch (Throwable $e) {
$result['errors'][] = 'php_0055_status_component_enabled: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0055_status_component_enabled - ' . $e->getMessage();
}
}
// Migration 0056: Minecraft-Statusdienst der Kategorie „Apps“ zuordnen.
if (!isset($done['php_0056_minecraft_status_category'])) {
try {
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryStmt->execute(['Apps']);
$gameCategoryId = $categoryStmt->fetchColumn();
if (!$gameCategoryId) {
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
$gameCategoryId = (int) $pdo->lastInsertId();
}
$pdo->prepare("UPDATE status_components SET category_id = ? WHERE LOWER(name) LIKE '%minecraft%' AND category_id IS NULL AND (category IS NULL OR TRIM(category) = '')")
->execute([(int) $gameCategoryId]);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0056_minecraft_status_category']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0056_minecraft_status_category';
} catch (Throwable $e) {
$result['errors'][] = 'php_0056_minecraft_status_category: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0056_minecraft_status_category - ' . $e->getMessage();
}
}
// Migration 0057: Minecraft-Dienst unter „Apps“ einsortieren.
if (!isset($done['php_0057_minecraft_status_apps_category'])) {
try {
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryStmt->execute(['Apps']);
$appCategoryId = $categoryStmt->fetchColumn();
if (!$appCategoryId) {
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
$appCategoryId = (int) $pdo->lastInsertId();
}
$pdo->prepare("UPDATE status_components SET category_id = ?, category = NULL WHERE LOWER(name) LIKE '%minecraft%'")
->execute([(int) $appCategoryId]);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0057_minecraft_status_apps_category']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0057_minecraft_status_apps_category';
} catch (Throwable $e) {
$result['errors'][] = 'php_0057_minecraft_status_apps_category: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0057_minecraft_status_apps_category - ' . $e->getMessage();
}
}
// Migration 0059: Optionalen Backup-Server für die Statusseite anlegen.
if (!isset($done['php_0059_backup_server_status_component'])) {
try {
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryStmt->execute(['Server & Datenbank']);
$categoryId = $categoryStmt->fetchColumn();
if (!$categoryId) {
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Server & Datenbank', $categoryOrder]);
$categoryId = (int) $pdo->lastInsertId();
}
$existing = $pdo->query("SELECT id FROM status_components WHERE LOWER(name) IN ('backup-server', 'backup server', 'ftp-backup-server') LIMIT 1")->fetchColumn();
if (!$existing) {
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
$insert->execute(['Backup-Server', 'Optionaler externer Backup-Server; Prüfung mit Host und Port.', 'operational', $sortOrder, db_now(), (int) $categoryId, 'tcp']);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0059_backup_server_status_component']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0059_backup_server_status_component';
} catch (Throwable $e) {
$result['errors'][] = 'php_0059_backup_server_status_component: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0059_backup_server_status_component - ' . $e->getMessage();
}
}
// Migration 0060: Geplante Wartungsfenster der Statusseite.
if (!isset($done['php_0060_status_maintenance_windows'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS status_maintenance_windows (
id $ai,
title VARCHAR(200) NOT NULL,
body TEXT NULL,
component_id INT NULL,
starts_at DATETIME NOT NULL,
ends_at DATETIME NOT NULL,
created_by INT NULL,
created_at DATETIME NOT NULL,
cancelled_at DATETIME NULL
)$eng");
$pdo->exec('CREATE INDEX idx_status_maintenance_window_time ON status_maintenance_windows (starts_at, ends_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0060_status_maintenance_windows']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0060_status_maintenance_windows';
} catch (Throwable $e) {
$result['errors'][] = 'php_0060_status_maintenance_windows: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0060_status_maintenance_windows - ' . $e->getMessage();
}
}
// Migration 0061: Prüfintervall je Statusdienst konfigurierbar machen.
if (!isset($done['php_0061_status_check_intervals'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$hasInterval = false;
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'check_interval_minutes') $hasInterval = true;
if (!$hasInterval) $pdo->exec('ALTER TABLE status_components ADD COLUMN check_interval_minutes INT NOT NULL DEFAULT 1');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0061_status_check_intervals']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0061_status_check_intervals';
} catch (Throwable $e) {
$result['errors'][] = 'php_0061_status_check_intervals: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0061_status_check_intervals - ' . $e->getMessage();
}
}
// Migration 0063: SLA-Wartepausen und Ereignisverlauf.
if (!isset($done['php_0063_ticket_sla_pause_history'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
$hasPauseColumn = false;
foreach ($cols as $col) if ((string) ($col['name'] ?? $col['Field'] ?? '') === 'sla_paused_at') $hasPauseColumn = true;
if (!$hasPauseColumn) $pdo->exec('ALTER TABLE tickets ADD COLUMN sla_paused_at DATETIME NULL');
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_sla_history (
id $ai, ticket_id INT NOT NULL, event_type VARCHAR(24) NOT NULL,
deadline_type VARCHAR(24) NULL, details TEXT NULL, staff_id INT NULL, created_at DATETIME NOT NULL
)$eng");
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(ticket_sla_history)' : 'SHOW INDEX FROM ticket_sla_history')->fetchAll();
$hasIndex = false;
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_ticket_sla_history_ticket_time') $hasIndex = true;
if (!$hasIndex) $pdo->exec('CREATE INDEX idx_ticket_sla_history_ticket_time ON ticket_sla_history (ticket_id, created_at)');
foreach ([['first_response_due_at', 'first_response'], ['resolution_due_at', 'resolution']] as [$dueColumn, $type]) {
$sql = "INSERT INTO ticket_sla_history (ticket_id, event_type, deadline_type, details, created_at)
SELECT t.id, 'started', ?, ?, t.created_at FROM tickets t
WHERE t.$dueColumn IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id = t.id AND h.event_type = 'started' AND h.deadline_type = ?
)";
$pdo->prepare($sql)->execute([$type, 'SLA gestartet; Fristfeld: ' . $dueColumn, $type]);
}
$now = db_now();
$pdo->prepare("UPDATE tickets SET sla_paused_at = ? WHERE sla_paused_at IS NULL AND status IN ('pending','on_hold')")->execute([$now]);
$pdo->prepare("INSERT INTO ticket_sla_history (ticket_id, event_type, details, created_at)
SELECT id, 'paused', 'SLA-Pause bei Aktivierung uebernommen', ? FROM tickets
WHERE status IN ('pending','on_hold') AND sla_paused_at = ?")->execute([$now, $now]);
$pdo->exec("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
SELECT t.id,'overdue','resolution',NULL,t.resolution_due_at FROM tickets t
WHERE t.is_overdue = 1 AND t.resolution_due_at IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='resolution'
)");
$backfillResponse = $pdo->prepare("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
SELECT t.id,'overdue','first_response',NULL,t.first_response_due_at FROM tickets t
WHERE t.first_responded_at IS NULL AND t.first_response_due_at IS NOT NULL AND t.first_response_due_at < ?
AND t.status NOT IN ('pending','on_hold','resolved','closed') AND NOT EXISTS (
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='first_response'
)");
$backfillResponse->execute([$now]);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0063_ticket_sla_pause_history']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0063_ticket_sla_pause_history';
} catch (Throwable $e) {
$result['errors'][] = 'php_0063_ticket_sla_pause_history: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0063_ticket_sla_pause_history - ' . $e->getMessage();
}
}
// Migration 0062: Problemmanagement und dokumentierte Ticket-Abschlussgründe.
if (!isset($done['php_0062_problems_and_resolution_codes'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$ticketCols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
$has = [];
foreach ($ticketCols as $col) $has[] = (string) ($col['name'] ?? $col['Field'] ?? '');
if (!in_array('resolution_code', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_code VARCHAR(40) NULL');
if (!in_array('resolution_note', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_note TEXT NULL');
$pdo->exec("CREATE TABLE IF NOT EXISTS problems (
id $ai,
title VARCHAR(200) NOT NULL,
description TEXT NULL,
root_cause TEXT NULL,
workaround TEXT NULL,
resolution TEXT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'investigating',
created_by INT NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
resolved_at DATETIME NULL
)$eng");
$pdo->exec("CREATE TABLE IF NOT EXISTS problem_tickets (
problem_id INT NOT NULL,
ticket_id INT NOT NULL,
linked_by INT NULL,
linked_at DATETIME NOT NULL,
PRIMARY KEY (problem_id, ticket_id)
)$eng");
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problem_tickets)' : 'SHOW INDEX FROM problem_tickets')->fetchAll();
$ticketIndexExists = false;
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problem_tickets_ticket') $ticketIndexExists = true;
if (!$ticketIndexExists) $pdo->exec('CREATE INDEX idx_problem_tickets_ticket ON problem_tickets (ticket_id)');
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problems)' : 'SHOW INDEX FROM problems')->fetchAll();
$problemIndexExists = false;
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problems_status_updated') $problemIndexExists = true;
if (!$problemIndexExists) $pdo->exec('CREATE INDEX idx_problems_status_updated ON problems (status, updated_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0062_problems_and_resolution_codes']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0062_problems_and_resolution_codes';
} catch (Throwable $e) {
$result['errors'][] = 'php_0062_problems_and_resolution_codes: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0062_problems_and_resolution_codes - ' . $e->getMessage();
}
}
// Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit). // Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit).
if (!isset($done['php_0049_i18n_missing'])) { if (!isset($done['php_0049_i18n_missing'])) {
try { try {
@@ -1571,6 +1820,336 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
} }
} }
// Warteschlange für ausgehende Mails: Fehlgeschlagene Mails werden später erneut versucht
if (!isset($done['php_0064_mail_queue'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$long = $drv === 'sqlite' ? 'TEXT' : 'MEDIUMTEXT';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS mail_queue (
id $ai,
to_email VARCHAR(255) NOT NULL,
from_header VARCHAR(255) NULL,
subject VARCHAR(500) NOT NULL,
body $long NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_attempt_at DATETIME NOT NULL,
last_error VARCHAR(400) NULL,
created_at DATETIME NOT NULL,
sent_at DATETIME NULL
)$eng");
$pdo->exec('CREATE INDEX idx_mail_queue_due ON mail_queue (status, next_attempt_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0064_mail_queue']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0064_mail_queue';
} catch (Throwable $e) {
$result['errors'][] = 'php_0064_mail_queue: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0064_mail_queue - ' . $e->getMessage();
}
}
// Anmelde-Wächter: Protokoll der Anmeldungen sowie gemerkte Geräte und Orte je Mitarbeiter
if (!isset($done['php_0065_login_guard'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS login_events (
id $ai,
staff_id INT NULL,
email VARCHAR(255) NOT NULL DEFAULT '',
event VARCHAR(20) NOT NULL,
channel VARCHAR(10) NOT NULL DEFAULT 'web',
ip VARCHAR(64) NOT NULL DEFAULT '',
place VARCHAR(100) NOT NULL DEFAULT '',
device VARCHAR(160) NOT NULL DEFAULT '',
detail VARCHAR(255) NOT NULL DEFAULT '',
token_hash VARCHAR(64) NULL,
token_used TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)$eng");
$pdo->exec('CREATE INDEX idx_login_events_created ON login_events (created_at)');
$pdo->exec('CREATE INDEX idx_login_events_staff ON login_events (staff_id, created_at)');
$pdo->exec('CREATE INDEX idx_login_events_token ON login_events (token_hash)');
$pdo->exec("CREATE TABLE IF NOT EXISTS login_devices (
id $ai,
staff_id INT NOT NULL,
device_hash VARCHAR(64) NOT NULL,
label VARCHAR(160) NOT NULL DEFAULT '',
channel VARCHAR(10) NOT NULL DEFAULT 'web',
first_ip VARCHAR(64) NOT NULL DEFAULT '',
last_ip VARCHAR(64) NOT NULL DEFAULT '',
place VARCHAR(100) NOT NULL DEFAULT '',
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
UNIQUE (staff_id, device_hash)
)$eng");
$pdo->exec("CREATE TABLE IF NOT EXISTS login_places (
id $ai,
staff_id INT NOT NULL,
place_key VARCHAR(80) NOT NULL,
label VARCHAR(100) NOT NULL DEFAULT '',
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
UNIQUE (staff_id, place_key)
)$eng");
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0065_login_guard']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0065_login_guard';
} catch (Throwable $e) {
$result['errors'][] = 'php_0065_login_guard: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0065_login_guard - ' . $e->getMessage();
}
}
// Abwesenheit im Profil: Welche Tickets an die Vertretung weitergegeben wurden (für die Rückgabe nach dem Urlaub)
if (!isset($done['php_0066_vacation_moves'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS vacation_moves (
id $ai,
ticket_id INT NOT NULL,
from_staff_id INT NOT NULL,
to_staff_id INT NOT NULL,
created_at DATETIME NOT NULL,
returned_at DATETIME NULL
)$eng");
$pdo->exec('CREATE INDEX idx_vacation_moves_from ON vacation_moves (from_staff_id, returned_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0066_vacation_moves']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0066_vacation_moves';
} catch (Throwable $e) {
$result['errors'][] = 'php_0066_vacation_moves: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0066_vacation_moves - ' . $e->getMessage();
}
}
// Reparatur: Entwarnungs-Notizen der öffentlichen API waren doppelt maskiert (&quot; statt ")
if (!isset($done['php_0067_fix_api_resolve_notes'])) {
try {
$rows = $pdo->query("SELECT t.id, t.message FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id
WHERE k.source = 'api' AND t.author_type = 'system' AND t.is_internal_note = 1
AND (t.message LIKE '%&quot;%' OR t.message LIKE '%&amp;%' OR t.message LIKE '%&#039;%')")->fetchAll();
$upd = $pdo->prepare('UPDATE ticket_threads SET message = ? WHERE id = ?');
foreach ($rows as $r) $upd->execute([html_entity_decode((string) $r['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $r['id']]);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0067_fix_api_resolve_notes']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0067_fix_api_resolve_notes (' . count($rows) . ' Notizen repariert)';
} catch (Throwable $e) {
$result['errors'][] = 'php_0067_fix_api_resolve_notes: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0067_fix_api_resolve_notes - ' . $e->getMessage();
}
}
// Indizes für die Übersicht (letzte Aktivität) und die Ticketliste (Sortierung nach letzter Bearbeitung)
if (!isset($done['php_0068_overview_indexes'])) {
try {
$hasIndex = function (string $table, string $name) use ($pdo): bool {
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? "PRAGMA index_list($table)" : "SHOW INDEX FROM $table")->fetchAll();
foreach ($rows as $r) if ((string) ($r['name'] ?? $r['Key_name'] ?? '') === $name) return true;
return false;
};
if (!$hasIndex('activity_log', 'idx_activity_log_created')) $pdo->exec('CREATE INDEX idx_activity_log_created ON activity_log (created_at)');
if (!$hasIndex('tickets', 'idx_tickets_updated')) $pdo->exec('CREATE INDEX idx_tickets_updated ON tickets (updated_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0068_overview_indexes']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0068_overview_indexes';
} catch (Throwable $e) {
$result['errors'][] = 'php_0068_overview_indexes: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0068_overview_indexes - ' . $e->getMessage();
}
}
// Statusseite: Dienste lassen sich als „intern“ markieren (werden geprüft, erscheinen aber nicht auf der öffentlichen Statusseite)
if (!isset($done['php_0069_status_component_internal'])) {
try {
$cols = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$has = false;
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_internal') $has = true;
if (!$has) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_internal TINYINT NOT NULL DEFAULT 0');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0069_status_component_internal']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0069_status_component_internal';
} catch (Throwable $e) {
$result['errors'][] = 'php_0069_status_component_internal: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0069_status_component_internal - ' . $e->getMessage();
}
}
// E-Mails mit Formatierung und eingebetteten Bildern: HTML-Fassung der Nachricht, Content-ID und Kennzeichnung eingebetteter Bilder
if (!isset($done['php_0070_mail_html'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$columns = function (string $table) use ($pdo, $drv): array {
$rows = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll();
return array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $rows);
};
if (!in_array('message_html', $columns('ticket_threads'), true)) $pdo->exec('ALTER TABLE ticket_threads ADD COLUMN message_html MEDIUMTEXT NULL');
$att = $columns('ticket_attachments');
if (!in_array('content_id', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN content_id VARCHAR(255) NULL');
if (!in_array('is_inline', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN is_inline TINYINT NOT NULL DEFAULT 0');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0070_mail_html']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0070_mail_html';
} catch (Throwable $e) {
$result['errors'][] = 'php_0070_mail_html: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0070_mail_html - ' . $e->getMessage();
}
}
// Migration 0071: verknüpfte Tickets, zeitversetzt gesendete Antworten und Berichte per E-Mail nach Zeitplan.
if (!isset($done['php_0071_links_schedules'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_links (
id $ai,
ticket_id INT NOT NULL,
linked_ticket_id INT NOT NULL,
link_type VARCHAR(20) NOT NULL DEFAULT 'related',
created_by INT NULL,
created_at DATETIME NULL,
UNIQUE (ticket_id, linked_ticket_id)
)$eng");
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_replies (
id $ai,
ticket_id INT NOT NULL,
staff_id INT NOT NULL,
message MEDIUMTEXT NOT NULL,
set_status VARCHAR(20) NOT NULL DEFAULT 'answered',
resolution_code VARCHAR(40) NULL,
resolution_note TEXT NULL,
signature_choice VARCHAR(40) NULL,
send_at DATETIME NOT NULL,
state VARCHAR(12) NOT NULL DEFAULT 'pending',
error TEXT NULL,
created_at DATETIME NULL,
sent_at DATETIME NULL
)$eng");
$pdo->exec("CREATE TABLE IF NOT EXISTS report_schedules (
id $ai,
report_id INT NOT NULL,
staff_id INT NOT NULL,
recipients TEXT NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'weekly',
weekday TINYINT NOT NULL DEFAULT 1,
monthday TINYINT NOT NULL DEFAULT 1,
send_time VARCHAR(5) NOT NULL DEFAULT '08:00',
period VARCHAR(12) NOT NULL DEFAULT 'last_7',
enabled TINYINT(1) NOT NULL DEFAULT 1,
next_run_at DATETIME NULL,
last_sent_at DATETIME NULL,
last_error TEXT NULL,
created_at DATETIME NULL
)$eng");
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0071_links_schedules']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0071_links_schedules';
} catch (Throwable $e) {
$result['errors'][] = 'php_0071_links_schedules: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0071_links_schedules - ' . $e->getMessage();
}
}
// Migration 0072: Merker „Mitarbeiter hat das Ticket gesehen“ für den Zähler in Chrome-Erweiterung und Windows-Programm.
if (!isset($done['php_0072_ticket_acks'])) {
try {
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_acks (
staff_id INT NOT NULL,
ticket_id INT NOT NULL,
acked_at DATETIME NOT NULL,
PRIMARY KEY (staff_id, ticket_id)
)" . ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'));
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0072_ticket_acks']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0072_ticket_acks';
} catch (Throwable $e) {
$result['errors'][] = 'php_0072_ticket_acks: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0072_ticket_acks - ' . $e->getMessage();
}
}
// Migration 0073: weniger Berichts-Mails: nur bei Daten, nur bei Änderung, bei „täglich“ nur an Werktagen.
if (!isset($done['php_0073_report_schedule_quiet'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(report_schedules)' : 'SHOW COLUMNS FROM report_schedules')->fetchAll());
foreach (['only_if_data' => 'TINYINT NOT NULL DEFAULT 1', 'skip_unchanged' => 'TINYINT NOT NULL DEFAULT 1', 'weekdays_only' => 'TINYINT NOT NULL DEFAULT 0', 'last_hash' => 'VARCHAR(64) NULL', 'last_note' => 'VARCHAR(190) NULL'] as $col => $def) {
if (!in_array($col, $cols, true)) $pdo->exec("ALTER TABLE report_schedules ADD COLUMN {$col} {$def}");
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0073_report_schedule_quiet']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0073_report_schedule_quiet';
} catch (Throwable $e) {
$result['errors'][] = 'php_0073_report_schedule_quiet: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0073_report_schedule_quiet - ' . $e->getMessage();
}
}
// Migration 0074: Merker für die Erinnerung „Ticket wartet auf Antwort“ (eine Erinnerung je Kundennachricht).
if (!isset($done['php_0074_wait_reminder'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll());
if (!in_array('wait_reminded_at', $cols, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN wait_reminded_at DATETIME NULL');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0074_wait_reminder']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0074_wait_reminder';
} catch (Throwable $e) {
$result['errors'][] = 'php_0074_wait_reminder: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0074_wait_reminder - ' . $e->getMessage();
}
}
// Migration 0075: Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter.
if (!isset($done['php_0075_staff_passkeys'])) {
try {
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$ai = $sqlite ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_passkeys (
id {$ai},
staff_id INT NOT NULL,
credential_id VARCHAR(512) NOT NULL,
public_key TEXT NOT NULL,
alg INT NOT NULL DEFAULT -7,
sign_count BIGINT NOT NULL DEFAULT 0,
name VARCHAR(80) NOT NULL,
created_at DATETIME NOT NULL,
last_used_at DATETIME NULL
)" . ($sqlite ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'));
$pdo->exec($sqlite ? 'CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_passkeys_cred ON staff_passkeys (credential_id)' : 'CREATE UNIQUE INDEX idx_staff_passkeys_cred ON staff_passkeys (credential_id(190))');
$pdo->exec('CREATE INDEX idx_staff_passkeys_staff ON staff_passkeys (staff_id)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0075_staff_passkeys']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0075_staff_passkeys';
} catch (Throwable $e) {
$result['errors'][] = 'php_0075_staff_passkeys: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0075_staff_passkeys - ' . $e->getMessage();
}
}
// Migration 0076: vordefinierte Rollen (Co-Administrator, Support-Manager, IT, Teamleitung, Support 1st/2nd Level, …). Vorhandene Rollen bleiben unberührt.
if (!isset($done['php_0076_default_roles'])) {
try {
require_once __DIR__ . '/../includes/permissions.php';
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(roles)' : 'SHOW COLUMNS FROM roles')->fetchAll());
if (in_array('is_active', $cols, true) && in_array('notes', $cols, true) && in_array('updated_at', $cols, true)) {
permission_seed_default_roles($pdo);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0076_default_roles']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0076_default_roles';
}
} catch (Throwable $e) {
$result['errors'][] = 'php_0076_default_roles: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0076_default_roles - ' . $e->getMessage();
}
}
return $result; return $result;
} }
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
+3 -1
View File
@@ -16,6 +16,8 @@ function app_platforms(): array
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'], 'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'], 'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'], 'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'],
'wordpress' => ['label' => 'WordPress', 'ext' => 'zip', 'stored' => 'deskhub-tickets-wordpress.zip', 'download' => 'deskhub-tickets-wordpress.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
'chrome' => ['label' => 'Chrome', 'ext' => 'zip', 'stored' => 'deskhub-chrome-erweiterung.zip', 'download' => 'deskhub-chrome-erweiterung.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
]; ];
} }
@@ -27,7 +29,7 @@ function app_downloads_dir(): string
/** Official publisher release endpoint; the access key stays on the server. */ /** Official publisher release endpoint; the access key stays on the server. */
function app_publisher_endpoint(string $platform, bool $download = false): string function app_publisher_endpoint(string $platform, bool $download = false): string
{ {
if (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !function_exists('catalog_defaults')) return ''; if (!isset(app_platforms()[$platform]) || !function_exists('catalog_defaults')) return '';
$base = (string) (catalog_defaults()['plugins_url'] ?? ''); $base = (string) (catalog_defaults()['plugins_url'] ?? '');
$parts = parse_url($base); $parts = parse_url($base);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return ''; if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
+7
View File
@@ -52,7 +52,9 @@ function staff_login(array $staff): void
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['staff_id'] = $staff['id']; $_SESSION['staff_id'] = $staff['id'];
$_SESSION['staff_last_activity'] = time(); $_SESSION['staff_last_activity'] = time();
$_SESSION['staff_login_at'] = time();
staff_touch_activity(get_db(), (int) $staff['id']); staff_touch_activity(get_db(), (int) $staff['id']);
login_guard_success($staff, 'web');
} }
function staff_logout(): void function staff_logout(): void
@@ -77,6 +79,11 @@ function current_staff(): ?array
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1'); $stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$_SESSION['staff_id']]); $stmt->execute([$_SESSION['staff_id']]);
$staff = $stmt->fetch(); $staff = $stmt->fetch();
// „Das war ich nicht“ (Anmelde-Wächter): ältere Browser-Sitzungen dieses Kontos sind ungültig
if ($staff && login_guard_session_revoked((int) $staff['id'])) {
unset($_SESSION['staff_id']);
return null;
}
return $cache = ($staff ?: null); return $cache = ($staff ?: null);
} }
+14
View File
@@ -12,7 +12,11 @@ require_once __DIR__ . '/time_tracking.php';
require_once __DIR__ . '/pseudo_cron.php'; require_once __DIR__ . '/pseudo_cron.php';
require_once __DIR__ . '/auth.php'; require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/smtp_mailer.php'; require_once __DIR__ . '/smtp_mailer.php';
require_once __DIR__ . '/mail_queue.php';
require_once __DIR__ . '/login_guard.php';
require_once __DIR__ . '/vacation.php';
require_once __DIR__ . '/mailer.php'; require_once __DIR__ . '/mailer.php';
require_once __DIR__ . '/mail_threading.php';
require_once __DIR__ . '/email_addresses.php'; require_once __DIR__ . '/email_addresses.php';
require_once __DIR__ . '/health.php'; require_once __DIR__ . '/health.php';
require_once __DIR__ . '/admin_models.php'; require_once __DIR__ . '/admin_models.php';
@@ -27,8 +31,18 @@ require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/csat.php'; require_once __DIR__ . '/csat.php';
require_once __DIR__ . '/status_page.php'; require_once __DIR__ . '/status_page.php';
require_once __DIR__ . '/ticket_templates.php'; require_once __DIR__ . '/ticket_templates.php';
require_once __DIR__ . '/problems.php';
require_once __DIR__ . '/ticket_links.php';
require_once __DIR__ . '/ticket_attention.php';
require_once __DIR__ . '/wait_reminders.php';
require_once __DIR__ . '/my_week.php';
require_once __DIR__ . '/signout.php';
require_once __DIR__ . '/scheduled_replies.php';
require_once __DIR__ . '/report_schedules.php';
require_once __DIR__ . '/gdpr.php';
require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php'; require_once __DIR__ . '/app_downloads.php';
require_once __DIR__ . '/permissions.php';
require_once __DIR__ . '/theme_catalog.php'; require_once __DIR__ . '/theme_catalog.php';
require_once __DIR__ . '/security_check.php'; require_once __DIR__ . '/security_check.php';
require_once __DIR__ . '/update_security.php'; require_once __DIR__ . '/update_security.php';
+1 -1
View File
@@ -76,7 +76,7 @@ function csat_mail_html(array $ticket, string $token): string
for ($i = 1; $i <= 5; $i++) { for ($i = 1; $i <= 5; $i++) {
$stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>'; $stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>';
} }
return '<h2 style="margin:0 0 12px;font-size:20px;color:#16202a">Wie zufrieden waren Sie?</h2>' return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Wie zufrieden waren Sie?</h2>'
. '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '') . '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
. 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. ' . 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. '
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>' . 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>'
+4
View File
@@ -15,6 +15,10 @@ function config(): array
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) { if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $extra); $config = array_replace_recursive($config, $extra);
} }
// Nur für Tests: Datenbankangaben (zum Beispiel eine MySQL-Testdatenbank) überschreiben alles andere, auch wenn der Test selbst DESKHUB_CONFIG_JSON setzt
if (getenv('DESKHUB_TESTING') === '1' && ($testDbJson = json_decode((string) getenv('DESKHUB_TEST_DB_JSON'), true)) && is_array($testDbJson)) {
$config['db'] = array_replace($config['db'] ?? [], $testDbJson);
}
// Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank // Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank
// (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei. // (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei.
if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') { if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') {
@@ -188,7 +188,7 @@ function email_address_send_test(PDO $pdo, int $id, string $recipient): string
$from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email']; $from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email'];
$via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server'; $via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server';
$html = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>'; $html = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>';
if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from)) { if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from, false)) {
throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502); throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502);
} }
return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via})."; return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via}).";
+8 -4
View File
@@ -311,13 +311,16 @@ function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = nul
if (!$staffIds || !fcm_configured()) return; if (!$staffIds || !fcm_configured()) return;
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) { fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
$pdo = get_db(); $pdo = get_db();
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Meldung
$staffIds = vacation_expand_ids($pdo, $staffIds);
if (!$staffIds) return;
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas // Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
if ($departmentId !== null) { if ($departmentId !== null) {
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter); $staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
if (!$staffIds) return; if (!$staffIds) return;
} }
$in = implode(',', array_fill(0, count($staffIds), '?')); $in = implode(',', array_fill(0, count($staffIds), '?'));
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)"); $stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND s.is_on_vacation = 0 AND d.staff_id IN ($in)");
$stmt->execute($staffIds); $stmt->execute($staffIds);
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen // Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? '')))); $devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
@@ -371,7 +374,7 @@ function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */ /** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
function fcm_message(string $type, array $ticket): array function fcm_message(string $type, array $ticket): array
{ {
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket']; $titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf Antwort', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket'];
$title = $titles[$type] ?? 'DeskHub'; $title = $titles[$type] ?? 'DeskHub';
$number = (string) ($ticket['ticket_number'] ?? ''); $number = (string) ($ticket['ticket_number'] ?? '');
if (get_setting('fcm_show_details', '0') === '1') { if (get_setting('fcm_show_details', '0') === '1') {
@@ -431,7 +434,7 @@ function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null
function fcm_event_types(): array function fcm_event_types(): array
{ {
return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat', return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat',
'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert']; 'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf meine Antwort', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert'];
} }
/** /**
@@ -443,7 +446,7 @@ function fcm_event_types(): array
function fcm_event_types_for(array $staff): array function fcm_event_types_for(array $staff): array
{ {
$types = fcm_event_types(); $types = fcm_event_types();
if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['chat_new']); if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['ticket_wait'], $types['chat_new']);
if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']); if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']);
return $types; return $types;
} }
@@ -499,6 +502,7 @@ function fcm_wants(array $prefs, string $type): bool
function fcm_notify_overdue(array $ticket): void function fcm_notify_overdue(array $ticket): void
{ {
if (!fcm_configured() || empty($ticket['ticket_number'])) return; if (!fcm_configured() || empty($ticket['ticket_number'])) return;
if (function_exists('sla_notify_mode') && sla_notify_mode() === 'admins') return; // nur Administratoren und nur per E-Mail
$dept = (int) ($ticket['department_id'] ?? 0); $dept = (int) ($ticket['department_id'] ?? 0);
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0); $assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
$to = fcm_recipients_for_department(get_db(), $dept ?: null); $to = fcm_recipients_for_department(get_db(), $dept ?: null);
+424 -66
View File
@@ -222,6 +222,229 @@ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentI
]; ];
} }
function ticket_sla_is_paused_status(string $status): bool
{
return in_array($status, ['pending', 'on_hold'], true);
}
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
{
try {
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
}
}
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
function ticket_sla_working_seconds_between(string $from, string $to): int
{
$start = strtotime($from); $end = strtotime($to);
if ($start === false || $end === false || $end <= $start) return 0;
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
$zone = new DateTimeZone(date_default_timezone_get());
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
$seconds = 0; $iterations = 0;
while ($day <= $lastDay && $iterations++ < 36600) {
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
$seconds += max(0, min($end, $close) - max($start, $open));
}
$day = $day->modify('+1 day');
}
return $seconds;
}
function ticket_sla_add_business_seconds(string $from, int $seconds): string
{
if ($seconds <= 0) return $from;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) {
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
}
$zone = new DateTimeZone(date_default_timezone_get());
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
while ($seconds > 0 && $iterations++ < 36600) {
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
}
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
if ($cursor < $open) $cursor = $open;
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
$available = $close->getTimestamp() - $cursor->getTimestamp();
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
}
return $cursor->format('Y-m-d H:i:s');
}
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
{
if ($pauseSeconds <= 0) return $deadline;
if (get_setting('business_hours_enabled', '0') === '1') {
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
}
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
}
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
{
try {
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
$stmt->execute([$ticketId]);
$started = null; $total = 0;
foreach ($stmt->fetchAll() as $event) {
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
}
return $total;
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
}
/** Startet die SLA eines wieder geöffneten Tickets neu: neue Lösungsfrist ab jetzt, Überfällig-Markierung weg. */
function ticket_sla_restart(PDO $pdo, int $ticketId, ?int $staffId = null): void
{
$q = $pdo->prepare('SELECT t.priority_id, t.department_id, t.help_topic_id FROM tickets t WHERE t.id = ?');
$q->execute([$ticketId]);
$t = $q->fetch();
if (!$t) return;
$p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$p->execute([(int) $t['priority_id']]);
$priority = $p->fetch();
if (!$priority) { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); return; }
$now = db_now();
$dates = calc_sla_dates($priority, $now, (int) $t['department_id'] ?: null, (int) $t['help_topic_id'] ?: null);
$pdo->prepare('UPDATE tickets SET resolution_due_at = ?, is_overdue = 0, sla_paused_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$dates['resolution_due_at'], $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'restarted', 'resolution', 'SLA neu gestartet: Ticket wurde wieder geöffnet', $staffId, $now);
}
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
if ($oldStatus === $newStatus) return;
// Wieder geöffnet (zum Beispiel weil der Kunde auf ein gelöstes Ticket antwortet): Die alte Lösungsfrist ist längst abgelaufen und würde
// sofort „SLA überschritten“ auslösen. Die SLA beginnt deshalb neu, mit den Zeiten der Priorität bzw. des SLA-Plans ab jetzt.
$terminal = ['resolved', 'closed'];
if (in_array($oldStatus, $terminal, true) && !in_array($newStatus, $terminal, true)) {
ticket_sla_restart($pdo, $ticketId, $staffId);
}
// Erledigt (gelöst oder geschlossen) ist dasselbe: Die SLA zählt nicht weiter, „überfällig“ und eine Wiedervorlage entfallen
if (!in_array($oldStatus, $terminal, true) && in_array($newStatus, $terminal, true)) {
try { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { error_log('SLA erledigt: ' . $e->getMessage()); }
try { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Spalte fehlt vor dem Datenbank-Update */ }
}
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
if (!$wasPaused && $willPause) {
$now = db_now();
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
}
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
$pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$ticketId]);
}
return;
}
if ($wasPaused && !$willPause) {
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
$stmt->execute([$ticketId]); $row = $stmt->fetch();
if (!$row) return;
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
$updates = []; $args = [];
if ($seconds > 0) {
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
$breach->execute([$ticketId, $deadlineType]);
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
}
}
}
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
$minutes = (int) floor($seconds / 60);
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
}
}
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
try {
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
} catch (Throwable $e) {
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
}
}
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
{
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
}
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
{
try {
$ticketId = (int) $ticket['id'];
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
$now = db_now();
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
}
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
}
}
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
if (empty($ticket[$field])) continue;
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
$check->execute([$ticketId, $type]);
if (!(int) $check->fetchColumn()) {
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
} else {
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
}
}
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
return [];
}
}
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
{
try {
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
$check->execute([$ticketId, $deadlineType]);
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
}
/** /**
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung. * Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität. * null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
@@ -413,6 +636,18 @@ function staff_can_access_department(array $staff, int $departmentId): bool
return $ids === null || in_array($departmentId, $ids, true); return $ids === null || in_array($departmentId, $ids, true);
} }
/** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */
function staff_is_on_vacation(int $staffId): bool
{
try {
$q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
$q->execute([$staffId]);
return (int) $q->fetchColumn() === 1;
} catch (Throwable $e) {
return false;
}
}
function staff_department_ids(array $staff): ?array function staff_department_ids(array $staff): ?array
{ {
if ($staff['role'] === 'admin') { if ($staff['role'] === 'admin') {
@@ -427,9 +662,56 @@ function staff_department_ids(array $staff): ?array
foreach ($stmt->fetchAll() as $row) { foreach ($stmt->fetchAll() as $row) {
$ids[] = (int) $row['department_id']; $ids[] = (int) $row['department_id'];
} }
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
/**
* Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin
* „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit,
* solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen.
* Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade
* zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste.
* @return list<int>
*/
function staff_substitution_department_ids(array $staff): array
{
if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return [];
$pdo = get_db();
$out = [];
try {
$frontier = [(int) $staff['id']];
$seen = $frontier;
for ($level = 0; $level < 3 && $frontier; $level++) {
$in = implode(',', array_fill(0, count($frontier), '?'));
$q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)");
$q->execute($frontier);
$next = [];
$adminAbsent = false;
foreach ($q->fetchAll() as $abs) {
$aid = (int) $abs['id'];
if (in_array($aid, $seen, true)) continue;
$seen[] = $aid;
$next[] = $aid;
if ($abs['role'] === 'admin') { $adminAbsent = true; continue; }
if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id'];
$d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
$d->execute([$aid]);
foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
if ($adminAbsent) {
$t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')");
$t->execute([(int) $staff['id']]);
foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
}
$frontier = $next;
}
} catch (Throwable $e) {
return [];
}
return array_values(array_unique(array_filter($out)));
}
/** /**
* Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen * Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht, * automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
@@ -530,6 +812,7 @@ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
$deletedByStaffId, $deletedByStaffId,
db_now(), db_now(),
]); ]);
if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']);
$pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]); $pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
} }
@@ -558,9 +841,39 @@ function ip_in_list(string $ip, array $list): bool
} }
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */ /** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
function cloudflare_proxy_ranges(): array
{
static $ranges = null;
if ($ranges !== null) return $ranges;
$ranges = [];
try {
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
$active->execute(['cloudflare-protection']);
if ((int) $active->fetchColumn() === 1) {
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
}
} catch (Throwable $e) {
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
}
return $ranges;
}
function trusted_proxy_ranges(): array
{
static $ranges = null;
if ($ranges === null) {
$ranges = array_values(array_unique(array_merge(
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
cloudflare_proxy_ranges()
)));
}
return $ranges;
}
function request_from_trusted_proxy(): bool function request_from_trusted_proxy(): bool
{ {
$list = (array) (config()['site']['trusted_proxies'] ?? []); $list = trusted_proxy_ranges();
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list); return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
} }
@@ -580,7 +893,12 @@ function client_ip(): string
{ {
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!request_from_trusted_proxy()) return $remote; if (!request_from_trusted_proxy()) return $remote;
$trusted = (array) (config()['site']['trusted_proxies'] ?? []); $cloudflareRanges = cloudflare_proxy_ranges();
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
$trusted = trusted_proxy_ranges();
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')))); $chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
foreach ($chain as $ip) { foreach ($chain as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip; if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
@@ -839,7 +1157,7 @@ function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?stri
* frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */ * frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */
function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
{ {
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
->execute([$staffId, db_now(), $ticketId]); ->execute([$staffId, db_now(), $ticketId]);
} }
@@ -877,7 +1195,7 @@ function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
* frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */ * frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */
function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
{ {
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?') $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
->execute([$staffId, db_now(), $ticketId]); ->execute([$staffId, db_now(), $ticketId]);
} }
@@ -1043,6 +1361,16 @@ function get_allowed_extensions(): array
return config()['uploads']['allowed_extensions']; return config()['uploads']['allowed_extensions'];
} }
/** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */
const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp';
/** @return string[] */
function mail_attachment_extra_extensions(): array
{
$list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT)))));
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list)));
}
/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */ /** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */
function get_max_upload_size(): int function get_max_upload_size(): int
{ {
@@ -1053,10 +1381,16 @@ function get_max_upload_size(): int
return config()['uploads']['max_size_bytes']; return config()['uploads']['max_size_bytes'];
} }
function handle_ticket_upload(array $file): array /**
* @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern
* @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml)
* @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat
* (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben.
*/
function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array
{ {
$cfg = config()['uploads']; $cfg = config()['uploads'];
$allowed = get_allowed_extensions(); $allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed)));
$maxSize = get_max_upload_size(); $maxSize = get_max_upload_size();
if ($file['error'] !== UPLOAD_ERR_OK) { if ($file['error'] !== UPLOAD_ERR_OK) {
@@ -1103,6 +1437,7 @@ function handle_ticket_upload(array $file): array
return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.']; return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.'];
} }
} }
if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']];
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
$storedName = bin2hex(random_bytes(16)) . '.' . $ext; $storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$uploadsDir = realpath(rtrim($cfg['path'], '/')); $uploadsDir = realpath(rtrim($cfg['path'], '/'));
@@ -1110,12 +1445,55 @@ function handle_ticket_upload(array $file): array
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) { if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
return ['error' => 'Ungültiger Upload-Pfad.']; return ['error' => 'Ungültiger Upload-Pfad.'];
} }
if (!move_uploaded_file($file['tmp_name'], $dest)) { $moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest);
if (!$moved) {
return ['error' => 'Die Datei konnte nicht gespeichert werden.']; return ['error' => 'Die Datei konnte nicht gespeichert werden.'];
} }
return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']];
} }
/**
* Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload
* (erlaubte Endungen, Größe, gesperrte Typen).
*
* @return array{ok:bool,error?:string,name:string}
*/
function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array
{
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name))));
if ($name === '') $name = 'Anhang';
$name = mb_substr($name, 0, 255);
if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name];
$tmp = tempnam(sys_get_temp_dir(), 'dhmail');
if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name];
$res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? []));
@unlink($tmp);
if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name];
if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]);
} else {
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]);
}
return ['ok' => true, 'name' => $name];
}
/** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */
function ticket_mail_html_ready(PDO $pdo): bool
{
static $cache = [];
$key = spl_object_id($pdo);
if (isset($cache[$key])) return $cache[$key];
try {
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll();
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true;
} catch (Throwable $e) {
// Tabelle fehlt: nichts zu tun
}
return $cache[$key] = false;
}
/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input). /** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input).
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */ * Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
function handle_ticket_uploads_multi(array $filesInput): array function handle_ticket_uploads_multi(array $filesInput): array
@@ -1229,8 +1607,26 @@ function build_report_table(PDO $pdo, string $dimColumn, string $from, string $t
* der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities), * der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities),
* weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert. * weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert.
*/ */
function sanitize_staff_html(string $html): string /**
* Ob eine Nachricht HTML enthält (echte HTML-Tags wie <p>, <div>, <br>, <b> …) und deshalb als HTML dargestellt werden muss.
* Eine Adresse in spitzen Klammern ("Olaf <olaf@gmx.de>") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche
* des Textes (HTML kennt keine).
*/
function message_is_html(string $text): bool
{ {
return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text);
}
/**
* @param array{remote_images?:string,cid_map?:array<string,string>} $opts
* remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt
* (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde).
* cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text)
*/
function sanitize_staff_html(string $html, array $opts = []): string
{
$placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder';
$cidMap = (array) ($opts['cid_map'] ?? []);
if (trim($html) === '') return ''; if (trim($html) === '') return '';
// Gefährliche Elemente komplett inkl. Inhalt entfernen // Gefährliche Elemente komplett inkl. Inhalt entfernen
@@ -1314,7 +1710,15 @@ function sanitize_staff_html(string $html): string
$src = $attr($raw, 'src'); $src = $attr($raw, 'src');
if ($src !== null) { if ($src !== null) {
// Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen // Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen
if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) { $blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=';
if (preg_match('#^cid:#i', $src)) {
$cid = strtolower(trim(substr($src, 4), " \t\r\n<>"));
if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"';
elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"';
else $out .= ' src="' . $e($src) . '"';
} elseif ($placeholderRemote && preg_match('#^https://#i', $src)) {
$out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"';
} elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) {
$out .= ' src="' . $e($src) . '"'; $out .= ' src="' . $e($src) . '"';
} else { } else {
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"'; $out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';
@@ -1513,7 +1917,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{ {
if (empty($ruleResult['close'])) return false; if (empty($ruleResult['close'])) return false;
$now = db_now(); $now = db_now();
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]); $statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$statusStmt->execute([$ticketId]);
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)'); log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
return true; return true;
} }
@@ -1806,60 +2214,7 @@ function add_business_hours(string $from, int $hours): string
// Rollen & Berechtigungen // Rollen & Berechtigungen
// ==================================================================== // ====================================================================
/** // Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions())
* Standard-Berechtigungen je Admin/Agent-Rolle.
* Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden.
*/
function default_permissions(string $role): array
{
if ($role === 'admin') {
return array_fill_keys(array_merge([
'reply_ticket','close_ticket','delete_ticket','transfer_ticket',
'post_note','assign_ticket','manage_canned','view_reports',
'manage_kb','create_ticket',
], array_keys(admin_section_permissions())), true);
}
// Standard-Agent
return array_merge([
'reply_ticket' => true,
'close_ticket' => true,
'delete_ticket' => false,
'transfer_ticket' => true,
'post_note' => true,
'assign_ticket' => true,
'manage_canned' => false,
'view_reports' => true,
'manage_kb' => false,
'create_ticket' => true,
], array_fill_keys(array_keys(admin_section_permissions()), false));
}
/**
* Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten
* (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen
* freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin").
* Admins haben über has_permission() ohnehin immer automatisch alle Rechte.
*/
function admin_section_permissions(): array
{
return [
'manage_departments' => 'Abteilungen verwalten',
'manage_priorities' => 'Prioritäten / SLA verwalten',
'manage_help_topics' => 'Hilfe-Themen verwalten',
'manage_kb' => 'Wissensdatenbank verwalten',
'manage_email_addresses' => 'E-Mail-Adressen verwalten',
'manage_email_templates' => 'E-Mail-Vorlagen verwalten',
'manage_organizations' => 'Organisationen verwalten',
'manage_teams' => 'Teams verwalten',
'manage_roles' => 'Rollen verwalten',
'manage_staff' => 'Mitarbeiter verwalten',
'view_audit_log' => 'Admin-Protokoll einsehen',
'manage_escalations' => 'Eskalationsregeln verwalten',
'view_time_entries' => 'Zeiterfassung einsehen',
'manage_business_hours' => 'Geschäftszeiten verwalten',
'manage_assistant' => 'Digitalen Assistenten verwalten',
];
}
/** /**
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat. * Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
@@ -1894,7 +2249,8 @@ function has_permission(array $staff, string $permission, ?int $departmentId = n
$roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null; $roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null;
} }
$perms = $roleCache[$rid]; $perms = $roleCache[$rid];
if ($perms !== null) return (bool) ($perms[$permission] ?? false); // Fehlt ein Recht in der gespeicherten Rolle: ein altes Recht gilt wie bisher als „nein“, ein neueres mit dem Standardwert des Katalogs
if ($perms !== null) return array_key_exists($permission, $perms) ? (bool) $perms[$permission] : (!in_array($permission, permission_legacy_keys(), true) && (bool) (default_permissions('agent')[$permission] ?? false));
} }
$defaults = default_permissions('agent'); $defaults = default_permissions('agent');
@@ -1928,8 +2284,10 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]); ->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
// E-Mail an erwähnten Mitarbeiter // E-Mail an erwähnten Mitarbeiter
// Im Urlaub geht die Mail an die Vertretung
$mailTo = vacation_expand_ids($pdo, [$mentionedId]);
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?'); $stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
$stmt->execute([$mentionedId]); $stmt->execute([$mailTo ? $mailTo[0] : 0]);
$mentioned = $stmt->fetch(); $mentioned = $stmt->fetch();
if ($mentioned) { if ($mentioned) {
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?'); $by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* Datenschutz-Werkzeuge für Auskunft und Löschung auf Anfrage eines Kunden (DSGVO Art. 15 und 17).
* Nur für Administratoren und nur, wenn unter Einstellungen → Datenschutz eingeschaltet (gdpr_tools_enabled).
*
* - gdpr_customer_export(): alle zum Kunden gespeicherten Daten als Struktur (wird als JSON-Datei geliefert)
* - gdpr_customer_erase(): anonymisiert Tickets, entfernt Nachrichten und Anhänge des Kunden, Notizen und das Kundenkonto
*
* Ein Kunde ist entweder ein Konto (user_id) oder ein Gast, der nur über seine E-Mail-Adresse in Tickets vorkommt.
*/
function gdpr_tools_enabled(): bool
{
return get_setting('gdpr_tools_enabled', '0') === '1';
}
/**
* @param array{user_id?:int,email?:string} $ref
* @return array{email:string,user:?array,tickets:list<array>}|null
*/
function gdpr_resolve_customer(PDO $pdo, array $ref): ?array
{
$user = null;
$email = trim((string) ($ref['email'] ?? ''));
if (!empty($ref['user_id'])) {
$stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?');
$stmt->execute([(int) $ref['user_id']]);
$user = $stmt->fetch() ?: null;
if (!$user) return null;
$email = (string) $user['email'];
}
if ($email === '') return null;
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE requester_email = ? OR (user_id IS NOT NULL AND user_id = ?) ORDER BY created_at');
$stmt->execute([$email, $user ? (int) $user['id'] : 0]);
$tickets = $stmt->fetchAll();
if (!$user && !$tickets) return null;
return ['email' => $email, 'user' => $user, 'tickets' => $tickets];
}
function gdpr_plain(string $html): string
{
$t = preg_replace('~<(br|/p|/div|/li|/tr)\b[^>]*>~i', "\n", $html) ?? $html;
return trim(html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Alle gespeicherten Daten zu einem Kunden. @return array<string,mixed>|null */
function gdpr_customer_export(PDO $pdo, array $ref): ?array
{
$c = gdpr_resolve_customer($pdo, $ref);
if ($c === null) return null;
$user = $c['user'];
$out = [
'erstellt_am' => date('c'),
'hinweis' => 'Auskunft nach Art. 15 DSGVO. Interne Notizen der Mitarbeiter sind als intern gekennzeichnet und vor der Weitergabe zu prüfen. Dateiinhalte der Anhänge sind nicht enthalten, nur deren Namen.',
'kunde' => [
'name' => $user['name'] ?? ($c['tickets'][0]['requester_name'] ?? ''),
'email' => $c['email'],
'telefon' => $user['phone'] ?? null,
'organisation' => $user['org_name'] ?? null,
'konto_angelegt_am' => $user['created_at'] ?? null,
'hat_konto' => $user !== null,
],
'tickets' => [],
'notizen_zum_kunden' => [],
];
$dept = $pdo->prepare('SELECT name FROM departments WHERE id = ?');
$threads = $pdo->prepare('SELECT t.*, s.name AS staff_name FROM ticket_threads t LEFT JOIN staff s ON s.id = t.staff_id WHERE t.ticket_id = ? ORDER BY t.created_at, t.id');
$atts = $pdo->prepare('SELECT a.original_name, a.filesize, a.created_at FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? ORDER BY a.id');
$collab = $pdo->prepare('SELECT email, name FROM ticket_collaborators WHERE ticket_id = ?');
$rating = $pdo->prepare('SELECT rating, comment, created_at FROM ticket_ratings WHERE ticket_id = ?');
$csat = $pdo->prepare('SELECT rating, comment, answered_at FROM ticket_csat WHERE ticket_id = ? AND answered_at IS NOT NULL');
foreach ($c['tickets'] as $t) {
$dept->execute([(int) $t['department_id']]);
$threads->execute([(int) $t['id']]);
$atts->execute([(int) $t['id']]);
$collab->execute([(int) $t['id']]);
$rating->execute([(int) $t['id']]);
$csat->execute([(int) $t['id']]);
$messages = [];
foreach ($threads->fetchAll() as $m) {
$messages[] = [
'zeitpunkt' => $m['created_at'],
'von' => $m['author_type'] === 'customer' ? 'Kunde' : ($m['author_type'] === 'staff' ? 'Mitarbeiter' : 'System'),
'mitarbeiter' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?? null) : null,
'intern' => (bool) $m['is_internal_note'],
'text' => gdpr_plain((string) $m['message']),
];
}
$out['tickets'][] = [
'nummer' => $t['ticket_number'], 'betreff' => $t['subject'], 'status' => $t['status'], 'quelle' => $t['source'],
'abteilung' => (string) ($dept->fetchColumn() ?: ''), 'angelegt_am' => $t['created_at'], 'geschlossen_am' => $t['closed_at'] ?: null,
'name_im_ticket' => $t['requester_name'], 'email_im_ticket' => $t['requester_email'], 'ip_adresse' => $t['requester_ip'] ?: null,
'nachrichten' => $messages,
'anhaenge' => array_map(fn($a) => ['name' => $a['original_name'], 'groesse_bytes' => (int) $a['filesize'], 'hinzugefuegt_am' => $a['created_at']], $atts->fetchAll()),
'mitleser' => $collab->fetchAll(PDO::FETCH_ASSOC),
'bewertung' => $rating->fetch(PDO::FETCH_ASSOC) ?: ($csat->fetch(PDO::FETCH_ASSOC) ?: null),
];
}
if ($user) {
$n = $pdo->prepare('SELECT note, created_at FROM customer_notes WHERE user_id = ? ORDER BY created_at');
$n->execute([(int) $user['id']]);
$out['notizen_zum_kunden'] = array_map(fn($r) => ['zeitpunkt' => $r['created_at'], 'text' => $r['note']], $n->fetchAll());
}
return $out;
}
/**
* Löscht die personenbezogenen Daten eines Kunden. Ticketnummer, Betreff, Status und Zeiten bleiben für die Statistik erhalten.
* Offene Tickets verhindern die Löschung, solange $force nicht gesetzt ist.
* @return array{error:?string,tickets:int,messages:int,files:int}
*/
function gdpr_customer_erase(PDO $pdo, array $ref, bool $force = false): array
{
$res = ['error' => null, 'tickets' => 0, 'messages' => 0, 'files' => 0];
$c = gdpr_resolve_customer($pdo, $ref);
if ($c === null) { $res['error'] = 'Kunde nicht gefunden.'; return $res; }
$open = array_filter($c['tickets'], fn($t) => !in_array($t['status'], ['resolved', 'closed'], true));
if ($open && !$force) {
$res['error'] = count($open) . ' Ticket(s) sind noch offen. Bitte zuerst schließen oder die Löschung ausdrücklich erzwingen.';
return $res;
}
$user = $c['user'];
$email = $c['email'];
$anon = 'anon_' . substr(hash('sha256', $email . 'deskhub-anon'), 0, 16) . '@deleted.local';
$uploads = realpath((string) (config()['uploads']['path'] ?? '')) ?: '';
$files = [];
$pdo->beginTransaction();
try {
foreach ($c['tickets'] as $t) {
$tid = (int) $t['id'];
// Dateien der Kunden-Nachrichten merken und Zeilen entfernen
$stmt = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads th ON th.id = a.thread_id WHERE th.ticket_id = ? AND th.author_type = 'customer'");
$stmt->execute([$tid]);
foreach ($stmt->fetchAll() as $a) {
$files[] = (string) $a['stored_name'];
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([(int) $a['id']]);
}
$hasHtml = function_exists('ticket_mail_html_ready') && ticket_mail_html_ready($pdo);
$upd = $pdo->prepare("UPDATE ticket_threads SET message = ?, " . ($hasHtml ? 'message_html = NULL, ' : '') . "user_id = NULL WHERE ticket_id = ? AND author_type = 'customer'");
$upd->execute(['[Auf Anfrage des Betroffenen gelöscht]', $tid]);
$res['messages'] += $upd->rowCount();
$pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)')->execute([$tid, $email]);
$pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$tid]);
$pdo->prepare('UPDATE ticket_csat SET comment = NULL WHERE ticket_id = ?')->execute([$tid]);
$pdo->prepare("UPDATE tickets SET requester_name = 'Gelöscht', requester_email = ?, requester_ip = '0.0.0.0', user_id = NULL WHERE id = ?")->execute([$anon, $tid]);
$res['tickets']++;
}
if ($user) {
$uid = (int) $user['id'];
$pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('UPDATE tickets SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$uid]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$res['error'] = 'Löschen fehlgeschlagen: ' . $e->getMessage();
return $res;
}
// Dateien erst nach erfolgreichem Abschluss löschen (nur innerhalb des Upload-Ordners)
foreach (array_unique($files) as $name) {
if ($name === '' || $uploads === '' || $name !== basename($name)) continue;
$path = $uploads . DIRECTORY_SEPARATOR . $name;
if (is_file($path) && @unlink($path)) $res['files']++;
}
return $res;
}
+6
View File
@@ -109,6 +109,12 @@ function system_health(PDO $pdo): array
$issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg]; $issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg];
} }
} }
// Mails, die nicht rausgingen: warten in der Warteschlange oder wurden aufgegeben
if (function_exists('mail_queue_counts')) {
$q = mail_queue_counts($pdo);
if ($q['failed'] > 0) $issues[] = ['level' => 'error', 'code' => 'mail_queue_failed', 'message' => $q['failed'] . ' Mail(s) konnten auch nach mehreren Versuchen nicht gesendet werden. Unter Verwalten → Mail-Warteschlange nachsenden oder löschen.'];
elseif ($q['pending'] > 0) $issues[] = ['level' => 'warning', 'code' => 'mail_queue_pending', 'message' => $q['pending'] . ' Mail(s) warten in der Warteschlange und werden erneut versucht (Verwalten → Mail-Warteschlange).'];
}
return $issues; return $issues;
} }
+83 -6
View File
@@ -95,15 +95,87 @@ function i18n_current(): string
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}". * Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int} * @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int}
*/ */
function i18n_dictionary(string $code): array function i18n_dictionary(string $code, bool $reload = false): array
{ {
static $cache = []; static $cache = [];
if ($reload) unset($cache[$code]);
if (isset($cache[$code])) return $cache[$code]; if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0]; $dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$file = i18n_languages()[$code]['strings'] ?? ''; $files = [];
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict; // Zuerst die eigenen Übersetzungen der aktiven Plugins (lang/<code>.json), danach das Sprachpaket: Es gewinnt bei gleichem Text,
// damit sich ein Plugin-Text im Sprachpaket verbessern lässt.
foreach (i18n_plugin_string_files($code) as $f) $files[] = $f;
$packFile = i18n_languages()[$code]['strings'] ?? '';
if ($packFile !== '') $files[] = $packFile;
foreach ($files as $file) i18n_load_strings_file($file, $dict);
// Genauere (längere) Muster zuerst: Sonst würde ein allgemeines wie „Ticket #{}“ ein spezielleres wie „Ticket #{} wurde nicht gefunden.“ überdecken
if (count($dict['patterns']) > 1) {
$order = array_keys($dict['patterns']);
usort($order, fn($x, $y) => strlen((string) ($dict['pkeys'][$y] ?? '')) <=> strlen((string) ($dict['pkeys'][$x] ?? '')));
$dict['patterns'] = array_map(fn($i) => $dict['patterns'][$i], $order);
$dict['pkeys'] = array_map(fn($i) => $dict['pkeys'][$i] ?? '', $order);
}
return $cache[$code] = $dict;
}
/**
* Übersetzungsdateien der aktiven Plugins für eine Sprache: <Plugin>/lang/<code>.json (gleiches Format wie strings.json eines Sprachpakets).
* So bringt jedes Plugin seine Texte selbst mit; eine neue Sprache braucht dann nur die Datei lang/<code>.json im Plugin.
* @return array<string,string> Plugin-Kennung => Dateipfad
*/
function i18n_plugin_string_files(string $code): array
{
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || $code === 'de') return [];
$slugs = [];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return [];
foreach (get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN) as $slug) {
if (plugin_valid_slug((string) $slug)) $slugs[] = (string) $slug;
}
} catch (Throwable $e) {
return [];
}
$out = [];
foreach ($slugs as $slug) {
$file = plugins_dir() . '/' . $slug . '/lang/' . $code . '.json';
if (is_file($file)) $out[$slug] = $file;
}
return $out;
}
/**
* Welche Plugins bringen für welche Sprachen eigene Übersetzungen mit? (Anzeige unter Sprachen)
* @return array<string,array<string,int>> Plugin-Kennung => [Sprachcode => Anzahl Texte]
*/
function i18n_plugin_language_overview(): array
{
$out = [];
try {
$slugs = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return [];
}
foreach ($slugs as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
foreach (glob(plugins_dir() . '/' . $slug . '/lang/*.json') ?: [] as $f) {
$code = strtolower(basename($f, '.json'));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || filesize($f) > I18N_MAX_STRINGS_BYTES) continue;
$data = json_decode((string) file_get_contents($f), true);
$n = 0;
if (is_array($data)) foreach ($data as $k => $v) if (is_string($v) && $v !== '' && (string) $k !== '' && ((string) $k)[0] !== '_') $n++;
$out[$slug][$code] = $n;
}
}
return $out;
}
/** Liest eine strings.json (Schlüssel = deutscher Text) in ein Wörterbuch ein; spätere Dateien überschreiben frühere. */
function i18n_load_strings_file(string $file, array &$dict): void
{
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return;
$data = json_decode((string) file_get_contents($file), true); $data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return $cache[$code] = $dict; if (!is_array($data)) return;
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
$key = (string) $key; $key = (string) $key;
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue; if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
@@ -114,13 +186,18 @@ function i18n_dictionary(string $code): array
$regex = '/^'; $regex = '/^';
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)'); foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
$regex .= '$/su'; $regex .= '$/su';
// gleicher Schlüssel in einer späteren Datei ersetzt das frühere Muster
foreach ($dict['patterns'] as $i => $p) if (($dict['pkeys'][$i] ?? null) === $norm) { unset($dict['patterns'][$i], $dict['pkeys'][$i]); $dict['count']--; }
$dict['patterns'] = array_values($dict['patterns']);
$dict['pkeys'] = array_values($dict['pkeys'] ?? []);
$dict['patterns'][] = [$regex, $value]; $dict['patterns'][] = [$regex, $value];
$dict['pkeys'][] = $norm;
$dict['count']++;
} else { } else {
if (!isset($dict['exact'][$norm])) $dict['count']++;
$dict['exact'][$norm] = $value; $dict['exact'][$norm] = $value;
} }
$dict['count']++;
} }
return $cache[$code] = $dict;
} }
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */ /** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
+2 -1
View File
@@ -151,8 +151,9 @@ function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore =
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben'); log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
} else { } else {
$pdo->prepare( $pdo->prepare(
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" "UPDATE tickets SET is_spam=1, status='closed', resolution_code='spam', resolution_note='Als Spam markiert', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]); )->execute([$now, $now, $ticket['id']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', $staffId);
finalize_work_session($pdo, (int) $ticket['id'], $staffId); finalize_work_session($pdo, (int) $ticket['id'], $staffId);
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam'); apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert'); log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
+220 -1
View File
@@ -49,8 +49,192 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array
return $row; return $row;
} }
// ── Ticket über die öffentliche API anlegen ───────────────────────────────── // ── Kundenformular für Fremdsysteme (eigene Felder, Formular der Abteilung) ──────────────────
/**
* Das Kundenformular für ein Hilfethema oder eine Abteilung, wie es ticket_new.php zeigt: Hat die Abteilung ein eigenes Formular,
* gelten dessen Texte und Felder; sonst die allgemeinen Felder und die des Hilfethemas.
*
* @return array{topicId:?int,departmentId:?int,own:?array,fields:array}
* @throws AdminModelException wenn das Hilfethema nicht existiert
*/
function public_form_definition(PDO $pdo, ?int $topicId, ?int $deptId): array
{
if ($topicId !== null) {
$t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1');
$t->execute([$topicId]);
$topic = $t->fetch();
if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC');
$deptId = $deptId ?? (int) $topic['department_id'];
}
$own = $deptId ? department_form($pdo, $deptId) : null;
if ($own) {
$fields = $own['fields'];
} else {
$all = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll();
$fields = array_values(array_filter($all, fn(array $f) => $f['help_topic_id'] === null || ($topicId !== null && (int) $f['help_topic_id'] === $topicId)));
}
return ['topicId' => $topicId, 'departmentId' => $deptId, 'own' => $own, 'fields' => $fields];
}
/** Die Felder eines Formulars für die API-Antwort. Hinweistexte kommen auch als sicheres HTML (Links mit absoluter Adresse). */
function public_form_fields_payload(array $fields): array
{
$base = base_url();
$out = [];
foreach ($fields as $f) {
$type = in_array($f['field_type'], ['text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date'], true) ? $f['field_type'] : 'text';
$options = [];
if (in_array($type, ['select', 'radio'], true)) {
foreach (preg_split('/\r?\n/', trim((string) ($f['options'] ?? ''))) ?: [] as $o) {
$o = trim($o);
if ($o !== '') $options[] = $o;
}
}
$help = (string) ($f['help_text'] ?? '');
$html = $help !== '' ? str_replace('href="/', 'href="' . $base . '/', form_help_html($help, (string) $f['label'])) : '';
$out[] = [
'id' => (int) $f['id'],
'label' => (string) $f['label'],
'type' => $type,
'required' => !empty($f['is_required']),
'options' => $options,
'help' => $help,
'helpHtml' => $html,
'width' => in_array($f['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $f['form_width'] : 'full',
'section' => (string) ($f['_section'] ?? 'extra'),
'sectionTitle' => (string) ($f['_section_title'] ?? ''),
'radioInline' => !empty($f['radio_inline']),
];
}
return $out;
}
/** Das ganze Formular für die API-Antwort (Texte und Felder). */
/** Grenzen für Dateianhänge über die öffentliche Schnittstelle (die Datei kommt Base64-kodiert im JSON). */
const PUBLIC_ATTACHMENT_MAX_FILES = 5;
const PUBLIC_ATTACHMENT_FILE_BYTES = 5 * 1048576;
const PUBLIC_ATTACHMENT_TOTAL_BYTES = 10 * 1048576;
/** Dürfen Besucher bei diesem Formular Dateien anhängen, und mit welchen Grenzen? (Ein eigenes Formular kann Anhänge abschalten.) */
function public_attachment_policy(?array $own): array
{
$enabled = $own === null || !array_key_exists('attachment_show', $own) || !empty($own['attachment_show']);
return [
'enabled' => $enabled,
'maxFiles' => PUBLIC_ATTACHMENT_MAX_FILES,
'maxFileBytes' => min(PUBLIC_ATTACHMENT_FILE_BYTES, get_max_upload_size()),
'maxTotalBytes' => PUBLIC_ATTACHMENT_TOTAL_BYTES,
'allowedExtensions' => get_allowed_extensions(),
];
}
/**
* Prüft die Dateianhänge einer Anfrage und liefert sie dekodiert: [['name' => …, 'data' => Rohdaten], …]. Geprüft wird alles vor dem
* Anlegen des Tickets (Anzahl, Größe, Endung, Inhalt wie beim Upload im Formular).
*
* @throws AdminModelException mit Code INVALID_ATTACHMENT (die Meldung ist für den Besucher gedacht)
*/
function public_attachments_prepare($raw, ?array $own): array
{
if ($raw === null || $raw === [] || $raw === '') return [];
$policy = public_attachment_policy($own);
if (!is_array($raw) || (count($raw) > 0 && array_keys($raw) !== range(0, count($raw) - 1))) throw new AdminModelException('Die Dateianhänge haben ein ungültiges Format.', 'INVALID_ATTACHMENT');
if (!$policy['enabled']) throw new AdminModelException('Bei diesem Formular können keine Dateien angehängt werden.', 'INVALID_ATTACHMENT');
if (count($raw) > $policy['maxFiles']) throw new AdminModelException('Es können höchstens ' . $policy['maxFiles'] . ' Dateien angehängt werden.', 'INVALID_ATTACHMENT');
$out = [];
$total = 0;
foreach ($raw as $a) {
$name = is_array($a) ? trim((string) ($a['name'] ?? '')) : '';
$b64 = is_array($a) ? (string) ($a['data'] ?? '') : '';
if ($name === '' || mb_strlen($name) > 200 || $b64 === '') throw new AdminModelException('Jeder Anhang braucht einen Namen und Inhalt (Base64).', 'INVALID_ATTACHMENT');
$data = base64_decode(preg_replace('/\s+/', '', $b64) ?? '', true);
if ($data === false || $data === '') throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist nicht lesbar (Base64).', 'INVALID_ATTACHMENT');
if (strlen($data) > $policy['maxFileBytes']) throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist zu groß (höchstens ' . round($policy['maxFileBytes'] / 1048576, 1) . ' MB).', 'INVALID_ATTACHMENT');
$total += strlen($data);
if ($total > $policy['maxTotalBytes']) throw new AdminModelException('Die Anhänge sind zusammen zu groß (höchstens ' . round($policy['maxTotalBytes'] / 1048576) . ' MB).', 'INVALID_ATTACHMENT');
// Name, Endung und Inhalt wie beim Upload im Formular prüfen, ohne etwas zu speichern
$tmp = tempnam(sys_get_temp_dir(), 'dhpub');
if ($tmp === false || file_put_contents($tmp, $data) === false) throw new AdminModelException('Der Anhang konnte nicht verarbeitet werden.', 'INVALID_ATTACHMENT');
$check = handle_ticket_upload(['name' => basename(str_replace(chr(92), '/', $name)), 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, [], true);
@unlink($tmp);
if (isset($check['error'])) throw new AdminModelException(strip_tags((string) $check['error']), 'INVALID_ATTACHMENT');
$out[] = ['name' => $name, 'data' => $data];
}
return $out;
}
function public_form_payload(array $def): array
{
$o = $def['own'];
return [
'helpTopicId' => $def['topicId'],
'departmentId' => $def['departmentId'],
'ownForm' => $o !== null,
'title' => (string) ($o['title'] ?? ''),
'intro' => (string) ($o['intro'] ?? ''),
'subject' => ['show' => $o ? (bool) $o['subject_show'] : true, 'label' => (string) ($o['subject_label'] ?? 'Betreff')],
'message' => ['show' => $o ? (bool) ($o['message_show'] ?? true) : true, 'label' => (string) ($o['message_label'] ?? 'Beschreibung'),
'hint' => (string) ($o['message_hint'] ?? 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…')],
'submitLabel' => (string) ($o['submit_label'] ?? 'Ticket erstellen'),
'fields' => public_form_fields_payload($def['fields']),
'attachments' => public_attachment_policy($o),
];
}
/** Wert eines Feldes aus der Anfrage, in der Form, wie ticket_new.php ihn speichert (Checkbox: "1" oder "0"). */
function public_form_field_value(array $f, array $input): string
{
$raw = $input[(int) $f['id']] ?? $input[(string) $f['id']] ?? null;
if ($f['field_type'] === 'checkbox') {
$norm = is_string($raw) ? strtolower(trim($raw)) : $raw;
return in_array($norm, [true, 1, '1', 'on', 'yes', 'ja', 'true'], true) ? '1' : '0';
}
return is_scalar($raw) ? trim((string) $raw) : '';
}
/**
* Prüft die gelieferten Feldwerte gegen das Formular (Pflichtfelder, Auswahlwerte, Zahl, Datum) wie ticket_new.php.
*
* @return array<int,string> Feld-ID => Wert
* @throws AdminModelException mit Code INVALID_FIELD (die Meldung ist für den Besucher gedacht)
*/
function public_form_validate_fields(array $fields, array $input): array
{
$values = [];
foreach ($fields as $f) {
$val = public_form_field_value($f, $input);
$label = (string) $f['label'];
$type = $f['field_type'];
if (mb_strlen($val) > 10000) throw new AdminModelException('Die Angabe bei „' . $label . '“ ist zu lang.', 'INVALID_FIELD');
if (!empty($f['is_required']) && $type !== 'checkbox' && $val === '') throw new AdminModelException('Bitte füllen Sie das Feld „' . $label . '“ aus.', 'INVALID_FIELD');
if (!empty($f['is_required']) && $type === 'checkbox' && $val !== '1') throw new AdminModelException('Bitte bestätigen Sie „' . $label . '“.', 'INVALID_FIELD');
if (in_array($type, ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options']) ?: []), true)) {
throw new AdminModelException('Bitte wählen Sie bei „' . $label . '“ eine der angebotenen Möglichkeiten.', 'INVALID_FIELD');
}
if ($type === 'number' && $val !== '' && !is_numeric($val)) throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ eine Zahl an.', 'INVALID_FIELD');
if ($type === 'date' && $val !== '' && !(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $val, $d) && checkdate((int) $d[2], (int) $d[3], (int) $d[1]))) {
throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ ein gültiges Datum an (JJJJ-MM-TT).', 'INVALID_FIELD');
}
$values[(int) $f['id']] = $val;
}
return $values;
}
/**
* Ist der Schlüssel gültig, wird aber nur wegen der IP-Beschränkung abgelehnt? So kann die API dem Aufrufer sagen, welche Adresse
* DeskHub sieht (hinter einem Proxy oder Cloudflare ist das oft nicht die erwartete). Verrät nichts, was der Aufrufer mit dem Schlüssel nicht ohnehin weiß.
*/
function api_key_ip_blocked(PDO $pdo, string $key, string $ip): bool
{
if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return false;
$stmt = $pdo->prepare('SELECT ip_whitelist FROM api_keys WHERE key_hash = ? AND is_active = 1');
$stmt->execute([hash('sha256', $key)]);
$row = $stmt->fetch();
return $row && !empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true);
}
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
/** /**
* Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an, * Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an,
* benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus. * benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus.
@@ -64,11 +248,35 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
$email = trim((string) ($in['email'] ?? '')); $email = trim((string) ($in['email'] ?? ''));
$subject = trim((string) ($in['subject'] ?? '')); $subject = trim((string) ($in['subject'] ?? ''));
$message = trim((string) ($in['message'] ?? '')); $message = trim((string) ($in['message'] ?? ''));
// Mit "fields" liefert der Aufrufer die Angaben eines Kundenformulars (eigene Felder, Formular der Abteilung). Ohne "fields" bleibt alles wie bisher.
$formDef = null;
$fieldInput = [];
if (isset($in['fields']) && is_array($in['fields'])) {
$fieldInput = $in['fields'];
$preTopic = am_int_or_null($in, 'helpTopicId');
$preDept = am_int_or_null($in, 'departmentId');
$formDef = public_form_definition($pdo, $preTopic, $preTopic === null ? ($preDept ?: (int) get_setting('default_department_id', '1')) : $preDept);
$own = $formDef['own'];
if ($own && !$own['subject_show'] && $subject === '') $subject = 'Anfrage ' . $own['department_name'];
if ($own && empty($own['message_show']) && $message === '') {
// Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder
$lines = [];
foreach ($formDef['fields'] as $ff) {
$v = public_form_field_value($ff, $fieldInput);
if ($ff['field_type'] === 'checkbox') $v = $v === '1' ? 'Ja' : 'Nein';
if ($v !== '') $lines[] = $ff['label'] . ': ' . $v;
}
$message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben';
}
}
if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME'); if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL'); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT'); if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT');
if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE'); if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE');
if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403); if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403);
$fieldValues = $formDef ? public_form_validate_fields($formDef['fields'], $fieldInput) : [];
// Dateianhänge (Base64 im JSON): ohne "fields" gilt das Standardformular, das Anhänge erlaubt
$attachmentList = public_attachments_prepare($in['attachments'] ?? null, $formDef['own'] ?? null);
$topicId = am_int_or_null($in, 'helpTopicId'); $topicId = am_int_or_null($in, 'helpTopicId');
$deptId = am_int_or_null($in, 'departmentId'); $deptId = am_int_or_null($in, 'departmentId');
@@ -110,6 +318,17 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
$ticketId = (int) $pdo->lastInsertId(); $ticketId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)') $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
->execute([$ticketId, 'customer', $message, $now]); ->execute([$ticketId, 'customer', $message, $now]);
$threadId = (int) $pdo->lastInsertId();
foreach ($attachmentList as $att) {
$saved = store_ticket_attachment_data($pdo, $threadId, $att['name'], $att['data']);
if (!$saved['ok']) log_activity($pdo, $ticketId, null, 'Anhang nicht übernommen: ' . mb_substr($att['name'], 0, 80) . ' (' . mb_substr((string) ($saved['error'] ?? ''), 0, 100) . ')');
}
if ($fieldValues) {
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
foreach ($fieldValues as $fieldId => $val) {
if ($val !== '') $cfStmt->execute([$ticketId, $fieldId, $val]);
}
}
log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt')); log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt'));
if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']); if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']);
+355
View File
@@ -0,0 +1,355 @@
<?php
/**
* Anmelde-Wächter: merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden, und warnt per Mail, wenn ein neues Gerät
* oder ein neuer Ort auftaucht. Fehlversuche und Sperren stehen im Protokoll (staff/login_log.php). Wer eine Warnung bekommt und die
* Anmeldung nicht selbst war, sichert das Konto über den Link „Das war ich nicht“ in der Mail (staff/login_alert.php).
*
* Ein Gerät ist ein Browser (Kennung in einem Cookie) bzw. eine App (Gerätename). Ein Ort ist das Land (nur wenn Cloudflare es
* mitschickt) oder sonst das Netz der IP-Adresse; echte Standortdaten gibt es ohne Fremddienst nicht.
* Fehler im Wächter dürfen eine Anmeldung nie verhindern: alles ist in try/catch gefasst.
*/
const LOGIN_GUARD_COOKIE = 'dh_device';
function login_guard_enabled(): bool
{
return get_setting('login_guard', '1') === '1';
}
function login_guard_max_attempts(): int
{
return max(3, min(20, (int) get_setting('login_guard_max_attempts', '5')));
}
function login_guard_lock_minutes(): int
{
return max(5, min(240, (int) get_setting('login_guard_lock_minutes', '15')));
}
function login_guard_retention_days(): int
{
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
}
/**
* Ist dieser Browser ein bekanntes Gerät dieses Mitarbeiters (Cookie des Anmelde-Wächters, bei einer früheren Anmeldung gemerkt)?
* Liest nur, setzt kein Cookie. Nur für Browser: Die Kennung von Apps stammt aus frei wählbaren Angaben und taugt dafür nicht.
*/
function login_guard_known_device(PDO $pdo, int $staffId): bool
{
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
if (!preg_match('/^[a-f0-9]{32}$/', $id)) return false;
$st = $pdo->prepare('SELECT 1 FROM login_devices WHERE staff_id = ? AND device_hash = ?');
$st->execute([$staffId, hash('sha256', $staffId . '|' . $id)]);
return (bool) $st->fetchColumn();
}
/** Wie login_guard_known_device(), aber für eine E-Mail-Adresse (unbekannte Adresse = nein). */
function login_guard_known_device_for_email(string $email): bool
{
if (!login_guard_enabled() || trim($email) === '') return false;
try {
$pdo = get_db();
$st = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
$st->execute([$email]);
$id = (int) $st->fetchColumn();
return $id > 0 && login_guard_known_device($pdo, $id);
} catch (Throwable $e) {
error_log('login_guard_known_device: ' . $e->getMessage());
return false;
}
}
/** Ist die Anmeldung dieses Kontos gerade wegen zu vieler Fehlversuche gesperrt (Web oder App)? */
function login_guard_is_locked(PDO $pdo, string $email): bool
{
$email = strtolower(trim($email));
if ($email === '') return false;
$since = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
$st = $pdo->prepare("SELECT COUNT(*) FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ? AND created_at >= ?");
$st->execute([$email, $since]);
return (int) $st->fetchColumn() >= login_guard_max_attempts();
}
/**
* Hebt die Sperre eines Kontos sofort auf: die Fehlversuch-Zähler für Web, App und 2FA-Code werden gelöscht.
* Die Sperre je IP-Adresse (viele Versuche von einer Adresse) bleibt bewusst bestehen.
* @return int Anzahl der gelöschten Zähler-Einträge
*/
function login_guard_unlock(PDO $pdo, int $staffId, ?int $byStaffId = null, string $reason = ''): int
{
$st = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
$st->execute([$staffId]);
$email = strtolower(trim((string) $st->fetchColumn()));
if ($email === '') return 0;
$n = 0;
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ?");
$del->execute([$email]);
$n += $del->rowCount();
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket = 'staff_totp_attempt' AND identifier = ?");
$del->execute([(string) $staffId]);
$n += $del->rowCount();
$detail = $reason !== '' ? $reason : ($byStaffId !== null && $byStaffId !== $staffId ? 'Sperre aufgehoben durch Administrator #' . $byStaffId : 'Sperre aufgehoben');
try { login_guard_record($pdo, 'unlocked', $staffId, $email, 'web', $detail); } catch (Throwable $e) { error_log('login_guard_unlock log: ' . $e->getMessage()); }
audit_log($byStaffId ?? $staffId, "Anmeldesperre aufgehoben für Mitarbeiter #{$staffId}" . ($reason !== '' ? ' (' . $reason . ')' : ''));
return $n;
}
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
function login_guard_describe_device(string $ua): string
{
$ua = trim($ua);
if ($ua === '') return 'Unbekanntes Gerät';
if (stripos($ua, 'okhttp') !== false || stripos($ua, 'DeskHub') !== false) return 'DeskHub-App';
$browser = 'Browser';
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari'] as $needle => $name) {
if (stripos($ua, $needle) !== false) { $browser = $name; break; }
}
$os = 'unbekanntem System';
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Macintosh' => 'macOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $name) {
if (stripos($ua, $needle) !== false) { $os = $name; break; }
}
return $browser . ' auf ' . $os;
}
/** Ort der Anmeldung: [Schlüssel, Anzeigetext]. Land nur, wenn Cloudflare es über einen vertrauten Proxy mitschickt. */
function login_guard_place(string $ip): array
{
if (request_from_trusted_proxy()) {
$country = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));
if (preg_match('/^[A-Z]{2}$/', $country) && !in_array($country, ['XX', 'T1'], true)) return ['C:' . $country, 'Land ' . $country];
}
if (!filter_var($ip, FILTER_VALIDATE_IP)) return ['unknown', 'Unbekannt'];
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['local', 'Lokales Netz'];
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$p = explode('.', $ip);
return ['N:' . $p[0] . '.' . $p[1], 'Netz ' . $p[0] . '.' . $p[1] . '.x.x'];
}
$bin = inet_pton($ip);
$h = $bin === false ? ['0', '0'] : array_map(fn($w) => dechex($w), array_values(unpack('n2', substr($bin, 0, 4))));
return ['N6:' . $h[0] . ':' . $h[1], 'Netz ' . $h[0] . ':' . $h[1] . '::/32'];
}
/** Schreibt einen Protokolleintrag; gibt die Eintrags-Nummer zurück. */
function login_guard_record(PDO $pdo, string $event, ?int $staffId, string $email, string $channel, string $detail = '', ?string $device = null): int
{
$ip = client_ip();
[, $placeLabel] = login_guard_place($ip);
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$pdo->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$staffId, mb_substr($email, 0, 255), $event, $channel, mb_substr($ip, 0, 64), mb_substr($placeLabel, 0, 100),
mb_substr($device ?? login_guard_describe_device($ua), 0, 160), mb_substr($detail, 0, 255), db_now()]);
if (random_int(1, 40) === 1) login_guard_purge($pdo);
return (int) $pdo->lastInsertId();
}
function login_guard_purge(PDO $pdo): void
{
$cut = date('Y-m-d H:i:s', time() - login_guard_retention_days() * 86400);
$pdo->prepare('DELETE FROM login_events WHERE created_at < ?')->execute([$cut]);
}
/** Kennung dieses Browsers (Cookie); legt sie an, wenn sie fehlt. */
function login_guard_device_id(string $channel, ?string $deviceName): string
{
if ($channel === 'app') return 'app:' . mb_strtolower(trim((string) $deviceName)) . '|' . login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
if (!preg_match('/^[a-f0-9]{32}$/', $id)) $id = bin2hex(random_bytes(16));
if (!headers_sent() && PHP_SAPI !== 'cli') {
setcookie(LOGIN_GUARD_COOKIE, $id, ['expires' => time() + 400 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => true, 'samesite' => 'Lax']);
}
$_COOKIE[LOGIN_GUARD_COOKIE] = $id;
return $id;
}
/**
* Nach erfolgreicher Anmeldung aufrufen (Web: staff_login(); App: api/staff/v1/login.php).
* Merkt Gerät und Ort, schreibt das Protokoll und warnt bei Neuem. Gibt das Ergebnis zurück: first | known | new_device | new_place | off.
*/
function login_guard_success(array $staff, string $channel = 'web', ?string $deviceName = null): string
{
if (!login_guard_enabled()) return 'off';
try {
$pdo = get_db();
$sid = (int) $staff['id'];
$now = db_now();
$ip = client_ip();
[$placeKey, $placeLabel] = login_guard_place($ip);
$label = $channel === 'app' && trim((string) $deviceName) !== '' ? mb_substr(trim((string) $deviceName), 0, 120) : login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
$hash = hash('sha256', $sid . '|' . login_guard_device_id($channel, $deviceName));
$st = $pdo->prepare('SELECT id FROM login_devices WHERE staff_id = ? AND device_hash = ?');
$st->execute([$sid, $hash]);
$devId = $st->fetchColumn();
$st = $pdo->prepare('SELECT id FROM login_places WHERE staff_id = ? AND place_key = ?');
$st->execute([$sid, $placeKey]);
$plId = $st->fetchColumn();
$st = $pdo->prepare('SELECT COUNT(*) FROM login_devices WHERE staff_id = ?');
$st->execute([$sid]);
$hadAny = (int) $st->fetchColumn() > 0;
if ($devId) {
$pdo->prepare('UPDATE login_devices SET last_seen = ?, last_ip = ?, place = ?, label = ? WHERE id = ?')->execute([$now, $ip, $placeLabel, $label, $devId]);
} else {
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$sid, $hash, $label, $channel, $ip, $ip, $placeLabel, $now, $now]);
}
if ($plId) {
$pdo->prepare('UPDATE login_places SET last_seen = ? WHERE id = ?')->execute([$now, $plId]);
} else {
$pdo->prepare('INSERT INTO login_places (staff_id, place_key, label, first_seen, last_seen) VALUES (?,?,?,?,?)')->execute([$sid, $placeKey, $placeLabel, $now, $now]);
}
$email = (string) $staff['email'];
if (!$hadAny) {
login_guard_record($pdo, 'success', $sid, $email, $channel, 'Erste Anmeldung, Gerät und Ort gemerkt', $label);
return 'first';
}
if (!$devId) {
$eid = login_guard_record($pdo, 'new_device', $sid, $email, $channel, 'Neues Gerät' . (!$plId ? ' und neuer Ort' : ''), $label);
login_guard_alert($pdo, $staff, $eid, 'Neues Gerät', $label, $placeLabel, $ip, $channel);
return 'new_device';
}
if (!$plId && get_setting('login_guard_warn_place', '1') === '1') {
$eid = login_guard_record($pdo, 'new_place', $sid, $email, $channel, 'Neuer Ort', $label);
login_guard_alert($pdo, $staff, $eid, 'Neuer Ort', $label, $placeLabel, $ip, $channel);
return 'new_place';
}
login_guard_record($pdo, 'success', $sid, $email, $channel, '', $label);
return 'known';
} catch (Throwable $e) {
error_log('login_guard_success: ' . $e->getMessage());
return 'off';
}
}
/** Warn-Mail an den Mitarbeiter mit einmaligem Link „Das war ich nicht“. */
function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason, string $device, string $place, string $ip, string $channel): void
{
$token = bin2hex(random_bytes(24));
$pdo->prepare('UPDATE login_events SET token_hash = ? WHERE id = ?')->execute([hash('sha256', $token), $eventId]);
$link = base_url() . '/staff/login_alert.php?t=' . $token;
if (getenv('DESKHUB_TESTING') === '1') $GLOBALS['login_guard_last_link'] = $link; // nur für Tests
$name = e((string) ($staff['name'] ?? ''));
$inner = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Neue Anmeldung bei Ihrem Konto</h2>'
. '<p style="margin:0 0 12px;color:#334155">Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.</p>'
. '<table style="margin:0 0 16px;font-size:14px;color:#334155"><tr><td style="padding:2px 14px 2px 0;color:#64748b">Zeit</td><td>' . e(date('d.m.Y H:i')) . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Gerät</td><td>' . e($device) . ($channel === 'app' ? ' (App)' : '') . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Ort</td><td>' . e($place) . '</td></tr>'
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">IP-Adresse</td><td>' . e($ip) . '</td></tr></table>'
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie?</strong> Dann müssen Sie nichts tun.</p>'
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie nicht?</strong> Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.</p>'
. '<p style="margin:0 0 8px"><a href="' . e($link) . '" style="display:inline-block;background:#dc2626;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600">Das war ich nicht – Konto sichern</a></p>'
. '<p style="margin:12px 0 0;font-size:12px;color:#64748b">Der Link gilt 7 Tage und kann einmal verwendet werden.</p>';
send_mail((string) $staff['email'], 'Neue Anmeldung bei Ihrem Konto (' . $reason . ')', email_wrap($inner));
}
/** Fehlversuch ins Protokoll. $staff ist null, wenn es die Adresse nicht gibt. */
function login_guard_failed(?array $staff, string $email, string $channel, string $detail): void
{
if (!login_guard_enabled()) return;
try {
login_guard_record(get_db(), 'failed', $staff ? (int) $staff['id'] : null, $email, $channel, $detail);
} catch (Throwable $e) {
error_log('login_guard_failed: ' . $e->getMessage());
}
}
/** Die Konto-Sperre hat gegriffen: einmal je Sperrzeit ins Protokoll, Mail an den Kontoinhaber und die Administratoren. */
function login_guard_locked(string $email, string $channel): void
{
if (!login_guard_enabled()) return;
try {
$pdo = get_db();
// Steuerzeichen und Zeilenumbrüche raus (die Adresse steht im Betreff), Länge begrenzen
$email = mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $email)), 0, 120);
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
$st->execute([$email, $cut]);
if ((int) $st->fetchColumn() > 0) return;
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
$st->execute([$email]);
$staff = $st->fetch() ?: null;
if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll
login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
$ip = client_ip();
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Anmeldung gesperrt</h2>'
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, (string) $staff['email']);
} catch (Throwable $e) {
error_log('login_guard_locked: ' . $e->getMessage());
}
}
/** Mail an alle aktiven Administratoren (abschaltbar). $skipEmail: dieser Empfänger hat die Mail schon bekommen. */
function login_guard_notify_admins(string $subject, string $innerHtml, ?string $skipEmail = null): void
{
if (get_setting('login_guard_notify_admins', '1') !== '1') return;
foreach (get_db()->query("SELECT email FROM staff WHERE role = 'admin' AND is_active = 1")->fetchAll(PDO::FETCH_COLUMN) as $adminMail) {
if ($skipEmail !== null && strcasecmp((string) $adminMail, $skipEmail) === 0) continue;
if (filter_var($adminMail, FILTER_VALIDATE_EMAIL)) send_mail((string) $adminMail, $subject, email_wrap($innerHtml));
}
}
/** Alarm-Link prüfen: gibt die Protokollzeile zurück, wenn der Link gültig ist (7 Tage, einmal verwendbar). */
function login_guard_find_alert(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{48}$/', $token)) return null;
$st = $pdo->prepare('SELECT * FROM login_events WHERE token_hash = ? AND token_used = 0 AND created_at >= ?');
$st->execute([hash('sha256', $token), date('Y-m-d H:i:s', time() - 7 * 86400)]);
return $st->fetch() ?: null;
}
/**
* „Das war ich nicht“: Passwort ungültig machen, App-Anmeldungen beenden, alle gemerkten Geräte und Orte vergessen, laufende
* Browser-Sitzungen beenden (current_staff() prüft den Zeitpunkt), Protokoll und Mail an die Administratoren.
*/
function login_guard_revoke(PDO $pdo, array $event): bool
{
$sid = (int) ($event['staff_id'] ?? 0);
$st = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
$st->execute([$sid]);
$staff = $st->fetch();
if (!$staff) return false;
$pdo->prepare('UPDATE login_events SET token_used = 1 WHERE id = ?')->execute([(int) $event['id']]);
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE id = ?')->execute([password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $sid]);
$pdo->prepare('DELETE FROM mobile_api_tokens WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
// Auch Passkeys: Wer das Konto übernommen hat, könnte einen eigenen eingetragen haben
try { $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$sid]); } catch (Throwable $e) { /* Tabelle fehlt vor dem Datenbank-Update */ }
set_setting('login_guard_revoke_' . $sid, (string) time());
login_guard_record($pdo, 'revoked', $sid, (string) $staff['email'], 'web', 'Konto gesichert: „Das war ich nicht“ nach Eintrag ' . (int) $event['id']);
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Konto gesichert</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . e((string) $staff['name']) . ' (' . e((string) $staff['email']) . ') hat eine Anmeldung als „nicht von mir“ gemeldet. Das Passwort wurde ungültig gemacht, alle App-Anmeldungen wurden beendet.</p>'
. '<p style="margin:0 0 12px;color:#334155">Gemeldet war: ' . e((string) $event['device']) . ', ' . e((string) $event['place']) . ', IP ' . e((string) $event['ip']) . '. Bitte prüfen Sie das Protokoll im Adminportal unter „Anmelde-Wächter“.</p>';
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
return true;
}
/** Nach „Das war ich nicht“ sind ältere Browser-Sitzungen ungültig. */
function login_guard_session_revoked(int $staffId): bool
{
$revoked = (int) get_setting('login_guard_revoke_' . $staffId, '0');
return $revoked > 0 && (int) ($_SESSION['staff_login_at'] ?? 0) <= $revoked;
}
/** Namen der Ereignisarten für die Anzeige. */
function login_guard_event_labels(): array
{
return [
'success' => 'Angemeldet',
'new_device' => 'Neues Gerät',
'new_place' => 'Neuer Ort',
'failed' => 'Fehlversuch',
'locked' => 'Gesperrt',
'revoked' => 'Konto gesichert',
'signout' => 'Überall abgemeldet',
'unlocked' => 'Sperre aufgehoben',
'passkey_added' => 'Passkey hinzugefügt',
'passkey_removed' => 'Passkey entfernt',
];
}
@@ -0,0 +1,82 @@
<?php
/**
* includes/mail_fetch_manual.php
*
* E-Mail-Abruf von Hand starten (Knopf in der Ticketliste). Läuft über dieselbe Funktion wie der Cronjob. Damit der Abruf nicht doppelt
* läuft (Doppelklick, zugleich der eingebaute Zeitgeber oder ein anderer Mitarbeiter), wird vorher der Zeitstempel des letzten Abrufs
* atomar beansprucht: Wer ihn bekommt, ruft ab; alle anderen innerhalb der Sperrzeit bekommen eine Meldung.
*/
/** Mindestabstand zwischen zwei Abrufen von Hand, in Sekunden. */
const MAIL_FETCH_MANUAL_COOLDOWN = 20;
/** Gibt es etwas abzurufen (mindestens ein aktives Postfach)? */
function mail_fetch_available(PDO $pdo): bool
{
try {
return (int) $pdo->query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn() > 0;
} catch (Throwable $e) {
return false;
}
}
/**
* Startet den Abruf. $runner ist nur für Tests austauschbar (liefert wie run_fetch_emails ['processed' => int, 'log' => string[]]).
*
* @return array{ok:bool,level:string,message:string,processed:int,new_tickets:int,replies:int,errors:string[]}
*/
function mail_fetch_manual(PDO $pdo, ?callable $runner = null): array
{
$res = ['ok' => false, 'level' => 'error', 'message' => '', 'processed' => 0, 'new_tickets' => 0, 'replies' => 0, 'errors' => []];
if (!function_exists('imap_open') && $runner === null) {
$res['message'] = 'Der Abruf ist nicht möglich: Die PHP-Erweiterung IMAP ist auf diesem Server nicht aktiv.';
return $res;
}
if (!mail_fetch_available($pdo)) {
$res['message'] = 'Es ist kein aktives Postfach eingerichtet (Adminportal → Postfächer (Abruf)).';
return $res;
}
if (!pseudo_cron_try_claim($pdo, 'cron_fetch_emails_last_run', MAIL_FETCH_MANUAL_COOLDOWN)) {
$res['level'] = 'info';
$res['message'] = 'Der E-Mail-Abruf lief gerade eben schon (oder läuft noch). Bitte einige Sekunden warten und dann erneut versuchen.';
return $res;
}
if ($runner === null) {
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
require_once __DIR__ . '/../cron/fetch_emails.php';
$runner = fn() => run_fetch_emails($pdo, true);
}
@set_time_limit(120);
try {
$out = $runner();
} catch (Throwable $e) {
error_log('Manueller E-Mail-Abruf: ' . $e->getMessage());
$res['message'] = 'Der E-Mail-Abruf ist mit einem Fehler abgebrochen: ' . mb_substr($e->getMessage(), 0, 200);
return $res;
}
$res['processed'] = (int) ($out['processed'] ?? 0);
foreach ((array) ($out['log'] ?? []) as $line) {
$line = (string) $line;
if (preg_match('/Neues Ticket #/', $line)) $res['new_tickets']++;
elseif (preg_match('/Antwort zu Ticket #/', $line)) $res['replies']++;
if (preg_match('/fehlgeschlagen|Abbruch|nicht installiert|Kein aktives Postfach/i', $line) && !str_contains($line, 'Anhang nicht übernommen')) {
$res['errors'][] = mb_substr(preg_replace('/^\[[^\]]*\]\s*/', '', $line) ?? $line, 0, 220);
}
}
$res['ok'] = $res['errors'] === [];
if ($res['errors']) {
$res['message'] = 'Der Abruf hatte Probleme: ' . implode(' | ', array_slice($res['errors'], 0, 3));
} elseif ($res['processed'] === 0) {
$res['level'] = 'info';
$res['message'] = 'Abruf fertig: Keine neuen E-Mails.';
} else {
$res['level'] = 'success';
$parts = [];
if ($res['new_tickets'] > 0) $parts[] = $res['new_tickets'] . ' neue(s) Ticket(s)';
if ($res['replies'] > 0) $parts[] = $res['replies'] . ' Antwort(en)';
$other = $res['processed'] - $res['new_tickets'] - $res['replies'];
if ($other > 0) $parts[] = $other . ' weitere (zum Beispiel Spam oder Systemmails, ohne Ticket)';
$res['message'] = 'Abruf fertig: ' . $res['processed'] . ' E-Mail(s) verarbeitet' . ($parts ? ' (' . implode(', ', $parts) . ')' : '') . '.';
}
return $res;
}
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* includes/mail_queue.php
*
* Warteschlange für ausgehende Mails. Scheitert der Versand (SMTP-Server nicht erreichbar, falsche Zugangsdaten, Port gesperrt …),
* wird die Mail hier gespeichert und vom Cron-Job "process_mail_queue" mit wachsenden Abständen erneut versucht. So gehen
* Antworten, Eingangsbestätigungen und Passwort-Mails bei einer Störung nicht verloren.
*
* Tabelle mail_queue (Migration php_0064): status = pending (wartet), sent (nachträglich gesendet), failed (aufgegeben).
*/
const MAIL_QUEUE_MAX_ATTEMPTS = 10;
const MAIL_QUEUE_MAX_AGE_HOURS = 48;
const MAIL_QUEUE_MAX_PENDING = 2000;
/** Minuten bis zum nächsten Versuch nach dem 1., 2., 3. … Fehlversuch (der letzte Wert gilt für alle weiteren). */
const MAIL_QUEUE_BACKOFF_MINUTES = [5, 10, 20, 40, 60, 120, 240, 360];
/** Ist die Warteschlange eingeschaltet? (Standard: ja; Einstellung mail_queue_enabled = 0 schaltet sie ab) */
function mail_queue_enabled(): bool
{
return get_setting('mail_queue_enabled', '1') === '1';
}
/** Merkt sich pro Anfrage, ob der letzte send_mail()-Aufruf wirklich versucht hat zu senden (nicht nur "Mailversand aus"). */
function mail_attempted(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Wurde die zuletzt fehlgeschlagene Mail in die Warteschlange gelegt? */
function mail_last_queued(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Legt eine Mail in die Warteschlange. @return int|null ID, null wenn nicht möglich (Tabelle fehlt, Warteschlange voll) */
function mail_queue_add(PDO $pdo, string $to, string $subject, string $htmlBody, ?string $fromHeader, string $error): ?int
{
try {
$pending = (int) $pdo->query("SELECT COUNT(*) FROM mail_queue WHERE status = 'pending'")->fetchColumn();
if ($pending >= MAIL_QUEUE_MAX_PENDING) return null;
$now = db_now();
$pdo->prepare("INSERT INTO mail_queue (to_email, from_header, subject, body, status, attempts, next_attempt_at, last_error, created_at) VALUES (?,?,?,?, 'pending', 1, ?, ?, ?)")
->execute([mb_substr($to, 0, 255), $fromHeader !== null && $fromHeader !== '' ? mb_substr($fromHeader, 0, 255) : null, mb_substr($subject, 0, 500), $htmlBody,
date('Y-m-d H:i:s', time() + MAIL_QUEUE_BACKOFF_MINUTES[0] * 60), mb_substr($error, 0, 400), $now]);
return (int) $pdo->lastInsertId();
} catch (Throwable $e) {
error_log('mail_queue_add: ' . $e->getMessage());
return null;
}
}
/**
* Versucht fällige Mails erneut zu senden (höchstens $limit pro Lauf). Jede Mail wird vorher atomar "reserviert", damit zwei
* gleichzeitige Läufe sie nicht doppelt senden.
* @return array{sent:int, failed:int, retry:int, gave_up:int}
*/
function mail_queue_run(PDO $pdo, int $limit = 20): array
{
$res = ['sent' => 0, 'failed' => 0, 'retry' => 0, 'gave_up' => 0];
try {
$now = db_now();
$due = $pdo->prepare("SELECT id FROM mail_queue WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY id LIMIT " . max(1, $limit));
$due->execute([$now]);
$ids = $due->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $res;
}
foreach ($ids as $id) {
// Reservieren: nur wer die Zeile verschiebt, darf senden
$claim = $pdo->prepare("UPDATE mail_queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending' AND next_attempt_at <= ?");
$claim->execute([date('Y-m-d H:i:s', time() + 600), $id, db_now()]);
if ($claim->rowCount() !== 1) continue;
$s = $pdo->prepare('SELECT * FROM mail_queue WHERE id = ?');
$s->execute([$id]);
$m = $s->fetch();
if (!$m) continue;
$ok = send_mail($m['to_email'], $m['subject'], $m['body'], $m['from_header'] ?: null, false);
$attempts = (int) $m['attempts'] + 1;
if ($ok) {
$pdo->prepare("UPDATE mail_queue SET status = 'sent', attempts = ?, sent_at = ?, last_error = NULL WHERE id = ?")->execute([$attempts, db_now(), $id]);
$res['sent']++;
continue;
}
$err = mb_substr(smtp_last_error() !== '' ? smtp_last_error() : 'Versand fehlgeschlagen', 0, 400);
$tooOld = time() - (int) strtotime((string) $m['created_at']) > MAIL_QUEUE_MAX_AGE_HOURS * 3600;
if ($attempts >= MAIL_QUEUE_MAX_ATTEMPTS || $tooOld) {
$pdo->prepare("UPDATE mail_queue SET status = 'failed', attempts = ?, last_error = ? WHERE id = ?")->execute([$attempts, $err, $id]);
$res['gave_up']++;
} else {
$wait = MAIL_QUEUE_BACKOFF_MINUTES[min($attempts - 1, count(MAIL_QUEUE_BACKOFF_MINUTES) - 1)];
$pdo->prepare("UPDATE mail_queue SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?")->execute([$attempts, $err, date('Y-m-d H:i:s', time() + $wait * 60), $id]);
$res['retry']++;
}
$res['failed']++;
}
// Aufräumen: Erledigtes und Aufgegebenes nach 30 Tagen löschen
try {
$pdo->prepare("DELETE FROM mail_queue WHERE status IN ('sent','failed') AND created_at < ?")->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]);
} catch (Throwable $e) {
}
return $res;
}
/** @return array{pending:int, failed:int, sent:int} Anzahl nach Status (für Anzeige und Gesundheitsprüfung) */
function mail_queue_counts(PDO $pdo): array
{
$out = ['pending' => 0, 'failed' => 0, 'sent' => 0];
try {
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM mail_queue GROUP BY status')->fetchAll() as $r) {
if (isset($out[$r['status']])) $out[$r['status']] = (int) $r['n'];
}
} catch (Throwable $e) {
}
return $out;
}
+126
View File
@@ -0,0 +1,126 @@
<?php
/**
* includes/mail_reload.php
*
* Lädt die Anhänge einer schon abgerufenen E-Mail nachträglich ins Ticket (für Mails, die abgerufen wurden, bevor DeskHub Anhänge
* übernommen hat, oder deren Anhang damals nicht übernommen werden konnte). Die Original-Mail wird im Postfach gesucht: über die
* gespeicherte UID im aktuellen Ordner, sonst über die Message-ID.
*/
require_once __DIR__ . '/imap_spam.php';
require_once __DIR__ . '/mail_text.php';
/**
* Nachricht des Tickets, zu der eine Mail gehört: die Kundennachricht, die zur Zeit des Abrufs angelegt wurde (die nächstliegende).
*
* @param array<int,array{id:int|string,created_at:string}> $threads Kundennachrichten des Tickets
*/
function mail_reload_pick_thread(array $threads, string $sourceCreatedAt): ?int
{
$at = strtotime($sourceCreatedAt) ?: 0;
$best = null;
$bestDiff = PHP_INT_MAX;
foreach ($threads as $t) {
$diff = abs((strtotime((string) $t['created_at']) ?: 0) - $at);
if ($diff < $bestDiff) {
$bestDiff = $diff;
$best = (int) $t['id'];
}
}
return $best;
}
/**
* Findet die Mail im geöffneten Ordner: über die UID (und passende Message-ID, falls beide bekannt), sonst nur über die Message-ID.
*/
function mail_reload_find_message(mixed $mbox, array $source): ?int
{
$wanted = imap_normalize_message_id((string) ($source['message_id'] ?? ''));
$uid = (int) ($source['message_uid'] ?? 0);
if ($uid > 0) {
$num = (int) @imap_msgno($mbox, $uid);
if ($num > 0) {
if ($wanted === '') return $num;
$h = @imap_headerinfo($mbox, $num);
if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return $num;
}
}
if ($wanted !== '') {
$hits = @imap_search($mbox, 'ALL') ?: [];
foreach ($hits as $num) {
$h = @imap_headerinfo($mbox, (int) $num);
if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return (int) $num;
}
}
return null;
}
/**
* Lädt die Anhänge aller Original-Mails eines Tickets nach. Bereits vorhandene Anhänge werden nicht doppelt gespeichert.
*
* @return array{error:?string,added:int,inline:int,html:int,skipped:int,existing:int,mails:int,failed:string[]}
*/
function reload_ticket_mail_attachments(PDO $pdo, array $ticket, ?int $staffId = null): array
{
$res = ['error' => null, 'added' => 0, 'inline' => 0, 'html' => 0, 'skipped' => 0, 'existing' => 0, 'mails' => 0, 'failed' => []];
if (!function_exists('imap_open')) {
$res['error'] = 'PHP-IMAP ist nicht installiert.';
return $res;
}
$ticketId = (int) $ticket['id'];
$srcStmt = $pdo->prepare(
'SELECT tes.*, ia.label, ia.host, ia.port, ia.encryption, ia.username, ia.password_enc
FROM ticket_email_sources tes JOIN imap_accounts ia ON ia.id = tes.imap_account_id
WHERE tes.ticket_id = ? ORDER BY tes.id'
);
$srcStmt->execute([$ticketId]);
$sources = $srcStmt->fetchAll();
if (!$sources) {
$res['error'] = 'Zu diesem Ticket ist keine Original-E-Mail gespeichert (nur per E-Mail abgerufene Tickets).';
return $res;
}
$thStmt = $pdo->prepare("SELECT id, created_at FROM ticket_threads WHERE ticket_id = ? AND author_type = 'customer' AND is_internal_note = 0 ORDER BY id");
$thStmt->execute([$ticketId]);
$threads = $thStmt->fetchAll();
if (!$threads) {
$res['error'] = 'Das Ticket hat keine Kundennachricht, an die Anhänge gehängt werden könnten.';
return $res;
}
foreach ($sources as $source) {
$folder = trim((string) ($source['current_folder'] ?? '')) ?: 'INBOX';
$label = (string) ($source['label'] ?: $source['username']);
$mbox = imap_connect_account($source, $folder);
if (!$mbox) {
$res['failed'][] = "[$label] Postfach nicht erreichbar: " . (imap_last_error() ?: 'unbekannter Fehler');
continue;
}
$num = mail_reload_find_message($mbox, $source);
if ($num === null) {
imap_close($mbox);
$res['failed'][] = "[$label] Original-E-Mail wurde im Ordner „{$folder}“ nicht gefunden (verschoben oder gelöscht?).";
continue;
}
$threadId = mail_reload_pick_thread($threads, (string) $source['created_at']);
$structure = @imap_fetchstructure($mbox, $num) ?: null;
$log = [];
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $num, $no);
// Formatierte Fassung der Nachricht (HTML) nachtragen, wenn es noch keine gibt
$htmlBody = null;
if (ticket_mail_html_ready($pdo) && $structure) {
$htmlBody = mail_html_for_display($structure, $fetchPart);
$has = $pdo->prepare('SELECT message_html FROM ticket_threads WHERE id = ?');
$has->execute([(int) $threadId]);
if ($htmlBody !== null && trim((string) $has->fetchColumn()) === '') {
$pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, (int) $threadId]);
$res['html']++;
}
}
$c = mail_store_attachments($pdo, $structure, $fetchPart, (int) $threadId, $ticketId, $label, $log, true, $htmlBody !== null);
imap_close($mbox);
$res['mails']++;
foreach (['added', 'inline', 'skipped', 'existing'] as $k) $res[$k] += $c[$k];
}
if ($res['added'] + $res['inline'] + $res['html'] > 0) log_activity($pdo, $ticketId, $staffId, 'Anhänge und Bilder aus der E-Mail nachgeladen: ' . $res['added'] . ' Anhang/Anhänge, ' . $res['inline'] . ' Bild(er)' . ($res['html'] ? ', Formatierung' : ''));
return $res;
}
+271
View File
@@ -144,6 +144,277 @@ function mail_collect_text_parts(object $part, string $no = ''): array
return $found; return $found;
} }
/** Dateiname eines Anhangs: MIME-Kodierung (=?UTF-8?B?…?=) und RFC 2231 (UTF-8''…) werden aufgelöst. */
function mail_decode_filename(string $value): string
{
$value = trim($value);
if (preg_match("/^([A-Za-z0-9_-]+)'[^']*'(.*)$/", $value, $m)) {
$decoded = rawurldecode($m[2]);
$cs = strtolower($m[1]);
if ($cs !== 'utf-8' && $cs !== 'utf8') {
try { $decoded = (string) mb_convert_encoding($decoded, 'UTF-8', $cs); } catch (Throwable) { /* bleibt */ }
}
return $decoded;
}
if (str_contains($value, '=?')) {
$decoded = function_exists('iconv_mime_decode') ? @iconv_mime_decode($value, 0, 'UTF-8') : false;
if ($decoded === false || $decoded === '') $decoded = mb_decode_mimeheader($value);
return (string) $decoded;
}
return mb_check_encoding($value, 'UTF-8') ? $value : (string) mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
}
/**
* Sammelt die Anhänge einer IMAP-Struktur: Teile mit Dateinamen oder der Anordnung "attachment" sowie weitergeleitete Mails
* (message/rfc822). Bilder mit Content-ID, die im Text der Mail verwendet werden (Logos, Signaturbilder, eingefügte Fotos), sind als
* "inline" gekennzeichnet: Sie gehören zur Darstellung der Mail und werden nur dann als Anhang gezeigt, wenn es keine HTML-Fassung gibt.
*
* @return array<int,array{no:string,name:string,encoding:int,bytes:int,inline:bool,cid:string,eml:bool}>
*/
function mail_collect_attachment_parts(object $part, string $no = ''): array
{
$type = (int) ($part->type ?? -1);
if (!empty($part->parts) && is_array($part->parts) && $type !== 2) {
$found = [];
foreach ($part->parts as $i => $sub) {
$found = array_merge($found, mail_collect_attachment_parts($sub, $no === '' ? (string) ($i + 1) : $no . '.' . ($i + 1)));
}
return $found;
}
$disposition = strtolower((string) ($part->disposition ?? ''));
$name = '';
foreach (array_merge((array) ($part->dparameters ?? []), (array) ($part->parameters ?? [])) as $p) {
$attr = strtolower((string) ($p->attribute ?? ''));
if ($name === '' && ($attr === 'filename' || $attr === 'filename*' || $attr === 'name' || $attr === 'name*')) $name = mail_decode_filename((string) $p->value);
}
$cid = strtolower(trim((string) ($part->id ?? ''), " \t\r\n<>"));
$isEml = $type === 2;
$inline = $type === 5 && $disposition !== 'attachment' && !empty($part->ifid) && $cid !== '';
if (!$isEml && !$inline && $name === '' && $disposition !== 'attachment') return [];
if ($name === '') {
if ($isEml) $name = 'Weitergeleitete E-Mail.eml';
else $name = 'Anhang' . (!empty($part->subtype) ? '.' . strtolower((string) $part->subtype) : '');
}
return [['no' => $no === '' ? '1' : $no, 'name' => $name, 'encoding' => (int) ($part->encoding ?? 0), 'bytes' => (int) ($part->bytes ?? 0), 'inline' => $inline, 'cid' => $cid, 'eml' => $isEml]];
}
/** Dateiendung für ein Rasterbild anhand seines Inhalts (nur gif, jpg, png, webp), sonst null: HTML oder Skripte im Bild-Gewand fallen so durch. */
function mail_image_extension(string $data): ?string
{
$info = @getimagesizefromstring($data);
if (!$info) return null;
return match ($info[2] ?? 0) {
IMAGETYPE_GIF => 'gif',
IMAGETYPE_JPEG => 'jpg',
IMAGETYPE_PNG => 'png',
IMAGETYPE_WEBP => 'webp',
default => null,
};
}
/** Name einer weitergeleiteten Mail (.eml) aus ihrem Betreff. */
function mail_eml_name(string $raw, string $fallback): string
{
if (preg_match('/^Subject:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/mi', substr($raw, 0, 8192), $m)) {
$subject = trim(mail_decode_filename(preg_replace('/\r?\n[ \t]+/', ' ', $m[1]) ?? ''));
$subject = trim((string) preg_replace('~[\\\\/:*?"<>|\x00-\x1F]+~u', ' ', $subject));
if ($subject !== '') return mb_substr($subject, 0, 120) . '.eml';
}
return $fallback;
}
/**
* HTML-Fassung einer Mail für die Anzeige im Ticket (bereinigt: nur erlaubte Tags und Eigenschaften), null ohne HTML-Teil.
*
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
*/
function mail_html_for_display(object $structure, callable $fetchPart): ?string
{
foreach (mail_collect_text_parts($structure) as $p) {
if ($p['subtype'] !== 'HTML') continue;
$html = mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset']);
if (trim($html) === '' || strlen($html) > 3000000) return null;
$clean = sanitize_staff_html($html);
if (trim(strip_tags($clean)) === '' && stripos($clean, '<img') === false) return null;
return strlen($clean) > 400000 ? null : $clean;
}
return null;
}
/** Rohdaten eines Mail-Teils nach der Transferkodierung (Anhänge sind Binärdaten, kein Zeichensatz). */
function mail_decode_binary(string $raw, int $encoding): string
{
return match ($encoding) {
3 => (string) base64_decode(preg_replace('/\s+/', '', $raw) ?? $raw, false),
4 => quoted_printable_decode($raw),
default => $raw,
};
}
/**
* Übernimmt die Anhänge einer Mail an die Nachricht $threadId des Tickets. Nicht übernommene Anhänge (Typ nicht erlaubt, zu groß)
* werden im Verlauf des Tickets vermerkt, damit niemand glaubt, die Mail hätte keinen gehabt.
*
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
* @param bool $skipExisting Anhänge, die an dieser Nachricht schon hängen (gleicher Name und gleiche Größe), nicht noch einmal speichern
* @param bool $inlineImages die Mail hat eine HTML-Fassung: Bilder mit Content-ID werden als eingebettete Bilder gespeichert (und nicht als
* Anhang aufgelistet); sonst zählen sie wie jeder andere Anhang
* @return array{added:int,inline:int,skipped:int,existing:int}
*/
function mail_store_attachments(PDO $pdo, ?object $structure, callable $fetchPart, int $threadId, int $ticketId, string $label, array &$log, bool $skipExisting = false, bool $inlineImages = false): array
{
$count = ['added' => 0, 'inline' => 0, 'skipped' => 0, 'existing' => 0];
if (!$structure || $threadId <= 0) return $count;
$max = get_max_upload_size();
$parts = mail_collect_attachment_parts($structure);
$imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
foreach (array_slice($parts, 0, 40) as $p) {
$reason = null;
$name = $p['name'];
$asInline = $inlineImages && $p['inline'];
// Vorab nach Größe prüfen, ohne den Teil zu laden (Base64 ist rund ein Drittel größer als die Datei)
$tooBig = $p['bytes'] > 0 && (int) ($p['encoding'] === 3 ? $p['bytes'] * 0.75 : $p['bytes']) > $max;
if ($tooBig) $reason = 'zu groß (max. ' . round($max / 1048576) . ' MB)';
if ($reason === null) {
$raw = $fetchPart($p['no']);
$data = $p['eml'] ? $raw : mail_decode_binary($raw, $p['encoding']);
$opts = ['extra_allowed' => mail_attachment_extra_extensions()];
if ($p['eml']) {
$name = mail_eml_name($data, $name);
if (!str_ends_with(strtolower($name), '.eml')) $name .= '.eml';
$opts['extra_allowed'][] = 'eml';
} elseif ($asInline) {
$ext = mail_image_extension($data);
if ($ext === null) {
$log[] = "[$label] → Eingebettetes Bild übersprungen (kein Bild): {$name}";
continue;
}
if (!in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), $imageExts, true)) $name = 'bild-' . substr(md5($p['cid']), 0, 8) . '.' . $ext;
$opts = ['extra_allowed' => $imageExts, 'content_id' => $p['cid'], 'inline' => true];
}
if ($skipExisting) {
if ($asInline) {
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND content_id = ?');
$dup->execute([$threadId, mb_substr($p['cid'], 0, 255)]);
} else {
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ?');
$dup->execute([$threadId, mb_substr(trim(basename(str_replace(chr(92), '/', $name))), 0, 255), strlen($data)]);
}
if ((int) $dup->fetchColumn() > 0) {
$count['existing']++;
continue;
}
}
$res = store_ticket_attachment_data($pdo, $threadId, $name, $data, $opts);
if ($res['ok']) {
$log[] = "[$label] → " . ($asInline ? 'Eingebettetes Bild' : 'Anhang') . " übernommen: {$res['name']}";
$count[$asInline ? 'inline' : 'added']++;
continue;
}
$reason = (string) ($res['error'] ?? 'unbekannter Fehler');
}
if ($asInline) {
$log[] = "[$label] → Eingebettetes Bild nicht übernommen: {$name} ({$reason})";
continue;
}
$count['skipped']++;
$log[] = "[$label] → Anhang nicht übernommen: {$name} ({$reason})";
log_activity($pdo, $ticketId, null, 'Anhang aus E-Mail nicht übernommen: ' . mb_substr($name, 0, 80) . ' (' . mb_substr($reason, 0, 100) . ')');
}
if (count($parts) > 40) log_activity($pdo, $ticketId, null, 'Anhänge aus E-Mail nicht übernommen: mehr als 40 Teile, die übrigen fehlen');
return $count;
}
/** Kopfzeilen-Schlüssel eines zitierten Mailkopfs ("Von: …", "Gesendet: …") am Zeilenanfang. */
const MAIL_QUOTE_HEADER_KEYS = '(?:Von|From|Gesendet|Sent|Datum|Date|An|To|Cc|Betreff|Subject)';
/**
* Trennt eine Nachricht (Text) in den neuen Teil und den zitierten Verlauf früherer Mails. Der Verlauf beginnt bei der ersten
* Zeile, die ein Zitat ankündigt: "Am … schrieb …:", "On … wrote:", "-----Ursprüngliche Nachricht-----", ein zitierter Mailkopf
* ("Von: … Gesendet: … An: … Betreff: …") oder eine mit ">" beginnende Zeile. Steht vor dieser Stelle kein Text (die ganze Mail ist
* zitiert oder weitergeleitet), wird nichts getrennt.
*
* @return array{0:string,1:string} [neuer Teil, zitierter Verlauf (leer, wenn es keinen gibt)]
*/
function mail_split_quoted_text(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$n = count($lines);
for ($i = 0; $i < $n; $i++) {
$line = $lines[$i];
$isMarker = false;
if (preg_match('/^\s*-{2,}\s*(Original Message|Ursprüngliche Nachricht|Urspr\. Nachricht|Weitergeleitete Nachricht|Forwarded message)\s*-{2,}\s*$/iu', $line)) {
$isMarker = true;
} elseif (preg_match('/^\s*(Am|On)\s.+/iu', $line)) {
$joined = $line . ' ' . ($lines[$i + 1] ?? '');
if (preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $joined) || preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $line)) $isMarker = true;
} elseif (preg_match('/^\s*>/', $line)) {
$isMarker = true;
} elseif (preg_match('/^\s*' . MAIL_QUOTE_HEADER_KEYS . '\s*:/iu', $line)) {
// Zitierter Mailkopf: mehrere solche Zeilen hintereinander, darunter "Von"
$keys = [];
for ($j = $i; $j < min($n, $i + 7); $j++) {
if (preg_match('/^\s*(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', $lines[$j], $m)) $keys[] = mb_strtolower($m[1]);
elseif (trim($lines[$j]) !== '') break;
}
if (count($keys) >= 3 && (in_array('von', $keys, true) || in_array('from', $keys, true))) $isMarker = true;
}
if (!$isMarker) continue;
$body = trim(implode("\n", array_slice($lines, 0, $i)));
if ($body === '') return [$text, ''];
return [$body, trim(implode("\n", array_slice($lines, $i)))];
}
return [$text, ''];
}
/** Bringt ein HTML-Bruchstück in Ordnung (offene Tags schließen, überzählige schließende Tags entfernen). */
function mail_balance_html(string $html): string
{
if (trim($html) === '' || !class_exists('DOMDocument')) return $html;
$prev = libxml_use_internal_errors(true);
$dom = new DOMDocument();
$ok = $dom->loadHTML('<?xml encoding="UTF-8"><div id="dh-root">' . $html . '</div>', LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($prev);
if (!$ok) return $html;
$root = $dom->getElementById('dh-root');
if (!$root) return $html;
$out = '';
foreach ($root->childNodes as $child) $out .= $dom->saveHTML($child);
return $out;
}
/**
* Trennt bereinigtes Mail-HTML in den neuen Teil und den zitierten Verlauf. Gesucht wird die Trennlinie (<hr>) oder der Block mit
* oberem Rahmen, den Outlook vor dem zitierten Mailkopf setzt, und danach der Mailkopf mit mindestens drei Angaben (Von, Gesendet, An …).
*
* @return array{0:string,1:string}
*/
function mail_split_quoted_html(string $html): array
{
if (!preg_match_all('/<hr\b[^>]*>|<div\b[^>]*border-top[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) return [$html, ''];
foreach ($matches[0] as [$tag, $offset]) {
$after = html_entity_decode(preg_replace('/<[^>]*>/', ' ', substr($html, $offset, 1500)) ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
$after = preg_replace('/\s+/u', ' ', $after) ?? $after;
preg_match_all('/(?:^|\s)(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', mb_substr($after, 0, 600), $m);
$keys = array_map('mb_strtolower', $m[1] ?? []);
if (count($keys) < 3 || !(in_array('von', $keys, true) || in_array('from', $keys, true))) continue;
$head = substr($html, 0, $offset);
if (trim(strip_tags($head)) === '' && stripos($head, '<img') === false) return [$html, ''];
$a = mail_balance_html($head);
$b = mail_balance_html(substr($html, $offset));
return [$a, $b];
}
return [$html, ''];
}
/** Zuklappbarer Block für zitierten Verlauf (derselbe Aufbau wie bei eingeklappten Zitaten). */
function mail_quote_wrap(string $contentHtml): string
{
return '<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Verlauf anzeigen\':\'▼ Verlauf ausblenden\'">▶ Verlauf anzeigen</button><div class="email-quoted" style="display:none">' . $contentHtml . '</div></div>';
}
/** /**
* Bester Text einer Mail: bevorzugt text/plain; ist der nur eine kurze Floskel ("Bitte HTML-Ansicht verwenden") und die HTML-Fassung * Bester Text einer Mail: bevorzugt text/plain; ist der nur eine kurze Floskel ("Bitte HTML-Ansicht verwenden") und die HTML-Fassung
* deutlich länger, wird die HTML-Fassung als Text genommen. * deutlich länger, wird die HTML-Fassung als Text genommen.
@@ -0,0 +1,104 @@
<?php
/**
* includes/mail_threading.php
*
* Ordnet eine eingehende Mail einem bestehenden Ticket zu (Antwort statt neues Ticket):
* 1. Ticketnummer im Betreff "[#260101-0001]" (so wie bisher, ohne weitere Prüfung);
* 2. andere Nummern im Betreff "[#923425]" (zum Beispiel aus einem früheren Ticketsystem), wenn es ein Ticket mit genau dieser Nummer gibt;
* 3. die Kopfzeilen In-Reply-To und References: DeskHub schreibt in jede Mail zu einem Ticket eine Message-ID mit der Ticketnummer
* (<deskhub.260101-0001.…@host>), die Mailprogramme in der Antwort mitschicken. Außerdem zählen die Message-IDs früher
* abgerufener Mails des Tickets.
* Bei 2 und 3 (die nicht schon durch die DeskHub-Nummer im Betreff gesichert sind) muss der Absender zum Ticket gehören
* (Auftraggeber oder früherer Verfasser), damit niemand mit einer geratenen Nummer fremden Tickets Nachrichten unterschieben kann.
*/
/** Message-IDs aus In-Reply-To und References, klein und ohne spitze Klammern. @return string[] */
function mail_reference_ids(string $rawHeader): array
{
$ids = [];
// Kopfzeilen können gefaltet sein (Fortsetzungszeilen beginnen mit Leerraum)
$unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $rawHeader) ?? $rawHeader;
foreach (['In-Reply-To', 'References'] as $name) {
if (preg_match_all('/^' . $name . ':(.*)$/mi', $unfolded, $m)) {
foreach ($m[1] as $value) {
if (preg_match_all('/<([^<>\s]+)>/', $value, $mm)) foreach ($mm[1] as $id) $ids[] = strtolower(trim($id));
}
}
}
return array_values(array_unique($ids));
}
/** Ticketnummer aus einer von DeskHub erzeugten Message-ID (<deskhub.NUMMER.zufall@host>) oder null. */
function mail_ticket_number_from_message_id(string $id): ?string
{
return preg_match('/^deskhub\.([a-z0-9-]{4,24})\.[a-z0-9]+@/i', $id, $m) ? $m[1] : null;
}
/** Message-ID für eine ausgehende Mail zu einem Ticket (Nummer steht im Betreff als [#NUMMER]); null, wenn der Betreff keine Nummer enthält. */
function mail_ticket_message_id(string $subject, string $host): ?string
{
if (!preg_match('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $m)) return null;
$host = preg_replace('/[^A-Za-z0-9.-]/', '', $host) ?: 'localhost';
return '<deskhub.' . $m[1] . '.' . bin2hex(random_bytes(8)) . '@' . $host . '>';
}
/** Gehört der Absender zum Ticket (Auftraggeber oder früherer Verfasser einer Kundennachricht)? */
function mail_sender_belongs_to_ticket(PDO $pdo, array $ticket, string $fromEmail): bool
{
$from = strtolower(trim($fromEmail));
if ($from === '') return false;
if (strtolower(trim((string) ($ticket['requester_email'] ?? ''))) === $from) return true;
try {
$stmt = $pdo->prepare("SELECT 1 FROM ticket_threads tt JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? AND tt.author_type = 'customer' AND LOWER(u.email) = ? LIMIT 1");
$stmt->execute([(int) $ticket['id'], $from]);
return (bool) $stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
/**
* Sucht das Ticket zu einer eingehenden Mail.
*
* @return array{ticket:array,via:string}|null via: subject (DeskHub-Nummer), legacy (andere Nummer im Betreff), header (In-Reply-To/References)
*/
function mail_find_ticket_for_reply(PDO $pdo, string $subject, string $rawHeader, string $fromEmail): ?array
{
$byNumber = $pdo->prepare('SELECT * FROM tickets WHERE LOWER(ticket_number) = LOWER(?)');
// 1. DeskHub-Nummer im Betreff wie bisher
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
$byNumber->execute([$m[1]]);
if ($t = $byNumber->fetch()) return ['ticket' => $t, 'via' => 'subject'];
}
// 2. Andere Nummer im Betreff (genau diese Nummer muss es als Ticket geben)
if (preg_match_all('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $all)) {
foreach ($all[1] as $num) {
$byNumber->execute([$num]);
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'legacy'];
}
}
// 3. Kopfzeilen
$ids = mail_reference_ids($rawHeader);
foreach ($ids as $id) {
$num = mail_ticket_number_from_message_id($id);
if ($num !== null) {
$byNumber->execute([$num]);
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
}
}
if ($ids) {
// Message-IDs früher abgerufener Mails des Tickets (gespeichert mit oder ohne spitze Klammern): grob in SQL vorfiltern, genau in PHP prüfen
$ids = array_slice($ids, 0, 15);
$likes = implode(' OR ', array_fill(0, count($ids), 'LOWER(message_id) LIKE ?'));
$stmt = $pdo->prepare("SELECT ticket_id, message_id FROM ticket_email_sources WHERE message_id IS NOT NULL AND ({$likes}) ORDER BY id DESC LIMIT 50");
$stmt->execute(array_map(fn($id) => '%' . str_replace(['%', '_'], ['\%', '\_'], $id) . '%', $ids));
foreach ($stmt->fetchAll() as $row) {
if (!in_array(strtolower(trim((string) $row['message_id'], '<> ')), $ids, true)) continue;
$byId = $pdo->prepare('SELECT * FROM tickets WHERE id = ?');
$byId->execute([(int) $row['ticket_id']]);
if (($t = $byId->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
}
}
return null;
}
+237 -55
View File
@@ -15,7 +15,27 @@ function smtp_last_error(): string
return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError; return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError;
} }
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool /**
* Sendet eine Mail. Scheitert der Versand, obwohl ein Versandweg eingerichtet ist, kommt die Mail in die Warteschlange
* (includes/mail_queue.php) und wird später erneut versucht; der Rückgabewert bleibt dann false, mail_last_queued() ist true.
* $queue = false für Test-Mails und für die Wiederholungsläufe der Warteschlange selbst.
*/
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null, bool $queue = true): bool
{
mail_attempted(false);
mail_last_queued(false);
$ok = send_mail_now($to, $subject, $htmlBody, $fromOverride);
if (!$ok && $queue && mail_attempted() && mail_queue_enabled()) {
try {
if (mail_queue_add(get_db(), $to, $subject, $htmlBody, $fromOverride, smtp_last_error()) !== null) mail_last_queued(true);
} catch (Throwable $e) {
error_log('send_mail queue: ' . $e->getMessage());
}
}
return $ok;
}
function send_mail_now(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
{ {
$cfg = [ $cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1', 'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
@@ -45,6 +65,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
(string) $own['smtp_username'], (string) $own['smtp_username'],
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '', $own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
); );
mail_attempted(true);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody); $ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) { if (!$ok) {
smtp_last_error($mailer->getLastError()); smtp_last_error($mailer->getLastError());
@@ -62,6 +83,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
get_setting('smtp_username', ''), get_setting('smtp_username', ''),
decrypt_secret(get_setting('smtp_password', '')), decrypt_secret(get_setting('smtp_password', '')),
); );
mail_attempted(true);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody); $ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) { if (!$ok) {
smtp_last_error($mailer->getLastError()); smtp_last_error($mailer->getLastError());
@@ -72,9 +94,13 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
} }
$headers = "MIME-Version: 1.0\r\n"; $headers = "MIME-Version: 1.0\r\n";
$headers .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
$headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "From: {$fromHeader}\r\n"; $headers .= "From: {$fromHeader}\r\n";
$mid = mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $fromHeader, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost'));
if ($mid !== null) $headers .= "Message-ID: {$mid}\r\n";
mail_attempted(true);
return @mail($to, $subject, $htmlBody, $headers); return @mail($to, $subject, $htmlBody, $headers);
} }
@@ -169,70 +195,118 @@ function render_email_template(string $key, array $vars, string $defaultSubject,
// Einheitliches, modernes E-Mail-Design // Einheitliches, modernes E-Mail-Design
// ==================================================================== // ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin // email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails // angepasst) mit demselben Rahmen, damit alle System-Mails gleich aussehen.
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare // email_badge()/email_button()/email_p() sind wiederverwendbare Bausteine.
// Bausteine für die Standardtexte der einzelnen Vorlagen. // Alles ist tabellenbasiert mit Inline-Styles und für Outlook (Word-Engine),
// Gmail, Apple Mail und Handys gebaut: feste Breite 600 px, die auf kleinen
// Bildschirmen schrumpft; der Knopf ist in Outlook ein echter Knopf (VML).
/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */ const EMAIL_FONT = "'Segoe UI',-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif";
/** Rahmen (Kopf mit Name oder Logo, weiße Karte, Fußzeile) um einen HTML-Textkörper. */
function email_wrap(string $innerHtml): string function email_wrap(string $innerHtml): string
{ {
$siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support')); $siteNameRaw = (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support');
$siteName = e($siteNameRaw);
$font = EMAIL_FONT;
$logo = (string) get_setting('logo_path', '');
$brand = '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-.01em;">' . $siteName . '</span>';
if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) {
$src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo);
$img = '<img src="' . $src . '" alt="' . $siteName . '" height="34" style="display:inline-block;height:34px;width:auto;border:0;vertical-align:middle;">';
$brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;vertical-align:middle;padding-left:12px;">' . $siteName . '</span>';
}
$home = e(base_url());
return <<<HTML return <<<HTML
<div style="background:#f5f7f9;padding:32px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#eef2f6" style="background:#eef2f6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;"> <tr>
<tr> <td align="center" style="padding:28px 12px;">
<td style="background:#0f6f6a;padding:20px 28px;"> <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span> <tr>
</td> <td bgcolor="#0f6f6a" style="background:#0f6f6a;border-radius:14px 14px 0 0;padding:24px 36px;">
</tr> {$brand}
<tr> </td>
<td style="padding:32px 28px;"> </tr>
{$innerHtml} <tr>
</td> <td height="4" bgcolor="#14958d" style="background:#14958d;font-size:0;line-height:0;height:4px;">&nbsp;</td>
</tr> </tr>
<tr> <tr>
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;"> <td bgcolor="#ffffff" style="background:#ffffff;padding:36px 36px 34px;font-family:{$font};color:#1f2937;font-size:15px;line-height:1.6;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p> {$innerHtml}
</td> </td>
</tr> </tr>
</table> <tr>
</div> <td bgcolor="#ffffff" style="background:#ffffff;border:1px solid #e2e8f0;border-top:1px solid #edf1f5;border-radius:0 0 14px 14px;padding:18px 36px;font-family:{$font};">
<span style="color:#94a3b8;font-size:12px;line-height:1.5;">Diese E-Mail wurde automatisch von <a href="{$home}" style="color:#64748b;text-decoration:none;">{$siteName}</a> versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
HTML; HTML;
} }
/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */ /** Hervorgehobene Karte, z.B. für die Ticketnummer (volle Breite, farbiger Balken links). $label/$value dürfen {platzhalter} enthalten. */
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
{ {
$font = EMAIL_FONT;
return <<<HTML return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 22px;">
<tr> <tr>
<td style="background:{$bg};border-radius:8px;padding:14px 18px;"> <td width="5" bgcolor="{$color}" style="background:{$color};width:5px;font-size:0;line-height:0;">&nbsp;</td>
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div> <td bgcolor="{$bg}" style="background:{$bg};padding:16px 20px;font-family:{$font};">
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div> <div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:{$color};line-height:1.4;">{$label}</div>
<div style="font-size:24px;font-weight:700;color:{$color};line-height:1.3;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
</td> </td>
</tr> </tr>
</table> </table>
HTML; HTML;
} }
/** Button-artiger Call-to-Action-Link. */ /** Knopf mit Link: in Outlook ein echter Knopf (VML), sonst ein abgerundeter Link. */
function email_button(string $label, string $url, string $color = '#0f6f6a'): string function email_button(string $label, string $url, string $color = '#0f6f6a'): string
{ {
$font = EMAIL_FONT;
$plain = trim(strip_tags($label));
$w = max(180, (int) round(mb_strlen($plain) * 9.2 + 64));
return <<<HTML return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:6px 0 4px;">
<tr> <tr>
<td style="background:{$color};border-radius:8px;"> <td align="center" bgcolor="{$color}" style="background:{$color};border-radius:10px;">
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a> <!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{$url}" style="height:48px;v-text-anchor:middle;width:{$w}px;" arcsize="20%" stroke="f" fillcolor="{$color}"><w:anchorlock/><center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:15px;font-weight:bold;">{$label}</center></v:roundrect><![endif]-->
<!--[if !mso]><!-- --><a href="{$url}" style="display:inline-block;padding:14px 30px;font-family:{$font};color:#ffffff;font-size:15px;font-weight:600;line-height:1.2;text-decoration:none;border-radius:10px;background:{$color};">{$label}</a><!--<![endif]-->
</td> </td>
</tr> </tr>
</table> </table>
HTML; HTML;
} }
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */ /** Normaler Absatz. */
function email_p(string $html, string $marginBottom = '16px'): string function email_p(string $html, string $marginBottom = '16px'): string
{ {
return "<p style=\"margin:0 0 {$marginBottom};color:#16202a;font-size:15px;line-height:1.5;\">{$html}</p>"; return "<p style=\"margin:0 0 {$marginBottom};color:#1f2937;font-size:15px;line-height:1.6;font-family:" . EMAIL_FONT . ";\">{$html}</p>";
}
/** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */
function email_quote(string $html): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px;"><tr>'
. '<td width="4" bgcolor="#0f6f6a" style="background:#0f6f6a;width:4px;font-size:0;line-height:0;">&nbsp;</td>'
. '<td bgcolor="#f6f9fb" style="background:#f6f9fb;padding:16px 20px;color:#1f2937;font-size:15px;line-height:1.65;font-family:' . EMAIL_FONT . ';">' . $html . '</td>'
. '</tr></table>';
}
/** Überschrift im Mailtext. */
function email_h(string $text): string
{
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:' . EMAIL_FONT . ';">' . $text . '</h2>';
}
/** Kleingedruckter Hinweis unter dem Knopf. */
function email_small(string $html): string
{
return '<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.55;font-family:' . EMAIL_FONT . ';">' . $html . '</p>';
} }
/** /**
@@ -242,25 +316,28 @@ function email_p(string $html, string $marginBottom = '16px'): string
*/ */
function email_ticket_created_default_body(): string function email_ticket_created_default_body(): string
{ {
return email_p('Hallo {customer_name},') return email_h('Wir haben Ihre Anfrage erhalten')
. email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px') . email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
. email_badge('Ihre Ticketnummer', '#{ticket_number}') . email_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('<strong>Betreff:</strong> {subject}', '24px') . email_p('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}') . email_button('Ticket ansehen', '{ticket_url}')
. '<p style="margin:24px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.</p>'; . email_small('Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.');
} }
function email_new_reply_default_body(): string function email_new_reply_default_body(): string
{ {
return email_p('Hallo {customer_name},') return email_h('Neue Antwort zu Ihrem Ticket')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:') . email_p('Hallo {customer_name},')
. '<div style="background:#f8fafc;border-left:3px solid #0f6f6a;border-radius:0 6px 6px 0;padding:14px 18px;margin:0 0 24px;color:#16202a;font-size:14px;line-height:1.6;">{message}</div>' . email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:', '18px')
. email_quote('{message}')
. email_button('Ticket ansehen', '{ticket_url}'); . email_button('Ticket ansehen', '{ticket_url}');
} }
function email_new_ticket_department_default_body(): string function email_new_ticket_department_default_body(): string
{ {
return email_p('Ein neues Ticket wurde erstellt.') return email_h('Neues Ticket')
. email_p('Ein neues Ticket wurde erstellt.', '18px')
. email_badge('Ticket', '#{ticket_number}') . email_badge('Ticket', '#{ticket_number}')
. email_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px') . email_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '6px') . email_p('<strong>Abteilung:</strong> {department_name}', '6px')
@@ -270,7 +347,8 @@ function email_new_ticket_department_default_body(): string
function email_ticket_overdue_default_body(): string function email_ticket_overdue_default_body(): string
{ {
return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:') return email_h('SLA-Frist überschritten')
. email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:', '18px')
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2') . email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
. email_p('<strong>Betreff:</strong> {subject}', '6px') . email_p('<strong>Betreff:</strong> {subject}', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '24px') . email_p('<strong>Abteilung:</strong> {department_name}', '24px')
@@ -279,8 +357,9 @@ function email_ticket_overdue_default_body(): string
function email_live_chat_transcript_default_body(): string function email_live_chat_transcript_default_body(): string
{ {
return email_p('Hallo {customer_name},') return email_h('Ihr Chat-Verlauf')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px') . email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Anfrage:', '20px')
. '{transcript}' . '{transcript}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px') . email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}'); . email_button('Ticket ansehen', '{ticket_url}');
@@ -289,9 +368,10 @@ function email_live_chat_transcript_default_body(): string
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */ /** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
function email_password_reset_body(string $link): string function email_password_reset_body(string $link): string
{ {
$inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist <strong>1 Stunde</strong> lang gültig.') $inner = email_h('Passwort zurücksetzen')
. email_p('Klicken Sie auf den folgenden Knopf, um ein neues Passwort festzulegen. Der Link ist <strong>1 Stunde</strong> lang gültig.', '22px')
. email_button('Passwort zurücksetzen', e($link)) . email_button('Passwort zurücksetzen', e($link))
. '<p style="margin:20px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.</p>'; . email_small('Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.');
return email_wrap($inner); return email_wrap($inner);
} }
@@ -323,10 +403,11 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */ /** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
function email_customer_reminder_default_body(): string function email_customer_reminder_default_body(): string
{ {
return '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>' return email_h('Brauchen Sie noch Hilfe?')
. '<p style="margin:0 0 12px;color:#334155;font-size:15px;line-height:1.6;">Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.</p>' . email_p('Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.', '16px')
. '{close_note}' . '{close_note}'
. '<p style="margin:20px 0 0;"><a href="{ticket_url}" style="background:#0f6f6a;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600;display:inline-block;">Ticket ansehen</a></p>'; . '<div style="height:8px;line-height:8px;font-size:0;">&nbsp;</div>'
. email_button('Ticket ansehen', '{ticket_url}');
} }
function notify_customer_ticket_created(array $ticket): void function notify_customer_ticket_created(array $ticket): void
@@ -387,7 +468,7 @@ function notify_customer_chat_transcript(array $ticket, array $messages): void
// rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich // rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen. // auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
$rawMessage = (string) $m['message']; $rawMessage = (string) $m['message'];
$plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage) $plainMessage = message_is_html($rawMessage)
? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: $rawMessage; : $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff); $rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
@@ -433,7 +514,7 @@ function notify_staff_new_ticket(array $ticket): void
if ($deptId) { if ($deptId) {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND ( "SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (
role = 'admin' role = 'admin'
OR department_id = ? OR department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1) OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
@@ -441,9 +522,10 @@ function notify_staff_new_ticket(array $ticket): void
); );
$stmt->execute([$deptId, $deptId]); $stmt->execute([$deptId, $deptId]);
} else { } else {
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'"); $stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
} }
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN); // Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Mail
$recipients = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $stmt->fetchAll(PDO::FETCH_COLUMN)));
if (!$recipients) return; if (!$recipients) return;
[$subject, $body] = render_email_template('new_ticket_department', [ [$subject, $body] = render_email_template('new_ticket_department', [
@@ -464,7 +546,8 @@ function notify_staff_new_ticket(array $ticket): void
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */ /** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
function notify_admins_overdue_ticket(array $ticket): void function notify_admins_overdue_ticket(array $ticket): void
{ {
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN); $pdo = get_db();
$admins = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
if (!$admins) return; if (!$admins) return;
[$subject, $body] = render_email_template('ticket_overdue', [ [$subject, $body] = render_email_template('ticket_overdue', [
@@ -479,3 +562,102 @@ function notify_admins_overdue_ticket(array $ticket): void
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']); email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
} }
} }
// ── SLA-Überschreitung: nur an Betroffene ─────────────────────────────────────
/**
* Wer bei einer SLA-Überschreitung eine Mail bekommt: der Bearbeiter des Tickets (ist er im Urlaub, seine Vertretung), sonst die
* Mitarbeiter der Abteilung (Hauptabteilung oder zusätzliche Abteilung mit Benachrichtigung), ohne zugewiesenes Ticket und ohne Abteilungs-
* Mitarbeiter die aktiven Administratoren. Nie die Postfach-Adresse der Abteilung: Sie wird vom E-Mail-Abruf gelesen und würde Tickets erzeugen.
* @return list<string> E-Mail-Adressen
*/
/** Wer bei SLA-Überschreitung benachrichtigt wird: „admins“ (Standard: nur die Administratoren, nur per E-Mail) oder „assignee“ (Bearbeiter bzw. Abteilung, Mail und Push). */
function sla_notify_mode(): string
{
return get_setting('sla_notify_mode', 'admins') === 'assignee' ? 'assignee' : 'admins';
}
function sla_overdue_recipients(PDO $pdo, array $ticket): array
{
$emails = [];
$assignee = (int) ($ticket['assigned_staff_id'] ?? 0);
if (sla_notify_mode() === 'admins') {
$assignee = 0; // nur Administratoren: weder der Bearbeiter noch die Abteilung
$ticket['department_id'] = null;
}
if ($assignee > 0) {
// Im Urlaub: an die Vertretung; ohne nutzbare Vertretung gar nicht an den Abwesenden (dann gehen die Abteilung bzw. die Administratoren vor)
$target = staff_is_on_vacation($assignee) ? vacation_target($pdo, $assignee) : $assignee;
if ($target !== null) {
$q = $pdo->prepare('SELECT email FROM staff WHERE id = ? AND is_active = 1 AND is_on_vacation = 0');
$q->execute([$target]);
$e = $q->fetchColumn();
if ($e) $emails[] = (string) $e;
}
}
if (!$emails && !empty($ticket['department_id'])) {
$q = $pdo->prepare(
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))"
);
$q->execute([(int) $ticket['department_id'], (int) $ticket['department_id']]);
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $q->fetchAll(PDO::FETCH_COLUMN)));
}
if (!$emails) {
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
}
$own = mail_own_system_addresses($pdo);
$out = [];
foreach ($emails as $e) {
$e = trim($e);
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL) && !in_array(strtolower($e), $own, true)) $out[strtolower($e)] = $e;
}
return array_values($out);
}
/** Sendet die SLA-Mail (Vorlage „ticket_overdue“) an die Betroffenen. Liefert die Zahl der Empfänger. */
function notify_overdue_ticket_staff(array $ticket): int
{
$pdo = get_db();
$recipients = sla_overdue_recipients($pdo, $ticket);
if (!$recipients) return 0;
[$subject, $body] = render_email_template('ticket_overdue', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
$body = email_wrap($body);
foreach ($recipients as $email) email_send_and_log($email, $subject, $body, null, 'SLA-überschritten-Mail', $ticket['ticket_number']);
return count($recipients);
}
// ── Schutz vor Mail-Schleifen ────────────────────────────────────────────────
/** Adressen (klein geschrieben), von denen DeskHub selbst Mails verschickt: Standard-Absender und Absender-/Abteilungsadressen. */
function mail_own_system_addresses(PDO $pdo): array
{
$out = [];
$add = function ($a) use (&$out) {
$a = strtolower(trim((string) $a));
if ($a !== '' && str_contains($a, '@')) $out[$a] = true;
};
$add(get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''));
$add(config()['mail']['from_email'] ?? '');
try {
foreach ($pdo->query('SELECT email FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
foreach ($pdo->query('SELECT email FROM departments')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
} catch (Throwable $e) {
}
return array_keys($out);
}
/**
* Ist das eine Mail, die DeskHub selbst verschickt hat (und die über ein abgerufenes Postfach zurückkommt)? Daraus darf kein Ticket werden.
* Erkennung: unsere Kopfzeile X-DeskHub-Mail oder ein Absender, der eine unserer eigenen Systemadressen ist.
*/
function mail_is_own_system_mail(PDO $pdo, string $fromEmail, string $rawHeader = ''): bool
{
if ($rawHeader !== '' && preg_match('/^X-DeskHub-Mail:/mi', $rawHeader)) return true;
return in_array(strtolower(trim($fromEmail)), mail_own_system_addresses($pdo), true);
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* Das Handbuch (PDF) für Administratoren: Adminportal → Hilfe (staff/help.php).
* Die Datei kommt mit jedem Update im Programmpaket mit (docs/DeskHub-Handbuch.pdf). Der Ordner docs/ ist für den Webserver gesperrt
* (.htaccess bzw. die nginx-Regeln); ausgeliefert wird die Datei nur über die Hilfe-Seite an echte Administratoren.
*/
const MANUAL_FILE = 'DeskHub-Handbuch.pdf';
function manual_path(): string
{
return (string) (getenv('DESKHUB_MANUAL_PATH') ?: dirname(__DIR__) . '/docs/' . MANUAL_FILE);
}
/** @return array{size:int,modified_at:string,sha256:string}|null */
function manual_info(): ?array
{
$path = manual_path();
if (!is_file($path)) return null;
$size = (int) filesize($path);
$mtime = (int) filemtime($path);
// Die Prüfsumme wird nur neu berechnet, wenn sich Größe oder Änderungsdatum ändern
$cache = json_decode((string) get_setting('manual_file_cache', ''), true);
if (!is_array($cache) || (int) ($cache['size'] ?? -1) !== $size || (int) ($cache['mtime'] ?? -1) !== $mtime || empty($cache['sha256'])) {
$cache = ['size' => $size, 'mtime' => $mtime, 'sha256' => (string) hash_file('sha256', $path)];
set_setting('manual_file_cache', json_encode($cache));
}
return ['size' => $size, 'modified_at' => date('Y-m-d H:i:s', $mtime), 'sha256' => (string) $cache['sha256']];
}
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* „Meine Woche“: persönliche Kennzahlen eines Mitarbeiters (gelöste Tickets, gesendete Antworten, durchschnittliche Zeit bis zur ersten
* Antwort, Zufriedenheit der Kunden, Pünktlichkeit). Sichtbar nur für den Mitarbeiter selbst (staff/my_week.php).
*/
/** @return array<string,string> */
function my_week_periods(): array
{
return ['week' => 'Diese Woche', 'lastweek' => 'Letzte Woche', 'month' => 'Dieser Monat', 'lastmonth' => 'Letzter Monat'];
}
/**
* Zeitraum und der gleich lange Zeitraum davor (zum Vergleichen).
* @return array{from:string,to:string,prev_from:string,prev_to:string,label:string}
*/
function my_week_range(string $key, ?int $now = null): array
{
$now = $now ?? time();
$labels = my_week_periods();
if (!isset($labels[$key])) $key = 'week';
$monday = strtotime('monday this week', $now);
switch ($key) {
case 'lastweek':
$from = strtotime('-7 days', $monday);
$to = $monday - 1;
break;
case 'month':
$from = strtotime(date('Y-m-01', $now));
$to = $now;
break;
case 'lastmonth':
$first = strtotime(date('Y-m-01', $now));
$from = strtotime('-1 month', $first);
$to = $first - 1;
break;
default:
$from = $monday;
$to = $now;
}
// Vergleichszeitraum nach dem Kalender (nicht in Sekunden): Sommer-/Winterzeit verschiebt sonst die Grenze um eine Stunde
if ($key === 'week' || $key === 'lastweek') {
$prevFrom = strtotime('-7 days', $from);
$prevTo = strtotime('-7 days', $to);
} elseif ($key === 'month') {
$prevFrom = strtotime('-1 month', $from);
$prevTo = min(strtotime('-1 month', $to), $from - 1); // gleich weit in den Vormonat, höchstens bis dessen Ende
} else {
$prevFrom = strtotime('-1 month', $from);
$prevTo = $from - 1;
}
$fmt = fn(int $ts) => date('Y-m-d H:i:s', $ts);
return ['from' => $fmt($from), 'to' => $fmt($to), 'prev_from' => $fmt($prevFrom), 'prev_to' => $fmt($prevTo), 'label' => $labels[$key]];
}
/**
* Kennzahlen eines Mitarbeiters in einem Zeitraum.
* @return array{resolved:int,replies:int,first_response_seconds:?int,first_response_count:int,csat_avg:?float,csat_count:int,csat_positive:?int,on_time:?int}
*/
function my_week_stats(PDO $pdo, int $staffId, string $from, string $to): array
{
$one = function (string $sql, array $params) use ($pdo) {
$s = $pdo->prepare($sql);
$s->execute($params);
return $s->fetch(PDO::FETCH_NUM) ?: [];
};
// Gelöst/geschlossen und mir zugewiesen; „pünktlich“ = vor Ablauf der Lösungsfrist (oder ohne Frist)
[$resolved, $onTime] = $one(
"SELECT COUNT(*), SUM(CASE WHEN resolution_due_at IS NULL OR COALESCE(resolved_at, closed_at) <= resolution_due_at THEN 1 ELSE 0 END)
FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0, 0];
[$replies] = $one(
"SELECT COUNT(*) FROM ticket_threads WHERE staff_id = ? AND author_type = 'staff' AND is_internal_note = 0 AND created_at BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0];
// Zeit bis zur ersten Antwort: nur Tickets, deren erste öffentliche Antwort von mir stammt (Arbeitszeit, wenn Geschäftszeiten eingerichtet sind)
$st = $pdo->prepare(
"SELECT t.created_at, t.first_responded_at FROM tickets t
WHERE t.is_spam = 0 AND t.first_responded_at BETWEEN ? AND ?
AND (SELECT th.staff_id FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'staff' AND th.is_internal_note = 0 ORDER BY th.created_at, th.id LIMIT 1) = ?
ORDER BY t.id DESC LIMIT 500"
);
$st->execute([$from, $to, $staffId]);
$sum = 0; $n = 0;
foreach ($st->fetchAll() as $r) {
if (empty($r['created_at']) || empty($r['first_responded_at'])) continue;
$sum += ticket_sla_working_seconds_between((string) $r['created_at'], (string) $r['first_responded_at']);
$n++;
}
[$csatN, $csatAvg, $csatGood] = $one(
"SELECT COUNT(*), AVG(rating), SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) FROM ticket_csat
WHERE staff_id = ? AND rating IS NOT NULL AND answered_at BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0, null, 0];
return [
'resolved' => (int) $resolved,
'replies' => (int) $replies,
'first_response_seconds' => $n > 0 ? (int) round($sum / $n) : null,
'first_response_count' => $n,
'csat_avg' => (int) $csatN > 0 ? round((float) $csatAvg, 2) : null,
'csat_count' => (int) $csatN,
'csat_positive' => (int) $csatN > 0 ? (int) round(100 * (int) $csatGood / (int) $csatN) : null,
'on_time' => (int) $resolved > 0 ? (int) round(100 * (int) $onTime / (int) $resolved) : null,
];
}
/** Was jetzt offen ist (unabhängig vom Zeitraum). @return array{open:int,overdue:int,waiting:int} */
function my_week_now(PDO $pdo, int $staffId, ?string $now = null): array
{
$now = $now ?? db_now();
$count = function (string $extra, array $params = []) use ($pdo, $staffId): int {
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed'){$extra}");
$s->execute(array_merge([$staffId], $params));
return (int) $s->fetchColumn();
};
return [
'open' => $count(''),
'overdue' => $count(' AND resolution_due_at IS NOT NULL AND resolution_due_at < ?', [$now]),
'waiting' => $count(" AND status = 'open'"),
];
}
/** Gelöste Tickets je Tag im Zeitraum (für das Balkendiagramm). @return array<string,int> Datum => Anzahl */
function my_week_per_day(PDO $pdo, int $staffId, string $from, string $to): array
{
$out = [];
for ($d = strtotime(date('Y-m-d', strtotime($from))); $d <= strtotime($to); $d = strtotime('+1 day', $d)) $out[date('Y-m-d', $d)] = 0;
$s = $pdo->prepare("SELECT COALESCE(resolved_at, closed_at) AS at FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?");
$s->execute([$staffId, $from, $to]);
foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $at) {
$day = date('Y-m-d', strtotime((string) $at));
if (isset($out[$day])) $out[$day]++;
}
return count($out) > 62 ? array_slice($out, -62, null, true) : $out;
}
/** Dauer kurz und lesbar: „2 Std 15 Min“, „40 Min“, „–“. */
function my_week_fmt_duration(?int $seconds): string
{
if ($seconds === null) return '–';
if ($seconds < 60) return 'unter 1 Min';
$m = (int) round($seconds / 60);
if ($m < 60) return $m . ' Min';
$h = intdiv($m, 60);
$rest = $m % 60;
if ($h >= 48) return round($h / 24) . ' Tage';
return $h . ' Std' . ($rest ? ' ' . $rest . ' Min' : '');
}
/**
* Veränderung zum Zeitraum davor als Text und Richtung (+1 besser, -1 schlechter, 0 gleich/unbekannt).
* @return array{text:string,trend:int}
*/
function my_week_delta(?float $now, ?float $before, bool $lowerIsBetter = false, string $unit = ''): array
{
if ($now === null || $before === null) return ['text' => 'kein Vergleich', 'trend' => 0];
$diff = $now - $before;
if (abs($diff) < 0.005) return ['text' => 'wie davor', 'trend' => 0];
$good = $lowerIsBetter ? $diff < 0 : $diff > 0;
$num = abs($diff) >= 10 || floor(abs($diff)) === abs($diff) ? (string) round(abs($diff)) : number_format(abs($diff), 1, ',', '');
return ['text' => ($diff > 0 ? '▲ ' : '▼ ') . $num . $unit . ' zum Zeitraum davor', 'trend' => $good ? 1 : -1];
}
+9 -3
View File
@@ -89,7 +89,7 @@ function notify_new_reply_channels(array $ticket, string $excerpt): void
// Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon // Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon
$pdo = get_db(); $pdo = get_db();
ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)'); ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)');
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]); $pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]);
ticket_followers_notify($ticket, 'Neue Kundenantwort'); ticket_followers_notify($ticket, 'Neue Kundenantwort');
} catch (Throwable $e) { } catch (Throwable $e) {
// Datenbank noch nicht aktualisiert: nicht schlimm // Datenbank noch nicht aktualisiert: nicht schlimm
@@ -119,13 +119,19 @@ function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $acto
$team = $stmt->fetch(); $team = $stmt->fetch();
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0; if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1'); $members = $pdo->prepare('SELECT s.id FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
$members->execute([$teamId]); $members->execute([$teamId]);
// Mitglieder im Urlaub: stattdessen ihre Vertretung
$memberRows = [];
foreach (vacation_expand_ids($pdo, $members->fetchAll(PDO::FETCH_COLUMN)) as $mid) {
$em = staff_emails_by_ids($pdo, [$mid]);
if ($em) $memberRows[] = ['id' => $mid, 'email' => $em[0]];
}
$sent = 0; $sent = 0;
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen'; $subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>' $body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
. e($team['name']) . '</strong> zugewiesen.</p>'; . e($team['name']) . '</strong> zugewiesen.</p>';
foreach ($members->fetchAll() as $m) { foreach ($memberRows as $m) {
if ($actorId !== null && (int) $m['id'] === $actorId) continue; if ($actorId !== null && (int) $m['id'] === $actorId) continue;
if (send_mail($m['email'], $subject, $body)) $sent++; if (send_mail($m['email'], $subject, $body)) $sent++;
} }
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/**
* Rechte-Katalog: Alle Berechtigungen an einer Stelle, gruppiert, mit Beschreibung und Standardwert.
*
* - 'agent' : Standardwert für Mitarbeiter ohne eigene Rolle (und für Rechte, die in einer älteren Rolle noch fehlen). Rechte, die bis zur
* Einführung des Katalogs jeder Mitarbeiter hatte, stehen auf true; neue Adminportal-Bereiche auf false.
* - 'area' : Bereich des Adminportals („Co-Admin“): Wer so ein Recht hat, sieht das Adminportal im Menü.
* - Echte Administratoren haben immer alle Rechte (has_permission()).
* Bewusst nicht delegierbar (immer nur echte Administratoren): Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, osTicket-Import,
* Sicherheitsprüfung, Passkeys ein-/ausschalten und die Datenschutz-Löschfunktionen.
*/
function permission_catalog(): array
{
$p = fn(string $label, string $hint, bool $agent, bool $area = false) => ['label' => $label, 'hint' => $hint, 'agent' => $agent, 'area' => $area];
return [
'tickets' => ['title' => 'Tickets bearbeiten', 'items' => [
'reply_ticket' => $p('Tickets beantworten', 'Antworten, Antworten einplanen, Anhänge aus E-Mails nachladen', true),
'post_note' => $p('Interne Notizen schreiben', 'Notizen, die nur das Team sieht', true),
'close_ticket' => $p('Tickets schließen/lösen', 'Status auf Gelöst oder Geschlossen setzen, als Spam markieren', true),
'assign_ticket' => $p('Tickets zuweisen (Mitarbeiter/Team)', 'Bearbeiter oder Team ändern', true),
'transfer_ticket' => $p('Tickets übertragen (Abteilung ändern)', 'Ticket in eine andere Abteilung verschieben', true),
'create_ticket' => $p('Tickets im Namen von Kunden erstellen', 'Neues Ticket anlegen', true),
'merge_split_ticket' => $p('Tickets zusammenführen und teilen', 'Doppelte Tickets vereinen, Nachrichten abspalten', true),
'link_tickets' => $p('Tickets verknüpfen', 'Verwandt, doppelt oder blockiert', true),
'delete_ticket' => $p('Tickets löschen', 'Endgültig löschen, auch per Sammelaktion', false),
]],
'customers' => ['title' => 'Kunden und Sperren', 'items' => [
'view_customers' => $p('Kundenprofile ansehen', 'Profil eines Kunden mit allen seinen Tickets', true),
'manage_customers' => $p('Kundenverzeichnis verwalten', 'Alle Kunden-Konten ansehen und Organisationen zuordnen', false, true),
'manage_organizations' => $p('Organisationen verwalten', 'Mehrere Kunden zu einer Firma bündeln', false, true),
'manage_banlist' => $p('Sperrliste und Absender sperren', 'Adressen und Domains blockieren', true),
]],
'reports' => ['title' => 'Auswertung und Export', 'items' => [
'view_reports' => $p('Berichte einsehen', 'Übersicht, Berichte, Bericht-Baukasten (nur eigene Abteilungen)', true),
'schedule_reports' => $p('Berichte per E-Mail versenden', 'Zeitpläne im Bericht-Baukasten (externe Empfänger nur Administratoren)', true),
'export_tickets' => $p('Tickets als CSV exportieren', 'Ticketliste für Excel herunterladen', false),
'view_time_entries' => $p('Zeiterfassung einsehen', 'Auswertung der erfassten Zeiten', false, true),
'view_audit_log' => $p('Admin-Protokoll einsehen', 'Wer hat was geändert', false, true),
]],
'mail' => ['title' => 'E-Mail', 'items' => [
'fetch_mail' => $p('E-Mails jetzt abrufen', 'Den Abruf per Knopf auslösen', true),
'manage_email_addresses' => $p('E-Mail-Adressen und Postfächer verwalten', 'Absender, Eingang, SMTP und IMAP je Adresse', false, true),
'manage_email_templates' => $p('E-Mail-Vorlagen verwalten', 'Texte der automatischen Mails', false, true),
'view_mail_queue' => $p('Mail-Warteschlange verwalten', 'Nicht zugestellte Mails ansehen, erneut senden, löschen', false, true),
]],
'content' => ['title' => 'Inhalte und Vorlagen', 'items' => [
'manage_canned' => $p('Textbausteine verwalten', 'Vorlagen für Antworten', false, true),
'manage_ticket_templates' => $p('Ticket-Vorlagen verwalten', 'Vorgefüllte Tickets für Mitarbeiter und Kunden', false, true),
'manage_kb' => $p('Wissensdatenbank verwalten', 'Artikel und Kategorien', false, true),
'manage_assistant' => $p('Digitalen Assistenten verwalten', 'Antworten, Fragen-Log, Einstellungen', false, true),
'manage_site_pages' => $p('Seiten im Kundenportal verwalten', 'Impressum, AGB und weitere Seiten', false, true),
'manage_languages' => $p('Sprachen verwalten', 'Sprachpakete und Standardsprache', false, true),
'manage_status_page' => $p('Statusseite verwalten', 'Dienste und Störungsmeldungen', false, true),
'share_views' => $p('Ansichten mit dem Team teilen', 'Gespeicherte Filter für alle sichtbar machen', true),
]],
'setup' => ['title' => 'Einrichtung', 'items' => [
'manage_departments' => $p('Abteilungen verwalten', 'Abteilungen, Formulare, automatische Zuweisung', false, true),
'manage_priorities' => $p('Prioritäten / SLA verwalten', 'Prioritäten und SLA-Pläne', false, true),
'manage_help_topics' => $p('Hilfethemen verwalten', 'Auswahl für Kunden beim Ticket-Erstellen', false, true),
'manage_custom_fields' => $p('Ticket-Felder und Listen verwalten', 'Zusatzfelder und Auswahllisten', false, true),
'manage_automation' => $p('Ticket-Regeln und wiederkehrende Tickets verwalten', 'Automatische Aktionen bei neuen Tickets, regelmäßige Tickets', false, true),
'manage_escalations' => $p('Eskalationsregeln verwalten', 'Aktionen bei überfälligen oder liegengebliebenen Tickets', false, true),
'manage_business_hours' => $p('Geschäftszeiten verwalten', 'Zeitfenster und Feiertage für die SLA', false, true),
'manage_problems' => $p('Problemmanagement', 'Probleme erfassen und mit Tickets verknüpfen', false, true),
]],
'team' => ['title' => 'Team und Sicherheit', 'items' => [
'manage_staff' => $p('Mitarbeiter verwalten', 'Konten, Sperre aufheben, Überall abmelden, Passkeys entfernen', false, true),
'manage_teams' => $p('Teams verwalten', 'Gruppen von Mitarbeitern', false, true),
'manage_roles' => $p('Rollen verwalten', 'Rechte vergeben', false, true),
'manage_availability' => $p('Status und Schichten der Kollegen ändern', 'Status anderer Mitarbeiter setzen, automatisches Offline einstellen', false, true),
'view_login_guard' => $p('Anmelde-Wächter ansehen', 'Anmeldeprotokoll und gesperrte Konten; Einstellungen bleiben Administratoren', false, true),
'view_help' => $p('Hilfe und Handbuch', 'Hilfe-Seite mit dem Handbuch (PDF)', false, true),
]],
];
}
/** Alle Rechte als schlüssel => Beschriftung (Reihenfolge wie im Katalog). */
function permission_labels(): array
{
$out = [];
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $i['label'];
return $out;
}
/** Rechte für Adminportal-Bereiche („Co-Admin“): schlüssel => Beschriftung. */
function admin_section_permissions(): array
{
$out = [];
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) if ($i['area']) $out[$k] = $i['label'];
return $out;
}
/** Standardrechte: Administrator alles, Mitarbeiter die Standardwerte des Katalogs. */
function default_permissions(string $role): array
{
$out = [];
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $role === 'admin' ? true : (bool) $i['agent'];
return $out;
}
/**
* Vorlagen für Rollen: Schlüssel => [Name, Beschreibung, Rechte, als Rolle anlegen?].
* Die Vorlagen mit „true“ werden bei der Installation und beim Update als echte Rollen angelegt (siehe permission_seed_default_roles()); sie lassen sich danach
* wie jede andere Rolle ändern oder löschen. „Mitarbeiter“ ist nur eine Vorlage im Formular, weil das die Standardrechte ohne Rolle sind.
*/
function permission_presets(): array
{
$all = array_keys(permission_labels());
$on = fn(array $keys) => array_fill_keys($keys, true) + array_fill_keys($all, false);
$agent = array_keys(array_filter(default_permissions('agent')));
$lead = array_merge($agent, ['delete_ticket', 'export_tickets', 'view_time_entries', 'manage_canned', 'manage_kb', 'manage_teams', 'manage_availability']);
return [
'mitarbeiter' => ['Mitarbeiter', 'Die Standardrechte ohne Rolle: Tickets bearbeiten, Berichte der eigenen Abteilungen.', $on($agent), false],
'co_admin' => ['Co-Administrator', 'Alle Rechte, die sich vergeben lassen. Nur Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, Import und Sicherheitsprüfung bleiben echten Administratoren.', $on($all), true],
'support_manager' => ['Support-Manager', 'Leitet den Support: Tickets, Einrichtung (Abteilungen, Prioritäten, Themen, Regeln, Felder), Mitarbeiter, Teams, Protokolle und Mail-Warteschlange.', $on(array_merge($lead, [
'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_custom_fields', 'manage_automation', 'manage_escalations', 'manage_business_hours', 'manage_problems',
'manage_ticket_templates', 'manage_staff', 'view_audit_log', 'view_login_guard', 'view_mail_queue', 'manage_customers', 'manage_organizations', 'manage_email_templates', 'view_help'])), true],
'it' => ['IT / Technik', 'Betrieb und Technik: Tickets bearbeiten, dazu Postfächer und E-Mail-Adressen, Mail-Warteschlange, Anmelde-Wächter, Statusseite, Regeln, Eskalationen, Protokoll und Hilfe.', $on(array_merge($agent, [
'manage_email_addresses', 'view_mail_queue', 'view_login_guard', 'manage_status_page', 'manage_automation', 'manage_escalations', 'view_audit_log', 'manage_problems', 'manage_custom_fields', 'view_help'])), true],
'teamleitung' => ['Teamleitung', 'Führt ein Team: wie Mitarbeiter, dazu Löschen, Export, Zeiterfassung, Textbausteine, Wissensdatenbank, Teams und Schichten.', $on($lead), true],
'support_2' => ['Support 2nd Level', 'Erfahrene Mitarbeiter: Tickets bearbeiten, Probleme verwalten, Export und Zeiterfassung.', $on(array_merge($agent, ['manage_problems', 'export_tickets', 'view_time_entries'])), true],
'support_1' => ['Support 1st Level', 'Einstieg: antworten, Notizen, zuweisen, Tickets anlegen und verknüpfen, E-Mails abrufen. Kein Schließen, Verschieben, Zusammenführen oder Sperren.', $on(['reply_ticket', 'post_note', 'assign_ticket', 'create_ticket', 'link_tickets', 'view_customers', 'fetch_mail']), true],
'redaktion' => ['Redaktion (Wissen und Inhalte)', 'Pflegt Inhalte: Wissensdatenbank, digitaler Assistent, Textbausteine, Ticket- und E-Mail-Vorlagen, Seiten und Sprachen.', $on(['reply_ticket', 'post_note', 'view_customers', 'manage_kb', 'manage_assistant', 'manage_canned', 'manage_ticket_templates', 'manage_email_templates', 'manage_site_pages', 'manage_languages']), true],
'auswertung' => ['Auswertung / Controlling', 'Zahlen und Berichte: Berichte (auch per E-Mail), Export, Zeiterfassung, Protokoll, Ansichten teilen. Kann keine Tickets bearbeiten.', $on(['view_reports', 'schedule_reports', 'export_tickets', 'view_time_entries', 'view_audit_log', 'share_views', 'view_customers']), true],
'teamverwaltung' => ['Teamverwaltung / Schichtplanung', 'Verwaltet Personal: Mitarbeiter, Teams, Status und Schichten, Geschäftszeiten, Anmelde-Wächter ansehen.', $on(['view_reports', 'manage_staff', 'manage_teams', 'manage_availability', 'manage_business_hours', 'view_login_guard', 'view_audit_log']), true],
'aushilfe' => ['Aushilfe / Praktikant', 'Nur das Nötigste: antworten und Notizen schreiben, Kundenprofile ansehen.', $on(['reply_ticket', 'post_note', 'view_customers']), true],
'nur_lesen' => ['Nur lesen', 'Tickets, Kundenprofile und Berichte ansehen, nichts ändern.', $on(['view_customers', 'view_reports']), true],
];
}
/**
* Legt die vordefinierten Rollen an, die es noch nicht gibt (Vergleich über den Namen, Groß-/Kleinschreibung egal). Vorhandene Rollen werden nie verändert.
* @return list<string> Namen der neu angelegten Rollen
*/
function permission_seed_default_roles(PDO $pdo): array
{
$created = [];
$exists = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?)');
$now = date('Y-m-d H:i:s');
foreach (permission_presets() as [$name, $desc, $perms, $seed]) {
if (!$seed) continue;
$exists->execute([$name]);
if ((int) $exists->fetchColumn() > 0) continue;
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,1,?,?,?)')
->execute([$name, json_encode($perms), 'Vordefinierte Rolle – frei änderbar. ' . $desc, $now, $now]);
$created[] = $name;
}
return $created;
}
/** Namen der vordefinierten Rollen, die auf dieser Installation fehlen. @return list<string> */
function permission_missing_default_roles(PDO $pdo): array
{
$have = array_map('mb_strtolower', $pdo->query('SELECT name FROM roles')->fetchAll(PDO::FETCH_COLUMN));
$missing = [];
foreach (permission_presets() as [$name, , , $seed]) if ($seed && !in_array(mb_strtolower($name), $have, true)) $missing[] = $name;
return $missing;
}
/**
* Rechte, die es schon vor dem Katalog gab. Fehlt eines davon in einer gespeicherten Rolle, gilt wie bisher „nein“; bei allen neueren Rechten gilt
* der Standardwert des Katalogs (so behalten bestehende Rollen, was bisher für alle offen war, und neue Adminbereiche bleiben aus).
*/
function permission_legacy_keys(): array
{
return ['reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note', 'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_email_addresses', 'manage_email_templates', 'manage_organizations', 'manage_teams', 'manage_roles',
'manage_staff', 'view_audit_log', 'manage_escalations', 'view_time_entries', 'manage_business_hours', 'manage_assistant'];
}
/** Wirksame Rechte einer gespeicherten Rolle (so, wie has_permission() sie auswertet): fehlende alte Rechte = nein, fehlende neuere = Standardwert. */
function permission_effective(array $rolePerms): array
{
$defaults = default_permissions('agent');
$legacy = permission_legacy_keys();
$out = [];
foreach (array_keys($defaults) as $k) {
$out[$k] = array_key_exists($k, $rolePerms) ? (bool) $rolePerms[$k] : (!in_array($k, $legacy, true) && $defaults[$k]);
}
return $out;
}
+127 -1
View File
@@ -34,13 +34,15 @@ function plugin_known_permissions(): array
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API', 'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst', 'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen', 'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
'auth.customer' => 'Kunden über einen externen Dienst anmelden und Kundenkonten anlegen (zum Beispiel authentik): Knopf auf der Anmeldeseite des Kundenportals. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf und ob Konten entstehen.',
'auth.login' => 'Mitarbeiter über einen externen Dienst anmelden (zum Beispiel authentik): Knopf auf der Anmeldeseite und eigene Anmelde-Adresse. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf.',
]; ];
} }
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */ /** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array function &plugin_state(): array
{ {
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false]; static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'logins' => [], 'login_loggedin' => [], 'customer_logins' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
return $state; return $state;
} }
@@ -128,6 +130,8 @@ function plugin_filter_permission(string $hook): ?string
{ {
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar'; if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets'; if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'staff.login.buttons') return 'auth.login'; // Knöpfe auf der Mitarbeiter-Anmeldeseite
if ($hook === 'customer.login.buttons') return 'auth.customer'; // Knöpfe auf der Kunden-Anmeldeseite
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
return null; return null;
} }
@@ -182,6 +186,37 @@ function plugin_register_webhook(string $endpoint, callable $handler): void
$state['webhooks'][$slug][$endpoint] = $handler; $state['webhooks'][$slug][$endpoint] = $handler;
} }
/**
* Registriert eine Anmelde-Adresse für Mitarbeiter: GET /staff/plugin_login.php?p=<slug>&e=<name>. Gedacht für externe Anmeldedienste (OpenID Connect, SAML …).
* Der Handler erhält ['query' => $_GET, 'ip' => …] (die Sitzung $_SESSION steht zur Verfügung) und gibt eines davon zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['staff_id' => 12] dieser Mitarbeiter ist geprüft: DeskHub meldet ihn an (nur aktive Konten, mit Eintrag im Admin-Protokoll)
* ['error' => 'Text'] zurück zur Anmeldeseite mit dieser Meldung
* Den Link zum Starten baut plugin_login_url(). Wer anmelden darf, entscheidet allein das Plugin (Berechtigung auth.login).
*
* Mit $whenLoggedIn = true läuft die Adresse auch für bereits angemeldete Mitarbeiter (zum Beispiel, um ein Konto beim Anmeldedienst zu verknüpfen).
* Der Handler bekommt dann zusätzlich ['staff' => <angemeldeter Mitarbeiter>] und darf nur zurückgeben:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst
* ['local' => '/staff/…', 'notice' => 'Text'] zurück in den Mitarbeiterbereich, mit Hinweis
* ['error' => 'Text', 'back' => '/staff/…'] zurück mit Fehlermeldung
* Ein anderer Mitarbeiter kann so nie angemeldet werden (staff_id wird dann abgelehnt).
*/
function plugin_register_login(string $endpoint, callable $handler, bool $whenLoggedIn = false): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.login')) return;
$state['logins'][$slug][$endpoint] = $handler;
$state['login_loggedin'][$slug][$endpoint] = $whenLoggedIn;
}
/** Adresse (ohne Domain) einer mit plugin_register_login() angemeldeten Anmelde-Adresse. */
function plugin_login_url(string $slug, string $endpoint): string
{
return '/staff/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{ {
if (!plugin_permission_allowed('api.endpoints')) return; if (!plugin_permission_allowed('api.endpoints')) return;
@@ -595,6 +630,21 @@ function catalog_refresh_background(): void
* *
* @return array<int, array{slug:string, name:string, installed:string, available:string}> * @return array<int, array{slug:string, name:string, installed:string, available:string}>
*/ */
/**
* Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht).
* @return array{updated:list<string>,failed:array<string,string>} Namen mit Version bzw. Slug => Fehlertext
*/
function plugin_catalog_update_all(PDO $pdo): array
{
$out = ['updated' => [], 'failed' => []];
foreach (plugin_updates_available($pdo) as $u) {
$r = plugin_catalog_install($pdo, $u['slug']);
if (!empty($r['ok'])) $out['updated'][] = $u['name'] . ' ' . $r['version'];
else $out['failed'][$u['slug']] = (string) ($r['error'] ?? 'Unbekannter Fehler');
}
return $out;
}
function plugin_updates_available(PDO $pdo): array function plugin_updates_available(PDO $pdo): array
{ {
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true); $cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
@@ -983,3 +1033,79 @@ function catalog_thumb(string $kind, array $c): string
} }
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>'; return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
} }
// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ──────────────────────────────────────────────────────────────
/**
* Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=<slug>&e=<name> (öffentlich, Kundenportal).
* Der Handler erhält ['query' => $_GET, 'ip' => …] und gibt zurück:
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
* ['customer' => ['email' => …, 'name' => …, 'create' => true|false, 'via' => 'Dienst']]
* diese Person ist vom Dienst bestätigt: DeskHub meldet das Kundenkonto mit dieser
* E-Mail-Adresse an; gibt es keines und 'create' ist true, wird es angelegt
* ['error' => 'Text'] zurück zur Kunden-Anmeldeseite mit dieser Meldung
* Die Rückkehr vom Dienst darf auch über eine mit plugin_register_login() angemeldete Adresse kommen, wenn das Plugin beide Berechtigungen hat.
* Das Plugin trägt die Verantwortung, dass die E-Mail-Adresse wirklich bestätigt ist.
*/
function plugin_register_customer_login(string $endpoint, callable $handler): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('auth.customer')) return;
$state['customer_logins'][$slug][$endpoint] = $handler;
}
/** Adresse (ohne Domain) einer mit plugin_register_customer_login() angemeldeten Kunden-Anmelde-Adresse. */
function plugin_customer_login_url(string $slug, string $endpoint): string
{
return '/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
}
/** Gilt die Weiterleitung zu einem Anmeldedienst? Nur https (oder http zu localhost zum Testen), keine Zeichen < Leerzeichen. */
function plugin_login_external_url_ok(string $url): bool
{
if ($url === '' || strlen($url) > 4000 || preg_match('/[\x00-\x20]/', $url)) return false;
$p = parse_url($url);
if (!$p || empty($p['host'])) return false;
$scheme = $p['scheme'] ?? '';
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
}
/**
* Meldet einen vom Plugin bestätigten Kunden an (und legt das Konto an, wenn erlaubt). Endet immer mit einer Weiterleitung.
* Ein Plugin ohne Berechtigung auth.customer kommt hier nicht durch.
* @param array{email?:string,name?:string,create?:bool,via?:string} $c
*/
function plugin_customer_login_finish(string $slug, array $c): never
{
$fail = function (string $message): never {
flash('error', $message);
redirect('/login.php');
};
if (!in_array('auth.customer', plugin_state()['perms'][$slug] ?? [], true)) $fail('Diese Anmeldung ist nicht erlaubt.');
$email = strtolower(trim((string) ($c['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 150) $fail('Die E-Mail-Adresse ist ungültig.');
if (is_banned($email)) $fail('Mit dieser E-Mail-Adresse ist keine Anmeldung möglich.');
$pdo = get_db();
$st = $pdo->prepare('SELECT * FROM users WHERE LOWER(email) = ?');
$st->execute([$email]);
$user = $st->fetch();
$created = false;
$via = mb_substr((string) ($c['via'] ?? $slug), 0, 60);
if (!$user) {
if (empty($c['create'])) $fail('Für diese E-Mail-Adresse gibt es kein Kundenkonto.');
$name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($c['name'] ?? ''))));
if ($name === '') $name = ucfirst(explode('@', $email)[0]);
$name = mb_substr($name, 0, 120);
// Ein Passwort gibt es nicht: Wer eines möchte, nutzt „Passwort vergessen“
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
->execute([$name, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), db_now()]);
$user = ['id' => (int) $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
audit_log(null, 'Kundenkonto über ' . $via . ' angelegt: ' . mb_substr($email, 0, 120));
$created = true;
}
customer_login($user);
if ($created) flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
redirect('/home.php');
}
+131
View File
@@ -0,0 +1,131 @@
<?php
/** Problem records group related tickets and retain root-cause documentation. */
function ticket_resolution_reasons(): array
{
return [
'fixed' => 'Fehler behoben',
'guidance' => 'Anleitung oder Beratung',
'duplicate' => 'Doppelt / zusammengeführt',
'not_reproducible' => 'Nicht reproduzierbar',
'no_action' => 'Keine Maßnahme erforderlich',
'spam' => 'Spam oder Sperre',
'automated' => 'Automatisch geschlossen',
'other' => 'Sonstiges',
];
}
function ticket_problem_statuses(): array
{
return [
'investigating' => 'Wird untersucht',
'workaround' => 'Workaround verfügbar',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
];
}
function ticket_problem_number(int $id): string
{
return 'PRB-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT);
}
function ticket_problems(PDO $pdo, bool $activeOnly = false): array
{
$where = $activeOnly ? "WHERE p.status <> 'resolved'" : '';
return $pdo->query("SELECT p.*, s.name AS creator_name, (SELECT COUNT(*) FROM problem_tickets pt WHERE pt.problem_id = p.id) AS ticket_count FROM problems p LEFT JOIN staff s ON s.id = p.created_by $where ORDER BY CASE WHEN p.status = 'resolved' THEN 1 ELSE 0 END, p.updated_at DESC, p.id DESC LIMIT 300")->fetchAll();
}
function ticket_problem_get(PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare('SELECT p.*, s.name AS creator_name FROM problems p LEFT JOIN staff s ON s.id = p.created_by WHERE p.id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function ticket_problem_create(PDO $pdo, array $input, int $staffId): int
{
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
$status = (string) ($input['status'] ?? 'investigating');
if (!isset(ticket_problem_statuses()[$status])) $status = 'investigating';
$resolution = trim((string) ($input['resolution'] ?? ''));
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
$now = db_now();
$stmt = $pdo->prepare('INSERT INTO problems (title, description, root_cause, workaround, resolution, status, created_by, updated_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
$stmt->execute([
$title,
trim((string) ($input['description'] ?? '')) ?: null,
trim((string) ($input['root_cause'] ?? '')) ?: null,
trim((string) ($input['workaround'] ?? '')) ?: null,
$resolution ?: null,
$status, $staffId, $staffId, $now, $now, $status === 'resolved' ? $now : null,
]);
return (int) $pdo->lastInsertId();
}
function ticket_problem_update(PDO $pdo, int $id, array $input, int $staffId): bool
{
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
$status = (string) ($input['status'] ?? 'investigating');
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
if (!isset(ticket_problem_statuses()[$status])) throw new InvalidArgumentException('Der Problemstatus ist ungültig.');
$resolution = trim((string) ($input['resolution'] ?? ''));
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
$current = ticket_problem_get($pdo, $id);
if (!$current) return false;
$resolvedAt = $status === 'resolved' ? ($current['resolved_at'] ?: db_now()) : null;
$stmt = $pdo->prepare('UPDATE problems SET title = ?, description = ?, root_cause = ?, workaround = ?, resolution = ?, status = ?, updated_by = ?, updated_at = ?, resolved_at = ? WHERE id = ?');
$stmt->execute([
$title,
trim((string) ($input['description'] ?? '')) ?: null,
trim((string) ($input['root_cause'] ?? '')) ?: null,
trim((string) ($input['workaround'] ?? '')) ?: null,
$resolution ?: null,
$status, $staffId, db_now(), $resolvedAt, $id,
]);
return true;
}
function ticket_problem_tickets(PDO $pdo, int $problemId): array
{
$stmt = $pdo->prepare('SELECT t.id, t.ticket_number, t.subject, t.status, t.requester_name, t.created_at, s.name AS linked_by_name FROM problem_tickets pt JOIN tickets t ON t.id = pt.ticket_id LEFT JOIN staff s ON s.id = pt.linked_by WHERE pt.problem_id = ? ORDER BY pt.linked_at DESC, t.id DESC');
$stmt->execute([$problemId]);
return $stmt->fetchAll();
}
function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staffId): bool
{
if (!ticket_problem_get($pdo, $problemId)) return false;
$ticket = $pdo->prepare('SELECT id FROM tickets WHERE id = ?');
$ticket->execute([$ticketId]);
if (!$ticket->fetchColumn()) return false;
$insertIgnore = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
$stmt = $pdo->prepare("$insertIgnore INTO problem_tickets (problem_id, ticket_id, linked_by, linked_at) VALUES (?,?,?,?)");
$stmt->execute([$problemId, $ticketId, $staffId, db_now()]);
return true;
}
/** Löst die Verknüpfung. Liefert false, wenn es sie nicht gab. */
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): bool
{
$stmt = $pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?');
$stmt->execute([$problemId, $ticketId]);
return $stmt->rowCount() > 0;
}
function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array
{
$stmt = $pdo->prepare('SELECT p.* FROM problems p JOIN problem_tickets pt ON pt.problem_id = p.id WHERE pt.ticket_id = ? ORDER BY p.updated_at DESC, p.id DESC');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
}
function ticket_record_resolution(PDO $pdo, int $ticketId, string $reason, string $note = ''): bool
{
if (!isset(ticket_resolution_reasons()[$reason])) return false;
$pdo->prepare('UPDATE tickets SET resolution_code = ?, resolution_note = ? WHERE id = ?')
->execute([$reason, trim(mb_substr($note, 0, 2000)) ?: null, $ticketId]);
return true;
}
@@ -39,9 +39,12 @@ function pseudo_cron_job_definitions(): array
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'], 'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'], 'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'], 'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
'process_mail_queue' => ['setting' => 'cron_process_mail_queue_last_run', 'interval' => 120, 'file' => 'process_mail_queue.php', 'fn' => 'run_process_mail_queue'],
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'], 'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'], 'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'], 'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'],
'send_scheduled_replies' => ['setting' => 'cron_send_scheduled_replies_last_run', 'interval' => 60, 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'],
'send_report_schedules' => ['setting' => 'cron_send_report_schedules_last_run', 'interval' => 300, 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'],
'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'], 'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
]; ];
} }
@@ -0,0 +1,218 @@
<?php
/**
* Berichte per E-Mail nach Zeitplan: Ein gespeicherter Bericht aus dem Bericht-Baukasten wird täglich, wöchentlich oder monatlich
* für einen relativen Zeitraum (z. B. „letzte Woche“) neu berechnet und als Tabelle an Empfänger geschickt
* (cron/send_report_schedules.php). Der Bericht wird mit den Abteilungsrechten des Einrichters berechnet.
*/
const REPORT_SCHEDULE_MAX_RECIPIENTS = 10;
/** @return array<string,string> */
function report_schedule_periods(): array
{
return [
'yesterday' => 'Gestern',
'last_7' => 'Letzte 7 Tage',
'last_week' => 'Letzte Kalenderwoche (Mo–So)',
'last_30' => 'Letzte 30 Tage',
'last_month' => 'Letzter Kalendermonat',
];
}
/** @return array<string,string> */
function report_schedule_frequencies(): array
{
return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich'];
}
/** @return array<int,string> */
function report_schedule_weekdays(): array
{
return [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag'];
}
/** Zeitraum (von, bis; beide Y-m-d) für einen relativen Zeitraum, bezogen auf $now. */
function report_period_range(string $period, ?int $now = null): array
{
$now = $now ?? time();
$day = fn(string $mod) => date('Y-m-d', strtotime($mod, $now));
switch ($period) {
case 'yesterday': return [$day('-1 day'), $day('-1 day')];
case 'last_week':
$monday = strtotime('monday this week', $now);
return [date('Y-m-d', strtotime('-7 days', $monday)), date('Y-m-d', strtotime('-1 day', $monday))];
case 'last_30': return [$day('-30 days'), $day('-1 day')];
case 'last_month':
$first = strtotime(date('Y-m-01', $now));
return [date('Y-m-d', strtotime('-1 month', $first)), date('Y-m-d', strtotime('-1 day', $first))];
case 'last_7':
default: return [$day('-7 days'), $day('-1 day')];
}
}
/** Nächster Sendezeitpunkt (Y-m-d H:i:s) nach $after für den Zeitplan. */
function report_schedule_next(array $s, ?int $after = null): string
{
$after = $after ?? time();
$time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($s['send_time'] ?? '')) ? (string) $s['send_time'] : '08:00';
$freq = (string) ($s['frequency'] ?? 'weekly');
$weekday = max(1, min(7, (int) ($s['weekday'] ?? 1)));
$monthday = max(1, min(28, (int) ($s['monthday'] ?? 1)));
for ($i = 0; $i <= 62; $i++) {
$dayTs = strtotime("+{$i} days", strtotime(date('Y-m-d', $after)));
if ($freq === 'weekly' && (int) date('N', $dayTs) !== $weekday) continue;
if ($freq === 'monthly' && (int) date('j', $dayTs) !== $monthday) continue;
if ($freq === 'daily' && !empty($s['weekdays_only']) && (int) date('N', $dayTs) > 5) continue;
$candidate = strtotime(date('Y-m-d', $dayTs) . ' ' . $time . ':00');
if ($candidate !== false && $candidate > $after) return date('Y-m-d H:i:s', $candidate);
}
return date('Y-m-d H:i:s', $after + 86400);
}
/**
* Prüft die Empfängerliste (Komma, Semikolon oder Zeilenumbruch getrennt). Nicht-Administratoren dürfen nur an aktive Mitarbeiter senden.
* @return array{0:list<string>,1:?string} [Adressen, Fehlermeldung]
*/
function report_schedule_recipients(PDO $pdo, string $input, array $staff): array
{
$list = [];
foreach (preg_split('/[\s,;]+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $addr) {
$addr = strtolower(trim($addr));
if (!filter_var($addr, FILTER_VALIDATE_EMAIL)) return [[], 'Ungültige E-Mail-Adresse: ' . $addr];
$list[$addr] = $addr;
}
$list = array_values($list);
if (!$list) return [[], 'Bitte mindestens einen Empfänger angeben.'];
if (count($list) > REPORT_SCHEDULE_MAX_RECIPIENTS) return [[], 'Höchstens ' . REPORT_SCHEDULE_MAX_RECIPIENTS . ' Empfänger je Zeitplan.'];
if (($staff['role'] ?? '') !== 'admin') {
$known = array_map('strtolower', $pdo->query('SELECT email FROM staff WHERE is_active = 1')->fetchAll(PDO::FETCH_COLUMN));
foreach ($list as $addr) {
if (!in_array($addr, $known, true)) return [[], 'Nur Administratoren dürfen Berichte an Adressen außerhalb der Mitarbeiter senden: ' . $addr];
}
}
return [$list, null];
}
/** Legt einen Zeitplan an. Gibt eine Fehlermeldung zurück oder null. */
function report_schedule_create(PDO $pdo, array $staff, array $in): ?string
{
$stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
$stmt->execute([(int) ($in['report_id'] ?? 0)]);
$report = $stmt->fetch();
if (!$report || ((int) $report['staff_id'] !== (int) $staff['id'] && !(int) $report['is_shared'] && ($staff['role'] ?? '') !== 'admin')) return 'Bericht nicht gefunden.';
[$recipients, $err] = report_schedule_recipients($pdo, (string) ($in['recipients'] ?? ''), $staff);
if ($err) return $err;
$freq = isset(report_schedule_frequencies()[$in['frequency'] ?? '']) ? (string) $in['frequency'] : 'weekly';
$period = isset(report_schedule_periods()[$in['period'] ?? '']) ? (string) $in['period'] : 'last_7';
$time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($in['send_time'] ?? '')) ? (string) $in['send_time'] : '08:00';
$row = [
'frequency' => $freq, 'weekday' => max(1, min(7, (int) ($in['weekday'] ?? 1))), 'monthday' => max(1, min(28, (int) ($in['monthday'] ?? 1))),
'send_time' => $time, 'weekdays_only' => !empty($in['weekdays_only']) ? 1 : 0,
];
// Weniger Mails: Standard ist „nur bei Daten“ und „nur bei Änderung“ (ein leeres Kontrollkästchen im Formular schaltet es ab, siehe „quiet_form“)
$onlyData = !empty($in['quiet_form']) ? (!empty($in['only_if_data']) ? 1 : 0) : 1;
$skipSame = !empty($in['quiet_form']) ? (!empty($in['skip_unchanged']) ? 1 : 0) : 1;
$pdo->prepare('INSERT INTO report_schedules (report_id, staff_id, recipients, frequency, weekday, monthday, send_time, period, enabled, next_run_at, created_at, only_if_data, skip_unchanged, weekdays_only) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
->execute([(int) $report['id'], (int) $staff['id'], implode(', ', $recipients), $freq, $row['weekday'], $row['monthday'], $time, $period, 1, report_schedule_next($row), db_now(), $onlyData, $skipSame, $row['weekdays_only']]);
return null;
}
/** Baut Betreff und HTML-Text für einen Zeitplan. @return array{0:string,1:string,2:int,3:string}|null [Betreff, HTML, Zeilen, Prüfsumme] oder null, wenn der Bericht fehlt. */
function report_schedule_render(PDO $pdo, array $schedule, ?int $now = null): ?array
{
$stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
$stmt->execute([(int) $schedule['report_id']]);
$saved = $stmt->fetch();
if (!$saved) return null;
$owner = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
$owner->execute([(int) $schedule['staff_id']]);
$ownerRow = $owner->fetch();
if (!$ownerRow || (isset($ownerRow['is_active']) && (int) $ownerRow['is_active'] === 0)) return null;
[$from, $to] = report_period_range((string) $schedule['period'], $now);
$cfgIn = json_decode((string) $saved['config'], true) ?: [];
$cfgIn['from'] = $from;
$cfgIn['to'] = $to;
$cfg = report_clean_config($pdo, $cfgIn);
$report = report_run($pdo, $cfg, staff_department_ids($ownerRow));
$fmt = function ($v, string $unit): string {
if ($v === null || $v === '') return '–';
if ($unit === '%' || $unit === 'Std') return number_format((float) $v, 1, ',', '.') . ($unit === '%' ? ' %' : ' Std');
return (string) $v;
};
$th = 'style="text-align:left;padding:6px 10px;border-bottom:2px solid #cbd5e1;background:#f1f5f9;font-size:13px"';
$td = 'style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:13px"';
$html = '<h2 style="margin:0 0 6px;font-size:20px;color:#0f172a">' . e($saved['name']) . '</h2>'
. '<p style="margin:0 0 14px;color:#64748b;font-size:14px">Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '</p>'
. '<table style="border-collapse:collapse;width:100%"><thead><tr>';
foreach ($report['columns'] as $c) $html .= '<th ' . $th . '>' . e($c['label']) . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($report['rows'] as $row) {
$html .= '<tr><td ' . $td . '><strong>' . e((string) $row['label']) . '</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '>' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '</td>';
$html .= '</tr>';
}
if (!$report['rows']) {
$html .= '<tr><td ' . $td . ' colspan="' . count($report['columns']) . '">Keine Tickets im Zeitraum.</td></tr>';
} else {
$html .= '<tr><td ' . $td . '><strong>Gesamt</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '><strong>' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '</strong></td>';
$html .= '</tr>';
}
$html .= '</tbody></table><p style="margin:16px 0 0;color:#94a3b8;font-size:12px">Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').</p>';
if (function_exists('email_wrap')) $html = email_wrap($html);
$subject = 'DeskHub-Bericht: ' . $saved['name'] . ' (' . date('d.m.', strtotime($from)) . '–' . date('d.m.Y', strtotime($to)) . ')';
// Prüfsumme nur der Zahlen (ohne Zeitraum): gleiche Zahlen wie beim letzten Versand = „nichts Neues“
$hash = sha1(json_encode([$report['columns'], $report['rows'], $report['totals']], JSON_UNESCAPED_UNICODE) ?: '');
return [$subject, $html, count($report['rows']), $hash];
}
/**
* Sendet einen Zeitplan. Mit $respectQuiet = true (automatischer Lauf) wird nichts gesendet, wenn der Bericht leer ist („nur bei Daten“)
* oder dieselben Zahlen enthält wie beim letzten Versand („nur bei Änderung“); „Test senden“ sendet immer.
* @return array{sent:int,error:?string,skipped:?string,hash:?string}
*/
function report_schedule_send(PDO $pdo, array $schedule, bool $respectQuiet = false): array
{
$rendered = report_schedule_render($pdo, $schedule);
if ($rendered === null) return ['sent' => 0, 'error' => 'Bericht oder Einrichter existiert nicht mehr oder ist deaktiviert.', 'skipped' => null, 'hash' => null];
[$subject, $html, $rows, $hash] = $rendered;
if ($respectQuiet) {
if (!empty($schedule['only_if_data']) && $rows === 0) return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Daten im Zeitraum', 'hash' => $hash];
if (!empty($schedule['skip_unchanged']) && !empty($schedule['last_hash']) && hash_equals((string) $schedule['last_hash'], $hash)) {
return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Änderung seit dem letzten Versand', 'hash' => $hash];
}
}
$sent = 0;
foreach (preg_split('/[\s,;]+/', (string) $schedule['recipients'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) {
if (send_mail($to, $subject, $html)) $sent++;
}
return ['sent' => $sent, 'error' => $sent === 0 ? 'Kein Empfänger erreicht (E-Mail-Versand prüfen).' : null, 'skipped' => null, 'hash' => $hash];
}
/** Sendet alle fälligen Zeitpläne. @return array{sent:int,failed:int} */
function report_schedules_run_due(PDO $pdo): array
{
$now = db_now();
$stmt = $pdo->prepare('SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at LIMIT 50');
$stmt->execute([$now]);
$ok = 0; $bad = 0;
foreach ($stmt->fetchAll() as $s) {
// Zeitplan atomar weiterschalten: nur ein Lauf darf senden, auch bei gleichzeitigen Anfragen
$next = report_schedule_next($s);
$claim = $pdo->prepare('UPDATE report_schedules SET next_run_at = ? WHERE id = ? AND next_run_at = ?');
$claim->execute([$next, (int) $s['id'], $s['next_run_at']]);
if ($claim->rowCount() === 0) continue;
try {
$r = report_schedule_send($pdo, $s, true);
} catch (Throwable $e) {
$r = ['sent' => 0, 'error' => $e->getMessage(), 'skipped' => null, 'hash' => null];
}
$pdo->prepare('UPDATE report_schedules SET last_sent_at = ?, last_error = ?, last_note = ?, last_hash = ? WHERE id = ?')
->execute([$r['sent'] > 0 ? $now : $s['last_sent_at'], $r['error'], $r['skipped'], $r['sent'] > 0 ? $r['hash'] : ($s['last_hash'] ?? null), (int) $s['id']]);
if ($r['error'] !== null) $bad++;
elseif ($r['skipped'] === null) $ok++;
}
return ['sent' => $ok, 'failed' => $bad];
}
+1
View File
@@ -16,6 +16,7 @@ function report_dimensions(PDO $pdo): array
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'], 'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'], 'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
'status' => ['Status', 't.status', ''], 'status' => ['Status', 't.status', ''],
'resolution_code' => ['Abschlussgrund', "CASE t.resolution_code WHEN 'fixed' THEN 'Fehler behoben' WHEN 'guidance' THEN 'Anleitung oder Beratung' WHEN 'duplicate' THEN 'Doppelt / zusammengeführt' WHEN 'not_reproducible' THEN 'Nicht reproduzierbar' WHEN 'no_action' THEN 'Keine Maßnahme erforderlich' WHEN 'spam' THEN 'Spam oder Sperre' WHEN 'automated' THEN 'Automatisch geschlossen' WHEN 'other' THEN 'Sonstiges' ELSE 'Nicht erfasst' END", ''],
'source' => ['Quelle', 't.source', ''], 'source' => ['Quelle', 't.source', ''],
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''], 'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''], 'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
@@ -0,0 +1,135 @@
<?php
/**
* Zeitversetzt gesendete Antworten: Eine öffentliche Antwort wird geschrieben und zu einem späteren Zeitpunkt an den Kunden
* geschickt (Hintergrundjob cron/send_scheduled_replies.php, alle Minute über den eingebauten Cron oder echten Cronjob).
* Bis dahin steht sie im Ticket als „geplant“ und lässt sich vom Verfasser oder einem Administrator abbrechen.
* Anhänge werden bei zeitversetztem Senden nicht unterstützt.
*/
const SCHEDULED_REPLY_MAX_DAYS = 90;
/** Parst die Eingabe eines datetime-local-Feldes (Y-m-d\TH:i). Gibt "Y-m-d H:i:s" oder null zurück. */
function scheduled_reply_parse_time(string $input): ?string
{
$input = trim($input);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?$/', $input, $m)) return null;
$ts = strtotime($m[1] . ' ' . $m[2] . ':00');
return $ts === false ? null : date('Y-m-d H:i:s', $ts);
}
/** Legt eine geplante Antwort an. Gibt eine Fehlermeldung zurück oder null bei Erfolg. $message ist bereits bereinigtes HTML. */
function scheduled_reply_create(PDO $pdo, array $ticket, int $staffId, string $message, ?string $sendAt, array $opts = []): ?string
{
if (trim(strip_tags($message)) === '') return 'Bitte geben Sie eine Nachricht ein.';
if ($sendAt === null) return 'Bitte Datum und Uhrzeit für das Senden angeben.';
$ts = strtotime($sendAt);
if ($ts === false || $ts < time() + 30) return 'Der Sendezeitpunkt muss in der Zukunft liegen.';
if ($ts > time() + SCHEDULED_REPLY_MAX_DAYS * 86400) return 'Der Sendezeitpunkt darf höchstens ' . SCHEDULED_REPLY_MAX_DAYS . ' Tage entfernt liegen.';
$status = (string) ($opts['set_status'] ?? 'answered');
if (!in_array($status, ['answered', 'pending', 'resolved', 'closed'], true)) $status = 'answered';
$code = (string) ($opts['resolution_code'] ?? '');
if (in_array($status, ['resolved', 'closed'], true) && !isset(ticket_resolution_reasons()[$code])) return 'Bitte beim Schließen einen Abschlussgrund auswählen.';
$pdo->prepare(
'INSERT INTO scheduled_replies (ticket_id, staff_id, message, set_status, resolution_code, resolution_note, signature_choice, send_at, state, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([
(int) $ticket['id'], $staffId, $message, $status,
in_array($status, ['resolved', 'closed'], true) ? $code : null,
trim(mb_substr((string) ($opts['resolution_note'] ?? ''), 0, 2000)) ?: null,
isset($opts['signature_choice']) ? (string) $opts['signature_choice'] : null,
$sendAt, 'pending', db_now(),
]);
return null;
}
/** @return list<array<string,mixed>> Ausstehende geplante Antworten eines Tickets. */
function scheduled_replies_pending(PDO $pdo, int $ticketId): array
{
$stmt = $pdo->prepare("SELECT r.*, s.name AS staff_name FROM scheduled_replies r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.ticket_id = ? AND r.state = 'pending' ORDER BY r.send_at");
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
}
/** Bricht eine geplante Antwort ab (Verfasser oder Administrator). */
function scheduled_reply_cancel(PDO $pdo, int $id, int $ticketId, array $staff): bool
{
$stmt = $pdo->prepare("UPDATE scheduled_replies SET state = 'cancelled' WHERE id = ? AND ticket_id = ? AND state = 'pending' AND (staff_id = ? OR ? = 'admin')");
$stmt->execute([$id, $ticketId, (int) $staff['id'], (string) ($staff['role'] ?? '')]);
return $stmt->rowCount() > 0;
}
/**
* Sendet eine fällige geplante Antwort: legt die Nachricht im Ticket an, setzt den Status, benachrichtigt den Kunden und die Beobachter.
* Gibt true bei Erfolg zurück; bei Fehlern steht der Grund in der Zeile (state = failed).
*/
function scheduled_reply_deliver(PDO $pdo, array $row): bool
{
// Atomar übernehmen: nur ein Lauf darf die Antwort senden
$claim = $pdo->prepare("UPDATE scheduled_replies SET state = 'sending' WHERE id = ? AND state = 'pending'");
$claim->execute([(int) $row['id']]);
if ($claim->rowCount() === 0) return false;
$fail = function (string $why) use ($pdo, $row): bool {
$pdo->prepare("UPDATE scheduled_replies SET state = 'failed', error = ? WHERE id = ?")->execute([mb_substr($why, 0, 500), (int) $row['id']]);
return false;
};
try {
$t = $pdo->prepare('SELECT * FROM tickets WHERE id = ?');
$t->execute([(int) $row['ticket_id']]);
$ticket = $t->fetch();
if (!$ticket) return $fail('Das Ticket existiert nicht mehr.');
$s = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
$s->execute([(int) $row['staff_id']]);
$staff = $s->fetch();
if (!$staff || (isset($staff['is_active']) && (int) $staff['is_active'] === 0)) return $fail('Der Verfasser ist nicht mehr aktiv.');
$now = db_now();
$message = (string) $row['message'];
$newStatus = (string) $row['set_status'];
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)')
->execute([$ticket['id'], 'staff', $staff['id'], $message, 0, $now]);
$threadId = (int) $pdo->lastInsertId();
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
$resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at'];
$closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at'];
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?')
->execute([
$newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt,
$terminal ? $row['resolution_code'] : $ticket['resolution_code'],
$terminal ? ($row['resolution_note'] !== null && $row['resolution_note'] !== '' ? $row['resolution_note'] : $ticket['resolution_note']) : $ticket['resolution_note'],
$ticket['id'],
]);
if (function_exists('ticket_sla_status_transition')) ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $row['signature_choice'] !== null ? (string) $row['signature_choice'] : null);
$collab = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
$collab->execute([$ticket['id']]);
$ccEmails = array_column($collab->fetchAll(), 'email');
notify_customer_new_reply($ticket, $message, $signature, $ccEmails);
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort gesendet (eingeplant am ' . format_dt((string) $row['created_at']) . ')');
if (file_exists(__DIR__ . '/push.php')) {
require_once __DIR__ . '/push.php';
if (function_exists('push_new_reply')) push_new_reply($ticket, $staff['name']);
}
ticket_followers_notify($ticket, 'Antwort von ' . $staff['name'], (int) $staff['id']);
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => false, 'scheduled' => true]);
$pdo->prepare("UPDATE scheduled_replies SET state = 'sent', sent_at = ? WHERE id = ?")->execute([$now, (int) $row['id']]);
return true;
} catch (Throwable $e) {
return $fail($e->getMessage());
}
}
/** Sendet alle fälligen geplanten Antworten. @return array{sent:int,failed:int} */
function scheduled_replies_run_due(PDO $pdo, int $limit = 50): array
{
$stmt = $pdo->prepare("SELECT * FROM scheduled_replies WHERE state = 'pending' AND send_at <= ? ORDER BY send_at LIMIT " . (int) $limit);
$stmt->execute([db_now()]);
$sent = 0; $failed = 0;
foreach ($stmt->fetchAll() as $row) {
scheduled_reply_deliver($pdo, $row) ? $sent++ : $failed++;
}
return ['sent' => $sent, 'failed' => $failed];
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* „Von allen Geräten abmelden“: beendet alle Anmeldungen eines Mitarbeiters auf einmal.
* - Browser-Sitzungen (current_staff() prüft den Zeitpunkt „login_guard_revoke_<id>“, siehe includes/login_guard.php)
* - Anmeldungen der Apps (Android, Windows-Programm, Chrome-Erweiterung): ihre Tokens werden gelöscht, die Programme fragen danach neu nach der Anmeldung
* - Push: Browser-Abonnements und Android-Geräte, damit ein verlorenes Gerät keine Benachrichtigungen mehr bekommt
* - Ticket-Sperren des Mitarbeiters und laufende Zeiterfassung
* Das Passwort bleibt unverändert; wer es zusätzlich ändern will, tut das im Profil oder über „Das war ich nicht“.
*/
/**
* @param bool $keepCurrentSession true = die Sitzung, in der das gerade geschieht, bleibt angemeldet (nur bei eigener Abmeldung sinnvoll)
* @return array{at:int,apps:int,browsers:int,devices:int,locks:int}
*/
function staff_sign_out_everywhere(PDO $pdo, int $staffId, ?int $byStaffId = null, bool $keepCurrentSession = false): array
{
$st = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
$st->execute([$staffId]);
$staff = $st->fetch();
$res = ['at' => 0, 'apps' => 0, 'browsers' => 0, 'devices' => 0, 'locks' => 0];
if (!$staff) return $res;
$count = function (string $table) use ($pdo, $staffId): int {
try {
$c = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE staff_id = ?");
$c->execute([$staffId]);
return (int) $c->fetchColumn();
} catch (Throwable $e) {
return 0;
}
};
$res['apps'] = $count('mobile_api_tokens');
$res['browsers'] = $count('push_subscriptions');
$res['devices'] = $count('push_devices');
$at = time();
$res['at'] = $at;
set_setting('login_guard_revoke_' . $staffId, (string) $at);
foreach (['mobile_api_tokens', 'push_subscriptions', 'push_devices'] as $table) {
try { $pdo->prepare("DELETE FROM {$table} WHERE staff_id = ?")->execute([$staffId]); } catch (Throwable $e) { error_log('signout ' . $table . ': ' . $e->getMessage()); }
}
try {
$l = $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL, updated_at = updated_at WHERE locked_by = ?');
$l->execute([$staffId]);
$res['locks'] = $l->rowCount();
} catch (Throwable $e) {
error_log('signout locks: ' . $e->getMessage());
}
if (function_exists('finalize_all_sessions_for_staff')) {
try { finalize_all_sessions_for_staff($pdo, $staffId); } catch (Throwable $e) { error_log('signout time: ' . $e->getMessage()); }
}
// Die eigene Sitzung bleibt, wenn gewünscht: Ihr Anmeldezeitpunkt liegt dann hinter dem Zeitpunkt des Widerrufs
if ($keepCurrentSession && $byStaffId === $staffId && PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['staff_login_at'] = $at + 1;
}
$self = $byStaffId === null || $byStaffId === $staffId;
$detail = $self ? 'Von allen Geräten abgemeldet (eigene Veranlassung)' : 'Von allen Geräten abgemeldet durch Administrator #' . (int) $byStaffId;
try { login_guard_record($pdo, 'signout', $staffId, (string) $staff['email'], 'web', $detail); } catch (Throwable $e) { error_log('signout log: ' . $e->getMessage()); }
audit_log($byStaffId ?? $staffId, "Mitarbeiter #{$staffId} von allen Geräten abgemeldet ({$res['apps']} App-Anmeldungen, {$res['browsers']} Browser-Push, {$res['devices']} App-Geräte)");
if (!$self && trim((string) $staff['email']) !== '') {
send_mail((string) $staff['email'], 'Sie wurden von allen Geräten abgemeldet',
email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Von allen Geräten abgemeldet</h2><p style="margin:0;color:#334155">Ein Administrator hat Ihre Anmeldungen in DeskHub beendet. Bitte melden Sie sich neu an. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
}
return $res;
}
/** Aktive App-Anmeldungen eines Mitarbeiters (Android, Windows, Chrome) für die Anzeige. @return list<array<string,mixed>> */
function staff_app_logins(PDO $pdo, int $staffId): array
{
$s = $pdo->prepare('SELECT device_name, last_used_at, created_at FROM mobile_api_tokens WHERE staff_id = ? AND revoked_at IS NULL AND expires_at > ? ORDER BY COALESCE(last_used_at, created_at) DESC');
$s->execute([$staffId, db_now()]);
return $s->fetchAll();
}
+18 -1
View File
@@ -86,7 +86,19 @@ class SmtpMailer
STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]) STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
); );
if (!$this->socket) { if (!$this->socket) {
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})"); // Bei SSL-Fehlern bleibt $errstr oft leer; der eigentliche Grund steht dann in der letzten PHP-Warnung
$reason = trim((string) $errstr);
if ($reason === '') {
$last = (string) (error_get_last()['message'] ?? '');
$reason = trim(preg_replace('/^stream_socket_client\(\):\s*/', '', $last));
}
if ($reason === '') $reason = $this->encryption === 'ssl'
? 'keine Antwort. Prüfen, ob Port und Verschlüsselung zusammenpassen (SSL = 465, STARTTLS = 587) und ob der Server ausgehend verbinden darf'
: 'keine Antwort (Zeitüberschreitung oder Sperre durch eine Firewall)';
if ($this->encryption === 'ssl' && $this->port === 587) $reason .= ' – Port 587 ist für STARTTLS gedacht, bei „SSL/TLS“ gehört Port 465 dazu';
elseif ($this->encryption === 'tls' && $this->port === 465) $reason .= ' – Port 465 ist für SSL/TLS gedacht, bei „STARTTLS“ gehört Port 587 dazu';
elseif (stripos($reason, 'certificate') !== false || stripos($reason, 'verify') !== false) $reason .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle: curl.cainfo/openssl.cafile in der PHP-Konfiguration)';
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$reason}" . ($errno ? " ({$errno})" : ''));
} }
stream_set_timeout($this->socket, $this->timeout); stream_set_timeout($this->socket, $this->timeout);
$this->readResponse([220]); $this->readResponse([220]);
@@ -123,6 +135,11 @@ class SmtpMailer
$headers[] = "From: {$from}"; $headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}"; $headers[] = "Subject: {$encodedSubject}";
$headers[] = "MIME-Version: 1.0"; $headers[] = "MIME-Version: 1.0";
$headers[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
// Mails zu einem Ticket bekommen eine Message-ID mit der Ticketnummer: Antwortet der Kunde, nennt sein Mailprogramm sie in
// In-Reply-To/References, und der E-Mail-Abruf ordnet die Antwort auch dann dem Ticket zu, wenn der Betreff geändert wurde.
$mid = function_exists('mail_ticket_message_id') ? mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $from, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')) : null;
if ($mid !== null) $headers[] = "Message-ID: {$mid}";
$headers[] = "Content-Type: text/html; charset=UTF-8"; $headers[] = "Content-Type: text/html; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64"; $headers[] = "Content-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n"; return implode("\r\n", $headers) . "\r\n";
+286 -25
View File
@@ -42,6 +42,16 @@ function status_app_connection(PDO $pdo, string $platform): array
return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes]; return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes];
} }
/** Prüfadresse der Plugin-Webseite (ihr health.php, das ohne Zugangsschlüssel antwortet); leer, wenn kein offizieller Katalog eingetragen ist. */
function status_plugin_site_url(): string
{
if (!function_exists('catalog_defaults')) return '';
$parts = parse_url((string) (catalog_defaults()['plugins_url'] ?? ''));
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
$path = rtrim(str_replace(chr(92), '/', dirname((string) ($parts['path'] ?? '/'))), '/');
return 'https://' . $parts['host'] . (!empty($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/health.php';
}
function status_component_monitor_target(PDO $pdo, array $component): ?array function status_component_monitor_target(PDO $pdo, array $component): ?array
{ {
$type = (string) ($component['monitor_type'] ?? 'auto'); $type = (string) ($component['monitor_type'] ?? 'auto');
@@ -51,7 +61,14 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
if ($type === 'manual') return null; if ($type === 'manual') return null;
if ($type === 'auto') { if ($type === 'auto') {
if ($target !== '') return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse']; if ($target !== '') {
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
}
if (str_contains($name, 'plugin') && (str_contains($name, 'webseite') || str_contains($name, 'website') || str_contains($name, 'seite') || str_contains($name, 'katalog'))) {
$url = status_plugin_site_url();
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
}
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung']; if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung']; if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank']; if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
@@ -61,6 +78,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst']; if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'];
return null; return null;
} }
if ($type === 'plugin_site') {
$url = status_plugin_site_url();
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
}
if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank']; if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung']; if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung']; if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
@@ -70,6 +91,7 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587); if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587);
return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null; return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null;
} }
if ($type === 'tcp' && $target !== '' && str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung']; if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung'];
return null; return null;
} }
@@ -83,11 +105,9 @@ function status_execute_component_check(PDO $pdo, array $check): array
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; } try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; }
} elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) { } elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) {
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android'; $platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
$connection = status_app_connection($pdo, $platform); // Erreichbar heißt: Der Server nimmt Anfragen der App an (Datenbank und Schnittstelle laufen). Ob gerade ein PC oder Handy
$ok = $connection['online']; // eingeschaltet ist, sagt darüber nichts: Ohne laufendes Programm ist der Dienst trotzdem erreichbar.
if (!$ok) $error = $connection['last_seen'] === null try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Schnittstelle für die App nicht erreichbar (Datenbankabfrage fehlgeschlagen)'; }
? 'Noch keine Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android') . ' gemeldet'
: 'Keine aktuelle Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android');
} elseif ($check['type'] === 'mail') { } elseif ($check['type'] === 'mail') {
$endpoints = status_configured_mail_endpoints($pdo); $endpoints = status_configured_mail_endpoints($pdo);
$ok = count($endpoints) > 0; $ok = count($endpoints) > 0;
@@ -117,13 +137,17 @@ function status_execute_component_check(PDO $pdo, array $check): array
if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar'; if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar';
} else $error = 'Push-Dienst nicht eingerichtet'; } else $error = 'Push-Dienst nicht eingerichtet';
} else $error = 'Push-Dienst nicht eingerichtet'; } else $error = 'Push-Dienst nicht eingerichtet';
} elseif ($check['type'] === 'tcp') { } elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
if (preg_match('/^([a-zA-Z0-9.-]+):(\\d{1,5})$/', (string) $check['target'], $m) && (int) $m[2] > 0 && (int) $m[2] <= 65535) { $endpoint = $check['type'] === 'minecraft'
$socket = @stream_socket_client('tcp://' . $m[1] . ':' . $m[2], $errno, $errstr, 4, STREAM_CLIENT_CONNECT); ? status_resolve_minecraft_target((string) $check['target'])
: status_parse_host_port((string) $check['target']);
if ($endpoint !== null) {
$host = strpos($endpoint['host'], ':') !== false ? '[' . $endpoint['host'] . ']' : $endpoint['host'];
$socket = @stream_socket_client('tcp://' . $host . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
$ok = is_resource($socket); $ok = is_resource($socket);
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar'; if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
} else $error = 'Ungültiges Prüfziel'; } else $error = 'Ungültiges Prüfziel';
} elseif ($check['type'] === 'http') { } elseif (in_array($check['type'], ['http', 'plugin_site'], true)) {
$url = (string) $check['target']; $url = (string) $check['target'];
if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
if (function_exists('curl_init')) { if (function_exists('curl_init')) {
@@ -147,7 +171,54 @@ function status_execute_component_check(PDO $pdo, array $check): array
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)]; return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
} }
function status_run_component_checks(PDO $pdo): int /** Sollen Administratoren bei Ausfall und Wiederkehr eines Dienstes benachrichtigt werden? (Standard: ja) */
function status_notify_admins_enabled(): bool
{
return get_setting('status_notify_admins', '1') === '1';
}
/**
* Benachrichtigt die aktiven Administratoren per E-Mail und Push, wenn ein überwachter Dienst ausfällt oder wieder läuft.
* Höchstens eine Meldung je Dienst und Richtung innerhalb von 10 Minuten (kein Dauerfeuer bei einem flackernden Dienst).
*
* @return int Anzahl der angeschriebenen Administratoren
*/
function status_notify_admins(PDO $pdo, array $component, bool $recovered, string $error = ''): int
{
if (!status_notify_admins_enabled()) return 0;
$key = 'status_notified_' . ($recovered ? 'up_' : 'down_') . (int) $component['id'];
$last = (string) get_setting($key, '');
if ($last !== '' && strtotime($last) > time() - 600) return 0;
set_setting($key, db_now());
$ids = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
$name = (string) $component['name'];
$internal = !empty($component['is_internal']);
$title = $recovered ? "Dienst läuft wieder: {$name}" : "Dienst nicht erreichbar: {$name}";
$url = base_url() . '/staff/status_page.php';
$inner = '<p><strong>' . e($title) . '</strong></p>'
. '<p>' . ($recovered ? 'Die automatische Prüfung erreicht den Dienst wieder.' : 'Die automatische Prüfung erreicht den Dienst seit mindestens zwei Prüfungen nicht.') . '</p>'
. ($error !== '' && !$recovered ? '<p>Fehler: ' . e($error) . '</p>' : '')
. ($internal ? '<p style="color:#64748b">Dieser Dienst ist intern und steht nicht auf der öffentlichen Statusseite.</p>' : '')
. '<p><a href="' . e($url) . '">Statusseite im Mitarbeiterbereich öffnen</a></p>';
$sent = 0;
if (function_exists('staff_emails_by_ids')) {
$emails = staff_emails_by_ids($pdo, function_exists('vacation_expand_ids') ? vacation_expand_ids($pdo, $ids) : $ids);
foreach ($emails as $email) {
email_send_and_log($email, '[DeskHub] ' . $title, email_wrap($inner), null, 'Statusmeldung');
$sent++;
}
}
if (function_exists('push_to_staff')) {
foreach ($ids as $id) {
try { push_to_staff((int) $id, $title, $recovered ? 'Der Dienst läuft wieder.' : ($error !== '' ? $error : 'Die Prüfung schlägt fehl.'), '/staff/status_page.php'); } catch (Throwable $e) { /* Push ist optional */ }
}
}
try { audit_log(null, mb_substr('Statusseite: ' . $title . ' (Administratoren benachrichtigt)', 0, 250)); } catch (Throwable $e) { /* Protokoll ist optional */ }
return $sent;
}
function status_run_component_checks(PDO $pdo, bool $force = false): int
{ {
$components = status_components($pdo); $components = status_components($pdo);
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?'); $update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
@@ -156,15 +227,20 @@ function status_run_component_checks(PDO $pdo): int
foreach ($components as $component) { foreach ($components as $component) {
$check = status_component_monitor_target($pdo, $component); $check = status_component_monitor_target($pdo, $component);
if ($check === null) continue; if ($check === null) continue;
$intervalMinutes = max(1, (int) ($component['check_interval_minutes'] ?? 1));
$lastChecked = trim((string) ($component['last_checked_at'] ?? ''));
if (!$force && $lastChecked !== '' && strtotime($lastChecked) > time() - $intervalMinutes * 60) continue;
$result = status_execute_component_check($pdo, $check); $result = status_execute_component_check($pdo, $check);
$failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1; $failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1;
$update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]); $update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]);
$currentStatus = (string) $component['status']; $currentStatus = (string) $component['status'];
if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') { if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') {
status_set_component($pdo, (int) $component['id'], 'operational'); status_set_component($pdo, (int) $component['id'], 'operational');
if ($currentStatus === 'outage') status_notify_admins($pdo, $component, true);
$currentStatus = 'operational'; $currentStatus = 'operational';
} elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') { } elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') {
status_set_component($pdo, (int) $component['id'], 'outage'); status_set_component($pdo, (int) $component['id'], 'outage');
status_notify_admins($pdo, $component, false, (string) $result['error']);
$currentStatus = 'outage'; $currentStatus = 'outage';
} }
$sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]); $sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]);
@@ -186,7 +262,7 @@ function status_discover_and_check(PDO $pdo): array
if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name']; if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name'];
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name']; elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name'];
} }
$checked = status_run_component_checks($pdo); $checked = status_run_component_checks($pdo, true);
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked]; return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
} }
@@ -201,9 +277,41 @@ function status_incident_states(): array
]; ];
} }
function status_components(PDO $pdo): array /** Gibt es die Spalte is_internal schon (Datenbank-Update eingespielt)? Bis dahin verhält sich alles wie bisher. */
function status_has_internal(PDO $pdo): bool
{ {
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll(); static $cache = [];
$key = spl_object_id($pdo);
if (isset($cache[$key])) return $cache[$key];
try {
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'is_internal') return $cache[$key] = true;
} catch (Throwable $e) {
// Tabelle fehlt: nichts zu filtern
}
return $cache[$key] = false;
}
/** SQL-Zusatz: nur öffentliche (nicht interne) Dienste; $alias ist der Tabellenname von status_components. */
function status_public_sql(PDO $pdo, string $alias = 'c'): string
{
return status_has_internal($pdo) ? " AND COALESCE({$alias}.is_internal, 0) = 0" : '';
}
/** Nicht öffentlich sichtbar, aber geprüft: Dienst als „intern“ markieren oder die Markierung entfernen. */
function status_set_component_internal(PDO $pdo, int $id, bool $internal): bool
{
if (!status_has_internal($pdo)) return false;
$pdo->prepare('UPDATE status_components SET is_internal = ? WHERE id = ?')->execute([$internal ? 1 : 0, $id]);
return (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
/** @param bool $publicOnly nur Dienste, die auf der öffentlichen Statusseite stehen dürfen (nicht intern) */
function status_components(PDO $pdo, bool $includeDisabled = false, bool $publicOnly = false): array
{
$where = $includeDisabled ? 'WHERE 1 = 1 ' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
if ($publicOnly) $where .= status_public_sql($pdo) . ' ';
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
} }
function status_categories(PDO $pdo): array function status_categories(PDO $pdo): array
@@ -222,11 +330,75 @@ function status_monitor_types(): array
'smtp' => 'SMTP-Server', 'smtp' => 'SMTP-Server',
'mail' => 'E-Mail (SMTP und IMAP)', 'mail' => 'E-Mail (SMTP und IMAP)',
'fcm' => 'Push-Dienst', 'fcm' => 'Push-Dienst',
'plugin_site' => 'Plugin-Webseite (offizieller Katalog)',
'tcp' => 'Server und Port', 'tcp' => 'Server und Port',
'manual' => 'Manuell verwalten', 'manual' => 'Manuell verwalten',
]; ];
} }
/** Parses hostname:port, IPv4:port, or [IPv6]:port for TCP service checks. */
function status_parse_host_port(string $target): ?array
{
$target = trim($target);
if (preg_match('/^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/D', $target, $m)) {
if (filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) return null;
$host = $m[1];
$port = (int) $m[2];
} elseif (preg_match('/^([A-Za-z0-9.-]+):(\d{1,5})$/D', $target, $m)) {
$host = $m[1];
$port = (int) $m[2];
if (strlen($host) > 253) return null;
foreach (explode('.', rtrim($host, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') return null;
}
} else {
return null;
}
if ($port < 1 || $port > 65535) return null;
return ['host' => $host, 'port' => $port];
}
/** Minecraft accepts a bare host and resolves its Java port through DNS SRV. */
function status_parse_minecraft_target(string $target): ?array
{
$target = trim($target);
$explicit = status_parse_host_port($target);
if ($explicit !== null) return $explicit;
if (preg_match('/^\[([0-9a-fA-F:.]+)\]$/D', $target, $m)) {
return filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
? ['host' => $m[1], 'port' => null]
: null;
}
if (filter_var($target, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return ['host' => $target, 'port' => null];
if (strlen($target) > 253 || $target === '') return null;
foreach (explode('.', rtrim($target, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-' || !preg_match('/^[A-Za-z0-9-]+$/D', $label)) return null;
}
return ['host' => rtrim($target, '.'), 'port' => null];
}
function status_resolve_minecraft_target(string $target): ?array
{
$endpoint = status_parse_minecraft_target($target);
if ($endpoint === null || $endpoint['port'] !== null) return $endpoint;
$host = $endpoint['host'];
if (filter_var($host, FILTER_VALIDATE_IP) !== false) return ['host' => $host, 'port' => 25565];
if (function_exists('dns_get_record') && defined('DNS_SRV')) {
$records = @dns_get_record('_minecraft._tcp.' . $host, DNS_SRV);
if (is_array($records) && $records) {
usort($records, static fn(array $a, array $b): int => ((int) ($a['pri'] ?? 0)) <=> ((int) ($b['pri'] ?? 0)));
foreach ($records as $record) {
$srvHost = rtrim((string) ($record['target'] ?? ''), '.');
$port = (int) ($record['port'] ?? 0);
if ($srvHost !== '' && $port >= 1 && $port <= 65535 && status_parse_minecraft_target($srvHost) !== null) return ['host' => $srvHost, 'port' => $port];
}
}
}
return ['host' => $host, 'port' => 25565];
}
/** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */ /** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */
function status_configured_mail_endpoints(PDO $pdo): array function status_configured_mail_endpoints(PDO $pdo): array
{ {
@@ -259,9 +431,9 @@ function status_component_detected_targets(PDO $pdo, array $check): array
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android'; $platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
$label = $platform === 'windows' ? 'Windows' : 'Android'; $label = $platform === 'windows' ? 'Windows' : 'Android';
$connection = status_app_connection($pdo, $platform); $connection = status_app_connection($pdo, $platform);
if ($connection['last_seen'] === null) return ['Noch keine Verbindung von ' . $label . ' gemeldet']; if ($connection['last_seen'] === null) return ['Server erreichbar · noch kein Gerät mit ' . $label . ' angemeldet gewesen'];
$when = format_dt($connection['last_seen']); $when = format_dt($connection['last_seen']);
return [$connection['online'] ? 'Verbunden · letzter Kontakt ' . $when : 'Letzter Kontakt ' . $when . ' · älter als ' . (int) $connection['timeout_minutes'] . ' Minuten']; return ['Server erreichbar · ' . ($connection['online'] ? 'Gerät gerade verbunden, letzter Kontakt ' . $when : 'kein Gerät gerade aktiv, letzter Kontakt ' . $when)];
} }
if ($check['type'] === 'database') { if ($check['type'] === 'database') {
$db = (array) (config()['db'] ?? []); $db = (array) (config()['db'] ?? []);
@@ -291,14 +463,23 @@ function status_component_detected_targets(PDO $pdo, array $check): array
return [(string) ($check['target'] ?? '')]; return [(string) ($check['target'] ?? '')];
} }
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target): bool function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target, int $intervalMinutes = 1): bool
{ {
if (!isset(status_monitor_types()[$type])) return false; if (!isset(status_monitor_types()[$type])) return false;
if (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
$target = trim(mb_substr($target, 0, 500)); $target = trim(mb_substr($target, 0, 500));
if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false; if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false;
if ($type === 'tcp' && $target !== '' && !preg_match('/^[a-zA-Z0-9.-]+:\\d{1,5}$/', $target)) return false; if ($type === 'tcp' && $target !== '') {
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?'); $componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE id = ?');
$stmt->execute([$type, $target !== '' ? $target : null, $id]); $componentStmt->execute([$id]);
$componentName = mb_strtolower((string) $componentStmt->fetchColumn());
$validTarget = str_contains($componentName, 'minecraft')
? status_parse_minecraft_target($target) !== null
: status_parse_host_port($target) !== null;
if (!$validTarget) return false;
}
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, check_interval_minutes = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$type, $target !== '' ? $target : null, $intervalMinutes, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn(); return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
} }
@@ -331,21 +512,30 @@ function status_overall(PDO $pdo): array
{ {
$states = status_component_states(); $states = status_component_states();
$worst = 'operational'; $worst = 'operational';
foreach (status_components($pdo) as $c) { foreach (status_components($pdo, false, true) as $c) {
if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status']; if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status'];
} }
// Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“ // Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents WHERE status <> 'resolved'")->fetchColumn(); $open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1" . status_public_sql($pdo) . "))")->fetchColumn();
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded'; if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
$activeMaintenance = status_active_maintenance_map($pdo);
$hasActiveMaintenance = isset($activeMaintenance[0]);
if (!$hasActiveMaintenance) {
foreach (status_components($pdo, false, true) as $component) {
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
}
}
if ($hasActiveMaintenance && $states[$worst][2] < $states['maintenance'][2]) $worst = 'maintenance';
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]]; return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
} }
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */ /** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): array function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
{ {
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400); $since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE " $sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50'; . ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$stmt->execute($onlyOpen ? [] : [$since]); $stmt->execute($onlyOpen ? [] : [$since]);
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
@@ -357,6 +547,59 @@ function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): arr
return $rows; return $rows;
} }
/** Liefert geplante und aktuell laufende Wartungsfenster (oder die Admin-Historie). */
function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): array
{
$now = date('Y-m-d H:i:s');
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
$where = $includeHistory
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
return $stmt->fetchAll();
}
function status_create_maintenance_window(PDO $pdo, string $title, string $body, string $startsAt, string $endsAt, ?int $componentId, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
$body = trim(mb_substr($body, 0, 4000));
$start = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $startsAt);
$end = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $endsAt);
$startErrors = DateTimeImmutable::getLastErrors();
$endErrors = DateTimeImmutable::getLastErrors();
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel für die Wartung angeben.'];
if (!$start || !$end || ($startErrors && ($startErrors['warning_count'] || $startErrors['error_count'])) || ($endErrors && ($endErrors['warning_count'] || $endErrors['error_count']))) return ['ok' => false, 'error' => 'Bitte gültige Start- und Endzeiten angeben.'];
if ($end <= $start || $end <= new DateTimeImmutable()) return ['ok' => false, 'error' => 'Die Endzeit muss nach der Startzeit und in der Zukunft liegen.'];
if ($componentId !== null) {
$component = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ? AND COALESCE(is_enabled, 1) = 1');
$component->execute([$componentId]);
if (!(int) $component->fetchColumn()) return ['ok' => false, 'error' => 'Der ausgewählte Dienst ist nicht verfügbar.'];
}
$now = db_now();
$pdo->prepare('INSERT INTO status_maintenance_windows (title, body, component_id, starts_at, ends_at, created_by, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([$title, $body !== '' ? $body : null, $componentId, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $staffId, $now]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
function status_cancel_maintenance_window(PDO $pdo, int $id): bool
{
$stmt = $pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL');
$stmt->execute([db_now(), $id]);
return $stmt->rowCount() > 0;
}
/** Aktive Fenster nach Dienst-ID; Schlüssel 0 steht für eine globale Wartung. */
function status_active_maintenance_map(PDO $pdo): array
{
$now = date('Y-m-d H:i:s');
$stmt = $pdo->prepare('SELECT id, component_id FROM status_maintenance_windows WHERE cancelled_at IS NULL AND starts_at <= ? AND ends_at >= ?');
$stmt->execute([$now, $now]);
$active = [];
foreach ($stmt->fetchAll() as $window) $active[(int) ($window['component_id'] ?? 0)] = true;
return $active;
}
function status_set_component(PDO $pdo, int $id, string $status): bool function status_set_component(PDO $pdo, int $id, string $status): bool
{ {
if (!isset(status_component_states()[$status])) return false; if (!isset(status_component_states()[$status])) return false;
@@ -381,6 +624,13 @@ function status_toggle_component_maintenance(PDO $pdo, int $id): bool
return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance'); return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance');
} }
function status_set_component_enabled(PDO $pdo, int $id, bool $enabled): bool
{
$stmt = $pdo->prepare('UPDATE status_components SET is_enabled = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$enabled ? 1 : 0, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
/** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */ /** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
{ {
@@ -463,6 +713,7 @@ function status_save_component(PDO $pdo, ?int $id, string $name, string $descrip
function status_delete_component(PDO $pdo, int $id): void function status_delete_component(PDO $pdo, int $id): void
{ {
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]); $pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
$pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE component_id = ? AND cancelled_at IS NULL')->execute([db_now(), $id]);
$pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]); $pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]); $pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
} }
@@ -474,8 +725,11 @@ function status_restore_core_components(PDO $pdo): int
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']], ['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']], ['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']], ['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']],
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']], ['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']], ['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
['Plugin-Webseite', 'Server & Datenbank', 'plugin_site', ['Plugin-Website', 'Pluginseite', 'Plugin Webseite']],
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']], ['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']], ['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
]; ];
@@ -496,6 +750,7 @@ function status_restore_core_components(PDO $pdo): int
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn(); $maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn();
$restored = 0; $restored = 0;
foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) { foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) {
if ($monitorType === 'plugin_site' && status_plugin_site_url() === '') continue; // ohne offiziellen Katalog gibt es nichts zu prüfen
$existing = null; $existing = null;
foreach (array_merge([$name], $aliases) as $candidate) { foreach (array_merge([$name], $aliases) as $candidate) {
$find->execute([$candidate]); $find->execute([$candidate]);
@@ -512,6 +767,8 @@ function status_restore_core_components(PDO $pdo): int
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId); $id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
if ($id) { if ($id) {
status_set_component_monitor($pdo, $id, $monitorType, ''); status_set_component_monitor($pdo, $id, $monitorType, '');
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
if ($monitorType === 'plugin_site') status_set_component_internal($pdo, $id, true); // nur für Mitarbeiter sichtbar
$maxOrder += 10; $maxOrder += 10;
$restored++; $restored++;
} }
@@ -587,12 +844,16 @@ function status_summary(PDO $pdo): array
'overall' => $overall, 'overall' => $overall,
'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states), 'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states),
'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()), 'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()),
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], status_components($pdo)), 'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status'], 'internal' => !empty($c['is_internal'])], status_components($pdo)),
'incidents' => array_map(fn($i) => [ 'incidents' => array_map(fn($i) => [
'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'], 'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'],
'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null, 'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null,
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'], 'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']), 'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
], status_incidents($pdo, 14)), ], status_incidents($pdo, 14)),
'maintenanceWindows' => array_map(static fn(array $w): array => [
'id' => (int) $w['id'], 'title' => (string) $w['title'], 'body' => (string) ($w['body'] ?? ''),
'component' => (string) ($w['component_name'] ?? ''), 'startsAt' => (string) $w['starts_at'], 'endsAt' => (string) $w['ends_at'],
], status_maintenance_windows($pdo)),
]; ];
} }
@@ -0,0 +1,53 @@
<?php
/**
* „Braucht Aufmerksamkeit“: der Zähler in Chrome-Erweiterung und Windows-Programm. Er zeigt den echten Stand und keine aufsummierten Ereignisse:
* Ein Ticket zählt, solange es offen (unbeantwortet) ist, dem Mitarbeiter gehört oder noch niemandem zugewiesen ist und er es seit der
* letzten Kundennachricht noch nicht geöffnet hat. Wird es geöffnet, beantwortet, geschlossen, jemand anderem zugewiesen oder zurückgestellt,
* verschwindet es beim nächsten Abruf aus dem Zähler.
*/
/** Merkt, dass der Mitarbeiter das Ticket jetzt gesehen hat. */
function ticket_ack(PDO $pdo, int $ticketId, int $staffId): void
{
try {
$now = db_now();
$upd = $pdo->prepare('UPDATE ticket_acks SET acked_at = ? WHERE staff_id = ? AND ticket_id = ?');
$upd->execute([$now, $staffId, $ticketId]);
if ($upd->rowCount() === 0) {
$exists = $pdo->prepare('SELECT 1 FROM ticket_acks WHERE staff_id = ? AND ticket_id = ?');
$exists->execute([$staffId, $ticketId]);
if (!$exists->fetchColumn()) {
$pdo->prepare('INSERT INTO ticket_acks (staff_id, ticket_id, acked_at) VALUES (?,?,?)')->execute([$staffId, $ticketId, $now]);
}
}
} catch (Throwable $e) {
error_log('ticket_ack: ' . $e->getMessage());
}
}
/**
* Tickets, die für den Mitarbeiter Aufmerksamkeit brauchen.
* @return array{unassigned:list<int>,mine:list<int>}
*/
function staff_attention_tickets(PDO $pdo, array $staff, int $limit = 300): array
{
[$deptSql, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1';
$stmt = $pdo->prepare(
"SELECT t.id, t.assigned_staff_id
FROM tickets t
LEFT JOIN ticket_acks a ON a.ticket_id = t.id AND a.staff_id = ?
WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff'
AND (t.assigned_staff_id IS NULL OR t.assigned_staff_id = ?)
AND {$snooze}{$deptSql}
AND (a.acked_at IS NULL OR a.acked_at < COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at))
ORDER BY t.id DESC
LIMIT " . max(1, $limit)
);
$stmt->execute([(int) $staff['id'], (int) $staff['id'], ...$deptParams]);
$out = ['unassigned' => [], 'mine' => []];
foreach ($stmt->fetchAll() as $row) {
$out[$row['assigned_staff_id'] === null ? 'unassigned' : 'mine'][] = (int) $row['id'];
}
return $out;
}
+8 -1
View File
@@ -76,7 +76,14 @@ function ticket_snooze_wake_due(PDO $pdo): int
} catch (Throwable $e) { } catch (Throwable $e) {
return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert) return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
} }
$woken = 0;
foreach ($due as $t) { foreach ($due as $t) {
// Erledigte Tickets (gelöst oder geschlossen) brauchen keine Wiedervorlage mehr: still aufräumen, keine Benachrichtigung
if (in_array((string) $t['status'], ['resolved', 'closed'], true)) {
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ?')->execute([$t['id']]);
continue;
}
$woken++;
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]); $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]);
log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig'); log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig');
$to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0)); $to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0));
@@ -88,7 +95,7 @@ function ticket_snooze_wake_due(PDO $pdo): int
} }
} }
} }
return count($due); return $woken;
} }
// ── Beobachter ─────────────────────────────────────────────────────────────── // ── Beobachter ───────────────────────────────────────────────────────────────
@@ -0,0 +1,91 @@
<?php
/**
* Verknüpfte Tickets: beliebige Tickets lassen sich miteinander verbinden (verwandt, doppelt, blockiert), unabhängig von den
* „Problemen“ (includes/problems.php). Eine Verknüpfung wird einmal gespeichert (ticket_id → linked_ticket_id) und von beiden
* Tickets aus angezeigt, mit der jeweils passenden Bezeichnung.
*/
/** @return array<string,array{0:string,1:string}> Art => [Bezeichnung vom Quell-Ticket aus, Bezeichnung vom Ziel-Ticket aus] */
function ticket_link_types(): array
{
return [
'related' => ['Verwandt mit', 'Verwandt mit'],
'duplicate' => ['Doppelt zu', 'Hat Duplikat'],
'blocks' => ['Blockiert', 'Blockiert durch'],
];
}
/**
* Verknüpft zwei Tickets. Gibt eine Fehlermeldung zurück oder null bei Erfolg.
* Der Mitarbeiter muss beide Tickets sehen dürfen ($deptScope: Abteilungs-IDs des Mitarbeiters, null = alle).
*/
function ticket_link_add(PDO $pdo, int $ticketId, string $targetNumber, string $type, int $staffId, ?array $deptScope = null): ?string
{
if (!isset(ticket_link_types()[$type])) return 'Ungültige Art der Verknüpfung.';
$targetNumber = ltrim(trim($targetNumber), '#');
if ($targetNumber === '') return 'Bitte eine Ticketnummer angeben.';
$stmt = $pdo->prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?');
$stmt->execute([$targetNumber]);
$target = $stmt->fetch();
if (!$target) return 'Ticket #' . $targetNumber . ' wurde nicht gefunden.';
$targetId = (int) $target['id'];
if ($targetId === $ticketId) return 'Ein Ticket lässt sich nicht mit sich selbst verknüpfen.';
if ($deptScope !== null && !in_array((int) $target['department_id'], array_map('intval', $deptScope), true)) {
return 'Ticket #' . $targetNumber . ' gehört zu einer Abteilung, auf die Sie keinen Zugriff haben.';
}
$exists = $pdo->prepare('SELECT 1 FROM ticket_links WHERE (ticket_id = ? AND linked_ticket_id = ?) OR (ticket_id = ? AND linked_ticket_id = ?)');
$exists->execute([$ticketId, $targetId, $targetId, $ticketId]);
if ($exists->fetchColumn()) return 'Die beiden Tickets sind bereits verknüpft.';
$pdo->prepare('INSERT INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by, created_at) VALUES (?,?,?,?,?)')
->execute([$ticketId, $targetId, $type, $staffId > 0 ? $staffId : null, db_now()]);
return null;
}
/** Entfernt eine Verknüpfung, sofern sie zu diesem Ticket gehört. */
function ticket_link_remove(PDO $pdo, int $linkId, int $ticketId): bool
{
$stmt = $pdo->prepare('DELETE FROM ticket_links WHERE id = ? AND (ticket_id = ? OR linked_ticket_id = ?)');
$stmt->execute([$linkId, $ticketId, $ticketId]);
return $stmt->rowCount() > 0;
}
/**
* Alle Verknüpfungen eines Tickets aus dessen Sicht. Tickets aus Abteilungen, die der Mitarbeiter nicht sehen darf, bleiben weg.
* @return list<array{id:int,type:string,label:string,number:string,subject:string,status:string}>
*/
function ticket_links_for(PDO $pdo, int $ticketId, ?array $deptScope = null): array
{
$stmt = $pdo->prepare(
'SELECT l.id, l.ticket_id, l.linked_ticket_id, l.link_type, t.ticket_number, t.subject, t.status, t.department_id
FROM ticket_links l
JOIN tickets t ON t.id = CASE WHEN l.ticket_id = ? THEN l.linked_ticket_id ELSE l.ticket_id END
WHERE l.ticket_id = ? OR l.linked_ticket_id = ?
ORDER BY l.id'
);
$stmt->execute([$ticketId, $ticketId, $ticketId]);
$types = ticket_link_types();
$scope = $deptScope === null ? null : array_map('intval', $deptScope);
$out = [];
foreach ($stmt->fetchAll() as $row) {
if ($scope !== null && !in_array((int) $row['department_id'], $scope, true)) continue;
$outgoing = (int) $row['ticket_id'] === $ticketId;
$labels = $types[$row['link_type']] ?? ['Verknüpft mit', 'Verknüpft mit'];
$out[] = [
'id' => (int) $row['id'], 'type' => (string) $row['link_type'], 'label' => $outgoing ? $labels[0] : $labels[1],
'number' => (string) $row['ticket_number'], 'subject' => (string) $row['subject'], 'status' => (string) $row['status'],
];
}
return $out;
}
/** Offene Tickets, die dieses Ticket blockieren (Hinweis vor dem Schließen). */
function ticket_open_blockers(PDO $pdo, int $ticketId): array
{
return array_values(array_filter(ticket_links_for($pdo, $ticketId), fn($l) => $l['type'] === 'blocks' && $l['label'] === 'Blockiert durch' && !in_array($l['status'], ['resolved', 'closed'], true)));
}
/** Beim Löschen eines Tickets die Verknüpfungen mit entfernen. */
function ticket_links_purge(PDO $pdo, int $ticketId): void
{
$pdo->prepare('DELETE FROM ticket_links WHERE ticket_id = ? OR linked_ticket_id = ?')->execute([$ticketId, $ticketId]);
}
@@ -81,3 +81,88 @@ function update_http_headers(): array
[$token] = update_token_info(); [$token] = update_token_info();
return $token !== '' ? ['Authorization: token ' . $token] : []; return $token !== '' ? ['Authorization: token ' . $token] : [];
} }
/**
* Gehört diese Datei aus dem Update-Paket nicht auf die Installation? Plugins, Uploads, Backups und App-Dateien gehören dem System, die Plugin-Webseite
* und Entwickler-Ordner gehören nicht auf Kunden-Installationen. Ausnahme: Das Handbuch (docs/DeskHub-Handbuch.pdf) kommt mit jedem Update (Adminportal → Hilfe).
*/
function update_path_is_skipped(string $relNorm): bool
{
// Die Konfiguration gehört der Installation: nichts aus config/ wird je überschrieben oder neu angelegt (auch nicht bei anderer Groß-/Kleinschreibung)
if (preg_match('#^(\./)*config(/|$)#i', $relNorm) || preg_match('#(^|/)(\.env|config\.php)$#i', $relNorm) && !str_contains($relNorm, '/')) return true;
if ($relNorm === 'docs/DeskHub-Handbuch.pdf' || $relNorm === 'tools/fix-permissions.sh') return false;
return (bool) (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm)
|| preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm));
}
// ─── Updates von der Plugin-Webseite ─────────────────────────────────────────
/**
* Adresse der Update-Schnittstelle auf der offiziellen Plugin-Webseite (app-release.php neben dem Katalog) oder '' ohne offiziellen Katalog.
* Der Zugangsschlüssel wird nur an diesen Server geschickt (catalog_access_headers).
*/
function update_publisher_endpoint(array $query): string
{
$base = function_exists('catalog_defaults') ? (string) (catalog_defaults()['plugins_url'] ?? '') : '';
$parts = parse_url($base);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
$path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/');
return 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?' . http_build_query(['platform' => 'server'] + $query);
}
/**
* Die auf der Plugin-Webseite veröffentlichten System-Updates, neueste zuerst. Leer, wenn die Seite nicht antwortet oder nichts
* veröffentlicht ist (dann greift die Ausweichquelle). Jeder Eintrag hat dieselben Felder wie ein Gitea-Release (tag_name, published_at, body)
* plus sha256, size, signature und source = 'publisher'.
*
* @return list<array<string,mixed>>
*/
function update_publisher_releases(): array
{
static $cache = null;
if ($cache !== null && !isset($GLOBALS['deskhub_http_get'])) return $cache;
$url = update_publisher_endpoint(['list' => 1]);
$out = [];
if ($url !== '') {
[$status, $body] = plugin_http_get($url, 1048576, 8);
$data = $status === 200 && is_string($body) ? json_decode($body, true) : null;
foreach ((array) ($data['releases'] ?? []) as $r) {
$v = (string) ($r['version'] ?? '');
if (!is_array($r) || !preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $v) || !preg_match('/^[0-9a-f]{64}$/i', (string) ($r['sha256'] ?? ''))) continue;
$out[] = ['tag_name' => $v, 'published_at' => (string) ($r['created_at'] ?? ''), 'body' => (string) ($r['notes'] ?? ''), 'sha256' => strtolower((string) $r['sha256']),
'size' => (int) ($r['size'] ?? 0), 'signature' => (string) ($r['signature'] ?? ''), 'source' => 'publisher'];
}
usort($out, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
}
return $cache = $out;
}
/** Ein Eintrag aus update_publisher_releases() zu einem Versions-Tag (mit oder ohne „v“) oder null. */
function update_publisher_find(string $tag): ?array
{
$v = ltrim(trim($tag), 'vV');
foreach (update_publisher_releases() as $r) if ($r['tag_name'] === $v) return $r;
return null;
}
/**
* Lädt das Paket einer Version von der Plugin-Webseite und prüft Prüfsumme und (bei eingebautem Herausgeber-Schlüssel) Signatur.
*
* @return array{0:?string,1:?string} [Pfad zur ZIP-Datei, Fehlertext]; bei Fehler ist die Datei schon gelöscht
*/
function update_publisher_fetch(array $release): array
{
$url = update_publisher_endpoint(['version' => $release['tag_name'], 'download' => 1]);
if ($url === '') return [null, 'Die Plugin-Webseite ist nicht eingetragen.'];
[$status, $body, $err] = plugin_http_get($url, 300 * 1048576, 180);
if ($status !== 200 || !is_string($body)) return [null, 'Download von der Plugin-Webseite fehlgeschlagen' . ($err ? ': ' . $err : " (HTTP $status).")];
if (!hash_equals((string) $release['sha256'], hash('sha256', $body))) return [null, 'Die Prüfsumme des Pakets stimmt nicht. Es wird nicht installiert.'];
$tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip';
file_put_contents($tmp, $body);
if (update_signature_required()) {
$problem = update_verify_package((string) $release['tag_name'], $tmp, (string) $release['signature']);
if ($problem !== null) { @unlink($tmp); return [null, $problem]; }
}
return [$tmp, null];
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Abwesenheit im Profil: Offene Tickets an die Vertretung weitergeben und nach dem Urlaub zurückgeben.
* Das Plugin „Urlaub und Vertretung“ macht dasselbe nach Datum; ist dort eine Abwesenheit aktiv, übernimmt das Plugin und der Server hält sich heraus.
* Welche Tickets weitergegeben wurden, steht in vacation_moves: Zurückgegeben wird nur, was noch bei der Vertretung liegt und offen ist
* (hat jemand ein Ticket inzwischen umgehängt oder erledigt, bleibt es so).
*/
/** Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen); null ohne nutzbare Vertretung. */
function vacation_target(PDO $pdo, int $staffId): ?int
{
$get = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
$get->execute([$staffId]);
$row = $get->fetch();
if (!$row || !(int) $row['is_on_vacation']) return null;
$seen = [$staffId];
for ($i = 0; $i < 3; $i++) {
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
if ($sub <= 0 || in_array($sub, $seen, true)) return null;
$get->execute([$sub]);
$row = $get->fetch();
if (!$row || !(int) $row['is_active']) return null;
if (!(int) $row['is_on_vacation']) return $sub;
$seen[] = $sub;
}
return null;
}
/** Übernimmt das Plugin „Urlaub und Vertretung“ gerade diese Abwesenheit? */
function vacation_plugin_owns(PDO $pdo, int $staffId): bool
{
try {
$q = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active'");
$q->execute([$staffId]);
return (int) $q->fetchColumn() > 0;
} catch (Throwable $e) {
return false; // Plugin nicht installiert
}
}
/** Gibt die offenen Tickets von $staffId an die Vertretung weiter (die Abwesenheit muss schon eingetragen sein). Liefert die Zahl. */
function vacation_hand_over(PDO $pdo, int $staffId): int
{
if (vacation_plugin_owns($pdo, $staffId)) return 0;
$to = vacation_target($pdo, $staffId);
if ($to === null || $to === $staffId) return 0;
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
$toName = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $to)->fetchColumn();
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
$t->execute([$staffId]);
$tickets = $t->fetchAll();
$own = !$pdo->inTransaction();
if ($own) $pdo->beginTransaction(); // entweder alles (Zuweisung und Vermerk zur Rückgabe) oder nichts
try {
$move = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?');
$note = $pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)');
foreach ($tickets as $row) {
$move->execute([$to, $row['id']]);
$note->execute([$row['id'], $staffId, $to, db_now()]);
log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)');
}
if ($own) $pdo->commit();
} catch (Throwable $e) {
if ($own && $pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
if ($tickets) {
$list = '';
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
$mail = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
$mail->execute([$to]);
$addr = (string) $mail->fetchColumn();
if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap(
'<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Sie vertreten ' . e($name) . '</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
. '<ul style="color:#334155">' . $list . '</ul>'));
}
return count($tickets);
}
/** Gibt weitergegebene Tickets zurück, die noch bei der Vertretung liegen und offen sind. Liefert die Zahl der zurückgegebenen Tickets. */
function vacation_take_back(PDO $pdo, int $staffId): int
{
if (vacation_plugin_owns($pdo, $staffId)) return 0;
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status FROM vacation_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.from_staff_id = ? AND m.returned_at IS NULL ORDER BY m.id');
$m->execute([$staffId]);
$back = 0;
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
foreach ($m->fetchAll() as $row) {
$stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
if ($stillThere) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
log_activity($pdo, (int) $row['ticket_id'], $staffId, 'Zurück an ' . $name . ' (Abwesenheit beendet)');
$back++;
}
$pdo->prepare('UPDATE vacation_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
}
return $back;
}
/**
* Wird nach dem Speichern der Abwesenheit im Profil aufgerufen. $before/$after: [is_on_vacation, vacation_substitute_id].
* Beim Speichern mit Haken: weitergeben (auch erneut, es zählen nur Tickets, die noch beim Abwesenden liegen); Ende → zurückgeben; neue Vertretung → erst zurück, dann an die neue.
* Liefert [weitergegeben, zurückgegeben].
*/
function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $after, bool $handOver): array
{
$handed = $back = 0;
$wasOn = (int) $before[0] === 1;
$isOn = (int) $after[0] === 1;
$changedSub = (int) ($before[1] ?? 0) !== (int) ($after[1] ?? 0);
if ($wasOn && (!$isOn || $changedSub)) $back = vacation_take_back($pdo, $staffId);
if ($isOn && $handOver) $handed = vacation_hand_over($pdo, $staffId); // idempotent: nur Tickets, die noch beim Abwesenden liegen
return [$handed, $back];
}
/**
* Ersetzt Mitarbeiter im Urlaub durch ihre Vertretung (folgt Ketten), damit Benachrichtigungen dort ankommen, wo jemand arbeitet.
* Ohne nutzbare Vertretung entfällt der Eintrag. Ergebnis: eindeutige Mitarbeiter-IDs, die aktiv und anwesend sind.
* @param int[] $ids
* @return int[]
*/
function vacation_expand_ids(PDO $pdo, array $ids): array
{
$out = [];
$ids = array_values(array_filter(array_unique(array_map('intval', $ids)), fn($id) => $id > 0));
if (!$ids) return [];
// Wer im Urlaub ist, in einer Abfrage ermitteln (statt eine je Mitarbeiter)
$inIds = implode(',', array_fill(0, count($ids), '?'));
$away = $pdo->prepare("SELECT id FROM staff WHERE is_on_vacation = 1 AND id IN ($inIds)");
$away->execute($ids);
$awayIds = array_flip(array_map('intval', $away->fetchAll(PDO::FETCH_COLUMN)));
foreach ($ids as $id) {
$target = isset($awayIds[$id]) ? vacation_target($pdo, $id) : $id;
if ($target !== null) $out[$target] = $target;
}
if (!$out) return [];
$in = implode(',', array_fill(0, count($out), '?'));
$q = $pdo->prepare("SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
$q->execute(array_values($out));
return array_map('intval', $q->fetchAll(PDO::FETCH_COLUMN));
}
/** E-Mail-Adressen der Mitarbeiter-IDs (nur aktive, anwesende). @param int[] $ids @return string[] */
function staff_emails_by_ids(PDO $pdo, array $ids): array
{
$ids = array_values(array_unique(array_map('intval', $ids)));
if (!$ids) return [];
$in = implode(',', array_fill(0, count($ids), '?'));
$q = $pdo->prepare("SELECT DISTINCT email FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
$q->execute($ids);
return array_map('strval', $q->fetchAll(PDO::FETCH_COLUMN));
}
@@ -0,0 +1,80 @@
<?php
/**
* Erinnerung an den Bearbeiter: Wartet ein ihm zugewiesenes Ticket seit mehreren Arbeitsstunden auf eine Antwort, bekommt er einen Hinweis
* (Push auf die Android-App und im Browser, auf Wunsch auch per E-Mail), bevor die SLA-Frist reißt. Pro Kundennachricht gibt es nur eine
* Erinnerung. Gezählt wird in Arbeitszeit (Geschäftszeiten und Feiertage aus der SLA-Einstellung, sonst die Uhr).
* Läuft im Hintergrundjob „SLA-Prüfung“ (cron/check_sla.php, alle 15 Minuten).
*/
function wait_reminder_enabled(): bool
{
return get_setting('wait_reminder_enabled', '1') === '1';
}
function wait_reminder_hours(): int
{
return max(1, min(72, (int) get_setting('wait_reminder_hours', '4')));
}
function wait_reminder_email(): bool
{
return get_setting('wait_reminder_email', '0') === '1';
}
/**
* Prüft alle zugewiesenen, unbeantworteten Tickets und erinnert die Bearbeiter.
* @return array{checked:int,sent:int,log:string[]}
*/
function wait_reminders_run(PDO $pdo, ?string $now = null): array
{
$res = ['checked' => 0, 'sent' => 0, 'log' => []];
if (!wait_reminder_enabled()) return $res;
$now = $now ?? db_now();
$needSeconds = wait_reminder_hours() * 3600;
$snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1';
$stmt = $pdo->prepare(
"SELECT t.*, s.name AS staff_name, s.email AS staff_email,
COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at) AS last_customer_at
FROM tickets t
JOIN staff s ON s.id = t.assigned_staff_id AND s.is_active = 1
WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff'
AND t.sla_paused_at IS NULL AND t.is_overdue = 0
AND (t.resolution_due_at IS NULL OR t.resolution_due_at > ?)
AND {$snooze}
ORDER BY t.id
LIMIT 500"
);
$stmt->execute([$now]);
$mark = $pdo->prepare('UPDATE tickets SET wait_reminded_at = ?, updated_at = updated_at WHERE id = ?');
foreach ($stmt->fetchAll() as $t) {
$res['checked']++;
$since = (string) $t['last_customer_at'];
// Pro Kundennachricht nur eine Erinnerung
if (!empty($t['wait_reminded_at']) && (string) $t['wait_reminded_at'] >= $since) continue;
$waited = ticket_sla_working_seconds_between($since, $now);
if ($waited < $needSeconds) continue;
$mark->execute([$now, (int) $t['id']]);
$hours = (int) floor($waited / 3600);
$assigned = (int) $t['assigned_staff_id'];
log_activity($pdo, (int) $t['id'], null, "Erinnerung an den Bearbeiter: wartet seit {$hours} Std. auf Antwort");
if (function_exists('fcm_notify_staff') && fcm_configured()) {
fcm_notify_staff([$assigned], fcm_data('ticket_wait', $t), (int) $t['department_id']);
}
if (function_exists('push_to_staff')) {
[$title, $body] = function_exists('fcm_message') ? fcm_message('ticket_wait', $t) : ['Ticket wartet auf Antwort', '#' . $t['ticket_number']];
try { push_to_staff($assigned, $title, $body, '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number'])); } catch (Throwable $e) { error_log('wait_reminder push: ' . $e->getMessage()); }
}
if (wait_reminder_email() && trim((string) $t['staff_email']) !== '') {
$url = base_url() . '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']);
$inner = email_p('Hallo ' . e(explode(' ', trim((string) $t['staff_name']))[0]) . ', das Ticket <strong>#' . e((string) $t['ticket_number']) . '</strong> „' . e(mb_strimwidth((string) $t['subject'], 0, 80, '…')) . '“ wartet seit etwa ' . $hours . ' Stunden auf deine Antwort.')
. email_button('Ticket öffnen', $url);
email_send_and_log((string) $t['staff_email'], 'Ticket #' . $t['ticket_number'] . ' wartet auf deine Antwort', email_wrap($inner), null, 'Erinnerung-an-Bearbeiter-Mail', (string) $t['ticket_number']);
}
$res['sent']++;
$res['log'][] = "[ok] Erinnerung an {$t['staff_name']}: Ticket #{$t['ticket_number']} wartet seit {$hours} Std.";
}
return $res;
}
+15 -7
View File
@@ -21,18 +21,26 @@ function web_handoff_pages(array $staff): array
// Alle Mitarbeiter // Alle Mitarbeiter
['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true], ['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true],
['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true], ['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true],
['apps', 'Apps und Downloads', '/staff/apps.php', 'Allgemein', true],
// Mit Recht // Mit Recht
['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')], ['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')],
['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')], ['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')],
// Nur Administratoren // Nur Administratoren
['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin], ['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin],
['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $isAdmin], ['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $perm('manage_automation')],
['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $isAdmin], ['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $perm('manage_canned')],
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin], ['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $perm('manage_custom_fields')],
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin], ['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $perm('manage_custom_fields')],
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin], ['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $perm('manage_site_pages')],
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin], ['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $perm('manage_ticket_templates')],
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin], ['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $perm('manage_status_page')],
['languages', 'Sprachen', '/staff/languages.php', 'Verwaltung', $perm('manage_languages')],
['mail_queue', 'Mail-Warteschlange', '/staff/mail_queue.php', 'Verwaltung', $perm('view_mail_queue')],
['login_log', 'Anmelde-Wächter', '/staff/login_log.php', 'System', $perm('view_login_guard')],
['problems', 'Problemmanagement', '/staff/problems.php', 'Verwaltung', $perm('manage_problems')],
['customers', 'Kundenverzeichnis', '/staff/customers.php', 'Verwaltung', $perm('manage_customers')],
['banlist', 'Sperrliste', '/staff/banlist.php', 'Allgemein', $perm('manage_banlist')],
['help', 'Hilfe und Handbuch', '/staff/help.php', 'System', $perm('view_help')],
['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin], ['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin], ['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin], ['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],
+345
View File
@@ -0,0 +1,345 @@
<?php
/**
* Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter, ohne Fremdbibliothek: CBOR lesen, Registrierung und Anmeldung prüfen.
*
* Unterstützt: ES256 (ECDSA P-256) und RS256, Attestation „none“ (bei Passkeys üblich, die Herkunft des Geräts wird nicht geprüft).
* Verlangt wird immer Nutzerprüfung (Fingerabdruck, Gesicht oder PIN): Ein Passkey ersetzt deshalb Passwort und 2FA-Code.
* Relying-Party-ID und Origin kommen aus der eingestellten Website-URL (Einstellungen → Anzeigename & URL); Passkeys gelten nur dafür.
*/
/** Passkeys eingeschaltet? (Adminportal → Anmelde-Wächter) */
function webauthn_enabled(): bool
{
return get_setting('passkeys_enabled', '1') === '1';
}
function webauthn_b64u_encode(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function webauthn_b64u_decode(string $s): string
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
$r = base64_decode($s, true);
return $r === false ? '' : $r;
}
/** Minimaler CBOR-Leser (genug für Attestation-Objekt und COSE-Schlüssel). @return mixed */
function webauthn_cbor_decode(string $d, int &$o = 0, int $depth = 0)
{
if ($depth > 8 || $o >= strlen($d)) throw new RuntimeException('CBOR ungültig');
$b = ord($d[$o++]);
$major = $b >> 5;
$info = $b & 31;
if ($major === 7) {
if ($info === 20) return false;
if ($info === 21) return true;
if ($info === 22 || $info === 23) return null;
throw new RuntimeException('CBOR: nicht unterstützter Wert');
}
if ($info < 24) {
$n = $info;
} elseif ($info === 24) {
if ($o + 1 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = ord($d[$o++]);
} elseif ($info === 25) {
if ($o + 2 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('n', substr($d, $o, 2))[1]; $o += 2;
} elseif ($info === 26) {
if ($o + 4 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('N', substr($d, $o, 4))[1]; $o += 4;
} elseif ($info === 27) {
if ($o + 8 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('J', substr($d, $o, 8))[1]; $o += 8;
if ($n < 0) throw new RuntimeException('CBOR: Zahl zu groß');
} else {
throw new RuntimeException('CBOR: unbestimmte Länge nicht unterstützt');
}
switch ($major) {
case 0: return $n;
case 1: return -1 - $n;
case 2:
case 3:
if ($n > strlen($d) - $o) throw new RuntimeException('CBOR zu kurz');
$s = substr($d, $o, $n); $o += $n;
return $s;
case 4:
if ($n > 1000) throw new RuntimeException('CBOR: Liste zu lang');
$a = [];
for ($i = 0; $i < $n; $i++) $a[] = webauthn_cbor_decode($d, $o, $depth + 1);
return $a;
case 5:
if ($n > 1000) throw new RuntimeException('CBOR: Tabelle zu lang');
$m = [];
for ($i = 0; $i < $n; $i++) {
$k = webauthn_cbor_decode($d, $o, $depth + 1);
$m[is_int($k) ? $k : (string) $k] = webauthn_cbor_decode($d, $o, $depth + 1);
}
return $m;
case 6:
return webauthn_cbor_decode($d, $o, $depth + 1);
}
throw new RuntimeException('CBOR ungültig');
}
function webauthn_der_len(int $n): string
{
if ($n < 128) return chr($n);
$b = ltrim(pack('N', $n), "\0");
return chr(0x80 | strlen($b)) . $b;
}
function webauthn_der(int $tag, string $content): string
{
return chr($tag) . webauthn_der_len(strlen($content)) . $content;
}
function webauthn_der_int(string $raw): string
{
$raw = ltrim($raw, "\0");
if ($raw === '' || (ord($raw[0]) & 0x80)) $raw = "\0" . $raw;
return webauthn_der(0x02, $raw);
}
function webauthn_pem(string $der): string
{
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
}
/** COSE-Schlüssel (ES256 oder RS256) in eine PEM-Datei für OpenSSL umwandeln. @return array{0:string,1:int} PEM und Algorithmus */
function webauthn_cose_to_pem(array $cose): array
{
$kty = $cose[1] ?? null;
$alg = $cose[3] ?? null;
if ($kty === 2 && $alg === -7 && ($cose[-1] ?? null) === 1 && strlen((string) ($cose[-2] ?? '')) === 32 && strlen((string) ($cose[-3] ?? '')) === 32) {
$spki = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . "\x04" . $cose[-2] . $cose[-3];
return [webauthn_pem($spki), -7];
}
if ($kty === 3 && $alg === -257 && is_string($cose[-1] ?? null) && is_string($cose[-2] ?? null) && strlen($cose[-1]) >= 128) {
$rsaKey = webauthn_der(0x30, webauthn_der_int($cose[-1]) . webauthn_der_int($cose[-2]));
$alg = hex2bin('300d06092a864886f70d0101010500');
$spki = webauthn_der(0x30, $alg . webauthn_der(0x03, "\0" . $rsaKey));
return [webauthn_pem($spki), -257];
}
throw new RuntimeException('Dieser Passkey-Typ wird nicht unterstützt (erlaubt: ES256, RS256).');
}
/**
* Relying Party: ['id' => Domain, 'origin' => Schema://Domain[:Port], 'secure' => bool, 'name' => Anzeigename].
* Grundlage ist die Website-URL (Einstellungen → Anzeigename & URL). Wird DeskHub unter einer Subdomain dieser Adresse aufgerufen
* (Website-URL https://beispiel.de, aufgerufen unter https://support.beispiel.de), gilt die Adresse, unter der die Seite gerade aufgerufen wird:
* Der Browser verlangt genau diese Domain. Fremde Domains werden ignoriert, dann gilt die Website-URL.
*/
function webauthn_rp(): array
{
$u = parse_url(base_url());
$siteHost = strtolower((string) ($u['host'] ?? ''));
$scheme = strtolower((string) ($u['scheme'] ?? 'https'));
$host = $siteHost;
$port = isset($u['port']) ? (int) $u['port'] : null;
// Mögliche Adressen, unter denen die Seite aufgerufen wird. Der Origin-Kopf stammt vom Browser (nicht fälschbar durch die Seite) und ist
// zuverlässig, auch wenn ein Reverse-Proxy den Host-Kopf verändert; X-Forwarded-Host zählt nur von einem vertrauenswürdigen Proxy.
$candidates = [];
$originHdr = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($originHdr !== '' && ($oh = parse_url($originHdr)) && !empty($oh['host'])) $candidates[] = strtolower((string) $oh['host']) . (isset($oh['port']) ? ':' . (int) $oh['port'] : '');
if (function_exists('request_from_trusted_proxy') && request_from_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$candidates[] = strtolower(trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
}
$candidates[] = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
foreach ($candidates as $req) {
if ($req === '' || !preg_match('/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/', $req, $m)) continue;
$reqHost = $m[1];
if ($siteHost !== '' && ($reqHost === $siteHost || str_ends_with($reqHost, '.' . $siteHost))) {
$host = $reqHost;
$port = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : null;
if ($port !== null && (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) $port = null;
break;
}
}
$origin = $scheme . '://' . $host . ($port !== null ? ':' . $port : '');
$secure = $scheme === 'https' || $host === 'localhost';
return ['id' => $host, 'origin' => $origin, 'secure' => $secure, 'name' => (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub')];
}
/** Feste Kennung des Mitarbeiters im Passkey (keine Personendaten). */
function webauthn_user_handle(int $staffId): string
{
return hash_hmac('sha256', 'deskhub-passkey-user-' . $staffId, (string) (config()['app_key'] ?? 'deskhub'), true);
}
function webauthn_challenge_new(string $type, ?int $staffId = null): string
{
$c = random_bytes(32);
$_SESSION['webauthn_challenge'] = ['v' => webauthn_b64u_encode($c), 'type' => $type, 'exp' => time() + 300, 'staff' => $staffId];
return webauthn_b64u_encode($c);
}
/** Merkt die Challenge nur einmal: Ein zweiter Versuch mit derselben Challenge scheitert. */
function webauthn_challenge_take(string $type, ?int $staffId = null): ?string
{
$c = $_SESSION['webauthn_challenge'] ?? null;
unset($_SESSION['webauthn_challenge']);
if (!is_array($c) || ($c['type'] ?? '') !== $type || (int) ($c['exp'] ?? 0) < time()) return null;
if ($staffId !== null && (int) ($c['staff'] ?? 0) !== $staffId) return null;
return (string) $c['v'];
}
/** @return list<array<string,mixed>> */
function webauthn_staff_passkeys(PDO $pdo, int $staffId): array
{
try {
$s = $pdo->prepare('SELECT * FROM staff_passkeys WHERE staff_id = ? ORDER BY id');
$s->execute([$staffId]);
return $s->fetchAll();
} catch (Throwable $e) {
return [];
}
}
function webauthn_count_passkeys(PDO $pdo, int $staffId): int
{
return count(webauthn_staff_passkeys($pdo, $staffId));
}
/** Optionen für die Registrierung (navigator.credentials.create). */
function webauthn_creation_options(PDO $pdo, array $staff): array
{
$rp = webauthn_rp();
$exclude = [];
foreach (webauthn_staff_passkeys($pdo, (int) $staff['id']) as $k) $exclude[] = ['type' => 'public-key', 'id' => $k['credential_id']];
return [
'challenge' => webauthn_challenge_new('create', (int) $staff['id']),
'rp' => ['id' => $rp['id'], 'name' => $rp['name']],
'user' => ['id' => webauthn_b64u_encode(webauthn_user_handle((int) $staff['id'])), 'name' => (string) $staff['email'], 'displayName' => (string) $staff['name']],
'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7], ['type' => 'public-key', 'alg' => -257]],
'timeout' => 120000,
'attestation' => 'none',
'excludeCredentials' => $exclude,
'authenticatorSelection' => ['residentKey' => 'required', 'requireResidentKey' => true, 'userVerification' => 'required'],
];
}
/** Optionen für die Anmeldung ohne Eingabe der Adresse (navigator.credentials.get). */
function webauthn_request_options(): array
{
return [
'challenge' => webauthn_challenge_new('get'),
'rpId' => webauthn_rp()['id'],
'timeout' => 120000,
'userVerification' => 'required',
'allowCredentials' => [],
];
}
/** @return array{0:bool,1:string} gemeinsame Prüfung von clientDataJSON */
function webauthn_check_client_data(string $json, string $type, string $challenge): array
{
$cd = json_decode($json, true);
if (!is_array($cd)) return [false, 'Antwort des Geräts ist ungültig.'];
if (($cd['type'] ?? '') !== $type) return [false, 'Falscher Vorgang.'];
if (!hash_equals($challenge, (string) ($cd['challenge'] ?? ''))) return [false, 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
if (($cd['origin'] ?? '') !== webauthn_rp()['origin']) return [false, 'Die Adresse stimmt nicht mit der Website-URL aus den Einstellungen überein (' . webauthn_rp()['origin'] . ').'];
if (!empty($cd['crossOrigin'])) return [false, 'Eingebettete Seiten sind nicht erlaubt.'];
return [true, ''];
}
/**
* Registrierung abschließen. $cred = ['id' => …, 'response' => ['clientDataJSON' => b64u, 'attestationObject' => b64u]].
* @return array{ok:bool,error?:string,id?:int}
*/
function webauthn_register_finish(PDO $pdo, array $staff, array $cred, string $name): array
{
$challenge = webauthn_challenge_take('create', (int) $staff['id']);
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
try {
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.create', $challenge);
if (!$ok) return ['ok' => false, 'error' => $err];
$att = webauthn_cbor_decode(webauthn_b64u_decode((string) ($cred['response']['attestationObject'] ?? '')));
if (!is_array($att) || !is_string($att['authData'] ?? null)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$ad = $att['authData'];
if (strlen($ad) < 55) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
$flags = ord($ad[32]);
if (!($flags & 0x01)) return ['ok' => false, 'error' => 'Anwesenheit nicht bestätigt.'];
if (!($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
if (!($flags & 0x40)) return ['ok' => false, 'error' => 'Das Gerät hat keinen Schlüssel geliefert.'];
$signCount = unpack('N', substr($ad, 33, 4))[1];
$idLen = unpack('n', substr($ad, 53, 2))[1];
if ($idLen < 16 || $idLen > 1023 || strlen($ad) < 55 + $idLen) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$credId = substr($ad, 55, $idLen);
$o = 55 + $idLen;
$cose = webauthn_cbor_decode($ad, $o);
if (!is_array($cose)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
[$pem, $alg] = webauthn_cose_to_pem($cose);
if (!openssl_pkey_get_public($pem)) return ['ok' => false, 'error' => 'Der öffentliche Schlüssel ist ungültig.'];
$idText = webauthn_b64u_encode($credId);
if (hash_equals($idText, (string) ($cred['id'] ?? '')) === false) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff_passkeys WHERE credential_id = ?');
$dup->execute([$idText]);
if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => 'Dieser Passkey ist schon registriert.'];
$name = trim(mb_substr($name, 0, 80));
if ($name === '') $name = 'Passkey ' . date('d.m.Y');
$pdo->prepare('INSERT INTO staff_passkeys (staff_id, credential_id, public_key, alg, sign_count, name, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([(int) $staff['id'], $idText, $pem, $alg, $signCount, $name, db_now()]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e instanceof RuntimeException ? $e->getMessage() : 'Der Passkey konnte nicht geprüft werden.'];
}
}
/**
* Anmeldung prüfen. $cred = ['id', 'response' => ['clientDataJSON', 'authenticatorData', 'signature', 'userHandle']] (alles base64url).
* @return array{ok:bool,error?:string,staff?:array<string,mixed>,passkey?:array<string,mixed>}
*/
function webauthn_login_finish(PDO $pdo, array $cred): array
{
$challenge = webauthn_challenge_take('get');
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
try {
$st = $pdo->prepare('SELECT * FROM staff_passkeys WHERE credential_id = ?');
$st->execute([(string) ($cred['id'] ?? '')]);
$key = $st->fetch();
if (!$key) return ['ok' => false, 'error' => 'Dieser Passkey ist hier nicht registriert.'];
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.get', $challenge);
if (!$ok) return ['ok' => false, 'error' => $err];
$ad = webauthn_b64u_decode((string) ($cred['response']['authenticatorData'] ?? ''));
$sig = webauthn_b64u_decode((string) ($cred['response']['signature'] ?? ''));
if (strlen($ad) < 37 || $sig === '') return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
$flags = ord($ad[32]);
if (!($flags & 0x01) || !($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
$pub = openssl_pkey_get_public((string) $key['public_key']);
if (!$pub) return ['ok' => false, 'error' => 'Der gespeicherte Schlüssel ist ungültig.'];
$data = $ad . hash('sha256', $clientData, true);
if (openssl_verify($data, $sig, $pub, OPENSSL_ALGO_SHA256) !== 1) return ['ok' => false, 'error' => 'Die Signatur des Passkeys ist ungültig.'];
$handle = (string) ($cred['response']['userHandle'] ?? '');
if ($handle !== '' && !hash_equals(webauthn_b64u_encode(webauthn_user_handle((int) $key['staff_id'])), $handle)) {
return ['ok' => false, 'error' => 'Der Passkey gehört zu einem anderen Konto.'];
}
// Zähler gegen kopierte Passkeys: Er muss wachsen, wenn das Gerät überhaupt zählt
$count = unpack('N', substr($ad, 33, 4))[1];
$old = (int) $key['sign_count'];
if (($count !== 0 || $old !== 0) && $count <= $old) return ['ok' => false, 'error' => 'Der Passkey wurde möglicherweise kopiert und ist abgelehnt worden. Bitte einen Administrator informieren.'];
$sd = $pdo->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$sd->execute([(int) $key['staff_id']]);
$staff = $sd->fetch();
if (!$staff) return ['ok' => false, 'error' => 'Das Konto ist nicht aktiv.'];
$pdo->prepare('UPDATE staff_passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?')->execute([$count, db_now(), (int) $key['id']]);
return ['ok' => true, 'staff' => $staff, 'passkey' => $key];
} catch (Throwable $e) {
return ['ok' => false, 'error' => 'Der Passkey konnte nicht geprüft werden.'];
}
}
/** Alle Passkeys eines Mitarbeiters löschen. @return int Anzahl */
function webauthn_remove_all(PDO $pdo, int $staffId): int
{
$n = webauthn_count_passkeys($pdo, $staffId);
if ($n > 0) $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$staffId]);
return $n;
}
+28 -13
View File
@@ -10,19 +10,35 @@ $pdo = get_db();
$q = trim($_GET['q'] ?? ''); $q = trim($_GET['q'] ?? '');
if ($q !== '') { if ($q !== '') {
$stmt = $pdo->prepare( $loadSearch = static function () use ($pdo, $q): array {
"SELECT a.*, c.name AS category_name FROM kb_articles a $stmt = $pdo->prepare(
JOIN kb_categories c ON c.id = a.category_id "SELECT a.*, c.name AS category_name FROM kb_articles a
WHERE a.is_published = 1 AND (a.title LIKE ? OR a.body LIKE ?) JOIN kb_categories c ON c.id = a.category_id
ORDER BY a.views DESC" WHERE a.is_published = 1 AND (a.title LIKE ? OR a.body LIKE ?)
); ORDER BY a.views DESC"
$like = '%' . $q . '%'; );
$stmt->execute([$like, $like]); $like = '%' . $q . '%';
$articles = $stmt->fetchAll(); $stmt->execute([$like, $like]);
return $stmt->fetchAll();
};
$articles = function_exists('dhcm_cache_remember')
? dhcm_cache_remember('kb.search.' . $q, $loadSearch)
: $loadSearch();
$categories = []; $categories = [];
} else { } else {
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll(); $loadIndex = static function () use ($pdo): array {
$artStmt = $pdo->prepare('SELECT * FROM kb_articles WHERE category_id = ? AND is_published = 1 ORDER BY title'); $categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
$articlesByCategory = [];
foreach ($pdo->query('SELECT * FROM kb_articles WHERE is_published = 1 ORDER BY title')->fetchAll() as $article) {
$articlesByCategory[(int) $article['category_id']][] = $article;
}
return ['categories' => $categories, 'articles' => $articlesByCategory];
};
$kbIndex = function_exists('dhcm_cache_remember')
? dhcm_cache_remember('kb.index', $loadIndex)
: $loadIndex();
$categories = $kbIndex['categories'] ?? [];
$articlesByCategory = $kbIndex['articles'] ?? [];
$articles = null; $articles = null;
} }
@@ -54,8 +70,7 @@ require __DIR__ . '/includes/header_customer.php';
<div class="kb-category-grid"> <div class="kb-category-grid">
<?php foreach ($categories as $i => $cat): ?> <?php foreach ($categories as $i => $cat): ?>
<?php <?php
$artStmt->execute([$cat['id']]); $arts = $articlesByCategory[(int) $cat['id']] ?? [];
$arts = $artStmt->fetchAll();
if (!$arts) continue; if (!$arts) continue;
$anyArticles = true; $anyArticles = true;
?> ?>
+12 -7
View File
@@ -9,13 +9,18 @@ if (get_setting('kb_enabled', '1') !== '1') {
$pdo = get_db(); $pdo = get_db();
$slug = $_GET['slug'] ?? ''; $slug = $_GET['slug'] ?? '';
$stmt = $pdo->prepare( $loadArticle = static function () use ($pdo, $slug): ?array {
'SELECT a.*, c.name AS category_name FROM kb_articles a $stmt = $pdo->prepare(
JOIN kb_categories c ON c.id = a.category_id 'SELECT a.*, c.name AS category_name FROM kb_articles a
WHERE a.slug = ? AND a.is_published = 1' JOIN kb_categories c ON c.id = a.category_id
); WHERE a.slug = ? AND a.is_published = 1'
$stmt->execute([$slug]); );
$article = $stmt->fetch(); $stmt->execute([$slug]);
return $stmt->fetch() ?: null;
};
$article = function_exists('dhcm_cache_remember')
? dhcm_cache_remember('kb.article.' . $slug, $loadArticle)
: $loadArticle();
if (!$article) { if (!$article) {
flash('error', 'Dieser Artikel wurde nicht gefunden.'); flash('error', 'Dieser Artikel wurde nicht gefunden.');
+1 -1
View File
@@ -248,7 +248,7 @@ if ($action !== 'typing') {
if ($action === 'typing') { if ($action === 'typing') {
if ($activeTicket && $activeTicket['status'] !== 'closed') { if ($activeTicket && $activeTicket['status'] !== 'closed') {
$pdo->prepare('UPDATE tickets SET typing_customer_until = ? WHERE id = ?') $pdo->prepare('UPDATE tickets SET typing_customer_until = ?, updated_at = updated_at WHERE id = ?')
->execute([date('Y-m-d H:i:s', time() + 6), $activeTicket['id']]); ->execute([date('Y-m-d H:i:s', time() + 6), $activeTicket['id']]);
} }
chat_json(['ok' => true]); chat_json(['ok' => true]);
+4
View File
@@ -132,6 +132,10 @@ $imprintHtml = get_setting('footer_imprint_html', '');
<span class="login-guest-links">Kein Konto nötig: <a href="/ticket_new.php">Ticket erstellen</a> · <a href="/ticket_lookup.php">Status prüfen</a></span> <span class="login-guest-links">Kein Konto nötig: <a href="/ticket_new.php">Ticket erstellen</a> · <a href="/ticket_lookup.php">Status prüfen</a></span>
<a href="/staff/login.php" class="login-staff-link">Mitarbeiter-Login →</a> <a href="/staff/login.php" class="login-staff-link">Mitarbeiter-Login →</a>
</div> </div>
<?php $loginButtons = (string) plugin_filter('customer.login.buttons', '', []); ?>
<?php if ($loginButtons !== ''): ?>
<div class="login-alt" style="margin-top:18px;padding-top:16px;border-top:1px solid #e2e8f0;display:grid;gap:10px"><?= $loginButtons ?></div>
<?php endif; ?>
</div> </div>
</div> </div>
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* plugin_login.php
*
* Gemeinsamer Einstieg für Kunden-Anmeldungen über Plugins (zum Beispiel authentik): GET ?p=<Plugin>&e=<Name>. Das Plugin braucht die
* Berechtigung "auth.customer" und meldet die Adresse mit plugin_register_customer_login() an. Hier steht nur, was für alle gleich ist:
* Weiterleitung, Anmeldung (und, wenn das Plugin es erlaubt, Anlegen des Kundenkontos), Fehler und Begrenzung der Aufrufe.
* Die Rückkehr vom Dienst kann auch über staff/plugin_login.php kommen (gleiche Rückkehr-Adresse für alle).
*/
require_once __DIR__ . '/includes/bootstrap.php';
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
function plugin_customer_login_back(string $message): never
{
flash('error', $message);
redirect('/login.php');
}
if (current_user()) redirect('/home.php');
$slug = (string) ($_GET['p'] ?? '');
$endpoint = (string) ($_GET['e'] ?? '');
$handler = plugin_state()['customer_logins'][$slug][$endpoint] ?? null;
if (!is_callable($handler)) {
http_response_code(404);
echo 'Unbekannte Anmelde-Adresse.';
exit;
}
if (!rate_limit_check('plugin_customer_login', client_ip(), 40, 15)) plugin_customer_login_back('Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.');
$result = plugin_guard($slug, fn() => $handler(['query' => $_GET, 'ip' => client_ip()]), null);
if (!is_array($result)) plugin_customer_login_back('Die Anmeldung ist gerade nicht möglich. Bitte versuchen Sie es später noch einmal oder melden Sie sich mit E-Mail und Passwort an.');
if (!empty($result['redirect'])) {
$url = (string) $result['redirect'];
if (!plugin_login_external_url_ok($url)) plugin_customer_login_back('Die Adresse des Anmeldedienstes ist ungültig.');
header('Location: ' . $url, true, 302);
exit;
}
if (!empty($result['customer']) && is_array($result['customer'])) plugin_customer_login_finish($slug, $result['customer']);
plugin_customer_login_back(mb_substr((string) ($result['error'] ?? 'Die Anmeldung wurde abgelehnt.'), 0, 300));
@@ -22,7 +22,8 @@
<tbody> <tbody>
<?php foreach ($recent as $t): <?php foreach ($recent as $t):
[$label, $color] = status_label($t['status']); [$label, $color] = status_label($t['status']);
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time(); $isDone = in_array($t['status'], ['resolved','closed'], true);
$isOverdue = !$isDone && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
?> ?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>"> <tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td> <td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
@@ -34,7 +35,7 @@
<td><?= e($t['department_name']) ?></td> <td><?= e($t['department_name']) ?></td>
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td> <td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td> <td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td> <td class="muted"><?= $isDone ? '–' : (!empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']) ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+1
View File
@@ -2,5 +2,6 @@
</div> </div>
<script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script> <script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script>
<script src="/assets/js/live-tickets.js?v=<?= @filemtime(__DIR__ . '/../assets/js/live-tickets.js') ?: time() ?>"></script> <script src="/assets/js/live-tickets.js?v=<?= @filemtime(__DIR__ . '/../assets/js/live-tickets.js') ?: time() ?>"></script>
<script src="/assets/js/staff-shortcuts.js?v=<?= @filemtime(__DIR__ . '/../assets/js/staff-shortcuts.js') ?: time() ?>"></script>
</body> </body>
</html> </html>
+434 -429
View File
@@ -1,429 +1,434 @@
<?php <?php
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */ /** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
$staff = current_staff(); $staff = current_staff();
$activePage = $activePage ?? ''; $activePage = $activePage ?? '';
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title> <title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
<meta name="csrf" content="<?= csrf_token() ?>"> <meta name="csrf" content="<?= csrf_token() ?>">
<?php <?php
// VAPID Public Key – aus Config oder automatisch aus DB // VAPID Public Key – aus Config oder automatisch aus DB
if (file_exists(__DIR__ . '/../includes/push.php')) { if (file_exists(__DIR__ . '/../includes/push.php')) {
require_once __DIR__ . '/../includes/push.php'; require_once __DIR__ . '/../includes/push.php';
$__vapidKeys = get_or_create_vapid_keys(); $__vapidKeys = get_or_create_vapid_keys();
$pushKey = $__vapidKeys['public_key'] ?? ''; $pushKey = $__vapidKeys['public_key'] ?? '';
} else { } else {
$pushKey = config()['push']['public_key'] ?? ''; $pushKey = config()['push']['public_key'] ?? '';
} }
?> ?>
<?php if ($pushKey): ?> <?php if ($pushKey): ?>
<meta name="push-key" content="<?= e($pushKey) ?>"> <meta name="push-key" content="<?= e($pushKey) ?>">
<?php endif; ?> <?php endif; ?>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>"> <link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?= site_icon_links() ?> <?= site_icon_links() ?>
<link rel="manifest" href="/manifest-staff.php"> <link rel="manifest" href="/manifest-staff.php">
<meta name="theme-color" content="#0f6f6a"> <meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){}); navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
} }
</script> </script>
</head> </head>
<body> <body>
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script> <script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">&#9776;</button> <button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">&#9776;</button>
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">&laquo;</button> <button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">&laquo;</button>
<div class="brand"> <div class="brand">
<?php $logo = get_setting('logo_path'); ?> <?php $logo = get_setting('logo_path'); ?>
<?php if ($logo): ?> <?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block"> <img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
<?php else: ?> <?php else: ?>
Desk<span>Hub</span> Desk<span>Hub</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<nav> <nav>
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt <?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?> // (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a> <a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a> <a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a> <a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a> <a href="/staff/my_week.php" class="<?= $activePage==='my_week'?'active':'' ?>">Meine Woche</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a> <a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/agent_directory.php" class="<?= $activePage==='agent_directory'?'active':'' ?>">Verzeichnis</a> <?php if (has_permission($staff, 'manage_banlist')): ?><a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a><?php endif; ?>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a> <a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?> <?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a> <a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?> <?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
<?php <?php
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet), // Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst). // auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id'); [$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed' $__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
AND (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) = 'customer'" AND (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) = 'customer'"
. $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql) . $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql)
$__lcStmt = get_db()->prepare($__lcSql); $__lcStmt = get_db()->prepare($__lcSql);
$__lcStmt->execute($__lcDeptParams); $__lcStmt->execute($__lcDeptParams);
$__lcPending = (int) $__lcStmt->fetchColumn(); $__lcPending = (int) $__lcStmt->fetchColumn();
?> ?>
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>"> <a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?> Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php <?php
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle // Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()). // gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine // has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig. // Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
$hasAnyAdminAccess = false; $hasAnyAdminAccess = false;
if ($staff) { if ($staff) {
foreach (array_keys(admin_section_permissions()) as $__perm) { foreach (array_keys(admin_section_permissions()) as $__perm) {
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; } if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
} }
} }
?> ?>
<?php if ($hasAnyAdminAccess): ?> <?php if ($hasAnyAdminAccess): ?>
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0"> <div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
Adminportal <span class="nav-toggle-chevron">▾</span> Adminportal <span class="nav-toggle-chevron">▾</span>
</div> </div>
<div class="nav-admin-block" id="admin-block"> <div class="nav-admin-block" id="admin-block">
<span class="nav-group-label">Verwalten</span> <span class="nav-group-label">Verwalten</span>
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?> <?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?> <?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?> <?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_canned')): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?> <?php if (has_permission($staff, 'manage_site_pages')): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_languages')): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?> <?php if (has_permission($staff, 'manage_ticket_templates')): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_status_page')): ?><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_problems')): ?><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?> <?php if (has_permission($staff, 'view_mail_queue')): ?><a href="/staff/mail_queue.php" class="<?= $activePage==='mail_queue'?'active':'' ?>">Mail-Warteschlange</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?> <?php if (has_permission($staff, 'view_login_guard')): ?><a href="/staff/login_log.php" class="<?= $activePage==='login_log'?'active':'' ?>">Anmelde-Wächter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?> <?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?> <?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a> <?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
<?php endforeach; ?>
<span class="nav-group-label">Kunden</span> <?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?> <?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?> <a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<span class="nav-group-label">Agenten</span> <span class="nav-group-label">Kunden</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?> <?php if (has_permission($staff, 'manage_customers')): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?> <?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?> <span class="nav-group-label">Agenten</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
<span class="nav-group-label">System</span> <?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?> <?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?> <?php if (has_permission($staff, 'manage_availability')): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?> <span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?> <?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?> <?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?> <?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?> <?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?> <?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?> <?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
</div> <?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
<script> <?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
(function () { <?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
var toggle = document.getElementById('admin-toggle'); <?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
var block = document.getElementById('admin-block'); <?php if (has_permission($staff, 'view_help')): ?><a href="/staff/help.php" class="<?= $activePage==='help'?'active':'' ?>">Hilfe</a><?php endif; ?>
var KEY = 'deskhub_admin_nav_collapsed'; </div>
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt <script>
var forceOpen = block.querySelector('a.active') !== null; (function () {
function apply(collapsed) { var toggle = document.getElementById('admin-toggle');
block.style.display = collapsed ? 'none' : ''; var block = document.getElementById('admin-block');
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾'; var KEY = 'deskhub_admin_nav_collapsed';
} // In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
var stored = localStorage.getItem(KEY); var forceOpen = block.querySelector('a.active') !== null;
apply(!forceOpen && stored === '1'); function apply(collapsed) {
toggle.addEventListener('click', function () { block.style.display = collapsed ? 'none' : '';
var collapsed = block.style.display !== 'none'; toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
apply(collapsed); }
localStorage.setItem(KEY, collapsed ? '1' : '0'); var stored = localStorage.getItem(KEY);
}); apply(!forceOpen && stored === '1');
toggle.addEventListener('keydown', function (e) { toggle.addEventListener('click', function () {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); } var collapsed = block.style.display !== 'none';
}); apply(collapsed);
})(); localStorage.setItem(KEY, collapsed ? '1' : '0');
</script> });
<?php endif; ?> toggle.addEventListener('keydown', function (e) {
</nav> if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
<div class="sidebar-footer"> });
<?php if ($staff): ?> })();
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px"> </script>
<?php if (!empty($staff['avatar_path'])): ?> <?php endif; ?>
<img src="<?= e($staff['avatar_path']) ?>" alt="" style="width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; border:1px solid #2a3744"> </nav>
<?php else: ?> <div class="sidebar-footer">
<div style="width:32px; height:32px; border-radius:50%; background:rgba(15,111,106,.3); display:flex; align-items:center; justify-content:center; font-size:.85rem; font-weight:700; color:#8bc4c2; flex-shrink:0"> <?php if ($staff): ?>
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?> <?php
</div> $__unreadMentions = 0;
<?php endif; ?> $__unreadChat = 0;
<div style="min-width:0"> $__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
<div style="font-size:.82rem; font-weight:600; color:#fff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($staff['name']) ?></div> $__ms->execute([$staff['id']]);
<div style="font-size:.72rem; color:#4e6278"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div> $__unreadMentions = (int)$__ms->fetchColumn();
<?php if (!empty($staff['is_on_vacation'])): ?> $__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
<div style="font-size:.7rem; color:var(--accent)">🌴 Im Urlaub</div> $__cs->execute([$staff['id']]);
<?php endif; ?> $__unreadChat = (int)$__cs->fetchColumn();
</div> $__onVacation = !empty($staff['is_on_vacation']);
</div> ?>
<div class="lang-row" style="margin-bottom:10px"><?= i18n_switcher_html('dark') ?></div> <div class="sf-user">
<div style="display:flex; gap:14px; align-items:center"> <?php if (!empty($staff['avatar_path'])): ?>
<img class="sf-avatar" src="<?= e($staff['avatar_path']) ?>" alt="">
<?php <?php else: ?>
$__unreadMentions = 0; <div class="sf-avatar sf-avatar-letter"><?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?></div>
$__unreadChat = 0; <?php endif; ?>
if ($staff) { <div class="sf-who">
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0'); <div class="sf-name"><?= e($staff['name']) ?></div>
$__ms->execute([$staff['id']]); <div class="sf-role"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
$__unreadMentions = (int)$__ms->fetchColumn(); <?php if ($__onVacation): ?><span class="sf-vac">🌴 Im Urlaub</span><?php endif; ?>
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0'); </div>
$__cs->execute([$staff['id']]); </div>
$__unreadChat = (int)$__cs->fetchColumn(); <div class="sf-actions">
} <a class="sf-btn" href="/staff/chat.php" title="Interner Chat" aria-label="Interner Chat">
?> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L3 21l1.9-5.4A8 8 0 1 1 21 12z"/></svg>
<a href="/staff/chat.php" title="Interner Chat" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none"> <?php if ($__unreadChat > 0): ?><span class="sf-badge"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
💬<?php if ($__unreadChat > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?> </a>
</a> <a class="sf-btn" href="/staff/mentions.php" title="Erwähnungen" aria-label="Erwähnungen">
<a href="/staff/mentions.php" title="Erwähnungen" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/></svg>
🔔<?php if ($__unreadMentions > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?> <?php if ($__unreadMentions > 0): ?><span class="sf-badge"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
</a> </a>
<a href="/staff/profile.php" style="color:#8b97a3; font-size:.78rem">Profil</a> <a class="sf-btn" href="/staff/profile.php" title="Profil" aria-label="Profil">
<a href="/staff/logout.php" style="color:#8b97a3; font-size:.78rem">Abmelden</a> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21a8 8 0 0 0-16 0M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"/></svg>
</div> </a>
<?php endif; ?> <a class="sf-btn sf-btn-out" href="/staff/logout.php" title="Abmelden" aria-label="Abmelden">
</div> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</aside> </a>
</div>
<?php <div class="lang-row"><?= i18n_switcher_html('dark') ?></div>
// Timeout in JS übergeben <?php endif; ?>
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30); </div>
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time(); </aside>
if (!isset($_SESSION['staff_last_activity'])) {
$_SESSION['staff_last_activity'] = time(); <?php
$_jsLastAct = time(); // Timeout in JS übergeben
} $_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time()); $_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
?> if (!isset($_SESSION['staff_last_activity'])) {
<script> $_SESSION['staff_last_activity'] = time();
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>; $_jsLastAct = time();
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>; }
</script> $_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
<script> ?>
(function() { <script>
var remaining = window.sessionTimerRemaining || 0; window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
var timeoutMin = window.sessionTimerTimeout || 30; window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
</script>
function init() { <script>
var el = document.getElementById('session-timer'); (function() {
var bar = document.getElementById('session-timer-bar'); var remaining = window.sessionTimerRemaining || 0;
if (!el || !bar || remaining <= 0) return; var timeoutMin = window.sessionTimerTimeout || 30;
function fmt(s) { function init() {
var m = Math.floor(s / 60); var el = document.getElementById('session-timer');
var sec = s % 60; var bar = document.getElementById('session-timer-bar');
return m + ':' + (sec < 10 ? '0' : '') + sec; if (!el || !bar || remaining <= 0) return;
}
function fmt(s) {
function tick() { var m = Math.floor(s / 60);
if (remaining <= 0) { var sec = s % 60;
window.location.href = '/staff/login.php'; return m + ':' + (sec < 10 ? '0' : '') + sec;
return; }
}
function tick() {
el.textContent = fmt(remaining); if (remaining <= 0) {
window.location.href = '/staff/login.php';
// Immer sichtbar return;
bar.style.opacity = '1'; }
bar.style.pointerEvents = 'auto';
el.textContent = fmt(remaining);
// Farbe nach Dringlichkeit
if (remaining <= 60) { // Immer sichtbar
bar.style.background = 'rgba(239,68,68,.9)'; bar.style.opacity = '1';
bar.style.borderColor = 'rgba(239,68,68,.5)'; bar.style.pointerEvents = 'auto';
el.style.color = '#fff';
} else if (remaining <= 300) { // Farbe nach Dringlichkeit
bar.style.background = 'rgba(245,158,11,.9)'; if (remaining <= 60) {
bar.style.borderColor = 'rgba(245,158,11,.5)'; bar.style.background = 'rgba(239,68,68,.9)';
el.style.color = '#fff'; bar.style.borderColor = 'rgba(239,68,68,.5)';
} else { el.style.color = '#fff';
bar.style.background = 'rgba(15,23,42,.72)'; } else if (remaining <= 300) {
bar.style.borderColor = 'rgba(255,255,255,.1)'; bar.style.background = 'rgba(245,158,11,.9)';
el.style.color = '#cbd5e1'; bar.style.borderColor = 'rgba(245,158,11,.5)';
} el.style.color = '#fff';
} else {
remaining--; bar.style.background = 'rgba(15,23,42,.72)';
setTimeout(tick, 1000); bar.style.borderColor = 'rgba(255,255,255,.1)';
} el.style.color = '#cbd5e1';
}
tick();
remaining--;
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen setTimeout(tick, 1000);
var resetDebounce; }
function resetTimer() {
clearTimeout(resetDebounce); tick();
resetDebounce = setTimeout(function() {
fetch('/staff/session_ping.php', { method:'POST', // Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''}, var resetDebounce;
credentials:'same-origin' }) function resetTimer() {
.then(function(r){ return r.json(); }) clearTimeout(resetDebounce);
.then(function(d){ if (d.remaining) remaining = d.remaining; }) resetDebounce = setTimeout(function() {
.catch(function(){}); fetch('/staff/session_ping.php', { method:'POST',
}, 2000); // max. alle 2 Sek. ein Ping headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
} credentials:'same-origin' })
.then(function(r){ return r.json(); })
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) { .then(function(d){ if (d.remaining) remaining = d.remaining; })
document.addEventListener(ev, resetTimer, { passive: true }); .catch(function(){});
}); }, 2000); // max. alle 2 Sek. ein Ping
}
// Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
// und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung ['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
function syncTimer() { document.addEventListener(ev, resetTimer, { passive: true });
fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' }) });
.then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; }) // Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
.catch(function(){}); // und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung
} function syncTimer() {
setInterval(syncTimer, 30000); fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' })
} // end init .then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; })
if (document.readyState === 'loading') { .catch(function(){});
document.addEventListener('DOMContentLoaded', init); }
} else { setInterval(syncTimer, 30000);
init(); } // end init
}
})(); if (document.readyState === 'loading') {
</script> document.addEventListener('DOMContentLoaded', init);
} else {
<script> init();
// ── Browser Push Notifications ─────────────────────────────────────────── }
(function() { })();
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value; </script>
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
var btn = document.getElementById('push-toggle-btn'); <script>
// ── Browser Push Notifications ───────────────────────────────────────────
function urlB64ToUint8(b64) { (function() {
var pad = '='.repeat((4 - b64.length % 4) % 4); var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad); if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); })); var btn = document.getElementById('push-toggle-btn');
}
function urlB64ToUint8(b64) {
function setBtn(active) { var pad = '='.repeat((4 - b64.length % 4) % 4);
if (!btn) return; var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
btn.textContent = active ? '🔔' : '🔕'; return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
btn.style.color = active ? '#22c55e' : '#8b97a3'; }
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
} function setBtn(active) {
if (!btn) return;
navigator.serviceWorker.ready.then(function(reg) { btn.textContent = active ? '🔔' : '🔕';
reg.pushManager.getSubscription().then(function(s) { btn.style.color = active ? '#22c55e' : '#8b97a3';
setBtn(!!(s && Notification.permission === 'granted')); btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
}); }
});
navigator.serviceWorker.ready.then(function(reg) {
window.togglePushNotifications = function() { reg.pushManager.getSubscription().then(function(s) {
navigator.serviceWorker.ready.then(function(reg) { setBtn(!!(s && Notification.permission === 'granted'));
reg.pushManager.getSubscription().then(function(sub) { });
if (sub) { });
sub.unsubscribe().then(function() {
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin', window.togglePushNotifications = function() {
headers:{'Content-Type':'application/json'}, navigator.serviceWorker.ready.then(function(reg) {
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) }); reg.pushManager.getSubscription().then(function(sub) {
setBtn(false); if (sub) {
}); sub.unsubscribe().then(function() {
} else { fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
Notification.requestPermission().then(function(perm) { headers:{'Content-Type':'application/json'},
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; } body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) }) setBtn(false);
.then(function(ns) { });
var j = ns.toJSON(); } else {
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin', Notification.requestPermission().then(function(perm) {
headers:{'Content-Type':'application/json'}, if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) }) reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
.then(function() { setBtn(true); }); .then(function(ns) {
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); }); var j = ns.toJSON();
}); fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
} headers:{'Content-Type':'application/json'},
}); body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
}); .then(function() { setBtn(true); });
}; }).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
})(); });
</script> }
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script> });
<main class="main"> });
<!-- Session-Timer: kompakte Pille oben rechts --> };
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s"> })();
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> </script>
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span> <script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
</div> <main class="main">
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?> <!-- Session-Timer: kompakte Pille oben rechts -->
<?php foreach (get_flashes() as $f): ?> <div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<?php endforeach; ?> <span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
<?php </div>
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) { <?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
foreach (system_health(get_db()) as $__issue): ?> <?php foreach (get_flashes() as $f): ?>
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?> <?php endforeach; ?>
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a> <?php
</div> if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
<?php endforeach; } ?> foreach (system_health(get_db()) as $__issue): ?>
<?php <div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') { <strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
require_once __DIR__ . '/../database/migrate.php'; <a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
$__pending = migrations_pending(get_db()); </div>
if ($__pending): ?> <?php endforeach; } ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <?php
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank. if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a> require_once __DIR__ . '/../database/migrate.php';
</div> $__pending = migrations_pending(get_db());
<?php endif; } ?> if ($__pending): ?>
<?php <div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) { <strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
$__upd = plugin_updates_available(get_db()); <a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
if ($__upd): ?> </div>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <?php endif; } ?>
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?> <?php
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a> if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
</div> $__upd = plugin_updates_available(get_db());
<?php endif; } ?> if ($__upd): ?>
<?php $lockedOverride = get_locked_ticket_override(); ?> <div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?> <strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> <a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten. </div>
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0"> <?php endif; } ?>
<?= csrf_field() ?> <?php $lockedOverride = get_locked_ticket_override(); ?>
<input type="hidden" name="action" value="admin_force_unlock"> <?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>"> <div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button> 🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
</form> <form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
</div> <?= csrf_field() ?>
<?php endif; ?> <input type="hidden" name="action" value="admin_force_unlock">
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?> <input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
<div class="flash warning" style="display:flex;align-items:center;gap:10px"> <button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet. </form>
Bitte in <code>config/config.php</code> auf <code>driver&nbsp;=&nbsp;'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen. </div>
</div> <?php endif; ?>
<?php endif; ?> <?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?> <div class="flash warning" style="display:flex;align-items:center;gap:10px">
<div class="flash warning" style="display:flex;align-items:center;gap:10px"> ⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt. Bitte in <code>config/config.php</code> auf <code>driver&nbsp;=&nbsp;'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen. </div>
Generieren: <code>php&nbsp;-r&nbsp;"echo&nbsp;bin2hex(random_bytes(32));"</code> <?php endif; ?>
</div> <?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
<?php endif; ?> <div class="flash warning" style="display:flex;align-items:center;gap:10px">
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
Generieren: <code>php&nbsp;-r&nbsp;"echo&nbsp;bin2hex(random_bytes(32));"</code>
</div>
<?php endif; ?>
@@ -34,12 +34,13 @@
<table> <table>
<thead><tr> <thead><tr>
<th style="width:24px"><input type="checkbox" onclick="document.querySelectorAll('.ticket-check').forEach(c=>c.checked=this.checked)"></th> <th style="width:24px"><input type="checkbox" onclick="document.querySelectorAll('.ticket-check').forEach(c=>c.checked=this.checked)"></th>
<th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Zugewiesen</th><th>Eingang</th><th>Fällig</th> <th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Zugewiesen</th><th>Letzte Aktivität</th><th>Fällig</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<?php foreach ($tickets as $t): <?php foreach ($tickets as $t):
[$label, $color] = status_label($t['status']); [$label, $color] = status_label($t['status']);
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time() && !in_array($t['status'], ['resolved','closed']); $isPaused = !empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']);
$isOverdue = !in_array($t['status'], ['resolved','closed']) && (!empty($t['is_overdue']) || (!$isPaused && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time()));
?> ?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>"> <tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><input type="checkbox" class="ticket-check" name="ticket_ids[]" value="<?= $t['id'] ?>"></td> <td><input type="checkbox" class="ticket-check" name="ticket_ids[]" value="<?= $t['id'] ?>"></td>
@@ -59,8 +60,8 @@
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td> <td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td> <td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td><?= $t['staff_name'] ? e($t['staff_name']) : '<span class="muted">–</span>' ?></td> <td><?= $t['staff_name'] ? e($t['staff_name']) : '<span class="muted">–</span>' ?></td>
<td class="muted" style="white-space:nowrap" title="Ticket eingegangen"><?= e(format_dt($t['created_at'] ?? null)) ?></td> <td class="muted" style="white-space:nowrap" title="Eingegangen: <?= e(format_dt($t['created_at'] ?? null)) ?>"><?= e(format_dt($t['updated_at'] ?? $t['created_at'] ?? null)) ?></td>
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td> <td class="muted"><?= in_array($t['status'], ['resolved','closed'], true) ? '–' : ($isPaused ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
+16 -1
View File
@@ -15,6 +15,7 @@ require __DIR__ . '/_header.php';
?> ?>
<h1 style="margin-top:0">Apps für Mitarbeiter</h1> <h1 style="margin-top:0">Apps für Mitarbeiter</h1>
<p class="muted">Mit den Apps bearbeitet ihr Tickets am PC oder unterwegs. Beide haben ein helles und dunkles Design.</p> <p class="muted">Mit den Apps bearbeitet ihr Tickets am PC oder unterwegs. Beide haben ein helles und dunkles Design.</p>
<p class="muted">Außerdem: das Plugin für WordPress (Tickets von eurer Webseite aus anlegen) und die Erweiterung für den Chrome-Browser.</p>
<div class="card" style="margin-bottom:20px"> <div class="card" style="margin-bottom:20px">
<strong>Serveradresse für die Anmeldung</strong> <strong>Serveradresse für die Anmeldung</strong>
@@ -26,7 +27,7 @@ require __DIR__ . '/_header.php';
</div> </div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px">
<?php foreach ($visiblePlatforms as $key => $p): $a = $apps[$key]; $noun = $key === "minecraft" ? "Plugin" : "App"; ?> <?php foreach ($visiblePlatforms as $key => $p): $a = $apps[$key]; $noun = ['minecraft' => 'Plugin', 'wordpress' => 'Plugin', 'chrome' => 'Erweiterung'][$key] ?? 'App'; ?>
<div class="card" style="margin:0"> <div class="card" style="margin:0">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px"><?= e($p['label']) ?></div> <div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px"><?= e($p['label']) ?></div>
<?php if ($a): ?> <?php if ($a): ?>
@@ -52,6 +53,20 @@ require __DIR__ . '/_header.php';
<li>Falls Windows warnt („Der Computer wurde geschützt“): <em>Weitere Informationen → Trotzdem ausführen</em>.</li> <li>Falls Windows warnt („Der Computer wurde geschützt“): <em>Weitere Informationen → Trotzdem ausführen</em>.</li>
<li>App öffnen, Serveradresse eingeben und anmelden.</li> <li>App öffnen, Serveradresse eingeben und anmelden.</li>
</ol> </ol>
<?php elseif ($key === 'wordpress'): ?>
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
<li>ZIP herunterladen. In WordPress unter <em>Plugins → Installieren → Plugin hochladen</em> hochladen und aktivieren.</li>
<li>In DeskHub unter Verwalten → API-Schlüssel einen Schlüssel mit dem Recht „Tickets anlegen“ erzeugen.</li>
<li>In WordPress unter <em>Einstellungen → DeskHub Tickets</em> Serveradresse (oben) und Schlüssel eintragen und „Speichern und Verbindung testen“ klicken.</li>
<li>Auf einer Seite den Shortcode <code>[deskhub_ticket]</code> einfügen. Die Statusabfrage für Kunden heißt <code>[deskhub_status]</code>.</li>
</ol>
<?php elseif ($key === 'chrome'): ?>
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
<li>ZIP herunterladen und in einen festen Ordner entpacken (der Ordner muss bleiben).</li>
<li>In Chrome <code>chrome://extensions</code> öffnen, oben rechts den <em>Entwicklermodus</em> einschalten und <em>Entpackte Erweiterung laden</em> wählen. Den Ordner auswählen.</li>
<li>Erweiterung öffnen, Serveradresse eingeben und anmelden.</li>
<li>Updates: neue ZIP in denselben Ordner entpacken und die Erweiterung auf <code>chrome://extensions</code> neu laden.</li>
</ol>
<?php elseif ($key === 'minecraft'): ?> <?php elseif ($key === 'minecraft'): ?>
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7"> <ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
<li>Jar herunterladen und in den Ordner <code>plugins</code> deines Spigot-, Paper- oder BungeeCord-Servers legen.</li> <li>Jar herunterladen und in den Ordner <code>plugins</code> deines Spigot-, Paper- oder BungeeCord-Servers legen.</li>
+67 -70
View File
@@ -38,7 +38,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($
} }
// ── Schichtplan (Admin-Seite) ───────────────────────────────────────────────── // ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
$admin = $staff['role'] === 'admin' ? $staff : null; $admin = has_permission($staff, 'manage_availability') ? $staff : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf(); verify_csrf();
@@ -125,17 +125,36 @@ $activePage = 'availability';
require __DIR__ . '/_header.php'; require __DIR__ . '/_header.php';
$statusLabels = [ $statusLabels = [
'available' => ['Verfügbar', '#22c55e', '🟢'], 'available' => ['Verfügbar', '#22c55e'],
'busy' => ['Beschäftigt', '#f59e0b', '🟡'], 'busy' => ['Beschäftigt', '#f59e0b'],
'break' => ['Pause', '#6366f1', '🟣'], 'break' => ['Pause', '#6366f1'],
'offline' => ['Offline', '#94a3b8', '⚫'], 'offline' => ['Offline', '#94a3b8'],
]; ];
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; $weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
?> ?>
<style>
.av-dot { display:inline-block; width:10px; height:10px; border-radius:50%; background:var(--c, #94a3b8); flex:none }
.av-status { display:flex; gap:10px; flex-wrap:wrap; align-items:center }
.status-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:.88rem; font-weight:600; padding:8px 16px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--ink-soft); cursor:pointer; transition:border-color .15s, background .15s, color .15s }
.status-btn:hover { border-color:var(--c) }
.status-btn.active { border-color:var(--c); background:color-mix(in srgb, var(--c) 12%, var(--surface)); color:var(--ink) }
.av-note { flex:1; min-width:200px; margin:0 }
.av-team { display:grid; grid-template-columns:repeat(auto-fill, minmax(250px, 1fr)); gap:12px }
.av-agent { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--border); border-radius:10px; background:var(--surface) }
.av-avatar { position:relative; width:38px; height:38px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex:none }
.av-avatar .status-dot { position:absolute; right:-2px; bottom:-2px; width:12px; height:12px; border-radius:50%; border:2px solid var(--surface); background:#94a3b8 }
.av-agent .av-name { font-weight:600; overflow-wrap:anywhere }
.av-agent .status-label { font-size:.8rem; font-weight:600 }
.av-agent .status-note, .av-agent .av-seen { font-size:.76rem; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.av-edit { margin-left:auto; flex:none; padding:4px 10px; font-size:.78rem }
.av-admin-row form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 }
.av-admin-row select, .av-admin-row input[type=text] { margin:0; width:auto }
.av-auto { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:16px; padding-top:16px; border-top:1px solid var(--border-soft) }
.av-auto label, .av-auto input { margin:0 }
.av-auto input[type=number] { width:90px }
</style>
<div class="topbar"> <h1 style="margin-top:0">Verfügbarkeit &amp; Schichten</h1>
<h1>Verfügbarkeit & Schichten</h1>
</div>
<!-- Hidden CSRF form für AJAX --> <!-- Hidden CSRF form für AJAX -->
<form id="csrf-form" style="display:none"><?= csrf_field() ?></form> <form id="csrf-form" style="display:none"><?= csrf_field() ?></form>
@@ -147,53 +166,38 @@ foreach ($agents as $a) { if ($a['id'] === $staff['id']) { $myAvail = $a; break;
$myStatus = $myAvail['avail_status'] ?? 'available'; $myStatus = $myAvail['avail_status'] ?? 'available';
?> ?>
<div class="card" style="margin-bottom:20px"> <div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Mein Status</div> <h3>Mein Status</h3>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center"> <div class="av-status">
<?php foreach ($statusLabels as $sk => [$sl, $sc, $se]): ?> <?php foreach ($statusLabels as $sk => [$sl, $sc]): ?>
<button type="button" onclick="setMyStatus('<?= $sk ?>')" <button type="button" onclick="setMyStatus('<?= $sk ?>')" class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>" data-status="<?= $sk ?>" style="--c:<?= $sc ?>">
class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>" <span class="av-dot" style="--c:<?= $sc ?>"></span><?= $sl ?>
data-status="<?= $sk ?>"
style="display:flex;align-items:center;gap:7px;padding:8px 16px;border:2px solid <?= $myStatus === $sk ? $sc : 'var(--border)' ?>;border-radius:8px;background:<?= $myStatus === $sk ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.85rem;font-weight:<?= $myStatus === $sk ? '600' : '400' ?>;color:<?= $myStatus === $sk ? $sc : 'var(--ink-soft)' ?>;transition:all .15s">
<span><?= $se ?></span> <?= $sl ?>
</button> </button>
<?php endforeach; ?> <?php endforeach; ?>
<div style="flex:1;min-width:160px"> <input type="text" id="avail-note" class="av-note" value="<?= e($myAvail['avail_note'] ?? '') ?>" maxlength="120"
<input type="text" id="avail-note" value="<?= e($myAvail['avail_note'] ?? '') ?>" placeholder="Notiz (optional, z. B. „In Besprechung bis 14:00“)" oninput="scheduleStatusUpdate()">
placeholder="Notiz (optional, z.B. &laquo;In Besprechung bis 14:00&raquo;)"
style="width:100%;font-size:.83rem"
oninput="scheduleStatusUpdate()">
</div>
</div> </div>
</div> </div>
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── --> <!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:20px"> <div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">Team-Übersicht</div> <h3>Team-Übersicht</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px"> <div class="av-team">
<?php foreach ($agents as $ag): <?php foreach ($agents as $ag):
$as = $ag['avail_status'] ?? 'offline'; $as = $ag['avail_status'] ?? 'offline';
[$asl, $asc, $ase] = $statusLabels[$as] ?? ['Unbekannt','#94a3b8','⚫']; [$asl, $asc] = $statusLabels[$as] ?? ['Unbekannt', '#94a3b8'];
$hasShift = isset($shiftsByStaff[$ag['id']]); if (!empty($ag['is_on_vacation'])) { $asl = 'Urlaub'; $asc = '#94a3b8'; }
$__la = staff_last_active_label($ag['last_active_at'] ?? null);
?> ?>
<div id="agent-status-<?= $ag['id'] ?>" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)"> <div class="av-agent" id="agent-status-<?= $ag['id'] ?>">
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem;font-weight:700;flex-shrink:0;position:relative"> <div class="av-avatar"><?= e(mb_strtoupper(mb_substr($ag['name'], 0, 1))) ?><span class="status-dot" style="background:<?= $asc ?>"></span></div>
<?= mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
<span class="status-dot" style="position:absolute;bottom:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:<?= $asc ?>;border:2px solid var(--surface)"></span>
</div>
<div style="min-width:0;flex:1"> <div style="min-width:0;flex:1">
<div style="font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> <div class="av-name"><?= e($ag['name']) ?></div>
<?= e($ag['name']) ?> <div><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
<?php if ($ag['is_on_vacation']): ?><span style="font-size:.7rem">🌴</span><?php endif; ?> <div class="status-note"><?= e($ag['avail_note'] ?? '') ?></div>
</div> <?php if (!empty($ag['avail_auto'])): ?><div class="av-seen">automatisch offline</div>
<div style="font-size:.75rem;font-weight:500"><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div> <?php elseif ($__la !== ''): ?><div class="av-seen">aktiv <?= e($__la) ?></div><?php endif; ?>
<div class="status-note" style="font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($ag['avail_note'] ?? '') ?></div>
</div> </div>
<?php if ($admin): ?> <?php if ($admin): ?><a href="?edit=<?= $ag['id'] ?>" class="btn secondary av-edit" title="Schichtplan bearbeiten">Schichtplan</a><?php endif; ?>
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
<?php endif; ?>
<?php $__la = staff_last_active_label($ag['last_active_at'] ?? null); ?>
<?php if ($__la !== '' || !empty($ag['avail_auto'])): ?><div style="font-size:.68rem;color:var(--muted);flex-shrink:0;text-align:right"><?= !empty($ag['avail_auto']) ? 'automatisch<br>offline' : '' ?><?= $__la !== '' && empty($ag['avail_auto']) ? 'aktiv ' . e($__la) : '' ?></div><?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@@ -202,34 +206,38 @@ $myStatus = $myAvail['avail_status'] ?? 'available';
<?php if ($admin): ?> <?php if ($admin): ?>
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── --> <!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
<div class="card" style="margin-bottom:20px"> <div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Status der Mitarbeiter ändern (Admin)</div> <h3>Status der Mitarbeiter ändern (Admin)</h3>
<p class="muted" style="font-size:.8rem;margin-bottom:12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p> <p class="muted" style="font-size:.85rem;margin:0 0 12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<table style="width:100%;border-collapse:collapse;font-size:.85rem"> <div style="overflow-x:auto"><table>
<thead><tr><th>Mitarbeiter</th><th>Status und Notiz</th></tr></thead>
<tbody>
<?php foreach ($agents as $ag): ?> <?php foreach ($agents as $ag): ?>
<tr style="border-top:1px solid var(--border)"> <tr class="av-admin-row">
<td style="padding:6px 8px"><?= e($ag['name']) ?></td> <td><strong><?= e($ag['name']) ?></strong></td>
<td style="padding:6px 8px"> <td>
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"> <form method="post">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="set_staff_status"> <input type="hidden" name="action" value="set_staff_status">
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>"> <input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
<select name="status" style="width:auto"> <select name="status">
<?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?> <?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?>
</select> </select>
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:180px"> <input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:220px">
<button type="submit" class="btn secondary">Setzen</button> <button type="submit" class="btn secondary">Setzen</button>
</form> </form>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</table> </tbody>
<form method="post" style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"> </table></div>
<form method="post" class="av-auto">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="save_auto_offline"> <input type="hidden" name="action" value="save_auto_offline">
<label style="margin:0;font-weight:500">Automatisch offline nach</label> <label>Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>" style="width:90px"> Minuten ohne Aktivität <input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>">
<span>Minuten ohne Aktivität</span>
<button type="submit" class="btn secondary">Speichern</button> <button type="submit" class="btn secondary">Speichern</button>
<span class="muted" style="font-size:.78rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span> <span class="muted" style="font-size:.8rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
</form> </form>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -309,18 +317,7 @@ var statusUpdateTimer;
function setMyStatus(s) { function setMyStatus(s) {
currentStatus = s; currentStatus = s;
document.querySelectorAll('.status-btn').forEach(function(btn) { document.querySelectorAll('.status-btn').forEach(function(btn) {
var active = btn.dataset.status === s; btn.classList.toggle('active', btn.dataset.status === s);
var colors = {
available: ['#22c55e','#22c55e18'],
busy: ['#f59e0b','#f59e0b18'],
break: ['#6366f1','#6366f118'],
offline: ['#94a3b8','#94a3b818'],
};
var c = colors[btn.dataset.status] || ['var(--border)','transparent'];
btn.style.borderColor = active ? c[0] : 'var(--border)';
btn.style.background = active ? c[1] : 'transparent';
btn.style.color = active ? c[0] : 'var(--ink-soft)';
btn.style.fontWeight = active ? '600' : '400';
}); });
sendStatusUpdate(); sendStatusUpdate();
} }
@@ -360,7 +357,7 @@ function refreshTeamStatus() {
var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' }; var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' };
var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' }; var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' };
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline'); var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
var label = ag.is_on_vacation ? '🌴 Urlaub' : labels[s]; var label = ag.is_on_vacation ? 'Urlaub' : labels[s];
var color = colors[s] || '#94a3b8'; var color = colors[s] || '#94a3b8';
el.querySelector('.status-dot').style.background = color; el.querySelector('.status-dot').style.background = color;
el.querySelector('.status-label').textContent = label; el.querySelector('.status-label').textContent = label;
+9 -3
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login(); $staff = require_admin_or_permission('manage_banlist');
$pdo = get_db(); $pdo = get_db();
$errors = []; $errors = [];
@@ -24,8 +24,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]); ->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]);
// Tickets schließen // Tickets schließen
$col = $matchType === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . ""; $col = $matchType === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
$priorStmt->execute([$value]); $priorTickets = $priorStmt->fetchAll();
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
->execute([db_now(), db_now(), $value]); ->execute([db_now(), db_now(), $value]);
ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']);
audit_log($staff['id'], "Sperrliste: $value hinzugefügt ($matchType)"); audit_log($staff['id'], "Sperrliste: $value hinzugefügt ($matchType)");
} }
flash('success', "$value gesperrt."); flash('success', "$value gesperrt.");
@@ -50,8 +53,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]); ->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]);
// Tickets schließen // Tickets schließen
$col = $type === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . ""; $col = $type === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
$priorStmt->execute([$val]); $priorTickets = $priorStmt->fetchAll();
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
->execute([db_now(), db_now(), $val]); ->execute([db_now(), db_now(), $val]);
ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']);
$added++; $added++;
} }
audit_log($staff['id'], "Sperrliste Massen-Import: $added hinzugefügt, $skipped übersprungen"); audit_log($staff['id'], "Sperrliste Massen-Import: $added hinzugefügt, $skipped übersprungen");
+1 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin(); $admin = require_admin_or_permission('manage_canned');
$pdo = get_db(); $pdo = get_db();
$errors = []; $errors = [];
+1 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$admin = require_admin(); $admin = require_admin_or_permission('manage_custom_fields');
$pdo = get_db(); $pdo = get_db();
$errors = []; $errors = [];
+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php'; require_once __DIR__ . '/_ui.php';
$staff = require_admin(); $staff = require_admin_or_permission('manage_custom_fields');
$pdo = get_db(); $pdo = get_db();
$errors = []; $errors = [];
+51 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login(); $staff = require_admin_or_permission('view_customers');
$pdo = get_db(); $pdo = get_db();
$userId = (int) ($_GET['id'] ?? 0); $userId = (int) ($_GET['id'] ?? 0);
@@ -44,6 +44,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail)); redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail));
} }
if ($action === 'gdpr_export' || $action === 'gdpr_erase') {
if ($staff['role'] !== 'admin' || !gdpr_tools_enabled()) {
flash('error', 'Die Datenschutz-Werkzeuge sind nicht verfügbar (nur Administratoren, in den Einstellungen einzuschalten).');
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
$ref = $isGuest ? ['email' => $guestEmail] : ['user_id' => $userId];
if ($action === 'gdpr_export') {
$data = gdpr_customer_export($pdo, $ref);
audit_log($staff['id'], 'Kundendaten exportiert (' . ($isGuest ? 'Gast ' . $guestEmail : 'Kunde #' . $userId) . ')');
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="kundendaten-' . ($isGuest ? 'gast' : $userId) . '-' . date('Ymd') . '.json"');
header('X-Content-Type-Options: nosniff');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
if (trim((string) ($_POST['confirm_email'] ?? '')) !== (string) $customer['email']) {
flash('error', 'Zur Bestätigung muss die E-Mail-Adresse des Kunden genau eingegeben werden.');
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
$res = gdpr_customer_erase($pdo, $ref, !empty($_POST['force']));
if ($res['error']) {
flash('error', $res['error']);
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
}
audit_log($staff['id'], 'Kundendaten gelöscht (' . ($isGuest ? 'Gast' : 'Kunde #' . $userId) . '): ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt');
flash('success', 'Kundendaten gelöscht: ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt.');
redirect('/staff/customers.php');
}
if ($action === 'add_note') { if ($action === 'add_note') {
$note = trim($_POST['note'] ?? ''); $note = trim($_POST['note'] ?? '');
if ($note !== '') { if ($note !== '') {
@@ -219,6 +248,27 @@ require __DIR__ . '/_header.php';
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($staff['role'] === 'admin' && gdpr_tools_enabled()): ?>
<div class="card" style="padding:16px 18px;margin-top:16px">
<div style="font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:8px">Datenschutz (DSGVO)</div>
<form method="post" style="margin-bottom:12px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="gdpr_export">
<button type="submit" class="btn secondary" style="width:100%;justify-content:center">Alle Daten exportieren (JSON)</button>
<p class="muted" style="font-size:.76rem;margin-top:6px">Auskunft: Stammdaten, alle Tickets mit Nachrichten und Anhangsnamen, Notizen. Die Datei bitte vor der Weitergabe prüfen.</p>
</form>
<form method="post" onsubmit="return confirm('Alle personenbezogenen Daten dieses Kunden unwiderruflich löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="gdpr_erase">
<label style="font-size:.78rem" for="gdpr_confirm">Zur Bestätigung die E-Mail-Adresse eingeben</label>
<input type="text" id="gdpr_confirm" name="confirm_email" autocomplete="off" placeholder="<?= e($customer['email']) ?>" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;font-size:.8rem;margin-top:8px"><input type="checkbox" name="force" value="1" style="width:auto"> Auch bei noch offenen Tickets löschen</label>
<button type="submit" class="btn" style="width:100%;justify-content:center;margin-top:10px;background:var(--danger,#dc2626)">Kundendaten löschen</button>
<p class="muted" style="font-size:.76rem;margin-top:6px">Tickets bleiben als anonymisierte Einträge (Nummer, Betreff, Status, Zeiten). Nachrichten und Dateien des Kunden, Notizen und das Konto werden entfernt.</p>
</form>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login(); $staff = require_admin_or_permission('manage_customers');
$pdo = get_db(); $pdo = get_db();
$errors = []; $errors = [];
@@ -10,7 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf(); verify_csrf();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'assign_org' && $staff['role'] === 'admin') { if ($action === 'assign_org') {
$userId = (int) $_POST['user_id']; $userId = (int) $_POST['user_id'];
$orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null; $orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null;
$pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]); $pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]);
+1 -1
View File
@@ -17,7 +17,7 @@ $mine->execute(array_merge([$staff['id']], $deptParams)); $mine = (int)$mine->fe
$unassigned = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')" . $deptFilter); $unassigned = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')" . $deptFilter);
$unassigned->execute($deptParams); $unassigned = (int)$unassigned->fetchColumn(); $unassigned->execute($deptParams); $unassigned = (int)$unassigned->fetchColumn();
$overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?" . $deptFilter); $overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at IS NOT NULL AND resolution_due_at < ?))" . $deptFilter);
$overdue->execute(array_merge([$now], $deptParams)); $overdue = (int)$overdue->fetchColumn(); $overdue->execute(array_merge([$now], $deptParams)); $overdue = (int)$overdue->fetchColumn();
$unanswered = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'" . $deptFilter); $unanswered = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'" . $deptFilter);
+7 -1
View File
@@ -312,7 +312,13 @@ require __DIR__ . '/_header.php';
function sync() { box.style.opacity = on.checked ? '1' : '.5'; box.querySelectorAll('input,select').forEach(function (i) { i.disabled = !on.checked; }); } function sync() { box.style.opacity = on.checked ? '1' : '.5'; box.querySelectorAll('input,select').forEach(function (i) { i.disabled = !on.checked; }); }
on.addEventListener('change', sync); sync(); on.addEventListener('change', sync); sync();
} }
toggler('imap_enabled', 'imap-fields'); toggler('smtp_enabled', 'smtp-fields'); toggler('imap_enabled', 'imap-fields'); toggler('smtp_enabled', 'smtp-fields');
// Verschlüsselung gewählt: Port passend setzen (nur wenn dort noch ein Standardport steht, eigene Ports bleiben)
var enc = document.getElementById('smtp_encryption'), prt = document.getElementById('smtp_port');
if (enc && prt) enc.addEventListener('change', function () {
var std = { tls: '587', ssl: '465', none: '25' };
if (['25', '465', '587', ''].indexOf(String(prt.value).trim()) !== -1) prt.value = std[enc.value] || prt.value;
});
var af = document.getElementById('after_fetch'), ar = document.getElementById('archive-row'); var af = document.getElementById('after_fetch'), ar = document.getElementById('archive-row');
function syncAf() { ar.style.display = af.value === 'archive' ? '' : 'none'; } function syncAf() { ar.style.display = af.value === 'archive' ? '' : 'none'; }
+1 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
require_once __DIR__ . '/../includes/bootstrap.php'; require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin(); $staff = require_admin_or_permission('export_tickets');
$pdo = get_db(); $pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen $myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen

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