780 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
M_Viper 97ede6f7de Upload via GUI (34 Dateien) 2026-10-25 17:59:36 +00:00
M_Viper a62971926b Upload via GUI (32 Dateien) 2026-10-25 17:58:33 +00:00
M_Viper 7f79c59ac4 Upload via GUI (333 Dateien) 2026-10-25 17:57:57 +00:00
M_Viper bbc3937040 Upload via GUI (333 Dateien) 2026-10-25 17:57:53 +00:00
M_Viper a21a12c899 Upload via GUI (333 Dateien) 2026-10-25 17:57:50 +00:00
M_Viper 2b12b3e40a Upload via GUI (333 Dateien) 2026-10-25 17:57:46 +00:00
M_Viper 64d86d6869 Upload via GUI (333 Dateien) 2026-10-25 17:57:42 +00:00
M_Viper 6cfc25ede2 Upload via GUI (333 Dateien) 2026-10-25 17:57:39 +00:00
M_Viper 877b1db785 Upload via GUI (333 Dateien) 2026-10-25 17:57:36 +00:00
M_Viper 934b1b3c4a Upload via GUI (4909 Dateien) 2026-10-25 17:57:17 +00:00
M_Viper e3f32f553a Upload via GUI (4909 Dateien) 2026-10-25 17:57:13 +00:00
M_Viper d73e0d5e6a Upload via GUI (4909 Dateien) 2026-10-25 17:57:10 +00:00
M_Viper 213e282028 Upload via GUI (4909 Dateien) 2026-10-25 17:57:07 +00:00
M_Viper 2048ecaff2 Upload via GUI (4909 Dateien) 2026-10-25 17:57:04 +00:00
M_Viper 487c001934 Upload via GUI (4909 Dateien) 2026-10-25 17:57:00 +00:00
M_Viper 10fcd56190 Upload via GUI (4909 Dateien) 2026-10-25 17:56:56 +00:00
M_Viper b57a7f251f Upload via GUI (4909 Dateien) 2026-10-25 17:56:51 +00:00
M_Viper 653739fc3d Upload via GUI (4909 Dateien) 2026-10-25 17:56:48 +00:00
M_Viper 0c5b3dd943 Upload via GUI (4909 Dateien) 2026-10-25 17:56:46 +00:00
M_Viper e40dfcbf64 Upload via GUI (4909 Dateien) 2026-10-25 17:56:45 +00:00
M_Viper 55456c830a Upload via GUI (4909 Dateien) 2026-10-25 17:56:42 +00:00
M_Viper 2ca24faec3 Upload via GUI (4909 Dateien) 2026-10-25 17:56:40 +00:00
M_Viper 609443fbde Upload via GUI (4909 Dateien) 2026-10-25 17:56:32 +00:00
M_Viper d9bee89379 Upload via GUI (4909 Dateien) 2026-10-25 17:56:29 +00:00
M_Viper b27caa16ce Upload via GUI (4909 Dateien) 2026-10-25 17:56:27 +00:00
M_Viper 0c759b83a8 Upload via GUI (4909 Dateien) 2026-10-25 17:56:24 +00:00
M_Viper d1d1714dde Upload via GUI (4909 Dateien) 2026-10-25 17:56:22 +00:00
M_Viper 8d2c5e8d4a Upload via GUI (4909 Dateien) 2026-10-25 17:56:19 +00:00
M_Viper 63f012805f Upload via GUI (4909 Dateien) 2026-10-25 17:56:17 +00:00
M_Viper b97f788cea Upload via GUI (4909 Dateien) 2026-10-25 17:56:13 +00:00
M_Viper dd7b352f7b Upload via GUI (4909 Dateien) 2026-10-25 17:56:11 +00:00
M_Viper 7a4b1b9f57 Upload via GUI (4909 Dateien) 2026-10-25 17:56:06 +00:00
M_Viper 68b695cb32 Upload via GUI (4909 Dateien) 2026-10-25 17:56:04 +00:00
M_Viper 2ff1e2f680 Upload via GUI (4909 Dateien) 2026-10-25 17:55:58 +00:00
M_Viper 0fc0ac62e5 Upload via GUI (4909 Dateien) 2026-10-25 17:55:53 +00:00
M_Viper 01757bda29 Upload via GUI (4909 Dateien) 2026-10-25 17:55:49 +00:00
M_Viper 246ee8a7c6 Upload via GUI (4909 Dateien) 2026-10-25 17:55:45 +00:00
M_Viper 7e73c5e8e0 Upload via GUI (4909 Dateien) 2026-10-25 17:55:42 +00:00
M_Viper 6ccfa0d484 Upload via GUI (4909 Dateien) 2026-10-25 17:55:37 +00:00
M_Viper 33a4dde772 Upload via GUI (4909 Dateien) 2026-10-25 17:55:32 +00:00
M_Viper 84f189afe4 Upload via GUI (4909 Dateien) 2026-10-25 17:55:29 +00:00
M_Viper 2fc637b0e2 Upload via GUI (4909 Dateien) 2026-10-25 17:55:25 +00:00
M_Viper a7266b60dc Upload via GUI (4909 Dateien) 2026-10-25 17:55:22 +00:00
M_Viper 4f07d2c6b1 Upload via GUI (4909 Dateien) 2026-10-25 17:55:18 +00:00
M_Viper 0f3b5cf4b1 Upload via GUI (4909 Dateien) 2026-10-25 17:55:15 +00:00
M_Viper 409a48c5d5 Upload via GUI (4909 Dateien) 2026-10-25 17:55:13 +00:00
M_Viper 3a6c5d5fd9 Upload via GUI (4909 Dateien) 2026-10-25 17:55:10 +00:00
M_Viper a44b3407f0 Upload via GUI (4909 Dateien) 2026-10-25 17:55:07 +00:00
M_Viper 07e60fccdc Upload via GUI (4909 Dateien) 2026-10-25 17:55:04 +00:00
M_Viper 738693f8c1 Upload via GUI (4909 Dateien) 2026-10-25 17:55:01 +00:00
M_Viper 884dd4a800 Upload via GUI (4909 Dateien) 2026-10-25 17:54:57 +00:00
M_Viper 7f2d28635d Upload via GUI (4909 Dateien) 2026-10-25 17:54:56 +00:00
M_Viper 73bb633ab2 Upload via GUI (4909 Dateien) 2026-10-25 17:54:53 +00:00
M_Viper 04b117ff3b Upload via GUI (4909 Dateien) 2026-10-25 17:54:48 +00:00
M_Viper 5e0a001567 Upload via GUI (4909 Dateien) 2026-10-25 17:54:45 +00:00
M_Viper 6498902853 Upload via GUI (4909 Dateien) 2026-10-25 17:54:32 +00:00
M_Viper 131468a089 Upload via GUI (4909 Dateien) 2026-10-25 17:54:27 +00:00
M_Viper b917058dc4 Upload via GUI (4909 Dateien) 2026-10-25 17:54:23 +00:00
M_Viper a267de671e Upload via GUI (4909 Dateien) 2026-10-25 17:54:19 +00:00
M_Viper 31da945225 Upload via GUI (4909 Dateien) 2026-10-25 17:54:13 +00:00
M_Viper 3602b47a17 Upload via GUI (4909 Dateien) 2026-10-25 17:54:06 +00:00
M_Viper 1148de6210 Upload via GUI (4909 Dateien) 2026-10-25 17:53:59 +00:00
M_Viper cc6d6a467c Upload via GUI (4909 Dateien) 2026-10-25 17:53:54 +00:00
M_Viper 3771ff9892 Upload via GUI (4909 Dateien) 2026-10-25 17:53:47 +00:00
M_Viper 970b01be36 Upload via GUI (4909 Dateien) 2026-10-25 17:53:43 +00:00
M_Viper 1c1dd3ec52 Upload via GUI (4909 Dateien) 2026-10-25 17:53:40 +00:00
M_Viper 92ea55a44d Upload via GUI (4909 Dateien) 2026-10-25 17:53:38 +00:00
M_Viper 328b8156e9 Upload via GUI (4909 Dateien) 2026-10-25 17:53:35 +00:00
M_Viper b5d80965dd Upload via GUI (4909 Dateien) 2026-10-25 17:53:32 +00:00
M_Viper 3056b6d5b6 Upload via GUI (4909 Dateien) 2026-10-25 17:53:29 +00:00
M_Viper 4d6be462c8 Upload via GUI (4909 Dateien) 2026-10-25 17:53:25 +00:00
M_Viper 9c382b71e7 Upload via GUI (4909 Dateien) 2026-10-25 17:53:22 +00:00
M_Viper 474456f5c5 Upload via GUI (4909 Dateien) 2026-10-25 17:53:19 +00:00
M_Viper a8e0a18c51 Upload via GUI (4909 Dateien) 2026-10-25 17:53:16 +00:00
M_Viper b8b3c0c810 Upload via GUI (4909 Dateien) 2026-10-25 17:53:13 +00:00
M_Viper 1820367fbd Upload via GUI (4909 Dateien) 2026-10-25 17:53:10 +00:00
M_Viper 9d641f8d99 Upload via GUI (4909 Dateien) 2026-10-25 17:53:07 +00:00
M_Viper 0829da5f30 Upload via GUI (4909 Dateien) 2026-10-25 17:53:04 +00:00
M_Viper abd47b130c Upload via GUI (4909 Dateien) 2026-10-25 17:53:00 +00:00
M_Viper 39dfb16397 Upload via GUI (4909 Dateien) 2026-10-25 17:52:57 +00:00
M_Viper c18ed432e0 Upload via GUI (4909 Dateien) 2026-10-25 17:52:54 +00:00
M_Viper b83a8da857 Upload via GUI (4909 Dateien) 2026-10-25 17:52:51 +00:00
M_Viper 4f811fe87c Upload via GUI (4909 Dateien) 2026-10-25 17:52:49 +00:00
M_Viper 0dcf0077ee Upload via GUI (4909 Dateien) 2026-10-25 17:52:47 +00:00
M_Viper fe3f232505 Upload via GUI (4909 Dateien) 2026-10-25 17:52:44 +00:00
M_Viper f70d914375 Upload via GUI (4909 Dateien) 2026-10-25 17:52:41 +00:00
M_Viper d0bd448c06 Upload via GUI (4909 Dateien) 2026-10-25 17:52:39 +00:00
M_Viper 243f05990b Upload via GUI (4909 Dateien) 2026-10-25 17:52:36 +00:00
M_Viper 3e9e81a5e9 Upload via GUI (4909 Dateien) 2026-10-25 17:52:33 +00:00
M_Viper db1dc818b3 Upload via GUI (4909 Dateien) 2026-10-25 17:52:30 +00:00
M_Viper aea1c17ba0 Upload via GUI (4909 Dateien) 2026-10-25 17:52:28 +00:00
M_Viper af627d8bdb Upload via GUI (4909 Dateien) 2026-10-25 17:52:27 +00:00
M_Viper 7afa084c1f Upload via GUI (4909 Dateien) 2026-10-25 17:52:24 +00:00
M_Viper 94293cc130 Upload via GUI (4909 Dateien) 2026-10-25 17:52:21 +00:00
M_Viper 89067a7ad0 Upload via GUI (4909 Dateien) 2026-10-25 17:52:18 +00:00
M_Viper fb30679f8e Upload via GUI (4909 Dateien) 2026-10-25 17:52:15 +00:00
M_Viper fba1d56ddb Upload via GUI (4909 Dateien) 2026-10-25 17:52:13 +00:00
M_Viper e700d2b7d6 Upload via GUI (4909 Dateien) 2026-10-25 17:52:12 +00:00
M_Viper 6d0112aa9c Upload via GUI (4909 Dateien) 2026-10-25 17:52:09 +00:00
M_Viper 1cc4b69645 Upload via GUI (4909 Dateien) 2026-10-25 17:52:07 +00:00
M_Viper 947c9d9541 Upload via GUI (4909 Dateien) 2026-10-25 17:52:04 +00:00
M_Viper 31944c801c Upload via GUI (4909 Dateien) 2026-10-25 17:52:01 +00:00
M_Viper 38b5825e68 Upload via GUI (4909 Dateien) 2026-10-25 17:51:59 +00:00
M_Viper dac8b07589 Upload via GUI (4909 Dateien) 2026-10-25 17:51:56 +00:00
M_Viper e0b11677f8 Upload via GUI (4909 Dateien) 2026-10-25 17:51:54 +00:00
M_Viper a8a54e7fa1 Upload via GUI (4909 Dateien) 2026-10-25 17:51:52 +00:00
M_Viper e027e35931 Upload via GUI (4909 Dateien) 2026-10-25 17:51:47 +00:00
M_Viper 414af69c53 Upload via GUI (4909 Dateien) 2026-10-25 17:51:44 +00:00
M_Viper 3824091e99 Upload via GUI (4909 Dateien) 2026-10-25 17:51:40 +00:00
M_Viper 790cfff280 Upload via GUI (4909 Dateien) 2026-10-25 17:51:37 +00:00
M_Viper 0fa867024f Upload via GUI (4909 Dateien) 2026-10-25 17:51:34 +00:00
M_Viper bc160d8ff4 Upload via GUI (4909 Dateien) 2026-10-25 17:51:31 +00:00
M_Viper 6cc7aebd88 Upload via GUI (4909 Dateien) 2026-10-25 17:51:27 +00:00
M_Viper 6829da2d39 Upload via GUI (4909 Dateien) 2026-10-25 17:51:25 +00:00
M_Viper 99141ffe7a Upload via GUI (4909 Dateien) 2026-10-25 17:51:18 +00:00
M_Viper e324010e42 Upload via GUI (4909 Dateien) 2026-10-25 17:51:14 +00:00
M_Viper caaf3c2171 Upload via GUI (4909 Dateien) 2026-10-25 17:51:12 +00:00
M_Viper 7df7442caa Upload via GUI (4909 Dateien) 2026-10-25 17:51:11 +00:00
M_Viper 3b956c13e6 Upload via GUI (4909 Dateien) 2026-10-25 17:51:08 +00:00
M_Viper 23497f2926 Upload via GUI (4909 Dateien) 2026-10-25 17:51:05 +00:00
M_Viper ee77d0a340 Upload via GUI (4909 Dateien) 2026-10-25 17:51:01 +00:00
M_Viper 7c9bb07201 Upload via GUI (4909 Dateien) 2026-10-25 17:50:57 +00:00
M_Viper 690e797b77 Upload via GUI (4909 Dateien) 2026-10-25 17:50:54 +00:00
M_Viper 716b0232db Upload via GUI (4909 Dateien) 2026-10-25 17:50:52 +00:00
M_Viper 5d7958dcdc Upload via GUI (4909 Dateien) 2026-10-25 17:50:49 +00:00
M_Viper 6338c5a366 Upload via GUI (4909 Dateien) 2026-10-25 17:50:47 +00:00
M_Viper d686c2c690 Upload via GUI (4909 Dateien) 2026-10-25 17:50:46 +00:00
M_Viper 7d1e489b64 Upload via GUI (4909 Dateien) 2026-10-25 17:50:44 +00:00
M_Viper df977defae Upload via GUI (4909 Dateien) 2026-10-25 17:50:42 +00:00
M_Viper f07d36ea8b Upload via GUI (4909 Dateien) 2026-10-25 17:50:40 +00:00
M_Viper 59aad18a6b Upload via GUI (4909 Dateien) 2026-10-25 17:50:37 +00:00
M_Viper fb40d9649a Upload via GUI (4909 Dateien) 2026-10-25 17:50:35 +00:00
M_Viper 1371e76940 Upload via GUI (4909 Dateien) 2026-10-25 17:50:32 +00:00
M_Viper af3e567a96 Upload via GUI (4909 Dateien) 2026-10-25 17:50:28 +00:00
M_Viper b91b72df58 Upload via GUI (4909 Dateien) 2026-10-25 17:50:25 +00:00
M_Viper 9ec4b8dc7a Upload via GUI (4909 Dateien) 2026-10-25 17:50:24 +00:00
M_Viper 23d6bee68a Upload via GUI (4909 Dateien) 2026-10-25 17:50:22 +00:00
M_Viper a7ade853ae Upload via GUI (4909 Dateien) 2026-10-25 17:50:20 +00:00
M_Viper be3da32f39 Upload via GUI (4909 Dateien) 2026-10-25 17:50:16 +00:00
M_Viper 580b6faeda Upload via GUI (4909 Dateien) 2026-10-25 17:50:13 +00:00
M_Viper ecb91b2394 Upload via GUI (4909 Dateien) 2026-10-25 17:50:12 +00:00
M_Viper bb5b01af39 Upload via GUI (4909 Dateien) 2026-10-25 17:50:09 +00:00
M_Viper d3a28f49f0 Upload via GUI (4909 Dateien) 2026-10-25 17:50:06 +00:00
M_Viper b3babbd0f8 Upload via GUI (4909 Dateien) 2026-10-25 17:50:01 +00:00
M_Viper bba3fe6ea6 Upload via GUI (4909 Dateien) 2026-10-25 17:49:54 +00:00
M_Viper f6b2bba339 Upload via GUI (4909 Dateien) 2026-10-25 17:49:47 +00:00
M_Viper 81df9b1471 Upload via GUI (4909 Dateien) 2026-10-25 17:49:42 +00:00
M_Viper bd12d91ce7 Upload via GUI (4909 Dateien) 2026-10-25 17:49:37 +00:00
M_Viper 0c364e74fa Upload via GUI (4909 Dateien) 2026-10-25 17:49:33 +00:00
M_Viper 73527d2862 Upload via GUI (4909 Dateien) 2026-10-25 17:49:30 +00:00
M_Viper 2d2cb72ab0 Upload via GUI (4909 Dateien) 2026-10-25 17:49:28 +00:00
M_Viper b4bad29895 Upload via GUI (4909 Dateien) 2026-10-25 17:49:25 +00:00
M_Viper 5051b0bd1e Upload via GUI (4909 Dateien) 2026-10-25 17:49:22 +00:00
M_Viper 8c8f5ec00c Upload via GUI (4909 Dateien) 2026-10-25 17:49:18 +00:00
M_Viper dcf38808d1 Upload via GUI (4909 Dateien) 2026-10-25 17:49:15 +00:00
M_Viper 42a78545e6 Upload via GUI (4909 Dateien) 2026-10-25 17:49:13 +00:00
M_Viper 0c1239afb4 Upload via GUI (4909 Dateien) 2026-10-25 17:49:10 +00:00
M_Viper 0c4dd12e29 Upload via GUI (4909 Dateien) 2026-10-25 17:49:08 +00:00
M_Viper c21acab19d Upload via GUI (4909 Dateien) 2026-10-25 17:48:53 +00:00
M_Viper a8d8834360 Upload via GUI (4909 Dateien) 2026-10-25 17:48:48 +00:00
M_Viper 5f6e206312 Upload via GUI (12 Dateien) 2026-10-25 17:48:24 +00:00
M_Viper 380d5536cb Upload via GUI (12 Dateien) 2026-10-25 17:48:23 +00:00
M_Viper 7b6403ceb0 Upload via GUI (72 Dateien) 2026-10-25 17:48:07 +00:00
M_Viper e812dac9e4 Upload via GUI (72 Dateien) 2026-10-25 17:48:04 +00:00
M_Viper a4736e39ab Upload via GUI (97 Dateien) 2026-10-25 17:47:48 +00:00
M_Viper 57648169aa Upload via GUI (97 Dateien) 2026-10-25 17:47:46 +00:00
M_Viper f43859ba5f Upload via GUI (338 Dateien) 2026-10-25 17:47:16 +00:00
M_Viper cf93c5fbae Upload via GUI (338 Dateien) 2026-10-25 17:47:13 +00:00
M_Viper 3aed0cc517 Upload via GUI (338 Dateien) 2026-10-25 17:47:11 +00:00
M_Viper 35809532cf Upload via GUI (338 Dateien) 2026-10-25 17:47:07 +00:00
M_Viper 7ca9dd3f12 Upload via GUI (338 Dateien) 2026-10-25 17:47:04 +00:00
M_Viper 63561d259f Upload via GUI (338 Dateien) 2026-10-25 17:47:01 +00:00
M_Viper b38f5b34ad Upload via GUI (338 Dateien) 2026-10-25 17:46:58 +00:00
M_Viper ac6f8ef915 Upload via GUI (338 Dateien) 2026-10-25 17:46:57 +00:00
M_Viper 115f48f957 Upload via GUI (338 Dateien) 2026-10-25 17:46:56 +00:00
M_Viper 071c0dae72 Upload via GUI (3 Dateien) 2026-10-24 20:19:27 +00:00
M_Viper cae6bc236d Upload via GUI (3 Dateien) 2026-10-24 20:19:19 +00:00
M_Viper a2c09909ea Upload via GUI (3 Dateien) 2026-10-24 20:19:17 +00:00
M_Viper f7be6099de Upload via GUI (34 Dateien) 2026-10-24 20:17:43 +00:00
M_Viper e457b97dce Upload via GUI (27 Dateien) 2026-10-24 20:17:11 +00:00
M_Viper ad961bc601 Upload via GUI (80 Dateien) 2026-10-24 20:16:49 +00:00
M_Viper 19bfd99e63 Upload via GUI (80 Dateien) 2026-10-24 20:16:47 +00:00
M_Viper 628efdf457 Upload via GUI (80 Dateien) 2026-10-24 20:16:47 +00:00
M_Viper 1cf5e819ee Upload via GUI (80 Dateien) 2026-10-24 20:16:45 +00:00
M_Viper 204e7fb88c Upload via GUI (80 Dateien) 2026-10-24 20:16:44 +00:00
M_Viper 9c5eef5b7b Upload via GUI (80 Dateien) 2026-10-24 20:16:43 +00:00
M_Viper e1b58b0d8e Upload via GUI (80 Dateien) 2026-10-24 20:16:40 +00:00
M_Viper b86dca3e30 Upload via GUI (80 Dateien) 2026-10-24 20:16:37 +00:00
M_Viper 162d925511 Upload via GUI (80 Dateien) 2026-10-24 20:16:36 +00:00
M_Viper c7d8a4e153 Upload via GUI (80 Dateien) 2026-10-24 20:16:35 +00:00
M_Viper f235642f48 Upload via GUI (72 Dateien) 2026-10-24 20:15:42 +00:00
M_Viper 64d6cf1afb Upload via GUI (72 Dateien) 2026-10-24 20:15:40 +00:00
M_Viper e7a6ba0b86 Upload via GUI (94 Dateien) 2026-10-24 20:15:16 +00:00
M_Viper cbf6139e74 Upload via GUI (94 Dateien) 2026-10-24 20:15:15 +00:00
M_Viper 9108a34774 Upload via GUI (335 Dateien) 2026-10-24 20:14:54 +00:00
M_Viper 65003aa37b Upload via GUI (335 Dateien) 2026-10-24 20:14:53 +00:00
M_Viper 7ee6d97c63 Upload via GUI (335 Dateien) 2026-10-24 20:14:51 +00:00
M_Viper c3b6bd5dcd Upload via GUI (335 Dateien) 2026-10-24 20:14:48 +00:00
M_Viper b2a0a2d0b8 Upload via GUI (335 Dateien) 2026-10-24 20:14:46 +00:00
M_Viper c270c0b6cf Upload via GUI (335 Dateien) 2026-10-24 20:14:43 +00:00
M_Viper 793fe6162e Upload via GUI (335 Dateien) 2026-10-24 20:14:41 +00:00
M_Viper f189918dc4 Upload via GUI (335 Dateien) 2026-10-24 20:14:39 +00:00
M_Viper b347bd8405 README.md aktualisiert 2026-10-23 19:05:19 +00:00
M_Viper 5fd2e0be47 README.md aktualisiert 2026-10-23 19:03:51 +00:00
M_Viper f00317d222 Upload via GUI (1 Datei) 2026-10-23 19:02:39 +00:00
M_Viper 02c5ba3354 Upload via GUI (2 Dateien) 2026-10-23 19:02:12 +00:00
M_Viper 2e4791d02c Upload via GUI (2 Dateien) 2026-10-23 19:02:10 +00:00
M_Viper 34c844bf67 Upload via GUI (1 Datei) 2026-10-23 18:59:33 +00:00
M_Viper 692e1dc421 Upload via GUI (2 Dateien) 2026-10-23 18:58:31 +00:00
M_Viper 49d0f292ed Upload via GUI (2 Dateien) 2026-10-23 18:58:30 +00:00
M_Viper 11e2b7c0e6 Upload via GUI (1 Datei) 2026-10-23 17:51:06 +00:00
M_Viper 3fc1568b6c Upload via GUI (12 Dateien) 2026-10-23 17:50:55 +00:00
M_Viper d7d374b6d9 Upload via GUI (12 Dateien) 2026-10-23 17:50:54 +00:00
M_Viper 202c61ee23 Upload via GUI (304 Dateien) 2026-10-23 17:50:26 +00:00
M_Viper b2edb63a0b Upload via GUI (304 Dateien) 2026-10-23 17:50:23 +00:00
M_Viper 1863e2e877 Upload via GUI (304 Dateien) 2026-10-23 17:50:20 +00:00
M_Viper c7a2f19ac7 Upload via GUI (304 Dateien) 2026-10-23 17:50:17 +00:00
M_Viper 0d7e6535cd Upload via GUI (304 Dateien) 2026-10-23 17:50:13 +00:00
M_Viper 7187609e3a Upload via GUI (304 Dateien) 2026-10-23 17:50:09 +00:00
M_Viper 5ffb46573f Upload via GUI (304 Dateien) 2026-10-23 17:50:05 +00:00
5577 changed files with 307896 additions and 9414 deletions
+5 -1
View File
@@ -13,7 +13,7 @@ RewriteRule ^ - [F,L]
# RewriteRule ^ - [F,L]
# Clickjacking-Schutz
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
# 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]
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
RewriteCond %{REQUEST_URI} ^/version\.php$ [NC]
RewriteRule ^ - [F,L]
+66 -6
View File
@@ -4,12 +4,17 @@
![DeskHub Kundenportal](DeskHub-Webseite/docs/images/customer-portal.png)
**Aktuelle Versionen:** Server **1.0.29** · Android-App **3.1.0** · Windows-Programm **2.7.0** · Minecraft-Plugin **1.0.0**
> Diese Anleitung ist auch für Einsteiger geeignet. Über das Inhaltsverzeichnis kommt ihr direkt zum gewünschten Thema. Die Abschnitte [Plugins entwickeln](#plugins-entwickeln) und [API und Webhooks](#api-und-webhooks-für-entwickler) richten sich an Entwickler.
## Funktionen
- **Kundenportal** mit Ticket-Erstellung, Ticketstatus, Wissensdatenbank und frei gestaltbaren Seiten
- **Digitaler Assistent** im Kundenportal: beantwortet Fragen aus Wissensdatenbank und eigenen Antworten, erstellt auf Wunsch das Ticket, mit Fragen-Log, Rückmeldung 👍/👎 und eigenen Wortgruppen (ohne externen KI-Dienst)
- **Mitarbeiterbereich** mit Ticketlisten, Filtern, gespeicherten Ansichten, Zuweisungen und internen Notizen
- **Wiedervorlage** („auf später legen“), **Beobachter**, **ähnliche Tickets und Artikel** beim Bearbeiten und **Erinnerung an Kunden** vor dem automatischen Schließen
- **Automatik**: Tickets beim Eingang oder Öffnen zuweisen, Status automatisch auf „Offline“, automatische Abmeldung, Ticket-Sperre gegen Doppelbearbeitung
- **E-Mail**: Versand über SMTP (auch pro Absenderadresse), automatischer Ticketimport über IMAP
- **Abteilungen, Teams und Rollen** mit Berechtigungen, die pro Abteilung unterschiedlich sein können
- **SLA-Pläne**, Eskalations- und Ticketregeln, Geschäftszeiten und Feiertage
@@ -19,10 +24,37 @@
- **Plugins** zum Nachrüsten eigener Funktionen
- **Update-Funktion** für Programmdateien und Datenbank, Backups und Datenschutz-Funktionen
- **Import aus osTicket** inklusive Abteilungen, Nachrichten und Anhängen
- **Windows-App und Android-App** mit hellem und dunklem Design, direkt aus dem Mitarbeiterbereich herunterladbar
- **Windows-Programm und Android-App** mit hellem und dunklem Design, Push-Benachrichtigungen, Verwaltung nach Rechten und Android-**Widget** in drei Größen, direkt aus dem Mitarbeiterbereich herunterladbar
- **Minecraft-Plugin** (Paper, Spigot, BungeeCord in einer Datei) zum Melden von Spielern und Problemen als Ticket
## Einblicke
### Demo-Video (mit Beispieldaten)
[![DeskHub Demo](DeskHub-Webseite/docs/video/deskhub-demo.gif)](DeskHub-Webseite/docs/video/deskhub-demo.mp4)
Klick auf die Vorschau öffnet das Video ([deskhub-demo.mp4](DeskHub-Webseite/docs/video/deskhub-demo.mp4), etwa 75 Sekunden). Alle Namen, Tickets und Antworten darin sind erfunden. Es zeigt das Kundenportal mit dem digitalen Assistenten, die Übersicht, Ticketliste und Ticketansicht mit Wiedervorlage und Beobachtern, die Verwaltung des Assistenten mit Fragen-Log sowie das Windows-Programm.
### Digitaler Assistent im Kundenportal
[![Digitaler Assistent](DeskHub-Webseite/docs/images/demo-assistent-kunde.png)](DeskHub-Webseite/docs/images/demo-assistent-kunde.png)
### Übersicht im Mitarbeiterbereich
[![Übersicht](DeskHub-Webseite/docs/images/demo-uebersicht.png)](DeskHub-Webseite/docs/images/demo-uebersicht.png)
### Ticket mit Wiedervorlage, Beobachtern und passenden Artikeln
[![Ticket mit Wiedervorlage](DeskHub-Webseite/docs/images/demo-ticket-wiedervorlage.png)](DeskHub-Webseite/docs/images/demo-ticket-wiedervorlage.png)
### Fragen-Log des Assistenten
[![Fragen-Log](DeskHub-Webseite/docs/images/demo-assistent-log.png)](DeskHub-Webseite/docs/images/demo-assistent-log.png)
### Windows-Programm
[![Windows-Programm](DeskHub-Webseite/docs/images/demo-windows-ticket.png)](DeskHub-Webseite/docs/images/demo-windows-ticket.png)
### Neues Ticket erstellen
[![Neues Ticket erstellen](DeskHub-Webseite/docs/images/new-ticket.png)](DeskHub-Webseite/docs/images/new-ticket.png)
@@ -49,6 +81,10 @@
- [E-Mails versenden (SMTP)](#e-mails-versenden-smtp)
- [E-Mails automatisch abrufen (Postfächer)](#e-mails-automatisch-abrufen-postfächer)
- [Automatisierung (Cronjobs)](#automatisierung-cronjobs)
- [Status und automatisch Offline](#status-und-automatisch-offline)
- [Digitaler Assistent](#digitaler-assistent)
- [Wiedervorlage, Beobachter, ähnliche Tickets, Kundenerinnerung](#wiedervorlage-beobachter-ähnliche-tickets-kundenerinnerung)
- [Ticket-Sperre und automatische Zuweisung](#ticket-sperre)
- [SLA-Pläne](#sla-pläne)
- [Gespeicherte Ansichten](#gespeicherte-ansichten)
- [Berichte und Bericht-Baukasten](#berichte-und-bericht-baukasten)
@@ -64,6 +100,7 @@
- [API und Webhooks (für Entwickler)](#api-und-webhooks-für-entwickler)
- [Sicherheit und Datenschutz](#sicherheit-und-datenschutz)
- [Backup](#backup)
- [Demo-Video neu erzeugen](#demo-video-neu-erzeugen)
- [Hilfe, etwas funktioniert nicht](#hilfe-etwas-funktioniert-nicht)
---
@@ -329,14 +366,14 @@ Der Assistent beantwortet Fragen im Kundenportal und ersetzt dabei das Live-Chat
- Für alles: einmal unter System-Update „Datenbank aktualisieren“ (Migration 0046).
## Digitaler Assistent in den Apps
Android-App (ab 2.9.0) und Windows-Programm (ab 2.7.0) haben die Verwaltung des Assistenten fest eingebaut (Verwaltung → „Digitaler Assistent“) mit fünf Bereichen: Einstellungen, zusätzliche Antworten (anlegen, ändern, löschen), Fragen-Log (Kennzahlen, Filter, Suche, häufige Fragen ohne Antwort mit „Antwort anlegen“, Log leeren), Gleichbedeutende Wörter und „Frage ausprobieren". Der Bereich erscheint nur für Administratoren und Mitarbeiter mit dem Recht „Digitalen Assistenten verwalten“; die Schnittstelle `api/staff/v1/assistant.php` prüft das Recht bei jedem Aufruf und trägt jede Änderung ins Admin-Protokoll ein.
Android-App (ab 2.9.0, aktuell 3.1.0) und Windows-Programm (ab 2.7.0) haben die Verwaltung des Assistenten fest eingebaut (Verwaltung → „Digitaler Assistent“) mit fünf Bereichen: Einstellungen, zusätzliche Antworten (anlegen, ändern, löschen), Fragen-Log (Kennzahlen, Filter, Suche, häufige Fragen ohne Antwort mit „Antwort anlegen“, Log leeren), Gleichbedeutende Wörter und „Frage ausprobieren". Der Bereich erscheint nur für Administratoren und Mitarbeiter mit dem Recht „Digitalen Assistenten verwalten“; die Schnittstelle `api/staff/v1/assistant.php` prüft das Recht bei jedem Aufruf und trägt jede Änderung ins Admin-Protokoll ein.
## Weboberfläche aus den Apps öffnen
Android-App (ab 2.8.0) und Windows-Programm (ab 2.6.0) zeigen in der Verwaltung zusätzlich die Seiten, die sie nicht selbst nachbauen: Einstellungen, Digitaler Assistent, Wiederkehrende Tickets, Textbausteine, Ticket-Felder, benutzerdefinierte Listen, Seiten, Webhooks, API-Schlüssel, Plugins, Backup, System-Update, Sicherheitsprüfung, Postfächer, osTicket-Import sowie Erwähnungen und Mitarbeiterverzeichnis. Im Ticket öffnet „Im Browser“ die Ticketseite (zusammenführen, aufteilen, drucken).
Das Windows-Programm (ab 2.6.0) zeigt in der Verwaltung zusätzlich die Seiten, die sie nicht selbst nachbauen: Einstellungen, Digitaler Assistent, Wiederkehrende Tickets, Textbausteine, Ticket-Felder, benutzerdefinierte Listen, Seiten, Webhooks, API-Schlüssel, Plugins, Backup, System-Update, Sicherheitsprüfung, Postfächer, osTicket-Import sowie Erwähnungen und Mitarbeiterverzeichnis. Im Ticket öffnet „Im Browser“ die Ticketseite (zusammenführen, aufteilen, drucken).
- **Rechte:** Der Server (`api/staff/v1/web_pages.php`) liefert nur Seiten, die der Mitarbeiter öffnen darf (Administrator: alle; „Digitalen Assistenten verwalten“ und „E-Mail-Adressen verwalten“ als Einzelrechte; Erwähnungen und Verzeichnis für alle). Die Weboberfläche prüft die Rechte danach noch einmal selbst.
- **Anmeldung:** Die App holt eine einmalige Adresse (`staff/app_login.php?t=…`), die 60 Sekunden gilt und nur einmal funktioniert; sie meldet im Browser an und leitet auf die Seite weiter. Jede Übergabe steht im Admin-Protokoll. Abgelaufene, benutzte oder erfundene Adressen führen zur Anmeldeseite. Dafür einmal „Datenbank aktualisieren“ (Migration 0047).
- **Anmeldung:** Die App holt eine einmalige Adresse (`staff/app_login.php?t=…`), die 60 Sekunden gilt und nur einmal funktioniert; sie meldet im Browser an und leitet auf die Seite weiter. Jede Übergabe steht im Admin-Protokoll. Abgelaufene, benutzte oder erfundene Adressen führen zur Anmeldeseite. Dafür einmal „Datenbank aktualisieren“ (Migration 0047). Die Android-App 3.x enthält diese Browser-Übergabe nicht mehr; die Schnittstelle bleibt auf dem Server für das Windows-Programm.
## Ticket-Sperre
@@ -449,12 +486,18 @@ Beide Apps sind für Mitarbeiter und Admins gedacht und haben ein helles und ein
**Was die Apps können:**
- Tickets bearbeiten (antworten, Notizen, zuweisen, Status ändern, lösen, schließen), Übersicht, Kundenverzeichnis, internen Chat mit Kollegen
- Tickets bearbeiten (antworten, Notizen, zuweisen, Status ändern, lösen, schließen), Übersicht, Kundenverzeichnis, internen Chat und Live-Chat
- **Wiedervorlage** („Auf später“: 1 Stunde bis 1 Woche, im Windows-Programm auch eigener Zeitpunkt), **Beobachten**, Filter „Wiedervorlage“ und „Beobachtet“ und die Karte **„Dazu passend“** (Artikel und ähnliche gelöste Tickets)
- **Digitalen Assistenten komplett verwalten**: Einstellungen, zusätzliche Antworten, Fragen-Log, Gleichbedeutende Wörter, „Frage ausprobieren“ (nur mit dem Recht „Digitalen Assistenten verwalten“)
- Beim Antworten zeigen die Apps nur die Textbausteine der Abteilung des Tickets (plus die für alle Abteilungen) und bieten wie im Web die **Signatur-Auswahl** (meine, Abteilung, keine) mit Vorschau. Das setzt einen Server mit diesem Stand voraus; ältere Server liefern die Signaturen nicht, dann antworten die Apps wie bisher.
- Gespeicherte Ansichten nutzen und Berichte aus dem Bericht-Baukasten ansehen
- Störungs- und Verbindungsbanner
- Admins und Co-Admins finden zusätzlich die **Verwaltung** mit fast allen Adminportal-Bereichen, je nach zugewiesenen Rechten: Abteilungen, Prioritäten, SLA-Pläne, Hilfethemen, E-Mail-Adressen (inkl. Test), Wissensdatenbank, E-Mail-Vorlagen, Organisationen, Mitarbeiter, Teams, Rollen, Admin-Protokoll, Eskalationsregeln, Zeiterfassung, Geschäftszeiten, Sperrliste, Aufgaben
- Systemnahe Bereiche (Backup, System-Update, Plugins, API-Schlüssel, Webhooks, Postfächer, Ticket-Regeln, Ticket-Felder, Seiten, Einstellungen) gibt es bewusst nur in der Weboberfläche
- Systemnahe Bereiche (Backup, System-Update, Plugins, API-Schlüssel, Webhooks, Postfächer, Ticket-Felder, Seiten, Einstellungen) gibt es in der **Android-App** bewusst nicht; das **Windows-Programm** öffnet sie auf Wunsch im Browser (siehe [Weboberfläche aus dem Windows-Programm öffnen](#weboberfläche-aus-den-apps-öffnen))
**Android-App 3.x:** komplett neues Design (helle, runde Karten, Blau/Türkis, Dunkelmodus), schwebende Navigation (Start, Tickets, Chat, Kunden, Mehr) und „Mehr“ mit Kacheln für Aufgaben, Status, Benachrichtigungen und die Verwaltung nach Rechten.
**Android-Widget (ab 3.1):** auf dem Startbildschirm in drei Größen. *Klein*: Meine offenen und Überfällige. *Mittel*: vier Kennzahlen und die drei wichtigsten Tickets als Nummern (Tipp öffnet das Ticket). *Groß*: zusätzlich „Neues Ticket“, „Alle Tickets“ und der eigene Status zum Umschalten. Überfälliges wird rot, im Dunkelmodus ist das Widget dunkel. Es zeigt bewusst keine Betreffs oder Kundennamen. Aktualisiert wird es nach Android-Vorgabe etwa alle 30 Minuten, beim Tippen auf ⟳, beim Öffnen der App und bei jeder Push-Nachricht. Die Statusknöpfe laufen über einen nicht exportierten Empfänger, andere Apps können den Status nicht ändern.
**Push-Benachrichtigungen (Android):** Die Android-App zeigt Popups bei neuen Tickets, Kundenantworten und Zuweisungen, auch wenn sie geschlossen ist. Das läuft über Firebase Cloud Messaging (Google).
@@ -1015,6 +1058,23 @@ Im Ordner `DeskHub-Webseite/` führt `php tests/run_all.php` alle PHP-Tests nach
---
## Demo-Video neu erzeugen
Video und Bilder mit Beispieldaten entstehen aus einer eigenen Demo-Installation (SQLite, alles erfunden, keine Kundendaten). Nötig sind PHP 8.2, Node mit Electron (liegt in `desktop/node_modules`) und ffmpeg. Alle Befehle im Ordner `DeskHub-Webseite`:
```bash
php tools/demo-video/seed_demo.php /pfad/zur/demo # Demo-Installation mit Beispieldaten anlegen
php -S 127.0.0.1:8097 -t /pfad/zur/demo & # Demo starten (Zugang: lena@demo.example / Demo-Passwort-1234)
../desktop/node_modules/.bin/electron tools/demo-video/capture.js /pfad/zu/bildern # Weboberfläche aufnehmen
../desktop/node_modules/.bin/electron tools/demo-video/capture_app.js /pfad/zu/bildern 50 # Windows-Programm aufnehmen
../desktop/node_modules/.bin/electron tools/demo-video/capture_titles.js /pfad/zu/bildern 1.0.28 # Titel- und Abspannbild
bash tools/demo-video/build_video.sh /pfad/zu/bildern docs/video # Video (mp4) und GIF-Vorschau bauen
```
Die Reihenfolge im Video ergibt sich aus den Dateinamen der Bilder. Die Bildunterschriften stehen in `capture.js` und `capture_app.js`.
---
## Hilfe, etwas funktioniert nicht
- **Admin-Protokoll** (Adminportal → Admin-Protokoll): Änderungen und fehlgeschlagene automatische E-Mails mit genauer Fehlermeldung.
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* Kleiner, öffentlicher Erreichbarkeitstest für die DeskHub-API.
* Gibt keine internen Details oder Zugangsdaten zurück.
*/
require_once __DIR__ . '/../../../includes/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
get_db()->query('SELECT 1');
echo json_encode(['status' => 'ok'], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(503);
echo json_encode(['status' => 'unavailable'], JSON_UNESCAPED_UNICODE);
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Öffentliche Webhook-Adresse für Plugins: POST /api/public/v1/plugin_webhook.php?p=<plugin>&e=<name>
* Das Plugin muss die Berechtigung "api.public" anfordern und prüft die Echtheit jeder Anfrage selbst (siehe plugin_register_webhook()).
* Hier gibt es nur: Methode POST, höchstens 256 KB, Aufruf des Handlers, Antwort als JSON oder Text.
*/
require_once __DIR__ . '/../../../includes/bootstrap.php';
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
function webhook_fail(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
webhook_fail(405, 'Nur POST.');
}
$slug = (string) ($_GET['p'] ?? '');
$endpoint = (string) ($_GET['e'] ?? '');
$handler = plugin_state()['webhooks'][$slug][$endpoint] ?? null;
if (!is_callable($handler)) webhook_fail(404, 'Unbekannte Adresse.');
$body = (string) file_get_contents('php://input', false, null, 0, 262145);
if (strlen($body) > 262144) webhook_fail(413, 'Anfrage zu groß.');
$headers = [];
foreach ($_SERVER as $k => $v) {
if (str_starts_with($k, 'HTTP_')) $headers[strtolower(str_replace('_', '-', substr($k, 5)))] = (string) $v;
}
if (isset($_SERVER['CONTENT_TYPE'])) $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
$result = plugin_guard($slug, fn() => $handler(['method' => 'POST', 'headers' => $headers, 'body' => $body, 'query' => $_GET, 'ip' => client_ip()]), null);
if (!is_array($result)) webhook_fail(500, 'Fehler im Plugin.');
http_response_code((int) ($result['status'] ?? 200));
foreach ((array) ($result['headers'] ?? []) as $name => $value) {
if (preg_match('/^[A-Za-z0-9-]+$/', (string) $name) && !preg_match('/[\r\n]/', (string) $value)) header($name . ': ' . $value);
}
if (isset($result['json'])) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($result['json'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
echo (string) ($result['body'] ?? '');
}
+63 -5
View File
@@ -5,7 +5,12 @@ declare(strict_types=1);
*
* POST /api/public/v1/tickets.php Ticket anlegen (Recht "Tickets anlegen")
* 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?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_…".
* 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];
$pdo = get_db();
$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 (!$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
$recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?");
$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.');
$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 {
$created = public_ticket_create($pdo, $body, client_ip());
} catch (AdminModelException $e) {
@@ -55,6 +91,28 @@ if ($method === 'POST') {
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)
if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.');
$number = trim((string) ($_GET['number'] ?? ''));
+9 -1
View File
@@ -6,11 +6,18 @@ api_require_method('GET');
$staff = api_require_staff();
$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'] ?? '');
if ($since === '') {
// Erster Aufruf nach Login: nur die Serverzeit als Referenzpunkt liefern, noch nichts zählen
// (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) {
api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.');
@@ -36,6 +43,7 @@ $newUnassigned = (int) $stmt->fetchColumn();
api_json([
'newReplies' => $newReplies,
'newUnassigned' => $newUnassigned,
'attention' => api_attention($pdo, $staff),
'serverTime' => db_now(),
'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [],
]);
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* 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).
*/
api_require_method('GET');
api_require_staff();
$platform = (string) ($_GET['platform'] ?? '');
if (!in_array($platform, ['android', 'windows', 'chrome'], true)) api_error(400, 'bad_platform', 'Unbekannte Plattform.');
$info = app_publisher_info($platform);
if (!$info) api_json(['available' => false, 'platform' => $platform]);
if (($_GET['download'] ?? '') !== '1') {
api_json([
'available' => true,
'platform' => $platform,
'version' => (string) $info['version'],
'sha256' => (string) $info['sha256'],
'size' => (int) $info['size'],
'notes' => (string) ($info['notes'] ?? ''),
'publishedAt' => (string) ($info['modified_at'] ?? ''),
]);
}
@set_time_limit(0);
while (ob_get_level() > 0) { @ob_end_clean(); }
header('Content-Type: ' . $info['mime']);
header('Content-Disposition: attachment; filename="' . $info['filename'] . '"');
if ((int) $info['size'] > 0) header('Content-Length: ' . (int) $info['size']);
header('X-Content-Type-Options: nosniff');
header('X-DeskHub-SHA256: ' . $info['sha256']);
header('Cache-Control: private, no-store');
$url = app_publisher_endpoint($platform, true);
$headers = function_exists('catalog_access_headers') ? catalog_access_headers($url) : [];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_TIMEOUT => 0, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) { echo $chunk; return strlen($chunk); }]);
curl_exec($ch);
curl_close($ch);
} else {
$remote = @fopen($url, 'rb', false, stream_context_create(['http' => ['timeout' => 0, 'header' => implode("\r\n", $headers)]]));
if ($remote) { fpassthru($remote); fclose($remote); }
}
@@ -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
$targetId = (int) $staff['id'];
if (isset($body['staffId']) && (int) $body['staffId'] !== $targetId) {
if (($staff['role'] ?? '') !== 'admin') {
api_error(403, 'FORBIDDEN', 'Nur Administratoren dürfen den Status anderer Mitarbeiter ändern.');
if (!has_permission($staff, 'manage_availability')) {
api_error(403, 'FORBIDDEN', 'Für den Status anderer Mitarbeiter fehlt die erforderliche Berechtigung.');
}
$targetId = (int) $body['staffId'];
$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');
$staff = api_require_staff();
api_require_admin($staff);
api_require_admin_or_permission($staff, 'manage_banlist');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
@@ -31,8 +31,12 @@ $action = $body['action'] ?? '';
function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void
{
$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]);
ticket_sla_transition_rows($pdo, $rows, 'closed', (int) ($GLOBALS['__api_staff']['id'] ?? 0) ?: null);
}
if ($action === 'create') {
+53 -17
View File
@@ -9,7 +9,24 @@ header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
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 {
error_log('Mobile Staff API: ' . $error->getMessage());
@@ -105,9 +122,9 @@ function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array
}
$pdo->prepare(
'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at)
VALUES (?,?,?,?,?)'
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now()]);
'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at)
VALUES (?,?,?,?,?,?)'
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now(), db_now()]);
return ['token' => $token, 'expires_at' => $expiresAt];
}
@@ -133,11 +150,10 @@ function api_require_staff(): array
api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.');
}
$lastUsed = $staff['mobile_token_last_used_at'] ?? null;
if (!$lastUsed || strtotime($lastUsed) < time() - 300) {
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
}
// Jede authentifizierte Anfrage ist ein App-Heartbeat. So bleibt der Status
// auch bei regelmaessiger Nutzung mit weniger als 5 Minuten Abstand aktuell.
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
// Aktionen in der App (Schreib-Aufrufe) zählen als Aktivität; reines Abfragen im Hintergrund und die Push-Anmeldung nicht
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !in_array(basename((string) ($_SERVER['SCRIPT_NAME'] ?? '')), ['push_device.php', 'crash_report.php', 'logout.php'], true)) {
@@ -154,10 +170,7 @@ function api_require_staff(): array
function api_staff_payload(array $staff): array
{
$permissionNames = array_merge([
'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
'create_ticket', 'view_reports',
], array_keys(admin_section_permissions()));
$permissionNames = array_keys(permission_labels()); // alle Rechte des Katalogs (includes/permissions.php)
$permissions = [];
foreach ($permissionNames as $permission) {
$permissions[$permission] = has_permission($staff, $permission);
@@ -249,6 +262,8 @@ function api_ticket_summary(array $ticket): array
'requesterName' => $ticket['requester_name'],
'requesterEmail' => $ticket['requester_email'],
'status' => $ticket['status'],
'resolutionCode' => $ticket['resolution_code'] ?? null,
'resolutionNote' => $ticket['resolution_note'] ?? null,
'priority' => [
'id' => (int) $ticket['priority_id'],
'name' => $ticket['priority_name'],
@@ -262,14 +277,16 @@ function api_ticket_summary(array $ticket): array
'id' => (int) $ticket['assigned_staff_id'],
'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()
&& !in_array($ticket['status'], ['resolved', 'closed'], true),
)),
// 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
// 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).
'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']),
'createdAt' => $ticket['created_at'],
'updatedAt' => $ticket['updated_at'],
@@ -297,7 +314,15 @@ function api_is_following(int $ticketId): bool
return isset($mine[$ticketId]);
}
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int
/** Cc-Empfänger eines Tickets für die Apps. */
function api_ticket_collaborators(PDO $pdo, int $ticketId): array
{
$s = $pdo->prepare('SELECT id, email, name FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at, id');
$s->execute([$ticketId]);
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, ?string $resolutionCode = null, string $resolutionNote = ''): int
{
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
api_require_permission($staff, $permission, (int) $ticket['department_id']);
@@ -345,6 +370,9 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
}
if (in_array($newStatus, ['resolved', 'closed'], true)) {
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();
@@ -363,15 +391,23 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
} else {
$firstResponse = empty($ticket['first_responded_at']);
$autoAssign = empty($ticket['assigned_staff_id']);
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
$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 = ?' : '')
. ($autoAssign ? ', assigned_staff_id = ?' : '')
. ' WHERE id = ?';
$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 ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id'];
$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);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* Auswertung der Zufriedenheitsabfrage.
* GET ?days=30 (1 bis 365) → {enabled, days, stats:{sent,answered,avg,rate,dist,byStaff,recent}}
* Mitarbeiter sehen nur die Abteilungen, in denen sie arbeiten.
*/
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$days = max(1, min(365, (int) ($_GET['days'] ?? 30)));
$from = date('Y-m-d 00:00:00', time() - ($days - 1) * 86400);
$to = date('Y-m-d 23:59:59');
$stats = null;
try {
$stats = csat_stats($pdo, $from, $to, staff_department_ids($staff));
} catch (Throwable $e) {
$stats = ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []];
}
$stats['dist'] = array_map(fn($k, $v) => ['rating' => $k, 'count' => $v], array_keys($stats['dist']), $stats['dist']);
api_json(['enabled' => csat_enabled(), 'days' => $days, 'stats' => $stats]);
+2 -2
View File
@@ -18,7 +18,7 @@ $myOpen = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare(
"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}"
);
$stmt->execute([db_now(), ...$deptParams]);
@@ -86,7 +86,7 @@ if ($departmentIds === null) {
"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')
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
FROM departments d
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 ($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']]);
}
api_json(['ok' => true]);
@@ -151,6 +151,7 @@ if ($action === 'send') {
if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id'];
$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)');
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
@@ -167,8 +168,9 @@ if ($action === 'end') {
}
$now = db_now();
$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']]);
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)');
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 === '') {
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
}
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|| !rate_limit_check('mobile_staff_login_email', strtolower($email), 5, 15)) {
$ipOk = rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 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.');
}
@@ -22,6 +24,7 @@ $stmt = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_
$stmt->execute([$email]);
$staff = $stmt->fetch();
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.');
}
if (!empty($staff['totp_enabled'])) {
@@ -29,6 +32,7 @@ if (!empty($staff['totp_enabled'])) {
api_json(['requiresTotp' => true], 202);
}
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.');
}
}
@@ -37,6 +41,7 @@ rate_limit_reset('mobile_staff_login_email', strtolower($email));
rate_limit_reset('mobile_staff_login_ip', client_ip());
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
login_guard_success($staff, 'app', isset($body['deviceName']) ? (string) $body['deviceName'] : null);
api_json([
'token' => $issued['token'],
'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 {
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]);
}
if ($action === 'delete') {
@@ -38,7 +38,7 @@ if ($action === 'save') {
$cfg = report_clean_config($pdo, (array) ($body['config'] ?? []));
$now = db_now();
$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()]);
}
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) –
// zusammen ergeben sie das vollständige Set, das über eine benutzerdefinierte Rolle vergeben werden kann.
$rolePermissionKeys = 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()));
$rolePermissionKeys = array_keys(permission_labels());
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$roles = $pdo->query(
@@ -21,11 +18,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
api_json([
'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'),
'items' => array_map(fn(array $r) => [
'id' => (int) $r['id'],
'name' => $r['name'],
'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [],
'effectivePermissions' => permission_effective(json_decode($r['permissions'] ?? '{}', true) ?? []),
'staffCount' => (int) $r['staff_count'],
'isActive' => (bool) ($r['is_active'] ?? 1),
'notes' => $r['notes'] ?? null,
@@ -44,9 +44,19 @@ if ($action === 'save') {
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
$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 = [];
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);
$dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* Statusseite für Kunden.
* GET → Zusammenfassung (Gesamtzustand, Dienste, Meldungen); für alle Mitarbeiter lesbar
* POST (nur Administratoren):
* {action:"set_component", id, status}
* {action:"create_incident", title, body?, status?, componentId?, componentState?}
* {action:"update_incident", id, status, body?}
* {action:"delete_incident", id}
* {action:"set_enabled", enabled}
*/
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
api_require_admin_or_permission($staff, 'manage_status_page');
$b = api_request_body();
$action = (string) ($b['action'] ?? '');
if ($action === 'set_component') {
if (!status_set_component($pdo, (int) ($b['id'] ?? 0), (string) ($b['status'] ?? ''))) api_error(422, 'INVALID_STATUS', 'Ungültiger Dienst oder Zustand.');
} elseif ($action === 'create_incident') {
$cid = (int) ($b['componentId'] ?? 0) ?: null;
$cs = isset($b['componentState']) && $b['componentState'] !== '' ? (string) $b['componentState'] : null;
$r = status_create_incident($pdo, (string) ($b['title'] ?? ''), (string) ($b['body'] ?? ''), (string) ($b['status'] ?? 'investigating'), $cid, $cs, (int) $staff['id']);
if (!$r['ok']) api_error(422, 'INVALID_INCIDENT', $r['error']);
audit_log($staff['id'], 'Statusseite: Meldung veröffentlicht (App)');
} elseif ($action === 'update_incident') {
$r = status_update_incident($pdo, (int) ($b['id'] ?? 0), (string) ($b['status'] ?? ''), (string) ($b['body'] ?? ''), (int) $staff['id'], true);
if (!$r['ok']) api_error(422, 'INVALID_INCIDENT', $r['error']);
} elseif ($action === 'delete_incident') {
status_delete_incident($pdo, (int) ($b['id'] ?? 0));
} elseif ($action === 'set_enabled') {
set_setting('status_page_enabled', !empty($b['enabled']) ? '1' : '0');
} else {
api_error(422, 'UNKNOWN_ACTION', 'Unbekannte Aktion.');
}
}
api_json(['status' => status_summary($pdo)] + ['canManage' => has_permission($staff, 'manage_status_page')]);
+13 -1
View File
@@ -40,8 +40,9 @@ $threadStmt = $pdo->prepare(
ORDER BY tt.created_at ASC, tt.id ASC'
);
$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(
'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(
'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['source'] = $ticket['source'];
$summary['firstResponseDueAt'] = $ticket['first_response_due_at'];
@@ -126,4 +129,13 @@ try {
} catch (Throwable $e) {
$summary['similar'] = ['articles' => [], 'tickets' => []];
}
// Cc-Empfänger, Beobachter und Bewertung des Kunden
try {
$summary['collaborators'] = api_ticket_collaborators($pdo, (int) $ticket['id']);
$summary['followers'] = array_map(fn($f) => ['id' => (int) $f['id'], 'name' => (string) $f['name']], ticket_followers($pdo, (int) $ticket['id']));
$__csat = csat_for_ticket($pdo, (int) $ticket['id']);
$summary['csat'] = $__csat ? ['rating' => $__csat['rating'] !== null ? (int) $__csat['rating'] : null, 'comment' => (string) ($__csat['comment'] ?? ''), 'sentAt' => $__csat['sent_at'], 'answeredAt' => $__csat['answered_at']] : null;
} catch (Throwable $e) {
$summary += ['collaborators' => [], 'followers' => [], 'csat' => null];
}
api_json(['ticket' => $summary]);
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* Cc-Empfänger (Kollaboratoren) eines Tickets: sie bekommen öffentliche Antworten per Mail in Kopie.
* GET ?number=… Liste
* POST {number, action:"add", email, name?}
* POST {number, action:"remove", id}
* Beobachter (Mitarbeiter) werden über ticket_update.php ("following") gesetzt und kommen mit ticket.php.
*/
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$body = $isPost ? api_request_body() : [];
$number = trim((string) ($isPost ? ($body['number'] ?? '') : ($_GET['number'] ?? '')));
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
$ticket = api_find_ticket($pdo, $staff, $number);
if ($isPost) {
$action = (string) ($body['action'] ?? '');
if ($action === 'add') {
$email = trim((string) ($body['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) api_error(422, 'INVALID_EMAIL', 'Bitte eine gültige E-Mail-Adresse angeben.');
$name = trim(mb_substr((string) ($body['name'] ?? ''), 0, 120));
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)');
$dup->execute([$ticket['id'], $email]);
if ((int) $dup->fetchColumn() === 0) {
$pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, added_by, created_at) VALUES (?,?,?,?,?)')
->execute([$ticket['id'], $email, $name !== '' ? $name : null, $staff['id'], db_now()]);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], "Kollaborator hinzugefügt: {$email}");
}
} elseif ($action === 'remove') {
$s = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE id = ? AND ticket_id = ?');
$s->execute([(int) ($body['id'] ?? 0), $ticket['id']]);
$email = $s->fetchColumn();
if ($email !== false) {
$pdo->prepare('DELETE FROM ticket_collaborators WHERE id = ? AND ticket_id = ?')->execute([(int) $body['id'], $ticket['id']]);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], "Kollaborator entfernt: {$email}");
}
} else {
api_error(422, 'UNKNOWN_ACTION', 'Unbekannte Aktion.');
}
}
api_json(['collaborators' => api_ticket_collaborators($pdo, (int) $ticket['id'])]);
@@ -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();
$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);
@@ -12,8 +12,8 @@ require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
if ($staff['role'] !== 'admin') {
api_error(403, 'ADMIN_REQUIRED', 'Nur Administratoren können Ticket-Regeln verwalten.');
if (!has_permission($staff, 'manage_automation')) {
api_error(403, 'FORBIDDEN', 'Für Ticket-Regeln fehlt die erforderliche Berechtigung.');
}
$pdo = get_db();
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* Ticket-Vorlagen zum Vorbelegen des Formulars „Neues Ticket“ in den Apps.
* GET → {templates:[{id,name,description,subject,body,departmentId,helpTopicId,priorityId,isPublic}]}
* Gepflegt werden die Vorlagen im Webbereich (Adminportal → Ticket-Vorlagen).
*/
api_require_method('GET');
api_require_staff();
$pdo = get_db();
$list = [];
try {
$list = array_map('ticket_template_payload', ticket_templates_list($pdo));
} catch (Throwable $e) {
// Datenbank noch nicht aktualisiert: keine Vorlagen
}
api_json(['templates' => $list]);
@@ -27,6 +27,6 @@ if ($holderId !== (int) $staff['id']) {
$n->execute([$holderId]);
$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');
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_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 = ?';
$params[] = $status;
if ($status === 'resolved' && empty($ticket['resolved_at'])) {
@@ -90,6 +96,7 @@ $params[] = $now;
$params[] = $ticket['id'];
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params);
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']);
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();
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
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;
} 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);
} 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;
}
}
+98
View File
@@ -1058,3 +1058,101 @@ body:has(.auth-wrap) .page-content > .auth-wrap {
details > summary { outline: none; }
details > summary::-webkit-details-marker { display: none; }
details[open] > summary span:last-child { transform: rotate(180deg); display:inline-block; }
/* Anmeldeseite der Mitarbeiter: Kopf- und Fußzeile, Hintergrund. Themes setzen --staff-login-* oder überschreiben die Klassen. */
body.staff-login-page {
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--staff-login-bg, #16202a);
}
.staff-login-header {
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
height: 56px; padding: 0 28px;
background: var(--staff-login-chrome, rgba(14,26,34,.95));
border-bottom: var(--staff-login-line, 1px solid rgba(255,255,255,.08));
}
.staff-login-brand { display: flex; align-items: center; gap: 12px; }
.staff-login-brand img { height: 32px; width: auto; }
.staff-login-brand-name { color: var(--staff-login-chrome-ink, #fff); font-weight: 700; font-size: .95rem; }
.staff-login-back { color: var(--staff-login-chrome-muted, rgba(255,255,255,.5)); font-size: .8rem; text-decoration: none; }
.staff-login-back:hover { color: var(--staff-login-chrome-ink, #fff); }
.staff-login-footer {
flex-shrink: 0; padding: 12px 28px; text-align: center;
background: var(--staff-login-chrome, rgba(14,26,34,.95));
border-top: var(--staff-login-line, 1px solid rgba(255,255,255,.08));
}
.staff-login-footer span { color: var(--staff-login-chrome-faint, rgba(255,255,255,.3)); font-size: .75rem; }
.staff-login-title { font-size: 1.4rem; font-weight: 700; margin: 0 0 4px; color: #0f1923; }
/* ── Seitenleiste ein-/ausklappen (eingeklappt bleibt eine Iconleiste) ─────────── */
.sidebar-toggle {
border: 0; cursor: pointer; font: inherit; line-height: 1;
color: #a8b8c8; background: rgba(255,255,255,.07);
width: 30px; height: 30px; border-radius: 8px; font-size: 1.1rem;
transition: background .12s, color .12s;
}
.sidebar-toggle:hover { background: rgba(255,255,255,.16); color: #fff; }
.sidebar-toggle-close { position: absolute; top: 16px; right: 12px; z-index: 2; }
.sidebar-toggle-open { display: none; }
.sidebar .nav-ico { display: none; }
.sidebar .nav-ico svg { display: block; }
@media (min-width: 821px) {
html.sidebar-closed .sidebar { width: 64px; overflow-x: hidden; }
html.sidebar-closed .sidebar .brand { height: 62px; padding: 0; font-size: 0; overflow: hidden; }
html.sidebar-closed .sidebar .brand > * { display: none; }
html.sidebar-closed .sidebar .sidebar-toggle-close { right: 17px; }
html.sidebar-closed .sidebar nav a { justify-content: center; padding: 11px 0; gap: 0; }
html.sidebar-closed .sidebar .nav-ico { display: flex; }
html.sidebar-closed .sidebar .nav-label { display: none; }
html.sidebar-closed .sidebar .nav-section-label { display: none; }
html.sidebar-closed .sidebar .nav-admin-block { display: block !important; border-top: 1px solid #243040; margin-top: 6px; padding-top: 6px; }
html.sidebar-closed .sidebar .nav-group-label { display: block; height: 1px; padding: 0; margin: 8px 14px; background: #243040; font-size: 0; overflow: hidden; }
html.sidebar-closed .sidebar .sidebar-footer { padding: 10px 0; }
html.sidebar-closed .sidebar .sidebar-footer > div { flex-direction: column; justify-content: center; align-items: center; gap: 12px !important; margin-bottom: 10px !important; }
html.sidebar-closed .sidebar .sidebar-footer > div:first-child > div:last-child { display: none; }
html.sidebar-closed .sidebar .sidebar-footer a { display: flex; }
html.sidebar-closed .sidebar .sidebar-footer .nav-ico { display: flex; }
html.sidebar-closed .sidebar .sidebar-footer .nav-label { display: none; }
}
@media (max-width: 820px) {
.sidebar { position: relative; }
.sidebar-toggle-close { top: 14px; }
.sidebar-toggle-open { position: fixed; top: 12px; left: 12px; z-index: 9998; background: var(--ink); color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
html.sidebar-closed .sidebar { display: none; }
html.sidebar-closed .sidebar-toggle-open { display: block; }
html.sidebar-closed .main { padding-top: 56px; }
}
/* Das Logo darf nicht unter den Einklappen-Knopf rutschen */
.sidebar .brand { padding-right: 52px; }
.sidebar .brand img { max-width: 100% !important; height: auto; }
/* Sprachwechsler: in der schmalen Iconleiste nicht anzeigen */
@media (min-width: 821px) {
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; }
+103
View File
@@ -0,0 +1,103 @@
/**
* Live-Anzeige im Mitarbeiterbereich: neue Tickets erscheinen sofort als Hinweis unten rechts, die Zahl steht im Browser-Tab und die
* Kennzahlen auf der Übersicht (Elemente mit data-live-count) aktualisieren sich von selbst. Fragt /staff/live_tickets.php ab.
*/
(function () {
'use strict';
if (window.__deskhubLive) return;
window.__deskhubLive = true;
var KEY = 'deskhub_live_last_ticket';
var since = 0;
try { since = parseInt(sessionStorage.getItem(KEY) || '0', 10) || 0; } catch (e) {}
var unseen = 0;
var baseTitle = document.title;
var stopped = false;
var box = null;
function container() {
if (box) return box;
box = document.createElement('div');
box.setAttribute('aria-live', 'polite');
box.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;flex-direction:column;gap:10px;max-width:360px;font:14px/1.4 "Segoe UI",system-ui,sans-serif';
document.body.appendChild(box);
return box;
}
function toast(t) {
var a = document.createElement('a');
a.href = '/staff/ticket_view.php?n=' + encodeURIComponent(t.number);
a.style.cssText = 'display:block;background:#0f172a;color:#fff;text-decoration:none;border-radius:12px;padding:12px 16px;box-shadow:0 10px 30px rgba(0,0,0,.35);border-left:4px solid #2dd4bf;transition:opacity .4s,transform .4s;opacity:0;transform:translateY(10px)';
var h = document.createElement('div');
h.style.cssText = 'font-size:.74rem;letter-spacing:.04em;text-transform:uppercase;color:#5eead4;font-weight:700';
h.textContent = 'Neues Ticket #' + t.number + (t.department ? ' · ' + t.department : '');
var s = document.createElement('div');
s.style.cssText = 'font-weight:600;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
s.textContent = t.subject;
var n = document.createElement('div');
n.style.cssText = 'color:#94a3b8;font-size:.82rem';
n.textContent = t.name;
a.appendChild(h); a.appendChild(s); a.appendChild(n);
container().appendChild(a);
requestAnimationFrame(function () { a.style.opacity = '1'; a.style.transform = 'none'; });
setTimeout(function () { a.style.opacity = '0'; setTimeout(function () { a.remove(); }, 450); }, 14000);
a.addEventListener('click', function () { a.remove(); });
}
function info(text) {
var d = document.createElement('div');
d.style.cssText = 'background:#334155;color:#e2e8f0;border-radius:10px;padding:8px 14px;font-size:.84rem;box-shadow:0 6px 18px rgba(0,0,0,.3)';
d.textContent = text;
container().appendChild(d);
setTimeout(function () { d.remove(); }, 14000);
}
function setCounts(c) {
document.querySelectorAll('[data-live-count]').forEach(function (el) {
var v = c[el.getAttribute('data-live-count')];
if (v === undefined || el.textContent.trim() === String(v)) return;
el.textContent = v;
el.style.transition = 'transform .3s';
el.style.transform = 'scale(1.25)';
setTimeout(function () { el.style.transform = 'none'; }, 350);
});
}
function updateTitle() {
document.title = unseen > 0 ? '(' + unseen + ') ' + baseTitle : baseTitle;
}
function seen() {
if (unseen === 0) return;
unseen = 0;
updateTitle();
}
document.addEventListener('visibilitychange', function () { if (!document.hidden) seen(); });
window.addEventListener('focus', seen);
function poll() {
if (stopped) return;
fetch('/staff/live_tickets.php?since=' + since, { credentials: 'same-origin', cache: 'no-store' })
.then(function (r) {
if (r.status === 401 || r.status === 403) { stopped = true; return null; }
return r.ok ? r.json() : null;
})
.then(function (d) {
if (!d) return;
setCounts(d.counts || {});
var list = d.tickets || [];
list.forEach(toast);
if (d.more > 0) info('… und ' + d.more + ' weitere neue Tickets');
if (list.length) {
unseen += list.length + (d.more || 0);
if (document.hidden || !document.hasFocus()) updateTitle(); else unseen = 0;
}
if (d.latest > since) {
since = d.latest;
try { sessionStorage.setItem(KEY, String(since)); } catch (e) {}
}
})
.catch(function () {})
.then(function () { if (!stopped) setTimeout(poll, document.hidden ? 30000 : 10000); });
}
setTimeout(poll, 1500);
})();
+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());
toolbar.appendChild(imgBtn);
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; });
+108
View File
@@ -0,0 +1,108 @@
/**
* Seitenleiste im Mitarbeiterbereich: ein-/ausklappen. Eingeklappt bleibt eine schmale Iconleiste sichtbar.
* Die Icons werden hier anhand der Seitenadresse zugeordnet, auch für Menüpunkte von Plugins (sonst ein Puzzle-Teil).
*/
(function () {
var root = document.documentElement, KEY = 'deskhub_sidebar';
var side = document.getElementById('sidebar');
if (!side) return;
var P = {
grid: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
check: '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
ban: '<circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
phone: '<rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>',
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
puzzle: '<path d="M20.5 11H19V7a2 2 0 0 0-2-2h-4V3.5a2.5 2.5 0 0 0-5 0V5H4a2 2 0 0 0-2 2v3.8h1.5a2.7 2.7 0 0 1 0 5.4H2V20a2 2 0 0 0 2 2h3.8v-1.5a2.7 2.7 0 0 1 5.4 0V22H17a2 2 0 0 0 2-2v-4h1.5a2.5 2.5 0 0 0 0-5z"/>',
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><line x1="9" y1="22" x2="9" y2="18"/><line x1="15" y1="22" x2="15" y2="18"/><line x1="8" y1="6" x2="8.01" y2="6"/><line x1="12" y1="6" x2="12.01" y2="6"/><line x1="16" y1="6" x2="16.01" y2="6"/><line x1="8" y1="10" x2="8.01" y2="10"/><line x1="12" y1="10" x2="12.01" y2="10"/><line x1="16" y1="10" x2="16.01" y2="10"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
mail: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
book: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
bot: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M12 8V4"/><circle cx="12" cy="3" r="1"/><line x1="8" y1="14" x2="8.01" y2="14"/><line x1="16" y1="14" x2="16.01" y2="14"/>',
briefcase: '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
shieldCheck: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
clipboard: '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>',
branch: '<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
trend: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
repeat: '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
db: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
activity: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
megaphone: '<path d="M3 11l18-8v18L3 13z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
userx: '<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="18" y1="8" x2="23" y2="13"/><line x1="23" y1="8" x2="18" y2="13"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>'
};
var PAGES = {
reports: 'grid', report_builder: 'chart', tickets: 'inbox', tasks: 'check', banlist: 'ban', agent_directory: 'users', apps: 'phone', live_chat: 'chat',
departments: 'building', priorities: 'flag', sla_plans: 'clock', help_topics: 'help', custom_fields: 'sliders', api_keys: 'key', webhooks: 'link',
custom_lists: 'list', canned_responses: 'file', site_pages: 'file', email_addresses: 'mail', email_templates: 'mail', kb_manage: 'book', assistant: 'bot',
plugins: 'puzzle', customers: 'user', organizations: 'briefcase', staff_manage: 'users', teams: 'users', roles: 'shield', availability: 'calendar',
security_check: 'shieldCheck', audit_log: 'clipboard', ticket_rules: 'branch', escalation_rules: 'trend', recurring_tickets: 'repeat', time_entries: 'clock',
business_hours: 'clock', settings: 'gear', backup: 'db', update: 'download', profile: 'user', logout: 'logout', ticket_templates: 'file', status_page: 'activity', languages: 'globe'
};
var PLUGINS = {
schichtuebergabe: 'sun', spamfilter: 'filter', vertretung: 'userx', virenscan: 'shieldCheck', datenschutz: 'lock', ueberwachung: 'activity', rundmail: 'megaphone',
'community-bot': 'bot', 'nas-backup': 'db', tagesbericht: 'clipboard'
};
function svg(name) {
return '<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">' + (P[name] || P.puzzle) + '</svg>';
}
function iconFor(a) {
var u;
try { u = new URL(a.getAttribute('href'), location.href); } catch (e) { return 'puzzle'; }
if (/\/staff\/plugin\.php$/.test(u.pathname)) return PLUGINS[u.searchParams.get('p')] || 'puzzle';
var m = u.pathname.match(/\/([a-z_]+)\.php$/);
return (m && PAGES[m[1]]) || 'puzzle';
}
var links = side.querySelectorAll('nav a[href], .sidebar-footer a[href$="profile.php"], .sidebar-footer a[href$="logout.php"]');
Array.prototype.forEach.call(links, function (a) {
if (a.querySelector('.nav-ico')) return;
var text = (a.textContent || '').replace(/\s+/g, ' ').trim();
if (!text) return;
var ico = document.createElement('span');
ico.className = 'nav-ico';
ico.innerHTML = svg(iconFor(a));
var label = document.createElement('span');
label.className = 'nav-label';
while (a.firstChild) label.appendChild(a.firstChild);
a.appendChild(ico);
a.appendChild(label);
a.setAttribute('title', text.replace(/\s*\d+\s*$/, ''));
});
var btn = document.getElementById('sidebar-toggle');
function paint() {
var closed = root.classList.contains('sidebar-closed');
btn.innerHTML = closed ? '&raquo;' : '&laquo;';
var t = closed ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen';
btn.setAttribute('title', t);
btn.setAttribute('aria-label', t);
}
btn.addEventListener('click', function () {
var closed = !root.classList.contains('sidebar-closed');
root.classList.toggle('sidebar-closed', closed);
try { localStorage.setItem(KEY, closed ? 'closed' : 'open'); } catch (e) {}
paint();
});
paint();
})();
@@ -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); }
});
})();
@@ -0,0 +1,78 @@
/**
* Verkleinert große Fotos, bevor sie hochgeladen werden (Handy-Fotos haben oft 5–15 MB).
* Das macht den Upload deutlich schneller. Ist etwas nicht möglich, wird die Originaldatei unverändert hochgeladen.
* Gilt für alle <input type="file"> auf der Seite; nur JPG/PNG über 1 MB, längste Seite höchstens 2400 px.
*/
(function () {
var MAX_SIDE = 2400, MIN_BYTES = 1024 * 1024, QUALITY = 0.82;
if (!window.DataTransfer || !window.HTMLCanvasElement || !window.URL) return;
function note(input, text) {
var el = input.parentNode && input.parentNode.querySelector('.upload-note');
if (!el) {
el = document.createElement('div');
el.className = 'upload-note';
el.style.cssText = 'font-size:.8rem;color:#64748b;margin-top:4px';
input.insertAdjacentElement('afterend', el);
}
el.textContent = text;
}
function shrink(file) {
return new Promise(function (resolve) {
if (!/^image\/(jpeg|png)$/.test(file.type) || file.size < MIN_BYTES) return resolve(file);
var url = URL.createObjectURL(file), img = new Image();
img.onerror = function () { URL.revokeObjectURL(url); resolve(file); };
img.onload = function () {
URL.revokeObjectURL(url);
var w = img.naturalWidth, h = img.naturalHeight, k = Math.min(1, MAX_SIDE / Math.max(w, h));
var c = document.createElement('canvas');
c.width = Math.round(w * k); c.height = Math.round(h * k);
var ctx = c.getContext('2d');
if (!ctx) return resolve(file);
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
c.toBlob(function (blob) {
if (!blob || blob.size >= file.size) return resolve(file);
var name = file.name.replace(/\.(png|jpe?g)$/i, '') + '.jpg';
resolve(new File([blob], name, { type: 'image/jpeg', lastModified: file.lastModified }));
}, 'image/jpeg', QUALITY);
};
img.src = url;
});
}
document.addEventListener('change', function (e) {
var input = e.target;
if (!input || input.type !== 'file' || input.dataset.shrunk === '1' || !input.files || !input.files.length) return;
var files = Array.prototype.slice.call(input.files);
if (!files.some(function (f) { return /^image\/(jpeg|png)$/.test(f.type) && f.size >= MIN_BYTES; })) return;
note(input, 'Bilder werden für einen schnelleren Upload verkleinert …');
Promise.all(files.map(shrink)).then(function (out) {
try {
var dt = new DataTransfer();
out.forEach(function (f) { dt.items.add(f); });
input.dataset.shrunk = '1';
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dataset.shrunk = '';
var before = files.reduce(function (s, f) { return s + f.size; }, 0), after = out.reduce(function (s, f) { return s + f.size; }, 0);
note(input, after < before ? 'Bilder verkleinert: ' + (before / 1048576).toFixed(1) + ' MB → ' + (after / 1048576).toFixed(1) + ' MB' : '');
} catch (err) { note(input, ''); }
});
}, true);
// Beim Absenden: Knopf sperren, damit nicht doppelt hochgeladen wird, und Fortschritt anzeigen
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || !form.querySelector || !form.querySelector('input[type=file]')) return;
var has = Array.prototype.some.call(form.querySelectorAll('input[type=file]'), function (i) { return i.files && i.files.length; });
if (!has) return;
setTimeout(function () {
if (e.defaultPrevented) return;
var b = form.querySelector('button[type=submit]');
if (b) { b.disabled = true; b.textContent = 'Wird hochgeladen …'; }
}, 0);
});
})();
@@ -52,6 +52,7 @@ function run_auto_close_tickets(PDO $pdo, bool $verbose = false): array
foreach ($tickets as $t) {
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE 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)");
$closed++;
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,
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
JOIN departments d ON d.id = t.department_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']) {
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE 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'];
}
@@ -201,6 +202,11 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
}
// 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'])) {
$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:')
+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
JOIN departments d ON d.id = t.department_id
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 < ?"
);
@@ -43,18 +44,12 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
foreach ($overdue as $t) {
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');
if ($t['department_email']) {
$subject = "SLA überschritten: Ticket #{$t['ticket_number']}";
$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);
// Nur die Betroffenen bekommen eine Mail (Bearbeiter bzw. Abteilung), nicht die Postfach-Adresse der Abteilung und nicht alle Admins
notify_overdue_ticket_staff($t);
fcm_notify_overdue($t); // Push auf die Android-App
$newlyOverdue++;
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
$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);
$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') {
+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/mail_text.php';
require_once __DIR__ . '/../includes/mail_threading.php';
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
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.
$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
$structure = @imap_fetchstructure($mbox, $emailNum);
$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)';
// ── Antwort auf bestehendes Ticket? ──────────────────────────────────
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
$ticketNumber = $m[1];
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
$stmt->execute([$ticketNumber]);
$ticket = $stmt->fetch();
// Zuordnung über die Nummer im Betreff, eine andere Nummer (früheres Ticketsystem) oder die Kopfzeilen In-Reply-To/References
$replyMatch = mail_find_ticket_for_reply($pdo, $subject, (string) @imap_fetchheader($mbox, $emailNum), (string) $fromEmail);
if ($replyMatch) {
$ticket = $replyMatch['ticket'];
$ticketNumber = (string) $ticket['ticket_number'];
$replyVia = ['subject' => '', 'legacy' => ' – zugeordnet über die Nummer im Betreff', 'header' => ' – zugeordnet über die Mail-Kopfzeilen'][$replyMatch['via']] ?? '';
if ($ticket) {
$now = db_now();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->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']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus);
log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen');
notify_new_reply_channels($ticket, $body);
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 (?,?,?,?,?,?)'
)->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);
$processed++;
continue;
@@ -294,6 +310,11 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->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[] = "[$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];
}
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);
foreach ($result['log'] as $line) {
echo $line . "\n";
+20
View File
@@ -59,8 +59,25 @@ function run_process_backups(PDO $pdo, bool $verbose = false, bool $force = fals
return ['ran' => false, 'ok' => true, 'log' => $log];
}
// Nur ein Backup gleichzeitig: Der echte Cronjob, der eingebaute Hintergrunddienst und der Knopf auf der Seite „Backup“ dürfen sich nicht
// überlappen. „Letztes Backup“ wird erst NACH dem Erstellen eingetragen; ein zweiter Lauf würde das Backup sonst weiterhin als fällig sehen.
if (!backup_lock_acquire($pdo)) {
$log[] = date('Y-m-d H:i:s') . ' – Es läuft bereits ein Backup, dieser Lauf wird übersprungen.';
return ['ran' => false, 'ok' => true, 'log' => $log];
}
if (!$force) { // Ein anderer Lauf kann gerade erst fertig geworden sein: nach dem Erhalt der Sperre noch einmal prüfen
$lastNow = bset($pdo, 'last_auto_backup') ?: '1970-01-01';
$days = ['daily' => 1, 'weekly' => 7, 'monthly' => 30][$schedule] ?? null;
if ($days === null || strtotime($lastNow) >= strtotime("-{$days} days", time())) {
backup_lock_release($pdo);
$log[] = date('Y-m-d H:i:s') . ' – Kein Backup nötig (ein anderer Lauf war gerade schneller).';
return ['ran' => false, 'ok' => true, 'log' => $log];
}
}
$log[] = date('Y-m-d H:i:s') . ' – Starte Backup...';
try {
$result = create_backup();
if (!$result['ok']) {
@@ -83,6 +100,9 @@ function run_process_backups(PDO $pdo, bool $verbose = false, bool $force = fals
: "INSERT INTO backup_settings (key_name, value, updated_at) VALUES ('last_auto_backup', ?, ?)
ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)";
$pdo->prepare($backupSettingSql)->execute([$backupTime, $backupTime]);
} finally {
backup_lock_release($pdo); // auch bei Fehlern und vorzeitigem Abbruch freigeben
}
// ── FTP-Upload (optional) ────────────────────────────────────────────────────
$ftpHost = bset($pdo, 'ftp_host');
@@ -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";
}
+41
View File
@@ -0,0 +1,41 @@
<?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/run_plugins.php
*
* Löst die Hintergrundaufgaben der Plugins aus (Hook "cron.run"): Überwachung, Tagesbericht, NAS-Backup, Rundmail-Versand usw.
* Läuft jede Minute, z. B. per Cronjob (wird mit „Neu installieren / aktualisieren“ unter Einstellungen → Cron-Jobs eingetragen):
* * * * * * /usr/bin/php /pfad/zu/deskhub/cron/run_plugins.php
* Das Ereignis „cron.minute“ kommt bei jedem Lauf, „cron.run“ höchstens alle 5 Minuten.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{count:int, log:string[]} */
function run_plugins_cron(PDO $pdo, bool $verbose = false): array
{
$n = count(plugin_state()['loaded'] ?? []);
// „cron.minute“: bei jedem Lauf (etwa jede Minute), für Aufgaben mit kurzem Prüfabstand (zum Beispiel die Überwachung)
plugin_hook('cron.minute', ['time' => time()]);
// „cron.run“: wie bisher höchstens alle 5 Minuten
if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim($pdo, 'cron_plugins_run_last', 290)) plugin_hook('cron.run', ['time' => time()]);
// Plugin- und Theme-Katalog aktuell halten (höchstens alle 5 Minuten)
if (function_exists('catalog_refresh_background') && (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280))) catalog_refresh_background();
set_setting('cron_run_plugins_last_run', db_now());
// Woher kam der Lauf? Der echte Server-Cronjob (Kommandozeile) oder der eingebaute Hintergrund-Betrieb bei einem Seitenaufruf
set_setting('cron_run_plugins_source', php_sapi_name() === 'cli' ? 'cron' : 'web');
if (php_sapi_name() === 'cli') set_setting('cron_run_plugins_last_cli', db_now());
return ['count' => $n, 'log' => ['[' . date('Y-m-d H:i:s') . "] Hintergrundaufgaben von {$n} aktiven Plugin(s) ausgelöst."]];
}
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)
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";
} else {
echo "[" . date('Y-m-d H:i:s') . "] Übersprungen: wurde gerade erst ausgeführt.\n";
}
}
+25
View File
@@ -0,0 +1,25 @@
<?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_csat.php
*
* Verschickt die Zufriedenheitsabfrage an Kunden, deren Ticket geschlossen oder gelöst wurde (Einstellungen → Allgemein →
* „Zufriedenheitsabfrage“). Pro Ticket gibt es höchstens eine Mail.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** @return array{sent:int, log:string[]} */
function run_send_csat(PDO $pdo, bool $verbose = false): array
{
set_setting('cron_send_csat_last_run', db_now());
if (!csat_enabled()) {
return ['sent' => 0, 'log' => ['[' . date('Y-m-d H:i:s') . '] Zufriedenheitsabfrage ist deaktiviert – nichts zu tun.']];
}
$sent = csat_send_due($pdo);
return ['sent' => $sent, 'log' => ['[' . date('Y-m-d H:i:s') . "] Zufriedenheitsabfrage: {$sent} Mail(s) gesendet."]];
}
if (php_sapi_name() === 'cli') {
foreach (run_send_csat(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";
}
+15
View File
@@ -0,0 +1,15 @@
<?php
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
require_once __DIR__ . '/../includes/bootstrap.php';
function run_status_checks(PDO $pdo, bool $verbose = false): array
{
$count = status_run_component_checks($pdo);
set_setting('cron_status_checks_last_run', db_now());
return ['checked' => $count, 'log' => ['Statusprüfung abgeschlossen: ' . $count . ' Dienst(e) geprüft.']];
}
if (php_sapi_name() === 'cli') {
$result = run_status_checks(get_db(), true);
foreach ($result['log'] as $line) echo '[' . date('Y-m-d H:i:s') . '] ' . $line . PHP_EOL;
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* csat.php – Bewertungsseite für die Zufriedenheitsabfrage (Link aus der Mail).
* Ein Klick auf einen Stern in der Mail öffnet diese Seite mit vorgewählter Bewertung; gespeichert wird erst mit dem Knopf
* (so zählen automatische Link-Prüfungen von Mailprogrammen nicht als Bewertung).
*/
require_once __DIR__ . '/includes/bootstrap.php';
$pdo = get_db();
$token = (string) ($_GET['t'] ?? $_POST['t'] ?? '');
$row = csat_find_by_token($pdo, $token);
$rating = (int) ($_POST['rating'] ?? $_GET['r'] ?? 0);
$error = null;
$done = false;
if ($row && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!rate_limit_check('csat', client_ip(), 30, 15)) {
$error = 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.';
} else {
$res = csat_record($pdo, $token, $rating, (string) ($_POST['comment'] ?? ''));
if ($res['ok']) $done = true; else $error = $res['error'];
$row = csat_find_by_token($pdo, $token);
}
}
$already = $row && $row['answered_at'] !== null && !$done;
$pageTitle = 'Ihre Bewertung';
require __DIR__ . '/includes/header_customer.php';
?>
<div style="max-width:560px;margin:24px auto">
<?php if (!$row): ?>
<h1>Link nicht gültig</h1>
<p class="muted">Dieser Bewertungslink ist ungültig oder abgelaufen.</p>
<?php elseif ($done): ?>
<h1>Vielen Dank!</h1>
<p style="font-size:2rem;color:#f5b301;margin:8px 0"><?= e(csat_stars((int) $row['rating'])) ?></p>
<p>Ihre Bewertung für Ticket <strong>#<?= e($row['ticket_number']) ?></strong> ist bei uns angekommen.</p>
<?php elseif ($already): ?>
<h1>Bereits bewertet</h1>
<p class="muted">Sie haben Ticket #<?= e($row['ticket_number']) ?> schon bewertet. Vielen Dank!</p>
<?php else: ?>
<h1>Wie zufrieden waren Sie?</h1>
<p class="muted">Ticket <strong>#<?= e($row['ticket_number']) ?></strong> – <?= e($row['subject']) ?></p>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<form method="post" class="card" style="padding:20px;margin-top:14px">
<input type="hidden" name="t" value="<?= e($token) ?>">
<div id="csat-stars" style="display:flex;gap:6px;flex-direction:row-reverse;justify-content:center;font-size:2.6rem;line-height:1">
<?php for ($i = 5; $i >= 1; $i--): ?>
<input type="radio" name="rating" id="r<?= $i ?>" value="<?= $i ?>" style="position:absolute;opacity:0;width:0;height:0" <?= $rating === $i ? 'checked' : '' ?> required>
<label for="r<?= $i ?>" title="<?= $i ?> von 5" class="csat-star" style="cursor:pointer;margin:0">★</label>
<?php endfor; ?>
</div>
<style>
.csat-star { color:#cbd5e1; transition:color .1s }
#csat-stars input:checked ~ .csat-star, #csat-stars .csat-star:hover, #csat-stars .csat-star:hover ~ .csat-star { color:#f5b301 }
#csat-stars input:focus-visible + .csat-star { outline:2px solid var(--primary,#0f6f6a); border-radius:4px }
</style>
<p class="muted" style="text-align:center;font-size:.8rem;margin:4px 0 14px">1 Stern = unzufrieden · 5 Sterne = sehr zufrieden</p>
<label for="comment">Möchten Sie uns noch etwas mitteilen? (optional)</label>
<textarea id="comment" name="comment" rows="4" maxlength="2000" placeholder="Was hat gut geklappt, was können wir besser machen?"></textarea>
<div class="btn-row"><button type="submit" class="btn">Bewertung absenden</button></div>
</form>
<?php endif; ?>
</div>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
+820
View File
@@ -1330,6 +1330,826 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
}
}
// Migration 0048: Zufriedenheitsabfrage (CSAT), Statusseite für Kunden und Ticket-Vorlagen.
if (!isset($done['php_0048_csat_status_templates'])) {
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_csat (
id $ai,
ticket_id INT NOT NULL,
token VARCHAR(64) NOT NULL,
staff_id INT NULL,
department_id INT NULL,
rating TINYINT NULL,
comment TEXT NULL,
sent_at DATETIME NOT NULL,
answered_at DATETIME NULL
)$eng");
$pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_ticket ON ticket_csat (ticket_id)');
$pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_token ON ticket_csat (token)');
$pdo->exec("CREATE TABLE IF NOT EXISTS status_components (
id $ai,
name VARCHAR(120) NOT NULL,
description VARCHAR(255) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'operational',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)$eng");
$pdo->exec("CREATE TABLE IF NOT EXISTS status_incidents (
id $ai,
title VARCHAR(200) NOT NULL,
body TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'investigating',
impact VARCHAR(20) NOT NULL DEFAULT 'minor',
component_id INT NULL,
created_by INT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
resolved_at DATETIME NULL
)$eng");
$pdo->exec('CREATE INDEX idx_status_incidents_created ON status_incidents (created_at)');
$pdo->exec("CREATE TABLE IF NOT EXISTS status_incident_updates (
id $ai,
incident_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
body TEXT NULL,
created_by INT NULL,
created_at DATETIME NOT NULL
)$eng");
$pdo->exec('CREATE INDEX idx_status_incident_updates_incident ON status_incident_updates (incident_id)');
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_templates (
id $ai,
name VARCHAR(120) NOT NULL,
description VARCHAR(255) NULL,
subject VARCHAR(200) NOT NULL DEFAULT '',
body TEXT NULL,
department_id INT NULL,
help_topic_id INT NULL,
priority_id INT NULL,
is_public TINYINT NOT NULL DEFAULT 1,
is_active TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)$eng");
$defaults = ['csat_enabled' => '0', 'csat_delay_minutes' => '30', 'status_page_enabled' => '0', 'status_page_title' => 'Systemstatus'];
$chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
$ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
foreach ($defaults as $k => $v) {
$chk->execute([$k]);
if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0048_csat_status_templates']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0048_csat_status_templates';
} catch (Throwable $e) {
$result['errors'][] = 'php_0048_csat_status_templates: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0048_csat_status_templates - ' . $e->getMessage();
}
}
// Migration 0050: Kategorien für Dienste auf der Statusseite.
if (!isset($done['php_0050_status_categories'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$hasCategory = false;
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'category') $hasCategory = true;
if (!$hasCategory) $pdo->exec('ALTER TABLE status_components ADD COLUMN category VARCHAR(80) NULL');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0050_status_categories']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0050_status_categories';
} catch (Throwable $e) {
$result['errors'][] = 'php_0050_status_categories: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0050_status_categories - ' . $e->getMessage();
}
}
// Migration 0051: Kategorien als eigene Datensätze mit Dropdown-Zuordnung.
if (!isset($done['php_0051_status_category_records'])) {
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_categories (
id $ai,
name VARCHAR(80) NOT NULL,
sort_order INT NOT NULL DEFAULT 0
)$eng");
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$hasCategoryId = false;
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'category_id') $hasCategoryId = true;
if (!$hasCategoryId) $pdo->exec('ALTER TABLE status_components ADD COLUMN category_id INT NULL');
$legacy = $pdo->query("SELECT DISTINCT category FROM status_components WHERE category IS NOT NULL AND TRIM(category) <> ''")->fetchAll(PDO::FETCH_COLUMN);
$find = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$add = $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)');
$assign = $pdo->prepare('UPDATE status_components SET category_id = ? WHERE category = ? AND category_id IS NULL');
foreach ($legacy as $name) {
$find->execute([$name]);
$categoryId = $find->fetchColumn();
if (!$categoryId) {
$add->execute([$name, 0]);
$categoryId = (int) $pdo->lastInsertId();
}
$assign->execute([$categoryId, $name]);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0051_status_category_records']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0051_status_category_records';
} catch (Throwable $e) {
$result['errors'][] = 'php_0051_status_category_records: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0051_status_category_records - ' . $e->getMessage();
}
}
// Migration 0052: Statuswechsel der Dienste für eine ehrliche Verlaufsanzeige speichern.
if (!isset($done['php_0052_status_component_history'])) {
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_component_events (
id $ai,
component_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL
)$eng");
// Der erste Messpunkt ist ab jetzt; frühere Zustände werden nicht geraten.
$existing = $pdo->prepare('SELECT COUNT(*) FROM status_component_events WHERE component_id = ?');
$seed = $pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)');
foreach ($pdo->query('SELECT id, status FROM status_components')->fetchAll() as $component) {
$existing->execute([(int) $component['id']]);
if ((int) $existing->fetchColumn() === 0) $seed->execute([(int) $component['id'], (string) $component['status'], date('Y-m-d H:i:s')]);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0052_status_component_history']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0052_status_component_history';
} catch (Throwable $e) {
$result['errors'][] = 'php_0052_status_component_history: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0052_status_component_history - ' . $e->getMessage();
}
}
// Migration 0053: automatische Statusprüfungen für Dienste konfigurieren.
if (!isset($done['php_0053_status_auto_checks'])) {
try {
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
$existing = [];
foreach ($cols as $col) $existing[] = (string) ($col['name'] ?? $col['Field'] ?? '');
$add = [
'monitor_type' => "VARCHAR(20) NOT NULL DEFAULT 'auto'",
'monitor_target' => 'VARCHAR(500) NULL',
'last_checked_at' => 'DATETIME NULL',
'last_check_ms' => 'INT NULL',
'last_check_error' => 'VARCHAR(255) NULL',
'consecutive_failures' => 'INT NOT NULL DEFAULT 0',
];
foreach ($add as $column => $definition) {
if (!in_array($column, $existing, true)) $pdo->exec("ALTER TABLE status_components ADD COLUMN $column $definition");
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0053_status_auto_checks']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0053_status_auto_checks';
} catch (Throwable $e) {
$result['errors'][] = 'php_0053_status_auto_checks: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0053_status_auto_checks - ' . $e->getMessage();
}
}
// Migration 0054: einzelne Prüfergebnisse für die Heartbeat-Leiste speichern.
if (!isset($done['php_0054_status_check_samples'])) {
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_component_checks (
id $ai,
component_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
checked_at DATETIME NOT NULL,
response_ms INT NULL
)$eng");
$pdo->exec('CREATE INDEX idx_status_component_checks_lookup ON status_component_checks (component_id, checked_at, id)');
$pdo->exec('CREATE INDEX idx_status_component_checks_checked_at ON status_component_checks (checked_at)');
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0054_status_check_samples']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0054_status_check_samples';
} catch (Throwable $e) {
$result['errors'][] = 'php_0054_status_check_samples: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0054_status_check_samples - ' . $e->getMessage();
}
}
// 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).
if (!isset($done['php_0049_i18n_missing'])) {
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 i18n_missing (
id $ai,
text_hash CHAR(32) NOT NULL,
text VARCHAR(400) NOT NULL,
hits INT NOT NULL DEFAULT 1,
last_seen DATETIME NOT NULL
)$eng");
$pdo->exec('CREATE UNIQUE INDEX idx_i18n_missing_hash ON i18n_missing (text_hash)');
$chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
$ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
foreach (['default_language' => 'de', 'i18n_collect' => '0'] as $k => $v) {
$chk->execute([$k]);
if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
}
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0049_i18n_missing']);
$result['applied']++;
if ($verbose) $result['log'][] = ' [OK] php_0049_i18n_missing';
} catch (Throwable $e) {
$result['errors'][] = 'php_0049_i18n_missing: ' . $e->getMessage();
if ($verbose) $result['log'][] = ' [FAIL] php_0049_i18n_missing - ' . $e->getMessage();
}
}
// 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;
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.
+137 -79
View File
@@ -1,79 +1,137 @@
<?php
declare(strict_types=1);
/**
* Download der Windows- und Android-App für Mitarbeiter.
*
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
* an angemeldete Mitarbeiter.
*/
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
function app_platforms(): array
{
return [
'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'],
];
}
function app_downloads_dir(): string
{
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
}
/** Legt den Ordner an und sperrt den Direktzugriff. */
function app_downloads_prepare_dir(): bool
{
$dir = app_downloads_dir();
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
}
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
return is_writable($dir);
}
/**
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
* solange sich Änderungsdatum und Größe nicht ändern).
*/
function app_download_info(string $platform): ?array
{
$platforms = app_platforms();
if (!isset($platforms[$platform])) return null;
$p = $platforms[$platform];
$path = app_downloads_dir() . '/' . $p['stored'];
clearstatcache(true, $path);
if (!is_file($path) || (int) filesize($path) < 1024) return null;
$size = (int) filesize($path);
$mtime = (int) filemtime($path);
$cacheKey = "app_{$platform}_hash_cache";
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
$sha = $cached[2];
} else {
$sha = (string) hash_file('sha256', $path);
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
}
return [
'platform' => $platform,
'label' => $p['label'],
'size' => $size,
'sha256' => $sha,
'modified_at' => date('Y-m-d H:i:s', $mtime),
'path' => $path,
'filename' => $p['download'],
'mime' => $p['mime'],
];
}
function app_format_size(int $bytes): string
{
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
}
<?php
declare(strict_types=1);
/**
* Download der Windows- und Android-App für Mitarbeiter.
*
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
* an angemeldete Mitarbeiter.
*/
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
function app_platforms(): array
{
return [
'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'],
'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'],
];
}
function app_downloads_dir(): string
{
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
}
/** Official publisher release endpoint; the access key stays on the server. */
function app_publisher_endpoint(string $platform, bool $download = false): string
{
if (!isset(app_platforms()[$platform]) || !function_exists('catalog_defaults')) return '';
$base = (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'] . ($parts['port'] ?? null ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?platform=' . rawurlencode($platform) . ($download ? '&download=1' : '');
}
function app_publisher_request(string $platform, bool $download = false)
{
$url = app_publisher_endpoint($platform, $download);
if ($url === '') return false;
$headers = function_exists('catalog_access_headers') ? catalog_access_headers($url) : [];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => $download ? 180 : 4, CURLOPT_HTTPHEADER => $headers]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $code === 200 ? $body : false;
}
$ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => $download ? 180 : 4, 'ignore_errors' => true, 'header' => implode("\r\n", $headers)]]);
$body = @file_get_contents($url, false, $ctx);
$status = $http_response_header[0] ?? '';
return is_string($body) && str_contains($status, ' 200 ') ? $body : false;
}
function app_publisher_info(string $platform): ?array
{
$cacheKey = 'app_publisher_' . $platform . '_cache';
$cached = json_decode((string) get_setting($cacheKey, ''), true);
if (is_array($cached) && (int) ($cached['at'] ?? 0) > time() - 60) return is_array($cached['info'] ?? null) ? $cached['info'] : null;
$json = app_publisher_request($platform);
$data = is_string($json) ? json_decode($json, true) : null;
if (!is_array($data) || empty($data['version']) || empty($data['sha256'])) {
if (is_array($cached) && is_array($cached['info'] ?? null)) return $cached['info'];
set_setting($cacheKey, json_encode(['at' => time(), 'info' => null]));
return null;
}
$p = app_platforms()[$platform] ?? null;
if (!$p) return null;
$info = ['platform' => $platform, 'label' => $p['label'], 'size' => (int) ($data['size'] ?? 0), 'sha256' => (string) $data['sha256'], 'modified_at' => (string) ($data['created_at'] ?? ''), 'filename' => $p['download'], 'mime' => $p['mime'], 'version' => (string) $data['version'], 'notes' => (string) ($data['notes'] ?? ''), 'source' => 'publisher'];
set_setting($cacheKey, json_encode(['at' => time(), 'info' => $info], JSON_UNESCAPED_UNICODE));
return $info;
}
/** Legt den Ordner an und sperrt den Direktzugriff. */
function app_downloads_prepare_dir(): bool
{
$dir = app_downloads_dir();
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
}
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
return is_writable($dir);
}
/**
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
* solange sich Änderungsdatum und Größe nicht ändern).
*/
function app_download_info(string $platform): ?array
{
$platforms = app_platforms();
if (!isset($platforms[$platform])) return null;
$p = $platforms[$platform];
$path = app_downloads_dir() . '/' . $p['stored'];
clearstatcache(true, $path);
if (!is_file($path) || (int) filesize($path) < 1024) return null;
$size = (int) filesize($path);
$mtime = (int) filemtime($path);
$cacheKey = "app_{$platform}_hash_cache";
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
$sha = $cached[2];
} else {
$sha = (string) hash_file('sha256', $path);
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
}
return [
'platform' => $platform,
'label' => $p['label'],
'size' => $size,
'sha256' => $sha,
'modified_at' => date('Y-m-d H:i:s', $mtime),
'path' => $path,
'filename' => $p['download'],
'mime' => $p['mime'],
];
}
function app_format_size(int $bytes): string
{
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
}
/** Minecraft-Plugin auf der Apps-Seite zeigen? Einstellung unter Einstellungen → System (Standard: aus). */
function app_minecraft_enabled(): bool
{
return get_setting('minecraft_enabled', '0') === '1';
}
+7
View File
@@ -52,7 +52,9 @@ function staff_login(array $staff): void
session_regenerate_id(true);
$_SESSION['staff_id'] = $staff['id'];
$_SESSION['staff_last_activity'] = time();
$_SESSION['staff_login_at'] = time();
staff_touch_activity(get_db(), (int) $staff['id']);
login_guard_success($staff, 'web');
}
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->execute([$_SESSION['staff_id']]);
$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);
}
+34 -3
View File
@@ -60,7 +60,9 @@ Deny from all
// Zeitlimit erhöhen für große Backups
@set_time_limit(300);
@ini_set('memory_limit', '256M');
// Arbeitsspeicher nur erhöhen, nie senken (ein Limit von 512 MB oder mehr vom Hoster bleibt erhalten)
$memBytes = (function (string $v): int { $n = (int) $v; return match (strtolower(substr(trim($v), -1))) { 'g' => $n * 1073741824, 'm' => $n * 1048576, 'k' => $n * 1024, default => $n }; })((string) ini_get('memory_limit'));
if ($memBytes >= 0 && $memBytes < 512 * 1048576 && $memBytes !== 0) { @ini_set('memory_limit', '512M'); }
// php_zip automatisch aktivieren falls nicht geladen
if (!class_exists('ZipArchive')) {
@@ -96,6 +98,10 @@ Deny from all
// Alle Dateien sammeln
$exclude = [realpath($backupDir) ?: $backupDir];
// Der Backup-Ordner des Servers (backups/) gehört NIE ins Backup, auch wenn das Ziel ein anderer Ordner ist (zum Beispiel ein Zwischenordner des
// NAS-Plugins): sonst steckten alle älteren Backups noch einmal darin. Nur die .htaccess bleibt dabei, damit der Ordner nach einer
// Wiederherstellung geschützt ist.
$stdBackups = realpath($rootDir . '/backups') ?: null;
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
@@ -107,8 +113,12 @@ Deny from all
$filePath = $file->getRealPath();
$norm = str_replace('\\', '/', $filePath);
$skip = false;
foreach ($exclude as $ex) {
if ($ex && strpos($filePath, $ex) === 0) { $skip = true; break; }
if ($stdBackups !== null && strpos($filePath, $stdBackups . DIRECTORY_SEPARATOR) === 0) {
if ($filePath !== $stdBackups . DIRECTORY_SEPARATOR . '.htaccess') $skip = true; // alles außer der .htaccess
} else {
foreach ($exclude as $ex) {
if ($ex && ($filePath === $ex || strpos($filePath, rtrim($ex, '/' . chr(92)) . DIRECTORY_SEPARATOR) === 0)) { $skip = true; break; }
}
}
foreach (backup_excluded_fragments() as $frag) {
if (str_contains($norm, $frag)) { $skip = true; break; }
@@ -205,8 +215,13 @@ Deny from all
if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return ['ok' => false, 'error' => 'Backup-ZIP konnte nicht erstellt werden.'];
}
$canStore = method_exists($zip, 'setCompressionName');
foreach ($files as $filePath => $relativePath) {
$zip->addFile($filePath, $relativePath);
// Bereits komprimierte Dateien nur ablegen statt erneut zu packen: spart bei großen Uploads viel Rechenzeit
if ($canStore && preg_match('/\.(zip|jpe?g|png|gif|webp|pdf|exe|apk|aab|jar|7z|gz|mp[34]|woff2?)$/i', $relativePath)) {
@$zip->setCompressionName($relativePath, ZipArchive::CM_STORE);
}
}
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
@@ -276,3 +291,19 @@ Deny from all
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
];
}
/**
* Sperre „es läuft gerade ein Backup“: höchstens ein Backup gleichzeitig (Cronjob, eingebauter Hintergrunddienst, Knopf auf der Seite, NAS-Plugin).
* Atomar über die Einstellungstabelle; eine Sperre älter als $maxSeconds (abgestürzter Lauf) gilt als frei.
*/
function backup_lock_acquire(PDO $pdo, int $maxSeconds = 10800): bool
{
if (!function_exists('pseudo_cron_try_claim')) return true;
return pseudo_cron_try_claim($pdo, 'backup_run_lock', $maxSeconds);
}
function backup_lock_release(PDO $pdo): void
{
if (!function_exists('pseudo_cron_try_claim')) return;
try { $pdo->prepare("UPDATE settings SET setting_value = '1970-01-01 00:00:00' WHERE setting_key = 'backup_run_lock'")->execute(); } catch (Throwable $e) { /* Sperre verfällt von selbst */ }
}
+21
View File
@@ -12,7 +12,11 @@ require_once __DIR__ . '/time_tracking.php';
require_once __DIR__ . '/pseudo_cron.php';
require_once __DIR__ . '/auth.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__ . '/mail_threading.php';
require_once __DIR__ . '/email_addresses.php';
require_once __DIR__ . '/health.php';
require_once __DIR__ . '/admin_models.php';
@@ -23,8 +27,22 @@ require_once __DIR__ . '/fcm.php';
require_once __DIR__ . '/staff_activity.php';
require_once __DIR__ . '/notifications.php';
require_once __DIR__ . '/ticket_extras.php';
require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/csat.php';
require_once __DIR__ . '/status_page.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__ . '/app_downloads.php';
require_once __DIR__ . '/permissions.php';
require_once __DIR__ . '/theme_catalog.php';
require_once __DIR__ . '/security_check.php';
require_once __DIR__ . '/update_security.php';
@@ -71,6 +89,9 @@ pseudo_cron_schedule_background();
// Aktive Plugins laden (Fehler in Plugins werden isoliert, siehe includes/plugins.php).
plugins_boot();
// Mehrsprachigkeit: bei einer anderen Sprache als Deutsch (oder im Sammelmodus) wird die fertige Seite übersetzt (includes/i18n.php).
i18n_start();
// DB-kompatibler SQL-Ausdruck für Domain-Teil einer E-Mail
function sql_email_domain(string $col): string {
$driver = config()['db']['driver'] ?? 'sqlite';
@@ -64,7 +64,7 @@ function detect_php_cli_binary(): string
return 'php'; // Fallback: hoffen, dass PATH im Cron-Kontext passt
}
/** Baut den verwalteten DeskHub-Block mit den 6 Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */
/** Baut den verwalteten DeskHub-Block mit den Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */
function crontab_marker_block(): string
{
$php = detect_php_cli_binary();
@@ -72,6 +72,8 @@ function crontab_marker_block(): string
$lines = [
DESKHUB_CRON_MARKER_START,
"*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.log 2>&1",
"* * * * * $php $root/cron/status_checks.php >> $root/cron/status_checks.log 2>&1",
"* * * * * $php $root/cron/run_plugins.php >> $root/cron/run_plugins.log 2>&1",
"*/15 * * * * $php $root/cron/check_sla.php >> $root/cron/check_sla.log 2>&1",
"0 * * * * $php $root/cron/check_escalations.php >> $root/cron/check_escalations.log 2>&1",
"0 6 * * * $php $root/cron/process_recurring.php >> $root/cron/process_recurring.log 2>&1",
+166
View File
@@ -0,0 +1,166 @@
<?php
/**
* includes/csat.php
*
* Zufriedenheitsabfrage (CSAT): Nach dem Schließen oder Lösen eines Tickets bekommt der Kunde eine Mail mit der Bitte,
* die Bearbeitung mit 1 bis 5 Sternen zu bewerten (und optional einen Kommentar zu schreiben). Pro Ticket gibt es höchstens
* eine Abfrage. Der Versand läuft über den Hintergrunddienst (cron/send_csat.php), damit er nicht an jedem Weg hängt,
* auf dem ein Ticket geschlossen werden kann (Web, App, Regeln, automatisches Schließen).
*/
function csat_enabled(): bool
{
return get_setting('csat_enabled', '0') === '1';
}
/** Wartezeit nach dem Schließen, bevor die Mail rausgeht (0 bis 1440 Minuten). */
function csat_delay_minutes(): int
{
return max(0, min(1440, (int) get_setting('csat_delay_minutes', '30')));
}
function csat_url(string $token, ?int $rating = null): string
{
return base_url() . '/csat.php?t=' . rawurlencode($token) . ($rating ? '&r=' . $rating : '');
}
/** Schaltet die Abfrage ein oder aus. Beim Einschalten gelten nur Tickets, die ab jetzt geschlossen werden (keine Mails für alte Tickets). */
function csat_set_enabled(bool $on, int $delayMinutes): void
{
if ($on && !csat_enabled()) set_setting('csat_since', db_now());
set_setting('csat_enabled', $on ? '1' : '0');
set_setting('csat_delay_minutes', (string) max(0, min(1440, $delayMinutes)));
}
/** Eintrag zu einem Ticket (oder null). */
function csat_for_ticket(PDO $pdo, int $ticketId): ?array
{
$s = $pdo->prepare('SELECT * FROM ticket_csat WHERE ticket_id = ?');
$s->execute([$ticketId]);
return $s->fetch() ?: null;
}
function csat_find_by_token(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{40}$/', $token)) return null;
$s = $pdo->prepare('SELECT c.*, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE c.token = ?');
$s->execute([$token]);
return $s->fetch() ?: null;
}
/**
* Speichert die Bewertung. Jede Abfrage lässt sich nur einmal beantworten.
* @return array{ok:bool,error?:string}
*/
function csat_record(PDO $pdo, string $token, int $rating, string $comment): array
{
$row = csat_find_by_token($pdo, $token);
if (!$row) return ['ok' => false, 'error' => 'Dieser Link ist ungültig oder abgelaufen.'];
if ($row['answered_at'] !== null) return ['ok' => false, 'error' => 'Vielen Dank, Sie haben dieses Ticket bereits bewertet.'];
if ($rating < 1 || $rating > 5) return ['ok' => false, 'error' => 'Bitte wählen Sie 1 bis 5 Sterne.'];
$comment = trim(mb_substr($comment, 0, 2000));
$pdo->prepare('UPDATE ticket_csat SET rating = ?, comment = ?, answered_at = ? WHERE id = ? AND answered_at IS NULL')
->execute([$rating, $comment !== '' ? $comment : null, db_now(), $row['id']]);
try {
log_activity($pdo, (int) $row['ticket_id'], null, 'Zufriedenheit bewertet: ' . $rating . ' von 5 Sternen');
} catch (Throwable $e) {
}
return ['ok' => true];
}
/** Text der Mail an den Kunden. */
function csat_mail_html(array $ticket, string $token): string
{
$name = trim((string) ($ticket['requester_name'] ?? ''));
$stars = '';
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>';
}
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) . ', ' : '')
. '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>'
. '<p style="margin:0 0 6px;text-align:center">' . $stars . '</p>'
. '<p style="margin:0 0 16px;text-align:center;color:#64748b;font-size:12px">1 Stern = unzufrieden · 5 Sterne = sehr zufrieden</p>';
}
/**
* Verschickt die fälligen Abfragen. Fällig: Ticket geschlossen oder gelöst (seit mindestens der eingestellten Wartezeit, höchstens 14 Tage),
* kein Spam, kein Live-Chat, gültige Kundenadresse, noch keine Abfrage, und geschlossen nach dem Einschalten der Funktion.
*/
function csat_send_due(PDO $pdo, int $limit = 50): int
{
if (!csat_enabled()) return 0;
$now = time();
$until = date('Y-m-d H:i:s', $now - csat_delay_minutes() * 60);
$oldest = date('Y-m-d H:i:s', $now - 14 * 86400);
$since = (string) get_setting('csat_since', '');
if ($since !== '' && $since > $oldest) $oldest = $since;
$stmt = $pdo->prepare(
"SELECT t.* FROM tickets t
WHERE t.status IN ('closed','resolved') AND t.is_spam = 0 AND t.source <> 'chat'
AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) <= ? AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) >= ?
AND t.requester_email IS NOT NULL AND t.requester_email <> ''
AND NOT EXISTS (SELECT 1 FROM ticket_csat c WHERE c.ticket_id = t.id)
ORDER BY t.id LIMIT " . max(1, $limit)
);
$stmt->execute([$until, $oldest]);
$sent = 0;
foreach ($stmt->fetchAll() as $t) {
if (!filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue;
$token = bin2hex(random_bytes(20));
try {
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, sent_at) VALUES (?,?,?,?,?)')
->execute([$t['id'], $token, $t['assigned_staff_id'] ?: null, $t['department_id'] ?: null, db_now()]);
} catch (Throwable $e) {
continue; // parallel schon angelegt
}
$from = resolve_from_address($t['department_id'] ? (int) $t['department_id'] : null, $t['priority_id'] ? (int) $t['priority_id'] : null);
email_send_and_log((string) $t['requester_email'], 'Wie zufrieden waren Sie? [#' . $t['ticket_number'] . ']', email_wrap(csat_mail_html($t, $token)), $from, 'Zufriedenheitsabfrage', (string) $t['ticket_number']);
$sent++;
}
return $sent;
}
/**
* Auswertung für einen Zeitraum.
* @param array|null $deptIds null = alle Abteilungen
* @return array{sent:int,answered:int,avg:?float,rate:?float,dist:array<int,int>,byStaff:list<array>,recent:list<array>}
*/
function csat_stats(PDO $pdo, string $from, string $to, ?array $deptIds = null): array
{
$where = 'c.sent_at >= ? AND c.sent_at <= ?';
$args = [$from, $to];
if ($deptIds !== null) {
if (!$deptIds) return ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []];
$where .= ' AND c.department_id IN (' . implode(',', array_map('intval', $deptIds)) . ')';
}
$q = $pdo->prepare("SELECT COUNT(*) AS sent, SUM(CASE WHEN c.rating IS NOT NULL THEN 1 ELSE 0 END) AS answered, AVG(c.rating) AS avg FROM ticket_csat c WHERE $where");
$q->execute($args);
$r = $q->fetch() ?: [];
$sent = (int) ($r['sent'] ?? 0);
$answered = (int) ($r['answered'] ?? 0);
$dist = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0];
$d = $pdo->prepare("SELECT c.rating AS r, COUNT(*) AS n FROM ticket_csat c WHERE $where AND c.rating IS NOT NULL GROUP BY c.rating");
$d->execute($args);
foreach ($d->fetchAll() as $row) $dist[(int) $row['r']] = (int) $row['n'];
$b = $pdo->prepare("SELECT s.id, s.name, COUNT(*) AS n, AVG(c.rating) AS avg FROM ticket_csat c JOIN staff s ON s.id = c.staff_id WHERE $where AND c.rating IS NOT NULL GROUP BY s.id, s.name ORDER BY AVG(c.rating) DESC, COUNT(*) DESC");
$b->execute($args);
$byStaff = array_map(fn($x) => ['id' => (int) $x['id'], 'name' => (string) $x['name'], 'count' => (int) $x['n'], 'avg' => round((float) $x['avg'], 2)], $b->fetchAll());
$rc = $pdo->prepare("SELECT c.rating, c.comment, c.answered_at, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE $where AND c.rating IS NOT NULL ORDER BY c.answered_at DESC LIMIT 10");
$rc->execute($args);
return [
'sent' => $sent, 'answered' => $answered,
'avg' => $answered ? round((float) $r['avg'], 2) : null,
'rate' => $sent ? round($answered * 100 / $sent, 1) : null,
'dist' => $dist, 'byStaff' => $byStaff,
'recent' => array_map(fn($x) => ['rating' => (int) $x['rating'], 'comment' => (string) ($x['comment'] ?? ''), 'answeredAt' => (string) $x['answered_at'], 'number' => (string) $x['ticket_number'], 'subject' => (string) $x['subject']], $rc->fetchAll()),
];
}
/** „★★★★☆“ für Anzeigen. */
function csat_stars(?int $rating): string
{
$rating = max(0, min(5, (int) $rating));
return str_repeat('★', $rating) . str_repeat('☆', 5 - $rating);
}
+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)) {
$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
// (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') {
@@ -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'];
$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>';
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);
}
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;
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
$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
if ($departmentId !== null) {
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
if (!$staffIds) return;
}
$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);
// 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'] ?? ''))));
@@ -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. */
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';
$number = (string) ($ticket['ticket_number'] ?? '');
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
{
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
{
$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']);
return $types;
}
@@ -499,6 +502,7 @@ function fcm_wants(array $prefs, string $type): bool
function fcm_notify_overdue(array $ticket): void
{
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);
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
$to = fcm_recipients_for_department(get_db(), $dept ?: null);
@@ -550,3 +550,4 @@ if ($__assistantOn) require __DIR__ . '/assistant_widget.php';
})();
</script>
<?php endif; ?>
<script src="/assets/js/upload-compress.js?v=<?= @filemtime(__DIR__ . "/../assets/js/upload-compress.js") ?: time() ?>" defer></script>
+436 -72
View File
@@ -47,6 +47,13 @@ function check_honeypot(): void
}
}
/** Stimmt der Formular-Token mit dem der Sitzung überein? (ohne Abbruch, für Anmeldeseiten mit freundlicher Meldung) */
function csrf_valid(): bool
{
$token = $_POST['csrf_token'] ?? '';
return $token !== '' && hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function verify_csrf(): void
{
$token = $_POST['csrf_token'] ?? '';
@@ -215,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.
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
@@ -406,6 +636,18 @@ function staff_can_access_department(array $staff, int $departmentId): bool
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
{
if ($staff['role'] === 'admin') {
@@ -420,9 +662,56 @@ function staff_department_ids(array $staff): ?array
foreach ($stmt->fetchAll() as $row) {
$ids[] = (int) $row['department_id'];
}
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
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
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
@@ -523,6 +812,7 @@ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
$deletedByStaffId,
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']]);
}
@@ -551,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)? */
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
{
$list = (array) (config()['site']['trusted_proxies'] ?? []);
$list = trusted_proxy_ranges();
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
}
@@ -573,7 +893,12 @@ function client_ip(): string
{
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
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'] ?? ''))));
foreach ($chain as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
@@ -832,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()). */
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]);
}
@@ -870,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()). */
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]);
}
@@ -1036,6 +1361,16 @@ function get_allowed_extensions(): array
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). */
function get_max_upload_size(): int
{
@@ -1046,10 +1381,16 @@ function get_max_upload_size(): int
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'];
$allowed = get_allowed_extensions();
$allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed)));
$maxSize = get_max_upload_size();
if ($file['error'] !== UPLOAD_ERR_OK) {
@@ -1070,15 +1411,14 @@ function handle_ticket_upload(array $file): array
// Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
// Doppelte Endungen blockieren (z.B. evil.php.jpg)
$allParts = explode('.', strtolower($file['name']));
// Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain
// als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt.
$dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe',
'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt'];
foreach ($allParts as $part) {
if (in_array($part, $dangerousExts, true)) {
return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.'];
}
// Gespeichert wird unter einem Zufallsnamen mit nur der letzten Endung. Namen wie „www.example.com.pdf“ oder „scan.js.2024.jpg“
// sind daher harmlos und werden nicht abgelehnt; geprüft wird die tatsächliche Endung.
if (in_array($ext, $dangerousExts, true)) {
return ['error' => 'Dieser Dateityp (.' . $ext . ') ist aus Sicherheitsgründen nicht erlaubt: ' . $file['name']];
}
if (!in_array($ext, $allowed, true)) {
return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)];
@@ -1094,9 +1434,10 @@ function handle_ticket_upload(array $file): array
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (in_array($mimeType, $dangerousMimes, true)) {
return ['error' => 'Dieser Dateityp 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
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$uploadsDir = realpath(rtrim($cfg['path'], '/'));
@@ -1104,12 +1445,55 @@ function handle_ticket_upload(array $file): array
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
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 ['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).
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
function handle_ticket_uploads_multi(array $filesInput): array
@@ -1223,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),
* 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 '';
// Gefährliche Elemente komplett inkl. Inhalt entfernen
@@ -1308,7 +1710,15 @@ function sanitize_staff_html(string $html): string
$src = $attr($raw, 'src');
if ($src !== null) {
// 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) . '"';
} else {
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';
@@ -1507,7 +1917,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{
if (empty($ruleResult['close'])) return false;
$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)');
return true;
}
@@ -1800,60 +2214,7 @@ function add_business_hours(string $from, int $hours): string
// Rollen & Berechtigungen
// ====================================================================
/**
* 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',
];
}
// Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions())
/**
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
@@ -1888,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;
}
$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');
@@ -1922,8 +2284,10 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
// 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->execute([$mentionedId]);
$stmt->execute([$mailTo ? $mailTo[0] : 0]);
$mentioned = $stmt->fetch();
if ($mentioned) {
$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;
}
@@ -95,11 +95,15 @@ if ('serviceWorker' in navigator) {
<?php if (get_setting('kb_enabled', '1') === '1'): ?>
<a href="/kb.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a>
<?php endif; ?>
<?php if (status_page_enabled()): ?>
<a href="/status.php" class="<?= $activePage==='status'?'active':'' ?>">Status</a>
<?php endif; ?>
<?php if ($user): ?>
<a href="/logout.php">Abmelden</a>
<?php else: ?>
<a href="/login.php" class="<?= $activePage==='login'?'active':'' ?>">Anmelden</a>
<?php endif; ?>
<?= i18n_switcher_html() ?>
</nav>
</div>
+210 -113
View File
@@ -1,113 +1,210 @@
<?php
/**
* Systemüberwachung: erkennt, wenn der Mail-Abruf oder die Hintergrundjobs nicht mehr laufen,
* benachrichtigt Administratoren per E-Mail und liefert eine Liste von Problemen für die
* Weboberfläche und die Apps (Banner).
*/
/** Ab so vielen Fehlversuchen in Folge gilt ein Postfach als ausgefallen (bei 5-Minuten-Takt ≈ 15 Min.). */
const HEALTH_FAIL_THRESHOLD = 3;
/** Wiederholte Alarm-Mail frühestens nach so vielen Sekunden, solange der Fehler anhält. */
const HEALTH_ALERT_REPEAT = 86400;
/** E-Mail-Adressen aller aktiven Administratoren. */
function health_admin_emails(PDO $pdo): array
{
return $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/**
* Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg.
* Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine
* Entwarnung, wenn das Postfach wieder erreichbar ist.
*/
function health_record_mail_result(PDO $pdo, array $account, ?string $error): void
{
$now = db_now();
$label = $account['label'] ?: $account['username'];
if ($error === null) {
$wasAlerted = !empty($account['alert_sent_at']);
$pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]);
if ($wasAlerted) {
foreach (health_admin_emails($pdo) as $to) {
send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> funktioniert wieder.</p>');
}
}
return;
}
$fails = (int) ($account['fail_count'] ?? 0) + 1;
$pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]);
$lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0;
if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) {
$since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie';
$sent = false;
foreach (health_admin_emails($pdo) as $to) {
$sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> (' . e($account['host']) . ') schlägt seit '
. $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit <strong>nicht</strong> als Tickets angelegt.</p>'
. '<p>Letzter Fehler: <code>' . e($error) . '</code><br>Letzter erfolgreicher Abruf: ' . e($since) . '</p>'
. '<p>Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.</p>') || $sent;
}
// Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird
$pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]);
}
}
/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */
function health_record_send_result(bool $ok, string $error = ''): void
{
try {
if ($ok) { set_setting('mail_send_last_ok', db_now()); return; }
set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300));
} catch (Throwable $e) {
// Überwachung darf den Versand nie beeinträchtigen
}
}
/**
* Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...].
* Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar.
*/
function system_health(PDO $pdo): array
{
$issues = [];
// Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) {
$issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.'];
}
$accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll();
if ($accounts) {
if (!function_exists('imap_open')) {
$issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.'];
}
foreach ($accounts as $a) {
$label = $a['label'] ?: $a['username'];
$fails = (int) ($a['fail_count'] ?? 0);
if ($fails >= HEALTH_FAIL_THRESHOLD) {
$since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : '';
$issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')];
} elseif ($fails > 0) {
$issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')];
}
}
// Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus)
$last = get_setting('cron_fetch_emails_last_run', '');
if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') {
if ($last !== '' && (time() - strtotime($last)) > 1200) {
$issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?'];
}
}
}
$lastErr = (string) get_setting('mail_send_last_error', '');
if ($lastErr !== '') {
[$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, '');
$ok = (string) get_setting('mail_send_last_ok', '');
if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) {
$issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg];
}
}
return $issues;
}
<?php
/**
* Systemüberwachung: erkennt, wenn der Mail-Abruf oder die Hintergrundjobs nicht mehr laufen,
* benachrichtigt Administratoren per E-Mail und liefert eine Liste von Problemen für die
* Weboberfläche und die Apps (Banner).
*/
/** Ab so vielen Fehlversuchen in Folge gilt ein Postfach als ausgefallen (bei 5-Minuten-Takt ≈ 15 Min.). */
const HEALTH_FAIL_THRESHOLD = 3;
/** Wiederholte Alarm-Mail frühestens nach so vielen Sekunden, solange der Fehler anhält. */
const HEALTH_ALERT_REPEAT = 86400;
/** E-Mail-Adressen aller aktiven Administratoren. */
function health_admin_emails(PDO $pdo): array
{
return $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/**
* Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg.
* Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine
* Entwarnung, wenn das Postfach wieder erreichbar ist.
*/
function health_record_mail_result(PDO $pdo, array $account, ?string $error): void
{
$now = db_now();
$label = $account['label'] ?: $account['username'];
if ($error === null) {
$wasAlerted = !empty($account['alert_sent_at']);
$pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]);
if ($wasAlerted) {
foreach (health_admin_emails($pdo) as $to) {
send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> funktioniert wieder.</p>');
}
}
return;
}
$fails = (int) ($account['fail_count'] ?? 0) + 1;
$pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]);
$lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0;
if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) {
$since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie';
$sent = false;
foreach (health_admin_emails($pdo) as $to) {
$sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> (' . e($account['host']) . ') schlägt seit '
. $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit <strong>nicht</strong> als Tickets angelegt.</p>'
. '<p>Letzter Fehler: <code>' . e($error) . '</code><br>Letzter erfolgreicher Abruf: ' . e($since) . '</p>'
. '<p>Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.</p>') || $sent;
}
// Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird
$pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]);
}
}
/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */
function health_record_send_result(bool $ok, string $error = ''): void
{
try {
if ($ok) { set_setting('mail_send_last_ok', db_now()); return; }
set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300));
} catch (Throwable $e) {
// Überwachung darf den Versand nie beeinträchtigen
}
}
/**
* Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...].
* Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar.
*/
function system_health(PDO $pdo): array
{
$issues = [];
// Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) {
$issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.'];
}
$accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll();
if ($accounts) {
if (!function_exists('imap_open')) {
$issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.'];
}
foreach ($accounts as $a) {
$label = $a['label'] ?: $a['username'];
$fails = (int) ($a['fail_count'] ?? 0);
if ($fails >= HEALTH_FAIL_THRESHOLD) {
$since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : '';
$issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')];
} elseif ($fails > 0) {
$issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')];
}
}
// Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus)
$last = get_setting('cron_fetch_emails_last_run', '');
if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') {
if ($last !== '' && (time() - strtotime($last)) > 1200) {
$issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?'];
}
}
}
$lastErr = (string) get_setting('mail_send_last_error', '');
if ($lastErr !== '') {
[$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, '');
$ok = (string) get_setting('mail_send_last_ok', '');
if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) {
$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;
}
// ── Fehler-Mail bei schweren PHP-Fehlern (wie bei WordPress) ───────────────────
/** Fehlerarten, die eine Seite abbrechen. */
const ERROR_ALERT_FATAL = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
/** Dieselbe Meldung frühestens nach so vielen Sekunden erneut mailen. */
const ERROR_ALERT_REPEAT = 3600;
/** Höchstens so viele Fehler-Mails pro Stunde insgesamt (schützt das Postfach bei einem Fehler auf jeder Seite). */
const ERROR_ALERT_MAX_PER_HOUR = 10;
/** Empfänger: eigene Adresse aus den Einstellungen, sonst Admin-Adresse, sonst alle aktiven Administratoren. */
function error_alert_recipients(PDO $pdo): array
{
$own = trim((string) get_setting('error_alert_email', ''));
if ($own !== '') return array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $own) ?: []), fn($m) => filter_var($m, FILTER_VALIDATE_EMAIL)));
$admin = trim((string) get_setting('admin_email', '') ?: (string) (config()['site']['admin_email'] ?? ''));
if (filter_var($admin, FILTER_VALIDATE_EMAIL) && $admin !== 'support@example.com') return [$admin];
return health_admin_emails($pdo);
}
/** Betreff und Text der Mail. Nur Pfad der Adresse (keine Parameter, dort können Zugangscodes stehen), keine Formular- oder Cookie-Daten. */
function error_alert_compose(array $err, array $context = []): array
{
$root = str_replace('\\', '/', dirname(__DIR__)) . '/';
$file = str_replace($root, '', str_replace('\\', '/', (string) ($err['file'] ?? '')));
$msg = mb_substr(str_replace($root, '', str_replace('\\', '/', (string) ($err['message'] ?? ''))), 0, 1500);
$site = (string) get_setting('display_name', 'DeskHub');
$path = (string) ($context['path'] ?? (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '(Hintergrundaufruf)'));
$headline = !empty($err['headline']) ? e((string) $err['headline']) : 'Auf <strong>' . e($site) . '</strong> ist ein schwerer Fehler aufgetreten. Die aufgerufene Seite konnte nicht angezeigt werden.';
$html = '<p>' . $headline . '</p>'
. '<p><strong>Fehler:</strong><br><code>' . nl2br(e($msg)) . '</code></p>'
. '<p><strong>Datei:</strong> ' . e($file) . ((int) ($err['line'] ?? 0) > 0 ? ', Zeile ' . (int) $err['line'] : '') . '<br>'
. '<strong>Seite:</strong> ' . e($path) . '<br>'
. '<strong>Zeit:</strong> ' . e(date('d.m.Y H:i:s')) . '<br>'
. '<strong>Version:</strong> DeskHub ' . e((string) (defined('DESKHUB_VERSION') ? DESKHUB_VERSION : '?')) . ', PHP ' . e(PHP_VERSION) . '</p>'
. '<p style="color:#666;font-size:12px">Dieselbe Meldung wird höchstens einmal pro Stunde gesendet. Ausschalten oder Empfänger ändern: Einstellungen → Allgemein.</p>';
return [!empty($err['subject']) ? "{$site}: " . (string) $err['subject'] : "{$site}: Schwerer Fehler auf der Webseite", $html];
}
/**
* Meldet einen Fehler, der die Seite nicht abbricht, aber Aufmerksamkeit braucht (zum Beispiel ein Plugin, das Fehler wirft, oder ein
* fehlgeschlagener Hintergrundjob). Gleiche Ein-/Ausschalter, Empfänger und Drosselung wie bei schweren Fehlern.
*/
function error_alert_notice(string $subject, string $headline, string $where, string $message): void
{
try {
error_alert_dispatch(get_db(), ['type' => E_USER_WARNING, 'message' => $message, 'file' => $where, 'line' => 0, 'subject' => $subject, 'headline' => $headline]);
} catch (Throwable $e) {
error_log('error_alert_notice: ' . $e->getMessage());
}
}
/**
* Prüft Ein/Aus und Drosselung und verschickt die Mail. $send ist nur für Tests austauschbar (Standard: send_mail).
* Liefert die Zahl der Empfänger, an die gesendet wurde.
*/
function error_alert_dispatch(PDO $pdo, array $err, ?callable $send = null): int
{
if (get_setting('error_alert', '1') !== '1') return 0;
$fp = sha1(preg_replace('/\d+/', '#', (string) ($err['file'] ?? '') . ':' . substr((string) ($err['message'] ?? ''), 0, 200)));
$now = time();
$log = json_decode((string) get_setting('error_alert_log', '[]'), true);
$log = array_values(array_filter(is_array($log) ? $log : [], fn($r) => is_array($r) && $now - (int) ($r[0] ?? 0) < 86400));
$lastHour = array_filter($log, fn($r) => $now - (int) $r[0] < 3600);
if (count($lastHour) >= ERROR_ALERT_MAX_PER_HOUR) return 0;
foreach ($lastHour as $r) if (($r[1] ?? '') === $fp && $now - (int) $r[0] < ERROR_ALERT_REPEAT) return 0;
// erst merken, dann senden: Schlägt das Senden selbst fehl, folgt keine Endlosschleife
$log[] = [$now, $fp];
set_setting('error_alert_log', json_encode($log));
[$subject, $html] = error_alert_compose($err);
$sent = 0;
foreach (error_alert_recipients($pdo) as $to) {
if (($send ?? $GLOBALS['error_alert_send'] ?? 'send_mail')($to, $subject, $html)) $sent++;
}
return $sent;
}
/** Wird am Ende jeder Anfrage aufgerufen: war es ein schwerer Fehler, geht eine Mail an den Administrator. */
function error_alert_shutdown(): void
{
static $running = false;
$err = error_get_last();
if ($running || !$err || !in_array($err['type'], ERROR_ALERT_FATAL, true)) return;
$running = true;
try {
error_alert_dispatch(get_db(), $err);
} catch (Throwable $e) {
error_log('error_alert: ' . $e->getMessage());
}
}
register_shutdown_function('error_alert_shutdown');
+411
View File
@@ -0,0 +1,411 @@
<?php
/**
* includes/i18n.php
*
* Mehrsprachigkeit. Der Quelltext von DeskHub ist deutsch; jede weitere Sprache kommt als Plugin ("Sprachpaket"), das nur Daten enthält:
*
* plugin.json {"name":"English","version":"1.0.0","entry":"plugin.php","language":{"code":"en","name":"English","flag":"flag.svg","strings":"strings.json","htmlLang":"en"}}
* plugin.php (leer; wird nur verlangt, weil jedes Plugin eine Startdatei hat)
* flag.svg Flagge (SVG oder PNG, höchstens 40 KB) – sie erscheint im Sprachwechsler
* strings.json {"Neues Ticket": "New ticket", "Willkommen, {}": "Welcome, {}", ...}
*
* Übersetzt wird die fertige Seite: Textstücke zwischen den HTML-Tags, Attribute wie placeholder/title/alt, Beschriftungen von Knöpfen und
* Textliteralen in confirm()/alert(). Ein Schlüssel muss genau dem deutschen Text entsprechen (Leerraum zählt nicht). "{}" steht für
* wechselnde Teile ("{#}" nur für Zahlen) ("Willkommen, {}"); "{1}", "{2}" in der Übersetzung erlauben eine andere Reihenfolge. Was nicht übersetzt ist, bleibt deutsch.
*
* Sprache wählen: Cookie "deskhub_lang" (gesetzt über /lang.php, dem Klick auf eine Flagge), sonst die Standardsprache (Einstellung
* "default_language", sonst Deutsch). Ist Deutsch gewählt und der Sammelmodus aus, läuft nichts davon: die Seiten werden unverändert ausgeliefert.
*
* Sammelmodus (Adminportal → Sprachen): merkt sich die deutschen Texte der besuchten Seiten, daraus entsteht eine Vorlage für Übersetzer.
*/
const I18N_COOKIE = 'deskhub_lang';
const I18N_FLAG_MAX_BYTES = 40960;
const I18N_MAX_STRINGS_BYTES = 2 * 1024 * 1024;
/** Eingebaute Flagge für Deutsch (Quellsprache; sie ist immer verfügbar, auch ohne Plugin). */
function i18n_builtin_flag_de(): string
{
return 'data:image/svg+xml;base64,' . base64_encode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1"/></svg>');
}
/**
* Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete.
* @return array<string,array{code:string,name:string,flag:string,htmlLang:string,slug:string,strings:string}> Schlüssel = Sprachcode
*/
function i18n_languages(bool $refresh = false): array
{
static $cache = null;
if ($cache !== null && !$refresh) return $cache;
$langs = ['de' => ['code' => 'de', 'name' => 'Deutsch', 'flag' => i18n_builtin_flag_de(), 'htmlLang' => 'de', 'slug' => '', 'strings' => '']];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return $cache = $langs;
$rows = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $cache = $langs;
}
foreach ($rows as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
if (!is_file($dir . '/plugin.json')) continue;
$raw = json_decode((string) file_get_contents($dir . '/plugin.json'), true);
$l = is_array($raw) && is_array($raw['language'] ?? null) ? $raw['language'] : null;
if (!$l) continue;
[$manifest] = plugin_read_manifest($dir);
if (!$manifest) continue;
$code = strtolower((string) ($l['code'] ?? ''));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) continue;
$flag = '';
$flagFile = (string) ($l['flag'] ?? '');
if ($flagFile !== '' && plugin_package_path_ok($flagFile) && is_file($dir . '/' . $flagFile) && filesize($dir . '/' . $flagFile) <= I18N_FLAG_MAX_BYTES) {
$ext = strtolower(pathinfo($flagFile, PATHINFO_EXTENSION));
$mime = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'][$ext] ?? '';
if ($mime !== '') $flag = 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($dir . '/' . $flagFile));
}
$stringsFile = (string) ($l['strings'] ?? 'strings.json');
if (!plugin_package_path_ok($stringsFile) || strtolower(pathinfo($stringsFile, PATHINFO_EXTENSION)) !== 'json') $stringsFile = '';
$langs[$code] = [
'code' => $code,
'name' => mb_substr(trim((string) ($l['name'] ?? $code)), 0, 40) ?: $code,
'flag' => $flag !== '' ? $flag : ($code === 'de' ? i18n_builtin_flag_de() : ''),
'htmlLang' => preg_match('/^[A-Za-z\-]{2,12}$/', (string) ($l['htmlLang'] ?? '')) ? (string) $l['htmlLang'] : $code,
'slug' => $slug,
'strings' => $stringsFile !== '' ? $dir . '/' . $stringsFile : '',
];
}
return $cache = $langs;
}
/** Standardsprache der Seite (Einstellung), sonst Deutsch. */
function i18n_default(): string
{
$d = strtolower((string) get_setting('default_language', 'de'));
return isset(i18n_languages()[$d]) ? $d : 'de';
}
/** Sprache dieser Anfrage: Cookie, sonst Standardsprache. */
function i18n_current(): string
{
$c = strtolower((string) ($_COOKIE[I18N_COOKIE] ?? ''));
return $c !== '' && isset(i18n_languages()[$c]) ? $c : i18n_default();
}
/**
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int}
*/
function i18n_dictionary(string $code, bool $reload = false): array
{
static $cache = [];
if ($reload) unset($cache[$code]);
if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$files = [];
// 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);
if (!is_array($data)) return;
foreach ($data as $key => $value) {
$key = (string) $key;
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
$norm = i18n_normalize($key);
if (str_contains($norm, '{}') || str_contains($norm, '{#}')) {
// {} = beliebiger Text, {#} = nur Ziffern
$pieces = preg_split('/(\{#\}|\{\})/', $norm, -1, PREG_SPLIT_DELIM_CAPTURE);
$regex = '/^';
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
$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['pkeys'][] = $norm;
$dict['count']++;
} else {
if (!isset($dict['exact'][$norm])) $dict['count']++;
$dict['exact'][$norm] = $value;
}
}
}
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
function i18n_normalize(string $text): string
{
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
/** Übersetzt einen (normalisierten) Text; null = keine Übersetzung bekannt. */
function i18n_lookup(string $norm, array $dict): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $value]) {
if (preg_match($regex, $norm, $m)) {
$value = preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1]] ?? '', $value);
$i = 0;
return preg_replace_callback('/\{\}/', function () use ($m, &$i) { $i++; return $m[$i] ?? ''; }, (string) $value);
}
}
return null;
}
/** Texte, die nicht übersetzt werden müssen: Zahlen, Adressen, Zeiten, reine Zeichen. */
function i18n_translatable(string $norm): bool
{
if (mb_strlen($norm) < 2 || mb_strlen($norm) > 400) return false;
if (!preg_match('/\p{L}{2}/u', $norm)) return false;
if (preg_match('#^(https?://|/|[\w.+-]+@[\w-]+\.)#i', $norm)) return false;
return true;
}
/**
* Übersetzt eine HTML-Seite. $seen sammelt (wenn übergeben) die deutschen Texte, für die es keine Übersetzung gab.
* @param array<string,bool>|null $seen
*/
function i18n_translate_html(string $html, string $code, ?array &$seen = null): string
{
$dict = $code === 'de' ? ['exact' => [], 'patterns' => [], 'count' => 0] : i18n_dictionary($code);
$collect = $seen !== null;
if (!$collect && !$dict['count']) return $html;
$note = function (string $norm) use (&$seen, $collect): void {
if ($collect && i18n_translatable($norm)) $seen[$norm] = true;
};
$tr = function (string $norm) use ($dict, $note): ?string {
$t = i18n_lookup($norm, $dict);
if ($t === null) $note($norm);
return $t;
};
// JavaScript: Textliterale, die genau einem Schlüssel entsprechen
$jsLiterals = function (string $js, bool $collectCalls) use ($dict, $note): string {
return (string) preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($m) use ($dict, $note) {
$raw = str_replace(["\\'", '\\"', '\\\\', '\\n'], ["'", '"', '\\', ' '], $m[2]);
$norm = trim((string) preg_replace('/\s+/u', ' ', $raw));
if ($norm === '') return $m[0];
$t = i18n_lookup($norm, $dict);
return $t === null ? $m[0] : json_encode($t, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}, $js);
};
$jsCollect = function (string $js) use ($note, $dict): void {
if (preg_match_all('/(?:confirm|alert|prompt|toast)\(\s*(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', $js, $mm)) {
foreach ($mm[2] as $lit) {
$norm = trim((string) preg_replace('/\s+/u', ' ', str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $lit)));
if (i18n_lookup($norm, $dict) === null) $note($norm); // schon Übersetztes nicht noch einmal sammeln
}
}
};
// Attribute eines Tags
$tag = function (string $t) use ($tr, $dict, $note, $jsCollect): string {
$isButton = (bool) preg_match('/^<input\b[^>]*\btype\s*=\s*["\']?(submit|button|reset)\b/i', $t);
return (string) preg_replace_callback('/\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(")([^"]*)"|\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(\')([^\']*)\'/i', function ($m) use ($tr, $dict, $isButton, $jsCollect) {
$name = strtolower($m[1] !== '' ? $m[1] : $m[4]);
$quote = $m[1] !== '' ? $m[2] : $m[5];
$val = $m[1] !== '' ? $m[3] : $m[6];
if ($name === 'value' && !$isButton) return $m[0];
if (str_starts_with($name, 'on')) {
$js = html_entity_decode($val, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$jsCollect($js);
$new = preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($x) use ($dict) {
$raw = str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $x[2]);
$t = i18n_lookup(trim((string) preg_replace('/\s+/u', ' ', $raw)), $dict);
return $t === null ? $x[0] : "'" . addcslashes($t, "'\\") . "'";
}, $js);
return $new === $js ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars((string) $new, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}
$norm = i18n_normalize($val);
$t = $tr($norm);
return $t === null ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars($t, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}, $t);
};
$parts = preg_split('/(<!--.*?-->|<script\b.*?<\/script>|<style\b.*?<\/style>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) return $html;
$out = '';
$skip = false; // innerhalb <textarea> stehen Eingaben, keine Beschriftungen
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 0) { // Text
if ($skip || trim($part) === '') { $out .= $part; continue; }
if (!preg_match('/^(\s*)(.*?)(\s*)$/su', $part, $m)) { $out .= $part; continue; }
$t = $tr(i18n_normalize($m[2]));
$out .= $t === null ? $part : $m[1] . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . $m[3];
continue;
}
if ($part[0] === '<' && $part[1] === '!') { $out .= $part; continue; } // Kommentar / doctype
if (stripos($part, '<script') === 0) {
if (preg_match('/^(<script\b[^>]*>)(.*)(<\/script>)$/is', $part, $m) && !preg_match('/\bsrc\s*=/i', $m[1])) {
if ($collect) $jsCollect($m[2]);
$out .= $m[1] . ($dict['count'] ? $jsLiterals($m[2], $collect) : $m[2]) . $m[3];
} else {
$out .= $part;
}
continue;
}
if (stripos($part, '<style') === 0) { $out .= $part; continue; }
if (stripos($part, '<textarea') === 0) $skip = true;
elseif (stripos($part, '</textarea') === 0) $skip = false;
if (stripos($part, '<html') === 0 && $code !== 'de') {
$lang = i18n_languages()[$code]['htmlLang'] ?? $code;
$part = preg_match('/\blang\s*=/i', $part) ? (string) preg_replace('/\blang\s*=\s*(["\'])[^"\']*\1/i', 'lang="' . $lang . '"', $part, 1) : (string) preg_replace('/^<html/i', '<html lang="' . $lang . '"', $part, 1);
}
$out .= $tag($part);
}
return $out;
}
/** Ausgabe-Filter der Seite (ob_start-Callback): nur HTML-Antworten werden angefasst. */
function i18n_output_filter(string $buffer, int $phase = 0)
{
try {
foreach (headers_list() as $h) {
if (stripos($h, 'content-type:') === 0 && stripos($h, 'text/html') === false) return false;
}
if (strlen($buffer) < 20 || !preg_match('/<[a-z!]/i', $buffer) || strlen($buffer) > 6 * 1024 * 1024) return false;
$code = $GLOBALS['__i18n_code'] ?? 'de';
$seen = null;
if (get_setting('i18n_collect', '0') === '1') $seen = [];
$html = i18n_translate_html($buffer, $code, $seen);
if ($seen) i18n_remember(array_keys($seen));
return $html;
} catch (Throwable $e) {
return false;
}
}
/** Merkt sich unübersetzte Texte (Sammelmodus). Höchstens 6000 Einträge. */
function i18n_remember(array $texts): void
{
$pdo = get_db();
$texts = array_slice(array_values(array_unique(array_map(fn($t) => mb_substr((string) $t, 0, 400), $texts))), 0, 400);
if (!$texts) return;
$hashes = array_map('md5', $texts);
$in = implode(',', array_fill(0, count($hashes), '?'));
$have = $pdo->prepare("SELECT text_hash FROM i18n_missing WHERE text_hash IN ($in)");
$have->execute($hashes);
$known = array_flip($have->fetchAll(PDO::FETCH_COLUMN));
$room = 6000 - (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
$ins = $pdo->prepare('INSERT INTO i18n_missing (text_hash, text, hits, last_seen) VALUES (?,?,1,?)');
$upd = $pdo->prepare('UPDATE i18n_missing SET hits = hits + 1, last_seen = ? WHERE text_hash = ?');
foreach ($texts as $k => $t) {
if (isset($known[$hashes[$k]])) { $upd->execute([db_now(), $hashes[$k]]); continue; }
if ($room-- <= 0) break;
try { $ins->execute([$hashes[$k], $t, db_now()]); } catch (Throwable $e) { }
}
}
/** Startet die Übersetzung dieser Anfrage (wird von bootstrap.php aufgerufen). */
function i18n_start(): void
{
if (PHP_SAPI === 'cli' || defined('DESKHUB_NO_I18N')) return;
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
if (str_starts_with($path, '/api/') || str_ends_with($path, '/lang.php')) return;
$code = i18n_current();
$GLOBALS['__i18n_code'] = $code;
if ($code === 'de' && get_setting('i18n_collect', '0') !== '1') return;
ob_start('i18n_output_filter');
}
/** Pfad für „zurück“ nach dem Sprachwechsel: nur Adressen dieser Seite. */
function i18n_safe_back(string $back): string
{
if ($back === '' || $back[0] !== '/' || str_starts_with($back, '//') || str_contains($back, '\\') || preg_match('/[\r\n\0]/', $back)) return '/';
return $back;
}
/**
* Sprachwechsler mit Flaggen. Leer, solange es nur eine Sprache gibt.
* $variant: "inline" (helle Leiste), "dark" (dunkle Seitenleiste) oder "floating" (feste Position oben rechts, für Anmeldeseiten).
*/
function i18n_switcher_html(string $variant = 'inline'): string
{
$langs = i18n_languages();
if (count($langs) < 2) return '';
$cur = $GLOBALS['__i18n_code'] ?? i18n_current();
$back = rawurlencode((string) ($_SERVER['REQUEST_URI'] ?? '/'));
$wrapStyle = 'display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;' . ($variant === 'floating' ? 'position:fixed;bottom:14px;right:14px;z-index:9999;background:rgba(255,255,255,.88);padding:5px 8px;border-radius:20px;box-shadow:0 1px 4px rgba(0,0,0,.18)' : '');
$html = '<span class="lang-switch" role="group" aria-label="Language" style="' . $wrapStyle . '">';
foreach ($langs as $code => $l) {
$on = $code === $cur;
$inner = $l['flag'] !== ''
? '<img src="' . htmlspecialchars($l['flag'], ENT_QUOTES) . '" alt="' . htmlspecialchars($l['name'], ENT_QUOTES) . '" width="24" height="16" style="display:block;width:24px;height:16px;object-fit:cover;border-radius:3px">'
: '<span style="font-size:.75rem;font-weight:700">' . htmlspecialchars(strtoupper($code)) . '</span>';
$html .= '<a href="/lang.php?l=' . rawurlencode($code) . '&amp;back=' . $back . '" hreflang="' . htmlspecialchars($l['htmlLang'], ENT_QUOTES) . '" title="' . htmlspecialchars($l['name'], ENT_QUOTES) . '"'
. ' style="display:inline-block;line-height:0;padding:2px;border-radius:5px;text-decoration:none;opacity:' . ($on ? '1' : '.6') . ';outline:' . ($on ? '2px solid ' . ($variant === 'dark' ? '#8bc4c2' : 'var(--primary,#0f6f6a)') : 'none') . ';outline-offset:1px"'
. ($on ? ' aria-current="true"' : '') . '>' . $inner . '</a>';
}
return $html . '</span>';
}
+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');
} else {
$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']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', $staffId);
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
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;
}
// ── 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,
* 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'] ?? ''));
$subject = trim((string) ($in['subject'] ?? ''));
$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 (!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 ($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);
$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');
$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();
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
->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'));
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;
}
/** 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
* 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;
}
+239 -55
View File
@@ -15,13 +15,35 @@ function smtp_last_error(): string
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 = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
// Adressen mit der reservierten Endung .invalid (z. B. von Discord- oder Telegram-Nutzern ohne E-Mail) sind nie zustellbar: nichts senden, kein Fehler
if (preg_match('/@[^@\\s]+\\.invalid$/i', trim($to))) return true;
$smtpHost = get_setting('smtp_host', '');
$fromHeader = $fromOverride ?: smtp_default_from();
@@ -43,6 +65,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
(string) $own['smtp_username'],
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
);
mail_attempted(true);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
@@ -60,6 +83,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
get_setting('smtp_username', ''),
decrypt_secret(get_setting('smtp_password', '')),
);
mail_attempted(true);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
@@ -70,9 +94,13 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
}
$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 .= "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);
}
@@ -167,70 +195,118 @@ function render_email_template(string $key, array $vars, string $defaultSubject,
// Einheitliches, modernes E-Mail-Design
// ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
// Bausteine für die Standardtexte der einzelnen Vorlagen.
// angepasst) mit demselben Rahmen, damit alle System-Mails gleich aussehen.
// email_badge()/email_button()/email_p() sind wiederverwendbare Bausteine.
// 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
{
$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
<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" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="background:#0f6f6a;padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span>
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
{$innerHtml}
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;">
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p>
</td>
</tr>
</table>
</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#eef2f6" style="background:#eef2f6;">
<tr>
<td align="center" style="padding:28px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td bgcolor="#0f6f6a" style="background:#0f6f6a;border-radius:14px 14px 0 0;padding:24px 36px;">
{$brand}
</td>
</tr>
<tr>
<td height="4" bgcolor="#14958d" style="background:#14958d;font-size:0;line-height:0;height:4px;">&nbsp;</td>
</tr>
<tr>
<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;">
{$innerHtml}
</td>
</tr>
<tr>
<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;
}
/** 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
{
$font = EMAIL_FONT;
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>
<td style="background:{$bg};border-radius:8px;padding:14px 18px;">
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div>
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
<td width="5" bgcolor="{$color}" style="background:{$color};width:5px;font-size:0;line-height:0;">&nbsp;</td>
<td bgcolor="{$bg}" style="background:{$bg};padding:16px 20px;font-family:{$font};">
<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>
</tr>
</table>
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
{
$font = EMAIL_FONT;
$plain = trim(strip_tags($label));
$w = max(180, (int) round(mb_strlen($plain) * 9.2 + 64));
return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:6px 0 4px;">
<tr>
<td style="background:{$color};border-radius:8px;">
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a>
<td align="center" bgcolor="{$color}" style="background:{$color};border-radius:10px;">
<!--[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>
</tr>
</table>
HTML;
}
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
/** Normaler Absatz. */
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>';
}
/**
@@ -240,25 +316,28 @@ function email_p(string $html, string $marginBottom = '16px'): 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_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('<strong>Betreff:</strong> {subject}', '24px')
. 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
{
return email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:')
. '<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>'
return email_h('Neue Antwort zu Ihrem Ticket')
. email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:', '18px')
. email_quote('{message}')
. email_button('Ticket ansehen', '{ticket_url}');
}
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_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '6px')
@@ -268,7 +347,8 @@ function email_new_ticket_department_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_p('<strong>Betreff:</strong> {subject}', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '24px')
@@ -277,8 +357,9 @@ function email_ticket_overdue_default_body(): string
function email_live_chat_transcript_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
return email_h('Ihr Chat-Verlauf')
. 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}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}');
@@ -287,9 +368,10 @@ function email_live_chat_transcript_default_body(): string
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
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))
. '<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);
}
@@ -321,10 +403,11 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
function email_customer_reminder_default_body(): string
{
return '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>'
. '<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>'
return email_h('Brauchen Sie noch Hilfe?')
. 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}'
. '<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
@@ -385,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
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
$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'))
: $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
@@ -431,7 +514,7 @@ function notify_staff_new_ticket(array $ticket): void
if ($deptId) {
$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'
OR department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
@@ -439,9 +522,10 @@ function notify_staff_new_ticket(array $ticket): void
);
$stmt->execute([$deptId, $deptId]);
} 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;
[$subject, $body] = render_email_template('new_ticket_department', [
@@ -462,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 */
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;
[$subject, $body] = render_email_template('ticket_overdue', [
@@ -477,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']);
}
}
// ── 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
$pdo = get_db();
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');
} catch (Throwable $e) {
// 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();
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]);
// 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;
$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>'
. e($team['name']) . '</strong> zugewiesen.</p>';
foreach ($members->fetchAll() as $m) {
foreach ($memberRows as $m) {
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
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;
}
+234 -17
View File
@@ -32,14 +32,17 @@ function plugin_known_permissions(): array
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'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',
'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). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], '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;
}
@@ -118,15 +121,17 @@ function plugin_permission_allowed(?string $permission): bool
function plugin_hook_permission(string $hook): ?string
{
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'cron.run') return 'hooks.cron';
if ($hook === 'cron.run' || $hook === 'cron.minute') return 'hooks.cron';
return null;
}
/** Erforderliche Berechtigung für einen Filter. */
function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar') 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 ($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
return null;
}
@@ -158,13 +163,60 @@ function plugin_register_menu(string $label, string $page, ?string $permission =
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return;
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && isset(plugin_parent_pages()[$belongsTo])) ? $belongsTo : null];
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && ($belongsTo === 'admin' || isset(plugin_parent_pages()[$belongsTo]))) ? $belongsTo : null];
}
/**
* Eigener API-Endpunkt: GET/POST /api/staff/v1/plugin.php?p=<slug>&endpoint=<name> (Bearer-Token wie die übrige Mitarbeiter-API).
* Der Handler erhält ($staff, $request) und gibt ein Array zurück, das als JSON ausgeliefert wird.
*/
/**
* Registriert eine öffentliche Webhook-Adresse: POST /api/public/v1/plugin_webhook.php?p=<slug>&e=<name>. Gedacht für Dienste, die selbst
* Anfragen schicken (Discord, Telegram, Zahlungsanbieter). Die Adresse ist ohne Anmeldung erreichbar; das Plugin MUSS die Echtheit der Anfrage
* selbst prüfen (Signatur oder geheimes Token) und ungültige Anfragen mit Status 401 beantworten, bevor es etwas tut.
* Der Handler erhält ['method', 'headers' (Namen klein), 'body' (roher Text, höchstens 256 KB), 'query', 'ip'] und gibt
* ['status' => 200, 'json' => [...]] oder ['status' => 200, 'body' => 'Text', 'headers' => ['Content-Type' => '...']] zurück.
*/
function plugin_register_webhook(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('api.public')) return;
$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
{
if (!plugin_permission_allowed('api.endpoints')) return;
@@ -216,10 +268,21 @@ function plugin_record_failure(string $slug, string $message): void
$pdo = get_db();
$pdo->prepare('UPDATE plugins SET fail_count = fail_count + 1, last_error = ? WHERE slug = ?')->execute([mb_substr($message, 0, 500), $slug]);
$fails = (int) $pdo->query('SELECT fail_count FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn();
if ($fails >= PLUGIN_FAIL_LIMIT) {
$disabled = $fails >= PLUGIN_FAIL_LIMIT;
if ($disabled) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
// Mail an den Administrator (gleiche Drosselung wie bei schweren Fehlern: dieselbe Meldung höchstens einmal pro Stunde)
if (function_exists('error_alert_notice')) {
$name = (string) ($pdo->query('SELECT name FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn() ?: $slug);
error_alert_notice(
$disabled ? "Plugin „{$name}“ wurde wegen Fehlern deaktiviert" : "Plugin „{$name}“ meldet einen Fehler",
$disabled ? "Das Plugin „{$name}“ ({$slug}) hat {$fails} Fehler verursacht und wurde automatisch deaktiviert. Die Webseite selbst läuft weiter. Die Funktionen des Plugins stehen erst wieder zur Verfügung, wenn Sie es unter Plugins erneut aktivieren." : "Das Plugin „{$name}“ ({$slug}) hat einen Fehler gemeldet (Fehler {$fails} von " . PLUGIN_FAIL_LIMIT . '). Nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern wird es automatisch deaktiviert.',
'plugins/' . $slug . ($disabled ? ' (deaktiviert)' : ''),
$message
);
}
} catch (Throwable $e) {
// Die Fehlerbehandlung selbst darf nie einen weiteren Fehler auslösen.
}
@@ -261,7 +324,7 @@ function plugins_boot(): void
if (plugins_globally_disabled()) return;
try {
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok, package_hash FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
} catch (Throwable $e) {
return; // Tabelle fehlt (Datenbank noch nicht aktualisiert)
}
@@ -298,6 +361,7 @@ function plugins_boot(): void
continue;
}
$state['perms'][$slug] = $manifest['permissions'];
if (in_array('db.migrations', $manifest['permissions'], true)) plugin_ensure_migrated($slug, (string) ($row['package_hash'] ?? ''));
plugin_guard($slug, function () use ($dir, $manifest, $slug, &$state) {
require $dir . '/' . $manifest['entry']; // einmal pro Request (plugins_boot läuft nur einmal)
$state['loaded'][$slug] = $manifest;
@@ -305,6 +369,38 @@ function plugins_boot(): void
}
}
/**
* Spielt neue Datenbank-Änderungen eines Plugins ein, wenn sich sein Paket seit dem letzten Mal geändert hat (zum Beispiel nach einem Update
* des aktiven Plugins; sonst liefen sie erst bei der nächsten Aktivierung). Läuft vor dem Laden des Plugins, höchstens einmal je Paketstand.
*/
function plugin_ensure_migrated(string $slug, string $packageHash): void
{
if ($packageHash === '') return;
$key = 'plugin_migrated.' . $slug;
try {
if (get_setting($key, '') === $packageHash) return;
$res = plugin_run_migrations(get_db(), $slug);
if ($res['errors']) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . implode('; ', $res['errors']));
return;
}
set_setting($key, $packageHash);
} catch (Throwable $e) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . $e->getMessage());
}
}
/** Meldung nach Installation oder Update eines Plugins. @param array $res Ergebnis von plugin_install_zip(), $row Zeile aus plugins_installed() */
function plugin_install_message(array $res, array $row): string
{
$sig = $res['signed'] ? 'Signatur geprüft' : 'nicht signiert';
if (!$res['updated']) return "{$res['name']} {$res['version']} installiert ({$sig}). Es ist noch nicht aktiv.";
$msg = "{$res['name']} auf Version {$res['version']} aktualisiert ({$sig}).";
if ($res['needs_reapproval']) return $msg . ' Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $res['needs_reapproval']) . '). Das Plugin ist nicht aktiv: Bitte prüfen und beim Aktivieren bestätigen.';
if (!empty($row['is_active'])) return $msg . ' Das Plugin läuft weiter, neue Datenbank-Änderungen werden beim nächsten Aufruf automatisch eingespielt.';
return $msg . ' Es ist weiterhin nicht aktiv.';
}
/** Menüpunkte der geladenen Plugins, gefiltert nach Rechten des Mitarbeiters. */
function plugin_menu_items(?array $staff): array
{
@@ -317,6 +413,22 @@ function plugin_menu_items(?array $staff): array
return $items;
}
/**
* Menüpunkte im Adminportal (Gruppe „Plugins“): Plugins registrieren sie mit plugin_register_menu(…, 'admin'). Nur für echte Administratoren;
* ist zusätzlich eine Berechtigung angegeben, muss sie auch erfüllt sein.
*/
function plugin_admin_menu_items(?array $staff): array
{
$items = [];
if (!$staff || ($staff['role'] ?? '') !== 'admin') return $items;
foreach (plugin_state()['menus'] as $m) {
if (($m['belongs_to'] ?? null) !== 'admin') continue;
if ($m['permission'] !== null && !has_permission($staff, $m['permission'])) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/** Seiten, unter denen Plugins Reiter anbieten dürfen: Schlüssel => [Bezeichnung des ersten Reiters, Adresse]. */
function plugin_parent_pages(): array
{
@@ -467,31 +579,38 @@ function plugin_catalog_url(): string
}
/**
* Katalog mit Zwischenspeicher (1 Stunde), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss.
* $refresh = true erzwingt einen neuen Abruf. Fehler werden nicht zwischengespeichert.
* Katalog mit kurzem Zwischenspeicher (Standard 5 Minuten), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss und trotzdem
* (fast) immer aktuell ist. $refresh = true erzwingt einen neuen Abruf, $maxAge legt fest, wie alt der Zwischenspeicher höchstens sein darf.
* Ist der Katalog-Server gerade nicht erreichbar, zeigt die Seite den letzten Stand und fragt zwei Minuten lang nicht erneut, statt bei jedem
* Aufruf zu warten.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int}
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int, stale?:bool}
*/
function plugin_catalog_cached(bool $refresh = false): array
function plugin_catalog_cached(bool $refresh = false, int $maxAge = 300): array
{
$url = plugin_catalog_url();
$key = hash('sha256', $url);
if (!$refresh) {
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
}
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
if (!$refresh && $usable) {
$age = time() - (int) ($cached['at'] ?? 0);
if ($age < $maxAge) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
if (time() - (int) get_setting('plugin_catalog_fail_at', '0') < 120) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
set_setting('plugin_catalog_fail_at', '0');
$res['cached_at'] = time();
return $res;
}
set_setting('plugin_catalog_fail_at', (string) time());
if (!$refresh && $usable) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung alle 30 Minuten aufgerufen), damit die Seiten sofort
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung etwa alle 5 Minuten aufgerufen), damit die Seiten sofort
* anzeigen und nie auf den Katalog-Server warten müssen. Fehler (Server nicht erreichbar) lassen den alten Stand stehen.
*/
function catalog_refresh_background(): void
@@ -511,6 +630,21 @@ function catalog_refresh_background(): void
*
* @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
{
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
@@ -642,9 +776,11 @@ function plugin_catalog_fetch(): array
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'requires' => (string) ($e['requires'] ?? ''), 'permissions' => $perms, 'downloadUrl' => $dl, 'sha256' => $sha,
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')), 'featured' => !empty($e['featured']), 'language' => (is_array($e['language'] ?? null) && preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', (string) ($e['language']['code'] ?? ''))) ? ['code' => (string) $e['language']['code'], 'name' => mb_substr((string) ($e['language']['name'] ?? ''), 0, 40)] : null,
];
}
// Empfohlene zuerst, sonst die Reihenfolge des Katalogs
usort($plugins, fn($a, $b) => (int) $b['featured'] <=> (int) $a['featured']); // stabil: sonst bleibt die Reihenfolge des Katalogs
return ['ok' => true, 'plugins' => $plugins];
}
@@ -784,6 +920,11 @@ function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 =
->execute([$manifest['name'], $manifest['version'], $hash, $now, $signed ? 1 : 0, $source, $slug]);
// Fordert die neue Version Berechtigungen an, die nie bestätigt wurden, wird ein aktives Plugin sofort angehalten
$needsReapproval = array_values(array_diff($manifest['permissions'], array_filter(explode(',', (string) ($existing['approved_permissions'] ?? '')))));
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung (wie bei der Aktivierung)
if ($needsReapproval && $source === 'catalog' && $signed) {
if ($existing['is_active']) $pdo->prepare('UPDATE plugins SET approved_permissions = ? WHERE slug = ?')->execute([implode(',', $manifest['permissions']), $slug]);
$needsReapproval = [];
}
if ($needsReapproval && $existing['is_active']) {
$pdo->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $needsReapproval) . '). Bitte prüfen und erneut aktivieren.', $slug]);
@@ -892,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>';
}
// ── 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;
}
+162 -157
View File
@@ -1,157 +1,162 @@
<?php
/**
* includes/pseudo_cron.php
*
* "Eingebauter" Ersatz für echte Betriebssystem-Cronjobs, für Hosting-Umgebungen
* ohne Crontab-Zugriff (z.B. viele Shared-Hosting-Pakete). Funktioniert komplett
* ohne externe Dienste: Bei jeder authentifizierten Anfrage im Mitarbeiterbereich
* (Web-Backend UND mobile App) wird nach dem Ausliefern der Antwort im Hintergrund
* geprüft, ob einer der 6 Cronjobs fällig ist – und falls ja, direkt ausgeführt.
*
* Ablauf, ohne die eigentliche Seite/den API-Request zu verlangsamen:
* 1. pseudo_cron_schedule_background() wird beim Seiten-/API-Aufruf registriert.
* 2. register_shutdown_function() sorgt dafür, dass der Code erst NACH dem Senden
* der Antwort läuft. Wo verfügbar (PHP-FPM) beendet fastcgi_finish_request()
* die Verbindung zum Browser sofort – der Nutzer merkt nichts von der Wartezeit.
* 3. pseudo_cron_dispatch() prüft für jeden Job das letzte Ausführungsdatum
* (Setting "cron_<job>_last_run") gegen sein empfohlenes Intervall und führt
* ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"-
* Button und der echte Cronjob.
*
* Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des
* last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap").
* Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job.
*
* Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"),
* falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht
* gebraucht wird.
*/
/** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */
function pseudo_cron_job_definitions(): array
{
return [
'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
'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'],
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
];
}
/**
* Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen:
* Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf
* "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei
* gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE).
*/
function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool
{
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
// Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig).
if ($driver === 'sqlite') {
$pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
} else {
$pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
}
$now = date('Y-m-d H:i:s');
$threshold = date('Y-m-d H:i:s', time() - $intervalSeconds);
$stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?');
$stmt->execute([$now, $settingKey, $threshold]);
return $stmt->rowCount() > 0;
}
/** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */
function pseudo_cron_dispatch(): void
{
try {
$pdo = get_db();
if (get_setting('pseudo_cron_enabled', '1') !== '1') return;
// Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel
// Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen.
if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return;
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
foreach (pseudo_cron_job_definitions() as $key => $job) {
if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue;
try {
require_once __DIR__ . '/../cron/' . $job['file'];
$fn = $job['fn'];
$fn($pdo, false);
} catch (Throwable $e) {
error_log("pseudo_cron [$key]: " . $e->getMessage());
}
}
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 30 Minuten)
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 1800)) {
catalog_refresh_background();
}
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
try {
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
backup_verify_pending(2);
} catch (Throwable $e) {
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
}
}
// Plugins: wiederkehrende Aufgaben (höchstens alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) {
plugin_hook('cron.run', ['time' => time()]);
}
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
try {
finalize_stale_work_sessions($pdo, false);
} catch (Throwable $e) {
error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage());
}
}
} catch (Throwable $e) {
error_log('pseudo_cron_dispatch: ' . $e->getMessage());
}
}
/**
* Registriert die Hintergrundprüfung für das Ende des aktuellen Requests.
* Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen –
* läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass
* jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss.
*/
function pseudo_cron_schedule_background(): void
{
static $scheduled = false;
if ($scheduled) return; // pro Request nur einmal registrieren
$scheduled = true;
if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig
register_shutdown_function(function () {
// Verbindung zum Client möglichst sofort beenden, damit die Prüfung
// die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt.
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
} else {
if (function_exists('ob_get_level')) {
while (ob_get_level() > 0) { @ob_end_flush(); }
}
@flush();
}
pseudo_cron_dispatch();
});
}
<?php
/**
* includes/pseudo_cron.php
*
* "Eingebauter" Ersatz für echte Betriebssystem-Cronjobs, für Hosting-Umgebungen
* ohne Crontab-Zugriff (z.B. viele Shared-Hosting-Pakete). Funktioniert komplett
* ohne externe Dienste: Bei jeder authentifizierten Anfrage im Mitarbeiterbereich
* (Web-Backend UND mobile App) wird nach dem Ausliefern der Antwort im Hintergrund
* geprüft, ob einer der 6 Cronjobs fällig ist – und falls ja, direkt ausgeführt.
*
* Ablauf, ohne die eigentliche Seite/den API-Request zu verlangsamen:
* 1. pseudo_cron_schedule_background() wird beim Seiten-/API-Aufruf registriert.
* 2. register_shutdown_function() sorgt dafür, dass der Code erst NACH dem Senden
* der Antwort läuft. Wo verfügbar (PHP-FPM) beendet fastcgi_finish_request()
* die Verbindung zum Browser sofort – der Nutzer merkt nichts von der Wartezeit.
* 3. pseudo_cron_dispatch() prüft für jeden Job das letzte Ausführungsdatum
* (Setting "cron_<job>_last_run") gegen sein empfohlenes Intervall und führt
* ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"-
* Button und der echte Cronjob.
*
* Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des
* last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap").
* Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job.
*
* Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"),
* falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht
* gebraucht wird.
*/
/** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */
function pseudo_cron_job_definitions(): array
{
return [
'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
'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'],
'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'],
'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'],
'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'],
];
}
/**
* Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen:
* Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf
* "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei
* gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE).
*/
function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool
{
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
// Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig).
if ($driver === 'sqlite') {
$pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
} else {
$pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
}
$now = date('Y-m-d H:i:s');
$threshold = date('Y-m-d H:i:s', time() - $intervalSeconds);
$stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?');
$stmt->execute([$now, $settingKey, $threshold]);
return $stmt->rowCount() > 0;
}
/** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */
function pseudo_cron_dispatch(): void
{
try {
$pdo = get_db();
if (get_setting('pseudo_cron_enabled', '1') !== '1') return;
// Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel
// Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen.
if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return;
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
foreach (pseudo_cron_job_definitions() as $key => $job) {
if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue;
try {
require_once __DIR__ . '/../cron/' . $job['file'];
$fn = $job['fn'];
$fn($pdo, false);
} catch (Throwable $e) {
error_log("pseudo_cron [$key]: " . $e->getMessage());
if (function_exists('error_alert_notice')) error_alert_notice("Hintergrundjob „{$key}“ ist fehlgeschlagen", "Der Hintergrundjob „{$key}“ hat einen Fehler gemeldet und wurde abgebrochen. Beim nächsten Intervall wird er erneut versucht.", 'cron/' . $job['file'], $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
}
}
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280)) {
catalog_refresh_background();
}
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
try {
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
backup_verify_pending(2);
} catch (Throwable $e) {
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
}
}
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
try {
finalize_stale_work_sessions($pdo, false);
} catch (Throwable $e) {
error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage());
}
}
} catch (Throwable $e) {
error_log('pseudo_cron_dispatch: ' . $e->getMessage());
}
}
/**
* Registriert die Hintergrundprüfung für das Ende des aktuellen Requests.
* Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen –
* läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass
* jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss.
*/
function pseudo_cron_schedule_background(): void
{
static $scheduled = false;
if ($scheduled) return; // pro Request nur einmal registrieren
$scheduled = true;
if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig
register_shutdown_function(function () {
// Verbindung zum Client möglichst sofort beenden, damit die Prüfung
// die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt.
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
} else {
if (function_exists('ob_get_level')) {
while (ob_get_level() > 0) { @ob_end_flush(); }
}
@flush();
}
// Sitzungssperre freigeben: Sonst wartet die nächste Seite desselben Mitarbeiters, bis der Hintergrundjob (Backup, Mail-Abruf …) fertig ist
if (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); }
@ignore_user_abort(true);
pseudo_cron_dispatch();
});
}
@@ -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'],
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
'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', ''],
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
'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]])
);
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);
$this->readResponse([220]);
@@ -123,6 +135,11 @@ class SmtpMailer
$headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}";
$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-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n";
+859
View File
@@ -0,0 +1,859 @@
<?php
/**
* includes/status_page.php
*
* Statusseite für Kunden (status.php): Zustand der Dienste („Komponenten“) und Meldungen zu Störungen und Wartungen.
* Verwaltet wird sie im Mitarbeiterbereich (Adminportal → Statusseite) und über die Apps.
*/
function status_page_enabled(): bool
{
return get_setting('status_page_enabled', '0') === '1';
}
/** Zustände einer Komponente, vom besten zum schlechtesten. Schlüssel => [Beschriftung, Farbe, Rang]. */
function status_component_states(): array
{
return [
'operational' => ['In Betrieb', '#157a45', 0],
'maintenance' => ['Wartung', '#2563eb', 1],
'degraded' => ['Eingeschränkt', '#b45309', 2],
'outage' => ['Ausfall', '#dc2626', 3],
];
}
/** Erkennt ohne zusätzliche Einstellung Datenbank, DeskHub-API und konfigurierte Prüf-URLs. */
function status_app_connection(PDO $pdo, string $platform): array
{
$timeoutSeconds = $platform === 'android' ? 3600 : 600;
$timeoutMinutes = (int) ($timeoutSeconds / 60);
$cutoff = date('Y-m-d H:i:s', time() - $timeoutSeconds);
$deviceFilter = $platform === 'windows'
? "device_name LIKE 'Windows Desktop (%'"
: "(device_name IS NULL OR device_name NOT LIKE 'Windows Desktop (%')";
try {
$stmt = $pdo->prepare("SELECT MAX(last_used_at) FROM mobile_api_tokens WHERE revoked_at IS NULL AND expires_at > ? AND last_used_at IS NOT NULL AND $deviceFilter");
$stmt->execute([db_now()]);
$lastSeen = $stmt->fetchColumn();
} catch (Throwable $e) {
$lastSeen = false;
}
$lastSeen = is_string($lastSeen) && $lastSeen !== '' ? $lastSeen : null;
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
{
$type = (string) ($component['monitor_type'] ?? 'auto');
$target = trim((string) ($component['monitor_target'] ?? ''));
$category = mb_strtolower((string) ($component['category_name'] ?? ''));
$name = mb_strtolower((string) ($component['name'] ?? ''));
if ($type === 'manual') return null;
if ($type === 'auto') {
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, '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, 'api') || str_contains($category, 'api')) return ['type' => 'http', 'target' => rtrim(base_url(), '/') . '/api/public/v1/health.php', 'label' => 'DeskHub-API'];
if ((str_contains($name, 'support') || str_contains($name, 'kundenportal')) && (str_contains($category, 'web') || $category === '')) return ['type' => 'http', 'target' => rtrim(base_url(), '/') . '/index.php', 'label' => 'DeskHub-Portal'];
if (str_contains($name, 'mail') || str_contains($category, 'e-mail')) return status_configured_mail_endpoints($pdo) ? ['type' => 'mail', 'target' => '', 'label' => 'E-Mail-Server'] : null;
if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'];
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 === '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 === 'mail') return status_configured_mail_endpoints($pdo) ? ['type' => 'mail', 'target' => '', 'label' => 'E-Mail-Server'] : null;
if ($type === 'fcm') return function_exists('fcm_configured') && fcm_configured() ? ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'] : null;
if ($type === 'smtp') {
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;
}
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'];
return null;
}
function status_execute_component_check(PDO $pdo, array $check): array
{
$started = microtime(true);
$ok = false;
$error = '';
if ($check['type'] === 'database') {
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; }
} elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) {
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
// Erreichbar heißt: Der Server nimmt Anfragen der App an (Datenbank und Schnittstelle laufen). Ob gerade ein PC oder Handy
// eingeschaltet ist, sagt darüber nichts: Ohne laufendes Programm ist der Dienst trotzdem erreichbar.
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Schnittstelle für die App nicht erreichbar (Datenbankabfrage fehlgeschlagen)'; }
} elseif ($check['type'] === 'mail') {
$endpoints = status_configured_mail_endpoints($pdo);
$ok = count($endpoints) > 0;
foreach ($endpoints as $endpoint) {
$socket = @stream_socket_client('tcp://' . $endpoint['host'] . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
if (!is_resource($socket)) {
$ok = false;
$error = $endpoint['name'] . '-Server nicht erreichbar (' . $endpoint['host'] . ':' . $endpoint['port'] . ')';
break;
}
fclose($socket);
}
if (!$ok && $error === '') $error = 'Kein E-Mail-Server eingerichtet';
} elseif ($check['type'] === 'fcm') {
$mode = function_exists('fcm_mode') ? fcm_mode() : 'none';
if ($mode === 'own' && function_exists('fcm_service_account') && function_exists('fcm_access_token')) {
$account = fcm_service_account();
$ok = $account !== null && fcm_access_token($account) !== null;
if (!$ok) $error = 'Firebase-Zugang konnte nicht bestätigt werden';
} elseif ($mode === 'relay') {
$url = function_exists('fcm_relay_url') ? fcm_relay_url() : '';
$host = (string) parse_url($url, PHP_URL_HOST);
$port = (int) (parse_url($url, PHP_URL_PORT) ?: 443);
if ($host !== '') {
$socket = @stream_socket_client('tcp://' . $host . ':' . $port, $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
$ok = is_resource($socket);
if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar';
} else $error = 'Push-Dienst nicht eingerichtet';
} else $error = 'Push-Dienst nicht eingerichtet';
} elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
$endpoint = $check['type'] === 'minecraft'
? 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);
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
} else $error = 'Ungültiges Prüfziel';
} elseif (in_array($check['type'], ['http', 'plugin_site'], true)) {
$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 (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => static fn($handle, string $body): int => strlen($body), CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 4, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_TIMEOUT => 7, CURLOPT_USERAGENT => 'DeskHub Status Check']);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
$ok = $code >= 200 && $code < 400;
} else {
$context = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 7, 'ignore_errors' => true, 'follow_location' => 1, 'max_redirects' => 4], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
@file_get_contents($url, false, $context);
$code = 0;
foreach (($http_response_header ?? []) as $header) if (preg_match('/^HTTP\\/\\S+\\s+(\\d{3})/', $header, $m)) $code = (int) $m[1];
$ok = $code >= 200 && $code < 400;
}
if (!$ok && $error === '') $error = 'Webseite antwortet mit HTTP ' . ($code ?: 'ohne Antwort');
} else $error = 'Ungültige oder nicht unterstützte Prüf-URL';
}
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
}
/** 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);
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
$sample = $pdo->prepare('INSERT INTO status_component_checks (component_id, status, checked_at, response_ms) VALUES (?,?,?,?)');
$checked = 0;
foreach ($components as $component) {
$check = status_component_monitor_target($pdo, $component);
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);
$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']]);
$currentStatus = (string) $component['status'];
if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') {
status_set_component($pdo, (int) $component['id'], 'operational');
if ($currentStatus === 'outage') status_notify_admins($pdo, $component, true);
$currentStatus = 'operational';
} elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') {
status_set_component($pdo, (int) $component['id'], 'outage');
status_notify_admins($pdo, $component, false, (string) $result['error']);
$currentStatus = 'outage';
}
$sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]);
$checked++;
}
if (function_exists('pseudo_cron_try_claim') && pseudo_cron_try_claim($pdo, 'status_check_samples_cleanup', 86400)) {
$pdo->prepare('DELETE FROM status_component_checks WHERE checked_at < ?')->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]);
}
return $checked;
}
/** Erkennt vorhandene automatische Prüfziele und prüft sie sofort; externe Ziele ohne URL bleiben offen. */
function status_discover_and_check(PDO $pdo): array
{
$recognized = [];
$pending = [];
foreach (status_components($pdo) as $component) {
if (($component['monitor_type'] ?? 'auto') === 'manual') continue;
if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name'];
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name'];
}
$checked = status_run_component_checks($pdo, true);
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
}
/** Stufen einer Störungsmeldung. */
function status_incident_states(): array
{
return [
'investigating' => 'Wird untersucht',
'identified' => 'Ursache gefunden',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
];
}
/** 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
{
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
{
return $pdo->query('SELECT * FROM status_categories ORDER BY sort_order, name')->fetchAll();
}
function status_monitor_types(): array
{
return [
'auto' => 'Automatisch erkennen',
'http' => 'Webseite / URL',
'android_app' => 'Android-App-Verbindung',
'windows_app' => 'Windows-Programm-Verbindung',
'database' => 'Datenbankverbindung',
'smtp' => 'SMTP-Server',
'mail' => 'E-Mail (SMTP und IMAP)',
'fcm' => 'Push-Dienst',
'plugin_site' => 'Plugin-Webseite (offizieller Katalog)',
'tcp' => 'Server und Port',
'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. */
function status_configured_mail_endpoints(PDO $pdo): array
{
$targets = [];
$globalHost = trim((string) get_setting('smtp_host', ''));
if ($globalHost !== '') $targets[] = ['host' => $globalHost, 'port' => (int) get_setting('smtp_port', '587') ?: 587, 'name' => 'SMTP'];
try {
foreach ($pdo->query("SELECT smtp_host, smtp_port FROM email_addresses WHERE smtp_enabled = 1 AND is_active = 1 AND smtp_host <> ''")->fetchAll() as $row) {
if (trim((string) $row['smtp_host']) !== '') $targets[] = ['host' => trim((string) $row['smtp_host']), 'port' => (int) ($row['smtp_port'] ?: 587), 'name' => 'SMTP'];
}
} catch (Throwable $e) {
// Einzelne oder alte Installationen können andere E-Mail-Tabellen haben.
}
try {
foreach ($pdo->query('SELECT host, port FROM imap_accounts WHERE is_active = 1')->fetchAll() as $row) {
if (trim((string) $row['host']) !== '') $targets[] = ['host' => trim((string) $row['host']), 'port' => (int) ($row['port'] ?: 993), 'name' => 'IMAP'];
}
} catch (Throwable $e) {
// IMAP ist optional.
}
$unique = [];
foreach ($targets as $target) $unique[$target['host'] . ':' . $target['port']] = $target;
return array_values($unique);
}
/** Zeigt die dynamisch verwendeten Prüfziele im Mitarbeiterbereich ohne Zugangsdaten an. */
function status_component_detected_targets(PDO $pdo, array $check): array
{
if (in_array($check['type'], ['android_app', 'windows_app'], true)) {
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
$label = $platform === 'windows' ? 'Windows' : 'Android';
$connection = status_app_connection($pdo, $platform);
if ($connection['last_seen'] === null) return ['Server erreichbar · noch kein Gerät mit ' . $label . ' angemeldet gewesen'];
$when = format_dt($connection['last_seen']);
return ['Server erreichbar · ' . ($connection['online'] ? 'Gerät gerade verbunden, letzter Kontakt ' . $when : 'kein Gerät gerade aktiv, letzter Kontakt ' . $when)];
}
if ($check['type'] === 'database') {
$db = (array) (config()['db'] ?? []);
$driver = strtolower((string) ($db['driver'] ?? ''));
if ($driver === 'sqlite') return ['SQLite-Datenbank (lokal)'];
if ($driver === 'mysql') {
$host = trim((string) ($db['host'] ?? ''));
$port = (int) ($db['port'] ?? 3306) ?: 3306;
return [$host !== '' ? 'MySQL · ' . $host . ':' . $port : 'MySQL-Datenbank'];
}
return [$driver !== '' ? strtoupper($driver) . '-Datenbank' : 'Datenbankverbindung'];
}
if ($check['type'] === 'mail') {
return array_map(static fn(array $endpoint): string => $endpoint['name'] . ' · ' . $endpoint['host'] . ':' . $endpoint['port'], status_configured_mail_endpoints($pdo));
}
if ($check['type'] === 'fcm') {
$mode = function_exists('fcm_mode') ? fcm_mode() : 'none';
if ($mode === 'own') return ['Firebase Cloud Messaging'];
if ($mode === 'relay') {
$url = function_exists('fcm_relay_url') ? fcm_relay_url() : '';
$host = (string) parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
return [$host !== '' ? 'Push-Relay · ' . $host . ($port ? ':' . $port : '') : 'Push-Relay'];
}
return ['Push-Dienst'];
}
return [(string) ($check['target'] ?? '')];
}
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 (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
$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 === 'tcp' && $target !== '') {
$componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE 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();
}
function status_save_category(PDO $pdo, string $name): bool
{
$name = trim(mb_substr($name, 0, 80));
if ($name === '') return false;
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE LOWER(name) = LOWER(?)');
$check->execute([$name]);
if ((int) $check->fetchColumn() > 0) return false;
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute([$name, count(status_categories($pdo)) * 10 + 10]);
return true;
}
function status_delete_category(PDO $pdo, int $id): void
{
$pdo->prepare('UPDATE status_components SET category_id = NULL, category = NULL WHERE category_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_categories WHERE id = ?')->execute([$id]);
}
function status_set_category_order(PDO $pdo, int $id, int $sort): bool
{
$stmt = $pdo->prepare('UPDATE status_categories SET sort_order = ? WHERE id = ?');
$stmt->execute([$sort, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_categories WHERE id = ' . (int) $id)->fetchColumn();
}
/** Gesamtzustand: der schlechteste Zustand aller Komponenten und offener Störungen. @return array{key:string,label:string,color:string} */
function status_overall(PDO $pdo): array
{
$states = status_component_states();
$worst = 'operational';
foreach (status_components($pdo, false, true) as $c) {
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“
$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';
$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]];
}
/** 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, bool $includeDisabledComponents = false): array
{
$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 "
. ($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->execute($onlyOpen ? [] : [$since]);
$rows = $stmt->fetchAll();
$up = $pdo->prepare('SELECT * FROM status_incident_updates WHERE incident_id = ? ORDER BY created_at DESC, id DESC');
foreach ($rows as &$r) {
$up->execute([$r['id']]);
$r['updates'] = $up->fetchAll();
}
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
{
if (!isset(status_component_states()[$status])) return false;
$current = $pdo->prepare('SELECT status FROM status_components WHERE id = ?');
$current->execute([$id]);
$previous = $current->fetchColumn();
if ($previous === false) return false;
$s = $pdo->prepare('UPDATE status_components SET status = ? WHERE id = ?');
$s->execute([$status, $id]);
if ($previous !== $status) {
$pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)')->execute([$id, $status, db_now()]);
}
return true;
}
function status_toggle_component_maintenance(PDO $pdo, int $id): bool
{
$stmt = $pdo->prepare('SELECT status FROM status_components WHERE id = ?');
$stmt->execute([$id]);
$current = $stmt->fetchColumn();
if ($current === false) return false;
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. */
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
{
$checks = max(1, min(100, $checks));
$now = time();
$windowStart = $now - 86400;
$priorQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? AND checked_at < ? ORDER BY checked_at DESC, id DESC LIMIT 1');
$priorQuery->execute([$componentId, date('Y-m-d H:i:s', $windowStart)]);
$prior = $priorQuery->fetch();
$recentQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? AND checked_at >= ? AND checked_at <= ? ORDER BY checked_at, id');
$recentQuery->execute([$componentId, date('Y-m-d H:i:s', $windowStart), date('Y-m-d H:i:s', $now)]);
$recent = $recentQuery->fetchAll();
$priorTime = $prior ? strtotime((string) $prior['checked_at']) : null;
$state = $priorTime !== null && $priorTime >= $windowStart - 120 ? (string) $prior['status'] : null;
$cursor = $state !== null ? max($windowStart, $priorTime) : $windowStart;
$knownSeconds = 0;
$upSeconds = 0;
foreach ($recent as $check) {
$checkTime = strtotime((string) $check['checked_at']);
if ($state !== null && $checkTime > $cursor) {
$duration = min($checkTime - $cursor, 120);
$knownSeconds += $duration;
if ($state === 'operational') $upSeconds += $duration;
}
$state = (string) $check['status'];
$cursor = max($cursor, $checkTime);
}
if ($state !== null && $now > $cursor) {
$duration = min($now - $cursor, 120);
$knownSeconds += $duration;
if ($state === 'operational') $upSeconds += $duration;
}
$barsQuery = $pdo->prepare('SELECT status, checked_at FROM status_component_checks WHERE component_id = ? ORDER BY checked_at DESC, id DESC LIMIT ' . $checks);
$barsQuery->execute([$componentId]);
$bars = array_reverse($barsQuery->fetchAll());
$bars = array_map(static fn(array $bar): array => [
'date' => date('d.m.Y H:i:s', strtotime((string) $bar['checked_at'])),
'status' => (string) $bar['status'],
], $bars);
while (count($bars) < $checks) array_unshift($bars, ['date' => '', 'status' => null]);
return [
'bars' => $bars,
'uptime' => $knownSeconds > 0 ? round($upSeconds / $knownSeconds * 100, 2) : null,
];
}
function status_set_component_category(PDO $pdo, int $id, ?int $categoryId, int $sort): bool
{
if ($categoryId !== null) {
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE id = ?');
$check->execute([$categoryId]);
if (!(int) $check->fetchColumn()) $categoryId = null;
}
$stmt = $pdo->prepare('UPDATE status_components SET category_id = ?, category = NULL, sort_order = ? WHERE id = ?');
$stmt->execute([$categoryId, $sort, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
function status_save_component(PDO $pdo, ?int $id, string $name, string $description, int $sort, ?int $categoryId = null): ?int
{
$name = trim(mb_substr($name, 0, 120));
if ($name === '') return null;
$description = trim(mb_substr($description, 0, 255));
if ($categoryId !== null) {
$check = $pdo->prepare('SELECT COUNT(*) FROM status_categories WHERE id = ?');
$check->execute([$categoryId]);
if (!(int) $check->fetchColumn()) $categoryId = null;
}
if ($id) {
$pdo->prepare('UPDATE status_components SET name = ?, description = ?, sort_order = ?, category_id = ?, category = NULL WHERE id = ?')->execute([$name, $description !== '' ? $description : null, $sort, $categoryId, $id]);
return $id;
}
$pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, category) VALUES (?,?,?,?,?,?,NULL)')->execute([$name, $description !== '' ? $description : null, 'operational', $sort, db_now(), $categoryId]);
$newId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)')->execute([$newId, 'operational', db_now()]);
return $newId;
}
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_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_components WHERE id = ?')->execute([$id]);
}
/** Restore missing DeskHub core services without changing existing entries. */
function status_restore_core_components(PDO $pdo): int
{
$defaults = [
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
['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']],
['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']],
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
];
$find = $pdo->prepare('SELECT id, category_id FROM status_components WHERE LOWER(name) = LOWER(?) LIMIT 1');
$findCategory = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
$categoryIds = [];
$nextCategoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
foreach (array_unique(array_column($defaults, 1)) as $categoryName) {
$findCategory->execute([$categoryName]);
$categoryId = $findCategory->fetchColumn();
if (!$categoryId) {
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?, ?)')->execute([$categoryName, $nextCategoryOrder]);
$categoryId = (int) $pdo->lastInsertId();
$nextCategoryOrder += 10;
}
$categoryIds[$categoryName] = (int) $categoryId;
}
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn();
$restored = 0;
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;
foreach (array_merge([$name], $aliases) as $candidate) {
$find->execute([$candidate]);
$row = $find->fetch(PDO::FETCH_ASSOC);
if ($row) { $existing = $row; break; }
}
$categoryId = $categoryIds[$categoryName] ?? null;
if ($existing) {
if (empty($existing['category_id']) && $categoryId) {
$pdo->prepare('UPDATE status_components SET category_id = ?, category = NULL WHERE id = ?')->execute([$categoryId, (int) $existing['id']]);
}
continue;
}
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
if ($id) {
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;
$restored++;
}
}
return $restored;
}
/**
* Neue Störungs- oder Wartungsmeldung. Mit $componentState wird die zugeordnete Komponente gleich auf diesen Zustand gesetzt.
* @return array{ok:bool,id?:int,error?:string}
*/
function status_create_incident(PDO $pdo, string $title, string $body, string $status, ?int $componentId, ?string $componentState, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel angeben.'];
if (!isset(status_incident_states()[$status])) $status = 'investigating';
if ($componentId !== null) {
$c = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ?');
$c->execute([$componentId]);
if (!(int) $c->fetchColumn()) $componentId = null;
}
$now = db_now();
$pdo->prepare('INSERT INTO status_incidents (title, body, status, impact, component_id, created_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$title, trim($body) !== '' ? trim($body) : null, $status, 'minor', $componentId, $staffId, $now, $now, $status === 'resolved' ? $now : null]);
$id = (int) $pdo->lastInsertId();
if (trim($body) !== '') {
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$id, $status, trim($body), $staffId, $now]);
}
if ($componentId !== null && $componentState !== null && isset(status_component_states()[$componentState])) status_set_component($pdo, $componentId, $componentState);
return ['ok' => true, 'id' => $id];
}
/**
* Update zu einer Meldung. Wird sie als „Behoben“ markiert, setzt die zugeordnete Komponente zurück auf „In Betrieb“, wenn $restore true ist.
* @return array{ok:bool,error?:string}
*/
function status_update_incident(PDO $pdo, int $id, string $status, string $body, ?int $staffId, bool $restore = true): array
{
if (!isset(status_incident_states()[$status])) return ['ok' => false, 'error' => 'Unbekannter Status.'];
$s = $pdo->prepare('SELECT * FROM status_incidents WHERE id = ?');
$s->execute([$id]);
$inc = $s->fetch();
if (!$inc) return ['ok' => false, 'error' => 'Meldung nicht gefunden.'];
$now = db_now();
$pdo->prepare('UPDATE status_incidents SET status = ?, updated_at = ?, resolved_at = ? WHERE id = ?')
->execute([$status, $now, $status === 'resolved' ? ($inc['resolved_at'] ?: $now) : null, $id]);
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')
->execute([$id, $status, trim(mb_substr($body, 0, 4000)), $staffId, $now]);
if ($status === 'resolved' && $restore && $inc['component_id']) {
// Nur zurücksetzen, wenn keine andere offene Meldung dieselbe Komponente betrifft
$o = $pdo->prepare("SELECT COUNT(*) FROM status_incidents WHERE component_id = ? AND status <> 'resolved' AND id <> ?");
$o->execute([$inc['component_id'], $id]);
if (!(int) $o->fetchColumn()) status_set_component($pdo, (int) $inc['component_id'], 'operational');
}
return ['ok' => true];
}
function status_delete_incident(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM status_incident_updates WHERE incident_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_incidents WHERE id = ?')->execute([$id]);
}
/** Zusammenfassung für die Apps. */
function status_summary(PDO $pdo): array
{
$states = status_component_states();
$overall = status_overall($pdo);
return [
'enabled' => status_page_enabled(),
'title' => (string) get_setting('status_page_title', 'Systemstatus'),
'url' => base_url() . '/status.php',
'overall' => $overall,
'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()),
'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) => [
'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,
'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']),
], 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)),
];
}
+14 -8
View File
@@ -45,24 +45,30 @@ function theme_catalog_fetch(): array
}
/**
* Theme-Katalog mit Zwischenspeicher (1 Stunde), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen Abruf.
* Theme-Katalog mit kurzem Zwischenspeicher (Standard 5 Minuten), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen
* Abruf. Ist der Katalog-Server nicht erreichbar, bleibt der letzte Stand sichtbar und es wird zwei Minuten lang nicht erneut gefragt.
*
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int}
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int, stale?:bool}
*/
function theme_catalog_cached(bool $refresh = false): array
function theme_catalog_cached(bool $refresh = false, int $maxAge = 300): array
{
$key = hash('sha256', theme_catalog_url());
if (!$refresh) {
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
}
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
if (!$refresh && $usable) {
$age = time() - (int) ($cached['at'] ?? 0);
if ($age < $maxAge) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
if (time() - (int) get_setting('theme_catalog_fail_at', '0') < 120) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
}
$res = theme_catalog_fetch();
if ($res['ok']) {
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
set_setting('theme_catalog_fail_at', '0');
$res['cached_at'] = time();
return $res;
}
set_setting('theme_catalog_fail_at', (string) time());
if (!$refresh && $usable) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
return $res;
}
@@ -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) {
return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
}
$woken = 0;
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']]);
log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig');
$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 ───────────────────────────────────────────────────────────────
@@ -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]);
}
@@ -0,0 +1,75 @@
<?php
/**
* includes/ticket_templates.php
*
* Ticket-Vorlagen: vorbelegte Formulare (Betreff, Text, Abteilung, Hilfethema, Priorität), zum Beispiel „Neuer Mitarbeiter“ oder
* „Passwort zurücksetzen“. Kunden wählen sie auf der Seite „Neues Ticket“ (wenn „für Kunden sichtbar“), Mitarbeiter beim Anlegen
* eines Tickets in Web, Windows-Programm und App.
*/
/** @return list<array> aktive Vorlagen; $publicOnly = nur die für Kunden sichtbaren */
function ticket_templates_list(PDO $pdo, bool $publicOnly = false, bool $includeInactive = false): array
{
$sql = 'SELECT * FROM ticket_templates WHERE 1 = 1' . ($includeInactive ? '' : ' AND is_active = 1') . ($publicOnly ? ' AND is_public = 1' : '') . ' ORDER BY sort_order, name';
return $pdo->query($sql)->fetchAll();
}
function ticket_template_get(PDO $pdo, int $id, bool $publicOnly = false): ?array
{
$s = $pdo->prepare('SELECT * FROM ticket_templates WHERE id = ? AND is_active = 1' . ($publicOnly ? ' AND is_public = 1' : ''));
$s->execute([$id]);
return $s->fetch() ?: null;
}
/**
* Legt eine Vorlage an oder ändert sie.
* @return array{ok:bool,id?:int,error?:string}
*/
function ticket_template_save(PDO $pdo, ?int $id, array $in): array
{
$name = trim(mb_substr((string) ($in['name'] ?? ''), 0, 120));
if ($name === '') return ['ok' => false, 'error' => 'Bitte einen Namen angeben.'];
$opt = function (string $key, string $table) use ($pdo, $in): ?int {
$v = (int) ($in[$key] ?? 0);
if ($v < 1) return null;
$c = $pdo->prepare("SELECT COUNT(*) FROM $table WHERE id = ?");
$c->execute([$v]);
return (int) $c->fetchColumn() ? $v : null;
};
$row = [
$name,
trim(mb_substr((string) ($in['description'] ?? ''), 0, 255)) ?: null,
trim(mb_substr((string) ($in['subject'] ?? ''), 0, 200)),
trim(mb_substr((string) ($in['body'] ?? ''), 0, 10000)) ?: null,
$opt('department_id', 'departments'),
$opt('help_topic_id', 'help_topics'),
$opt('priority_id', 'priorities'),
!empty($in['is_public']) ? 1 : 0,
!isset($in['is_active']) || !empty($in['is_active']) ? 1 : 0,
(int) ($in['sort_order'] ?? 0),
];
if ($id) {
$pdo->prepare('UPDATE ticket_templates SET name=?, description=?, subject=?, body=?, department_id=?, help_topic_id=?, priority_id=?, is_public=?, is_active=?, sort_order=? WHERE id=?')->execute([...$row, $id]);
return ['ok' => true, 'id' => $id];
}
$pdo->prepare('INSERT INTO ticket_templates (name, description, subject, body, department_id, help_topic_id, priority_id, is_public, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')->execute([...$row, db_now()]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
function ticket_template_delete(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM ticket_templates WHERE id = ?')->execute([$id]);
}
/** Vorlage als Feldliste für die Apps und die Formulare. */
function ticket_template_payload(array $t): array
{
return [
'id' => (int) $t['id'], 'name' => (string) $t['name'], 'description' => (string) ($t['description'] ?? ''),
'subject' => (string) $t['subject'], 'body' => (string) ($t['body'] ?? ''),
'departmentId' => $t['department_id'] !== null ? (int) $t['department_id'] : null,
'helpTopicId' => $t['help_topic_id'] !== null ? (int) $t['help_topic_id'] : null,
'priorityId' => $t['priority_id'] !== null ? (int) $t['priority_id'] : null,
'isPublic' => (bool) $t['is_public'],
];
}

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