Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ede6f7de | ||
|
|
a62971926b | ||
|
|
7f79c59ac4 | ||
|
|
bbc3937040 | ||
|
|
a21a12c899 | ||
|
|
2b12b3e40a | ||
|
|
64d86d6869 | ||
|
|
6cfc25ede2 | ||
|
|
877b1db785 | ||
|
|
934b1b3c4a | ||
|
|
e3f32f553a | ||
|
|
d73e0d5e6a | ||
|
|
213e282028 | ||
|
|
2048ecaff2 | ||
|
|
487c001934 | ||
|
|
10fcd56190 | ||
|
|
b57a7f251f | ||
|
|
653739fc3d | ||
|
|
0c5b3dd943 | ||
|
|
e40dfcbf64 | ||
|
|
55456c830a | ||
|
|
2ca24faec3 | ||
|
|
609443fbde | ||
|
|
d9bee89379 | ||
|
|
b27caa16ce | ||
|
|
0c759b83a8 | ||
|
|
d1d1714dde | ||
|
|
8d2c5e8d4a | ||
|
|
63f012805f | ||
|
|
b97f788cea | ||
|
|
dd7b352f7b | ||
|
|
7a4b1b9f57 | ||
|
|
68b695cb32 | ||
|
|
2ff1e2f680 | ||
|
|
0fc0ac62e5 | ||
|
|
01757bda29 | ||
|
|
246ee8a7c6 | ||
|
|
7e73c5e8e0 | ||
|
|
6ccfa0d484 | ||
|
|
33a4dde772 | ||
|
|
84f189afe4 | ||
|
|
2fc637b0e2 | ||
|
|
a7266b60dc | ||
|
|
4f07d2c6b1 | ||
|
|
0f3b5cf4b1 | ||
|
|
409a48c5d5 | ||
|
|
3a6c5d5fd9 | ||
|
|
a44b3407f0 | ||
|
|
07e60fccdc | ||
|
|
738693f8c1 | ||
|
|
884dd4a800 | ||
|
|
7f2d28635d | ||
|
|
73bb633ab2 | ||
|
|
04b117ff3b | ||
|
|
5e0a001567 | ||
|
|
6498902853 | ||
|
|
131468a089 | ||
|
|
b917058dc4 | ||
|
|
a267de671e | ||
|
|
31da945225 | ||
|
|
3602b47a17 | ||
|
|
1148de6210 | ||
|
|
cc6d6a467c | ||
|
|
3771ff9892 | ||
|
|
970b01be36 | ||
|
|
1c1dd3ec52 | ||
|
|
92ea55a44d | ||
|
|
328b8156e9 | ||
|
|
b5d80965dd | ||
|
|
3056b6d5b6 | ||
|
|
4d6be462c8 | ||
|
|
9c382b71e7 | ||
|
|
474456f5c5 | ||
|
|
a8e0a18c51 | ||
|
|
b8b3c0c810 | ||
|
|
1820367fbd | ||
|
|
9d641f8d99 | ||
|
|
0829da5f30 | ||
|
|
abd47b130c | ||
|
|
39dfb16397 | ||
|
|
c18ed432e0 | ||
|
|
b83a8da857 | ||
|
|
4f811fe87c | ||
|
|
0dcf0077ee | ||
|
|
fe3f232505 | ||
|
|
f70d914375 | ||
|
|
d0bd448c06 | ||
|
|
243f05990b | ||
|
|
3e9e81a5e9 | ||
|
|
db1dc818b3 | ||
|
|
aea1c17ba0 | ||
|
|
af627d8bdb | ||
|
|
7afa084c1f | ||
|
|
94293cc130 | ||
|
|
89067a7ad0 | ||
|
|
fb30679f8e | ||
|
|
fba1d56ddb | ||
|
|
e700d2b7d6 | ||
|
|
6d0112aa9c | ||
|
|
1cc4b69645 | ||
|
|
947c9d9541 | ||
|
|
31944c801c | ||
|
|
38b5825e68 | ||
|
|
dac8b07589 | ||
|
|
e0b11677f8 | ||
|
|
a8a54e7fa1 | ||
|
|
e027e35931 | ||
|
|
414af69c53 | ||
|
|
3824091e99 | ||
|
|
790cfff280 | ||
|
|
0fa867024f | ||
|
|
bc160d8ff4 | ||
|
|
6cc7aebd88 | ||
|
|
6829da2d39 | ||
|
|
99141ffe7a | ||
|
|
e324010e42 | ||
|
|
caaf3c2171 | ||
|
|
7df7442caa | ||
|
|
3b956c13e6 | ||
|
|
23497f2926 | ||
|
|
ee77d0a340 | ||
|
|
7c9bb07201 | ||
|
|
690e797b77 | ||
|
|
716b0232db | ||
|
|
5d7958dcdc | ||
|
|
6338c5a366 | ||
|
|
d686c2c690 | ||
|
|
7d1e489b64 | ||
|
|
df977defae | ||
|
|
f07d36ea8b | ||
|
|
59aad18a6b | ||
|
|
fb40d9649a | ||
|
|
1371e76940 | ||
|
|
af3e567a96 | ||
|
|
b91b72df58 | ||
|
|
9ec4b8dc7a | ||
|
|
23d6bee68a | ||
|
|
a7ade853ae | ||
|
|
be3da32f39 | ||
|
|
580b6faeda | ||
|
|
ecb91b2394 | ||
|
|
bb5b01af39 | ||
|
|
d3a28f49f0 | ||
|
|
b3babbd0f8 | ||
|
|
bba3fe6ea6 | ||
|
|
f6b2bba339 | ||
|
|
81df9b1471 | ||
|
|
bd12d91ce7 | ||
|
|
0c364e74fa | ||
|
|
73527d2862 | ||
|
|
2d2cb72ab0 | ||
|
|
b4bad29895 | ||
|
|
5051b0bd1e | ||
|
|
8c8f5ec00c | ||
|
|
dcf38808d1 | ||
|
|
42a78545e6 | ||
|
|
0c1239afb4 | ||
|
|
0c4dd12e29 | ||
|
|
c21acab19d | ||
|
|
a8d8834360 | ||
|
|
5f6e206312 | ||
|
|
380d5536cb | ||
|
|
7b6403ceb0 | ||
|
|
e812dac9e4 | ||
|
|
a4736e39ab | ||
|
|
57648169aa | ||
|
|
f43859ba5f | ||
|
|
cf93c5fbae | ||
|
|
3aed0cc517 | ||
|
|
35809532cf | ||
|
|
7ca9dd3f12 | ||
|
|
63561d259f | ||
|
|
b38f5b34ad | ||
|
|
ac6f8ef915 | ||
|
|
115f48f957 | ||
|
|
071c0dae72 | ||
|
|
cae6bc236d | ||
|
|
a2c09909ea | ||
|
|
f7be6099de | ||
|
|
e457b97dce | ||
|
|
ad961bc601 | ||
|
|
19bfd99e63 | ||
|
|
628efdf457 | ||
|
|
1cf5e819ee | ||
|
|
204e7fb88c | ||
|
|
9c5eef5b7b | ||
|
|
e1b58b0d8e | ||
|
|
b86dca3e30 | ||
|
|
162d925511 | ||
|
|
c7d8a4e153 | ||
|
|
f235642f48 | ||
|
|
64d6cf1afb | ||
|
|
e7a6ba0b86 | ||
|
|
cbf6139e74 | ||
|
|
9108a34774 | ||
|
|
65003aa37b | ||
|
|
7ee6d97c63 | ||
|
|
c3b6bd5dcd | ||
|
|
b2a0a2d0b8 | ||
|
|
c270c0b6cf | ||
|
|
793fe6162e | ||
|
|
f189918dc4 | ||
|
|
b347bd8405 | ||
|
|
5fd2e0be47 | ||
|
|
f00317d222 | ||
|
|
02c5ba3354 | ||
|
|
2e4791d02c | ||
|
|
34c844bf67 | ||
|
|
692e1dc421 | ||
|
|
49d0f292ed | ||
|
|
11e2b7c0e6 | ||
|
|
3fc1568b6c | ||
|
|
d7d374b6d9 | ||
|
|
202c61ee23 | ||
|
|
b2edb63a0b | ||
|
|
1863e2e877 | ||
|
|
c7a2f19ac7 | ||
|
|
0d7e6535cd | ||
|
|
7187609e3a | ||
|
|
5ffb46573f |
@@ -4,12 +4,17 @@
|
||||
|
||||

|
||||
|
||||
**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-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
|
||||
|
||||
[](DeskHub-Webseite/docs/images/demo-assistent-kunde.png)
|
||||
|
||||
### Übersicht im Mitarbeiterbereich
|
||||
|
||||
[](DeskHub-Webseite/docs/images/demo-uebersicht.png)
|
||||
|
||||
### Ticket mit Wiedervorlage, Beobachtern und passenden Artikeln
|
||||
|
||||
[](DeskHub-Webseite/docs/images/demo-ticket-wiedervorlage.png)
|
||||
|
||||
### Fragen-Log des Assistenten
|
||||
|
||||
[](DeskHub-Webseite/docs/images/demo-assistent-log.png)
|
||||
|
||||
### Windows-Programm
|
||||
|
||||
[](DeskHub-Webseite/docs/images/demo-windows-ticket.png)
|
||||
|
||||
### Neues Ticket erstellen
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/common.php';
|
||||
|
||||
/**
|
||||
* Update-Prüfung der Apps: GET ?platform=android|windows 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'], 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); }
|
||||
}
|
||||
@@ -105,9 +105,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 +133,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)) {
|
||||
@@ -297,6 +296,14 @@ function api_is_following(int $ticketId): bool
|
||||
return isset($mine[$ticketId]);
|
||||
}
|
||||
|
||||
/** 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): int
|
||||
{
|
||||
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
|
||||
|
||||
@@ -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]);
|
||||
@@ -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($staff);
|
||||
$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' => $staff['role'] === 'admin']);
|
||||
@@ -126,4 +126,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,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]);
|
||||
@@ -1058,3 +1058,79 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 ? '»' : '«';
|
||||
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,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);
|
||||
});
|
||||
})();
|
||||
@@ -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,38 @@
|
||||
<?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') {
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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'; ?>
|
||||
@@ -1330,6 +1330,247 @@ 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 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();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,79 +1,135 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
|
||||
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 (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !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';
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ 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__ . '/plugins.php';
|
||||
require_once __DIR__ . '/app_downloads.php';
|
||||
require_once __DIR__ . '/theme_catalog.php';
|
||||
@@ -71,6 +75,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",
|
||||
|
||||
@@ -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 12px;font-size:20px;color:#16202a">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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] ?? '';
|
||||
@@ -1070,15 +1077,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,7 +1100,7 @@ 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.'];
|
||||
}
|
||||
}
|
||||
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,113 +1,204 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?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): array
|
||||
{
|
||||
static $cache = [];
|
||||
if (isset($cache[$code])) return $cache[$code];
|
||||
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
|
||||
$file = i18n_languages()[$code]['strings'] ?? '';
|
||||
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict;
|
||||
$data = json_decode((string) file_get_contents($file), true);
|
||||
if (!is_array($data)) return $cache[$code] = $dict;
|
||||
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';
|
||||
$dict['patterns'][] = [$regex, $value];
|
||||
} else {
|
||||
$dict['exact'][$norm] = $value;
|
||||
}
|
||||
$dict['count']++;
|
||||
}
|
||||
return $cache[$code] = $dict;
|
||||
}
|
||||
|
||||
/** 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) . '&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>';
|
||||
}
|
||||
@@ -22,6 +22,8 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
|
||||
'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();
|
||||
|
||||
@@ -32,6 +32,7 @@ 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',
|
||||
];
|
||||
}
|
||||
@@ -39,7 +40,7 @@ function plugin_known_permissions(): array
|
||||
/** 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' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
return $state;
|
||||
}
|
||||
|
||||
@@ -118,14 +119,14 @@ 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 === '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 +159,29 @@ 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;
|
||||
}
|
||||
|
||||
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
|
||||
{
|
||||
if (!plugin_permission_allowed('api.endpoints')) return;
|
||||
@@ -216,10 +233,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 +289,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 +326,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 +334,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 +378,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 +544,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
|
||||
@@ -642,9 +726,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 +870,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]);
|
||||
|
||||
@@ -1,157 +1,159 @@
|
||||
<?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'],
|
||||
'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'],
|
||||
'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,598 @@
|
||||
<?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];
|
||||
}
|
||||
|
||||
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 !== '') return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
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 === '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 (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';
|
||||
$connection = status_app_connection($pdo, $platform);
|
||||
$ok = $connection['online'];
|
||||
if (!$ok) $error = $connection['last_seen'] === null
|
||||
? 'Noch keine Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android') . ' gemeldet'
|
||||
: 'Keine aktuelle Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android');
|
||||
} 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 ($check['type'] === 'tcp') {
|
||||
if (preg_match('/^([a-zA-Z0-9.-]+):(\\d{1,5})$/', (string) $check['target'], $m) && (int) $m[2] > 0 && (int) $m[2] <= 65535) {
|
||||
$socket = @stream_socket_client('tcp://' . $m[1] . ':' . $m[2], $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 ($check['type'] === 'http') {
|
||||
$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)];
|
||||
}
|
||||
|
||||
function status_run_component_checks(PDO $pdo): 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;
|
||||
$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');
|
||||
$currentStatus = 'operational';
|
||||
} elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') {
|
||||
status_set_component($pdo, (int) $component['id'], 'outage');
|
||||
$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);
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
function status_components(PDO $pdo): array
|
||||
{
|
||||
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
|
||||
}
|
||||
|
||||
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',
|
||||
'tcp' => 'Server und Port',
|
||||
'manual' => 'Manuell verwalten',
|
||||
];
|
||||
}
|
||||
|
||||
/** 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 ['Noch keine Verbindung von ' . $label . ' gemeldet'];
|
||||
$when = format_dt($connection['last_seen']);
|
||||
return [$connection['online'] ? 'Verbunden · letzter Kontakt ' . $when : 'Letzter Kontakt ' . $when . ' · älter als ' . (int) $connection['timeout_minutes'] . ' Minuten'];
|
||||
}
|
||||
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): bool
|
||||
{
|
||||
if (!isset(status_monitor_types()[$type])) 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 !== '' && !preg_match('/^[a-zA-Z0-9.-]+:\\d{1,5}$/', $target)) return false;
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$type, $target !== '' ? $target : null, $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) 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 WHERE status <> 'resolved'")->fetchColumn();
|
||||
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
|
||||
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): array
|
||||
{
|
||||
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||||
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
$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;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/** 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('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']],
|
||||
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
|
||||
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
|
||||
['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]) {
|
||||
$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, '');
|
||||
$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']], 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)),
|
||||
];
|
||||
}
|
||||
@@ -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,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'],
|
||||
];
|
||||
}
|
||||
@@ -31,6 +31,8 @@ function web_handoff_pages(array $staff): array
|
||||
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin],
|
||||
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin],
|
||||
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin],
|
||||
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin],
|
||||
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin],
|
||||
['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
|
||||
['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
|
||||
['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* lang.php – Sprache wechseln (Klick auf eine Flagge): /lang.php?l=en&back=/pfad
|
||||
* Speichert die Wahl in einem Cookie und leitet auf die Seite zurück, von der man kam.
|
||||
*/
|
||||
define('DESKHUB_NO_I18N', true);
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$code = strtolower((string) ($_GET['l'] ?? ''));
|
||||
if (isset(i18n_languages()[$code])) {
|
||||
setcookie(I18N_COOKIE, $code, [
|
||||
'expires' => time() + 365 * 86400,
|
||||
'path' => '/',
|
||||
'secure' => request_is_https(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
header('Cache-Control: no-store');
|
||||
redirect(i18n_safe_back((string) ($_GET['back'] ?? '/')));
|
||||
@@ -5,8 +5,9 @@ if (current_user()) { redirect('/home.php'); }
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
|
||||
$errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$pass = $_POST['password'] ?? '';
|
||||
|
||||
@@ -62,6 +63,7 @@ $imprintHtml = get_setting('footer_imprint_html', '');
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
<body class="login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh">
|
||||
<?= i18n_switcher_html('floating') ?>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="site-header">
|
||||
@@ -148,5 +150,10 @@ $imprintHtml = get_setting('footer_imprint_html', '');
|
||||
Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
|
||||
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
|
||||
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -32,7 +32,7 @@ plugins/mein-plugin/
|
||||
}
|
||||
```
|
||||
|
||||
`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
|
||||
`permissions` nennt die Erweiterungspunkte, die das Plugin nutzt (`hooks.tickets`, `hooks.cron`, `ui.menu`, `ui.sidebar`, `api.endpoints`, `api.public`, `db.migrations`). Der Admin bestätigt sie bei der Aktivierung; nicht deklarierte Erweiterungspunkte werden abgelehnt. Das ist keine Sandbox. Die Hooks `cron.minute` (etwa jede Minute, für kurze Prüfabstände) und `cron.run` (höchstens alle 5 Minuten), beide mit der Berechtigung `hooks.cron`, werden ausgelöst vom echten Server-Cronjob (`cron/run_plugins.php`, wird unter Einstellungen → Cron-Jobs eingetragen) und vom eingebauten Hintergrund-Betrieb, nie doppelt. Der Filter `staff.page.top` (Berechtigung `ui.sidebar`) ergänzt einen Hinweis oben auf jeder Seite des Mitarbeiterbereichs (fertiges HTML; Kontext: `staff`, `activePage`). Vollständige Dokumentation inklusive Signieren: README.md im Hauptverzeichnis.
|
||||
|
||||
## Erweiterungspunkte
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
</main>
|
||||
</div>
|
||||
<script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script>
|
||||
<script src="/assets/js/live-tickets.js?v=<?= @filemtime(__DIR__ . '/../assets/js/live-tickets.js') ?: time() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
|
||||
$staff = current_staff();
|
||||
$activePage = $activePage ?? '';
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
@@ -36,8 +37,11 @@ if ('serviceWorker' in navigator) {
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
|
||||
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">☰</button>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">«</button>
|
||||
<div class="brand">
|
||||
<?php $logo = get_setting('logo_path'); ?>
|
||||
<?php if ($logo): ?>
|
||||
@@ -102,12 +106,17 @@ if ('serviceWorker' in navigator) {
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
|
||||
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
|
||||
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
|
||||
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<span class="nav-group-label">Kunden</span>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
|
||||
@@ -173,6 +182,7 @@ if ('serviceWorker' in navigator) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lang-row" style="margin-bottom:10px"><?= i18n_switcher_html('dark') ?></div>
|
||||
<div style="display:flex; gap:14px; align-items:center">
|
||||
|
||||
<?php
|
||||
@@ -354,12 +364,14 @@ window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
|
||||
<main class="main">
|
||||
<!-- Session-Timer: kompakte Pille oben rechts -->
|
||||
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
|
||||
</div>
|
||||
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
|
||||
<?php foreach (get_flashes() as $f): ?>
|
||||
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
|
||||
$info = app_download_info((string) ($_GET['platform'] ?? ''));
|
||||
$platform = (string) ($_GET['platform'] ?? '');
|
||||
if ($platform === 'minecraft' && !app_minecraft_enabled()) {
|
||||
http_response_code(404);
|
||||
exit('Diese App ist nicht hinterlegt.');
|
||||
}
|
||||
$info = app_publisher_info($platform);
|
||||
if (!$info) {
|
||||
http_response_code(404);
|
||||
exit('Diese App ist nicht hinterlegt.');
|
||||
@@ -18,4 +23,21 @@ header('Content-Disposition: attachment; filename="' . $info['filename'] . '"');
|
||||
header('Content-Length: ' . $info['size']);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: private, no-store');
|
||||
readfile($info['path']);
|
||||
if (($info['source'] ?? '') === 'publisher') {
|
||||
session_write_close();
|
||||
@set_time_limit(0);
|
||||
$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 {
|
||||
$context = stream_context_create(['http' => ['timeout' => 0, 'header' => implode("\r\n", $headers)]]);
|
||||
$remote = @fopen($url, 'rb', false, $context);
|
||||
if ($remote) { fpassthru($remote); fclose($remote); }
|
||||
}
|
||||
} else {
|
||||
readfile($info['path']);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_staff_login();
|
||||
$isAdmin = ($staff['role'] ?? '') === 'admin';
|
||||
|
||||
if ($isAdmin) app_downloads_prepare_dir(); // Ordner anlegen und Direktzugriff sperren
|
||||
|
||||
$apps = [];
|
||||
foreach (array_keys(app_platforms()) as $key) $apps[$key] = app_download_info($key);
|
||||
$visiblePlatforms = array_filter(app_platforms(), fn($k) => $k !== 'minecraft' || app_minecraft_enabled(), ARRAY_FILTER_USE_KEY);
|
||||
foreach (array_keys($visiblePlatforms) as $key) $apps[$key] = app_publisher_info($key);
|
||||
$serverUrl = base_url();
|
||||
|
||||
$pageTitle = 'Apps';
|
||||
@@ -27,20 +26,21 @@ require __DIR__ . '/_header.php';
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px">
|
||||
<?php foreach (app_platforms() as $key => $p): $a = $apps[$key]; ?>
|
||||
<?php foreach ($visiblePlatforms as $key => $p): $a = $apps[$key]; $noun = $key === "minecraft" ? "Plugin" : "App"; ?>
|
||||
<div class="card" style="margin:0">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px"><?= e($p['label']) ?></div>
|
||||
<?php if ($a): ?>
|
||||
<div style="font-size:1.3rem;font-weight:800"><?= e($p['label']) ?>-App</div>
|
||||
<div style="font-size:1.3rem;font-weight:800"><?= e($p['label']) ?>-<?= $noun ?><?= !empty($a['version']) ? ' · ' . e($a['version']) : '' ?></div>
|
||||
<div class="muted" style="font-size:.82rem;margin:4px 0 12px">
|
||||
<?= e(app_format_size($a['size'])) ?> · Stand <?= e(ui_date($a['modified_at'])) ?>
|
||||
</div>
|
||||
<a class="btn" href="/staff/app_download.php?platform=<?= e($key) ?>">⬇ <?= e($p['label']) ?>-App herunterladen</a>
|
||||
<a class="btn" href="/staff/app_download.php?platform=<?= e($key) ?>">⬇ <?= e($p['label']) ?>-<?= $noun ?> herunterladen</a>
|
||||
<div class="muted" style="font-size:.72rem;margin-top:10px;word-break:break-all">SHA-256: <?= e($a['sha256']) ?></div>
|
||||
<?php if (!empty($a['notes'])): ?><div class="muted" style="font-size:.82rem;margin-top:8px"><?= nl2br(e($a['notes'])) ?></div><?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="muted">Hier ist noch keine <?= e($p['label']) ?>-App hinterlegt.<?= $isAdmin ? '' : ' Bitte einen Administrator fragen.' ?></div>
|
||||
<div class="muted">Für <?= e($p['label']) ?> ist noch keine Version auf der Plugin-Website veröffentlicht.<?= $isAdmin ? '' : ' Bitte einen Administrator fragen.' ?></div>
|
||||
<?php if ($isAdmin): ?>
|
||||
<div style="font-size:.85rem;margin-top:8px">Datei per FTP in den Ordner <code>app_downloads/</code> legen und genau so benennen: <code><?= e($p['stored']) ?></code></div>
|
||||
<div style="font-size:.85rem;margin-top:8px">Veröffentliche die Version im Administrationsbereich der Plugin-Website.</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -52,6 +52,13 @@ require __DIR__ . '/_header.php';
|
||||
<li>Falls Windows warnt („Der Computer wurde geschützt“): <em>Weitere Informationen → Trotzdem ausführen</em>.</li>
|
||||
<li>App öffnen, Serveradresse eingeben und anmelden.</li>
|
||||
</ol>
|
||||
<?php elseif ($key === 'minecraft'): ?>
|
||||
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
|
||||
<li>Jar herunterladen und in den Ordner <code>plugins</code> deines Spigot-, Paper- oder BungeeCord-Servers legen.</li>
|
||||
<li>Server einmal starten, dann <code>plugins/DeskHub-Minecraft/config.yml</code> ausfüllen: Serveradresse (oben), API-Schlüssel und Absender-E-Mail.</li>
|
||||
<li>API-Schlüssel mit dem Recht „Tickets anlegen“ erzeugst du unter Verwalten → API-Schlüssel.</li>
|
||||
<li>Mit <code>/deskhubmc test</code> ein Testticket anlegen. Bei einem Ausfall des Servers legt das Plugin selbst ein Ticket an.</li>
|
||||
</ol>
|
||||
<?php else: ?>
|
||||
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
|
||||
<li>Datei auf dem Handy herunterladen und öffnen.</li>
|
||||
@@ -65,9 +72,5 @@ require __DIR__ . '/_header.php';
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<p class="muted" style="font-size:.82rem;margin-top:18px">
|
||||
Hinweis für Administratoren: Zum Aktualisieren einer App die neue Datei per FTP in <code>app_downloads/</code> hochladen und die alte ersetzen (Namen: <code>DeskHub-Setup.exe</code> und <code>DeskHub.apk</code>). Die Seite zeigt automatisch Größe, Datum und Prüfsumme der neuen Datei. Die Dateien sind nur für angemeldete Mitarbeiter abrufbar.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($isAdmin): ?><p class="muted" style="font-size:.82rem;margin-top:18px">Neue App-Versionen veröffentlichst du in der Plugin-Website unter „DeskHub-Apps veröffentlichen“. Downloads hier bleiben auf angemeldete Mitarbeiter beschränkt.</p><?php endif; ?>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
|
||||
@@ -320,7 +320,7 @@ $tabs = ['settings' => 'Einstellungen', 'answers' => 'Zusätzliche Antworten' .
|
||||
<?php foreach ($logResults as $key => $label): ?><option value="<?= e($key) ?>" <?= $logFilter['result'] === $key ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<select name="lp" style="width:auto">
|
||||
<?php foreach (['today' => 'Heute', '7' => 'Letzte 7 Tage', '30' => 'Letzte 30 Tage', 'all' => 'Gesamter Zeitraum'] as $k => $l): ?><option value="<?= e($k) ?>" <?= $logFilter['period'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?>
|
||||
<?php foreach (['today' => 'Heute', '7' => 'Letzte 7 Tage', '30' => 'Letzte 30 Tage', 'all' => 'Gesamter Zeitraum'] as $k => $l): ?><option value="<?= e($k) ?>" <?= $logFilter['period'] === (string) $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<input type="text" name="lq" value="<?= e($logFilter['q']) ?>" placeholder="Suchen…" maxlength="100" style="flex:1;min-width:160px">
|
||||
<button type="submit" class="btn secondary">Filtern</button>
|
||||
|
||||
@@ -29,7 +29,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create_backup') {
|
||||
$result = create_backup();
|
||||
// Lange Aufgabe: Sitzungssperre freigeben (sonst blockiert sie jede weitere Seite dieses Mitarbeiters) und bei Abbruch des Browsers weiterlaufen
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(600);
|
||||
if (!backup_lock_acquire($pdo)) {
|
||||
flash('error', 'Es läuft bereits ein Backup (automatisch oder von jemand anderem gestartet). Bitte warte, bis es fertig ist, und prüfe dann die Liste.');
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
session_write_close();
|
||||
try {
|
||||
$result = create_backup();
|
||||
} finally {
|
||||
backup_lock_release($pdo);
|
||||
}
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||
if ($result['ok']) {
|
||||
// FTP-Upload wenn konfiguriert
|
||||
$ftpHost = bget('ftp_host');
|
||||
@@ -42,6 +55,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($ftpHost && $ftpUser && function_exists('ftp_connect')) {
|
||||
$ftp = $ftpTls ? @ftp_ssl_connect($ftpHost, $ftpPort, 10) : @ftp_connect($ftpHost, $ftpPort, 10);
|
||||
if ($ftp && @ftp_login($ftp, $ftpUser, $ftpPass)) {
|
||||
@ftp_set_option($ftp, FTP_TIMEOUT_SEC, 120); // sonst wartet die Seite bei hängender Verbindung bis zu 90 s je Schritt
|
||||
ftp_pasv($ftp, true);
|
||||
$remote = rtrim($ftpPath, '/') . '/' . $result['filename'];
|
||||
$ftpMsg = ftp_put($ftp, $remote, $result['path'], FTP_BINARY)
|
||||
|
||||
@@ -60,21 +60,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
redirect('/staff/chat.php?with=' . $toId);
|
||||
}
|
||||
|
||||
function chatAvClass(string $name): string {
|
||||
$l = strtolower(mb_substr($name, 0, 1));
|
||||
return 'av-' . (ctype_alpha($l) ? $l : 'a');
|
||||
}
|
||||
|
||||
// AJAX: neue Nachrichten pollen
|
||||
if (($_GET['action'] ?? '') === 'poll' && $withId) {
|
||||
header('Content-Type: application/json');
|
||||
$since = $_GET['since'] ?? '1970-01-01 00:00:00';
|
||||
$after = max(0, (int) ($_GET['after'] ?? 0));
|
||||
$pStmt = $pdo->prepare(
|
||||
"SELECT m.*, sf.name AS from_name FROM staff_chat_messages m
|
||||
JOIN staff sf ON sf.id = m.from_staff_id
|
||||
WHERE ((m.from_staff_id=? AND m.to_staff_id=?) OR (m.from_staff_id=? AND m.to_staff_id=?))
|
||||
AND m.created_at > ?
|
||||
ORDER BY m.created_at ASC"
|
||||
AND m.id > ?
|
||||
ORDER BY m.id ASC"
|
||||
);
|
||||
$pStmt->execute([$staff['id'], $withId, $withId, $staff['id'], $since]);
|
||||
$pStmt->execute([$staff['id'], $withId, $withId, $staff['id'], $after]);
|
||||
$pdo->prepare('UPDATE staff_chat_messages SET is_read=1 WHERE from_staff_id=? AND to_staff_id=?')
|
||||
->execute([$withId, $staff['id']]);
|
||||
echo json_encode($pStmt->fetchAll());
|
||||
// Anzeige-Angaben gleich mitliefern: Das Gerüst im Browser baut die Blase genau wie die Seite selbst
|
||||
echo json_encode(array_map(fn(array $m) => [
|
||||
'id' => (int) $m['id'], 'from_staff_id' => (int) $m['from_staff_id'], 'from_name' => $m['from_name'], 'message' => $m['message'],
|
||||
'created_at' => $m['created_at'], 'initial' => mb_strtoupper(mb_substr($m['from_name'], 0, 1)), 'av_class' => chatAvClass($m['from_name']),
|
||||
], $pStmt->fetchAll()));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -138,10 +147,6 @@ require __DIR__ . '/_header.php';
|
||||
</style>
|
||||
|
||||
<?php
|
||||
function chatAvClass(string $name): string {
|
||||
$l = strtolower(mb_substr($name, 0, 1));
|
||||
return 'av-' . (ctype_alpha($l) ? $l : 'a');
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="ac-wrap">
|
||||
@@ -173,7 +178,7 @@ function chatAvClass(string $name): string {
|
||||
<!-- Nachrichten -->
|
||||
<div id="chat-messages" class="ac-msgs">
|
||||
<?php if (!$conversation): ?>
|
||||
<div style="text-align:center;color:#9ca3af;font-size:.82rem;padding:30px 0;margin:auto">Noch keine Nachrichten – schreib etwas!</div>
|
||||
<div id="chat-empty" style="text-align:center;color:#9ca3af;font-size:.82rem;padding:30px 0;margin:auto">Noch keine Nachrichten – schreib etwas!</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$prevDate = '';
|
||||
@@ -187,7 +192,7 @@ function chatAvClass(string $name): string {
|
||||
?>
|
||||
<div style="text-align:center;font-size:.72rem;color:#9ca3af;padding:12px 0"><?= date('d.m.Y', strtotime($date)) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="ac-msg <?= $isMe?'me':'them' ?>" data-ts="<?= e($msg['created_at']) ?>">
|
||||
<div class="ac-msg <?= $isMe?'me':'them' ?>" data-id="<?= (int) $msg['id'] ?>" data-ts="<?= e($msg['created_at']) ?>">
|
||||
<?php if (!$isMe): ?>
|
||||
<div class="ac-msg-av <?= $cls ?>"><?= e($ini) ?></div>
|
||||
<?php endif; ?>
|
||||
@@ -254,7 +259,7 @@ var withId = <?= $withId ?>;
|
||||
var csrfToken = document.querySelector('[name=csrf_token]').value;
|
||||
// Token nach Rotation aktualisieren
|
||||
function getCsrf() { return document.querySelector('[name=csrf_token]').value; }
|
||||
var lastTs = <?= $conversation ? json_encode(end($conversation)['created_at']) : json_encode('1970-01-01 00:00:00') ?>;
|
||||
var lastId = <?= $conversation ? (int) end($conversation)['id'] : 0 ?>;
|
||||
|
||||
function insertEmoji(em) {
|
||||
var input = document.getElementById('chat-input');
|
||||
@@ -279,14 +284,52 @@ function scrollBottom() {
|
||||
scrollBottom();
|
||||
|
||||
function appendBubble(msg) {
|
||||
var box = document.getElementById('chat-messages');
|
||||
if (box.querySelector('[data-id="' + msg.id + '"]')) return; // schon da
|
||||
var empty = document.getElementById('chat-empty');
|
||||
if (empty) empty.remove();
|
||||
var isMe = msg.from_staff_id == myId;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'chat-bubble ' + (isMe ? 'me' : 'them');
|
||||
div.dataset.ts = msg.created_at;
|
||||
div.innerHTML = '<div class="bubble-text">' + msg.message.replace(/&/g,'&').replace(/</g,'<').replace(/\n/g,'<br>') + '</div>'
|
||||
+ '<div class="bubble-time">' + msg.created_at + '</div>';
|
||||
document.getElementById('chat-messages').appendChild(div);
|
||||
lastTs = msg.created_at;
|
||||
var all = box.querySelectorAll('.ac-msg');
|
||||
var last = all.length ? all[all.length - 1] : null;
|
||||
var day = msg.created_at.slice(0, 10);
|
||||
if (!last || last.dataset.ts.slice(0, 10) !== day) {
|
||||
var sep = document.createElement('div');
|
||||
sep.style.cssText = 'text-align:center;font-size:.72rem;color:#9ca3af;padding:12px 0';
|
||||
sep.textContent = day.slice(8, 10) + '.' + day.slice(5, 7) + '.' + day.slice(0, 4);
|
||||
box.appendChild(sep);
|
||||
}
|
||||
var row = document.createElement('div');
|
||||
row.className = 'ac-msg ' + (isMe ? 'me' : 'them');
|
||||
row.dataset.id = msg.id;
|
||||
row.dataset.ts = msg.created_at;
|
||||
if (!isMe) {
|
||||
var av = document.createElement('div');
|
||||
av.className = 'ac-msg-av ' + msg.av_class;
|
||||
av.textContent = msg.initial;
|
||||
row.appendChild(av);
|
||||
}
|
||||
var body = document.createElement('div');
|
||||
body.className = 'ac-msg-body';
|
||||
if (!isMe) {
|
||||
var name = document.createElement('div');
|
||||
name.className = 'ac-msg-name';
|
||||
name.textContent = msg.from_name;
|
||||
body.appendChild(name);
|
||||
}
|
||||
var bubble = document.createElement('div');
|
||||
bubble.className = 'ac-msg-bubble';
|
||||
msg.message.split('\n').forEach(function (line, i) {
|
||||
if (i) bubble.appendChild(document.createElement('br'));
|
||||
bubble.appendChild(document.createTextNode(line));
|
||||
});
|
||||
body.appendChild(bubble);
|
||||
var time = document.createElement('div');
|
||||
time.className = 'ac-msg-time';
|
||||
time.textContent = msg.created_at.slice(11, 16);
|
||||
body.appendChild(time);
|
||||
row.appendChild(body);
|
||||
box.appendChild(row);
|
||||
if (msg.id > lastId) lastId = msg.id;
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
@@ -300,23 +343,19 @@ function sendMsg() {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
|
||||
body: 'csrf_token=' + encodeURIComponent(getCsrf()) + '&to_id=' + withId + '&message=' + encodeURIComponent(msg)
|
||||
}).then(function() {
|
||||
appendBubble({from_staff_id: myId, message: msg, created_at: new Date().toISOString().slice(0,19).replace('T',' ')});
|
||||
poll();
|
||||
});
|
||||
}).then(function() { poll(); }); // die eigene Nachricht kommt mit Nummer und Serverzeit zurück
|
||||
}
|
||||
|
||||
var polling = false;
|
||||
function poll() {
|
||||
fetch('/staff/chat.php?with=' + withId + '&action=poll&since=' + encodeURIComponent(lastTs))
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
fetch('/staff/chat.php?with=' + withId + '&action=poll&after=' + lastId)
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(msgs) {
|
||||
msgs.forEach(function(m) {
|
||||
if (m.from_staff_id != myId) appendBubble(m);
|
||||
lastTs = m.created_at;
|
||||
});
|
||||
});
|
||||
.then(function(msgs) { msgs.forEach(appendBubble); })
|
||||
.catch(function() {})
|
||||
.then(function() { polling = false; });
|
||||
}
|
||||
|
||||
setInterval(poll, 8000);
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -45,6 +45,7 @@ $pageTitle = 'Passwort vergessen';
|
||||
<?= site_icon_links() ?>
|
||||
</head>
|
||||
<body>
|
||||
<?= i18n_switcher_html('floating') ?>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$pdo = get_db();
|
||||
$langs = i18n_languages(true);
|
||||
|
||||
// Vorlage für Übersetzer: alle gesammelten deutschen Texte, bereits Übersetztes ist eingetragen, der Rest leer
|
||||
if (isset($_GET['download'])) {
|
||||
$code = strtolower((string) $_GET['download']);
|
||||
$dictFile = $langs[$code]['strings'] ?? '';
|
||||
$existing = $dictFile !== '' && is_file($dictFile) ? (json_decode((string) file_get_contents($dictFile), true) ?: []) : [];
|
||||
$out = [];
|
||||
foreach ($pdo->query('SELECT text FROM i18n_missing ORDER BY hits DESC, text')->fetchAll(PDO::FETCH_COLUMN) as $t) $out[$t] = '';
|
||||
foreach ($existing as $k => $v) if (is_string($v) && ($k === '' || $k[0] !== '_')) $out[$k] = $v;
|
||||
$out = ['_info' => 'Schlüssel = deutscher Text, Wert = Übersetzung. {} steht für wechselnde Teile. Leere Werte werden ignoriert.'] + $out;
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="strings-' . preg_replace('/[^a-z\-]/', '', $code) . '.json"');
|
||||
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'save') {
|
||||
$def = strtolower((string) ($_POST['default_language'] ?? 'de'));
|
||||
set_setting('default_language', isset($langs[$def]) ? $def : 'de');
|
||||
set_setting('i18n_collect', !empty($_POST['i18n_collect']) ? '1' : '0');
|
||||
audit_log($admin['id'], 'Sprach-Einstellungen geändert');
|
||||
flash('success', 'Gespeichert.');
|
||||
} elseif ($action === 'clear') {
|
||||
$pdo->exec('DELETE FROM i18n_missing');
|
||||
flash('success', 'Gesammelte Texte gelöscht.');
|
||||
}
|
||||
redirect('/staff/languages.php');
|
||||
}
|
||||
|
||||
$collected = (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
|
||||
$collect = get_setting('i18n_collect', '0') === '1';
|
||||
$pageTitle = 'Sprachen';
|
||||
$activePage = 'languages';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1>Sprachen</h1>
|
||||
<p class="muted" style="max-width:860px">Jede Sprache ist ein Plugin („Sprachpaket“): Deutsch ist eingebaut, weitere Sprachen installieren Sie unter Plugins. Besucher und Mitarbeiter wechseln die Sprache über die Flaggen. Texte ohne Übersetzung bleiben deutsch.</p>
|
||||
|
||||
<div class="card" style="max-width:860px;margin-bottom:16px">
|
||||
<h3>Installierte Sprachen</h3>
|
||||
<?php foreach ($langs as $code => $l): $n = $code === 'de' ? null : i18n_dictionary($code)['count']; ?>
|
||||
<div style="display:flex;gap:12px;align-items:center;padding:8px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||
<?php if ($l['flag'] !== ''): ?><img src="<?= e($l['flag']) ?>" alt="" width="30" height="20" style="width:30px;height:20px;object-fit:cover;border-radius:3px;border:1px solid rgba(0,0,0,.15)"><?php endif; ?>
|
||||
<div style="flex:1"><strong><?= e($l['name']) ?></strong> <span class="muted" style="font-size:.8rem"><?= e($code) ?><?= $l['slug'] !== '' ? ' · Plugin ' . e($l['slug']) : ' · eingebaut (Quellsprache)' ?></span></div>
|
||||
<div class="muted" style="font-size:.85rem"><?= $n === null ? '' : $n . ' Übersetzungen' ?></div>
|
||||
<a class="btn secondary" style="padding:3px 12px;font-size:.8rem" href="/staff/languages.php?download=<?= e($code) ?>"><?= $code === 'de' ? 'Leere Vorlage laden' : 'Vorlage laden' ?></a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (count($langs) < 2): ?><p class="muted" style="margin-top:8px">Noch kein Sprachpaket aktiv. Installieren Sie zum Beispiel „English“ unter <a href="/staff/plugins.php">Plugins</a>; dann erscheinen die Flaggen.</p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post" class="card" style="max-width:860px;margin-bottom:16px"><?= csrf_field() ?><input type="hidden" name="action" value="save">
|
||||
<h3>Einstellungen</h3>
|
||||
<label for="default_language">Standardsprache (für alle, die noch keine Flagge gewählt haben)</label>
|
||||
<select id="default_language" name="default_language" style="max-width:260px"><?php foreach ($langs as $code => $l): ?><option value="<?= e($code) ?>" <?= i18n_default() === $code ? 'selected' : '' ?>><?= e($l['name']) ?></option><?php endforeach; ?></select>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;margin-top:12px;font-weight:400"><input type="checkbox" name="i18n_collect" value="1" style="width:auto;margin-top:3px" <?= $collect ? 'checked' : '' ?>>
|
||||
<span><strong>Sammelmodus für Übersetzer:</strong> merkt sich die Texte aller besuchten Seiten (auch Kundenportal und Mitarbeiterbereich). Klicken Sie die Seiten durch, laden Sie danach die Vorlage. Bitte nach dem Sammeln wieder ausschalten, er verlangsamt die Seiten etwas.</span></label>
|
||||
<div class="btn-row"><button class="btn" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
|
||||
<div class="card" style="max-width:860px;margin-bottom:16px">
|
||||
<h3>Gesammelte Texte: <?= $collected ?></h3>
|
||||
<p class="muted" style="font-size:.88rem">Das sind deutsche Texte, die der Sammelmodus gesehen hat. Sie bilden die Vorlage für ein neues Sprachpaket.</p>
|
||||
<?php if ($collected): ?><form method="post" onsubmit="return confirm('Gesammelte Texte löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="clear"><button class="btn secondary" type="submit">Alle löschen</button></form><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:860px">
|
||||
<h3>Eigene Sprache als Plugin erstellen</h3>
|
||||
<ol style="line-height:1.7;padding-left:20px;font-size:.92rem">
|
||||
<li>Sammelmodus einschalten, die Seiten durchklicken, dann bei Deutsch „Leere Vorlage laden“ (alle gesammelten Texte, noch ohne Übersetzung). Für Ergänzungen zu einer vorhandenen Sprache bei dieser „Vorlage laden“: bereits Übersetztes ist eingetragen.</li>
|
||||
<li>In der Datei <code>strings.json</code> die Werte übersetzen. Der Schlüssel ist der deutsche Text, <code>{}</code> steht für wechselnde Teile („Willkommen, {}“ → „Welcome, {}“).</li>
|
||||
<li>Ordner mit diesen Dateien packen: <code>plugin.json</code>, <code>plugin.php</code> (kann leer sein), <code>flag.svg</code> (Flagge, höchstens 40 KB), <code>strings.json</code>.</li>
|
||||
<li>Die <code>plugin.json</code> sieht so aus: <code>{"name":"Français","version":"1.0.0","entry":"plugin.php","language":{"code":"fr","name":"Français","flag":"flag.svg","strings":"strings.json"}}</code></li>
|
||||
<li>ZIP unter Plugins hochladen und aktivieren (das Sprachpaket braucht keine Berechtigungen). Die Flagge erscheint sofort.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/live_tickets.php
|
||||
*
|
||||
* Leichter Abruf für die Live-Anzeige im Mitarbeiterbereich (assets/js/live-tickets.js): aktuelle Kennzahlen und Tickets, die seit der
|
||||
* zuletzt gesehenen Ticket-ID neu angelegt wurden. Jeder sieht nur Tickets seiner Abteilungen.
|
||||
* ?since=0 liefert nur den Ausgangsstand (keine „neuen“ Tickets), damit beim ersten Aufruf nichts aufpoppt.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = current_staff();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
if (!$staff) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'login']);
|
||||
exit;
|
||||
}
|
||||
// Abgelaufene Sitzung (Inaktivität) gilt auch hier als abgemeldet; dieser Abruf verlängert die Sitzung nie
|
||||
$__timeout = (int) (get_setting('staff_session_timeout', '30') ?: 30);
|
||||
$__last = (int) ($_SESSION['staff_last_activity'] ?? 0);
|
||||
if ($__last && (time() - $__last) > $__timeout * 60) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'login', 'sessionExpired' => true]);
|
||||
exit;
|
||||
}
|
||||
// Die Sitzungsdatei sofort freigeben: Der Abruf läuft alle paar Sekunden und soll andere Seitenaufrufe nicht ausbremsen
|
||||
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
|
||||
|
||||
$pdo = get_db();
|
||||
$since = max(0, (int) ($_GET['since'] ?? 0));
|
||||
[$deptFilter, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||||
$count = function (string $where, array $params = []) use ($pdo, $deptFilter, $deptParams): int {
|
||||
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE {$where}{$deptFilter}");
|
||||
$s->execute(array_merge($params, $deptParams));
|
||||
return (int) $s->fetchColumn();
|
||||
};
|
||||
$open = "t.status NOT IN ('resolved','closed')";
|
||||
$counts = [
|
||||
'open' => $count($open),
|
||||
'mine' => $count("t.assigned_staff_id = ? AND {$open}", [(int) $staff['id']]),
|
||||
'unassigned' => $count("t.assigned_staff_id IS NULL AND {$open}"),
|
||||
'overdue' => $count("{$open} AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?", [db_now()]),
|
||||
'unanswered' => $count("t.status = 'open'"),
|
||||
];
|
||||
|
||||
// Neue Tickets: ohne Spam, ohne Live-Chats (die haben eigene Hinweise) und ohne zusammengeführte
|
||||
$visible = "t.is_spam = 0 AND t.source <> 'chat' AND t.merged_into IS NULL";
|
||||
$m = $pdo->prepare("SELECT COALESCE(MAX(t.id), 0) FROM tickets t WHERE {$visible}{$deptFilter}");
|
||||
$m->execute($deptParams);
|
||||
$latest = (int) $m->fetchColumn();
|
||||
$tickets = [];
|
||||
$more = 0;
|
||||
if ($since > 0 && $latest > $since) {
|
||||
$s = $pdo->prepare("SELECT t.id, t.ticket_number, t.subject, t.requester_name, d.name AS department FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
|
||||
WHERE t.id > ? AND {$visible}{$deptFilter} ORDER BY t.id ASC LIMIT 6");
|
||||
$s->execute(array_merge([$since], $deptParams));
|
||||
foreach ($s->fetchAll() as $t) {
|
||||
$tickets[] = ['id' => (int) $t['id'], 'number' => (string) $t['ticket_number'], 'subject' => mb_substr((string) $t['subject'], 0, 120), 'name' => mb_substr((string) $t['requester_name'], 0, 60), 'department' => (string) ($t['department'] ?? '')];
|
||||
}
|
||||
$c = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE t.id > ? AND {$visible}{$deptFilter}");
|
||||
$c->execute(array_merge([$since], $deptParams));
|
||||
$more = max(0, (int) $c->fetchColumn() - count($tickets));
|
||||
}
|
||||
echo json_encode(['latest' => $latest, 'counts' => $counts, 'tickets' => $tickets, 'more' => $more], JSON_UNESCAPED_UNICODE);
|
||||
@@ -9,8 +9,10 @@ if (current_staff()) {
|
||||
$errors = [];
|
||||
$need2fa = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
|
||||
// Die Anmeldeseite war so lange offen, dass die Sitzung auf dem Server inzwischen aufgeräumt wurde: neu anmelden statt Fehlerseite
|
||||
$errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? 'login';
|
||||
|
||||
if ($action === 'verify_totp') {
|
||||
@@ -82,25 +84,26 @@ $displayName = get_setting('display_name', config()['site']['name']);
|
||||
<?= site_icon_links() ?>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||||
</head>
|
||||
<body class="login-page staff-login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh; background:#16202a <?php if($banner): ?>; background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center<?php endif; ?>">
|
||||
<body class="login-page staff-login-page"<?php if ($banner): ?> style="background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center"<?php endif; ?>>
|
||||
<?= i18n_switcher_html('floating') ?>
|
||||
|
||||
<!-- Schlanker Header -->
|
||||
<header style="background:rgba(14,26,34,.95); border-bottom:1px solid rgba(255,255,255,.08); padding:0 28px; height:56px; display:flex; align-items:center; justify-content:space-between; flex-shrink:0">
|
||||
<div style="display:flex; align-items:center; gap:12px">
|
||||
<header class="staff-login-header">
|
||||
<div class="staff-login-brand">
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="" style="height:32px; width:auto">
|
||||
<img src="<?= e($logo) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<?php if (!$logo || get_setting('logo_hide_name', '0') !== '1'): ?>
|
||||
<span style="color:#fff; font-weight:700; font-size:.95rem"><?= e($displayName) ?></span>
|
||||
<span class="staff-login-brand-name"><?= e($displayName) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<a href="/login.php" style="color:rgba(255,255,255,.5); font-size:.8rem; text-decoration:none">← Zum Kundenportal</a>
|
||||
<a href="/login.php" class="staff-login-back">← Zum Kundenportal</a>
|
||||
</header>
|
||||
|
||||
<!-- Auth-Bereich -->
|
||||
<div class="login-screen staff-login-screen">
|
||||
<div class="auth-card login-card staff-login-card">
|
||||
<h1 style="font-size:1.4rem; font-weight:700; margin-bottom:4px; color:#0f1923">Mitarbeiterbereich</h1>
|
||||
<h1 class="staff-login-title">Mitarbeiterbereich</h1>
|
||||
<div class="sub">Anmeldung für Support-Mitarbeiter und Administratoren.</div>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
@@ -142,9 +145,14 @@ $displayName = get_setting('display_name', config()['site']['name']);
|
||||
</div>
|
||||
|
||||
<!-- Schlanker Footer -->
|
||||
<footer style="background:rgba(14,26,34,.95); border-top:1px solid rgba(255,255,255,.08); padding:12px 28px; text-align:center; flex-shrink:0">
|
||||
<span style="color:rgba(255,255,255,.3); font-size:.75rem">Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
|
||||
<footer class="staff-login-footer">
|
||||
<span>Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
|
||||
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
|
||||
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,12 @@ if (!$loaded || !$registered || !is_file($file)) { http_response_code(404); exit
|
||||
$menu = reset($registered);
|
||||
if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); }
|
||||
|
||||
$isAdminPage = ($menu['belongs_to'] ?? null) === 'admin'; // Adminportal-Seite eines Plugins: nur für Administratoren
|
||||
if ($isAdminPage && ($staff['role'] ?? '') !== 'admin') { http_response_code(403); exit('Keine Berechtigung.'); }
|
||||
$pageTitle = $menu['label'];
|
||||
$activePage = !empty($menu['belongs_to']) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“)
|
||||
$activePage = (!empty($menu['belongs_to']) && !$isAdminPage) ? (string) $menu['belongs_to'] : 'plugin_' . $slug; // Reiter-Seiten markieren die übergeordnete Seite (zum Beispiel „Backup“)
|
||||
require __DIR__ . '/_header.php';
|
||||
if (!empty($menu['belongs_to'])) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page);
|
||||
if (!empty($menu['belongs_to']) && !$isAdminPage) echo plugin_subpage_tabs((string) $menu['belongs_to'], $staff, $slug . ':' . $page);
|
||||
plugin_guard($slug, function () use ($file, $staff, $pdo) {
|
||||
// Variablen für die Plugin-Seite: $staff, $pdo
|
||||
include $file;
|
||||
|
||||
@@ -44,8 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$errors[] = $res['error'];
|
||||
} else {
|
||||
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
|
||||
flash('success', ($res['updated'] ? 'Plugin aktualisiert' : 'Plugin installiert') . ": {$res['name']} {$res['version']} (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.'
|
||||
. ($res['needs_reapproval'] ? ' Die neue Version fordert zusätzliche Berechtigungen an und wurde angehalten.' : ''));
|
||||
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
}
|
||||
@@ -54,8 +53,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
|
||||
$res = plugin_catalog_install($pdo, $slug);
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], "Plugin aus dem Katalog installiert: {$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
|
||||
flash('success', "{$res['name']} {$res['version']} installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.');
|
||||
audit_log($staff['id'], ($res['updated'] ? 'Plugin aus dem Katalog aktualisiert: ' : 'Plugin aus dem Katalog installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
|
||||
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
$errors[] = $res['error'];
|
||||
@@ -68,7 +67,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
if (!$errors) {
|
||||
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
|
||||
$approve = !empty($_POST['confirm_permissions']) && $manifest ? $manifest['permissions'] : null;
|
||||
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung; manuell hochgeladene schon
|
||||
$row = plugins_installed($pdo)[$slug] ?? [];
|
||||
$fromCatalog = ($row['source'] ?? '') === 'catalog' && !empty($row['signature_ok']);
|
||||
$approve = (!empty($_POST['confirm_permissions']) || $fromCatalog) && $manifest ? $manifest['permissions'] : null;
|
||||
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
|
||||
@@ -96,7 +98,10 @@ foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(p
|
||||
$disabled = plugins_globally_disabled();
|
||||
$strict = plugin_strict_mode();
|
||||
$official = plugin_official_configured();
|
||||
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh'])) : null;
|
||||
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh']), 60) : null;
|
||||
// Katalogeinträge je Plugin (für die Vorschaubilder der installierten Plugins)
|
||||
$catBySlug = [];
|
||||
if ($catalog && !empty($catalog['ok'])) foreach ($catalog['plugins'] as $__c) $catBySlug[$__c['slug']] = $__c;
|
||||
$hasKey = plugin_publisher_key() !== '';
|
||||
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
|
||||
|
||||
@@ -121,6 +126,7 @@ require __DIR__ . '/_header.php';
|
||||
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $catBySlug[$slug] ?? ['slug' => $slug, 'name' => $p['name']]) ?><div style="min-width:0">
|
||||
<strong><?= e($p['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($slug) ?></span>
|
||||
<?php if ($m && $m['description'] !== ''): ?><div class="muted" style="font-size:.82rem"><?= e($m['description']) ?></div><?php endif; ?>
|
||||
<?php if ($m && $m['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($m['author']) ?></div><?php endif; ?>
|
||||
@@ -130,12 +136,14 @@ require __DIR__ . '/_header.php';
|
||||
</details>
|
||||
<?php elseif ($m): ?><div class="muted" style="font-size:.78rem">Fordert keine Berechtigungen an.</div><?php endif; ?>
|
||||
<?php if (!$m): ?><div style="color:#dc2626;font-size:.82rem">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</div><?php endif; ?>
|
||||
</div></div>
|
||||
</td>
|
||||
<td><?= e($p['version']) ?></td>
|
||||
<td>
|
||||
<?php if ($p['is_active']): ?><span style="color:#15803d;font-weight:600">Aktiv</span>
|
||||
<?php elseif ((int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT): ?><span style="color:#dc2626;font-weight:600">Wegen Fehlern deaktiviert</span>
|
||||
<?php else: ?><span class="muted">Inaktiv</span><?php endif; ?>
|
||||
<?php if (!empty($p['last_error'])): ?><div style="color:#b45309;font-size:.74rem;margin-top:4px;max-width:260px;white-space:normal;word-break:break-word"><?= e(mb_strimwidth((string) $p['last_error'], 0, 300, '…')) ?></div><?php endif; ?>
|
||||
</td>
|
||||
<td style="font-size:.82rem">
|
||||
<?php if (!empty($p['signature_ok'])): ?><span style="color:#15803d;font-weight:600">✓ Signiert</span>
|
||||
@@ -148,7 +156,7 @@ require __DIR__ . '/_header.php';
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:inline-block;text-align:left" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
|
||||
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
|
||||
<?php if ($perms): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
|
||||
<?php if ($perms && !(($p['source'] ?? '') === 'catalog' && !empty($p['signature_ok']))): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
|
||||
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
@@ -182,7 +190,7 @@ require __DIR__ . '/_header.php';
|
||||
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
|
||||
<tr>
|
||||
<td><div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $c) ?><div style="min-width:0">
|
||||
<strong><?= e($c['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
|
||||
<strong><?= e($c['name']) ?></strong> <?php if (!empty($c['language'])): ?><span title="Sprachpaket: nach der Aktivierung erscheint die Flagge zum Umschalten" style="background:#e8f1ff;color:#1d4ed8;border:1px solid #bcd3f7;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">🌐 Sprache: <?= e($c['language']['name']) ?></span> <?php endif; ?><?php if (!empty($c['featured'])): ?><span title="Vom Herausgeber empfohlen" style="background:#fff4d6;color:#92600a;border:1px solid #f1d38a;border-radius:20px;padding:1px 9px;font-size:.72rem;font-weight:700">★ Empfohlen</span><?php endif; ?> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
|
||||
<div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div>
|
||||
<?php if ($c['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($c['author']) ?></div><?php endif; ?>
|
||||
<?php if ($c['permissions']): ?><div class="muted" style="font-size:.76rem">Fordert an: <?= e(implode(', ', array_map(fn($p) => $permLabels[$p] ?? $p, $c['permissions']))) ?></div><?php endif; ?>
|
||||
|
||||
@@ -73,6 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$errors[] = 'Erlaubte Bildformate: JPG, PNG, GIF, WEBP.';
|
||||
} elseif ($_FILES['avatar']['size'] > 2 * 1024 * 1024) {
|
||||
$errors[] = 'Maximale Dateigröße: 2 MB.';
|
||||
} elseif (($imgErr = favicon_validate_file($_FILES['avatar']['tmp_name'], $ext)) !== null) {
|
||||
$errors[] = $imgErr;
|
||||
} else {
|
||||
$dir = __DIR__ . '/../assets/avatars/';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
|
||||
@@ -414,11 +414,11 @@ require __DIR__ . '/_header.php';
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
|
||||
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num" data-live-count="open"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num" data-live-count="mine"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num" data-live-count="unassigned"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
|
||||
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num" data-live-count="overdue"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num" data-live-count="unanswered"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
|
||||
<?php if ($unreadMentions > 0): ?>
|
||||
<div class="stat-card" style="cursor:pointer; border-color:#f59e0b" onclick="location.href='/staff/mentions.php'"><div class="num" style="color:#f59e0b"><?= $unreadMentions ?></div><div class="lbl">🔔 Neue Erwähnungen</div></div>
|
||||
<?php endif; ?>
|
||||
@@ -524,6 +524,50 @@ setInterval(refreshDashTeam, 20000);
|
||||
<div class="stat-card"><div class="num"><?= $avgResolution !== null ? round($avgResolution, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Lösungszeit</div></div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Zufriedenheit (CSAT): nur zeigen, wenn die Abfrage eingeschaltet ist oder es schon Antworten gibt
|
||||
$csat = null;
|
||||
try { $csat = csat_stats($pdo, $rangeFrom, $rangeTo, $myDeptIds); } catch (Throwable $e) { $csat = null; }
|
||||
if ($csat && (csat_enabled() || $csat['sent'] > 0)):
|
||||
?>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3>Kundenzufriedenheit <span class="muted" style="font-weight:400; font-size:.8rem">(Bewertungen im gewählten Zeitraum)</span></h3>
|
||||
<?php if (!$csat['sent']): ?>
|
||||
<p class="muted">Im Zeitraum wurden noch keine Abfragen verschickt.</p>
|
||||
<?php else: ?>
|
||||
<div class="stat-grid" style="margin-bottom:12px">
|
||||
<div class="stat-card"><div class="num"><?= $csat['avg'] !== null ? number_format($csat['avg'], 1, ',', '') . ' ★' : '–' ?></div><div class="lbl">Ø Bewertung</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $csat['answered'] ?></div><div class="lbl">Antworten von <?= $csat['sent'] ?> Abfragen</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $csat['rate'] !== null ? number_format($csat['rate'], 0, ',', '') . ' %' : '–' ?></div><div class="lbl">Rücklaufquote</div></div>
|
||||
</div>
|
||||
<?php $distMax = max(1, max($csat['dist'])); ?>
|
||||
<table style="width:100%;max-width:520px">
|
||||
<?php for ($i = 5; $i >= 1; $i--): ?>
|
||||
<tr><td style="white-space:nowrap;width:1%;color:#f5b301"><?= e(csat_stars($i)) ?></td>
|
||||
<td><div style="background:#e2e8f0;border-radius:6px;height:12px"><div style="background:#f5b301;height:12px;border-radius:6px;width:<?= (int) round($csat['dist'][$i] * 100 / $distMax) ?>%"></div></div></td>
|
||||
<td style="width:1%;text-align:right"><?= $csat['dist'][$i] ?></td></tr>
|
||||
<?php endfor; ?>
|
||||
</table>
|
||||
<?php if ($csat['byStaff']): ?>
|
||||
<h4 style="margin:14px 0 6px">Nach Mitarbeiter</h4>
|
||||
<table style="width:100%;max-width:520px"><?php foreach ($csat['byStaff'] as $row): ?>
|
||||
<tr><td><?= e($row['name']) ?></td><td style="text-align:right"><?= number_format($row['avg'], 1, ',', '') ?> ★</td><td class="muted" style="text-align:right;width:1%;white-space:nowrap"><?= $row['count'] ?> Bewertung<?= $row['count'] === 1 ? '' : 'en' ?></td></tr>
|
||||
<?php endforeach; ?></table>
|
||||
<?php endif; ?>
|
||||
<?php if ($csat['recent']): ?>
|
||||
<h4 style="margin:14px 0 6px">Letzte Bewertungen</h4>
|
||||
<?php foreach ($csat['recent'] as $row): ?>
|
||||
<div style="padding:6px 0;border-top:1px solid var(--border,#e3e9ee);font-size:.88rem">
|
||||
<span style="color:#f5b301"><?= e(csat_stars($row['rating'])) ?></span>
|
||||
<a href="/staff/ticket_view.php?n=<?= e(rawurlencode($row['number'])) ?>">#<?= e($row['number']) ?></a> <?= e($row['subject']) ?>
|
||||
<?php if ($row['comment'] !== ''): ?><div class="muted" style="margin-top:2px">„<?= e($row['comment']) ?>“</div><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="get" class="filters card">
|
||||
<div>
|
||||
<label>Berichtszeitraum</label>
|
||||
|
||||
@@ -42,6 +42,7 @@ $pageTitle = 'Passwort zurücksetzen';
|
||||
<?= site_icon_links() ?>
|
||||
</head>
|
||||
<body>
|
||||
<?= i18n_switcher_html('floating') ?>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$pdo = get_db();
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'settings') {
|
||||
set_setting('status_page_enabled', !empty($_POST['enabled']) ? '1' : '0');
|
||||
$title = trim(mb_substr((string) ($_POST['title'] ?? ''), 0, 80));
|
||||
set_setting('status_page_title', $title !== '' ? $title : 'Systemstatus');
|
||||
audit_log($admin['id'], 'Statusseite: Einstellungen geändert');
|
||||
flash('success', 'Einstellungen gespeichert.');
|
||||
} elseif ($action === 'component_save') {
|
||||
$id = (int) ($_POST['id'] ?? 0) ?: null;
|
||||
$categoryId = (int) ($_POST['category_id'] ?? 0) ?: null;
|
||||
$r = status_save_component($pdo, $id, (string) ($_POST['name'] ?? ''), (string) ($_POST['description'] ?? ''), (int) ($_POST['sort_order'] ?? 0), $categoryId);
|
||||
if ($r === null) $errors[] = 'Bitte einen Namen angeben.'; else flash('success', 'Dienst gespeichert.');
|
||||
} elseif ($action === 'component_state') {
|
||||
status_set_component($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['status'] ?? ''));
|
||||
flash('success', 'Zustand geändert.');
|
||||
} elseif ($action === 'component_maintenance') {
|
||||
if (!status_toggle_component_maintenance($pdo, (int) ($_POST['id'] ?? 0))) $errors[] = 'Dienst konnte nicht gefunden werden.';
|
||||
else flash('success', 'Wartungsstatus geändert.');
|
||||
} elseif ($action === 'component_monitor') {
|
||||
$ok = status_set_component_monitor($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['monitor_type'] ?? 'auto'), (string) ($_POST['monitor_target'] ?? ''));
|
||||
if (!$ok) $errors[] = 'Bitte Prüfart und eine gültige Zieladresse prüfen.';
|
||||
else flash('success', 'Automatische Prüfung gespeichert.');
|
||||
} elseif ($action === 'auto_discover') {
|
||||
$result = status_discover_and_check($pdo);
|
||||
audit_log($admin['id'], 'Statusseite: Dienste automatisch erkannt und geprüft');
|
||||
$message = count($result['recognized']) . ' Dienste erkannt, ' . $result['checked'] . ' geprüft.';
|
||||
if ($result['pending']) $message .= ' Bei ' . count($result['pending']) . ' externen Dienst(en) fehlt noch eine Prüf-URL.';
|
||||
flash('success', $message);
|
||||
} elseif ($action === 'component_category') {
|
||||
status_set_component_category($pdo, (int) ($_POST['id'] ?? 0), (int) ($_POST['category_id'] ?? 0) ?: null, (int) ($_POST['sort_order'] ?? 0));
|
||||
flash('success', 'Kategorie und Reihenfolge gespeichert.');
|
||||
} elseif ($action === 'category_order') {
|
||||
status_set_category_order($pdo, (int) ($_POST['id'] ?? 0), (int) ($_POST['sort_order'] ?? 0));
|
||||
flash('success', 'Reihenfolge gespeichert.');
|
||||
} elseif ($action === 'category_save') {
|
||||
if (!status_save_category($pdo, (string) ($_POST['name'] ?? ''))) $errors[] = 'Bitte einen Namen angeben. Kategorienamen müssen eindeutig sein.';
|
||||
else flash('success', 'Kategorie angelegt.');
|
||||
} elseif ($action === 'category_delete') {
|
||||
status_delete_category($pdo, (int) ($_POST['id'] ?? 0));
|
||||
flash('success', 'Kategorie entfernt. Zugeordnete Dienste bleiben erhalten.');
|
||||
} elseif ($action === 'component_delete') {
|
||||
status_delete_component($pdo, (int) ($_POST['id'] ?? 0));
|
||||
flash('success', 'Dienst entfernt.');
|
||||
} elseif ($action === 'component_restore_core') {
|
||||
$restored = status_restore_core_components($pdo);
|
||||
audit_log($admin['id'], 'Statusseite: fehlende DeskHub-Standarddienste wiederhergestellt');
|
||||
flash('success', 'Fehlende DeskHub-Dienste und passende Kategorien wurden hinzugefuegt. Vorhandene Eintraege blieben erhalten.');
|
||||
} elseif ($action === 'incident_create') {
|
||||
$cid = (int) ($_POST['component_id'] ?? 0) ?: null;
|
||||
$r = status_create_incident($pdo, (string) ($_POST['title'] ?? ''), (string) ($_POST['body'] ?? ''), (string) ($_POST['status'] ?? 'investigating'), $cid, ($_POST['component_state'] ?? '') !== '' ? (string) $_POST['component_state'] : null, (int) $admin['id']);
|
||||
if (!$r['ok']) $errors[] = $r['error']; else { audit_log($admin['id'], 'Statusseite: Meldung veröffentlicht'); flash('success', 'Meldung veröffentlicht.'); }
|
||||
} elseif ($action === 'incident_update') {
|
||||
$r = status_update_incident($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['status'] ?? ''), (string) ($_POST['body'] ?? ''), (int) $admin['id'], true);
|
||||
if (!$r['ok']) $errors[] = $r['error']; else flash('success', 'Update veröffentlicht.');
|
||||
} elseif ($action === 'incident_delete') {
|
||||
status_delete_incident($pdo, (int) ($_POST['id'] ?? 0));
|
||||
flash('success', 'Meldung gelöscht.');
|
||||
}
|
||||
if (!$errors) redirect('/staff/status_page.php');
|
||||
}
|
||||
|
||||
$components = status_components($pdo);
|
||||
$categories = status_categories($pdo);
|
||||
$automaticChecks = 0;
|
||||
$pendingChecks = 0;
|
||||
foreach ($components as $component) {
|
||||
if (status_component_monitor_target($pdo, $component) !== null) $automaticChecks++;
|
||||
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pendingChecks++;
|
||||
}
|
||||
$incidents = status_incidents($pdo, 60);
|
||||
$states = status_component_states();
|
||||
$monitorTypes = status_monitor_types();
|
||||
$incStates = status_incident_states();
|
||||
$pageTitle = 'Statusseite';
|
||||
$activePage = 'status_page';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1>Statusseite</h1>
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
<p class="muted">Eine öffentliche Seite, auf der Ihre Kunden sehen, ob alles läuft, und über Störungen und Wartungen informiert werden.
|
||||
<?php if (status_page_enabled()): ?>Adresse: <a href="/status.php" target="_blank"><?= e(base_url()) ?>/status.php</a><?php endif; ?></p>
|
||||
<div class="card" style="margin-bottom:16px;padding:12px 16px">
|
||||
<strong><?= $automaticChecks ?> Dienst<?= $automaticChecks === 1 ? '' : 'e' ?> werden automatisch geprüft</strong>
|
||||
<?php if ($pendingChecks): ?><span class="muted"> · <?= $pendingChecks ?> <?= $pendingChecks === 1 ? 'Dienst wartet' : 'Dienste warten' ?> auf ein Prüfziel</span><?php endif; ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-top:6px">
|
||||
<span class="muted" style="font-size:.8rem">DeskHub erkennt Datenbank, API, konfigurierte Mailserver, Push-Zugang sowie Android- und Windows-Verbindungen automatisch. Android-Apps gelten bei einem API-Kontakt innerhalb der letzten 60 Minuten, Windows-Apps innerhalb der letzten 10 Minuten als verbunden. Statusprüfungen laufen jede Minute; externe Webseiten und weitere Apps benötigen eine Prüf-URL.</span>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="auto_discover"><button class="btn secondary" type="submit">Jetzt erkennen und prüfen</button></form>
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="component_restore_core"><button class="btn" type="submit">DeskHub-Dienste automatisch hinzufügen</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card" style="margin-bottom:16px">
|
||||
<summary style="cursor:pointer;font-weight:700">Seiteneinstellungen</summary>
|
||||
<form method="post" style="margin-top:12px">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="settings">
|
||||
<h3>Einstellungen</h3>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" value="1" style="width:auto" <?= status_page_enabled() ? 'checked' : '' ?>> Statusseite für Kunden anzeigen (Link „Status“ im Kundenbereich)</label>
|
||||
<label for="title" style="margin-top:8px">Überschrift</label>
|
||||
<input type="text" id="title" name="title" maxlength="80" value="<?= e((string) get_setting('status_page_title', 'Systemstatus')) ?>" style="max-width:340px">
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.status-admin-layout{display:grid;grid-template-columns:minmax(0,1.8fr) minmax(320px,1fr);gap:16px;align-items:start;margin-bottom:16px}
|
||||
@media(max-width:1100px){.status-admin-layout{grid-template-columns:1fr}}
|
||||
.status-category-row{display:grid;grid-template-columns:minmax(0,1fr) 104px 32px;gap:8px;align-items:center;padding:9px 4px;border-bottom:1px solid var(--border,#e3e9ee)}
|
||||
.status-category-head{font-size:.75rem;font-weight:700;color:var(--muted);padding:0 4px 6px}
|
||||
.status-category-row form{margin:0}
|
||||
.status-component-add{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0!important;padding:0!important;border:0!important}
|
||||
.status-component-add input,.status-component-add select{width:100%;min-width:0;box-sizing:border-box}
|
||||
.status-component-add button{grid-column:1/-1;justify-self:start}
|
||||
@media(max-width:420px){.status-category-row{grid-template-columns:minmax(0,1fr) 94px 30px;gap:5px}}
|
||||
</style>
|
||||
<div class="status-admin-layout">
|
||||
<section class="card">
|
||||
<h3>Dienste & automatische Prüfungen</h3>
|
||||
<p class="muted">Automatisch geprüfte Dienste erhalten ihren Status selbst. Während geplanter Arbeiten kannst du sie auf Wartung setzen. Für nicht erkannte externe Dienste ist eine Prüf-URL nötig.</p>
|
||||
<div style="margin:14px 0;padding:12px;border:1px solid var(--border,#e3e9ee);border-radius:8px;background:var(--bg-soft,#f8fafc)">
|
||||
<strong style="display:block;margin-bottom:8px">Eigenen Dienst hinzufügen</strong>
|
||||
<form method="post" class="status-component-add"><?= csrf_field() ?><input type="hidden" name="action" value="component_save">
|
||||
<input type="text" name="name" placeholder="Neuer Dienst, z. B. Webseite" maxlength="120" required style="flex:1;min-width:180px">
|
||||
<input type="text" name="description" placeholder="Beschreibung (optional)" maxlength="255" style="flex:1;min-width:180px">
|
||||
<select name="category_id" style="flex:1;min-width:150px"><option value="">Ohne Kategorie</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>"><?= e($category['name']) ?></option><?php endforeach; ?></select>
|
||||
<input type="number" name="sort_order" value="<?= count($components) * 10 + 10 ?>" title="Reihenfolge" style="width:80px">
|
||||
<button class="btn" type="submit">Hinzufügen</button></form>
|
||||
</div>
|
||||
<?php if (!$components): ?><p class="muted">Noch keine Dienste. Legen Sie unten zum Beispiel Webseite, E-Mail oder Telefon an.</p><?php endif; ?>
|
||||
<?php foreach ($components as $c): $st = $states[$c['status']] ?? $states['operational']; ?>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:8px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||
<?php
|
||||
$checkInfo = status_component_monitor_target($pdo, $c);
|
||||
$selectedMonitorType = ($c['monitor_type'] ?? 'auto') === 'auto' && $checkInfo ? $checkInfo['type'] : (string) ($c['monitor_type'] ?? 'auto');
|
||||
$monitorTargetValue = trim((string) ($c['monitor_target'] ?? ''));
|
||||
if ($monitorTargetValue === '' && $checkInfo && $checkInfo['type'] === 'http') $monitorTargetValue = (string) $checkInfo['target'];
|
||||
$showsDetectedTargets = $checkInfo && in_array($checkInfo['type'], ['database', 'mail', 'fcm', 'android_app', 'windows_app'], true);
|
||||
$detectedTargets = $showsDetectedTargets ? status_component_detected_targets($pdo, $checkInfo) : [];
|
||||
?>
|
||||
<div style="flex:1;min-width:180px"><strong><?= e($c['name']) ?></strong><?php if ($c['description']): ?><div class="muted" style="font-size:.8rem"><?= e($c['description']) ?></div><?php endif; ?>
|
||||
<div class="muted" style="font-size:.76rem;margin-top:3px"><?php if ($checkInfo): ?>Automatische Prüfung: <?= e($checkInfo['label']) ?> · <?= $c['last_checked_at'] ? 'zuletzt ' . e(format_dt((string) $c['last_checked_at'])) : 'wartet auf ersten Lauf' ?><?php elseif (($c['monitor_type'] ?? 'auto') === 'manual'): ?>Manuell verwaltet<?php else: ?>Prüfziel fehlt – wartet auf Zuordnung<?php endif; ?><?php if (!empty($c['last_check_ms'])): ?> · <?= (int) $c['last_check_ms'] ?> ms<?php endif; ?></div>
|
||||
<?php if (!empty($c['last_check_error'])): ?><div style="color:#b91c1c;font-size:.76rem"><?= e((string) $c['last_check_error']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<form method="post" style="display:flex;gap:5px;align-items:center"><?= csrf_field() ?><input type="hidden" name="action" value="component_category"><input type="hidden" name="id" value="<?= (int) $c['id'] ?>"><select name="category_id" aria-label="Kategorie für <?= e($c['name']) ?>" style="max-width:170px;padding:5px"><option value="">Ohne Kategorie</option><?php foreach ($categories as $category): ?><option value="<?= (int) $category['id'] ?>" <?= (int) ($c['category_id'] ?? 0) === (int) $category['id'] ? 'selected' : '' ?>><?= e($category['name']) ?></option><?php endforeach; ?></select><input type="number" name="sort_order" value="<?= (int) $c['sort_order'] ?>" title="Reihenfolge" aria-label="Reihenfolge für <?= e($c['name']) ?>" style="width:58px;padding:4px"><button class="btn secondary" type="submit" style="padding:4px 8px;font-size:.78rem">Speichern</button></form>
|
||||
<?php if ($checkInfo): ?>
|
||||
<div style="display:flex;gap:8px;align-items:center;white-space:nowrap">
|
||||
<span style="color:<?= e($st[1]) ?>;font-weight:600"><?= e($st[0]) ?></span>
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="component_maintenance"><input type="hidden" name="id" value="<?= (int) $c['id'] ?>"><button class="btn secondary" type="submit" style="padding:4px 8px;font-size:.78rem"><?= $c['status'] === 'maintenance' ? 'Wartung beenden' : 'Wartung aktivieren' ?></button></form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:flex;gap:6px;align-items:center"><?= csrf_field() ?><input type="hidden" name="action" value="component_state"><input type="hidden" name="id" value="<?= (int) $c['id'] ?>">
|
||||
<select name="status" onchange="this.form.submit()" style="color:<?= e($st[1]) ?>;font-weight:600"><?php foreach ($states as $k => $s): ?><option value="<?= e($k) ?>" <?= $c['status'] === $k ? 'selected' : '' ?>><?= e($s[0]) ?></option><?php endforeach; ?></select></form>
|
||||
<?php endif; ?>
|
||||
<form method="post" onsubmit="return confirm('Dienst entfernen?')"><?= csrf_field() ?><input type="hidden" name="action" value="component_delete"><input type="hidden" name="id" value="<?= (int) $c['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 10px;font-size:.78rem">Entfernen</button></form>
|
||||
</div>
|
||||
<details style="padding:0 0 10px 0;border-bottom:1px solid var(--border,#e3e9ee)">
|
||||
<summary style="cursor:pointer;color:var(--muted,#64748b);font-size:.82rem">Prüfung konfigurieren</summary>
|
||||
<form method="post" style="display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:10px 0"><?= csrf_field() ?><input type="hidden" name="action" value="component_monitor"><input type="hidden" name="id" value="<?= (int) $c['id'] ?>">
|
||||
<div><label>Prüfart</label><select name="monitor_type"><?php foreach ($monitorTypes as $type => $label): ?><option value="<?= e($type) ?>" <?= $selectedMonitorType === $type ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?></select></div>
|
||||
<?php if ($showsDetectedTargets): ?>
|
||||
<div style="flex:1;min-width:220px"><label>Automatisch erkannte Prüfziele</label><div style="min-height:38px;box-sizing:border-box;padding:9px 12px;border:1px solid var(--border,#d8e0e8);border-radius:8px;background:var(--surface,#f5f7f9)"><?php if ($detectedTargets): ?><?= e(implode(' · ', $detectedTargets)) ?><?php else: ?>Keine passenden Ziele in der Dienst-Konfiguration gefunden<?php endif; ?></div></div>
|
||||
<?php else: ?>
|
||||
<div style="flex:1;min-width:220px"><label>Prüf-URL oder Host:Port</label><input type="text" name="monitor_target" value="<?= e($monitorTargetValue) ?>" placeholder="https://example.com"></div>
|
||||
<?php endif; ?>
|
||||
<button class="btn secondary" type="submit">Prüfung speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3 style="margin-top:0">Kategorien verwalten (<?= count($categories) ?>)</h3>
|
||||
<form method="post" style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px"><?= csrf_field() ?><input type="hidden" name="action" value="category_save"><input type="text" name="name" placeholder="Neue Kategorie, z. B. API-Schnittstellen" maxlength="80" required style="flex:1;min-width:200px"><button class="btn" type="submit">Kategorie anlegen</button></form>
|
||||
<?php if ($categories): ?><div style="border-top:1px solid var(--border,#e3e9ee)">
|
||||
<div class="status-category-row status-category-head"><span>Kategorie</span><span>Reihenfolge</span><span></span></div>
|
||||
<?php foreach ($categories as $category): ?><div class="status-category-row">
|
||||
<span><?= e($category['name']) ?></span>
|
||||
<form method="post" style="display:flex;gap:4px;align-items:center"><?= csrf_field() ?><input type="hidden" name="action" value="category_order"><input type="hidden" name="id" value="<?= (int) $category['id'] ?>"><input type="number" name="sort_order" value="<?= (int) $category['sort_order'] ?>" title="Reihenfolge" aria-label="Reihenfolge für <?= e($category['name']) ?>" style="width:58px;padding:3px"><button class="btn secondary" type="submit" style="padding:2px 6px;font-size:.76rem" aria-label="Reihenfolge speichern">↕</button></form>
|
||||
<form method="post" onsubmit="return confirm('Kategorie entfernen? Zugeordnete Dienste bleiben ohne Kategorie erhalten.')"><?= csrf_field() ?><input type="hidden" name="action" value="category_delete"><input type="hidden" name="id" value="<?= (int) $category['id'] ?>"><button class="btn secondary" type="submit" aria-label="Kategorie <?= e($category['name']) ?> entfernen" style="padding:1px 7px">×</button></form>
|
||||
</div><?php endforeach; ?>
|
||||
</div><?php else: ?><p class="muted">Noch keine Kategorien angelegt.</p><?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details class="card" style="max-width:820px;margin-bottom:16px">
|
||||
<summary style="cursor:pointer;font-weight:700">Störung oder Wartung melden</summary>
|
||||
<form method="post" style="margin-top:12px"><?= csrf_field() ?><input type="hidden" name="action" value="incident_create">
|
||||
<h3>Neue Meldung (Störung oder Wartung)</h3>
|
||||
<label for="inc_title">Titel</label><input type="text" id="inc_title" name="title" maxlength="200" required placeholder="z. B. E-Mail-Versand verzögert">
|
||||
<label for="inc_body" style="margin-top:8px">Beschreibung</label><textarea id="inc_body" name="body" rows="3" maxlength="4000"></textarea>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:8px">
|
||||
<div><label>Stufe</label><select name="status"><?php foreach ($incStates as $k => $l): ?><option value="<?= e($k) ?>"><?= e($l) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label>Betrifft</label><select name="component_id"><option value="">– kein bestimmter Dienst –</option><?php foreach ($components as $c): ?><option value="<?= (int) $c['id'] ?>"><?= e($c['name']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label>Dienst setzen auf</label><select name="component_state"><option value="">– nicht ändern –</option><?php foreach ($states as $k => $s): ?><option value="<?= e($k) ?>"><?= e($s[0]) ?></option><?php endforeach; ?></select></div>
|
||||
</div>
|
||||
<div class="btn-row"><button type="submit" class="btn">Veröffentlichen</button></div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<details class="card" style="max-width:820px">
|
||||
<summary style="cursor:pointer;font-weight:700">Meldungen (<?= count($incidents) ?>)</summary>
|
||||
<?php if (!$incidents): ?><p class="muted">Keine Meldungen in den letzten 60 Tagen.</p><?php endif; ?>
|
||||
<?php foreach ($incidents as $i): ?>
|
||||
<div style="padding:10px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||
<strong><?= e($i['title']) ?></strong>
|
||||
<span class="muted" style="font-size:.8rem">· <?= e(format_dt((string) $i['created_at'])) ?><?= $i['component_name'] ? ' · ' . e($i['component_name']) : '' ?> · <?= e($incStates[$i['status']] ?? $i['status']) ?></span>
|
||||
<?php foreach ($i['updates'] as $u): ?><div style="font-size:.86rem;margin-top:3px"><span class="muted"><?= e($incStates[$u['status']] ?? $u['status']) ?>:</span> <?= nl2br(e((string) $u['body'])) ?></div><?php endforeach; ?>
|
||||
<?php if ($i['status'] !== 'resolved'): ?>
|
||||
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"><?= csrf_field() ?><input type="hidden" name="action" value="incident_update"><input type="hidden" name="id" value="<?= (int) $i['id'] ?>">
|
||||
<select name="status"><?php foreach ($incStates as $k => $l): ?><option value="<?= e($k) ?>" <?= $k === $i['status'] ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select>
|
||||
<input type="text" name="body" placeholder="Update für die Kunden" maxlength="4000" style="flex:1;min-width:200px">
|
||||
<button class="btn secondary" type="submit">Update senden</button></form>
|
||||
<?php endif; ?>
|
||||
<form method="post" onsubmit="return confirm('Meldung löschen?')" style="margin-top:4px"><?= csrf_field() ?><input type="hidden" name="action" value="incident_delete"><input type="hidden" name="id" value="<?= (int) $i['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 10px;font-size:.76rem">Löschen</button></form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</details>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -149,6 +149,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
|
||||
// Ticket-Vorlagen: ?template=ID füllt das Formular vor
|
||||
$ticketTemplates = [];
|
||||
$activeTemplateId = 0;
|
||||
try {
|
||||
$ticketTemplates = ticket_templates_list($pdo);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && (int) ($_GET['template'] ?? 0) > 0) {
|
||||
$tpl = ticket_template_get($pdo, (int) $_GET['template']);
|
||||
if ($tpl) {
|
||||
$activeTemplateId = (int) $tpl['id'];
|
||||
$_POST['subject'] = (string) $tpl['subject'];
|
||||
$_POST['message'] = nl2br(e((string) ($tpl['body'] ?? ''))); // der Editor erwartet HTML
|
||||
if ($tpl['department_id']) $_POST['department_id'] = (string) $tpl['department_id'];
|
||||
if ($tpl['help_topic_id']) $_POST['help_topic_id'] = (string) $tpl['help_topic_id'];
|
||||
if ($tpl['priority_id']) $_POST['priority_id'] = (string) $tpl['priority_id'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$ticketTemplates = [];
|
||||
}
|
||||
$pageTitle = 'Ticket erstellen';
|
||||
$activePage = 'tickets';
|
||||
require __DIR__ . '/_header.php';
|
||||
@@ -161,6 +180,15 @@ require __DIR__ . '/_header.php';
|
||||
<a href="/staff/tickets.php" class="btn secondary">← Zur Ticket-Liste</a>
|
||||
</div>
|
||||
<p class="muted">Ticket im Namen eines Kunden anlegen – z.B. bei Telefonanrufen oder Vor-Ort-Anfragen.</p>
|
||||
<?php if ($ticketTemplates): ?>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:0 0 14px">
|
||||
<span class="muted" style="font-size:.82rem">Vorlage:</span>
|
||||
<?php foreach ($ticketTemplates as $__tp): ?>
|
||||
<a href="/staff/ticket_create.php?template=<?= (int) $__tp['id'] ?>" class="btn <?= $activeTemplateId === (int) $__tp['id'] ? '' : 'secondary' ?>" style="font-size:.82rem;padding:4px 12px"><?= e($__tp['name']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($activeTemplateId): ?><a href="/staff/ticket_create.php" class="muted" style="font-size:.8rem">Ohne Vorlage</a><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
@@ -263,7 +291,7 @@ require __DIR__ . '/_header.php';
|
||||
<button type="button" onclick="document.execCommand('insertUnorderedList')">•</button>
|
||||
<button type="button" onclick="document.execCommand('insertOrderedList')">1.</button>
|
||||
</div>
|
||||
<div class="richtext-editable" id="msg-editor" contenteditable="true"><?= $_POST['message'] ?? '' ?></div>
|
||||
<div class="richtext-editable" id="msg-editor" contenteditable="true"><?= sanitize_staff_html((string) ($_POST['message'] ?? '')) ?></div>
|
||||
</div>
|
||||
<input type="hidden" name="message" id="msg-hidden">
|
||||
</div>
|
||||
@@ -314,7 +342,7 @@ require __DIR__ . '/_header.php';
|
||||
<button type="button" onclick="document.execCommand('underline')"><u>U</u></button>
|
||||
<button type="button" onclick="document.execCommand('insertUnorderedList')">•</button>
|
||||
</div>
|
||||
<div class="richtext-editable" id="note-editor" contenteditable="true" style="min-height:80px; border-color:var(--accent)"><?= $_POST['internal_note'] ?? '' ?></div>
|
||||
<div class="richtext-editable" id="note-editor" contenteditable="true" style="min-height:80px; border-color:var(--accent)"><?= sanitize_staff_html((string) ($_POST['internal_note'] ?? '')) ?></div>
|
||||
</div>
|
||||
<input type="hidden" name="internal_note" id="note-hidden">
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$pdo = get_db();
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'save') {
|
||||
$id = (int) ($_POST['id'] ?? 0) ?: null;
|
||||
$r = ticket_template_save($pdo, $id, $_POST);
|
||||
if (!$r['ok']) {
|
||||
$errors[] = $r['error'];
|
||||
} else {
|
||||
audit_log($admin['id'], 'Ticket-Vorlage gespeichert: ' . trim((string) ($_POST['name'] ?? '')));
|
||||
flash('success', 'Vorlage gespeichert.');
|
||||
redirect('/staff/ticket_templates.php');
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
ticket_template_delete($pdo, (int) ($_POST['id'] ?? 0));
|
||||
audit_log($admin['id'], 'Ticket-Vorlage gelöscht');
|
||||
flash('success', 'Vorlage gelöscht.');
|
||||
redirect('/staff/ticket_templates.php');
|
||||
}
|
||||
}
|
||||
|
||||
$departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
|
||||
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY id')->fetchAll();
|
||||
$helpTopics = help_topic_options($pdo);
|
||||
$templates = ticket_templates_list($pdo, false, true);
|
||||
$editing = null;
|
||||
if (!empty($_GET['edit'])) {
|
||||
foreach ($templates as $t) if ((int) $t['id'] === (int) $_GET['edit']) $editing = $t;
|
||||
}
|
||||
// Bei einem Fehler die eingegebenen Werte behalten
|
||||
$v = $errors ? $_POST : ($editing ?: ['is_public' => 1, 'is_active' => 1, 'sort_order' => count($templates) * 10 + 10]);
|
||||
$pageTitle = 'Ticket-Vorlagen';
|
||||
$activePage = 'ticket_templates';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1>Ticket-Vorlagen</h1>
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
<p class="muted" style="max-width:820px">Vorbelegte Formulare für häufige Anliegen, zum Beispiel „Neuer Mitarbeiter“ oder „Passwort zurücksetzen“. Kunden wählen sie auf der Seite „Neues Ticket“ (wenn sie für Kunden sichtbar sind), Mitarbeiter beim Anlegen eines Tickets im Web, im Windows-Programm und in der App.</p>
|
||||
|
||||
<form method="post" class="card" style="max-width:820px;margin-bottom:16px"><?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save"><input type="hidden" name="id" value="<?= (int) ($editing['id'] ?? $_POST['id'] ?? 0) ?>">
|
||||
<h3><?= $editing || !empty($_POST['id']) ? 'Vorlage bearbeiten' : 'Neue Vorlage' ?></h3>
|
||||
<div class="field-row">
|
||||
<div><label for="name">Name der Vorlage</label><input type="text" id="name" name="name" maxlength="120" required value="<?= e((string) ($v['name'] ?? '')) ?>" placeholder="z. B. Neuer Mitarbeiter"></div>
|
||||
<div><label for="description">Kurzbeschreibung (optional)</label><input type="text" id="description" name="description" maxlength="255" value="<?= e((string) ($v['description'] ?? '')) ?>"></div>
|
||||
</div>
|
||||
<label for="subject" style="margin-top:8px">Betreff</label>
|
||||
<input type="text" id="subject" name="subject" maxlength="200" value="<?= e((string) ($v['subject'] ?? '')) ?>" placeholder="Vorgabe für den Betreff">
|
||||
<label for="body" style="margin-top:8px">Text</label>
|
||||
<textarea id="body" name="body" rows="7" maxlength="10000" placeholder="Zum Beispiel eine Liste mit den Angaben, die Sie brauchen: Name, Startdatum, benötigte Geräte …"><?= e((string) ($v['body'] ?? '')) ?></textarea>
|
||||
<div class="field-row" style="margin-top:8px">
|
||||
<div><label for="department_id">Abteilung</label><select id="department_id" name="department_id"><option value="">– nicht vorgeben –</option><?php foreach ($departments as $d): ?><option value="<?= (int) $d['id'] ?>" <?= (int) ($v['department_id'] ?? 0) === (int) $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="help_topic_id">Hilfethema</label><select id="help_topic_id" name="help_topic_id"><option value="">– nicht vorgeben –</option><?php foreach ($helpTopics as $h): ?><option value="<?= (int) $h['id'] ?>" <?= (int) ($v['help_topic_id'] ?? 0) === (int) $h['id'] ? 'selected' : '' ?>><?= e($h['path']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="priority_id">Priorität</label><select id="priority_id" name="priority_id"><option value="">– nicht vorgeben –</option><?php foreach ($priorities as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) ($v['priority_id'] ?? 0) === (int) $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;align-items:center">
|
||||
<label style="font-weight:400"><input type="checkbox" name="is_public" value="1" style="width:auto" <?= !empty($v['is_public']) ? 'checked' : '' ?>> Für Kunden sichtbar (auf „Neues Ticket“)</label>
|
||||
<label style="font-weight:400"><input type="checkbox" name="is_active" value="1" style="width:auto" <?= !isset($v['is_active']) || !empty($v['is_active']) ? 'checked' : '' ?>> Aktiv</label>
|
||||
<label style="font-weight:400">Reihenfolge <input type="number" name="sort_order" value="<?= (int) ($v['sort_order'] ?? 0) ?>" style="width:80px"></label>
|
||||
</div>
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button><?php if ($editing): ?> <a class="btn secondary" href="/staff/ticket_templates.php">Abbrechen</a><?php endif; ?></div>
|
||||
</form>
|
||||
|
||||
<div class="card" style="max-width:820px">
|
||||
<h3>Vorhandene Vorlagen</h3>
|
||||
<?php if (!$templates): ?><p class="muted">Noch keine Vorlagen.</p><?php endif; ?>
|
||||
<?php foreach ($templates as $t): ?>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:10px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||
<div style="flex:1;min-width:200px"><strong><?= e($t['name']) ?></strong>
|
||||
<?= $t['is_public'] ? '<span class="muted" style="font-size:.76rem"> · für Kunden</span>' : '<span class="muted" style="font-size:.76rem"> · nur Mitarbeiter</span>' ?>
|
||||
<?= $t['is_active'] ? '' : '<span style="color:#b45309;font-size:.76rem"> · inaktiv</span>' ?>
|
||||
<?php if ($t['description']): ?><div class="muted" style="font-size:.82rem"><?= e($t['description']) ?></div><?php endif; ?></div>
|
||||
<a class="btn secondary" href="/staff/ticket_templates.php?edit=<?= (int) $t['id'] ?>" style="padding:3px 12px;font-size:.8rem">Bearbeiten</a>
|
||||
<form method="post" onsubmit="return confirm('Vorlage löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $t['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 12px;font-size:.8rem">Löschen</button></form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -1268,6 +1268,21 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php $csatRow = null; try { $csatRow = csat_for_ticket($pdo, (int) $ticket['id']); } catch (Throwable $e) {} ?>
|
||||
<?php if ($csatRow): ?>
|
||||
<!-- Zufriedenheit des Kunden -->
|
||||
<div class="card" style="padding:14px 20px">
|
||||
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Kundenzufriedenheit</span>
|
||||
<?php if ($csatRow['rating'] !== null): ?>
|
||||
<div style="font-size:1.25rem;color:#f5b301;margin-top:4px"><?= e(csat_stars((int) $csatRow['rating'])) ?> <span style="font-size:.85rem;color:var(--muted)"><?= (int) $csatRow['rating'] ?> von 5</span></div>
|
||||
<?php if (!empty($csatRow['comment'])): ?><div style="font-size:.85rem;margin-top:4px">„<?= e((string) $csatRow['comment']) ?>“</div><?php endif; ?>
|
||||
<div class="muted" style="font-size:.76rem;margin-top:4px">Bewertet am <?= e(format_dt((string) $csatRow['answered_at'])) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="muted" style="font-size:.84rem;margin-top:4px">Abfrage am <?= e(format_dt((string) $csatRow['sent_at'])) ?> gesendet, noch keine Antwort.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Wiedervorlage und Beobachter -->
|
||||
<div class="card" style="padding:16px 20px">
|
||||
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Wiedervorlage</span>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
/**
|
||||
* status.php – öffentliche Statusseite: Zustand der Dienste und aktuelle Meldungen.
|
||||
* Wird im Mitarbeiterbereich unter „Statusseite“ gepflegt und nur gezeigt, wenn sie dort eingeschaltet ist.
|
||||
*/
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$pdo = get_db();
|
||||
$activePage = 'status';
|
||||
$pageTitle = (string) get_setting('status_page_title', 'Systemstatus');
|
||||
if (!status_page_enabled()) {
|
||||
http_response_code(404);
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
echo '<h1>Seite nicht gefunden</h1><p class="muted">Die angeforderte Seite existiert nicht.</p><p><a href="/">Zurück zur Startseite</a></p>';
|
||||
require __DIR__ . '/includes/footer_customer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$states = status_component_states();
|
||||
$overall = status_overall($pdo);
|
||||
$components = status_components($pdo);
|
||||
foreach ($components as &$component) {
|
||||
$component['timeline'] = status_component_timeline($pdo, (int) $component['id'], 30);
|
||||
}
|
||||
unset($component);
|
||||
$incidents = status_incidents($pdo, 14);
|
||||
$open = array_values(array_filter($incidents, fn($i) => $i['status'] !== 'resolved'));
|
||||
$past = array_values(array_filter($incidents, fn($i) => $i['status'] === 'resolved'));
|
||||
$incStates = status_incident_states();
|
||||
|
||||
if (($_GET['live'] ?? '') === '1') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
$liveComponents = [];
|
||||
foreach ($components as $component) {
|
||||
$componentState = $states[$component['status']] ?? $states['operational'];
|
||||
$liveComponents[] = [
|
||||
'id' => (int) $component['id'],
|
||||
'status' => (string) $component['status'],
|
||||
'statusLabel' => (string) $componentState[0],
|
||||
'color' => (string) $componentState[1],
|
||||
'uptime' => $component['timeline']['uptime'],
|
||||
'bars' => array_map(static function (array $bar) use ($states): array {
|
||||
$key = $bar['status'];
|
||||
return [
|
||||
'date' => (string) $bar['date'],
|
||||
'status' => $key,
|
||||
'label' => $key !== null ? (string) ($states[$key][0] ?? $key) : 'Keine Daten',
|
||||
'color' => $key !== null ? (string) ($states[$key][1] ?? '#cbd5e1') : '#dfe4e8',
|
||||
];
|
||||
}, $component['timeline']['bars']),
|
||||
];
|
||||
}
|
||||
$liveIncidents = [];
|
||||
foreach ($open as $incident) {
|
||||
$liveIncidents[] = [
|
||||
'title' => (string) $incident['title'],
|
||||
'statusLabel' => (string) ($incStates[$incident['status']] ?? $incident['status']),
|
||||
'componentName' => (string) ($incident['component_name'] ?? ''),
|
||||
'updates' => array_map(static fn(array $update): array => [
|
||||
'statusLabel' => (string) ($incStates[$update['status']] ?? $update['status']),
|
||||
'createdAt' => format_dt((string) $update['created_at']),
|
||||
'body' => (string) ($update['body'] ?? ''),
|
||||
], $incident['updates']),
|
||||
];
|
||||
}
|
||||
echo json_encode([
|
||||
'updatedAt' => date('d.m.Y H:i:s'),
|
||||
'overall' => $overall,
|
||||
'components' => $liveComponents,
|
||||
'incidents' => $liveIncidents,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
?>
|
||||
<div style="max-width:820px;margin:0 auto">
|
||||
<h1><?= e($pageTitle) ?></h1>
|
||||
<div id="status-live-overall" class="card" style="padding:18px 22px;margin:16px 0;border-left:6px solid <?= e($overall['color']) ?>">
|
||||
<strong id="status-live-overall-label" style="font-size:1.2rem;color:<?= e($overall['color']) ?>"><?= e($overall['label']) ?></strong>
|
||||
<div id="status-live-updated-at" class="muted" style="font-size:.82rem;margin-top:2px">Stand: <?= e(date("d.m.Y H:i:s")) ?> Uhr · Live-Aktualisierung aktiv</div>
|
||||
</div>
|
||||
|
||||
<div id="status-live-incidents">
|
||||
<?php foreach ($open as $i): ?>
|
||||
<div class="card" style="padding:16px 20px;margin-bottom:14px;border-left:6px solid #dc2626">
|
||||
<strong><?= e($i['title']) ?></strong>
|
||||
<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:2px 10px;font-size:.72rem;font-weight:700;margin-left:6px"><?= e($incStates[$i['status']] ?? $i['status']) ?></span>
|
||||
<?php if ($i['component_name']): ?><div class="muted" style="font-size:.82rem">Betrifft: <?= e($i['component_name']) ?></div><?php endif; ?>
|
||||
<?php foreach ($i['updates'] as $u): ?>
|
||||
<div style="margin-top:10px;font-size:.9rem"><strong><?= e($incStates[$u['status']] ?? $u['status']) ?></strong> <span class="muted" style="font-size:.78rem">· <?= e(format_dt((string) $u['created_at'])) ?></span>
|
||||
<?php if (trim((string) $u['body']) !== ''): ?><div><?= nl2br(e((string) $u['body'])) ?></div><?php endif; ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!$components): ?>
|
||||
<div class="card" style="padding:18px 20px;margin:16px 0"><p class="muted" style="margin:0">Es sind noch keine Dienste eingetragen.</p></div>
|
||||
<?php else: ?>
|
||||
<?php $groups = []; foreach ($components as $c) $groups[trim((string) ($c['category_name'] ?? '')) ?: 'Weitere Dienste'][] = $c; ?>
|
||||
<?php foreach ($groups as $category => $group): ?>
|
||||
<h2 style="font-size:1.05rem;margin:22px 0 8px"><?= e($category) ?></h2>
|
||||
<section class="card" style="padding:0 20px;margin:0 0 16px">
|
||||
<?php foreach ($group as $groupIndex => $c): $st = $states[$c['status']] ?? $states['operational']; ?>
|
||||
<div data-status-component="<?= (int) $c['id'] ?>" style="padding:13px 0;<?= $groupIndex < count($group) - 1 ? 'border-bottom:1px solid var(--border,#e3e9ee)' : '' ?>">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<div><strong><?= e($c['name']) ?></strong><?php if (!empty($c['description'])): ?><div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div><?php endif; ?></div>
|
||||
<span data-status-label style="color:<?= e($st[1]) ?>;font-weight:700;white-space:nowrap"><span data-status-dot style="display:inline-block;width:9px;height:9px;border-radius:50%;background:<?= e($st[1]) ?>;margin-right:6px"></span><span data-status-text><?= e($st[0]) ?></span></span>
|
||||
</div>
|
||||
<?php $timeline = $c['timeline']; ?>
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-top:12px">
|
||||
<div data-status-bars role="img" aria-label="<?= e($c['name']) ?>: letzte automatische Prüfungen" style="display:flex;flex:1;gap:3px;min-width:0">
|
||||
<?php foreach ($timeline['bars'] as $bar): $barStatus = $bar['status']; $barColor = $barStatus !== null ? ($states[$barStatus][1] ?? '#cbd5e1') : '#dfe4e8'; $barLabel = $barStatus !== null ? ($states[$barStatus][0] ?? $barStatus) : 'Noch keine Verlaufsdaten'; ?>
|
||||
<span title="<?= e($bar['date'] . ': ' . $barLabel) ?>" style="display:block;flex:1;min-width:2px;height:22px;border-radius:3px;background:<?= e($barColor) ?>"></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<span data-status-uptime title="Verfügbarkeit der letzten 24 Stunden" style="min-width:112px;text-align:right;font-size:.82rem;font-weight:600;color:var(--text,#334155)"><?= $timeline['uptime'] === null ? 'Noch keine Prüfungen' : e(number_format((float) $timeline['uptime'], 2, ',', '.') . ' %') ?></span>
|
||||
</div>
|
||||
<div class="muted" style="display:flex;justify-content:space-between;font-size:.72rem;margin-top:4px"><span>Letzte Prüfungen</span><span>Jetzt</span></div>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:6px;font-size:.7rem;color:var(--muted,#64748b)">
|
||||
<?php foreach ($states as $stateKey => $stateMeta): ?><span><i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:<?= e($stateMeta[1]) ?>;margin-right:4px"></i><?= e($stateMeta[0]) ?></span><?php endforeach; ?>
|
||||
<span><i style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#dfe4e8;margin-right:4px"></i>Keine Daten</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2 style="margin-top:28px">Vergangene Meldungen (letzte 14 Tage)</h2>
|
||||
<?php if (!$past): ?><p class="muted">Keine Störungen in den letzten 14 Tagen.</p><?php endif; ?>
|
||||
<?php foreach ($past as $i): ?>
|
||||
<div style="padding:12px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||
<strong><?= e($i['title']) ?></strong> <span class="muted" style="font-size:.8rem">· <?= e(format_dt((string) $i['created_at'])) ?> · behoben</span>
|
||||
<?php foreach ($i['updates'] as $u): if (trim((string) $u['body']) === '') continue; ?>
|
||||
<div style="font-size:.88rem;margin-top:4px"><?= nl2br(e((string) $u['body'])) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const endpoint = new URL(window.location.href);
|
||||
endpoint.searchParams.set('live', '1');
|
||||
const overallCard = document.getElementById('status-live-overall');
|
||||
const overallLabel = document.getElementById('status-live-overall-label');
|
||||
const updatedAt = document.getElementById('status-live-updated-at');
|
||||
const incidentList = document.getElementById('status-live-incidents');
|
||||
let polling = false;
|
||||
|
||||
function addText(parent, tag, text, className, style) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (style) node.style.cssText = style;
|
||||
node.textContent = text || '';
|
||||
parent.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderIncidents(incidents) {
|
||||
if (!incidentList) return;
|
||||
incidentList.replaceChildren();
|
||||
for (const incident of incidents || []) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.style.cssText = 'padding:16px 20px;margin-bottom:14px;border-left:6px solid #dc2626';
|
||||
addText(card, 'strong', incident.title);
|
||||
addText(card, 'span', incident.statusLabel, '', 'background:#fef3c7;color:#92400e;border-radius:20px;padding:2px 10px;font-size:.72rem;font-weight:700;margin-left:6px');
|
||||
if (incident.componentName) addText(card, 'div', 'Betrifft: ' + incident.componentName, 'muted', 'font-size:.82rem');
|
||||
for (const update of incident.updates || []) {
|
||||
const line = document.createElement('div');
|
||||
line.style.cssText = 'margin-top:10px;font-size:.9rem';
|
||||
addText(line, 'strong', update.statusLabel);
|
||||
addText(line, 'span', ' - ' + update.createdAt, 'muted', 'font-size:.78rem');
|
||||
if (update.body) addText(line, 'div', update.body, '', 'white-space:pre-line');
|
||||
card.appendChild(line);
|
||||
}
|
||||
incidentList.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (polling || document.visibilityState === 'hidden') return;
|
||||
polling = true;
|
||||
try {
|
||||
const response = await fetch(endpoint, {cache: 'no-store', headers: {'Accept': 'application/json'}});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (overallCard && data.overall) {
|
||||
overallCard.style.borderLeftColor = data.overall.color;
|
||||
overallLabel.textContent = data.overall.label;
|
||||
overallLabel.style.color = data.overall.color;
|
||||
}
|
||||
if (updatedAt) updatedAt.textContent = 'Stand: ' + data.updatedAt + ' Uhr - Live-Aktualisierung aktiv';
|
||||
for (const component of data.components || []) {
|
||||
const row = document.querySelector('[data-status-component="' + component.id + '"]');
|
||||
if (!row) continue;
|
||||
const label = row.querySelector('[data-status-label]');
|
||||
const text = row.querySelector('[data-status-text]');
|
||||
const dot = row.querySelector('[data-status-dot]');
|
||||
if (label) label.style.color = component.color;
|
||||
if (text) text.textContent = component.statusLabel;
|
||||
if (dot) dot.style.backgroundColor = component.color;
|
||||
const bars = row.querySelector('[data-status-bars]');
|
||||
if (bars) {
|
||||
bars.replaceChildren();
|
||||
for (const bar of component.bars || []) {
|
||||
const block = document.createElement('span');
|
||||
block.title = (bar.date ? bar.date + ': ' : '') + bar.label;
|
||||
block.style.cssText = 'display:block;flex:1;min-width:2px;height:22px;border-radius:3px;background:' + bar.color;
|
||||
bars.appendChild(block);
|
||||
}
|
||||
}
|
||||
const uptime = row.querySelector('[data-status-uptime]');
|
||||
if (uptime) uptime.textContent = component.uptime === null ? 'Noch keine Pr\u00fcfungen' : Number(component.uptime).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' %';
|
||||
}
|
||||
renderIncidents(data.incidents);
|
||||
} catch (_) {
|
||||
// Bei einem voruebergehenden Netzfehler bleibt die zuletzt geladene Anzeige sichtbar.
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
refreshStatus();
|
||||
window.setInterval(refreshStatus, 15000);
|
||||
})();
|
||||
</script>
|
||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Schnittstellen der Apps zu Zufriedenheitsabfrage, Statusseite, Ticket-Vorlagen und Cc-Empfängern.
|
||||
*/
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-apicss-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(36500, 36999);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$api = function (string $method, string $path, ?array $json = null, ?string $bearer = null) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
$h = ['Content-Type: application/json'];
|
||||
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h, CURLOPT_CUSTOMREQUEST => $method]);
|
||||
if ($json !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json));
|
||||
$b = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return [$s, json_decode($b, true) ?? $b];
|
||||
};
|
||||
$p = $pdo();
|
||||
$nowS = date('Y-m-d H:i:s');
|
||||
$p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['T9001', 'API-Test', 'Kunde', 'kunde@test.de', $dept, 2, 'open', 'web', $nowS, $nowS]);
|
||||
$tid = (int) $p->lastInsertId();
|
||||
$login = function (string $email) use ($api): string {
|
||||
[, $lg] = $api('POST', '/api/staff/v1/login.php', ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']);
|
||||
return (string) ($lg['token'] ?? '');
|
||||
};
|
||||
$admin = $login('chef@test.de');
|
||||
$agent = $login('agent@test.de');
|
||||
$check($admin !== '' && $agent !== '', 'Anmeldung von Administrator und Mitarbeiter');
|
||||
|
||||
// Cc-Empfänger
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'cc@test.de', 'name' => 'Cc Person'], $agent);
|
||||
$check($s === 200 && count($r['collaborators']) === 1 && $r['collaborators'][0]['email'] === 'cc@test.de' && $r['collaborators'][0]['name'] === 'Cc Person', 'Cc hinzufügen');
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'CC@test.de'], $agent);
|
||||
$check(count($r['collaborators']) === 1, 'dieselbe Adresse nicht doppelt');
|
||||
[$s, ] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'add', 'email' => 'kaputt'], $agent);
|
||||
$check($s === 422, 'ungültige Adresse wird abgelehnt');
|
||||
[$s, $r] = $api('GET', '/api/staff/v1/ticket_collaborators.php?number=T9001', null, $agent);
|
||||
$cid = (int) ($r['collaborators'][0]['id'] ?? 0);
|
||||
$check($s === 200 && $cid > 0, 'Cc-Liste abrufen');
|
||||
[$s, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent);
|
||||
$check(count($r['ticket']['collaborators']) === 1 && $r['ticket']['followers'] === [] && $r['ticket']['csat'] === null, 'Ticket enthält Cc, Beobachter und (noch keine) Bewertung');
|
||||
$api('PATCH', '/api/staff/v1/ticket_update.php', ['number' => 'T9001', 'following' => true], $agent);
|
||||
[, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent);
|
||||
$check(count($r['ticket']['followers']) === 1 && $r['ticket']['followers'][0]['name'] === 'Agent', 'Beobachter erscheinen am Ticket');
|
||||
[, $r] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'T9001', 'action' => 'remove', 'id' => $cid], $agent);
|
||||
$check($r['collaborators'] === [], 'Cc entfernen');
|
||||
[$s, ] = $api('POST', '/api/staff/v1/ticket_collaborators.php', ['number' => 'GIBTS-NICHT', 'action' => 'add', 'email' => 'a@b.de'], $agent);
|
||||
$check($s === 404 || $s === 403, 'unbekanntes Ticket: ' . $s);
|
||||
|
||||
// Bewertung am Ticket und Auswertung
|
||||
$p->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, rating, comment, sent_at, answered_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||
->execute([$tid, bin2hex(random_bytes(20)), $ids['Agent'], $dept, 5, 'Top', $nowS, $nowS]);
|
||||
[, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9001', null, $agent);
|
||||
$check($r['ticket']['csat']['rating'] === 5 && $r['ticket']['csat']['comment'] === 'Top', 'Bewertung am Ticket');
|
||||
[$s, $r] = $api('GET', '/api/staff/v1/csat.php?days=7', null, $agent);
|
||||
$check($s === 200 && $r['enabled'] === false && $r['stats']['answered'] === 1 && $r['stats']['avg'] == 5 && count($r['stats']['dist']) === 5 && $r['stats']['byStaff'][0]['name'] === 'Agent', 'Auswertung über die Schnittstelle');
|
||||
|
||||
// Vorlagen
|
||||
[$s, $r] = $api('GET', '/api/staff/v1/ticket_templates.php', null, $agent);
|
||||
$check($s === 200 && $r['templates'] === [], 'noch keine Vorlagen');
|
||||
$p->prepare("INSERT INTO ticket_templates (name, subject, body, department_id, is_public, is_active, sort_order, created_at) VALUES ('Neuer Mitarbeiter','Neu: ','Name:',?,1,1,1,?)")->execute([$dept, $nowS]);
|
||||
$p->prepare("INSERT INTO ticket_templates (name, subject, is_public, is_active, sort_order, created_at) VALUES ('Inaktiv','x',1,0,2,?)")->execute([$nowS]);
|
||||
[, $r] = $api('GET', '/api/staff/v1/ticket_templates.php', null, $agent);
|
||||
$check(count($r['templates']) === 1 && $r['templates'][0]['name'] === 'Neuer Mitarbeiter' && $r['templates'][0]['departmentId'] === $dept && $r['templates'][0]['body'] === 'Name:', 'Vorlagen für die Apps (nur aktive)');
|
||||
[$s, ] = $api('GET', '/api/staff/v1/ticket_templates.php');
|
||||
$check($s === 401, 'Vorlagen nur mit Anmeldung');
|
||||
|
||||
// Statusseite
|
||||
[$s, $r] = $api('GET', '/api/staff/v1/status.php', null, $agent);
|
||||
$check($s === 200 && $r['canManage'] === false && $r['status']['enabled'] === false && $r['status']['components'] === [], 'Statusseite lesen (Mitarbeiter)');
|
||||
[$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_enabled', 'enabled' => true], $agent);
|
||||
$check($s === 403, 'Mitarbeiter dürfen die Statusseite nicht ändern');
|
||||
$p->prepare("INSERT INTO status_components (name, status, sort_order, created_at) VALUES ('Webseite','operational',1,?)")->execute([$nowS]);
|
||||
$comp = (int) $p->lastInsertId();
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_enabled', 'enabled' => true], $admin);
|
||||
$check($s === 200 && $r['status']['enabled'] === true && $r['canManage'] === true, 'Administrator schaltet die Statusseite ein');
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'create_incident', 'title' => 'Portal langsam', 'body' => 'Wir prüfen das.', 'status' => 'investigating', 'componentId' => $comp, 'componentState' => 'degraded'], $admin);
|
||||
$check($s === 200 && $r['status']['overall']['key'] === 'degraded' && count($r['status']['incidents']) === 1 && $r['status']['components'][0]['status'] === 'degraded', 'Meldung veröffentlichen');
|
||||
$inc = (int) ($r['status']['incidents'][0]['id'] ?? 0);
|
||||
[$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'create_incident', 'title' => ''], $admin);
|
||||
$check($s === 422, 'Meldung ohne Titel');
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_component', 'id' => $comp, 'status' => 'outage'], $admin);
|
||||
$check($r['status']['overall']['key'] === 'outage', 'Dienst-Zustand ändern');
|
||||
[$s, ] = $api('POST', '/api/staff/v1/status.php', ['action' => 'set_component', 'id' => $comp, 'status' => 'kaputt'], $admin);
|
||||
$check($s === 422, 'ungültiger Zustand');
|
||||
[$s, $r] = $api('POST', '/api/staff/v1/status.php', ['action' => 'update_incident', 'id' => $inc, 'status' => 'resolved', 'body' => 'Behoben.'], $admin);
|
||||
$check($r['status']['overall']['key'] === 'operational' && $r['status']['incidents'][0]['status'] === 'resolved' && count($r['status']['incidents'][0]['updates']) === 2, 'Meldung beheben setzt den Dienst zurück');
|
||||
[, $r] = $api('GET', '/api/staff/v1/status.php', null, $agent);
|
||||
$check($r['status']['enabled'] === true && $r['status']['title'] === 'Systemstatus', 'Mitarbeiter sehen den Stand');
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-apicss-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "API CSAT, Status und Vorlagen checks passed ({$total} Prüfungen).\n";
|
||||
@@ -108,7 +108,9 @@ try {
|
||||
// 2) Einrichten über das Formular
|
||||
[$s, $b] = $req('POST', $pluginUrl, ['csrf_token' => $csrf($page), 'do' => 'save', 'enabled' => '1', 'host' => '127.0.0.1', 'port' => (string) $davPort, 'https' => '', 'user' => 'nas', 'password' => $davPass,
|
||||
'target' => '/dav/DeskHub-Backups/', 'mirror' => '1', 'db' => '1', 'files' => '1', 'scope' => 'important', 'interval' => 'daily', 'time' => '02:00', 'keep_days' => '0', 'keep' => '10', 'verify_tls' => '1'], $jar);
|
||||
$check($s === 200 && str_contains($b, 'Einstellungen gespeichert'), 'Einstellungen speichern meldet Erfolg (HTTP ' . $s . ')');
|
||||
$check($s === 200 && str_contains($b, 'location.replace') && !str_contains($b, 'Fatal error'), 'Nach dem Speichern ersetzt sich die Seite durch die normale Adresse (kein erneutes Absenden beim Neuladen, HTTP ' . $s . ')');
|
||||
[, $b] = $req('GET', $pluginUrl, [], $jar);
|
||||
$check(str_contains($b, 'Einstellungen gespeichert'), 'Einstellungen speichern meldet Erfolg');
|
||||
$check(str_contains($b, 'NAS erreichbar'), 'Die Seite zeigt „NAS erreichbar“ nach dem Einrichten');
|
||||
$check(!str_contains($b, 'Fatal error') && !str_contains($b, 'Warning:'), 'Die Seite erzeugt keine PHP-Fehler');
|
||||
|
||||
@@ -124,9 +126,12 @@ try {
|
||||
$check(str_contains($bp, '☁ auf NAS') || (str_contains($bp, 'nur hier') && str_contains($bp, '☁ Auf NAS')), 'Die Backup-Liste zeigt bei dem Backup die NAS-Marke (schon kopiert oder „nur hier“ mit Knopf „Auf NAS“; der Plugin-Takt kann bereits gelaufen sein)');
|
||||
[, $pp] = $req('GET', $pluginUrl, [], $jar);
|
||||
[$s, $b] = $req('POST', $pluginUrl, ['csrf_token' => $csrf($pp), 'do' => 'sync'], $jar);
|
||||
$check(str_contains($b, 'location.replace'), 'Auch nach „Fehlende kopieren“ lädt die Seite die normale Adresse: Neuladen (alle 10 s) oder F5 sendet den Klick nicht noch einmal ab');
|
||||
$check($name !== '' && in_array($name, $remoteFiles(), true), 'Das Backup der Backup-Seite wird auf die NAS kopiert (Aktion „Fehlende kopieren“)');
|
||||
[, $bp] = $req('GET', '/staff/backup.php', [], $jar);
|
||||
$check(str_contains($bp, '☁ auf NAS'), 'Danach trägt es in der Backup-Liste die Marke „auf NAS“');
|
||||
// Das Kopieren läuft seit Plugin 2.3 im Hintergrund (nach der Antwort): die Seite nach dem Klick neu laden
|
||||
[, $b] = $req('GET', $pluginUrl, [], $jar);
|
||||
$check(str_contains($b, '☁ auf der NAS') && str_contains($b, $name), 'Die Plugin-Seite zeigt es als „auf der NAS“');
|
||||
|
||||
// 4) Lokal löschen: bleibt als „nur auf der NAS“ sichtbar und lässt sich zurückholen
|
||||
|
||||
@@ -136,6 +136,21 @@ $t1 = theme_catalog_cached();
|
||||
$n1 = count($requests);
|
||||
$t2 = theme_catalog_cached();
|
||||
$check($t1['ok'] && $t2['ok'] && count($requests) === $n1 && $n1 === $before + 1, 'Theme-Katalog wird beim ersten Aufruf geladen und danach aus dem Zwischenspeicher gezeigt');
|
||||
$tc = json_decode((string) get_setting('theme_catalog_cache', ''), true);
|
||||
$tc['at'] = time() - 400;
|
||||
set_setting('theme_catalog_cache', json_encode($tc));
|
||||
$n3 = count($requests);
|
||||
theme_catalog_cached();
|
||||
$check(count($requests) === $n3 + 1, 'Theme-Katalog wird nach über 5 Minuten von selbst neu geladen');
|
||||
$tc = json_decode((string) get_setting('theme_catalog_cache', ''), true);
|
||||
$tc['at'] = time() - 400;
|
||||
set_setting('theme_catalog_cache', json_encode($tc));
|
||||
$saved = $GLOBALS['deskhub_http_get'];
|
||||
$GLOBALS['deskhub_http_get'] = fn(string $url, int $max): array => [0, null, 'Verbindung fehlgeschlagen'];
|
||||
$tStale = theme_catalog_cached();
|
||||
$check($tStale['ok'] && !empty($tStale['stale']) && is_array($tStale['themes']), 'Theme-Katalog: bei Serverausfall bleibt der letzte Stand sichtbar');
|
||||
$GLOBALS['deskhub_http_get'] = $saved;
|
||||
set_setting('theme_catalog_fail_at', '0');
|
||||
$check(plugin_updates_available($pdo = get_db()) === [], 'ohne installierte Plugins gibt es keine Update-Hinweise');
|
||||
|
||||
catalog_refresh_background();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft Zufriedenheitsabfrage (CSAT), Statusseite und Ticket-Vorlagen: Datenbank, Versand, Bewertung, Auswertung,
|
||||
* Störungsmeldungen, Vorlagen für Kunden und Mitarbeiter sowie die Seiten und Schnittstellen dazu.
|
||||
*/
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
$now = db_now();
|
||||
|
||||
// ── Zufriedenheitsabfrage ───────────────────────────────────────────────────
|
||||
$check(!csat_enabled() && csat_delay_minutes() === 30, 'Standard: ausgeschaltet, 30 Minuten');
|
||||
$mk = function (string $status, string $email, ?string $closedAt, string $source = 'web', int $spam = 0) use ($pdo, $now): int {
|
||||
static $n = 5000;
|
||||
$n++;
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, is_spam, closed_at, resolved_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(["CS-{$n}", "Betreff {$n}", 'Erika Muster', $email, 1, 2, $status, $source, $spam, $status === 'closed' ? $closedAt : null, $status === 'resolved' ? $closedAt : null, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$old = date('Y-m-d H:i:s', time() - 3600);
|
||||
$t1 = $mk('closed', 'erika@kunde.example', $old);
|
||||
$check(csat_send_due($pdo) === 0, 'ausgeschaltet: nichts wird gesendet');
|
||||
csat_set_enabled(true, 30);
|
||||
$check(csat_enabled() && get_setting('csat_since', '') !== '', 'Einschalten merkt sich den Zeitpunkt');
|
||||
$check(csat_send_due($pdo) === 0, 'Ticket aus der Zeit vor dem Einschalten bekommt keine Abfrage');
|
||||
set_setting('csat_since', date('Y-m-d H:i:s', time() - 86400));
|
||||
$tNew = $mk('closed', 'neu@kunde.example', date('Y-m-d H:i:s', time() - 60));
|
||||
$tSpam = $mk('closed', 'spam@kunde.example', $old, 'web', 1);
|
||||
$tChat = $mk('closed', 'chat@kunde.example', $old, 'chat');
|
||||
$tOpen = $mk('open', 'offen@kunde.example', null);
|
||||
$tNoMail = $mk('closed', '', $old);
|
||||
$tBad = $mk('closed', 'kein-email', $old);
|
||||
$tRes = $mk('resolved', 'geloest@kunde.example', $old);
|
||||
$sent = csat_send_due($pdo);
|
||||
$rows = $pdo->query('SELECT ticket_id FROM ticket_csat ORDER BY ticket_id')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$check($sent === 2 && array_map('intval', $rows) === [$t1, $tRes], 'gesendet nur für geschlossene/gelöste Tickets mit Adresse, nach der Wartezeit, ohne Spam und Chat: ' . json_encode($rows));
|
||||
$check(csat_send_due($pdo) === 0, 'pro Ticket nur eine Abfrage');
|
||||
$pdo->prepare('UPDATE tickets SET updated_at = ?, closed_at = ? WHERE id = ?')->execute([$old, $old, $tNew]);
|
||||
$check(csat_send_due($pdo) === 1, 'nach Ablauf der Wartezeit kommt auch das neuere Ticket dran');
|
||||
|
||||
$row = csat_for_ticket($pdo, $t1);
|
||||
$token = (string) $row['token'];
|
||||
$check(preg_match('/^[a-f0-9]{40}$/', $token) === 1 && csat_find_by_token($pdo, $token)['ticket_number'] === 'CS-5001', 'Token und Suche');
|
||||
$check(csat_find_by_token($pdo, 'zzz') === null && csat_find_by_token($pdo, str_repeat('a', 40)) === null, 'ungültiger Token');
|
||||
$check(str_contains(csat_mail_html($pdo->query("SELECT * FROM tickets WHERE id = {$t1}")->fetch(), $token), 'csat.php?t=' . $token . '&r=5') || str_contains(csat_mail_html($pdo->query("SELECT * FROM tickets WHERE id = {$t1}")->fetch(), $token), 'r=5'), 'Mail enthält die fünf Sterne-Links');
|
||||
$check(!csat_record($pdo, $token, 0, '')['ok'] && !csat_record($pdo, $token, 6, '')['ok'], 'Bewertung muss 1 bis 5 sein');
|
||||
$check(csat_record($pdo, $token, 4, ' Schnell und freundlich ')['ok'], 'Bewertung wird gespeichert');
|
||||
$check(!csat_record($pdo, $token, 1, '')['ok'], 'zweite Bewertung wird abgelehnt');
|
||||
$row = csat_for_ticket($pdo, $t1);
|
||||
$check((int) $row['rating'] === 4 && $row['comment'] === 'Schnell und freundlich' && $row['answered_at'] !== null, 'Bewertung und Kommentar stehen in der Datenbank');
|
||||
$check(!csat_record($pdo, 'x', 3, '')['ok'], 'unbekannter Link');
|
||||
$tok2 = (string) csat_for_ticket($pdo, $tRes)['token'];
|
||||
csat_record($pdo, $tok2, 2, '');
|
||||
$st = csat_stats($pdo, date('Y-m-d 00:00:00', time() - 86400), date('Y-m-d 23:59:59'), null);
|
||||
$check($st['sent'] === 3 && $st['answered'] === 2 && $st['avg'] === 3.0 && $st['rate'] === 66.7 && $st['dist'][4] === 1 && $st['dist'][2] === 1 && count($st['recent']) === 2, 'Auswertung: ' . json_encode($st));
|
||||
$check(csat_stats($pdo, '2000-01-01 00:00:00', '2000-12-31 23:59:59', null)['sent'] === 0 && csat_stats($pdo, $now, $now, [])['sent'] === 0, 'leere Zeiträume und Abteilungen');
|
||||
$check(csat_stars(4) === '★★★★☆' && csat_stars(9) === '★★★★★', 'Sterne-Text');
|
||||
csat_set_enabled(false, 30);
|
||||
$check(!csat_enabled(), 'Ausschalten');
|
||||
|
||||
// ── Statusseite ─────────────────────────────────────────────────────────────
|
||||
$check(!status_page_enabled() && status_overall($pdo)['key'] === 'operational', 'Statusseite standardmäßig aus, ohne Dienste alles in Ordnung');
|
||||
$web = status_save_component($pdo, null, 'Webseite', 'Kundenportal', 10);
|
||||
$mail = status_save_component($pdo, null, 'E-Mail', '', 20);
|
||||
$check($web !== null && $mail !== null && status_save_component($pdo, null, ' ', '', 0) === null, 'Dienste anlegen (leerer Name abgelehnt)');
|
||||
$check(!status_set_component($pdo, $web, 'kaputt') && status_set_component($pdo, $web, 'degraded') && status_overall($pdo)['key'] === 'degraded', 'Zustand setzen, ungültiger Zustand abgelehnt');
|
||||
status_set_component($pdo, $mail, 'outage');
|
||||
$check(status_overall($pdo)['key'] === 'outage' && status_overall($pdo)['label'] === 'Ausfall', 'schlechtester Zustand zählt');
|
||||
status_set_component($pdo, $mail, 'maintenance');
|
||||
status_set_component($pdo, $web, 'operational');
|
||||
$check(status_overall($pdo)['key'] === 'maintenance', 'Wartung');
|
||||
status_set_component($pdo, $mail, 'operational');
|
||||
$r = status_create_incident($pdo, ' ', 'x', 'investigating', null, null, 1);
|
||||
$check(!$r['ok'], 'Meldung ohne Titel wird abgelehnt');
|
||||
$r = status_create_incident($pdo, 'E-Mail verzögert', 'Wir untersuchen das.', 'investigating', $mail, 'degraded', 1);
|
||||
$check($r['ok'] && status_overall($pdo)['key'] === 'degraded', 'Meldung setzt den Dienst auf „eingeschränkt“');
|
||||
$inc = (int) $r['id'];
|
||||
$check(count(status_incidents($pdo, 14, true)) === 1 && status_incidents($pdo)[0]['updates'][0]['body'] === 'Wir untersuchen das.', 'offene Meldung mit erstem Update');
|
||||
$other = status_create_incident($pdo, 'Zweite Störung', '', 'identified', $mail, 'outage', 1)['id'];
|
||||
$check(status_update_incident($pdo, $inc, 'resolved', 'Behoben.', 1)['ok'] && status_overall($pdo)['key'] === 'outage', 'Dienst bleibt gestört, solange eine andere Meldung offen ist');
|
||||
$check(status_update_incident($pdo, $other, 'resolved', 'Auch behoben.', 1)['ok'] && status_overall($pdo)['key'] === 'operational', 'nach der letzten Meldung wieder „In Betrieb“');
|
||||
$check(!status_update_incident($pdo, 999, 'resolved', '', 1)['ok'] && !status_update_incident($pdo, $inc, 'quatsch', '', 1)['ok'], 'unbekannte Meldung / Stufe');
|
||||
$check(status_incidents($pdo, 14, true) === [] && count(status_incidents($pdo, 14)) === 2 && status_incidents($pdo)[0]['resolved_at'] !== null, 'behobene Meldungen bleiben 14 Tage sichtbar');
|
||||
$pdo->prepare('UPDATE status_incidents SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 30 * 86400), $inc]);
|
||||
$check(count(status_incidents($pdo, 14)) === 1, 'ältere behobene Meldungen verschwinden');
|
||||
$sum = status_summary($pdo);
|
||||
$check($sum['enabled'] === false && count($sum['components']) === 2 && $sum['overall']['key'] === 'operational' && count($sum['componentStates']) === 4 && str_ends_with($sum['url'], '/status.php'), 'Zusammenfassung für die Apps');
|
||||
status_delete_incident($pdo, $inc);
|
||||
status_delete_component($pdo, $mail);
|
||||
$check(count(status_components($pdo)) === 1 && (int) $pdo->query("SELECT COUNT(*) FROM status_incident_updates WHERE incident_id = {$inc}")->fetchColumn() === 0, 'Löschen räumt die Updates mit auf');
|
||||
|
||||
// ── Ticket-Vorlagen ─────────────────────────────────────────────────────────
|
||||
$check(!ticket_template_save($pdo, null, ['name' => ' '])['ok'], 'Vorlage ohne Namen wird abgelehnt');
|
||||
$a = ticket_template_save($pdo, null, ['name' => 'Neuer Mitarbeiter', 'subject' => 'Neuer Mitarbeiter: ', 'body' => "Name:\nStartdatum:", 'department_id' => '1', 'priority_id' => '2', 'is_public' => '1', 'sort_order' => '10']);
|
||||
$b = ticket_template_save($pdo, null, ['name' => 'Interne Vorlage', 'subject' => 'Intern', 'department_id' => '9999', 'is_public' => '', 'sort_order' => '20']);
|
||||
$check($a['ok'] && $b['ok'], 'Vorlagen anlegen');
|
||||
$check(count(ticket_templates_list($pdo)) === 2 && count(ticket_templates_list($pdo, true)) === 1 && ticket_templates_list($pdo, true)[0]['name'] === 'Neuer Mitarbeiter', 'Kunden sehen nur öffentliche Vorlagen');
|
||||
$tb = ticket_template_get($pdo, $b['id']);
|
||||
$check($tb['department_id'] === null && ticket_template_get($pdo, $b['id'], true) === null && ticket_template_get($pdo, 9999) === null, 'unbekannte Abteilung wird verworfen, nicht öffentliche Vorlage bleibt Kunden verborgen');
|
||||
$check(ticket_template_save($pdo, $a['id'], ['name' => 'Neuer Mitarbeiter 2', 'is_public' => '1', 'is_active' => '0'])['ok'] && ticket_template_get($pdo, $a['id']) === null && count(ticket_templates_list($pdo, false, true)) === 2, 'Vorlage deaktivieren');
|
||||
ticket_template_save($pdo, $a['id'], ['name' => 'Neuer Mitarbeiter', 'subject' => 'Neuer Mitarbeiter: ', 'body' => "Name:\nStartdatum:", 'department_id' => '1', 'is_public' => '1', 'is_active' => '1']);
|
||||
$p = ticket_template_payload(ticket_template_get($pdo, $a['id']));
|
||||
$check($p['name'] === 'Neuer Mitarbeiter' && $p['departmentId'] === 1 && $p['priorityId'] === null && $p['isPublic'] === true && $p['body'] === "Name:\nStartdatum:", 'Vorlage als Feldliste');
|
||||
ticket_template_delete($pdo, $b['id']);
|
||||
$check(count(ticket_templates_list($pdo)) === 1, 'Vorlage löschen');
|
||||
|
||||
// ── Seiten ──────────────────────────────────────────────────────────────────
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$render = function (string $file, array $get = [], array $post = [], string $method = 'GET') {
|
||||
$_GET = $get;
|
||||
$_POST = $post;
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
ob_start();
|
||||
try {
|
||||
include dirname(__DIR__) . '/' . $file;
|
||||
} finally {
|
||||
$html = (string) ob_get_clean();
|
||||
}
|
||||
return $html;
|
||||
};
|
||||
// Bewertungsseite
|
||||
csat_set_enabled(true, 30);
|
||||
$pdo->prepare('UPDATE ticket_csat SET rating = NULL, comment = NULL, answered_at = NULL WHERE ticket_id = ?')->execute([$t1]);
|
||||
$html = $render('csat.php', ['t' => $token, 'r' => '5']);
|
||||
$check(str_contains($html, 'Wie zufrieden waren Sie?') && str_contains($html, 'CS-5001') && str_contains($html, 'value="5" style="position:absolute;opacity:0;width:0;height:0" checked'), 'Bewertungsseite zeigt das Ticket mit vorgewähltem Stern');
|
||||
$check((int) csat_for_ticket($pdo, $t1)['answered_at'] === 0 && csat_for_ticket($pdo, $t1)['answered_at'] === null, 'Aufrufen des Links bewertet noch nichts');
|
||||
$html = $render('csat.php', [], ['t' => $token, 'rating' => '3', 'comment' => 'ok'], 'POST');
|
||||
$check(str_contains($html, 'Vielen Dank') && (int) csat_for_ticket($pdo, $t1)['rating'] === 3, 'Absenden speichert die Bewertung');
|
||||
$check(str_contains($render('csat.php', ['t' => $token]), 'Bereits bewertet'), 'zweiter Aufruf: bereits bewertet');
|
||||
$check(str_contains($render('csat.php', ['t' => 'unsinn']), 'Link nicht gültig'), 'ungültiger Link');
|
||||
// Statusseite
|
||||
set_setting('status_page_enabled', '1');
|
||||
status_set_component($pdo, $web, 'degraded');
|
||||
status_create_incident($pdo, 'Portal langsam', 'Wir arbeiten daran.', 'identified', $web, null, 1);
|
||||
$html = $render('status.php');
|
||||
$check(str_contains($html, 'Systemstatus') && str_contains($html, 'Teilweise eingeschränkt') && str_contains($html, 'Webseite') && str_contains($html, 'Portal langsam') && str_contains($html, 'Wir arbeiten daran.') && str_contains($html, 'Ursache gefunden'), 'Statusseite zeigt Dienste und Meldung');
|
||||
$check(str_contains($html, 'href="/status.php"'), 'Link „Status“ im Kundenbereich');
|
||||
// Ticket-Formular mit Vorlage
|
||||
$tpl = (int) $a['id'];
|
||||
$html = $render('ticket_new.php', ['template' => (string) $tpl]);
|
||||
$check(str_contains($html, 'value="Neuer Mitarbeiter:"') && str_contains($html, "Name:\nStartdatum:") && str_contains($html, 'ticket_new.php?template=' . $tpl) && str_contains($html, 'Ohne Vorlage'), 'Kundenformular wird von der Vorlage vorbelegt');
|
||||
$html = $render('ticket_new.php');
|
||||
$check(!str_contains($html, 'value="Neuer Mitarbeiter:"') && str_contains($html, 'Womit können wir helfen'), 'ohne Vorlage bleibt das Formular leer, Vorlagen werden angeboten');
|
||||
$html = $render('ticket_new.php', ['template' => '99999']);
|
||||
$check(!str_contains($html, 'Ohne Vorlage'), 'unbekannte Vorlage wird ignoriert');
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'CSAT, Status und Vorlagen checks passed.' . "\n";
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Fehler-Mail bei schweren PHP-Fehlern: Empfänger, Inhalt (ohne Parameter und Serverpfade), Drosselung, Ausschalter
|
||||
* und einmal echt: Eine Seite mit einem schweren Fehler löst in einem eigenen Prozess die Meldung aus.
|
||||
*/
|
||||
|
||||
$db = sys_get_temp_dir() . '/deskhub-ea-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=' . $db);
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
$mails = [];
|
||||
$send = function (string $to, string $subject, string $html) use (&$mails): bool {
|
||||
$mails[] = compact('to', 'subject', 'html');
|
||||
return true;
|
||||
};
|
||||
$root = str_replace('\\', '/', dirname(__DIR__));
|
||||
$err = ['type' => E_ERROR, 'message' => "Uncaught Error: Call to undefined function foo() in {$root}/staff/x.php:12", 'file' => $root . '/staff/x.php', 'line' => 12];
|
||||
|
||||
// Empfänger
|
||||
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Chef','chef@firma.example','x','admin',1,?)")->execute([db_now()]);
|
||||
set_setting('admin_email', '');
|
||||
$check(in_array('chef@firma.example', error_alert_recipients($pdo), true), 'ohne eigene Adresse gehen die Mails an die Administratoren');
|
||||
set_setting('admin_email', 'it@firma.example');
|
||||
$check(error_alert_recipients($pdo) === ['it@firma.example'], 'die Admin-Adresse der Seite hat Vorrang');
|
||||
set_setting('error_alert_email', 'a@firma.example, b@firma.example; kaputt');
|
||||
$check(error_alert_recipients($pdo) === ['a@firma.example', 'b@firma.example'], 'eigene Empfänger (mehrere, ungültige werden übersprungen)');
|
||||
|
||||
// Inhalt
|
||||
$_SERVER['REQUEST_URI'] = '/staff/tickets.php?token=GEHEIM&x=1';
|
||||
[$subject, $html] = error_alert_compose($err);
|
||||
$check(str_contains($html, 'staff/x.php') && str_contains($html, 'Zeile 12') && str_contains($html, '/staff/tickets.php'), 'Mail nennt Datei, Zeile und Seite');
|
||||
$check(!str_contains($html, 'GEHEIM') && !str_contains($html, $root), 'Mail enthält weder Adress-Parameter noch den Serverpfad');
|
||||
[, $evil] = error_alert_compose(['message' => '<script>alert(1)</script>', 'file' => 'x', 'line' => 1]);
|
||||
$check(!str_contains($evil, '<script>'), 'Fehlertext wird maskiert');
|
||||
|
||||
// Senden und Drosselung
|
||||
$n = error_alert_dispatch($pdo, $err, $send);
|
||||
$check($n === 2 && count($mails) === 2, 'erste Meldung geht an beide Empfänger');
|
||||
$check(error_alert_dispatch($pdo, $err, $send) === 0 && count($mails) === 2, 'dieselbe Meldung wird innerhalb einer Stunde nicht noch einmal gesendet');
|
||||
$check(error_alert_dispatch($pdo, ['line' => 99, 'message' => 'Anderer Fehler'] + $err, $send) === 2, 'eine andere Meldung geht durch');
|
||||
$same = error_alert_dispatch($pdo, ['message' => "Uncaught Error: Call to undefined function foo() in {$root}/staff/x.php:13", 'line' => 13] + $err, $send);
|
||||
$check($same === 0, 'dieselbe Meldung mit anderer Zeilennummer im Text zählt als gleich');
|
||||
for ($i = 0; $i < 20; $i++) error_alert_dispatch($pdo, ['message' => "Fehler Nummer-{$i}x", 'file' => "/f{$i}x.php"] + $err, $send);
|
||||
$check(count($mails) <= 2 * 10 + 0 && count(array_unique(array_map(fn($m) => $m['subject'] . $m['html'], $mails))) <= 10, 'höchstens 10 verschiedene Fehler-Mails pro Stunde');
|
||||
|
||||
// Ausschalter
|
||||
set_setting('error_alert_log', '[]');
|
||||
set_setting('error_alert', '0');
|
||||
$before = count($mails);
|
||||
$check(error_alert_dispatch($pdo, $err, $send) === 0 && count($mails) === $before, 'ausgeschaltet wird nichts gesendet');
|
||||
set_setting('error_alert', '1');
|
||||
|
||||
// Nicht schwere Fehler und Hinweise lösen nichts aus
|
||||
$check(!in_array(E_WARNING, ERROR_ALERT_FATAL, true) && !in_array(E_NOTICE, ERROR_ALERT_FATAL, true) && !in_array(E_DEPRECATED, ERROR_ALERT_FATAL, true), 'Warnungen und Hinweise zählen nicht');
|
||||
|
||||
// Echt: eigener Prozess mit schwerem Fehler
|
||||
set_setting('error_alert_log', '[]');
|
||||
$script = sys_get_temp_dir() . '/ea-page-' . bin2hex(random_bytes(3)) . '.php';
|
||||
file_put_contents($script, "<?php\nputenv('DESKHUB_TESTING=1');\nputenv('DESKHUB_SQLITE_PATH=' . " . var_export($db, true) . ");\nrequire " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . ";\nnoch_nie_definiert_im_system();\n");
|
||||
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $out, $code);
|
||||
@unlink($script);
|
||||
$log = json_decode((string) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'error_alert_log'")->fetchColumn(), true);
|
||||
$check(is_array($log) && count($log) === 1, 'echter schwerer Fehler in einer Seite löst die Meldung aus (Prozess-Ende ' . $code . ')');
|
||||
// Seite ohne Fehler: kein Eintrag
|
||||
set_setting('error_alert_log', '[]');
|
||||
$script = sys_get_temp_dir() . '/ea-ok-' . bin2hex(random_bytes(3)) . '.php';
|
||||
file_put_contents($script, "<?php\nputenv('DESKHUB_TESTING=1');\nputenv('DESKHUB_SQLITE_PATH=' . " . var_export($db, true) . ");\nrequire " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . ";\ntrigger_error('nur eine Warnung', E_USER_WARNING);\n");
|
||||
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $out2, $code2);
|
||||
@unlink($script);
|
||||
$log = json_decode((string) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'error_alert_log'")->fetchColumn(), true);
|
||||
$check(($log === [] || $log === null), 'eine Warnung löst keine Meldung aus');
|
||||
|
||||
@unlink($db);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Error alert checks passed.' . "\n";
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Mehrsprachigkeit: Sprachpakete als Plugin, Übersetzung der fertigen Seite (Text, Attribute, Knöpfe, JavaScript-Texte,
|
||||
* Muster mit {} und {#}), Sammelmodus, Sprachwechsler mit Flaggen und den Wechsel über lang.php.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-i18n-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
// ── Ohne Sprachpaket: nur Deutsch, kein Wechsler ────────────────────────────
|
||||
$check(array_keys(i18n_languages()) === ['de'] && i18n_switcher_html() === '' && i18n_current() === 'de', 'ohne Sprachpaket gibt es nur Deutsch und keinen Wechsler');
|
||||
|
||||
// ── Sprachpaket installieren: eigenes kleines Paket und das mitgelieferte English ──
|
||||
$mkZip = function (array $files) {
|
||||
$path = sys_get_temp_dir() . '/lang-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->open($path, ZipArchive::CREATE);
|
||||
foreach ($files as $name => $content) $z->addFromString($name, $content);
|
||||
$z->close();
|
||||
return $path;
|
||||
};
|
||||
$flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#009246"/></svg>';
|
||||
$strings = [
|
||||
'_info' => 'Test',
|
||||
'Neues Ticket' => 'New ticket',
|
||||
'Ticket erstellen' => 'Create ticket',
|
||||
'Betreff' => 'Subject',
|
||||
'Kurze Zusammenfassung' => 'Short summary',
|
||||
'Willkommen, {}' => 'Welcome, {}',
|
||||
'{} · erstellt am {}' => '{1} · created {2}',
|
||||
'{#} Treffer' => '{} hits',
|
||||
'Wirklich löschen?' => 'Really delete?',
|
||||
'Ein <b>Test</b>' => 'A test',
|
||||
'Mit & Zeichen' => 'With & signs',
|
||||
'Leer' => '',
|
||||
];
|
||||
$pkg = $mkZip([
|
||||
'plugin.json' => json_encode(['slug' => 'testsprache', 'name' => 'Testsprache', 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => [], 'language' => ['code' => 'xx', 'name' => 'Testsprache', 'flag' => 'flag.svg', 'strings' => 'strings.json', 'htmlLang' => 'xx-TS']]),
|
||||
'plugin.php' => "<?php\n",
|
||||
'flag.svg' => $flag,
|
||||
'strings.json' => json_encode($strings, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$r = plugin_install_zip($pdo, $pkg);
|
||||
$check($r['ok'] && $r['slug'] === 'testsprache', 'Sprachpaket lässt sich installieren: ' . json_encode($r));
|
||||
$check(count(i18n_languages(true)) === 1, 'ein nicht aktiviertes Sprachpaket zählt nicht');
|
||||
$act = plugin_set_active($pdo, 'testsprache', true, []);
|
||||
$check($act['ok'], 'Sprachpaket braucht keine Berechtigungen und lässt sich aktivieren: ' . json_encode($act));
|
||||
$langs = i18n_languages(true);
|
||||
$check(array_keys($langs) === ['de', 'xx'] && $langs['xx']['name'] === 'Testsprache' && $langs['xx']['htmlLang'] === 'xx-TS' && str_starts_with($langs['xx']['flag'], 'data:image/svg+xml;base64,'), 'Sprachen: Deutsch (eingebaut) und das Paket mit Flagge als Datenadresse');
|
||||
$check(i18n_dictionary('xx')['count'] === 10 && !isset(i18n_dictionary('xx')['exact']['Leer']), 'Wörterbuch: leere Werte und _info werden ignoriert');
|
||||
|
||||
// ── Auswahl der Sprache ─────────────────────────────────────────────────────
|
||||
$check(i18n_current() === 'de', 'ohne Cookie gilt die Standardsprache');
|
||||
$_COOKIE[I18N_COOKIE] = 'xx';
|
||||
$check(i18n_current() === 'xx', 'Cookie wählt die Sprache');
|
||||
$_COOKIE[I18N_COOKIE] = 'zz';
|
||||
$check(i18n_current() === 'de', 'unbekannter Sprachcode im Cookie wird ignoriert');
|
||||
unset($_COOKIE[I18N_COOKIE]);
|
||||
set_setting('default_language', 'xx');
|
||||
$check(i18n_default() === 'xx' && i18n_current() === 'xx', 'Standardsprache aus den Einstellungen');
|
||||
set_setting('default_language', 'qq');
|
||||
$check(i18n_default() === 'de', 'unbekannte Standardsprache fällt auf Deutsch zurück');
|
||||
set_setting('default_language', 'de');
|
||||
|
||||
// ── Übersetzung ─────────────────────────────────────────────────────────────
|
||||
$t = fn(string $html) => i18n_translate_html($html, 'xx');
|
||||
$check($t('<h1>Neues Ticket</h1>') === '<h1>New ticket</h1>', 'Text zwischen Tags');
|
||||
$check($t("<p>\n Neues Ticket \n</p>") === "<p>\n New ticket \n</p>", 'Leerraum um den Text bleibt, Leerraum im Text zählt nicht');
|
||||
$check($t('<input placeholder="Kurze Zusammenfassung" title="Betreff">') === '<input placeholder="Short summary" title="Subject">', 'Attribute placeholder und title');
|
||||
$check($t('<input type="submit" value="Ticket erstellen">') === '<input type="submit" value="Create ticket">', 'Beschriftung von Knöpfen');
|
||||
$check($t('<input type="text" value="Ticket erstellen">') === '<input type="text" value="Ticket erstellen">', 'Eingaben (value) bleiben unberührt');
|
||||
$check($t('<option value="Betreff">Betreff</option>') === '<option value="Betreff">Subject</option>', 'Optionstext ja, Wert nein');
|
||||
$check($t('<textarea>Neues Ticket</textarea>') === '<textarea>Neues Ticket</textarea>', 'Inhalt von Textfeldern bleibt');
|
||||
$check($t('<p>Unbekannter Text</p>') === '<p>Unbekannter Text</p>', 'ohne Übersetzung bleibt es deutsch');
|
||||
$check($t('<p>Willkommen, Erika</p>') === '<p>Welcome, Erika</p>', 'Muster mit {}');
|
||||
$check($t('<p>Technik · erstellt am 24.10.2026</p>') === '<p>Technik · created 24.10.2026</p>', 'Muster mit {1} und {2} (Reihenfolge)');
|
||||
$check($t('<p>12 Treffer</p>') === '<p>12 hits</p>' && $t('<p>viele Treffer</p>') === '<p>viele Treffer</p>', 'Muster mit {#} nur für Zahlen');
|
||||
$check($t('<p>Mit & Zeichen</p>') === '<p>With & signs</p>', 'Entities im Schlüssel und im Ergebnis');
|
||||
$check($t('<p>Wirklich löschen?</p>') === '<p>Really delete?</p>', 'Fragezeichen');
|
||||
$check($t('<button onclick="return confirm(\'Wirklich löschen?\')">x</button>') === '<button onclick="return confirm(\'Really delete?\')">x</button>', 'confirm() in Ereignis-Attributen');
|
||||
$check($t('<script>if (confirm("Wirklich löschen?")) go(); var a = "Neues Ticket"; var b = "Anderes";</script>') === '<script>if (confirm("Really delete?")) go(); var a = "New ticket"; var b = "Anderes";</script>', 'Textliterale in Skripten (nur genaue Treffer)');
|
||||
$check($t('<script src="/x.js"></script>') === '<script src="/x.js"></script>', 'Skript-Dateien bleiben');
|
||||
$check($t('<style>.a::after{content:"Neues Ticket"}</style>') === '<style>.a::after{content:"Neues Ticket"}</style>', 'Stile bleiben');
|
||||
$check($t('<html lang="de"><body>x</body></html>') === '<html lang="xx-TS"><body>x</body></html>', 'lang-Attribut passt zur Sprache');
|
||||
$check($t('<img src="data:image/png;base64,AAAA" alt="Betreff">') === '<img src="data:image/png;base64,AAAA" alt="Subject">', 'Bilder: alt ja, src nein');
|
||||
$check($t('<!-- Neues Ticket --><p>Neues Ticket</p>') === '<!-- Neues Ticket --><p>New ticket</p>', 'Kommentare bleiben');
|
||||
$check($t('<p>Neues <b>Ticket</b></p>') === '<p>Neues <b>Ticket</b></p>', 'Text mit Markup mittendrin wird nicht zerhackt (nur ganze Textstücke)');
|
||||
$check(i18n_translate_html('<p>Neues Ticket</p>', 'de') === '<p>Neues Ticket</p>', 'Deutsch verändert nichts');
|
||||
|
||||
// ── Sammelmodus ─────────────────────────────────────────────────────────────
|
||||
$seen = [];
|
||||
i18n_translate_html('<h1>Neues Ticket</h1><p>Völlig unbekannter Satz hier</p><p>42</p><a href="x">https://beispiel.de</a><span title="Ein Tooltip hier">x</span><script>if (confirm("Soll das wirklich weg?")) {}</script>', 'xx', $seen);
|
||||
$check(isset($seen['Völlig unbekannter Satz hier']) && isset($seen['Ein Tooltip hier']) && isset($seen['Soll das wirklich weg?']) && !isset($seen['Neues Ticket']) && !isset($seen['42']) && !isset($seen['https://beispiel.de']), 'gesammelt wird nur Unübersetztes, keine Zahlen und Adressen: ' . json_encode(array_keys($seen)));
|
||||
i18n_remember(array_keys($seen));
|
||||
i18n_remember(['Völlig unbekannter Satz hier']);
|
||||
$row = $pdo->query("SELECT hits FROM i18n_missing WHERE text = 'Völlig unbekannter Satz hier'")->fetchColumn();
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn() === 3 && (int) $row === 2, 'unübersetzte Texte werden gespeichert und gezählt');
|
||||
|
||||
// ── Ausgabe-Filter ──────────────────────────────────────────────────────────
|
||||
$GLOBALS['__i18n_code'] = 'xx';
|
||||
$check(i18n_output_filter('<!doctype html><html lang="de"><body><h1>Neues Ticket</h1></body></html>') === '<!doctype html><html lang="xx-TS"><body><h1>New ticket</h1></body></html>', 'Ausgabe-Filter übersetzt die Seite');
|
||||
$check(i18n_output_filter('{"a":"Neues Ticket"}') === false, 'JSON bleibt unverändert (keine HTML-Tags)');
|
||||
|
||||
// ── Sprachwechsler ──────────────────────────────────────────────────────────
|
||||
$_SERVER['REQUEST_URI'] = '/ticket_new.php?x=1';
|
||||
$sw = i18n_switcher_html();
|
||||
$check(substr_count($sw, '<img') === 2 && str_contains($sw, '/lang.php?l=xx&back=%2Fticket_new.php%3Fx%3D1') && str_contains($sw, '/lang.php?l=de') && str_contains($sw, 'aria-current'), 'Wechsler: zwei Flaggen, Links mit Rücksprung, aktive Sprache markiert');
|
||||
$check(str_contains(i18n_switcher_html('floating'), 'position:fixed') && !str_contains($sw, 'position:fixed'), 'Wechsler: feste Position nur für Anmeldeseiten');
|
||||
$check(i18n_safe_back('/staff/tickets.php?a=1') === '/staff/tickets.php?a=1' && i18n_safe_back('//evil.example') === '/' && i18n_safe_back('https://evil.example') === '/' && i18n_safe_back("/a\r\nSet-Cookie: x") === '/' && i18n_safe_back('/\\evil') === '/' && i18n_safe_back('') === '/', 'Rücksprung nur auf Adressen dieser Seite');
|
||||
|
||||
// ── Das mitgelieferte English-Paket ─────────────────────────────────────────
|
||||
$enSrc = plugin_source_dir_or_skip('lang-en', 'i18n');
|
||||
$deSrc = plugin_source_dir_or_skip('lang-de', 'i18n');
|
||||
foreach ([$enSrc, $deSrc] as $src) {
|
||||
$zp = sys_get_temp_dir() . '/lp-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->open($zp, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
$z->close();
|
||||
$ri = plugin_install_zip($pdo, $zp);
|
||||
$check($ri['ok'], 'mitgeliefertes Paket installierbar: ' . ($ri['error'] ?? $ri['slug']));
|
||||
$slug = (string) ($ri['slug'] ?? '');
|
||||
$check(plugin_set_active($pdo, $slug, true, [])['ok'], "Paket {$slug} aktivierbar");
|
||||
@unlink($zp);
|
||||
}
|
||||
$langs = i18n_languages(true);
|
||||
$check(isset($langs['en'], $langs['de'], $langs['xx']) && $langs['de']['slug'] === 'lang-de' && $langs['en']['name'] === 'English' && $langs['en']['flag'] !== '', 'English und Deutsch (als Plugin) sind da');
|
||||
$en = i18n_dictionary('en');
|
||||
$check($en['count'] > 900, 'English hat viele Übersetzungen: ' . $en['count']);
|
||||
$check(i18n_translate_html('<h1>Neues Ticket erstellen</h1><label>Betreff</label><p>Willkommen, Erika</p><a>Meine Tickets – Test</a>', 'en') === '<h1>Create new ticket</h1><label>Subject</label><p>Welcome, Erika</p><a>My tickets – Test</a>', 'English übersetzt Kundenportal-Texte');
|
||||
$check(i18n_translate_html('<th>Abteilungen</th><button>Speichern</button><span>Abmelden</span>', 'en') === '<th>Departments</th><button>Save</button><span>Sign out</span>', 'English übersetzt Texte des Mitarbeiterbereichs');
|
||||
|
||||
// ── Aufräumen ───────────────────────────────────────────────────────────────
|
||||
@unlink($pkg);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Mehrsprachigkeit checks passed.' . "\n";
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Mehrsprachigkeit auf echten Seiten: Sprachwechsel über lang.php, übersetzte Kundenportal- und Mitarbeiterseiten, Sammelmodus.
|
||||
*/
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-i18np-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(37500, 37999);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
// Sprachpakete in die Test-Installation einspielen (wie über Plugins → Hochladen)
|
||||
putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/database/deskhub.sqlite');
|
||||
$zips = [];
|
||||
foreach (['lang-en', 'lang-de'] as $slug) {
|
||||
$src = $root . '/../Plugins/plugins/' . $slug;
|
||||
$zp = $work . '/' . $slug . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->open($zp, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace(chr(92), '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
$z->close();
|
||||
$zips[] = $zp;
|
||||
}
|
||||
$script = $work . '/install.php';
|
||||
file_put_contents($script, "<?php\nrequire " . var_export($tmp . '/includes/bootstrap.php', true) . ";\n\$pdo = get_db();\nforeach (" . var_export($zips, true) . " as \$z) { \$r = plugin_install_zip(\$pdo, \$z); if (!\$r['ok']) { echo 'FEHLER ' . \$r['error']; exit(1); } plugin_set_active(\$pdo, \$r['slug'], true, []); }\necho 'ok';");
|
||||
$out = (string) shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1');
|
||||
$check(trim($out) === 'ok', 'Sprachpakete in der Test-Installation installiert: ' . $out);
|
||||
|
||||
$call = function (string $method, string $path, string $jar, array $form = [], array $headers = []) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => array_merge(['Sec-Fetch-Mode: navigate'], $headers)]);
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
|
||||
$raw = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
|
||||
};
|
||||
$jar = fn() => tempnam(sys_get_temp_dir(), 'i18n');
|
||||
|
||||
// Gast: erst deutsch, mit Flaggen
|
||||
$g = $jar();
|
||||
[$s, $b] = $call('GET', '/login.php', $g);
|
||||
$check($s === 200 && str_contains($b, 'Passwort vergessen?') && str_contains($b, '<html lang="de">') && substr_count($b, 'class="lang-switch"') === 1 && substr_count($b, 'data:image/svg+xml;base64') >= 2, 'Anmeldeseite: deutsch, mit Sprachwechsler (zwei Flaggen)');
|
||||
// Wechsel zu English
|
||||
[$s, , $h] = $call('GET', '/lang.php?l=en&back=%2Flogin.php', $g);
|
||||
$check($s === 302 && preg_match('#^Location: /login\.php#mi', $h) === 1 && stripos($h, 'Set-Cookie: deskhub_lang=en') !== false && stripos($h, 'SameSite=Lax') !== false, 'lang.php setzt das Cookie und springt zurück');
|
||||
[$s, $b] = $call('GET', '/login.php', $g);
|
||||
$check(str_contains($b, 'Forgot your password?') && str_contains($b, '<html lang="en">') && !str_contains($b, 'Passwort vergessen?'), 'Anmeldeseite ist englisch');
|
||||
[, $b] = $call('GET', '/ticket_new.php', $g);
|
||||
$check(str_contains($b, 'Create new ticket') && str_contains($b, 'Subject') && str_contains($b, 'Open a new ticket') | str_contains($b, 'Support center home'), 'Neues Ticket ist englisch');
|
||||
[, $b] = $call('GET', '/ticket_lookup.php', $g);
|
||||
$check(str_contains($b, 'Check ticket status') || str_contains($b, 'Ticket number'), 'Ticketstatus-Seite ist englisch');
|
||||
[, $b] = $call('GET', '/staff/login.php', $g);
|
||||
$check(str_contains($b, '<html lang="en">') && str_contains($b, 'class="lang-switch"'), 'Mitarbeiter-Anmeldung: englisch, mit Wechsler');
|
||||
// Ungültiges wird ignoriert
|
||||
$g2 = $jar();
|
||||
[$s, , $h] = $call('GET', '/lang.php?l=zz&back=https%3A%2F%2Fevil.example', $g2);
|
||||
$check($s === 302 && stripos($h, 'deskhub_lang') === false && preg_match('#^Location: /\s*$#mi', $h) === 1, 'unbekannte Sprache und fremde Rücksprung-Adresse werden ignoriert');
|
||||
[$s, , $h] = $call('GET', '/lang.php?l=en&back=%2F%2Fevil.example', $g2);
|
||||
$check(preg_match('#^Location: /\s*$#mi', $h) === 1, 'Rücksprung-Adresse mit // wird verworfen');
|
||||
|
||||
// Mitarbeiterbereich auf Englisch
|
||||
$adm = $jar();
|
||||
[, $lp] = $call('GET', '/staff/login.php', $adm);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', $adm, ['csrf_token' => $m[1] ?? '', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||
$call('GET', '/lang.php?l=en&back=%2F', $adm);
|
||||
[$s, $b] = $call('GET', '/staff/departments.php', $adm);
|
||||
$check($s === 200 && str_contains($b, '<html lang="en">') && str_contains($b, 'Departments') && str_contains($b, 'Sign out') && str_contains($b, 'class="lang-switch"'), 'Mitarbeiterseite ist englisch, Wechsler in der Seitenleiste');
|
||||
$call('GET', '/lang.php?l=de&back=%2F', $adm);
|
||||
[, $b] = $call('GET', '/staff/departments.php', $adm);
|
||||
$check(str_contains($b, 'Abteilungen') && str_contains($b, 'Abmelden'), 'zurück auf Deutsch');
|
||||
|
||||
// Seite „Sprachen“, Standardsprache, Sammelmodus
|
||||
[$s, $b] = $call('GET', '/staff/languages.php', $adm);
|
||||
$check($s === 200 && str_contains($b, 'English') && str_contains($b, 'lang-en') && str_contains($b, 'Vorlage laden'), 'Seite „Sprachen“ zeigt die installierten Sprachen');
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $b, $m);
|
||||
$call('POST', '/staff/languages.php', $adm, ['csrf_token' => $m[1] ?? '', 'action' => 'save', 'default_language' => 'en', 'i18n_collect' => '1']);
|
||||
$fresh = $jar();
|
||||
[, $b] = $call('GET', '/login.php', $fresh);
|
||||
$check(str_contains($b, 'Forgot your password?') && str_contains($b, '<html lang="en">'), 'Standardsprache gilt für Besucher ohne Cookie');
|
||||
$call('GET', '/lang.php?l=de&back=%2F', $fresh);
|
||||
$call('GET', '/login.php', $fresh);
|
||||
$call('GET', '/ticket_lookup.php', $fresh);
|
||||
$check((int) $pdo()->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn() > 3, 'Sammelmodus merkt sich Texte, auch im deutschen Modus');
|
||||
[$s, $b, $h] = $call('GET', '/staff/languages.php?download=en', $adm);
|
||||
$tpl = json_decode($b, true);
|
||||
$check($s === 200 && is_array($tpl) && stripos($h, 'application/json') !== false && stripos($h, 'attachment') !== false && ($tpl['Anmelden'] ?? null) === 'Sign in', 'Vorlage zum Herunterladen: vorhandene Übersetzungen eingetragen');
|
||||
// Schnittstellen bleiben unberührt (JSON)
|
||||
[$s, $b] = $call('POST', '/api/staff/v1/login.php', $fresh, [], ['Content-Type: application/json']);
|
||||
$check(json_decode($b, true) !== null, 'Schnittstelle liefert weiter reines JSON');
|
||||
[$s, $b] = $call('POST', '/staff/languages.php', $adm, ['csrf_token' => 'falsch', 'action' => 'clear']);
|
||||
$check($s === 403, 'Löschen der gesammelten Texte braucht das CSRF-Token');
|
||||
// Nicht-Admins kommen nicht auf die Seite
|
||||
$ag = $jar();
|
||||
[, $lp] = $call('GET', '/staff/login.php', $ag);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', $ag, ['csrf_token' => $m[1] ?? '', 'email' => 'agent@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||
[$s, , $h] = $call('GET', '/staff/languages.php', $ag);
|
||||
$check($s === 302, 'Mitarbeiter ohne Adminrechte werden von der Seite „Sprachen“ umgeleitet');
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-i18np-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "i18n pages checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Live-Anzeige für neue Tickets: Ausgangsstand ohne Hinweise, neue Tickets seit der letzten ID, Ausschluss von Spam, Live-Chats und
|
||||
* zusammengeführten Tickets, Abteilungsrechte, Kennzahlen, Begrenzung auf 6 Hinweise und die Einbindung ins Layout.
|
||||
*/
|
||||
|
||||
$db = sys_get_temp_dir() . '/deskhub-lt-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=' . $db);
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||
$d2 = (int) $pdo->lastInsertId();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$mkStaff = function (string $name, string $role, ?int $dept) use ($pdo): int {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$anna = $mkStaff('Anna', 'agent', $d1);
|
||||
$chef = $mkStaff('Chef', 'admin', null);
|
||||
$n = 0;
|
||||
$mk = function (string $subject, int $dept, array $o = []) use ($pdo, $prio, &$n): int {
|
||||
$n++;
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, is_spam, merged_into, assigned_staff_id, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['LT-' . $n, $subject, $o['name'] ?? 'Kunde', 'k@x.example', $dept, $prio, $o['status'] ?? 'open', $o['source'] ?? 'web', $o['spam'] ?? 0, $o['merged'] ?? null, $o['assigned'] ?? null, $o['due'] ?? null, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$call = function (int $staffId, int $since) {
|
||||
$_SESSION['staff_id'] = $staffId;
|
||||
$_GET = ['since' => (string) $since];
|
||||
// current_staff() merkt sich den Mitarbeiter je Prozess; für den Test über Reflection zurücksetzen
|
||||
$rf = new ReflectionFunction('current_staff');
|
||||
foreach ($rf->getStaticVariables() as $k => $v) { /* nur lesen */ }
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/staff/live_tickets.php';
|
||||
return json_decode((string) ob_get_clean(), true);
|
||||
};
|
||||
|
||||
// current_staff() cached den ersten Mitarbeiter im Prozess: deshalb jeden Fall in einem eigenen Prozess prüfen
|
||||
$php = escapeshellarg(PHP_BINARY);
|
||||
$quiet = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null';
|
||||
$script = sys_get_temp_dir() . '/lt-call-' . bin2hex(random_bytes(3)) . '.php';
|
||||
file_put_contents($script, "<?php\nputenv('DESKHUB_TESTING=1');\nputenv('DESKHUB_SQLITE_PATH=' . " . var_export($db, true) . ");\nrequire " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . ";\nif ((int) (\$argv[1] ?? 0) > 0) \$_SESSION['staff_id'] = (int) \$argv[1];\n\$_GET = ['since' => \$argv[2] ?? '0'];\ninclude " . var_export(dirname(__DIR__) . '/staff/live_tickets.php', true) . ";\n");
|
||||
$run = function (int $staffId, int $since) use ($php, $script, $quiet): ?array {
|
||||
$out = [];
|
||||
exec("{$php} " . escapeshellarg($script) . " {$staffId} {$since} " . $quiet, $out);
|
||||
return json_decode(implode("\n", $out), true);
|
||||
};
|
||||
|
||||
// Ausgangsstand
|
||||
$base = $mk('Altes Ticket', $d1);
|
||||
$r0 = $run($chef, 0);
|
||||
$check(is_array($r0) && $r0['latest'] === $base && $r0['tickets'] === [] && $r0['more'] === 0, 'Ausgangsstand liefert die letzte ID und keine Hinweise: ' . json_encode($r0));
|
||||
$check(is_array($r0) && $r0['counts']['open'] === 1 && $r0['counts']['unassigned'] === 1 && $r0['counts']['mine'] === 0 && $r0['counts']['unanswered'] === 1 && $r0['counts']['overdue'] === 0, 'Kennzahlen stimmen');
|
||||
|
||||
// Neue Tickets
|
||||
$t1 = $mk('Login geht nicht', $d1, ['name' => 'Erika Muster']);
|
||||
$t2 = $mk('Technik-Frage', $d2);
|
||||
$mk('Spam', $d1, ['spam' => 1]);
|
||||
$mk('Live-Chat', $d1, ['source' => 'chat']);
|
||||
$mk('Zusammengeführt', $d1, ['merged' => $t1]);
|
||||
$r1 = $run($chef, $base);
|
||||
$ids = array_column((array) ($r1['tickets'] ?? []), 'id');
|
||||
$check($ids === [$t1, $t2], 'Administrator sieht die beiden echten neuen Tickets (kein Spam, kein Chat, kein zusammengeführtes): ' . json_encode($ids));
|
||||
$check(($r1['tickets'][0]['subject'] ?? '') === 'Login geht nicht' && ($r1['tickets'][0]['name'] ?? '') === 'Erika Muster' && ($r1['tickets'][0]['number'] ?? '') === 'LT-2' && ($r1['tickets'][1]['department'] ?? '') === 'Technik', 'Hinweis enthält Nummer, Betreff, Name und Abteilung');
|
||||
$check(($r1['latest'] ?? 0) === $t2, 'latest ist die ID des neuesten sichtbaren Tickets (Spam, Chat und zusammengeführte zählen nicht)');
|
||||
|
||||
// Abteilungsrechte
|
||||
$r2 = $run($anna, $base);
|
||||
$check(array_column((array) ($r2['tickets'] ?? []), 'id') === [$t1], 'Mitarbeiter sehen nur Tickets ihrer Abteilung');
|
||||
$check(($r2['counts']['open'] ?? -1) === 5 && ($r1['counts']['open'] ?? -1) === 6, 'Zahlen werden je Abteilung gezählt wie auf der Übersicht (Mitarbeiter 5, Administrator 6): ' . ($r2['counts']['open'] ?? '?') . '/' . ($r1['counts']['open'] ?? '?'));
|
||||
$ra = $run($anna, 0);
|
||||
$check(($ra['counts']['open'] ?? -1) === $r2['counts']['open'] && ($ra['counts']['open'] ?? -1) < ($run($chef, 0)['counts']['open'] ?? 0), 'Mitarbeiter zählen weniger als Administratoren');
|
||||
|
||||
// Nach dem Abruf nichts Neues
|
||||
$r3 = $run($chef, (int) $r1['latest']);
|
||||
$check(($r3['tickets'] ?? null) === [] && ($r3['more'] ?? -1) === 0, 'kein Hinweis, wenn nichts Neues kam');
|
||||
|
||||
// Mir zugewiesen und überfällig
|
||||
$mk('Zugewiesen', $d1, ['assigned' => $anna, 'due' => date('Y-m-d H:i:s', time() - 3600)]);
|
||||
$r4 = $run($anna, 0);
|
||||
$check(($r4['counts']['mine'] ?? 0) === 1 && ($r4['counts']['overdue'] ?? 0) === 1, 'zugewiesene und überfällige Tickets werden gezählt');
|
||||
|
||||
// Begrenzung auf 6 Hinweise
|
||||
$before = (int) $run($chef, 0)['latest'];
|
||||
for ($i = 0; $i < 9; $i++) $mk('Massenticket ' . $i, $d1);
|
||||
$r5 = $run($chef, $before);
|
||||
$check(count($r5['tickets'] ?? []) === 6 && ($r5['more'] ?? 0) === 3, 'höchstens 6 Hinweise, der Rest wird als „weitere“ gezählt: ' . count($r5['tickets'] ?? []) . ' / ' . ($r5['more'] ?? '?'));
|
||||
$check(($r5['tickets'][0]['subject'] ?? '') === 'Massenticket 0', 'älteste zuerst');
|
||||
|
||||
// Ohne Anmeldung
|
||||
$out = [];
|
||||
exec("{$php} " . escapeshellarg($script) . " 0 0 " . $quiet, $out);
|
||||
$check(json_decode(implode('', $out), true) === ['error' => 'login'], 'ohne Anmeldung: Fehlermeldung statt Daten');
|
||||
|
||||
// Einbindung
|
||||
$footer = (string) file_get_contents(dirname(__DIR__) . '/staff/_footer.php');
|
||||
$check(str_contains($footer, '/assets/js/live-tickets.js'), 'Skript ist im Layout eingebunden');
|
||||
$reports = (string) file_get_contents(dirname(__DIR__) . '/staff/reports.php');
|
||||
$check(substr_count($reports, 'data-live-count=') === 5, 'fünf Kennzahlen der Übersicht aktualisieren sich live');
|
||||
$js = (string) file_get_contents(dirname(__DIR__) . '/assets/js/live-tickets.js');
|
||||
$check(str_contains($js, '/staff/live_tickets.php') && str_contains($js, 'textContent') && !str_contains($js, 'innerHTML'), 'Skript fragt den Abruf ab und setzt Inhalte nur als Text (kein innerHTML)');
|
||||
$check(preg_match('/setTimeout\(poll, document\.hidden \? 30000 : 10000\)/', $js) === 1, 'Abruf alle 10 Sekunden (im Hintergrund-Tab alle 30)');
|
||||
|
||||
@unlink($script);
|
||||
@unlink($db);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Live tickets checks passed.' . "\n";
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Discord- und Telegram-Bot": Signaturprüfung (Ed25519), Befehle /ticket /status /antwort, Ratenbegrenzung,
|
||||
* Benachrichtigung bei Antworten und Lösung (mit ersetztem HTTP-Aufruf), Telegram-Geheimtoken und Schutz fremder Tickets.
|
||||
*/
|
||||
|
||||
if (!function_exists('sodium_crypto_sign_keypair')) { echo "sodium fehlt, Test übersprungen\n"; exit(0); }
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-cb-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('community-bot', 'plugin_community_bot');
|
||||
$zipPath = sys_get_temp_dir() . '/cb-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'community-bot', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'community-bot', true, ['api.public', 'hooks.tickets', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$wh = plugin_state()['webhooks']['community-bot'] ?? [];
|
||||
$check(isset($wh['discord'], $wh['telegram']), 'beide Webhook-Adressen sind registriert');
|
||||
|
||||
// ── Discord ────────────────────────────────────────────────────────────────
|
||||
$kp = sodium_crypto_sign_keypair();
|
||||
$sk = sodium_crypto_sign_secretkey($kp);
|
||||
plugin_set_setting('community-bot', 'discord_key', bin2hex(sodium_crypto_sign_publickey($kp)));
|
||||
communitybot_set_secret('discord_token', 'bot-token-xyz');
|
||||
plugin_set_setting('community-bot', 'discord_app', '123456789012');
|
||||
$sent = [];
|
||||
$GLOBALS['communitybot_http'] = function (string $method, string $url, array $headers, ?string $body) use (&$sent): array {
|
||||
$sent[] = compact('method', 'url', 'headers', 'body');
|
||||
if (str_contains($url, '/users/@me/channels')) return ['status' => 200, 'body' => '{"id":"dm-1"}'];
|
||||
return ['status' => 200, 'body' => '{"ok":true,"id":"m1"}'];
|
||||
};
|
||||
$dc = function (array $payload, ?string $ts = null, bool $tamper = false) use ($wh, $sk): array {
|
||||
$body = json_encode($payload);
|
||||
$ts ??= (string) time();
|
||||
$sig = bin2hex(sodium_crypto_sign_detached($ts . $body, $sk));
|
||||
return $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => $sig, 'x-signature-timestamp' => $ts], 'body' => $tamper ? $body . ' ' : $body, 'query' => [], 'ip' => '1.2.3.4']);
|
||||
};
|
||||
$check($dc(['type' => 1])['json'] === ['type' => 1], 'PING wird mit PONG beantwortet');
|
||||
$check($dc(['type' => 1], null, true)['status'] === 401, 'veränderter Inhalt wird abgelehnt (401)');
|
||||
$check($dc(['type' => 1], (string) (time() - 3600))['status'] === 401, 'alter Zeitstempel wird abgelehnt');
|
||||
$bad = $wh['discord'](['method' => 'POST', 'headers' => [], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']);
|
||||
$check($bad['status'] === 401, 'ohne Signatur: 401');
|
||||
$other = sodium_crypto_sign_secretkey(sodium_crypto_sign_keypair());
|
||||
$ts = (string) time();
|
||||
$forged = $wh['discord'](['method' => 'POST', 'headers' => ['x-signature-ed25519' => bin2hex(sodium_crypto_sign_detached($ts . '{"type":1}', $other)), 'x-signature-timestamp' => $ts], 'body' => '{"type":1}', 'query' => [], 'ip' => '1.2.3.4']);
|
||||
$check($forged['status'] === 401, 'Signatur mit fremdem Schlüssel: 401');
|
||||
|
||||
$cmd = fn(string $name, array $opts, string $uid = '111111111111') => ['type' => 2, 'channel_id' => '999', 'member' => ['user' => ['id' => $uid, 'username' => 'kim', 'global_name' => 'Kim Test']], 'data' => ['name' => $name, 'options' => array_map(fn($k, $v) => ['name' => $k, 'value' => $v], array_keys($opts), $opts)]];
|
||||
$res = $dc($cmd('ticket', ['betreff' => 'Login geht nicht', 'nachricht' => 'Ich komme nicht mehr in mein Konto']));
|
||||
$content = $res['json']['data']['content'] ?? '';
|
||||
$check($res['status'] === 200 && ($res['json']['data']['flags'] ?? 0) === 64 && preg_match('/#(\S+) wurde angelegt/', $content, $m) === 1, '/ticket legt ein Ticket an und nennt die Nummer: ' . $content);
|
||||
$number = $m[1] ?? '';
|
||||
$t = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
||||
$t->execute([$number]);
|
||||
$ticket = $t->fetch();
|
||||
$check($ticket && $ticket['subject'] === 'Login geht nicht' && $ticket['requester_email'] === 'discord-111111111111@discord.invalid' && $ticket['source'] === 'api', 'Ticket hat Betreff, Platzhalter-Adresse und Quelle');
|
||||
$check($ticket && $ticket['requester_name'] === 'Kim Test', 'Name aus Discord übernommen');
|
||||
$tid = (int) ($ticket['id'] ?? 0);
|
||||
$tagged = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$tid} AND g.name = 'Discord'")->fetchColumn();
|
||||
$check($tagged === 1, 'Ticket trägt den Tag „Discord“');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_communitybot_links WHERE ticket_id = {$tid} AND platform = 'discord' AND ext_user = '111111111111'")->fetchColumn() === 1, 'Verknüpfung gespeichert');
|
||||
$check(($res['json']['data']['allowed_mentions']['parse'] ?? null) === [], 'keine Erwähnungen in der Antwort erlaubt');
|
||||
|
||||
$st = $dc($cmd('status', ['nummer' => $number]))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($st, $number) && str_contains($st, 'Offen'), '/status zeigt den Stand: ' . $st);
|
||||
$foreign = $dc($cmd('status', ['nummer' => $number], '222222222222'))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($foreign, 'gehört nicht zu Ihnen') && !str_contains($foreign, 'Offen'), 'fremdes Ticket ist nicht abfragbar');
|
||||
$foreignReply = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Ich mische mich ein'], '222222222222'))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($foreignReply, 'gehört nicht zu Ihnen') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid}")->fetchColumn() === 1, 'fremde Antwort wird nicht gespeichert');
|
||||
$list = $dc($cmd('status', []))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($list, $number), '/status ohne Nummer listet die eigenen Tickets');
|
||||
|
||||
$rep = $dc($cmd('antwort', ['nummer' => $number, 'text' => 'Noch eine Info']))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($rep, 'hinzugefügt') && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$tid} AND author_type = 'customer'")->fetchColumn() === 2, '/antwort ergänzt das Ticket');
|
||||
|
||||
// Ratenbegrenzung (Standard 3 pro Stunde): zwei weitere gehen, das vierte nicht
|
||||
$ok2 = $dc($cmd('ticket', ['betreff' => 'Zwei', 'nachricht' => 'Zweites Anliegen']))['json']['data']['content'] ?? '';
|
||||
$ok3 = $dc($cmd('ticket', ['betreff' => 'Drei', 'nachricht' => 'Drittes Anliegen']))['json']['data']['content'] ?? '';
|
||||
$no4 = $dc($cmd('ticket', ['betreff' => 'Vier', 'nachricht' => 'Viertes Anliegen']))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($ok2, 'angelegt') && str_contains($ok3, 'angelegt') && str_contains($no4, 'schon 3 Tickets'), 'Ratenbegrenzung greift beim vierten Ticket: ' . $no4);
|
||||
$other1 = $dc($cmd('ticket', ['betreff' => 'Ich', 'nachricht' => 'Anderer Nutzer'], '333333333333'))['json']['data']['content'] ?? '';
|
||||
$check(str_contains($other1, 'angelegt'), 'andere Nutzer sind nicht betroffen');
|
||||
|
||||
// Benachrichtigung bei Mitarbeiter-Antwort
|
||||
$staffId = (int) $pdo->query('SELECT id FROM staff ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, ?, 0, ?)")->execute([$tid, $staffId, '<p>Bitte setzen Sie das <b>Passwort</b> zurück.</p>', db_now()]);
|
||||
$sent = [];
|
||||
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => false]);
|
||||
$dm = array_values(array_filter($sent, fn($s) => str_contains($s['url'], '/channels/dm-1/messages')));
|
||||
$check(count($dm) === 1 && str_contains((string) $dm[0]['body'], 'Passwort zurück') && !str_contains((string) $dm[0]['body'], '<b>') && in_array('Authorization: Bot bot-token-xyz', $dm[0]['headers'], true), 'Antwort kommt als Direktnachricht (ohne HTML, mit Bot-Token)');
|
||||
$sent = [];
|
||||
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'staff', 'internal' => true]);
|
||||
webhook_dispatch($pdo, 'ticket.replied', $tid, ['author' => 'customer']);
|
||||
$check(!$sent, 'interne Notizen und Kunden-Antworten lösen nichts aus');
|
||||
$pdo->prepare("UPDATE tickets SET status = 'resolved' WHERE id = ?")->execute([$tid]);
|
||||
webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']);
|
||||
$check(count(array_filter($sent, fn($s) => str_contains((string) $s['body'], 'Gelöst'))) === 1, 'bei „Gelöst“ kommt eine Mitteilung');
|
||||
$sent = [];
|
||||
plugin_set_setting('community-bot', 'notify', '0');
|
||||
webhook_dispatch($pdo, 'ticket.status_changed', $tid, ['previousStatus' => 'open']);
|
||||
$check(!$sent, 'ohne Benachrichtigung wird nichts gesendet');
|
||||
plugin_set_setting('community-bot', 'notify', '1');
|
||||
|
||||
// Discord-Befehle registrieren
|
||||
$sent = [];
|
||||
$reg = communitybot_discord_register();
|
||||
$check($reg['ok'] && $sent[0]['method'] === 'PUT' && str_ends_with($sent[0]['url'], '/applications/123456789012/commands') && count(json_decode((string) $sent[0]['body'], true)) === 3, 'Befehle werden bei Discord eingetragen');
|
||||
|
||||
// ── Telegram ───────────────────────────────────────────────────────────────
|
||||
communitybot_set_secret('telegram_token', '123:ABC');
|
||||
communitybot_set_secret('telegram_secret', 'geheim-1234');
|
||||
$good = ['x-telegram-bot-api-secret-token' => 'geheim-1234'];
|
||||
$tg = fn(string $text, array $headers = ['x-telegram-bot-api-secret-token' => 'geheim-1234'], int $uid = 777) => $wh['telegram'](['method' => 'POST', 'headers' => $headers, 'body' => json_encode(['update_id' => 1, 'message' => ['message_id' => 5, 'text' => $text, 'chat' => ['id' => $uid, 'type' => 'private'], 'from' => ['id' => $uid, 'first_name' => 'Tom', 'last_name' => 'Tester']]]), 'query' => [], 'ip' => '1.2.3.4']);
|
||||
$check($tg('/ticket x', [])['status'] === 401, 'Telegram ohne Token: 401');
|
||||
$check($tg('/ticket x', ['x-telegram-bot-api-secret-token' => 'falsch'])['status'] === 401, 'Telegram mit falschem Token: 401');
|
||||
$res = $tg('/ticket Drucker kaputt | Der Drucker im Büro druckt nicht mehr');
|
||||
$check(($res['json']['method'] ?? '') === 'sendMessage' && (int) $res['json']['chat_id'] === 777 && preg_match('/#(\S+) wurde angelegt/', (string) ($res['json']['text'] ?? ''), $m2) === 1, 'Telegram: /ticket antwortet mit der Nummer: ' . json_encode($res));
|
||||
$num2 = $m2[1] ?? '';
|
||||
$s = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
||||
$s->execute([$num2]);
|
||||
$t2 = $s->fetch();
|
||||
$check($t2 && $t2['subject'] === 'Drucker kaputt' && $t2['requester_name'] === 'Tom Tester' && $t2['requester_email'] === 'telegram-777@telegram.invalid', 'Telegram-Ticket: Betreff vor dem Strich, Name, Adresse');
|
||||
$noSub = $tg('/ticket Mein Monitor flackert seit heute', $good, 778);
|
||||
$check(str_contains((string) $noSub['json']['text'], 'angelegt'), 'Telegram: ohne Strich wird der Betreff aus dem Text gebildet');
|
||||
$check(str_contains((string) $tg('/status ' . $num2)['json']['text'], 'Offen'), 'Telegram: /status');
|
||||
$check(str_contains((string) $tg('/status ' . $num2, $good, 999)['json']['text'], 'gehört nicht zu Ihnen'), 'Telegram: fremdes Ticket nicht abfragbar');
|
||||
$check(str_contains((string) $tg('/antwort ' . $num2 . ' Danke, jetzt geht es')['json']['text'], 'hinzugefügt'), 'Telegram: /antwort');
|
||||
$check(str_contains((string) $tg('/hilfe@MeinBot')['json']['text'], '/ticket'), 'Telegram: /hilfe mit Bot-Namen');
|
||||
$check($tg('Hallo ohne Befehl')['json'] instanceof stdClass, 'Telegram: normaler Text wird ignoriert');
|
||||
$id2 = (int) $t2['id'];
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?, 'staff', ?, 'Wir kümmern uns darum.', 0, ?)")->execute([$id2, $staffId, db_now()]);
|
||||
$sent = [];
|
||||
webhook_dispatch($pdo, 'ticket.replied', $id2, ['author' => 'staff', 'internal' => false]);
|
||||
$send = array_values(array_filter($sent, fn($x) => str_contains($x['url'], 'api.telegram.org/bot123:ABC/sendMessage')));
|
||||
$check(count($send) === 1 && (string) json_decode((string) $send[0]['body'], true)['chat_id'] === '777', 'Telegram: Antwort wird an den Chat geschickt');
|
||||
$sent = [];
|
||||
$treg = communitybot_telegram_register();
|
||||
$check($treg['ok'] && str_contains((string) $sent[0]['body'], 'secret_token') && str_contains((string) $sent[0]['body'], 'plugin_webhook.php?p=community-bot'), 'Telegram-Webhook wird mit Token und Adresse gesetzt');
|
||||
|
||||
// Gesperrte Absenderadresse
|
||||
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'telegram-555@telegram.invalid', db_now()]);
|
||||
$ban = $tg('/ticket Sperre | Test', $good, 555);
|
||||
$check(str_contains((string) $ban['json']['text'], 'nicht angelegt'), 'gesperrte Absender bekommen kein Ticket: ' . ($ban['json']['text'] ?? ''));
|
||||
|
||||
unset($GLOBALS['communitybot_http']);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Community-Bot plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft, dass der echte Server-Cronjob (cron/run_plugins.php) die Hintergrundaufgaben der Plugins auslöst (Hook "cron.run"),
|
||||
* nicht doppelt läuft, im Crontab-Block steht und im eingebauten Hintergrund-Betrieb als Job geführt wird.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-cj-' . bin2hex(random_bytes(4));
|
||||
$db = sys_get_temp_dir() . '/deskhub-cj-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
$marker = sys_get_temp_dir() . '/deskhub-cj-marker-' . bin2hex(random_bytes(4)) . '.txt';
|
||||
mkdir($pluginDir, 0755, true);
|
||||
foreach (['DESKHUB_PLUGINS_DIR' => $pluginDir, 'DESKHUB_TESTING' => '1', 'DESKHUB_CATALOG_DEFAULTS' => __DIR__ . '/fixtures/no-catalog.php', 'DESKHUB_SQLITE_PATH' => $db, 'DESKHUB_CJ_MARKER' => $marker] as $k => $v) putenv("{$k}={$v}");
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
// Kleines Plugin, das bei jedem Lauf eine Zeile in eine Datei schreibt
|
||||
$zipPath = sys_get_temp_dir() . '/cj-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
$zip->addFromString('cronprobe/plugin.json', json_encode(['slug' => 'cronprobe', 'name' => 'Cron-Probe', 'version' => '1.0.0', 'permissions' => ['hooks.cron']]));
|
||||
$zip->addFromString('cronprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () { file_put_contents(getenv('DESKHUB_CJ_MARKER'), 'lauf' . PHP_EOL, FILE_APPEND); });\nplugin_add_hook('cron.minute', function () { file_put_contents(getenv('DESKHUB_CJ_MARKER'), 'minute' . PHP_EOL, FILE_APPEND); });\n");
|
||||
$zip->close();
|
||||
$check(plugin_install_zip($pdo, $zipPath)['ok'], 'Test-Plugin lässt sich installieren');
|
||||
$check(plugin_set_active($pdo, 'cronprobe', true, ['hooks.cron'])['ok'], 'Test-Plugin lässt sich aktivieren');
|
||||
|
||||
$php = escapeshellarg(PHP_BINARY);
|
||||
$script = escapeshellarg(dirname(__DIR__) . '/cron/run_plugins.php');
|
||||
exec("{$php} {$script} 2>&1", $out, $code);
|
||||
$check($code === 0 && str_contains(implode("\n", $out), '1 aktive'), 'Cronjob löst die Plugin-Aufgaben aus: ' . implode(' | ', $out));
|
||||
$n = fn(string $w) => substr_count((string) @file_get_contents($marker), $w);
|
||||
$check(is_file($marker) && $n('lauf') === 1 && $n('minute') === 1, 'beide Hooks des Plugins wurden genau einmal ausgeführt (Minute und 5-Minuten-Lauf)');
|
||||
unset($out);
|
||||
exec("{$php} {$script} 2>&1", $out, $code);
|
||||
$check($code === 0 && str_contains(implode("\n", $out), 'Übersprungen') && $n('lauf') === 1 && $n('minute') === 1, 'direkt danach läuft er nicht doppelt');
|
||||
$last = (string) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'cron_run_plugins_last_run'")->fetchColumn();
|
||||
$check($last !== '' && strtotime($last) > time() - 60, 'letzter Lauf ist vermerkt (Anzeige unter Einstellungen → Cron-Jobs)');
|
||||
$srcRow = $pdo->query("SELECT setting_key, setting_value FROM settings WHERE setting_key IN ('cron_run_plugins_source','cron_run_plugins_last_cli')")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$check(($srcRow['cron_run_plugins_source'] ?? '') === 'cron' && strtotime((string) ($srcRow['cron_run_plugins_last_cli'] ?? '')) > time() - 60, 'der Lauf wird als „echter Cronjob“ vermerkt (Quelle und Zeit)');
|
||||
|
||||
// Eine Minute später: das Minuten-Ereignis kommt wieder, der 5-Minuten-Lauf noch nicht
|
||||
$setAgo = fn(string $key, int $sec) => $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ?')->execute([date('Y-m-d H:i:s', time() - $sec), $key]);
|
||||
$setAgo('cron_run_plugins_last_run', 70);
|
||||
unset($out);
|
||||
exec("{$php} {$script} 2>&1", $out, $code);
|
||||
$check($n('minute') === 2 && $n('lauf') === 1, 'nach einer Minute: Minuten-Ereignis ja, 5-Minuten-Ereignis noch nicht');
|
||||
// Fünf Minuten später: beide
|
||||
$setAgo('cron_run_plugins_last_run', 70);
|
||||
$setAgo('cron_plugins_run_last', 310);
|
||||
unset($out);
|
||||
exec("{$php} {$script} 2>&1", $out, $code);
|
||||
$check($n('minute') === 3 && $n('lauf') === 2, 'nach fünf Minuten: beide Ereignisse');
|
||||
|
||||
// Der Webzugriff bleibt gesperrt
|
||||
$src = (string) file_get_contents(dirname(__DIR__) . '/cron/run_plugins.php');
|
||||
$check(str_contains($src, "php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')") && str_contains($src, 'Kein Web-Zugriff'), 'Webzugriff auf das Skript ist gesperrt');
|
||||
|
||||
// Eintrag im Crontab-Block und im eingebauten Hintergrund-Betrieb
|
||||
require_once dirname(__DIR__) . '/includes/crontab_installer.php';
|
||||
$block = crontab_marker_block();
|
||||
$check(preg_match('#^\* \* \* \* \* .*/cron/run_plugins\.php#m', $block) === 1, 'Crontab-Block enthält den Plugin-Job jede Minute');
|
||||
$defs = pseudo_cron_job_definitions();
|
||||
$check(isset($defs['run_plugins']) && $defs['run_plugins']['fn'] === 'run_plugins_cron' && $defs['run_plugins']['interval'] === 60 && is_file(dirname(__DIR__) . '/cron/' . $defs['run_plugins']['file']), 'eingebauter Hintergrund-Betrieb führt den Job (jede Minute)');
|
||||
$settingsSrc = (string) file_get_contents(dirname(__DIR__) . '/staff/settings.php');
|
||||
// Jeder Job der Statusliste braucht eine Anzeige „letzter Lauf“ (sonst bleibt die Zeit leer)
|
||||
preg_match('/\$cronJobs = \[(.*?)\n\];/s', $settingsSrc, $mj);
|
||||
preg_match('/\$cronLastRun = \[(.*?)\n \];/s', $settingsSrc, $ml);
|
||||
preg_match_all("/^\s*'([a-z_]+)'\s*=>/m", $mj[1] ?? '', $jobKeys);
|
||||
preg_match_all("/^\s*'([a-z_]+)'\s*=>/m", $ml[1] ?? '', $lastKeys);
|
||||
$check(count($jobKeys[1]) >= 10 && array_diff($jobKeys[1], $lastKeys[1]) === [], 'jeder Job der Statusliste hat eine Anzeige des letzten Laufs (fehlt: ' . implode(', ', array_diff($jobKeys[1], $lastKeys[1])) . ')');
|
||||
$check(str_contains($settingsSrc, "get_setting('cron_run_plugins_last_run')"), 'der Plugin-Job liest seinen letzten Lauf aus der Einstellung, die das Skript schreibt');
|
||||
$check(str_contains($settingsSrc, "'run_plugins'") && str_contains($settingsSrc, 'run_plugins_cron'), 'Job steht in der Statusliste unter Einstellungen → Cron-Jobs');
|
||||
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/pseudo_cron.php'), "plugin_hook('cron.run'"), 'Plugin-Aufgaben werden nicht noch ein zweites Mal im Hintergrund-Betrieb angestoßen');
|
||||
$check(plugin_hook_permission('cron.minute') === 'hooks.cron' && plugin_hook_permission('cron.run') === 'hooks.cron', 'das Minuten-Ereignis braucht dieselbe Berechtigung wie cron.run');
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
@unlink($db);
|
||||
@unlink($marker);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Plugin cron job checks passed.' . "\n";
|
||||
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Datenschutz": Tickets ohne Registrierung werden nach der Frist unwiderruflich anonymisiert, registrierte Kunden nicht,
|
||||
* Löschung auf Antrag, Sicherung gegen versehentliches Einschalten, Einstellungen und Protokoll ohne Kundendaten.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-ds-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('datenschutz', 'plugin_datenschutz');
|
||||
$zipPath = sys_get_temp_dir() . '/ds-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'datenschutz', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'datenschutz', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt nur für Administratoren im Adminportal');
|
||||
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$admin = ['id' => (int) $pdo->query("SELECT id FROM staff WHERE role = 'admin' ORDER BY id LIMIT 1")->fetchColumn(), 'role' => 'admin'];
|
||||
$agent = ['id' => 999, 'role' => 'agent'];
|
||||
$day = fn(int $d) => date('Y-m-d H:i:s', time() - $d * 86400);
|
||||
$mkUser = function (string $name, string $email) use ($pdo): int {
|
||||
$pdo->prepare('INSERT INTO users (name, email, password_hash, phone, created_at) VALUES (?,?,?,?,?)')->execute([$name, $email, 'hash', '0170 1234567', db_now()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$n = 0;
|
||||
$mk = function (string $name, string $email, string $status, int $ageDays, ?int $userId = null, ?int $closedAgo = null) use ($pdo, $dept, $prio, &$n, $day): int {
|
||||
$n++;
|
||||
$closed = in_array($status, ['closed', 'resolved'], true) ? $day($closedAgo ?? $ageDays) : null;
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, requester_ip, user_id, department_id, priority_id, status, source, closed_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['DS-' . $n, 'Thema ' . $n, $name, $email, '203.0.113.7', $userId, $dept, $prio, $status, 'web', $closed, $day($ageDays), $day($closedAgo ?? $ageDays)]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, 'Mein Name ist ' . $name . ', Tel. 0171 555', $day($ageDays)]);
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'staff', 'Wir kümmern uns.', 0, ?)")->execute([$id, $day($ageDays)]);
|
||||
$pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)')->execute([$id, 'mitleser@kunde.example', 'Mitleser', db_now()]);
|
||||
return $id;
|
||||
};
|
||||
$row = fn(int $id) => $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch();
|
||||
$isAnon = fn(int $id) => $row($id)['requester_name'] === 'Anonymisiert' && str_starts_with($row($id)['requester_email'], 'anon_') && str_ends_with($row($id)['requester_email'], '@deleted.local');
|
||||
|
||||
$user = $mkUser('Erika Muster', 'erika@muster.example');
|
||||
$old = $mk('Gast Alt', 'gast.alt@kunde.example', 'closed', 45);
|
||||
$oldResolved = $mk('Gast Gelöst', 'gast.geloest@kunde.example', 'resolved', 40);
|
||||
$young = $mk('Gast Neu', 'gast.neu@kunde.example', 'closed', 10);
|
||||
$openOld = $mk('Gast Offen', 'gast.offen@kunde.example', 'open', 60);
|
||||
$regOld = $mk('Erika Muster', 'erika@muster.example', 'closed', 90, $user);
|
||||
$guestSameMail = $mk('Erika Gast', 'erika@muster.example', 'closed', 90);
|
||||
$closedLongAgoCreatedRecently = $mk('Gast Z', 'gast.z@kunde.example', 'closed', 5, null, 5);
|
||||
$cf = $pdo->prepare("INSERT INTO custom_fields (label, field_type, is_active, created_at) VALUES ('Telefon','text',1,?)");
|
||||
try { $cf->execute([db_now()]); $cfId = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)')->execute([$old, $cfId, '0171 999']); } catch (Throwable $e) { $cfId = 0; }
|
||||
$pdo->prepare('INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at) VALUES (?,?,?,?)')->execute([$old, hash('sha256', 'abc'), 'enc', db_now()]);
|
||||
$pdo->prepare('INSERT INTO ticket_ratings (ticket_id, rating, comment, created_at) VALUES (?,?,?,?)')->execute([$old, 4, 'Ich bin Gast Alt', db_now()]);
|
||||
|
||||
// Standardwerte und Schutz gegen versehentliches Einschalten
|
||||
$cfg = datenschutz_config();
|
||||
$check($cfg['enabled'] === false && $cfg['amount'] === 30 && $cfg['unit'] === 'days' && $cfg['basis'] === 'closed' && !$cfg['include_open'] && !in_array('messages', $cfg['fields'], true) && in_array('name', $cfg['fields'], true) && in_array('email', $cfg['fields'], true), 'Standard: ausgeschaltet, 30 Tage nach dem Schließen');
|
||||
$check(datenschutz_run($pdo) === 0 && !$isAnon($old), 'ausgeschaltet wird nichts anonymisiert');
|
||||
$check(datenschutz_candidate_count($pdo) === 3, 'Vorschau zählt: gelöste/geschlossene Gast-Tickets nach der Frist (alt, gelöst, Gast mit gleicher Adresse): ' . datenschutz_candidate_count($pdo));
|
||||
plugin_set_setting('datenschutz', 'enabled', '1');
|
||||
|
||||
// Lauf
|
||||
$n1 = datenschutz_run($pdo);
|
||||
$check($n1 === 3, 'Lauf anonymisiert 3 Tickets: ' . $n1);
|
||||
$check($isAnon($old) && $isAnon($oldResolved) && $isAnon($guestSameMail), 'Gast-Tickets nach der Frist sind anonymisiert');
|
||||
$check($row($old)['requester_ip'] === '0.0.0.0', 'IP-Adresse ist entfernt');
|
||||
$check(!$isAnon($young) && !$isAnon($openOld) && !$isAnon($regOld) && !$isAnon($closedLongAgoCreatedRecently), 'junge, offene und registrierte Tickets bleiben unberührt');
|
||||
$check($row($regOld)['requester_name'] === 'Erika Muster' && (int) $row($regOld)['user_id'] === $user, 'registrierter Kunde bleibt vollständig erhalten');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster' AND email = 'erika@muster.example'")->fetchColumn() === 1, 'Konto des registrierten Kunden bleibt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$old}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$young}")->fetchColumn() === 1, 'Mitleser werden entfernt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_access_tokens WHERE ticket_id = {$old}")->fetchColumn() === 0, 'Zugangslinks sind ungültig');
|
||||
$check($pdo->query("SELECT comment FROM ticket_ratings WHERE ticket_id = {$old}")->fetchColumn() === null, 'Bewertungs-Kommentar ist entfernt');
|
||||
if ($cfId) $check($pdo->query("SELECT value FROM ticket_field_values WHERE ticket_id = {$old}")->fetchColumn() === null, 'Zusatzfelder sind geleert');
|
||||
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$old} AND author_type = 'customer'")->fetchColumn() === 'Mein Name ist Gast Alt, Tel. 0171 555', 'Nachrichten bleiben standardmäßig erhalten');
|
||||
$check(datenschutz_run($pdo) === 0, 'ein zweiter Lauf findet nichts mehr');
|
||||
$e1 = $row($old)['requester_email'];
|
||||
$check(!str_contains($e1, 'gast') && !str_contains($e1, hash('sha256', 'gast.alt@kunde.example')) && strlen($e1) > 20, 'Ersatzadresse hat keinen Bezug zur echten Adresse');
|
||||
$check($row($old)['requester_email'] !== $row($oldResolved)['requester_email'], 'Ersatzadressen sind je Ticket verschieden');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$old} AND action LIKE '%anonymisiert%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
|
||||
$log = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
|
||||
$check($log['kind'] === 'auto' && (int) $log['tickets'] === 3 && !str_contains((string) $log['note'], '@'), 'Protokoll ohne Kundendaten');
|
||||
|
||||
// Einstellungen: Frist, Berechnung ab Erstellung, offene Tickets, Nachrichten
|
||||
plugin_set_setting('datenschutz', 'amount', '7');
|
||||
$check(datenschutz_candidate_count($pdo) === 1, 'Frist 7 Tage: nur das 10 Tage alte geschlossene Gast-Ticket');
|
||||
plugin_set_setting('datenschutz', 'basis', 'created');
|
||||
$check(datenschutz_candidate_count($pdo) === 1, 'ab Erstellung: dasselbe Ticket');
|
||||
plugin_set_setting('datenschutz', 'include_open', '1');
|
||||
$check(datenschutz_candidate_count($pdo) === 2, 'mit offenen Tickets: auch das alte offene Gast-Ticket');
|
||||
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,messages');
|
||||
datenschutz_run($pdo);
|
||||
$check($isAnon($young) && $isAnon($openOld), 'Frist 7 Tage: weitere Tickets anonymisiert');
|
||||
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$young} AND author_type = 'staff'")->fetchColumn() === 'Wir kümmern uns.', 'Kundennachrichten entfernt, Antworten der Mitarbeiter bleiben');
|
||||
$check(!$isAnon($closedLongAgoCreatedRecently) && !$isAnon($regOld), '5 Tage altes und registriertes bleiben');
|
||||
$check(!$cfg['include_open'] && datenschutz_config()['amount'] === 7, 'Einstellungen werden gelesen');
|
||||
plugin_set_setting('datenschutz', 'amount', '0');
|
||||
$check(datenschutz_config()['amount'] === 1, 'Frist hat eine Untergrenze');
|
||||
|
||||
// Anhänge
|
||||
$uploads = rtrim((string) config()['uploads']['path'], '/');
|
||||
@mkdir($uploads, 0775, true);
|
||||
$file = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
|
||||
file_put_contents($uploads . '/' . $file, 'Inhalt');
|
||||
$att = $mk('Gast Anhang', 'gast.anhang@kunde.example', 'closed', 100);
|
||||
$th = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att} AND author_type = 'customer'")->fetchColumn();
|
||||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th, 'lebenslauf.pdf', $file, 6, db_now()]);
|
||||
plugin_set_setting('datenschutz', 'amount', '30');
|
||||
plugin_set_setting('datenschutz', 'basis', 'closed');
|
||||
plugin_set_setting('datenschutz', 'include_open', '0');
|
||||
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
|
||||
datenschutz_run($pdo);
|
||||
$check(is_file($uploads . '/' . $file), 'ohne die Einstellung bleibt der Anhang');
|
||||
$att2 = $mk('Gast Anhang 2', 'gast.anhang2@kunde.example', 'closed', 100);
|
||||
$th2 = (int) $pdo->query("SELECT id FROM ticket_threads WHERE ticket_id = {$att2} AND author_type = 'customer'")->fetchColumn();
|
||||
$file2 = 'ds-test-' . bin2hex(random_bytes(3)) . '.txt';
|
||||
file_put_contents($uploads . '/' . $file2, 'Inhalt');
|
||||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$th2, 'ausweis.jpg', $file2, 6, db_now()]);
|
||||
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating,attachments');
|
||||
datenschutz_run($pdo);
|
||||
$check(!is_file($uploads . '/' . $file2) && $pdo->query("SELECT stored_name FROM ticket_attachments WHERE thread_id = {$th2}")->fetchColumn() === 'geloescht', 'mit der Einstellung wird der Anhang gelöscht');
|
||||
@unlink($uploads . '/' . $file);
|
||||
|
||||
// Hintergrundlauf höchstens einmal pro Stunde
|
||||
plugin_set_setting('datenschutz', 'last_tick', (string) time());
|
||||
$late = $mk('Gast Spät', 'gast.spaet@kunde.example', 'closed', 100);
|
||||
$check(datenschutz_tick($pdo) === 0 && !$isAnon($late), 'Hintergrundlauf wartet eine Stunde');
|
||||
plugin_set_setting('datenschutz', 'last_tick', '0');
|
||||
$check(datenschutz_tick($pdo) >= 1 && $isAnon($late), 'Hintergrundlauf anonymisiert nach Ablauf');
|
||||
|
||||
// ── Auswahl der Datenarten und Frist-Einheiten ─────────────────────────────
|
||||
plugin_set_setting('datenschutz', 'amount', '30');
|
||||
plugin_set_setting('datenschutz', 'unit', 'days');
|
||||
plugin_set_setting('datenschutz', 'basis', 'closed');
|
||||
plugin_set_setting('datenschutz', 'include_open', '0');
|
||||
$nameOnly = $mk('Nur Name', 'nur.name@kunde.example', 'closed', 100);
|
||||
plugin_set_setting('datenschutz', 'fields', 'name');
|
||||
$check(datenschutz_run($pdo) === 1, 'nur Name: ein Ticket bearbeitet');
|
||||
$r1 = $row($nameOnly);
|
||||
$check($r1['requester_name'] === 'Anonymisiert' && $r1['requester_email'] === 'nur.name@kunde.example' && $r1['requester_ip'] === '203.0.113.7', 'nur Name: E-Mail und IP bleiben');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = {$nameOnly}")->fetchColumn() === 1, 'nur Name: Mitleser bleiben');
|
||||
$check(datenschutz_run($pdo) === 0 && datenschutz_candidate_count($pdo) === 0, 'bearbeitete Tickets werden nicht erneut angefasst, auch wenn die E-Mail noch echt ist');
|
||||
$mailOnly = $mk('Nur Mail', 'nur.mail@kunde.example', 'closed', 100);
|
||||
plugin_set_setting('datenschutz', 'fields', 'email');
|
||||
datenschutz_run($pdo);
|
||||
$r2 = $row($mailOnly);
|
||||
$check($r2['requester_name'] === 'Nur Mail' && str_starts_with($r2['requester_email'], 'anon_') && $r2['requester_ip'] === '203.0.113.7', 'nur E-Mail: Name und IP bleiben');
|
||||
$both = $mk('Beides', 'beides@kunde.example', 'closed', 100);
|
||||
plugin_set_setting('datenschutz', 'fields', 'name,email');
|
||||
datenschutz_run($pdo);
|
||||
$r3 = $row($both);
|
||||
$check($r3['requester_name'] === 'Anonymisiert' && str_starts_with($r3['requester_email'], 'anon_') && $r3['requester_ip'] === '203.0.113.7', 'Name und E-Mail: IP bleibt');
|
||||
$full = $mk('Komplett', 'komplett@kunde.example', 'closed', 100);
|
||||
plugin_set_setting('datenschutz', 'fields', implode(',', array_keys(datenschutz_field_labels())));
|
||||
datenschutz_run($pdo);
|
||||
$check($isAnon($full) && $row($full)['requester_ip'] === '0.0.0.0' && $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$full} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'komplett: alles entfernt');
|
||||
$check(datenschutz_config()['fields'] === array_keys(datenschutz_field_labels()), 'Auswahl wird gelesen');
|
||||
plugin_set_setting('datenschutz', 'fields', 'quatsch,name');
|
||||
$check(datenschutz_config()['fields'] === ['name'], 'ungültige Datenarten werden ignoriert');
|
||||
plugin_set_setting('datenschutz', 'fields', '');
|
||||
$none = $mk('Nichts', 'nichts@kunde.example', 'closed', 100);
|
||||
$check(datenschutz_config()['fields'] === [] && datenschutz_run($pdo) === 0 && $row($none)['requester_name'] === 'Nichts', 'ohne ausgewählte Datenarten passiert nichts');
|
||||
plugin_set_setting('datenschutz', 'fields', 'name,email,ip,collaborators,fieldvalues,tokens,rating');
|
||||
$check(array_keys(datenschutz_presets()) === ['standard', 'full', 'name', 'email', 'name_email'] && datenschutz_presets()['name'][1] === ['name'], 'Vorlagen für die Auswahl');
|
||||
foreach (['days' => ['1', 'Tag'], 'weeks' => ['2', 'Wochen'], 'months' => ['1', 'Monat'], 'years' => ['3', 'Jahre']] as $unit => [$amount, $word]) {
|
||||
plugin_set_setting('datenschutz', 'unit', $unit);
|
||||
plugin_set_setting('datenschutz', 'amount', $amount);
|
||||
$c = datenschutz_config();
|
||||
$expected = strtotime("-{$amount} {$unit}");
|
||||
$check(abs(strtotime(datenschutz_cutoff($c)) - $expected) < 5 && datenschutz_frist_label($c) === "{$amount} {$word}", "Frist in {$unit}: " . datenschutz_frist_label($c));
|
||||
}
|
||||
plugin_set_setting('datenschutz', 'unit', 'jahrhunderte');
|
||||
$check(datenschutz_config()['unit'] === 'days', 'unbekannte Einheit fällt auf Tage zurück');
|
||||
plugin_set_setting('datenschutz', 'unit', 'months');
|
||||
plugin_set_setting('datenschutz', 'amount', '2');
|
||||
$mid = $mk('Zwei Monate', 'zwei@kunde.example', 'closed', 45);
|
||||
$old2 = $mk('Drei Monate', 'drei@kunde.example', 'closed', 95);
|
||||
datenschutz_run($pdo);
|
||||
$check(!$isAnon($mid) && $isAnon($old2), 'Frist 2 Monate: 45 Tage alt bleibt, 95 Tage alt wird anonymisiert');
|
||||
plugin_set_setting('datenschutz', 'unit', 'days');
|
||||
plugin_set_setting('datenschutz', 'amount', '30');
|
||||
// ── Löschung auf Antrag ────────────────────────────────────────────────────
|
||||
$check(datenschutz_find_customer($pdo, 'unbekannt@x.example') === null && datenschutz_find_customer($pdo, '') === null, 'unbekannter Kunde');
|
||||
$found = datenschutz_find_customer($pdo, ' ERIKA@muster.example ');
|
||||
$check($found && (int) $found['user']['id'] === $user && $found['tickets'] === 1 && $found['open'] === 0 && !$found['already'], 'Kunde wird gefunden (Groß-/Kleinschreibung egal), Tickets gezählt');
|
||||
$check(!datenschutz_delete_customer($pdo, $agent, $user, false)['ok'], 'Mitarbeiter dürfen nicht löschen');
|
||||
$openReg = $mk('Erika Muster', 'erika@muster.example', 'open', 3, $user);
|
||||
$blocked = datenschutz_delete_customer($pdo, $admin, $user, false);
|
||||
$check(!$blocked['ok'] && str_contains($blocked['error'], 'offene Tickets'), 'mit offenen Tickets: Hinweis statt Löschung');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE id = {$user} AND name = 'Erika Muster'")->fetchColumn() === 1, 'nichts wurde verändert');
|
||||
$pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')->execute([$user, $admin['id'], 'Ruft oft an', db_now()]);
|
||||
$pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)')->execute(['customer', 'erika@muster.example', 'x', db_now(), db_now()]);
|
||||
$del = datenschutz_delete_customer($pdo, $admin, $user, true);
|
||||
$check($del['ok'] && $del['tickets'] === 2, 'Löschung mit Schließen der offenen Tickets: ' . json_encode($del));
|
||||
$u = $pdo->query("SELECT * FROM users WHERE id = {$user}")->fetch();
|
||||
$check($u['name'] === 'Anonymisiert' && str_starts_with($u['email'], 'anon_') && $u['phone'] === null && $u['password_hash'] !== 'hash' && strlen($u['password_hash']) > 30, 'Konto ist anonymisiert, Anmeldung nicht mehr möglich');
|
||||
$check($isAnon($regOld) && $isAnon($openReg) && $row($openReg)['status'] === 'closed' && $row($regOld)['user_id'] === null, 'alle Tickets des Kunden sind anonymisiert, das offene geschlossen');
|
||||
$check($pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$regOld} AND author_type = 'customer'")->fetchColumn() === DATENSCHUTZ_TEXT_REMOVED, 'bei Löschung auf Antrag werden die Nachrichten immer entfernt (unabhängig von der Einstellung)');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM customer_notes WHERE user_id = {$user}")->fetchColumn() === 0 && (int) $pdo->query("SELECT COUNT(*) FROM password_resets WHERE email = 'erika@muster.example'")->fetchColumn() === 0, 'Notizen und Passwort-Rücksetzung sind gelöscht');
|
||||
$l = $pdo->query('SELECT * FROM plugin_datenschutz_log ORDER BY id DESC LIMIT 1')->fetch();
|
||||
$check($l['kind'] === 'request' && (int) $l['staff_id'] === $admin['id'] && str_contains((string) $l['note'], 'e***@m***.example') && !str_contains((string) $l['note'], 'erika@'), 'Protokoll enthält nur die verkürzte Adresse: ' . $l['note']);
|
||||
$check(!datenschutz_delete_customer($pdo, $admin, $user, true)['ok'], 'ein zweites Mal geht nicht');
|
||||
$check(datenschutz_find_customer($pdo, 'erika@muster.example') === null, 'Kunde ist unter der alten Adresse nicht mehr auffindbar');
|
||||
$check(!datenschutz_delete_customer($pdo, $admin, 99999, true)['ok'], 'unbekannte Kunden-ID');
|
||||
$check(datenschutz_mask('ab@firma.de') === 'a***@f***.de' && datenschutz_mask('x') === 'x***', 'Maskierung für das Protokoll');
|
||||
|
||||
// Selbstheilung: fehlt die Tabelle nach einem Update (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||
$pdo->exec('DROP TABLE plugin_datenschutz_done');
|
||||
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_datenschutz_002%'");
|
||||
datenschutz_ensure_schema(true);
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'plugin_datenschutz_done'")->fetchColumn() === 1, 'Selbstheilung: die fehlende Tabelle wird nachgeholt');
|
||||
// ── Reiter der Seite ───────────────────────────────────────────────────────
|
||||
$pagePath = $pluginDir . '/datenschutz/pages/datenschutz.php';
|
||||
$adminStaff = ['id' => $admin['id'], 'role' => 'admin', 'name' => 'Chef'];
|
||||
$render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string {
|
||||
$_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get;
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$staff = $who ?? $adminStaff;
|
||||
ob_start();
|
||||
include $pagePath;
|
||||
return (string) ob_get_clean();
|
||||
};
|
||||
$pdo->exec('DELETE FROM plugin_datenschutz_log');
|
||||
for ($i = 1; $i <= 120; $i++) datenschutz_log($pdo, 'auto', $i, 0, "Eintrag-{$i}-ende", null);
|
||||
$home = $render('');
|
||||
$check(str_contains($home, '<h3>Regeln</h3>') && !str_contains($home, 'Eintrag-') && !str_contains($home, 'name="fields[]"') && !str_contains($home, 'E-Mail-Adresse des Kunden'), 'Übersicht: nur Regeln und Status');
|
||||
$check(str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=antrag') && str_contains($home, 'tab=protokoll'), 'Reiterleiste mit allen Reitern');
|
||||
$set = $render('einstellungen');
|
||||
$check(str_contains($set, 'name="fields[]"') && !str_contains($set, '<h3>Regeln</h3>'), 'Reiter Einstellungen');
|
||||
$req = $render('antrag');
|
||||
$check(str_contains($req, 'E-Mail-Adresse des Kunden') && !str_contains($req, 'name="fields[]"'), 'Reiter Löschung auf Antrag');
|
||||
$p1 = $render('protokoll');
|
||||
$check(substr_count($p1, '-ende') === 50 && str_contains($p1, 'Eintrag-120-ende') && !str_contains($p1, 'Eintrag-70-ende') && str_contains($p1, 'Seite 1 von 3') && !str_contains($p1, '<h3>Regeln</h3>'), 'Protokoll: eigener Reiter, 50 pro Seite, neueste zuerst');
|
||||
$p3 = $render('protokoll', ['seite' => '3']);
|
||||
$check(substr_count($p3, '-ende') === 20 && str_contains($p3, 'Eintrag-1-ende'), 'Protokoll Seite 3 mit 20 Einträgen');
|
||||
$check(str_contains($render('protokoll', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird begrenzt');
|
||||
$check(str_contains($render('quatsch'), '<h3>Regeln</h3>'), 'unbekannter Reiter → Übersicht');
|
||||
$check(str_contains($render('', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'nur für Administratoren') && !str_contains($render('protokoll', [], ['id' => 2, 'role' => 'agent', 'name' => 'X']), 'Eintrag-'), 'Nicht-Admins sehen nichts');
|
||||
$check(datenschutz_tab_url('protokoll', ['seite' => 2]) === '/staff/plugin.php?p=datenschutz&page=datenschutz&tab=protokoll&seite=2', 'datenschutz_tab_url');
|
||||
|
||||
unset($GLOBALS['x']);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Datenschutz plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft, dass der Administrator eine Mail bekommt, wenn ein Plugin Fehler wirft oder wegen Fehlern abgeschaltet wird, und wenn ein
|
||||
* Hintergrundjob scheitert, mit Drosselung und Ausschalter.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-pm-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
set_setting('admin_email', 'admin@firma.example');
|
||||
$mails = [];
|
||||
$GLOBALS['error_alert_send'] = function (string $to, string $subject, string $html) use (&$mails): bool { $mails[] = compact('to', 'subject', 'html'); return true; };
|
||||
|
||||
$zipPath = sys_get_temp_dir() . '/pm-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
$zip->addFromString('mailprobe/plugin.json', json_encode(['slug' => 'mailprobe', 'name' => 'Mail-Probe', 'version' => '1.0.0', 'permissions' => ['hooks.cron']]));
|
||||
$zip->addFromString('mailprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () { throw new RuntimeException('Spalte fehlt: first_fail_at'); });\n");
|
||||
$zip->close();
|
||||
$check(plugin_install_zip($pdo, $zipPath)['ok'] && plugin_set_active($pdo, 'mailprobe', true, ['hooks.cron'])['ok'], 'Test-Plugin installiert und aktiv');
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
|
||||
// 1. Fehler: Mail „meldet einen Fehler“
|
||||
plugin_hook('cron.run');
|
||||
$check(count($mails) === 1 && $mails[0]['to'] === 'admin@firma.example' && str_contains($mails[0]['subject'], 'Plugin „Mail-Probe“ meldet einen Fehler'), 'erster Fehler: Mail an den Administrator: ' . json_encode(array_column($mails, 'subject'), JSON_UNESCAPED_UNICODE));
|
||||
$h = $mails[0]['html'] ?? '';
|
||||
$check(str_contains($h, 'Spalte fehlt: first_fail_at') && str_contains($h, 'Fehler 1 von ' . PLUGIN_FAIL_LIMIT) && str_contains($h, 'plugins/mailprobe') && !str_contains($h, 'Zeile 0') && !str_contains($h, 'Die aufgerufene Seite konnte nicht angezeigt werden'), 'Inhalt nennt Plugin, Fehlertext und Zähler, ohne die Seiten-Formulierung');
|
||||
// 2. Derselbe Fehler noch einmal: gedrosselt
|
||||
plugin_hook('cron.run');
|
||||
$check(count($mails) === 1, 'derselbe Fehler wird nicht noch einmal gemailt (höchstens einmal pro Stunde)');
|
||||
// 3. Abschaltung nach dem dritten Fehler: eigene Mail
|
||||
plugin_hook('cron.run');
|
||||
$row = plugins_installed($pdo)['mailprobe'];
|
||||
$check((int) $row['is_active'] === 0 && (int) $row['fail_count'] >= PLUGIN_FAIL_LIMIT, 'Plugin ist nach den Fehlern deaktiviert');
|
||||
$off = array_values(array_filter($mails, fn($m) => str_contains($m['subject'], 'wurde wegen Fehlern deaktiviert')));
|
||||
$check(count($off) === 1 && str_contains($off[0]['html'], 'automatisch deaktiviert') && str_contains($off[0]['html'], 'mailprobe'), 'Mail „wegen Fehlern deaktiviert“ kommt trotz Drosselung (eigener Betreff)');
|
||||
|
||||
// Ausschalter
|
||||
set_setting('error_alert', '0');
|
||||
$before = count($mails);
|
||||
plugin_record_failure('mailprobe', 'Ein ganz anderer Fehler');
|
||||
$check(count($mails) === $before, 'bei ausgeschalteter Fehler-Mail wird nichts gesendet');
|
||||
set_setting('error_alert', '1');
|
||||
|
||||
// Hintergrundjob: Meldung
|
||||
error_alert_notice('Hintergrundjob „x“ ist fehlgeschlagen', 'Der Hintergrundjob „x“ hat einen Fehler gemeldet.', 'cron/x.php', 'Verbindung abgelehnt');
|
||||
$last = end($mails);
|
||||
$check(str_contains($last['subject'], 'Hintergrundjob „x“ ist fehlgeschlagen') && str_contains($last['html'], 'Verbindung abgelehnt'), 'fehlgeschlagene Hintergrundjobs werden gemeldet');
|
||||
// Wirkt auch im eingebauten Hintergrund-Betrieb
|
||||
$pc = (string) file_get_contents(dirname(__DIR__) . '/includes/pseudo_cron.php');
|
||||
$check(str_contains($pc, 'error_alert_notice('), 'der eingebaute Hintergrund-Betrieb meldet fehlgeschlagene Jobs');
|
||||
|
||||
unset($GLOBALS['error_alert_send']);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Plugin error mail checks passed.' . "\n";
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Störungsmitteilung und Rundmail": Empfängergruppen (offene Tickets, Abteilung, Organisation), Filter für unbrauchbare
|
||||
* Adressen, Rechte der Mitarbeiter, Versand in Stapeln mit Protokoll, Abbruch, Begrenzungen und Probemail.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-rm-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('rundmail', 'plugin_rundmail');
|
||||
$zipPath = sys_get_temp_dir() . '/rm-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'rundmail', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'rundmail', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für Mitarbeiter sichtbar');
|
||||
|
||||
$sentMails = [];
|
||||
$GLOBALS['rundmail_send'] = function (string $to, string $subject, string $html) use (&$sentMails): bool {
|
||||
if (str_contains($to, 'kaputt')) return false;
|
||||
$sentMails[] = compact('to', 'subject', 'html');
|
||||
return true;
|
||||
};
|
||||
|
||||
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||
$d2 = (int) $pdo->lastInsertId();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$mkStaff = function (string $name, string $role, ?int $dept) use ($pdo): array {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')
|
||||
->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||
return ['id' => (int) $pdo->lastInsertId(), 'role' => $role, 'department_id' => $dept, 'name' => $name, 'email' => strtolower($name) . '@team.example'];
|
||||
};
|
||||
$anna = $mkStaff('Anna', 'agent', $d1);
|
||||
$chef = $mkStaff('Chef', 'admin', null);
|
||||
$pdo->prepare("INSERT INTO organizations (name, created_at) VALUES ('Muster GmbH', ?)")->execute([db_now()]);
|
||||
$org = (int) $pdo->lastInsertId();
|
||||
$mkUser = function (string $name, string $email, ?int $org) use ($pdo): int {
|
||||
$pdo->prepare('INSERT INTO users (name, email, password_hash, organization_id, created_at) VALUES (?,?,?,?,?)')->execute([$name, $email, 'x', $org, db_now()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$u1 = $mkUser('Erika Muster', 'erika@muster.example', $org);
|
||||
$u2 = $mkUser('Max Muster', 'max@muster.example', $org);
|
||||
$n = 0;
|
||||
$mk = function (string $name, string $email, int $dept, string $status = 'open', ?int $userId = null, int $spam = 0) use ($pdo, $prio, &$n): int {
|
||||
$n++;
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, user_id, department_id, priority_id, status, source, is_spam, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['RM-' . $n, 'T' . $n, $name, $email, $userId, $dept, $prio, $status, 'web', $spam, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$mk('Erika Muster', 'erika@muster.example', $d1, 'open', $u1);
|
||||
$mk('Erika Muster', 'ERIKA@muster.example', $d1, 'pending', $u1); // gleiche Adresse, einmal
|
||||
$mk('Gast Eins', 'gast1@kunde.example', $d1);
|
||||
$mk('Gast Zwei', 'gast2@kunde.example', $d2);
|
||||
$mk('Fertig', 'fertig@kunde.example', $d1, 'closed');
|
||||
$mk('Gelöst', 'geloest@kunde.example', $d1, 'resolved');
|
||||
$mk('Spammer', 'spam@kunde.example', $d1, 'open', null, 1);
|
||||
$mk('Discord', 'discord-123@discord.invalid', $d1);
|
||||
$mk('Anonym', 'anon_abcd@deleted.local', $d1);
|
||||
$mk('Kaputt', 'kaputt@kunde.example', $d1);
|
||||
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'gesperrt@kunde.example', db_now()]);
|
||||
$mk('Gesperrt', 'gesperrt@kunde.example', $d1);
|
||||
$mails = fn(array $rows) => array_map(fn($r) => $r['email'], $rows);
|
||||
|
||||
// Empfänger
|
||||
$all = $mails(rundmail_recipients($pdo, $chef, 'open', null));
|
||||
sort($all);
|
||||
$check($all === ['erika@muster.example', 'gast1@kunde.example', 'gast2@kunde.example', 'kaputt@kunde.example'], 'offene Tickets: nur brauchbare Adressen, einmal je Adresse: ' . json_encode($all));
|
||||
$dep = $mails(rundmail_recipients($pdo, $chef, 'department', $d2));
|
||||
$check($dep === ['gast2@kunde.example'], 'Abteilung: nur offene Tickets dieser Abteilung');
|
||||
$orgAll = $mails(rundmail_recipients($pdo, $chef, 'organization', $org));
|
||||
$check($orgAll === ['erika@muster.example', 'max@muster.example'], 'Organisation (Administrator): alle Mitglieder');
|
||||
$orgStaff = $mails(rundmail_recipients($pdo, $anna, 'organization', $org));
|
||||
$check($orgStaff === ['erika@muster.example'], 'Organisation (Mitarbeiter): nur Mitglieder mit offenem Ticket in der eigenen Abteilung');
|
||||
$annaOpen = $mails(rundmail_recipients($pdo, $anna, 'open', null));
|
||||
sort($annaOpen);
|
||||
$check($annaOpen === ['erika@muster.example', 'gast1@kunde.example', 'kaputt@kunde.example'], 'Mitarbeiter sehen nur Kunden der eigenen Abteilung');
|
||||
$check(rundmail_recipients($pdo, $anna, 'department', $d2) === [], 'fremde Abteilung ist für Mitarbeiter leer');
|
||||
$groupsAnna = array_column(rundmail_groups($pdo, $anna), 'key');
|
||||
$groupsChef = array_column(rundmail_groups($pdo, $chef), 'key');
|
||||
$check(!in_array('department:' . $d2, $groupsAnna, true) && in_array('department:' . $d2, $groupsChef, true) && in_array('organization:' . $org, $groupsAnna, true), 'Gruppenauswahl richtet sich nach den Abteilungen');
|
||||
$check(rundmail_parse_group($pdo, 'department:99999') === null && rundmail_parse_group($pdo, 'x') === null && rundmail_parse_group($pdo, 'open')['type'] === 'open', 'Gruppenschlüssel werden geprüft');
|
||||
|
||||
// Prüfungen
|
||||
$check(rundmail_validate('', 'Text') !== null && rundmail_validate('Betreff', '') !== null && rundmail_validate("A\nB", 'x') !== null && rundmail_validate(str_repeat('x', 151), 'x') !== null, 'Betreff und Text werden geprüft');
|
||||
$check(!rundmail_create($pdo, $anna, 'department:' . $d2, 'Hallo', 'Text')['ok'], 'Mitarbeiter können nicht an fremde Abteilungen senden');
|
||||
$check(!rundmail_create($pdo, $chef, 'open', '', 'Text')['ok'], 'ohne Betreff kein Versand');
|
||||
$check(!rundmail_create($pdo, $chef, 'organization:99999', 'Hallo', 'Text')['ok'], 'unbekannte Organisation');
|
||||
|
||||
// Rendern
|
||||
[$subj, $html] = rundmail_render(['subject' => 'Wartung', 'body' => "Hallo {name},\n<b>fett</b>"], 'Erika <script>');
|
||||
$check($subj === 'Wartung' && str_contains($html, 'Hallo Erika <script>') && !str_contains($html, '<script>') && !str_contains($html, '<b>fett') && str_contains($html, '<br'), 'Text wird maskiert, Name eingesetzt, Zeilenumbrüche bleiben');
|
||||
[, $anon] = rundmail_render(['subject' => 'x', 'body' => 'Hallo {name}'], '');
|
||||
$check(str_contains($anon, 'Hallo Kunde'), 'ohne Namen steht „Kunde“');
|
||||
|
||||
// Versand in Stapeln (Admin: 4 Empfänger, davon einer mit Fehler)
|
||||
$res = rundmail_create($pdo, $chef, 'open', 'Aktuelle Störung', 'Hallo {name}, wir arbeiten daran.');
|
||||
$check($res['ok'] && $res['total'] === 4, 'Rundmail wird angelegt: ' . json_encode($res));
|
||||
$msg = $pdo->query("SELECT * FROM plugin_rundmail_messages WHERE id = {$res['id']}")->fetch();
|
||||
$check($msg['status'] === 'done' && (int) $msg['sent'] === 3 && (int) $msg['failed'] === 1, 'kleine Gruppe geht sofort raus, der Fehler wird gezählt: ' . json_encode($msg));
|
||||
$sentTo = array_column($sentMails, 'to');
|
||||
sort($sentTo);
|
||||
$check($sentTo === ['erika@muster.example', 'gast1@kunde.example', 'gast2@kunde.example'], 'jeder Empfänger bekommt eine eigene Mail (keine Sammelmail)');
|
||||
$check(str_contains(array_values(array_filter($sentMails, fn($m) => $m['to'] === 'erika@muster.example'))[0]['html'], 'Hallo Erika Muster'), 'Name wird je Empfänger eingesetzt');
|
||||
$err = $pdo->query("SELECT error FROM plugin_rundmail_queue WHERE email = 'kaputt@kunde.example'")->fetchColumn();
|
||||
$check(str_contains((string) $err, 'fehlgeschlagen'), 'Fehler steht im Protokoll');
|
||||
|
||||
// Große Gruppe: Stapel und Hintergrundlauf
|
||||
$bulk = [];
|
||||
for ($i = 0; $i < 60; $i++) $bulk[] = $mk('Kunde ' . $i, "bulk{$i}@kunde.example", $d2);
|
||||
$sentMails = [];
|
||||
$res2 = rundmail_create($pdo, $chef, 'department:' . $d2, 'Wartung', 'Hallo {name}');
|
||||
$check($res2['ok'] && $res2['total'] === 61 && count($sentMails) === 25, 'erster Stapel: 25 Mails sofort');
|
||||
$st = $pdo->query("SELECT status, sent FROM plugin_rundmail_messages WHERE id = {$res2['id']}")->fetch();
|
||||
$check($st['status'] === 'sending' && (int) $st['sent'] === 25, 'Nachricht steht auf „Läuft“');
|
||||
$check(rundmail_tick($pdo) === 25 && count($sentMails) === 50, 'Hintergrundlauf verschickt den nächsten Stapel');
|
||||
$check(rundmail_tick($pdo) === 11 && count($sentMails) === 61, 'letzter Stapel');
|
||||
$st = $pdo->query("SELECT status, sent, failed, finished_at FROM plugin_rundmail_messages WHERE id = {$res2['id']}")->fetch();
|
||||
$check($st['status'] === 'done' && (int) $st["sent"] === 61 && $st["finished_at"] !== null, 'Nachricht ist fertig');
|
||||
$check(rundmail_tick($pdo) === 0, 'danach ist nichts mehr zu tun');
|
||||
|
||||
// Abbruch
|
||||
$sentMails = [];
|
||||
$res3 = rundmail_create($pdo, $chef, 'department:' . $d2, 'Noch eine', 'Hallo');
|
||||
$check(!rundmail_cancel($pdo, $anna, $res3['id']), 'Mitarbeiter können fremde Versände nicht abbrechen');
|
||||
$check(rundmail_cancel($pdo, $chef, $res3['id']), 'Administrator bricht ab');
|
||||
$check(rundmail_tick($pdo) === 0 && count($sentMails) === 25, 'nach dem Abbruch geht nichts mehr raus');
|
||||
$st = $pdo->query("SELECT status, sent FROM plugin_rundmail_messages WHERE id = {$res3['id']}")->fetch();
|
||||
$check($st['status'] === 'cancelled' && (int) $st['sent'] === 25, 'Stand nach Abbruch: abgebrochen, 25 gesendet');
|
||||
$check(!rundmail_cancel($pdo, $chef, $res3['id']), 'Fertige oder abgebrochene Versände lassen sich nicht noch einmal abbrechen');
|
||||
|
||||
// Begrenzungen für Mitarbeiter
|
||||
$pdo->exec("DELETE FROM plugin_rundmail_messages");
|
||||
$pdo->exec("DELETE FROM plugin_rundmail_queue");
|
||||
for ($i = 0; $i < 250; $i++) $mk('Viele ' . $i, "viele{$i}@kunde.example", $d1);
|
||||
$tooMany = rundmail_create($pdo, $anna, 'open', 'Viele', 'Text');
|
||||
$check(!$tooMany['ok'] && str_contains($tooMany['error'], 'höchstens 200'), 'Mitarbeiter dürfen höchstens 200 Empfänger: ' . json_encode($tooMany));
|
||||
$pdo->exec("DELETE FROM tickets WHERE ticket_number LIKE 'RM-%' AND requester_email LIKE 'viele%'");
|
||||
$pdo->exec("DELETE FROM tickets WHERE requester_email LIKE 'bulk%'");
|
||||
for ($i = 0; $i < 5; $i++) rundmail_create($pdo, $anna, 'open', 'Hallo ' . $i, 'Text');
|
||||
$limited = rundmail_create($pdo, $anna, 'open', 'Sechste', 'Text');
|
||||
$check(!$limited['ok'] && str_contains($limited['error'], 'letzten Stunde'), 'höchstens 5 Rundmails pro Stunde für Mitarbeiter');
|
||||
$check(count(rundmail_history($pdo, $anna)) === 5 && count(rundmail_history($pdo, $chef)) === 5, 'Verlauf: Mitarbeiter sehen ihre eigenen, Administratoren alle');
|
||||
$check(count(rundmail_history($pdo, $mkStaff('Ben', 'agent', $d1))) === 0, 'fremde Nachrichten sehen Mitarbeiter nicht');
|
||||
|
||||
// Probemail
|
||||
$sentMails = [];
|
||||
$t = rundmail_test($anna, 'Wartung', 'Hallo {name}');
|
||||
$check($t['ok'] && $sentMails[0]['to'] === 'anna@team.example' && str_starts_with($sentMails[0]['subject'], '[TEST]') && str_contains($sentMails[0]['html'], 'Hallo Anna'), 'Probemail geht an den Mitarbeiter');
|
||||
$check(!rundmail_test($anna, '', 'x')['ok'], 'Probemail prüft die Eingaben');
|
||||
|
||||
unset($GLOBALS['rundmail_send']);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Rundmail plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Schichtübergabe": Notizen, Sichtbarkeit je Abteilung, ungelesen/gelesen, Menü-Zähler, wichtige Notizen in der Seitenleiste,
|
||||
* Löschrechte und automatisches Aufräumen.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-sh-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('schichtuebergabe', 'plugin_schichtuebergabe');
|
||||
$zipPath = sys_get_temp_dir() . '/sh-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'schichtuebergabe', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'schichtuebergabe', true, ['hooks.cron', 'ui.menu', 'ui.sidebar', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar');
|
||||
|
||||
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Technik', 'technik@team.example', 1, ?)")->execute([db_now()]);
|
||||
$d2 = (int) $pdo->lastInsertId();
|
||||
$mk = function (string $name, string $role, ?int $dept) use ($pdo): array {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)')
|
||||
->execute([$name, strtolower($name) . '@team.example', 'x', $role, $dept, db_now()]);
|
||||
return ['id' => (int) $pdo->lastInsertId(), 'role' => $role, 'department_id' => $dept, 'name' => $name];
|
||||
};
|
||||
$anna = $mk('Anna', 'agent', $d1);
|
||||
$ben = $mk('Ben', 'agent', $d2);
|
||||
$chef = $mk('Chef', 'admin', null);
|
||||
|
||||
// Anlegen und Prüfungen
|
||||
$check(!schicht_create($pdo, $anna, ' ', null, false)['ok'], 'leere Notiz wird abgelehnt');
|
||||
$check(!schicht_create($pdo, $anna, str_repeat('x', 3001), null, false)['ok'], 'zu lange Notiz wird abgelehnt');
|
||||
$check(!schicht_create($pdo, $anna, 'Test', $d2, false)['ok'], 'nur für eigene Abteilungen schreiben');
|
||||
$check(!schicht_create($pdo, $anna, 'Test', 99999, false)['ok'] && !schicht_create($pdo, $chef, 'Test', 99999, false)['ok'], 'unbekannte Abteilung wird abgelehnt');
|
||||
$all = schicht_create($pdo, $anna, "Server X zickt.\nBitte beobachten.", null, true);
|
||||
$mine = schicht_create($pdo, $anna, 'Nur für Abteilung 1', $d1, false);
|
||||
$tech = schicht_create($pdo, $chef, 'Nur für Technik', $d2, false);
|
||||
$check($all['ok'] && $mine['ok'] && $tech['ok'], 'Notizen lassen sich anlegen');
|
||||
|
||||
// Sichtbarkeit
|
||||
$ids = fn(array $rows) => array_map(fn($n) => (int) $n['id'], $rows);
|
||||
$check($ids(schicht_list($pdo, $ben)) === [$tech['id'], $all['id']], 'Ben sieht „alle“ und Technik, nicht Abteilung 1: ' . json_encode($ids(schicht_list($pdo, $ben))));
|
||||
$check($ids(schicht_list($pdo, $anna)) === [$mine['id'], $all['id']], 'Anna sieht „alle“ und ihre Abteilung');
|
||||
$check(count(schicht_list($pdo, $chef)) === 3, 'Administrator sieht alles');
|
||||
|
||||
// Ungelesen / gelesen
|
||||
$check(schicht_unread_count($pdo, $anna) === 0, 'Verfasser hat die eigene Notiz schon gelesen');
|
||||
$check(schicht_unread_count($pdo, $ben) === 2 && schicht_unread_count($pdo, $chef) === 2, 'Andere sehen sie als neu (Ben 2, Chef 2 ohne die eigene)');
|
||||
$unread = schicht_unread($pdo, $ben);
|
||||
$check(str_contains((string) $unread[1]['message'], "\n") && $unread[1]['author'] === 'Anna', 'Notiz enthält Zeilenumbruch und Verfasser');
|
||||
$_SESSION['staff_id'] = $ben['id'];
|
||||
$label = schicht_menu_label();
|
||||
$check($label === 'Schichtübergabe (2 neu)', 'Menü zeigt den Zähler: ' . $label);
|
||||
schicht_mark_read($pdo, $ben, [$all['id']]);
|
||||
schicht_mark_read($pdo, $ben, [$all['id']]);
|
||||
$check(schicht_unread_count($pdo, $ben) === 1, 'gelesene Notiz zählt nicht mehr (auch doppelt markieren schadet nicht)');
|
||||
schicht_mark_read($pdo, $ben, array_map(fn($n) => $n['id'], schicht_unread($pdo, $ben)));
|
||||
$check(schicht_unread_count($pdo, $ben) === 0, 'alles gelesen');
|
||||
$check(schicht_readers($pdo, $all['id']) === ['Anna', 'Ben'], 'Verfasser sieht, wer gelesen hat: ' . json_encode(schicht_readers($pdo, $all['id'])));
|
||||
unset($_SESSION['staff_id']);
|
||||
|
||||
// Seitenleiste: nur wichtige und ungelesene
|
||||
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $chef]);
|
||||
$check(str_contains($html, 'Wichtige Schichtübergabe') && str_contains($html, 'Server X zickt') && !str_contains($html, 'Nur für Technik'), 'Seitenleiste zeigt nur wichtige ungelesene Notizen');
|
||||
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $ben]) === '', 'wer sie gelesen hat, sieht sie nicht mehr in der Seitenleiste');
|
||||
$xss = schicht_create($pdo, $anna, '<script>alert(1)</script>', null, true);
|
||||
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $ben]);
|
||||
$check(str_contains($html, '<script>') && !str_contains($html, '<script>'), 'Inhalte werden in der Seitenleiste maskiert');
|
||||
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => null]) === '', 'ohne Mitarbeiter keine Seitenleiste');
|
||||
|
||||
// Der Verfasser sieht seine eigene wichtige Notiz ebenfalls (12 Stunden), mit der Zahl der Leser
|
||||
$ownHtml = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $anna]);
|
||||
$check(str_contains($ownHtml, 'Ihre Notiz') && str_contains($ownHtml, 'Server X zickt') && str_contains($ownHtml, 'von 1 gelesen'), 'Verfasser sieht seine wichtige Notiz in der Seitenleiste, mit „von 1 gelesen“ (Ben): ' . strip_tags($ownHtml));
|
||||
$check(!str_contains($ownHtml, 'Nur für Abteilung 1'), 'unwichtige eigene Notizen erscheinen dort nicht');
|
||||
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 13 * 3600), $all['id']]);
|
||||
$check(!str_contains(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1, 'ticket' => [], 'staff' => $anna]), 'Server X zickt'), 'nach 12 Stunden verschwindet die eigene Notiz aus der Seitenleiste');
|
||||
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([db_now(), $all['id']]);
|
||||
$check(schicht_unread_count($pdo, $anna) === 0, 'die eigene Notiz zählt trotzdem nicht als „neu“ im Menü');
|
||||
$both = schicht_sidebar_notes($pdo, $chef);
|
||||
$check(count(array_unique(array_column($both, 'id'))) === count($both), 'keine Notiz doppelt (ungelesen und eigene)');
|
||||
|
||||
// Löschen
|
||||
$check(!schicht_delete($pdo, $ben, $all['id']), 'fremde Notiz kann ein Mitarbeiter nicht löschen');
|
||||
$check(schicht_delete($pdo, $anna, $mine['id']), 'der Verfasser kann löschen');
|
||||
$check(schicht_delete($pdo, $chef, $all['id']), 'Administrator kann jede Notiz löschen');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_schichtuebergabe_reads WHERE note_id IN ({$all['id']}, {$mine['id']})")->fetchColumn() === 0, 'Lesemarken werden mit gelöscht');
|
||||
$check(!schicht_delete($pdo, $chef, 99999), 'unbekannte Notiz');
|
||||
|
||||
// Aufbewahrung
|
||||
plugin_set_setting('schichtuebergabe', 'keep_days', '7');
|
||||
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 10 * 86400), $tech['id']]);
|
||||
$check(!in_array($tech['id'], $ids(schicht_list($pdo, $chef)), true), 'Notizen älter als die Frist werden nicht mehr angezeigt');
|
||||
plugin_set_setting('schichtuebergabe', 'last_cleanup', '0');
|
||||
$check(schicht_cleanup($pdo) >= 1 && (int) $pdo->query("SELECT COUNT(*) FROM plugin_schichtuebergabe_notes WHERE id = {$tech['id']}")->fetchColumn() === 0, 'Aufräumen löscht abgelaufene Notizen');
|
||||
$check(schicht_cleanup($pdo) === 0, 'Aufräumen läuft höchstens einmal pro Stunde');
|
||||
plugin_set_setting('schichtuebergabe', 'keep_days', '0');
|
||||
$check(schicht_keep_days() === 1, 'Frist hat eine Untergrenze');
|
||||
|
||||
plugin_set_setting('schichtuebergabe', 'keep_days', '30');
|
||||
// Deutlicher Hinweis oben auf jeder Seite (auch direkt nach der Anmeldung), solange etwas ungelesen ist
|
||||
$check(plugin_filter_permission('staff.page.top') === 'ui.sidebar', 'der Seitenkopf-Hinweis braucht die Berechtigung ui.sidebar (hat das Plugin)');
|
||||
$check(str_contains((string) file_get_contents(dirname(__DIR__) . '/staff/_header.php'), "plugin_filter('staff.page.top'"), 'das Layout des Mitarbeiterbereichs ruft den Hinweis oben auf jeder Seite auf');
|
||||
$bannerFor = fn(?array $who, string $page = 'reports') => plugin_filter('staff.page.top', '', ['staff' => $who, 'activePage' => $page]);
|
||||
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||
$pdo->exec('DELETE FROM plugin_schichtuebergabe_notes');
|
||||
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||
$check($bannerFor($ben) === '', 'ohne Notizen kein Hinweis');
|
||||
$n1 = schicht_create($pdo, $anna, "Normale Info\nzweite Zeile", null, false);
|
||||
$h1 = $bannerFor($ben);
|
||||
$check(str_contains($h1, 'Schichtübergabe: 1 neue Notiz') && str_contains($h1, 'Normale Info') && str_contains($h1, '#0f6f6a') && !str_contains($h1, 'wichtig)') && str_contains($h1, 'page=schichtuebergabe') && str_contains($h1, 'Normale Info<br />'), 'eine normale Notiz: grüner Hinweis mit Text und Link');
|
||||
$n2 = schicht_create($pdo, $anna, 'Server X ist ausgefallen', null, true);
|
||||
$h2 = $bannerFor($ben);
|
||||
$check(str_contains($h2, '2 neue Notizen') && str_contains($h2, '(1 wichtig)') && str_contains($h2, '#dc2626') && strpos($h2, 'Server X ist ausgefallen') < strpos($h2, 'Normale Info'), 'mit wichtiger Notiz: roter Hinweis, die wichtige zuerst');
|
||||
$check($bannerFor($anna, 'tickets') === '' && !str_contains($bannerFor($anna, 'tickets'), 'neue Notiz'), 'auf anderen Seiten sieht der Verfasser keinen „neu“-Hinweis zu seinen eigenen Notizen');
|
||||
$own = $bannerFor($anna, 'reports');
|
||||
$check(str_contains($own, 'Ihre Notiz an die nächste Schicht') && str_contains($own, 'Server X ist ausgefallen') && str_contains($own, 'Normale Info') && str_contains($own, 'noch von niemandem gelesen') && !str_contains($own, 'neue Notiz') && str_contains($own, '#93c5fd'), 'auf der Übersicht sieht der Verfasser seine eigenen Notizen als ruhige Bestätigung (blau), noch ungelesen: ' . substr_count($own, 'schicht-banner-own') . ' Kästen');
|
||||
$check(substr_count($own, 'schicht-banner-own') === 2 && str_contains($own, '(wichtig)') && str_contains($own, 'alle Mitarbeiter'), 'beide Notizen als eigene Kästen, die wichtige ist gekennzeichnet');
|
||||
$check(str_contains($bannerFor($anna, 'dashboard'), 'Ihre Notiz an die nächste Schicht'), 'auch auf der Dashboard-Seite');
|
||||
schicht_mark_read($pdo, $ben, [$n2['id']]);
|
||||
$check(str_contains($bannerFor($anna, 'reports'), 'Gelesen von: Ben'), 'der Verfasser sieht, wer die Notiz gelesen hat');
|
||||
$check($bannerFor($ben, 'plugin_schichtuebergabe') === '', 'auf der Seite der Schichtübergabe selbst entfallen die Hinweise');
|
||||
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id IN (?, ?)')->execute([date('Y-m-d H:i:s', time() - 13 * 3600), $n1['id'], $n2['id']]);
|
||||
$check(!str_contains($bannerFor($anna, 'reports'), 'Ihre Notiz an die nächste Schicht'), 'nach 12 Stunden verschwindet die Bestätigung des Verfassers');
|
||||
$pdo->prepare('UPDATE plugin_schichtuebergabe_notes SET created_at = ? WHERE id IN (?, ?)')->execute([db_now(), $n1['id'], $n2['id']]);
|
||||
$check($bannerFor($ben, 'plugin_schichtuebergabe') === '', 'auf der Seite der Schichtübergabe selbst erscheint der Hinweis nicht');
|
||||
for ($i = 0; $i < 4; $i++) schicht_create($pdo, $anna, 'Weitere ' . $i, null, false);
|
||||
$check(str_contains($bannerFor($ben), '… und 2 weitere') && substr_count($bannerFor($ben), '<strong>Anna</strong>') === 3, 'höchstens drei Notizen im Hinweis, der Rest als „weitere“');
|
||||
schicht_create($pdo, $anna, '<img src=x onerror=alert(1)>', null, true);
|
||||
$check(!str_contains($bannerFor($ben), '<img') && str_contains($bannerFor($ben), '<img'), 'Inhalte im Hinweis werden maskiert');
|
||||
schicht_mark_read($pdo, $ben, array_map(fn($n) => $n['id'], schicht_unread($pdo, $ben)));
|
||||
$check($bannerFor($ben) === '', 'nach dem Lesen verschwindet der Hinweis');
|
||||
$tc = schicht_create($pdo, $chef, 'Nur Technik', $d2, false);
|
||||
$check(!str_contains($bannerFor($anna, 'tickets'), 'Nur Technik') && str_contains($bannerFor($ben), 'Nur Technik'), 'Abteilungsnotizen erscheinen nur bei der Abteilung');
|
||||
$check($bannerFor(null) === '', 'ohne Mitarbeiter kein Hinweis');
|
||||
|
||||
// ── Seite: Gelesen-Knöpfe, nichts wird beim Öffnen automatisch gelesen ──────
|
||||
$_SESSION['csrf_token'] = 'tok';
|
||||
$shPage = $pluginDir . '/schichtuebergabe/pages/schichtuebergabe.php';
|
||||
$shRender = function (array $who, string $method = 'GET', array $post = []) use ($pdo, $shPage): string {
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
$_POST = $post + ($method === 'POST' ? ['csrf_token' => 'tok'] : []);
|
||||
$staff = $who;
|
||||
ob_start();
|
||||
include $shPage;
|
||||
return (string) ob_get_clean();
|
||||
};
|
||||
$pdo->exec('DELETE FROM plugin_schichtuebergabe_reads');
|
||||
$pdo->exec('DELETE FROM plugin_schichtuebergabe_notes');
|
||||
$u1 = schicht_create($pdo, $anna, 'Lesetest eins', null, false)['id'];
|
||||
$u2 = schicht_create($pdo, $anna, 'Lesetest zwei', null, true)['id'];
|
||||
$pg = $shRender($ben);
|
||||
$check(count(schicht_unread($pdo, $ben)) === 2 && str_contains($pg, 'Alle 2 als gelesen markieren') && substr_count($pg, '✓ Gelesen') === 2, 'Öffnen der Seite markiert nichts automatisch, Knöpfe sind da');
|
||||
$check(str_contains($bannerFor($ben), '✓ Gelesen') && str_contains($bannerFor($ben), 'value="mark_all"'), 'der Hinweis hat einen Gelesen-Knopf');
|
||||
$pg = $shRender($ben, 'POST', ['action' => 'mark_read', 'id' => (string) $u1]);
|
||||
$check(count(schicht_unread($pdo, $ben)) === 1 && str_contains($pg, 'Als gelesen markiert') && str_contains($pg, 'Alle 1 als gelesen markieren'), 'eine Notiz einzeln als gelesen markieren');
|
||||
$pg = $shRender($ben, 'POST', ['action' => 'mark_all']);
|
||||
$check(count(schicht_unread($pdo, $ben)) === 0 && !str_contains($pg, 'als gelesen markieren</button>') && $bannerFor($ben) === '', 'alle als gelesen: Hinweis verschwindet');
|
||||
$check(count(schicht_unread($pdo, $anna)) === 0 && !str_contains($shRender($anna), '✓ Gelesen'), 'eigene Notizen sind schon gelesen, kein Knopf');
|
||||
$check(substr_count($bannerFor($anna, 'reports'), 'schicht-banner-own') === 2 && str_contains($bannerFor($anna, 'reports'), 'value="hide_own"'), 'eigene Bestätigungen haben einen Ausblenden-Knopf');
|
||||
$shRender($anna, 'POST', ['action' => 'hide_own', 'id' => (string) $u2]);
|
||||
$check(substr_count($bannerFor($anna, 'reports'), 'schicht-banner-own') === 1 && !str_contains($bannerFor($anna, 'reports'), 'Lesetest zwei') && str_contains($shRender($anna), 'Lesetest zwei'), 'ausgeblendete Bestätigung verschwindet, die Notiz bleibt in der Liste');
|
||||
$check(substr_count($bannerFor($chef, 'reports'), 'schicht-banner-own') === 0 && count(schicht_unread($pdo, $ben)) === 0, 'Ausblenden betrifft nur den eigenen Mitarbeiter');
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Schichtübergabe plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Spam- und Missbrauchsfilter": Bewertung (Phishing, Marken-Nachahmung, Links, Wegwerf-Adressen, Wiederholungen, Vertrauen,
|
||||
* Listen, eigene Wörter), Entscheidung beim Anlegen eines Tickets (ablegen, Verdacht, nichts), Korrekturen, Seitenleiste und Maskierung.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-sf-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('spamfilter', 'plugin_spamfilter');
|
||||
$zipPath = sys_get_temp_dir() . '/sf-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'spamfilter', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'spamfilter', true, ['hooks.tickets', 'ui.menu', 'ui.sidebar', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(isset(plugin_state()['loaded']['spamfilter']), 'Plugin wird geladen');
|
||||
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt ist registriert');
|
||||
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$n = 0;
|
||||
$mk = function (string $name, string $email, string $subject, string $message, string $source = 'web', string $status = 'open', ?string $created = null) use ($pdo, $dept, $prio, &$n): int {
|
||||
$n++;
|
||||
$created ??= db_now();
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['SF-' . $n, $subject, $name, $email, $dept, $prio, $status, $source, $created, $created]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', ?, 0, ?)")->execute([$id, $message, $created]);
|
||||
return $id;
|
||||
};
|
||||
$sc = fn(array $m) => spamfilter_score($pdo, $m);
|
||||
|
||||
// ── Bewertung ──────────────────────────────────────────────────────────────
|
||||
$phish = ['name' => 'Netflix Support', 'email' => 'billing@secure-mailer.example', 'subject' => 'Your membership is on hold', 'message' => "We're processing your refund. Click here https://bit.ly/abc123 to update your payment."];
|
||||
$p = $sc($phish);
|
||||
$check($p['score'] >= 70, 'Phishing-Mail liegt über der Spam-Schwelle: ' . $p['score'] . ' ' . json_encode($p['reasons'], JSON_UNESCAPED_UNICODE));
|
||||
$check(count(array_filter($p['reasons'], fn($x) => str_contains($x, 'Marke netflix'))) === 1, 'Marken-Nachahmung wird erkannt');
|
||||
$check(count(array_filter($p['reasons'], fn($x) => str_contains($x, 'Link-Verkürzer'))) === 1, 'Link-Verkürzer wird erkannt');
|
||||
$ok = $sc(['name' => 'Erika Muster', 'email' => 'erika@kunde.example', 'subject' => 'Passwort zurücksetzen', 'message' => 'Hallo, ich habe mein Passwort vergessen und komme nicht mehr in mein Konto. Können Sie mir helfen?']);
|
||||
$check($ok['score'] < 35, 'normale Kundenanfrage bleibt unter dem Verdacht: ' . $ok['score']);
|
||||
$legit = $sc(['name' => 'Anna Muster', 'email' => 'anna@kunde.example', 'subject' => 'Frage zu Amazon-Bestellung', 'message' => 'Ich habe bei Amazon bestellt und das Paket ist nicht da.']);
|
||||
$check($legit['score'] < 35, 'eine Marke im Text allein ist kein Spam (nur im Absendernamen zählt sie): ' . $legit['score']);
|
||||
$custom = $sc(['name' => 'Apple', 'email' => 'jemand@apple.example', 'subject' => 'Hallo', 'message' => 'Gruß von Apple.']);
|
||||
$check(!array_filter($custom['reasons'], fn($x) => str_contains($x, 'Marke')), 'Marke mit passender Domain ist unverdächtig');
|
||||
|
||||
plugin_set_setting('spamfilter', 'allow', "partner.example\n# Kommentar\nkim@firma.example");
|
||||
$check($sc(['name' => 'Netflix', 'email' => 'x@partner.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['score'] === 0, 'Domain auf der Freigabeliste wird nie bewertet');
|
||||
$check($sc(['name' => 'Netflix', 'email' => 'x@mail.partner.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['allowed'] === true, 'auch Unterdomains der Freigabeliste');
|
||||
$check($sc(['name' => 'Kim', 'email' => 'kim@firma.example', 'subject' => 'Your membership is on hold', 'message' => $phish['message']])['allowed'] === true, 'einzelne Adresse auf der Freigabeliste');
|
||||
plugin_set_setting('spamfilter', 'allow', '');
|
||||
plugin_set_setting('spamfilter', 'block', 'boese.example');
|
||||
$check($sc(['name' => 'X', 'email' => 'a@boese.example', 'subject' => 'Hallo', 'message' => 'Ein harmloser Text ohne Besonderheiten.'])['score'] >= 100, 'Sperrliste gibt 100 Punkte');
|
||||
plugin_set_setting('spamfilter', 'block', '');
|
||||
$check($sc(['name' => 'X', 'email' => 'a@mailinator.com', 'subject' => 'Hallo', 'message' => 'Ein harmloser Text ohne Besonderheiten.'])['score'] === 30, 'Wegwerf-Adresse gibt 30 Punkte');
|
||||
$many = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'Hallo', 'message' => str_repeat('http://x.example/a ', 10)]);
|
||||
$check($many['score'] >= 30, 'viele Links geben Punkte');
|
||||
$caps = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'DRINGEND BITTE SOFORT ANTWORTEN', 'message' => 'Ein normaler Text mit genug Zeichen für den Test.']);
|
||||
$check(count(array_filter($caps['reasons'], fn($x) => str_contains($x, 'Großbuchstaben'))) === 1, 'Betreff in Großbuchstaben gibt Punkte');
|
||||
plugin_set_setting('spamfilter', 'words', "lieferpilz=50\n# nichts\ngewinnspiel");
|
||||
$cw = $sc(['name' => 'X', 'email' => 'a@kunde.example', 'subject' => 'Hallo', 'message' => 'Das Lieferpilz-Angebot ist toll. Gewinnspiel inklusive.']);
|
||||
$check($cw['score'] === 50 + 20, 'eigene Wörter mit und ohne Punkte (50 + 20): ' . $cw['score']);
|
||||
plugin_set_setting('spamfilter', 'words', '');
|
||||
|
||||
// Vertrauen: bekannter Absender mit früherem gelösten Ticket
|
||||
$mk('Stammkunde', 'stamm@kunde.example', 'Altes Ticket', 'Hilfe', 'web', 'closed', date('Y-m-d H:i:s', time() - 5 * 86400));
|
||||
$mild = ['name' => 'Stammkunde', 'email' => 'stamm@kunde.example', 'subject' => 'Hallo', 'message' => 'Bitte klicken Sie hier für den Gewinn, gewonnen!'];
|
||||
$unknown = $sc(['email' => 'neu@kunde.example'] + $mild);
|
||||
$known = $sc($mild);
|
||||
$check($known['score'] === max(0, $unknown['score'] - 40), 'bekannter Absender bekommt 40 Punkte Abzug (' . $unknown['score'] . ' → ' . $known['score'] . ')');
|
||||
$check($sc(['user_id' => 5] + ['email' => 'neu@kunde.example'] + $mild)['score'] === max(0, $unknown['score'] - 30), 'angemeldeter Kunde bekommt 30 Punkte Abzug');
|
||||
|
||||
// Kampagne: gleicher Betreff von drei Absendern
|
||||
$mk('A', 'a1@x1.example', 'Rechnung offen 4711', 'Bitte zahlen', 'web');
|
||||
$mk('B', 'b1@x2.example', 'Rechnung offen 4711', 'Bitte zahlen', 'web');
|
||||
$camp = $sc(['name' => 'C', 'email' => 'c1@x3.example', 'subject' => 'Rechnung offen 4711', 'message' => 'Bitte zahlen Sie den offenen Betrag sofort an dieses Konto.']);
|
||||
$check(count(array_filter($camp['reasons'], fn($x) => str_contains($x, 'verschiedenen Absendern'))) >= 1, 'gleicher Betreff von mehreren Absendern wird erkannt: ' . json_encode($camp['reasons'], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// ── Entscheidung beim Anlegen ──────────────────────────────────────────────
|
||||
$tSpam = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tSpam);
|
||||
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSpam}")->fetch();
|
||||
$check((int) $row['is_spam'] === 1 && $row['status'] === 'closed', 'eindeutiger Spam wird über das Ticket-Ereignis abgelegt');
|
||||
$tag = fn(int $id, string $name) => (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$id} AND g.name = " . $pdo->quote($name))->fetchColumn();
|
||||
$check($tag($tSpam, 'Spam') === 1, 'abgelegtes Ticket trägt den Tag „Spam“');
|
||||
$check($pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tSpam}")->fetchColumn() === 'spam', 'Entscheidung steht im Protokoll');
|
||||
|
||||
$tSus = $mk('Y', 'y@mailinator.com', 'Hallo', 'Schauen Sie hier: http://bit.ly/zz1 bitte.', 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tSus);
|
||||
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSus}")->fetch();
|
||||
$check((int) $row['is_spam'] === 0 && $row['status'] === 'open' && $tag($tSus, 'Spam-Verdacht') === 1, 'Zweifelsfall bleibt offen und bekommt den Tag „Spam-Verdacht“');
|
||||
|
||||
$tOk = $mk('Erika', 'erika2@kunde.example', 'Frage', 'Wie lange dauert der Versand meiner Bestellung ungefähr?', 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tOk);
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tOk}")->fetchColumn() === 0 && $tag($tOk, 'Spam-Verdacht') === 0 && $pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tOk}")->fetchColumn() === 'ok', 'normales Ticket bleibt unberührt');
|
||||
|
||||
foreach (['chat', 'staff'] as $src) {
|
||||
$tx = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], $src);
|
||||
webhook_dispatch($pdo, 'ticket.created', $tx);
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tx}")->fetchColumn() === 0 && !$pdo->query("SELECT COUNT(*) FROM plugin_spamfilter_log WHERE ticket_id = {$tx}")->fetchColumn(), "Tickets mit Quelle „{$src}“ werden nicht bewertet");
|
||||
}
|
||||
$tMail = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'email');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tMail);
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tMail}")->fetchColumn() === 1, 'E-Mail-Tickets werden ebenfalls abgelegt');
|
||||
|
||||
plugin_set_setting('spamfilter', 'enabled', '0');
|
||||
$tOff = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tOff);
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tOff}")->fetchColumn() === 0, 'ausgeschaltet: nichts passiert');
|
||||
plugin_set_setting('spamfilter', 'enabled', '1');
|
||||
plugin_set_setting('spamfilter', 'auto', '0');
|
||||
$tNoAuto = $mk($phish['name'], $phish['email'], $phish['subject'], $phish['message'], 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tNoAuto);
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tNoAuto}")->fetchColumn() === 0 && $tag($tNoAuto, 'Spam-Verdacht') === 1, 'ohne automatische Ablage wird nur markiert');
|
||||
plugin_set_setting('spamfilter', 'auto', '1');
|
||||
plugin_set_setting('spamfilter', 'ban', '1');
|
||||
$tBan = $mk($phish['name'], 'ban-me@secure-mailer.example', $phish['subject'], $phish['message'], 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $tBan);
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM banlist WHERE value = 'ban-me@secure-mailer.example'")->fetchColumn() === 1, 'mit der Option wird der Absender gesperrt');
|
||||
plugin_set_setting('spamfilter', 'ban', '0');
|
||||
|
||||
// ── Korrekturen ────────────────────────────────────────────────────────────
|
||||
$check(spamfilter_not_spam($pdo, $tSpam, 1, true), '„Kein Spam“ klappt');
|
||||
$row = $pdo->query("SELECT is_spam, status FROM tickets WHERE id = {$tSpam}")->fetch();
|
||||
$check((int) $row['is_spam'] === 0 && $row['status'] === 'open' && $tag($tSpam, 'Spam') === 0, 'Ticket ist wieder offen, Tag entfernt');
|
||||
$check(in_array($phish['email'], spamfilter_lines((string) plugin_setting('spamfilter', 'allow', '')), true), 'Absender landet auf der Freigabeliste');
|
||||
$check($pdo->query("SELECT decision FROM plugin_spamfilter_log WHERE ticket_id = {$tSpam}")->fetchColumn() === 'manual_ok', 'Protokoll zeigt die Korrektur');
|
||||
$check(spamfilter_confirm_spam($pdo, $tSus, 1, true), '„Doch Spam“ klappt');
|
||||
$check((int) $pdo->query("SELECT is_spam FROM tickets WHERE id = {$tSus}")->fetchColumn() === 1 && $tag($tSus, 'Spam-Verdacht') === 0, 'Verdachts-Ticket ist abgelegt, Verdachts-Tag entfernt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM banlist WHERE value = 'y@mailinator.com'")->fetchColumn() === 1, 'Absender gesperrt');
|
||||
$check(!spamfilter_confirm_spam($pdo, $tSus, 1, false), 'zweites „Doch Spam“ wird abgelehnt');
|
||||
|
||||
// ── Seitenleiste und Maskierung ────────────────────────────────────────────
|
||||
$evil = $mk('Netflix <script>alert(1)</script>', 'e@evil.example', 'Your membership is on hold', $phish['message'], 'web');
|
||||
webhook_dispatch($pdo, 'ticket.created', $evil);
|
||||
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => $evil, 'ticket' => [], 'staff' => ['id' => 1, 'role' => 'admin']]);
|
||||
$check(str_contains($html, 'Spamfilter') && str_contains($html, 'Punkte') && !str_contains($html, '<script>'), 'Seitenleiste zeigt die Bewertung und maskiert alles');
|
||||
$check(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 999999, 'ticket' => [], 'staff' => []]) === '', 'ohne Bewertung keine Seitenleiste');
|
||||
$st = spamfilter_stats($pdo);
|
||||
$check($st['spam'] >= 2 && $st['ok'] >= 1 && $st['suspect'] >= 1, 'Kennzahlen zählen die Entscheidungen: ' . json_encode($st));
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Spamfilter plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Website- und Server-Überwachung": Eingaben, Ausfall nach mehreren Fehlschlägen (Ticket, Mail), Entwarnung (Ticket gelöst),
|
||||
* Zertifikatswarnung und Erneuerung, Pausieren, Fälligkeit, und die echten Prüfungen gegen einen lokalen Webserver und Port.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-ue-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('ueberwachung', 'plugin_ueberwachung');
|
||||
$zipPath = sys_get_temp_dir() . '/ue-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'ueberwachung', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'ueberwachung', true, ['hooks.cron', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1 && count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0 && count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Menüpunkt steht im Adminportal und nur für Administratoren');
|
||||
|
||||
set_setting('admin_email', 'admin@firma.example');
|
||||
$mails = [];
|
||||
$GLOBALS['ueberwachung_mail'] = function (string $to, string $subject, string $html) use (&$mails): void { $mails[] = compact('to', 'subject', 'html'); };
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 40, 'cert_expires' => null];
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||
$mon = fn(int $id) => $pdo->query("SELECT * FROM plugin_ueberwachung_monitors WHERE id = {$id}")->fetch();
|
||||
$ticket = fn(?int $id) => $id ? $pdo->query("SELECT * FROM tickets WHERE id = " . (int) $id)->fetch() : null;
|
||||
|
||||
// Eingaben
|
||||
$bad = fn(array $in) => !ueberwachung_save($pdo, $in)['ok'];
|
||||
$check($bad(['name' => '', 'type' => 'http', 'target' => 'https://x.example/']), 'Name ist Pflicht');
|
||||
$check($bad(['name' => 'A', 'type' => 'http', 'target' => 'ftp://x.example']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'javascript:alert(1)']) && $bad(['name' => 'A', 'type' => 'http', 'target' => 'https://']), 'nur http(s)-Adressen');
|
||||
$check($bad(['name' => 'A', 'type' => 'port', 'target' => 'host']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'host:99999']) && $bad(['name' => 'A', 'type' => 'port', 'target' => 'a b:80']) && $bad(['name' => 'A', 'type' => 'xyz', 'target' => 'h:80']), 'Port, Host und Art werden geprüft');
|
||||
$web = ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 0, 'timeout_sec' => 999, 'cert_warn_days' => 14]);
|
||||
$m1 = $mon($web['id']);
|
||||
$check($web['ok'] && (int) $m1['interval_min'] === 1 && (int) $m1['timeout_sec'] === 30, 'Werte werden begrenzt');
|
||||
$check(ueberwachung_save($pdo, ['name' => 'Support neu', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id'])['ok'] && $mon($web['id'])['name'] === 'Support neu', 'Ändern');
|
||||
ueberwachung_save($pdo, ['name' => 'Support', 'type' => 'http', 'target' => 'https://support.example.de/', 'interval_min' => 5], $web['id']);
|
||||
$id = $web['id'];
|
||||
|
||||
// Erste Prüfung in Ordnung
|
||||
ueberwachung_check($pdo, $id);
|
||||
$m = $mon($id);
|
||||
$check($m['state'] === 'up' && (int) $m['fails'] === 0 && (int) $m['last_ms'] === 40 && $m['last_check_at'] !== null, 'erste Prüfung: in Ordnung');
|
||||
$check($mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ohne Fehler keine Mail und kein Ticket');
|
||||
|
||||
// Ein einzelner Fehler löst noch nichts aus (Schwelle 2)
|
||||
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
$m = $mon($id);
|
||||
$check($m['state'] === 'up' && (int) $m['fails'] === 1 && $mails === [] && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 0, 'ein einzelner Fehlschlag meldet noch nichts');
|
||||
// Kurzer Aussetzer behoben
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 50, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check((int) $mon($id)['fails'] === 0 && $mails === [], 'ein Aussetzer ohne Folgen setzt den Zähler zurück');
|
||||
|
||||
// Ausfall: zweiter Fehlschlag in Folge
|
||||
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 90, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
ueberwachung_check($pdo, $id);
|
||||
$m = $mon($id);
|
||||
$t = $ticket($m['ticket_id'] ? (int) $m['ticket_id'] : null);
|
||||
$check($m['state'] === 'down' && $m['down_since'] !== null && $t && str_contains($t['subject'], 'Ausfall: Support'), 'Ausfall: Zustand „down“ und Ticket');
|
||||
$check($t && $t['status'] === 'open' && $t['requester_name'] === 'Überwachung' && $t['requester_email'] === 'admin@firma.example', 'Ticket ist offen, Absender „Überwachung“');
|
||||
$tag = (int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = " . (int) $m['ticket_id'] . " AND g.name = 'Überwachung'")->fetchColumn();
|
||||
$check($tag === 1, 'Ticket trägt den Tag „Überwachung“');
|
||||
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Ausfall: Support') && $mails[0]['to'] === 'admin@firma.example' && str_contains($mails[0]['html'], 'HTTP-Status 503'), 'Mail an den Administrator');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'down'")->fetchColumn() === 1, 'Ereignis im Protokoll');
|
||||
// Weiter fehlerhaft: kein zweites Ticket, keine zweite Mail
|
||||
ueberwachung_check($pdo, $id);
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1 && count($mails) === 1, 'anhaltender Ausfall erzeugt kein weiteres Ticket und keine weitere Mail');
|
||||
|
||||
// Entwarnung
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET down_since = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 780), $id]);
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
$m = $mon($id);
|
||||
$t = $ticket((int) $pdo->query('SELECT id FROM tickets ORDER BY id LIMIT 1')->fetchColumn());
|
||||
$check($m['state'] === 'up' && $m['down_since'] === null && $m['ticket_id'] === null, 'wieder erreichbar: Zustand zurückgesetzt');
|
||||
$check($t['status'] === 'resolved' && $t['resolved_at'] !== null, 'Ticket ist automatisch gelöst');
|
||||
$note = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||
$check(str_contains((string) $note, 'Wieder erreichbar') && str_contains((string) $note, '13 Minuten'), 'interne Notiz nennt die Dauer: ' . $note);
|
||||
$check(count($mails) === 2 && str_contains($mails[1]['subject'], 'Entwarnung') && str_contains($mails[1]['html'], '13 Minuten'), 'Entwarnungs-Mail mit Dauer');
|
||||
|
||||
// Neuer Ausfall nach Entwarnung legt ein neues Ticket an
|
||||
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Zeitüberschreitung', 'ms' => 8000, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 2 && $mon($id)['state'] === 'down', 'ein neuer Ausfall bekommt ein neues Ticket');
|
||||
// ohne automatisches Lösen bleibt das Ticket offen
|
||||
plugin_set_setting('ueberwachung', 'auto_resolve', '0');
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
$last = $ticket((int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn());
|
||||
$threads = $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$last['id']}")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$check($last['status'] === 'open' && count(array_filter($threads, fn($t) => str_contains((string) $t, 'Wieder erreichbar'))) === 1, 'ohne „automatisch lösen“ bleibt das Ticket offen, die Notiz steht trotzdem: ' . json_encode([$last['id'], $last['status'], $threads], JSON_UNESCAPED_UNICODE));
|
||||
plugin_set_setting('ueberwachung', 'auto_resolve', '1');
|
||||
|
||||
// Ohne Tickets und ohne Mail
|
||||
plugin_set_setting('ueberwachung', 'create_tickets', '0');
|
||||
plugin_set_setting('ueberwachung', 'mail', '0');
|
||||
$before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)];
|
||||
$answer = ['ok' => false, 'error' => 'x', 'ms' => 1, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check($mon($id)['state'] === 'down' && [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), count($mails)] === $before, 'Einstellungen: keine Tickets und keine Mails möglich, der Zustand wird trotzdem erfasst');
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 1, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $id);
|
||||
plugin_set_setting('ueberwachung', 'create_tickets', '1');
|
||||
plugin_set_setting('ueberwachung', 'mail', '1');
|
||||
|
||||
// Zertifikat
|
||||
$mails = [];
|
||||
$ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 45, 'cert_expires' => time() + 60 * 86400];
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore && $mon($id)['cert_expires'] !== null && $mon($id)['cert_ticket_id'] === null, 'Zertifikat noch 60 Tage gültig: nichts zu melden');
|
||||
$answer['cert_expires'] = time() + 9 * 86400 + 3600;
|
||||
ueberwachung_check($pdo, $id);
|
||||
$m = $mon($id);
|
||||
$ct = $ticket((int) $m['cert_ticket_id']);
|
||||
$check($ct && str_contains($ct['subject'], 'Zertifikat: Support') && str_contains($ct['subject'] . $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$ct['id']} LIMIT 1")->fetchColumn(), 'läuft in 9 Tagen ab'), 'Zertifikat läuft in 9 Tagen ab: Ticket');
|
||||
$check(count($mails) === 1 && str_contains($mails[0]['subject'], 'Zertifikat'), 'Mail zur Zertifikatswarnung');
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore + 1 && count($mails) === 1, 'die Warnung kommt nur einmal');
|
||||
$answer['cert_expires'] = time() + 90 * 86400;
|
||||
ueberwachung_check($pdo, $id);
|
||||
$ct = $ticket((int) $ct['id']);
|
||||
$check($ct['status'] === 'resolved' && $mon($id)['cert_ticket_id'] === null && str_contains(end($mails)['subject'], 'erneuert'), 'Zertifikat erneuert: Ticket gelöst, Entwarnung');
|
||||
$answer['cert_expires'] = time() - 2 * 86400;
|
||||
ueberwachung_check($pdo, $id);
|
||||
$check($mon($id)['cert_ticket_id'] !== null && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE kind = 'cert' AND message LIKE '%abgelaufen%'")->fetchColumn() === 1, 'abgelaufenes Zertifikat wird als abgelaufen gemeldet');
|
||||
$answer['cert_expires'] = null;
|
||||
|
||||
// Ticket-Anlage trotz Sperre: die eigene Adresse steht auf der Sperrliste, das normale Anlegen lehnt ab, der Rückfall legt das Ticket trotzdem an
|
||||
$pdo->prepare('INSERT INTO banlist (match_type, value, created_at) VALUES (?,?,?)')->execute(['email', 'admin@firma.example', db_now()]);
|
||||
$mails = [];
|
||||
$tCount = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
|
||||
$fb = ueberwachung_save($pdo, ['name' => 'Rückfall', 'type' => 'port', 'target' => 'rueckfall.example:25'])['id'];
|
||||
$answer = ['ok' => false, 'error' => 'Nicht erreichbar: Verbindung abgelehnt', 'ms' => 5, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $fb);
|
||||
ueberwachung_check($pdo, $fb);
|
||||
$fm = $mon($fb);
|
||||
$ft = $ticket($fm['ticket_id'] ? (int) $fm['ticket_id'] : null);
|
||||
$check($fm['state'] === 'down' && $ft && str_contains($ft['subject'], 'Ausfall: Rückfall') && (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $tCount + 1, 'trotz Sperrliste wird das Ausfall-Ticket angelegt (Rückfall)');
|
||||
$check($ft && $ft['status'] === 'open' && $ft['requester_name'] === 'Überwachung' && (int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ft['id']}")->fetchColumn() === 1, 'Rückfall-Ticket hat Nachricht und Absender');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ft['id']} AND g.name = 'Überwachung'")->fetchColumn() === 1, 'Rückfall-Ticket trägt den Tag');
|
||||
$err = (string) plugin_setting('ueberwachung', 'ticket_error', '');
|
||||
$check(str_contains($err, 'am normalen Weg vorbei') && str_contains($err, 'gesperrt'), 'der Grund steht auf der Seite: ' . $err);
|
||||
$check(count($mails) === 1, 'die Mail an den Administrator kommt trotzdem');
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $fb);
|
||||
$check($ticket((int) $ft['id'])['status'] === 'resolved', 'Entwarnung löst auch das Rückfall-Ticket');
|
||||
// Ohne Sperre geht es auf dem normalen Weg und der Hinweis verschwindet
|
||||
$pdo->exec('DELETE FROM banlist');
|
||||
$tid = ueberwachung_open_ticket($pdo, 'Test: Überwachung', 'Testticket');
|
||||
$check($tid && plugin_setting('ueberwachung', 'ticket_error', '') === '' && $ticket($tid)['requester_email'] === 'admin@firma.example', 'ohne Sperre: normaler Weg, Hinweis weg');
|
||||
// Ist die gewählte Priorität ungültig, scheitern beide Wege: Fehler wird sichtbar statt still verschluckt
|
||||
plugin_set_setting('ueberwachung', 'priority_id', '99999');
|
||||
$none = ueberwachung_open_ticket($pdo, 'Test 2', 'x');
|
||||
$check($none === null && str_contains((string) plugin_setting('ueberwachung', 'ticket_error', ''), 'konnte nicht angelegt werden'), 'scheitert alles, steht der Grund auf der Seite: ' . plugin_setting('ueberwachung', 'ticket_error', ''));
|
||||
plugin_set_setting('ueberwachung', 'priority_id', '0');
|
||||
plugin_set_setting('ueberwachung', 'ticket_error', '');
|
||||
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||
|
||||
// ── Inhalt der Tickets: Name und Adresse, Tabelle, lesbare Fehler, Hinweise ──
|
||||
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||
$mails = [];
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||
$ct = ueberwachung_save($pdo, ['name' => 'E-Book <b>Shop</b>', 'type' => 'http', 'target' => 'https://e-book.example.net/start', 'interval_min' => 5, 'expect_text' => 'Willkommen'])['id'];
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 120, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $ct);
|
||||
$answer = ['ok' => false, 'error' => 'HTTP-Status 503', 'ms' => 2500, 'cert_expires' => time() + 40 * 86400, 'status' => 503, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
|
||||
ueberwachung_check($pdo, $ct);
|
||||
$first = $mon($ct)['first_fail_at'];
|
||||
ueberwachung_check($pdo, $ct);
|
||||
$m = $mon($ct);
|
||||
$t = $ticket((int) $m['ticket_id']);
|
||||
$msg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} ORDER BY id LIMIT 1")->fetchColumn();
|
||||
$check($t['subject'] === 'Ausfall: E-Book <b>Shop</b> (e-book.example.net)', 'Betreff nennt Name und Adresse der Seite: ' . $t['subject']);
|
||||
$check($first !== null && $m['down_since'] === $first, 'Beginn des Ausfalls ist der erste Fehlschlag');
|
||||
foreach (['<table', '<strong>Dienst</strong>', 'E-Book <b>Shop</b>', 'https://e-book.example.net/start', 'Webseite (HTTP/HTTPS)', 'HTTP-Status 503', '<strong>HTTP-Status</strong>', '503', 'Fehlgeschlagene Prüfungen in Folge', '2 (Prüfung alle 5 Minuten', 'Letzter erfolgreicher Abruf', 'Erwarteter Text', 'Willkommen', 'Zertifikat gültig bis', 'Aussteller', 'Let's Encrypt', 'Mögliche Ursachen und nächste Schritte', '<li>', 'automatisch gelöst'] as $needle) {
|
||||
$check(str_contains($msg, $needle), "Ticket enthält „{$needle}“");
|
||||
}
|
||||
$check(!str_contains($msg, '<b>Shop</b>'), 'der Name wird im Ticket maskiert');
|
||||
$check(!str_contains($msg, "\n\nDiese Meldung"), 'kein loser Fließtext mehr');
|
||||
$check(str_contains((string) $mails[0]['html'], '<table') && str_contains($mails[0]['subject'], 'Ausfall: E-Book'), 'die Mail an den Administrator hat dieselbe Tabelle');
|
||||
// Entwarnung nennt Beginn, Ende und Dauer
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 130, 'cert_expires' => null];
|
||||
ueberwachung_check($pdo, $ct);
|
||||
$note = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$t['id']} AND is_internal_note = 1 ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||
$check(str_contains($note, 'Wieder erreichbar') && preg_match('/Von \d\d\.\d\d\.\d{4} \d\d:\d\d:\d\d bis \d\d\.\d\d\.\d{4}/', $note) === 1, 'Notiz bei Entwarnung nennt von/bis: ' . $note);
|
||||
$check(str_contains(end($mails)['subject'], 'Entwarnung: E-Book') && str_contains(end($mails)['html'], '<table'), 'Entwarnungs-Mail mit Tabelle');
|
||||
$check($mon($ct)['first_fail_at'] === null, 'erster Fehlschlag wird nach der Entwarnung zurückgesetzt');
|
||||
// Zertifikat-Ticket
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 100, 'cert_expires' => time() + 5 * 86400 + 600, 'issuer' => "Let's Encrypt", 'cert_subject' => 'e-book.example.net'];
|
||||
ueberwachung_check($pdo, $ct);
|
||||
$cert = $ticket((int) $mon($ct)['cert_ticket_id']);
|
||||
$certMsg = (string) $pdo->query("SELECT message FROM ticket_threads WHERE ticket_id = {$cert['id']} ORDER BY id LIMIT 1")->fetchColumn();
|
||||
$check(str_starts_with($cert['subject'], 'Zertifikat: E-Book') && str_contains($certMsg, 'läuft in 5 Tagen ab') && str_contains($certMsg, 'Aussteller') && str_contains($certMsg, 'Warnung ab') && str_contains($certMsg, 'Zertifikat erneuern'), 'Zertifikat-Ticket mit Tabelle und Hinweisen');
|
||||
|
||||
// Lesbare Fehlertexte
|
||||
$d = fn(string $raw) => ueberwachung_describe_error($raw);
|
||||
$check(str_contains($d('fopen(https://x.example): Failed to open stream: php_network_getaddresses: getaddrinfo failed'), 'DNS'), 'Fehlertext: DNS');
|
||||
$check(str_contains($d('Connection refused'), 'Verbindung abgelehnt') && str_contains($d('Connection timed out'), 'Zeitüberschreitung') && str_contains($d('No route to host'), 'nicht erreichbar'), 'Fehlertexte: abgelehnt, Zeitüberschreitung, keine Route');
|
||||
$check(str_starts_with($d('SSL operation failed with code 1. certificate verify failed'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'Fehlertext: Zertifikat ungültig');
|
||||
$check(str_starts_with($d('SSL: certificate has expired'), 'Zertifikat/TLS-Fehler (Zertifikat abgelaufen'), 'Fehlertext: Zertifikat abgelaufen');
|
||||
$check(str_contains($d('Could not resolve host: e-book.example.net'), 'DNS') && str_contains($d('Operation timed out after 8001 milliseconds'), 'Zeitüberschreitung') && str_contains($d('Failed to connect to x port 443: Connection refused'), 'abgelehnt'), 'cURL-Meldungen werden übersetzt');
|
||||
$check(ueberwachung_default_page('Welcome to nginx!') !== null && ueberwachung_default_page('Apache2 Ubuntu Default Page: It works') !== null && ueberwachung_default_page('502 Bad Gateway') !== null && ueberwachung_default_page('Index of /') !== null, 'Standard- und Fehlerseiten werden am Titel erkannt');
|
||||
$check(ueberwachung_default_page('Default Site') !== null && ueberwachung_default_page('Start', "<p>If you're seeing this site then you're trying to access a host that isn't set up yet.</p>") !== null, 'Titel „Default Site“ und der Standardtext des Proxy Managers werden erkannt');
|
||||
$check(ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 1, 'state' => 'up', 'interval_min' => 3]) === 45 && ueberwachung_next_wait(['fails' => 2, 'state' => 'down', 'interval_min' => 3]) === 165 && ueberwachung_next_wait(['fails' => 0, 'state' => 'up', 'interval_min' => 1]) === 45, 'nach dem ersten Fehlschlag wird nach 45 Sekunden nachgeprüft, sonst im eingestellten Abstand');
|
||||
$check(ueberwachung_default_page('E-Book Shop – Startseite') === null && ueberwachung_default_page('') === null && ueberwachung_default_page('Nginx Proxy Manager') === null, 'normale Titel und der Nginx Proxy Manager selbst gelten nicht als Standardseite');
|
||||
$check(ueberwachung_page_title("<html><head>\n<title> Mein & Dein\n Shop </title>") === 'Mein & Dein Shop', 'Seitentitel wird sauber gelesen');
|
||||
$check(str_contains(ueberwachung_hints(['type' => 'http'], 'Der Server antwortet mit einer Standard- oder Fehlerseite statt mit der Anwendung')[0], 'Reverse-Proxy'), 'Hinweis für Standardseiten nennt den Reverse-Proxy');
|
||||
$tlsBad = ['type' => 'http', 'target' => 'https://a.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => 'Zertifikat/TLS-Fehler (Zertifikat ungültig): x'];
|
||||
$tlsOk = ['type' => 'http', 'target' => 'https://b.example/', 'verify_tls' => 1, 'last_check_at' => '2026-01-01 00:00:00', 'last_error' => null];
|
||||
$check(ueberwachung_ca_warning([$tlsBad], true) === true, 'Warnung: alle HTTPS-Ziele scheitern am Zertifikat und das System vermutet ein fehlendes CA-Bundle');
|
||||
$check(ueberwachung_ca_warning([$tlsBad, $tlsOk], true) === false, 'keine Warnung, sobald irgendein HTTPS-Ziel mit Prüfung erreicht wird (die Prüfung funktioniert)');
|
||||
$check(ueberwachung_ca_warning([$tlsBad], false) === false && ueberwachung_ca_warning([], true) === false, 'keine Warnung ohne Verdacht des Systems oder ohne Ziele');
|
||||
$check(ueberwachung_ca_warning([['verify_tls' => 0] + $tlsBad], true) === false && ueberwachung_ca_warning([['target' => 'http://c.example/'] + $tlsBad], true) === false, 'Ziele ohne Zertifikatsprüfung und ohne HTTPS zählen nicht');
|
||||
$check(str_starts_with($d('SSL certificate problem: self-signed certificate'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig') && str_starts_with($d('SSL: no alternative certificate subject name matches target host name'), 'Zertifikat/TLS-Fehler (Zertifikat ungültig'), 'cURL-Zertifikatsfehler werden übersetzt');
|
||||
$check(str_contains($d('fopen(): SSL operation failed with code 1. OpenSSL Error messages: error:0A000086:SSL routines::certificate verify failed | fopen(https://x): Failed to open stream: operation failed'), 'Zertifikat ungültig'), 'alle PHP-Warnungen zusammen: die Zertifikatsursache geht nicht mehr verloren');
|
||||
$vt = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/', 'verify_tls' => 0]);
|
||||
$vt2 = ueberwachung_validate(['name' => 'A', 'type' => 'http', 'target' => 'https://x.example/']);
|
||||
$check($vt['data']['verify_tls'] === 0 && $vt2['data']['verify_tls'] === 1, 'Zertifikatsprüfung lässt sich je Ziel abschalten (Standard: an)');
|
||||
$generic = $d('fopen(https://e-book.viper.ipv64.net): Failed to open stream: operation failed');
|
||||
$check(str_starts_with($generic, 'Nicht erreichbar:') && !str_contains($generic, 'fopen') && !str_contains($generic, 'e-book'), 'allgemeiner Fehler ohne technische Reste: ' . $generic);
|
||||
$check(ueberwachung_subject('Ausfall', ['name' => 'e-book.example.net', 'target' => 'https://e-book.example.net/']) === 'Ausfall: e-book.example.net', 'Betreff ohne doppelte Adresse, wenn sie schon im Namen steht');
|
||||
$check(ueberwachung_subject('Ausfall', ['name' => 'Mail', 'target' => 'mail.example.de:993']) === 'Ausfall: Mail (mail.example.de)', 'Betreff bei Port-Zielen');
|
||||
$check(ueberwachung_hints(['type' => 'http'], 'HTTP-Status 503')[0] !== ueberwachung_hints(['type' => 'port'], 'Nicht erreichbar: x')[0], 'Hinweise passen zur Art des Fehlers');
|
||||
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||
|
||||
// Nachträgliche Änderung des Prüfabstands wird gespeichert und gilt für die Fälligkeit
|
||||
$GLOBALS['probeCalls'] = 0;
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
|
||||
$answer = ['ok' => true, 'error' => null, 'ms' => 5, 'cert_expires' => null];
|
||||
$iv = ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5])['id'];
|
||||
ueberwachung_check($pdo, $iv);
|
||||
$check((int) $mon($iv)['interval_min'] === 5, 'Prüfabstand 5 Minuten gespeichert');
|
||||
$check(ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 3], $iv)['ok'] && (int) $mon($iv)['interval_min'] === 3, 'nachträgliche Änderung auf 3 Minuten wird gespeichert');
|
||||
$check($mon($iv)['last_check_at'] !== null && $mon($iv)['name'] === 'Abstand', 'Änderung löscht weder Name noch bisherige Ergebnisse');
|
||||
$calls = $GLOBALS['probeCalls'];
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 120), $iv]);
|
||||
ueberwachung_tick($pdo);
|
||||
$check($GLOBALS['probeCalls'] === $calls, 'nach 2 Minuten noch nicht fällig (Abstand 3)');
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
|
||||
ueberwachung_tick($pdo);
|
||||
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach knapp 3 Minuten ist die Prüfung fällig (sobald der Hintergrunddienst läuft)');
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 175), $iv]);
|
||||
ueberwachung_save($pdo, ['name' => 'Abstand', 'type' => 'port', 'target' => 'abstand.example:25', 'interval_min' => 5], $iv);
|
||||
$calls = $GLOBALS['probeCalls'];
|
||||
ueberwachung_tick($pdo);
|
||||
$check($GLOBALS['probeCalls'] === $calls, 'zurück auf 5 Minuten: nach knapp 3 Minuten nicht fällig');
|
||||
// Das Plugin hängt am Minuten-Ereignis (eingestellter Abstand) und am 5-Minuten-Ereignis (Rückfall), ohne doppelt zu prüfen
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
|
||||
$calls = $GLOBALS['probeCalls'];
|
||||
plugin_hook('cron.minute', ['time' => time()]);
|
||||
$check($GLOBALS['probeCalls'] === $calls + 1, 'das Minuten-Ereignis prüft fällige Ziele');
|
||||
plugin_hook('cron.run', ['time' => time()]);
|
||||
$check($GLOBALS['probeCalls'] === $calls + 1, 'das 5-Minuten-Ereignis direkt danach prüft nicht noch einmal');
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $iv]);
|
||||
plugin_hook('cron.run', ['time' => time()]);
|
||||
$check($GLOBALS['probeCalls'] === $calls + 2, 'Rückfall: auch das 5-Minuten-Ereignis allein prüft fällige Ziele (ältere DeskHub-Dateien)');
|
||||
// Nach einem ersten Fehlschlag wird schon nach knapp einer Minute nachgeprüft (Abstand 5 Minuten)
|
||||
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 1, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
|
||||
$calls = $GLOBALS['probeCalls'];
|
||||
ueberwachung_tick($pdo);
|
||||
$check($GLOBALS['probeCalls'] === $calls + 1, 'nach einem ersten Fehlschlag wird nach ~1 Minute nachgeprüft statt erst nach 5');
|
||||
$pdo->prepare("UPDATE plugin_ueberwachung_monitors SET fails = 0, state = 'up', last_check_at = ? WHERE id = ?")->execute([date('Y-m-d H:i:s', time() - 50), $iv]);
|
||||
$calls = $GLOBALS['probeCalls'];
|
||||
ueberwachung_tick($pdo);
|
||||
$check($GLOBALS['probeCalls'] === $calls, 'ohne Fehlschlag gilt weiter der eingestellte Abstand');
|
||||
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { return $answer; };
|
||||
|
||||
// Fälligkeit und Pausieren
|
||||
$GLOBALS['probeCalls'] = 0;
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m) use (&$answer): array { $GLOBALS['probeCalls']++; return $answer; };
|
||||
$pdo->exec('DELETE FROM plugin_ueberwachung_monitors');
|
||||
$a = ueberwachung_save($pdo, ['name' => 'A', 'type' => 'port', 'target' => 'a.example:25', 'interval_min' => 5])['id'];
|
||||
$b = ueberwachung_save($pdo, ['name' => 'B', 'type' => 'port', 'target' => 'b.example:25', 'interval_min' => 5, 'is_active' => 0])['id'];
|
||||
$check(ueberwachung_tick($pdo) === 1 && $GLOBALS['probeCalls'] === 1, 'Hintergrundlauf prüft nur aktive Ziele');
|
||||
$check(ueberwachung_tick($pdo) === 0, 'frisch geprüfte Ziele sind nicht fällig');
|
||||
$pdo->prepare('UPDATE plugin_ueberwachung_monitors SET last_check_at = ? WHERE id = ?')->execute([date('Y-m-d H:i:s', time() - 400), $a]);
|
||||
$check(ueberwachung_tick($pdo) === 1, 'nach dem Intervall ist es wieder fällig');
|
||||
$check(!ueberwachung_check($pdo, 99999), 'unbekanntes Ziel');
|
||||
ueberwachung_delete($pdo, $a);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_ueberwachung_monitors')->fetchColumn() === 1 && (int) $pdo->query("SELECT COUNT(*) FROM plugin_ueberwachung_events WHERE monitor_id = {$a}")->fetchColumn() === 0, 'Löschen entfernt Ziel und Ereignisse');
|
||||
// Fehler in der Prüfung selbst
|
||||
$GLOBALS['ueberwachung_probe'] = function (array $m): array { throw new RuntimeException('kaputt'); };
|
||||
$c = ueberwachung_save($pdo, ['name' => 'C', 'type' => 'port', 'target' => 'c.example:25'])['id'];
|
||||
ueberwachung_check($pdo, $c);
|
||||
$check(str_contains((string) $mon($c)['last_error'], 'Prüfung fehlgeschlagen') && (int) $mon($c)['fails'] === 1, 'Ausnahmen in der Prüfung werden als Fehler gewertet');
|
||||
unset($GLOBALS['ueberwachung_probe'], $GLOBALS['ueberwachung_mail']);
|
||||
|
||||
// Selbstheilung: fehlt nach einem Update die Spalte (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||
$pdo->exec('ALTER TABLE plugin_ueberwachung_monitors DROP COLUMN first_fail_at');
|
||||
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_ueberwachung_002%'");
|
||||
$colsOf = fn() => array_column($pdo->query('PRAGMA table_info(plugin_ueberwachung_monitors)')->fetchAll(), 'name');
|
||||
$check(!in_array('first_fail_at', $colsOf(), true), 'Vorbereitung: Spalte fehlt');
|
||||
ueberwachung_ensure_schema(true);
|
||||
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung: die fehlende Spalte wird nachgeholt');
|
||||
ueberwachung_ensure_schema(true);
|
||||
$check(in_array('first_fail_at', $colsOf(), true), 'Selbstheilung ist wiederholbar');
|
||||
|
||||
// ── Echte Prüfungen gegen einen lokalen Webserver und Port ──────────────────
|
||||
$router = sys_get_temp_dir() . '/ue-router-' . bin2hex(random_bytes(3)) . '.php';
|
||||
file_put_contents($router, <<<'ROUTER'
|
||||
<?php
|
||||
$c = (int) ($_GET['code'] ?? 200);
|
||||
http_response_code($c);
|
||||
if (($_GET['page'] ?? '') === 'npm') {
|
||||
echo '<!doctype html><html><head><title>Default Site</title></head><body><h1>Congratulations!</h1><p>You\'ve successfully started the Nginx Proxy Manager.</p><p>If you\'re seeing this site then you\'re trying to access a host that isn\'t set up yet.</p></body></html>';
|
||||
return;
|
||||
}
|
||||
if (($_GET['page'] ?? '') === 'neutral') {
|
||||
echo '<html><head><title>Start</title></head><body><p>You\'ve successfully started the Nginx Proxy Manager.</p></body></html>';
|
||||
return;
|
||||
}
|
||||
if (($_GET['page'] ?? '') === 'default') {
|
||||
echo '<html><head><title>Congratulations! You have successfully started</title></head><body>Willkommen</body></html>';
|
||||
return;
|
||||
}
|
||||
echo $c === 200 ? '<html><head><title>Meine Anwendung</title></head><body>Willkommen beim Test-Server</body></html>' : 'Fehler';
|
||||
ROUTER);
|
||||
$port = 0;
|
||||
for ($try = 0; $try < 20 && !$port; $try++) {
|
||||
$p = random_int(20000, 60000);
|
||||
$s = @stream_socket_server("tcp://127.0.0.1:{$p}");
|
||||
if ($s) { fclose($s); $port = $p; }
|
||||
}
|
||||
$proc = $port ? @proc_open([PHP_BINARY, '-S', "127.0.0.1:{$port}", $router], [0 => ['pipe', 'r'], 1 => ['file', sys_get_temp_dir() . '/ue-srv-out.txt', 'w'], 2 => ['file', sys_get_temp_dir() . '/ue-srv-err.txt', 'w']], $pipes) : false;
|
||||
$up = false;
|
||||
if ($proc) {
|
||||
for ($i = 0; $i < 40 && !$up; $i++) { usleep(100000); $c = @stream_socket_client("tcp://127.0.0.1:{$port}", $en, $es, 0.3); if ($c) { fclose($c); $up = true; } }
|
||||
}
|
||||
if ($up) {
|
||||
$base = ['id' => 1, 'type' => 'http', 'expect_text' => '', 'timeout_sec' => 5, 'target' => ''];
|
||||
$ok = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
|
||||
$check($ok['ok'] && $ok['ms'] >= 0 && $ok['cert_expires'] === null, 'echte Prüfung: Webseite erreichbar: ' . json_encode($ok));
|
||||
$e500 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||
$check(!$e500['ok'] && str_contains((string) $e500['error'], '503'), 'echte Prüfung: HTTP 503 wird erkannt');
|
||||
$txt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Willkommen'] + $base);
|
||||
$check($txt['ok'], 'echte Prüfung: erwarteter Text vorhanden');
|
||||
$noTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
|
||||
$check(!$noTxt['ok'] && str_contains((string) $noTxt['error'], 'Text'), 'echte Prüfung: fehlender Text wird erkannt');
|
||||
$def = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
|
||||
$check(!$def['ok'] && str_contains((string) $def['error'], 'Standard- oder Fehlerseite') && str_contains((string) $def['error'], 'Congratulations'), 'echte Prüfung: Standardseite mit 200 gilt als Ausfall: ' . ($def['error'] ?? ''));
|
||||
$npm = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm"] + $base);
|
||||
$check(!$npm['ok'] && str_contains((string) $npm['error'], 'Default Site') && ($npm['status'] ?? 0) === 200, 'echte Prüfung: Standardseite des Nginx Proxy Managers („Default Site“, Status 200) gilt als Ausfall: ' . ($npm['error'] ?? ''));
|
||||
$neutral = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=neutral"] + $base);
|
||||
$check(!$neutral['ok'] && str_contains((string) $neutral['error'], 'Standardseite') && str_contains((string) $neutral['error'], 'Nginx Proxy Manager'), 'echte Prüfung: erkannt auch am Text der Seite, wenn der Titel nichtssagend ist');
|
||||
$npmTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=npm", 'expect_text' => 'Congratulations'] + $base);
|
||||
$check($npmTxt['ok'], 'mit erwartetem Text wird nichts als Standardseite gewertet');
|
||||
$defTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default", 'expect_text' => 'Willkommen'] + $base);
|
||||
$check($defTxt['ok'], 'mit erwartetem Text entfällt die Erkennung von Standardseiten');
|
||||
$check(($ok['title'] ?? '') === 'Meine Anwendung' && ($ok['status'] ?? 0) === 200, 'Titel und Status werden mitgeliefert');
|
||||
$GLOBALS['ueberwachung_no_curl'] = true;
|
||||
$defS = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?page=default"] + $base);
|
||||
unset($GLOBALS['ueberwachung_no_curl']);
|
||||
$check(!$defS['ok'] && str_contains((string) $defS['error'], 'Standard- oder Fehlerseite'), 'dasselbe ohne cURL');
|
||||
$pOk = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}"] + $base);
|
||||
$check($pOk['ok'], 'echte Prüfung: offener Port');
|
||||
// Rückfall ohne cURL (reine PHP-Streams) liefert dieselben Ergebnisse
|
||||
$GLOBALS['ueberwachung_no_curl'] = true;
|
||||
$sOk = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/"] + $base);
|
||||
$s503 = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||
$sTxt = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'expect_text' => 'Nicht da'] + $base);
|
||||
unset($GLOBALS['ueberwachung_no_curl']);
|
||||
$check($sOk['ok'] && !$s503['ok'] && str_contains((string) $s503['error'], '503') && !$sTxt['ok'] && str_contains((string) $sTxt['error'], 'Text'), 'ohne cURL (Streams): erreichbar, 503 und fehlender Text werden richtig erkannt');
|
||||
$viaCurl = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/?code=503"] + $base);
|
||||
$check(($viaCurl['status'] ?? 0) === 503, 'mit cURL steht der HTTP-Status im Ergebnis');
|
||||
proc_terminate($proc);
|
||||
proc_close($proc);
|
||||
usleep(300000);
|
||||
$pClosed = ueberwachung_probe(['type' => 'port', 'target' => "127.0.0.1:{$port}", 'timeout_sec' => 2] + $base);
|
||||
$check(!$pClosed['ok'] && str_contains((string) $pClosed['error'], 'Nicht erreichbar'), 'echte Prüfung: geschlossener Port wird erkannt');
|
||||
$gone = ueberwachung_probe(['target' => "http://127.0.0.1:{$port}/", 'timeout_sec' => 2] + $base);
|
||||
$check(!$gone['ok'] && str_contains((string) $gone['error'], 'Nicht erreichbar'), 'echte Prüfung: nicht erreichbare Webseite wird erkannt');
|
||||
} else {
|
||||
fwrite(STDERR, "Hinweis: Lokaler Testserver konnte nicht gestartet werden, echte Prüfungen übersprungen.\n");
|
||||
if ($proc) { proc_terminate($proc); proc_close($proc); }
|
||||
}
|
||||
@unlink($router);
|
||||
@unlink(sys_get_temp_dir() . '/ue-srv-out.txt');
|
||||
@unlink(sys_get_temp_dir() . '/ue-srv-err.txt');
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Überwachung plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft den echten Cronjob der Überwachung (plugins/ueberwachung/cron.php): läuft nur auf der Kommandozeile, prüft fällige Ziele und
|
||||
* meldet den Lauf, sodass die Seite den Hintergrunddienst als „läuft“ anzeigen kann.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-uc-' . bin2hex(random_bytes(4));
|
||||
$db = sys_get_temp_dir() . '/deskhub-uc-' . bin2hex(random_bytes(4)) . '.sqlite';
|
||||
mkdir($pluginDir, 0755, true);
|
||||
$env = ['DESKHUB_PLUGINS_DIR' => $pluginDir, 'DESKHUB_TESTING' => '1', 'DESKHUB_CATALOG_DEFAULTS' => __DIR__ . '/fixtures/no-catalog.php', 'DESKHUB_SQLITE_PATH' => $db, 'DESKHUB_BOOTSTRAP' => dirname(__DIR__) . '/includes/bootstrap.php'];
|
||||
foreach ($env as $k => $v) putenv("{$k}={$v}");
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('ueberwachung', 'plugin_ueberwachung_cron');
|
||||
$zipPath = sys_get_temp_dir() . '/uc-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
$check(plugin_install_zip($pdo, $zipPath)['ok'], 'Plugin lässt sich installieren');
|
||||
$check(plugin_set_active($pdo, 'ueberwachung', true, ['hooks.cron', 'ui.menu', 'db.migrations'])['ok'], 'Plugin lässt sich aktivieren');
|
||||
$check(is_file($pluginDir . '/ueberwachung/cron.php'), 'cron.php liegt im Plugin');
|
||||
|
||||
// Ein Ziel, das nicht erreichbar ist (geschlossener Port)
|
||||
$closed = 0;
|
||||
for ($i = 0; $i < 20 && !$closed; $i++) {
|
||||
$p = random_int(20000, 60000);
|
||||
$s = @stream_socket_server("tcp://127.0.0.1:{$p}");
|
||||
if ($s) { fclose($s); $closed = $p; }
|
||||
}
|
||||
$pdo->prepare("INSERT INTO plugin_ueberwachung_monitors (name, type, target, timeout_sec, created_at) VALUES ('Test', 'port', ?, 2, ?)")->execute(["127.0.0.1:{$closed}", db_now()]);
|
||||
|
||||
$php = escapeshellarg(PHP_BINARY);
|
||||
$cron = escapeshellarg($pluginDir . '/ueberwachung/cron.php');
|
||||
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||
$check($code === 0 && str_contains(implode("\n", $out), '1 Ziel(e) geprüft'), 'Cronjob prüft das fällige Ziel: ' . implode(' | ', $out));
|
||||
$m = $pdo->query('SELECT * FROM plugin_ueberwachung_monitors')->fetch();
|
||||
$check($m['last_check_at'] !== null && (int) $m['fails'] === 1 && str_contains((string) $m['last_error'], 'Nicht erreichbar'), 'Ergebnis wurde gespeichert (Fehlschlag Nr. 1)');
|
||||
$tick = (int) $pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'plugin.ueberwachung.last_tick'")->fetchColumn();
|
||||
$check($tick > time() - 60, 'Zeitpunkt des Laufs ist vermerkt (für die Anzeige auf der Seite)');
|
||||
unset($out);
|
||||
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||
$check($code === 0 && str_contains(implode("\n", $out), '0 Ziel(e) geprüft'), 'ein zweiter Aufruf direkt danach prüft nichts (noch nicht fällig)');
|
||||
|
||||
// Deaktiviert: klare Meldung statt Fehler im Hintergrund
|
||||
plugin_set_active($pdo, 'ueberwachung', false);
|
||||
unset($out);
|
||||
exec("{$php} {$cron} 2>&1", $out, $code);
|
||||
$check($code === 1 && str_contains(implode("\n", $out), 'nicht aktiv'), 'deaktiviertes Plugin: Hinweis und Exit-Code 1');
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
@unlink($db);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Überwachung cron checks passed.' . "\n";
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft, dass ein Update eines aktiven Plugins seine neuen Datenbank-Änderungen automatisch einspielt (vor dem Laden des Plugins),
|
||||
* dass fehlerhafte Änderungen sichtbar werden statt still zu scheitern, und die Meldungen nach Installation und Update.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-um-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
$mkZip = function (string $version, array $migrations, array $perms = ['db.migrations', 'hooks.cron']) use ($pluginDir): string {
|
||||
$p = sys_get_temp_dir() . '/um-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->open($p, ZipArchive::CREATE);
|
||||
$z->addFromString('updprobe/plugin.json', json_encode(['slug' => 'updprobe', 'name' => 'Update-Probe', 'version' => $version, 'permissions' => $perms]));
|
||||
// Das Plugin liest beim Laden die neue Spalte: ohne eingespielte Änderung käme es zu einem Fehler
|
||||
$z->addFromString('updprobe/plugin.php', "<?php\nplugin_add_hook('cron.run', function () {});\n\$GLOBALS['updprobe_loaded'] = (\$GLOBALS['updprobe_loaded'] ?? 0) + 1;\n");
|
||||
foreach ($migrations as $name => $sql) $z->addFromString('updprobe/migrations/' . $name, $sql);
|
||||
$z->close();
|
||||
return $p;
|
||||
};
|
||||
$reboot = function (): void {
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
};
|
||||
$cols = fn() => array_column($pdo->query('PRAGMA table_info(plugin_updprobe_items)')->fetchAll(), 'name');
|
||||
|
||||
// Version 1 mit Tabelle
|
||||
$v1 = $mkZip('1.0.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)', '001_items.mysql.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)']);
|
||||
$r = plugin_install_zip($pdo, $v1);
|
||||
$check($r['ok'] && !$r['updated'], 'Version 1 lässt sich installieren');
|
||||
$check(plugin_install_message($r, plugins_installed($pdo)['updprobe']) === 'Update-Probe 1.0.0 installiert (nicht signiert). Es ist noch nicht aktiv.', 'Meldung bei Neuinstallation: ' . plugin_install_message($r, plugins_installed($pdo)['updprobe']));
|
||||
$check(plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron'])['ok'], 'Version 1 lässt sich aktivieren');
|
||||
$check($cols() === ['id', 'name'], 'Tabelle von Version 1 ist da');
|
||||
$reboot();
|
||||
$check(($GLOBALS['updprobe_loaded'] ?? 0) === 1, 'Plugin wird geladen');
|
||||
$hash1 = $pdo->query("SELECT package_hash FROM plugins WHERE slug = 'updprobe'")->fetchColumn();
|
||||
$check(get_setting('plugin_migrated.updprobe', '') === $hash1, 'Paketstand von Version 1 ist als eingespielt vermerkt');
|
||||
$migrationsBefore = (int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn();
|
||||
|
||||
// Version 2 bringt eine neue Spalte mit; das Plugin bleibt aktiv
|
||||
$v2 = $mkZip('1.1.0', [
|
||||
'001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)',
|
||||
'001_items.mysql.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)',
|
||||
'002_extra.sqlite.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra TEXT NULL',
|
||||
'002_extra.mysql.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra VARCHAR(50) NULL',
|
||||
]);
|
||||
$r2 = plugin_install_zip($pdo, $v2);
|
||||
$row = plugins_installed($pdo)['updprobe'];
|
||||
$check($r2['ok'] && $r2['updated'] && (int) $row['is_active'] === 1, 'Update eines aktiven Plugins: bleibt aktiv');
|
||||
$msg = plugin_install_message($r2, $row);
|
||||
$check(str_contains($msg, 'auf Version 1.1.0 aktualisiert') && str_contains($msg, 'läuft weiter') && !str_contains($msg, 'noch nicht aktiv'), 'Meldung beim Update eines aktiven Plugins: ' . $msg);
|
||||
$check(!in_array('extra', $cols(), true), 'unmittelbar nach dem Update ist die neue Spalte noch nicht da (läuft beim nächsten Aufruf)');
|
||||
$reboot();
|
||||
$check(in_array('extra', $cols(), true), 'beim nächsten Aufruf wird die neue Spalte automatisch eingespielt');
|
||||
$hash2 = $pdo->query("SELECT package_hash FROM plugins WHERE slug = 'updprobe'")->fetchColumn();
|
||||
$check($hash2 !== $hash1 && get_setting('plugin_migrated.updprobe', '') === $hash2, 'Paketstand von Version 2 ist vermerkt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn() === $migrationsBefore + 1, 'nur die neue Änderung wurde eingespielt');
|
||||
$reboot();
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_updprobe_%'")->fetchColumn() === $migrationsBefore + 1, 'weitere Aufrufe spielen nichts mehr ein');
|
||||
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug = 'updprobe'")->fetchColumn() === 0, 'keine Fehler gezählt');
|
||||
|
||||
// Version 3 mit kaputter Änderung: Fehler wird sichtbar, das Plugin wird nicht stillschweigend weiterbenutzt
|
||||
$v3 = $mkZip('1.2.0', [
|
||||
'001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)',
|
||||
'002_extra.sqlite.sql' => 'ALTER TABLE plugin_updprobe_items ADD COLUMN extra TEXT NULL',
|
||||
'003_kaputt.sqlite.sql' => 'ALTER TABLE plugin_gibt_es_nicht ADD COLUMN x TEXT NULL',
|
||||
]);
|
||||
plugin_install_zip($pdo, $v3);
|
||||
$reboot();
|
||||
$p = plugins_installed($pdo)['updprobe'];
|
||||
$check((int) $p['fail_count'] >= 1 && str_contains((string) $p['last_error'], 'Datenbank-Änderung fehlgeschlagen'), 'fehlerhafte Änderung wird gemeldet: ' . $p['last_error']);
|
||||
$check(get_setting('plugin_migrated.updprobe', '') === $hash2, 'der kaputte Stand gilt nicht als eingespielt (nächster Versuch beim nächsten Aufruf)');
|
||||
|
||||
// Update mit neuer Berechtigung (nicht signiert): Plugin wird angehalten, Meldung sagt es
|
||||
$v4 = $mkZip('1.3.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)'], ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||
plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron']);
|
||||
$r4 = plugin_install_zip($pdo, $v4);
|
||||
$check($r4['needs_reapproval'] === ['ui.menu'] && (int) plugins_installed($pdo)['updprobe']['is_active'] === 0, 'nicht signiertes Update mit neuer Berechtigung wird angehalten');
|
||||
$check(str_contains(plugin_install_message($r4, plugins_installed($pdo)['updprobe']), 'zusätzliche Berechtigungen') && str_contains(plugin_install_message($r4, plugins_installed($pdo)['updprobe']), 'nicht aktiv'), 'Meldung nennt die zusätzlichen Berechtigungen und dass das Plugin nicht aktiv ist');
|
||||
// Update eines inaktiven Plugins ohne neue Berechtigungen
|
||||
plugin_set_active($pdo, 'updprobe', true, ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||
plugin_set_active($pdo, 'updprobe', false);
|
||||
$v5 = $mkZip('1.4.0', ['001_items.sqlite.sql' => 'CREATE TABLE IF NOT EXISTS plugin_updprobe_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)'], ['db.migrations', 'hooks.cron', 'ui.menu']);
|
||||
$r5 = plugin_install_zip($pdo, $v5);
|
||||
$check(str_contains(plugin_install_message($r5, plugins_installed($pdo)['updprobe']), 'weiterhin nicht aktiv'), 'Meldung beim Update eines inaktiven Plugins');
|
||||
|
||||
// Ohne Berechtigung db.migrations kein automatisches Einspielen
|
||||
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/plugins.php'), "plugin_ensure_migrated(\$slug, (string) (\$row['package_hash'] ?? ''));\n") || str_contains((string) file_get_contents(dirname(__DIR__) . '/includes/plugins.php'), "in_array('db.migrations', \$manifest['permissions'], true)) plugin_ensure_migrated"), 'nur Plugins mit der Berechtigung db.migrations bekommen automatische Änderungen');
|
||||
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
foreach ([$v1, $v2, $v3, $v4, $v5] as $z) @unlink($z);
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Plugin update migration checks passed.' . "\n";
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Urlaub und Vertretung": Eingabeprüfung, Beginn (Tickets an die Vertretung, Urlaubsstatus), neue Tickets,
|
||||
* Ketten, Ende (Rückgabe nur der noch offenen und noch bei der Vertretung liegenden Tickets), Abbruch und Rechte.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-vt-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('vertretung', 'plugin_vertretung');
|
||||
$zipPath = sys_get_temp_dir() . '/vt-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'vertretung', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$act = plugin_set_active($pdo, 'vertretung', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(count(plugin_menu_items(['id' => 1, 'role' => 'agent'])) === 1, 'Menüpunkt ist für jeden Mitarbeiter sichtbar');
|
||||
|
||||
$mkStaff = function (string $name, string $role = 'agent') use ($pdo): int {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)')
|
||||
->execute([$name, strtolower($name) . '@team.example', 'x', $role, db_now()]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$anna = $mkStaff('Anna'); $ben = $mkStaff('Ben'); $cara = $mkStaff('Cara'); $adm = $mkStaff('Chef', 'admin');
|
||||
$actor = fn(int $id, string $role = 'agent') => ['id' => $id, 'role' => $role];
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$n = 0;
|
||||
$mk = function (int $assigned, string $status = 'open') use ($pdo, $dept, $prio, &$n): int {
|
||||
$n++;
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute(['VT-' . $n, 'Anfrage ' . $n, 'Kunde', 'kunde@x.example', $dept, $prio, $status, 'web', $assigned ?: null, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
$owner = fn(int $t) => (int) $pdo->query("SELECT assigned_staff_id FROM tickets WHERE id = {$t}")->fetchColumn();
|
||||
$flag = fn(int $s) => $pdo->query("SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = {$s}")->fetch();
|
||||
$day = fn(int $offset) => date('Y-m-d', strtotime('2026-07-15 12:00:00') + $offset * 86400);
|
||||
$GLOBALS['vertretung_today'] = $day(0);
|
||||
|
||||
// ── Eingabeprüfung und Rechte ──────────────────────────────────────────────
|
||||
$bad = fn($res) => !$res['ok'];
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(3), $day(1), '')), 'Ende vor Beginn wird abgelehnt');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, $day(-5), $day(-2), '')), 'Zeitraum in der Vergangenheit wird abgelehnt');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $anna, $day(1), $day(2), '')), 'man kann sich nicht selbst vertreten');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, 9999, $day(1), $day(2), '')), 'unbekannte Vertretung wird abgelehnt');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $ben, '2026-02-30', $day(2), '')), 'ungültiges Datum wird abgelehnt');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $ben, $cara, $day(1), $day(2), '')), 'Mitarbeiter tragen keine fremde Abwesenheit ein');
|
||||
$pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$cara]);
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(1), $day(2), '')), 'inaktive Vertretung wird abgelehnt');
|
||||
$pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$cara]);
|
||||
|
||||
// ── Geplant, dann Beginn ───────────────────────────────────────────────────
|
||||
$t1 = $mk($anna); $t2 = $mk($anna, 'pending'); $tDone = $mk($anna, 'resolved'); $tBen = $mk($ben); $tNobody = $mk(0);
|
||||
$plan = vertretung_create($pdo, $actor($anna), $anna, $ben, $day(2), $day(6), 'Urlaub');
|
||||
$check($plan['ok'], 'Anna trägt ihre eigene Abwesenheit ein');
|
||||
$check($owner($t1) === $anna && (int) $flag($anna)['is_on_vacation'] === 0, 'vor dem Beginn ändert sich nichts');
|
||||
$check($bad(vertretung_create($pdo, $actor($anna), $anna, $cara, $day(4), $day(8), '')), 'überschneidende Abwesenheit wird abgelehnt');
|
||||
vertretung_tick($pdo);
|
||||
$check($owner($t1) === $anna, 'Tick vor dem Beginn ändert nichts');
|
||||
|
||||
$GLOBALS['vertretung_today'] = $day(2);
|
||||
vertretung_tick($pdo);
|
||||
$check($owner($t1) === $ben && $owner($t2) === $ben, 'zum Beginn gehen die offenen Tickets an die Vertretung');
|
||||
$check($owner($tDone) === $anna, 'gelöste Tickets bleiben bei Anna');
|
||||
$check($owner($tBen) === $ben && $owner($tNobody) === 0, 'andere Tickets bleiben unberührt');
|
||||
$f = $flag($anna);
|
||||
$check((int) $f['is_on_vacation'] === 1 && (int) $f['vacation_substitute_id'] === $ben, 'Urlaubsstatus und Vertretung im Profil sind gesetzt');
|
||||
$check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$t1} AND action LIKE '%Vertretung Ben%'")->fetchColumn() === 1, 'Aktivität im Ticket vermerkt');
|
||||
vertretung_tick($pdo);
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves')->fetchColumn() === 2, 'zweiter Tick verteilt nicht noch einmal');
|
||||
|
||||
// neue Tickets für Anna
|
||||
$tNew = $mk($anna);
|
||||
webhook_dispatch($pdo, 'ticket.created', $tNew);
|
||||
$check($owner($tNew) === $ben, 'neues Ticket für die Abwesende geht an die Vertretung');
|
||||
$tManual = $mk($anna);
|
||||
webhook_dispatch($pdo, 'ticket.assigned', $tManual);
|
||||
$check($owner($tManual) === $ben, 'von Hand an die Abwesende zugewiesen geht ebenfalls weiter');
|
||||
$tAnyone = $mk($cara);
|
||||
webhook_dispatch($pdo, 'ticket.created', $tAnyone);
|
||||
$check($owner($tAnyone) === $cara, 'Tickets anderer Personen bleiben');
|
||||
|
||||
// automatische Zuweisung der Abteilung berücksichtigt den Urlaubsstatus bereits
|
||||
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'staff', auto_assign_staff_id = ? WHERE id = ?")->execute([$anna, $dept]);
|
||||
$check(department_auto_assignee($pdo, $dept) === $ben, 'automatische Zuweisung der Abteilung nimmt die Vertretung');
|
||||
$pdo->prepare("UPDATE departments SET auto_assign_mode = 'none' WHERE id = ?")->execute([$dept]);
|
||||
|
||||
// Kette: Ben ist selbst abwesend mit Vertretung Cara
|
||||
$chain = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $cara, $day(2), $day(4), 'Kette');
|
||||
$check($chain['ok'], 'Administrator trägt die Abwesenheit eines anderen ein');
|
||||
vertretung_tick($pdo);
|
||||
$check($owner($t1) === $cara && $owner($tBen) === $cara, 'Bens Tickets (auch von Anna übernommene) gehen weiter an Cara');
|
||||
$check(vertretung_target($pdo, $anna) === $cara, 'die Kette führt von Anna über Ben zu Cara');
|
||||
$tChain = $mk($anna);
|
||||
webhook_dispatch($pdo, 'ticket.created', $tChain);
|
||||
$check($owner($tChain) === $cara, 'neues Ticket für Anna landet am Ende der Kette');
|
||||
|
||||
// ── Ende von Ben: seine Tickets kommen zurück (Cara hatte sie nur übernommen) ──
|
||||
$GLOBALS['vertretung_today'] = $day(5);
|
||||
vertretung_tick($pdo);
|
||||
$check((int) $flag($ben)['is_on_vacation'] === 0, 'Bens Urlaubsstatus ist zurückgenommen');
|
||||
$check($owner($tBen) === $ben, 'Bens eigenes Ticket ist wieder bei ihm');
|
||||
$check($owner($t1) === $ben && $owner($tChain) === $ben, 'auch die von Anna übernommenen liegen wieder bei Ben (dort ist die Abwesenheit von Anna noch aktiv)');
|
||||
$st = $pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $chain['id'])->fetchColumn();
|
||||
$check($st === 'done', 'Abwesenheit steht auf „Beendet“');
|
||||
|
||||
// ── Ende von Anna: nur offene, noch bei der Vertretung liegende Tickets kommen zurück ──
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed' WHERE id = ?")->execute([$t2]); // erledigt während der Abwesenheit
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$cara, $tManual]); // von Hand umverteilt
|
||||
$GLOBALS['vertretung_today'] = $day(7);
|
||||
vertretung_tick($pdo);
|
||||
$check((int) $flag($anna)['is_on_vacation'] === 0 && $flag($anna)['vacation_substitute_id'] === null, 'Annas Urlaubsstatus ist zurückgenommen');
|
||||
$check($owner($t1) === $anna && $owner($tNew) === $anna && $owner($tChain) === $anna, 'offene Tickets sind wieder bei Anna');
|
||||
$check($owner($t2) === $ben, 'geschlossenes Ticket bleibt, wo es war');
|
||||
$check($owner($tManual) === $cara, 'von Hand umverteiltes Ticket bleibt bei der neuen Person');
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_vertretung_moves WHERE returned_at IS NULL')->fetchColumn() === 0, 'keine offenen Rückgaben mehr');
|
||||
$check($pdo->query("SELECT status FROM plugin_vertretung_absences WHERE id = " . (int) $plan['id'])->fetchColumn() === 'done', 'Annas Abwesenheit steht auf „Beendet“');
|
||||
|
||||
// ── Beginn am selben Tag, Abbruch, nicht angetreten, Rechte ────────────────
|
||||
$tX = $mk($cara);
|
||||
$now1 = vertretung_create($pdo, $actor($cara), $cara, $anna, $day(7), $day(20), '');
|
||||
$check($now1['ok'] && $owner($tX) === $anna, 'Beginn heute: Tickets gehen sofort weiter');
|
||||
$check(!vertretung_cancel($pdo, $actor($ben), (int) $now1['id']), 'fremde Abwesenheit kann nicht beendet werden');
|
||||
$check(vertretung_cancel($pdo, $actor($cara), (int) $now1['id']) && $owner($tX) === $cara && (int) $flag($cara)['is_on_vacation'] === 0, 'Abbruch gibt die Tickets zurück und nimmt den Status zurück');
|
||||
$later = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(10), $day(12), '');
|
||||
$check($later['ok'] && vertretung_cancel($pdo, $actor($adm, 'admin'), (int) $later['id']) && $pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $later['id'])->fetchColumn() === 'cancelled', 'geplante Abwesenheit lässt sich löschen');
|
||||
$ghost = vertretung_create($pdo, $actor($adm, 'admin'), $ben, $anna, $day(8), $day(9), '');
|
||||
$GLOBALS['vertretung_today'] = $day(15);
|
||||
vertretung_tick($pdo);
|
||||
$check($pdo->query('SELECT status FROM plugin_vertretung_absences WHERE id = ' . (int) $ghost['id'])->fetchColumn() === 'missed' && (int) $flag($ben)['is_on_vacation'] === 0, 'versäumter Zeitraum wird nicht mehr gestartet');
|
||||
|
||||
// Profil wurde zwischendurch von Hand geändert: Plugin nimmt fremde Einstellungen nicht zurück
|
||||
$GLOBALS['vertretung_today'] = $day(30);
|
||||
$pdo->exec('UPDATE plugin_vertretung_absences SET status = \'cancelled\' WHERE status IN (\'planned\',\'active\')');
|
||||
$c = vertretung_create($pdo, $actor($adm, 'admin'), $cara, $ben, $day(30), $day(31), '');
|
||||
$pdo->prepare('UPDATE staff SET vacation_substitute_id = ? WHERE id = ?')->execute([$anna, $cara]);
|
||||
$GLOBALS['vertretung_today'] = $day(32);
|
||||
vertretung_tick($pdo);
|
||||
$check((int) $flag($cara)['is_on_vacation'] === 1 && (int) $flag($cara)['vacation_substitute_id'] === $anna, 'von Hand geänderter Urlaubsstatus bleibt bestehen');
|
||||
|
||||
unset($GLOBALS['vertretung_today']);
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Vertretung plugin checks passed.' . "\n";
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft das Plugin "Anhänge-Virenscan": Installation, Prüfung über die eingebaute Testprüfung (EICAR), Sperren und Quarantäne,
|
||||
* Freigeben und Löschen, Fehler- und Größenbehandlung, Auswertung der ClamAV-Ausgabe, Zeitlimit des Prüfprogramms, Auslösung über die Ticket-Ereignisse.
|
||||
*/
|
||||
|
||||
$pluginDir = sys_get_temp_dir() . '/deskhub-vs-' . bin2hex(random_bytes(4));
|
||||
mkdir($pluginDir, 0755, true);
|
||||
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$pdo = get_db();
|
||||
|
||||
require_once __DIR__ . '/fixtures/paths.php';
|
||||
$src = plugin_source_dir_or_skip('virenscan', 'plugin_virenscan');
|
||||
$zipPath = sys_get_temp_dir() . '/vs-' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
|
||||
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$r = plugin_install_zip($pdo, $zipPath);
|
||||
$check($r['ok'] && $r['slug'] === 'virenscan', 'Plugin lässt sich installieren: ' . json_encode($r));
|
||||
$check(!plugin_set_active($pdo, 'virenscan', true)['ok'], 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
|
||||
$act = plugin_set_active($pdo, 'virenscan', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']);
|
||||
$check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act));
|
||||
$state = &plugin_state();
|
||||
$state['booted'] = false;
|
||||
plugins_boot();
|
||||
$check(isset(plugin_state()['loaded']['virenscan']), 'Plugin wird geladen');
|
||||
$check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt steht im Adminportal');
|
||||
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt steht nicht im Hauptmenü');
|
||||
$check(count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Mitarbeiter sehen den Menüpunkt nicht');
|
||||
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files')->fetchColumn() === 0, 'Tabelle wurde angelegt');
|
||||
|
||||
// Testprüfung erzwingen (ClamAV muss für den Test nicht installiert sein)
|
||||
plugin_set_setting('virenscan', 'mode', 'builtin');
|
||||
$st = virenscan_status();
|
||||
$check($st['mode'] === 'builtin' && $st['ok'] === false && str_contains($st['message'], 'ClamAV'), 'ohne ClamAV: Hinweis, dass nur die Testprüfung läuft');
|
||||
|
||||
// ── Daten ──────────────────────────────────────────────────────────────────
|
||||
$uploads = virenscan_uploads_dir();
|
||||
$created = [];
|
||||
$now = db_now();
|
||||
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
|
||||
$pdo->prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('VS-1','Test','K','k@example.test',?,?, 'open','web',?,?)")->execute([$dept, $prio, $now, $now]);
|
||||
$ticketId = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', 'Anhang anbei', 0, ?)")->execute([$ticketId, $now]);
|
||||
$threadId = (int) $pdo->lastInsertId();
|
||||
$attach = function (string $name, string $content) use ($pdo, $threadId, $uploads, &$created, $now): int {
|
||||
$stored = 'vstest-' . bin2hex(random_bytes(4)) . '.bin';
|
||||
file_put_contents($uploads . '/' . $stored, $content);
|
||||
$created[] = $stored;
|
||||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$threadId, $name, $stored, strlen($content), $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
};
|
||||
|
||||
// Erster Lauf legt die Startmarke fest: schon vorhandene Anhänge werden nicht nachgeprüft
|
||||
$old = $attach('alt.bin', virenscan_eicar());
|
||||
$check(virenscan_tick($pdo) === 0, 'Bestand vor dem Einschalten wird nicht geprüft');
|
||||
$check(virenscan_start_id($pdo) === $old, 'Startmarke steht auf dem letzten vorhandenen Anhang');
|
||||
|
||||
// ── Prüfung und Quarantäne ─────────────────────────────────────────────────
|
||||
$clean = $attach('rechnung.pdf', "%PDF-1.4 harmlos\n");
|
||||
$bad = $attach('lebenslauf.pdf.exe', 'MZ' . str_repeat('x', 50) . virenscan_eicar());
|
||||
$n = virenscan_scan_ticket($pdo, $ticketId);
|
||||
$check($n === 2, 'Beide neuen Anhänge werden geprüft (' . $n . ')');
|
||||
$rows = $pdo->query('SELECT attachment_id, status, quarantined, signature FROM plugin_virenscan_files ORDER BY attachment_id')->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC);
|
||||
$check(($rows[$clean]['status'] ?? '') === 'clean' && (int) $rows[$clean]['quarantined'] === 0, 'saubere Datei bleibt unverändert');
|
||||
$check(($rows[$bad]['status'] ?? '') === 'infected' && (int) $rows[$bad]['quarantined'] === 1 && ($rows[$bad]['signature'] ?? '') === 'Eicar-Test-Signature', 'verseuchte Datei wird erkannt und gesperrt');
|
||||
$badRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch();
|
||||
$check($badRow['stored_name'] === 'gesperrt' && str_starts_with($badRow['original_name'], '[GESPERRT] '), 'Anhang ist umbenannt und nicht mehr herunterladbar');
|
||||
$cleanRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$clean}")->fetch();
|
||||
$check($cleanRow['original_name'] === 'rechnung.pdf' && is_file($uploads . '/' . $cleanRow['stored_name']), 'saubere Datei liegt unverändert da');
|
||||
$badStored = $created[array_search(true, array_map(fn($c) => !is_file($uploads . '/' . $c), $created), true)] ?? '';
|
||||
$check($badStored !== '' && is_file($uploads . '/quarantaene/' . $badStored), 'verseuchte Datei liegt in der Quarantäne');
|
||||
$check(!is_file($uploads . '/' . $badStored), 'verseuchte Datei ist aus dem Upload-Ordner verschwunden');
|
||||
$check(is_file($uploads . '/quarantaene/.htaccess'), 'Quarantäne-Ordner ist vor Web-Zugriff geschützt');
|
||||
$note = $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ticketId} AND author_type = 'system' AND is_internal_note = 1 AND message LIKE '%Virenscan gesperrt%'")->fetchColumn();
|
||||
$check((int) $note === 1, 'Ticket bekommt eine interne Notiz');
|
||||
$act = $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$ticketId} AND action LIKE 'Anhang vom Virenscan gesperrt%'")->fetchColumn();
|
||||
$check((int) $act === 1, 'Aktivitätsverlauf nennt die Sperre');
|
||||
$tag = $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ticketId} AND g.name = 'Virus-Verdacht'")->fetchColumn();
|
||||
$check((int) $tag === 1, 'Ticket trägt den Tag „Virus-Verdacht“');
|
||||
$check(virenscan_scan_ticket($pdo, $ticketId) === 0, 'nichts wird doppelt geprüft');
|
||||
|
||||
// ── Freigeben und Löschen ──────────────────────────────────────────────────
|
||||
$q = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad}")->fetchColumn();
|
||||
$check(virenscan_release($pdo, $q), 'Freigeben klappt');
|
||||
$after = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch();
|
||||
$check($after['original_name'] === 'lebenslauf.pdf.exe' && $after['stored_name'] === $badStored && is_file($uploads . '/' . $badStored), 'freigegebene Datei ist wieder da, Name zurückgesetzt');
|
||||
$check(virenscan_stats($pdo)['released'] === 1 && virenscan_stats($pdo)['quarantined'] === 0, 'Zähler zeigen „freigegeben“');
|
||||
$check(!virenscan_release($pdo, $q), 'zweites Freigeben wird abgelehnt');
|
||||
$bad2 = $attach('zweite.exe', virenscan_eicar());
|
||||
virenscan_tick($pdo);
|
||||
$q2 = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad2}")->fetchColumn();
|
||||
$stored2 = $pdo->query("SELECT stored_name FROM plugin_virenscan_files WHERE id = {$q2}")->fetchColumn();
|
||||
$check(is_file($uploads . '/quarantaene/' . $stored2), 'Hintergrundlauf findet und sperrt weitere Dateien');
|
||||
$check(virenscan_delete($pdo, $q2) && !is_file($uploads . '/quarantaene/' . $stored2), 'Löschen entfernt die Datei endgültig');
|
||||
$check(str_starts_with((string) $pdo->query("SELECT original_name FROM ticket_attachments WHERE id = {$bad2}")->fetchColumn(), '[GELÖSCHT] '), 'Anhang im Ticket ist als gelöscht vermerkt');
|
||||
|
||||
// ── Sonderfälle ────────────────────────────────────────────────────────────
|
||||
$missing = $attach('weg.txt', 'x');
|
||||
@unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing}")->fetchColumn());
|
||||
virenscan_tick($pdo);
|
||||
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing}")->fetchColumn() === 'error', 'fehlende Datei: Fehler, nichts gesperrt');
|
||||
plugin_set_setting('virenscan', 'max_mb', '1');
|
||||
$big = $attach('gross.bin', str_repeat('A', 1200000));
|
||||
virenscan_tick($pdo);
|
||||
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$big}")->fetchColumn() === 'skipped', 'zu große Datei wird übersprungen');
|
||||
plugin_set_setting('virenscan', 'block_on_error', '1');
|
||||
$missing2 = $attach('weg2.txt', 'x');
|
||||
@unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing2}")->fetchColumn());
|
||||
virenscan_tick($pdo);
|
||||
$check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing2}")->fetchColumn() === 'error', 'fehlende Datei wird auch bei „bei Fehler sperren“ nur als Fehler vermerkt (nichts zum Sperren)');
|
||||
|
||||
// ── ClamAV-Ausgabe und Programmaufruf ──────────────────────────────────────
|
||||
$check(virenscan_interpret(0, '/tmp/a: OK', '')['status'] === 'clean', 'ClamAV Exit 0 = sauber');
|
||||
$inf = virenscan_interpret(1, "/tmp/a.exe: Win.Test.EICAR_HDB-1 FOUND\n", '');
|
||||
$check($inf['status'] === 'infected' && $inf['signature'] === 'Win.Test.EICAR_HDB-1', 'ClamAV Exit 1 = Fund mit Signaturname');
|
||||
$errR = virenscan_interpret(2, '', 'ERROR: Can\'t connect to clamd');
|
||||
$check($errR['status'] === 'error' && str_contains((string) $errR['message'], 'clamd'), 'ClamAV Exit 2 = Fehler mit Meldung');
|
||||
[$code, $out] = virenscan_run([PHP_BINARY, '-r', 'echo "hallo"; exit(3);'], 10);
|
||||
$check($code === 3 && $out === 'hallo', 'Programmaufruf liefert Exit-Code und Ausgabe');
|
||||
$t0 = microtime(true);
|
||||
[$code2, , $e2] = virenscan_run([PHP_BINARY, '-r', 'sleep(6);'], 1);
|
||||
$check($code2 === -1 && str_contains($e2, 'Zeitüberschreitung') && microtime(true) - $t0 < 4, 'Zeitlimit beendet hängende Prüfprogramme');
|
||||
|
||||
// ── Auslösung über die Ticket-Ereignisse ───────────────────────────────────
|
||||
plugin_set_setting('virenscan', 'max_mb', '25');
|
||||
$viaHook = $attach('per-hook.exe', virenscan_eicar());
|
||||
webhook_dispatch($pdo, 'ticket.replied', $ticketId, ['author' => 'customer', 'internal' => false]);
|
||||
$check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook}")->fetchColumn() == 1, 'Ereignis „Antwort“ löst die Prüfung aus');
|
||||
$viaHook2 = $attach('neu.exe', virenscan_eicar());
|
||||
webhook_dispatch($pdo, 'ticket.created', $ticketId);
|
||||
$check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook2}")->fetchColumn() == 1, 'Ereignis „Ticket erstellt“ löst die Prüfung aus');
|
||||
$check(is_string(virenscan_eicar()) && strlen(virenscan_eicar()) === 68, 'EICAR-Zeichenfolge ist die Standardzeichenfolge (68 Zeichen)');
|
||||
|
||||
// ── Eingebaute Dateianalyse ────────────────────────────────────────────────
|
||||
$tmpDir = sys_get_temp_dir() . '/vs-an-' . bin2hex(random_bytes(3));
|
||||
mkdir($tmpDir);
|
||||
$mk = function (string $name, string $content) use ($tmpDir): string {
|
||||
$p = $tmpDir . '/' . bin2hex(random_bytes(3));
|
||||
file_put_contents($p, $content);
|
||||
return $p;
|
||||
};
|
||||
$zipOf = function (array $entries, ?string $password = null) use ($tmpDir): string {
|
||||
$p = $tmpDir . '/' . bin2hex(random_bytes(3)) . '.zip';
|
||||
$z = new ZipArchive();
|
||||
$z->open($p, ZipArchive::CREATE);
|
||||
foreach ($entries as $n => $c) {
|
||||
$z->addFromString($n, $c);
|
||||
if ($password !== null) { $z->setEncryptionName($n, ZipArchive::EM_AES_256, $password); }
|
||||
}
|
||||
$z->close();
|
||||
return $p;
|
||||
};
|
||||
$verdict = function (string $path, string $name) use ($pdo): array {
|
||||
$r = virenscan_scan_file($path, $name);
|
||||
return [$r['status'], (string) $r['signature']];
|
||||
};
|
||||
plugin_set_setting('virenscan', 'mode', 'builtin');
|
||||
plugin_set_setting('virenscan', 'analysis', '1');
|
||||
foreach (['block_macros' => '1', 'block_encrypted' => '1', 'block_unscannable' => '0'] as $k => $v) plugin_set_setting('virenscan', $k, $v);
|
||||
$clean = fn(string $path, string $name) => $verdict($path, $name)[0] === 'clean';
|
||||
$infected = fn(string $path, string $name) => $verdict($path, $name)[0] === 'infected';
|
||||
|
||||
$check($clean($mk('a', 'Hallo, anbei die Rechnung.'), 'rechnung.txt'), 'Textdatei ist sauber');
|
||||
$check($clean($mk('a', "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF"), 'rechnung.pdf'), 'normales PDF ist sauber');
|
||||
$check($clean($mk('a', "\x89PNG\r\n\x1a\n" . str_repeat('x', 100)), 'foto.png'), 'Bild ist sauber');
|
||||
$check($clean($zipOf(['bericht.docx' => 'PK', 'notiz.txt' => 'hallo']), 'daten.zip'), 'harmloses Archiv ist sauber');
|
||||
$check($infected($mk('a', 'echo hi'), 'start.bat'), '.bat wird gesperrt');
|
||||
$check($infected($mk('a', 'x'), 'Rechnung.pdf.exe'), 'doppelte Endung mit .exe wird gesperrt');
|
||||
$check($infected($mk('a', 'x'), 'Rechnung.exe.pdf'), 'versteckte .exe vor der Endung wird erkannt');
|
||||
$check($infected($mk('a', 'x'), "Rechnung\u{202E}fdp.exe"), 'umgekehrte Schreibrichtung im Namen wird erkannt');
|
||||
$check($infected($mk('a', "MZ\x90\x00\x03" . str_repeat("\0", 60)), 'foto.jpg'), 'Programm als Bild getarnt wird erkannt');
|
||||
$check($infected($mk('a', "\x7fELF\x02\x01"), 'dokument.pdf'), 'Linux-Programm als PDF getarnt wird erkannt');
|
||||
$check($infected($zipOf(['readme.txt' => 'hi', 'setup.exe' => 'x']), 'update.zip'), 'ZIP mit .exe wird gesperrt');
|
||||
$check($infected($zipOf(['bilder/foto.jpg' => "MZ\x90\x00" . str_repeat('x', 30)]), 'fotos.zip'), 'ZIP mit getarntem Programm wird gesperrt');
|
||||
$check($infected($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP wird gesperrt');
|
||||
plugin_set_setting('virenscan', 'block_encrypted', '0');
|
||||
$check($clean($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP lässt sich erlauben');
|
||||
plugin_set_setting('virenscan', 'block_encrypted', '1');
|
||||
$check($infected($zipOf(['word/vbaProject.bin' => 'x', '[Content_Types].xml' => '<x/>']), 'angebot.docx'), 'Office-Datei mit vbaProject.bin wird gesperrt');
|
||||
$check($infected($mk('a', 'x'), 'angebot.docm'), 'makrofähige Endung wird gesperrt');
|
||||
$check($infected($mk('a', "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" . str_repeat("\0", 100) . '_VBA_PROJECT' . str_repeat("\0", 50)), 'tabelle.xls'), 'altes Office-Format mit VBA wird gesperrt');
|
||||
plugin_set_setting('virenscan', 'block_macros', '0');
|
||||
$check($clean($mk('a', 'x'), 'angebot.docm'), 'Makros lassen sich erlauben');
|
||||
plugin_set_setting('virenscan', 'block_macros', '1');
|
||||
$check($infected($mk('a', "%PDF-1.7\n<< /OpenAction << /S /JavaScript /JS (app.alert(1)) >> >>"), 'info.pdf'), 'PDF mit automatischem JavaScript wird gesperrt');
|
||||
$check($infected($mk('a', "%PDF-1.7\n<< /S /Launch /F (cmd.exe) >>"), 'info.pdf'), 'PDF mit /Launch wird gesperrt');
|
||||
$check($infected($mk('a', '<html><script>location="http://x"</script></html>'), 'zahlung.html'), 'HTML mit Skript wird gesperrt');
|
||||
$check($clean($mk('a', '<html><body>Hallo</body></html>'), 'seite.html'), 'HTML ohne Skript ist sauber');
|
||||
$check($infected($mk('a', '<svg onload="alert(1)"></svg>'), 'logo.svg'), 'SVG mit Skript wird gesperrt');
|
||||
$check($infected($mk('a', '{\\rtf1 {\\object {\\*\\objdata 0105}}}'), 'brief.rtf'), 'RTF mit eingebettetem Objekt wird gesperrt');
|
||||
$check($clean($mk('a', 'Rar!'), 'archiv.rar'), 'RAR ist standardmäßig erlaubt');
|
||||
plugin_set_setting('virenscan', 'block_unscannable', '1');
|
||||
$check($infected($mk('a', 'Rar!'), 'archiv.rar'), 'RAR lässt sich sperren');
|
||||
plugin_set_setting('virenscan', 'block_unscannable', '0');
|
||||
$check($verdict($mk('a', virenscan_eicar()), 'test.txt')[1] === 'Eicar-Test-Signature', 'EICAR wird mit Namen erkannt');
|
||||
plugin_set_setting('virenscan', 'analysis', '0');
|
||||
$check($clean($mk('a', 'x'), 'start.bat') && $infected($mk('a', virenscan_eicar()), 'test.txt'), 'ohne Dateianalyse bleibt nur die Testdatei');
|
||||
plugin_set_setting('virenscan', 'analysis', '1');
|
||||
|
||||
// ── Hash-Abfrage (VirusTotal), HTTP ersetzt ────────────────────────────────
|
||||
$vtCalls = [];
|
||||
$GLOBALS['virenscan_http'] = function (string $url, array $headers) use (&$vtCalls, &$vtAnswer): array {
|
||||
$vtCalls[] = ['url' => $url, 'headers' => $headers];
|
||||
return $vtAnswer;
|
||||
};
|
||||
$sha = str_repeat('a', 64);
|
||||
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 12, 'suspicious' => 0], 'popular_threat_classification' => ['suggested_threat_label' => 'trojan.agent']]]])];
|
||||
$r1 = virenscan_vt_lookup($sha, 'k', 3);
|
||||
$check($r1['result'] === 'bad' && str_contains((string) $r1['detail'], '12 Scanner') && str_contains((string) $r1['detail'], 'trojan.agent'), 'VirusTotal: bekannte Schadsoftware wird erkannt');
|
||||
$check(str_ends_with($vtCalls[0]['url'], '/files/' . $sha) && in_array('x-apikey: k', $vtCalls[0]['headers'], true), 'Abfrage enthält nur den Hash und den Schlüssel');
|
||||
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 1]]]])];
|
||||
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'clean', 'unter der Schwelle gilt als sauber');
|
||||
$vtAnswer = ['status' => 404, 'body' => ''];
|
||||
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'unknown', '404 = Datei unbekannt');
|
||||
$vtAnswer = ['status' => 429, 'body' => ''];
|
||||
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'limit', '429 = Abfragegrenze');
|
||||
$vtAnswer = ['status' => 401, 'body' => ''];
|
||||
$check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'key', '401 = Schlüssel ungültig');
|
||||
|
||||
// Hintergrundlauf: sauberer Anhang wird nachträglich gesperrt, wenn VirusTotal ihn kennt
|
||||
plugin_set_setting('virenscan', 'vt_key', encrypt_secret(str_repeat('b', 64)));
|
||||
$known = $attach('rechnung.pdf', "%PDF-1.4\nBekannte Schadsoftware " . bin2hex(random_bytes(4)));
|
||||
$vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 20]]]])];
|
||||
$vtCalls = [];
|
||||
virenscan_tick($pdo);
|
||||
$row = $pdo->query("SELECT status, quarantined, rep, sha256 FROM plugin_virenscan_files WHERE attachment_id = {$known}")->fetch();
|
||||
$check($row && $row['status'] === 'infected' && (int) $row['quarantined'] === 1 && $row['rep'] === 'bad' && strlen((string) $row['sha256']) === 64, 'Hintergrundlauf sperrt eine bei VirusTotal bekannte Datei: ' . json_encode($row));
|
||||
$check($pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$known}")->fetchColumn() === 'gesperrt', 'Anhang ist im Ticket gesperrt');
|
||||
$unknownAtt = $attach('neu.pdf', "%PDF-1.4\nUnbekannt " . bin2hex(random_bytes(4)));
|
||||
$vtAnswer = ['status' => 404, 'body' => ''];
|
||||
virenscan_tick($pdo);
|
||||
$check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() === 'unknown' && $pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() == 0, 'unbekannte Datei bleibt freigegeben und ist als geprüft vermerkt');
|
||||
$before = count($vtCalls);
|
||||
virenscan_tick($pdo);
|
||||
$check(count($vtCalls) === $before, 'bereits abgefragte Dateien werden nicht noch einmal abgefragt');
|
||||
$lim = $attach('limit.pdf', "%PDF-1.4\nLimit " . bin2hex(random_bytes(4)));
|
||||
$vtAnswer = ['status' => 429, 'body' => ''];
|
||||
virenscan_tick($pdo);
|
||||
$check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$lim}")->fetchColumn() === null, 'bei Abfragegrenze bleibt die Datei für den nächsten Lauf offen');
|
||||
$vtAnswer = ['status' => 401, 'body' => ''];
|
||||
virenscan_tick($pdo);
|
||||
$check(str_contains((string) plugin_setting('virenscan', 'vt_error', ''), 'abgelehnt'), 'ungültiger Schlüssel wird auf der Seite gemeldet');
|
||||
plugin_set_setting('virenscan', 'vt_key', '');
|
||||
$vtCalls = [];
|
||||
virenscan_reputation_tick($pdo);
|
||||
$check($vtCalls === [], 'ohne Schlüssel wird nichts abgefragt');
|
||||
unset($GLOBALS['virenscan_http']);
|
||||
foreach (glob($tmpDir . '/*') ?: [] as $f) @unlink($f);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
// Selbstheilung: fehlen nach einem Update die Spalten (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach
|
||||
$pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN rep');
|
||||
$pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN sha256');
|
||||
$pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_virenscan_002%'");
|
||||
virenscan_ensure_schema(true);
|
||||
$vsCols = array_column($pdo->query('PRAGMA table_info(plugin_virenscan_files)')->fetchAll(), 'name');
|
||||
$check(in_array('rep', $vsCols, true) && in_array('sha256', $vsCols, true), 'Selbstheilung: die fehlenden Spalten werden nachgeholt');
|
||||
// ── Reiter der Seite: Übersicht, Quarantäne, Geprüft, Einstellungen ───────
|
||||
$pagePath = $pluginDir . '/virenscan/pages/virenscan.php';
|
||||
$adminStaff = ['id' => 1, 'role' => 'admin', 'name' => 'Chef'];
|
||||
$render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string {
|
||||
$_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get;
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$staff = $who ?? $adminStaff;
|
||||
ob_start();
|
||||
include $pagePath;
|
||||
return (string) ob_get_clean();
|
||||
};
|
||||
$pdo->exec('DELETE FROM plugin_virenscan_files');
|
||||
$ins = $pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)');
|
||||
for ($i = 1; $i <= 120; $i++) $ins->execute([9000 + $i, null, "datei-{$i}.pdf", "s{$i}", 'clean', null, 'Dateianalyse', 0, date('Y-m-d H:i:s', time() - $i * 60)]);
|
||||
$ins->execute([9500, null, 'Rechnung.pdf.exe', 'q1', 'infected', 'Dateianalyse: Gefährlicher Dateityp (.exe)', null, 1, db_now()]);
|
||||
$ins->execute([9501, null, 'fehler.zip', 'q2', 'error', null, 'Zeitüberschreitung', 0, db_now()]);
|
||||
|
||||
$home = $render('');
|
||||
$check(str_contains($home, '<h3>Zustand</h3>') && str_contains($home, 'Prüfung testen (EICAR-Testdatei)') && !str_contains($home, 'Geprüfte Dateien') && !str_contains($home, 'Dateianalyse (braucht nichts auf dem Server)'), 'Standard-Reiter „Übersicht“: Zustand und Testknöpfe, aber weder Liste noch Einstellungen');
|
||||
$check(str_contains($home, 'tab=quarantaene') && str_contains($home, 'tab=geprueft') && str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=uebersicht'), 'Reiterleiste mit allen vier Reitern');
|
||||
$check(preg_match('#Quarantäne <span[^>]*>1</span>#', $home) === 1, 'der Reiter „Quarantäne“ zeigt die Anzahl (1)');
|
||||
$check(str_contains($home, '1 Datei(en) in Quarantäne prüfen'), 'die Übersicht verweist auf die Quarantäne');
|
||||
$quar = $render('quarantaene');
|
||||
$check(str_contains($quar, 'Rechnung.pdf.exe') && str_contains($quar, 'Freigeben') && str_contains($quar, 'Löschen') && !str_contains($quar, 'datei-1.pdf') && !str_contains($quar, '<h3>Zustand</h3>'), 'Reiter „Quarantäne“: nur gesperrte Dateien mit Freigeben und Löschen');
|
||||
$pdo->exec('UPDATE plugin_virenscan_files SET quarantined = 0');
|
||||
$check(str_contains($render('quarantaene'), 'Keine gesperrten Dateien'), 'leere Quarantäne zeigt einen ruhigen Hinweis');
|
||||
$pdo->exec("UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = 9500");
|
||||
$p1 = $render('geprueft');
|
||||
$check(str_contains($p1, 'Geprüfte Dateien') && substr_count($p1, 'datei-') === 48 && substr_count($p1, '<tr>') === 51 && str_contains($p1, 'Seite 1 von 3') && str_contains($p1, 'Ältere →') && !str_contains($p1, '← Neuere'), 'Reiter „Geprüft“: 50 Einträge pro Seite, Seite 1 von 3');
|
||||
$p3 = $render('geprueft', ['seite' => '3']);
|
||||
$check(str_contains($p3, 'Seite 3 von 3') && str_contains($p3, '← Neuere') && !str_contains($p3, 'Ältere →'), 'letzte Seite hat nur „Neuere“');
|
||||
$check(str_contains($render('geprueft', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird auf die letzte begrenzt');
|
||||
$inf = $render('geprueft', ['status' => 'infected']);
|
||||
$check(str_contains($inf, 'Rechnung.pdf.exe') && !str_contains($inf, 'datei-1.pdf') && !str_contains($inf, 'fehler.zip') && str_contains($inf, 'mit Ergebnis „Verseucht“'), 'Filter nach Ergebnis (Verseucht)');
|
||||
$check(str_contains($render('geprueft', ['status' => 'unsinn']), '(122)'), 'unbekannter Filter wird ignoriert (alle 122)');
|
||||
$set = $render('einstellungen');
|
||||
$check(str_contains($set, 'Dateianalyse (braucht nichts auf dem Server)') && str_contains($set, 'Hash-Abfrage bei VirusTotal') && str_contains($set, 'name="action" value="save"') && !str_contains($set, 'Geprüfte Dateien'), 'Reiter „Einstellungen“: nur das Formular');
|
||||
$check(str_contains($render('gibt-es-nicht'), '<h3>Zustand</h3>'), 'unbekannter Reiter fällt auf die Übersicht zurück');
|
||||
$check(str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'nur für Administratoren') && !str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'name="action" value="save"'), 'Mitarbeiter ohne Adminrecht sehen weder Reiter noch Formular');
|
||||
$check(str_contains(virenscan_tab_url('geprueft', ['status' => 'clean', 'seite' => 2]), 'tab=geprueft&status=clean&seite=2') && !str_contains(virenscan_tab_url('geprueft', ['seite' => 1]), 'seite'), 'Adressen der Reiter: Seite 1 und leere Filter werden weggelassen');
|
||||
|
||||
// ── Aufräumen ──────────────────────────────────────────────────────────────
|
||||
foreach ($created as $c) { @unlink($uploads . '/' . $c); @unlink($uploads . '/quarantaene/' . $c); }
|
||||
foreach (['.htaccess', 'index.html'] as $x) @unlink($uploads . '/quarantaene/' . $x);
|
||||
@rmdir($uploads . '/quarantaene');
|
||||
foreach (scandir($pluginDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
|
||||
}
|
||||
@rmdir($pluginDir);
|
||||
@unlink($zipPath);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Virenscan plugin checks passed.' . "\n";
|
||||
@@ -75,6 +75,8 @@ $check(site_publish('theme', $themeZip('1.1.0'), ['slug' => 'nordlicht', 'name'
|
||||
|
||||
$bad = site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"x1","name":"X","version":"1.0.0","permissions":["alles"]}', 'plugin.php' => '<?php']));
|
||||
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung wird abgelehnt');
|
||||
$future = site_inspect_package('plugin', $makeZip(['plugin.json' => '{"slug":"x2","name":"X","version":"1.0.0","permissions":["api.public","neu.spaeter"]}', 'plugin.php' => '<?php']));
|
||||
$check($future['ok'], 'Berechtigungen in der Form "bereich.name" blockieren die Webseite nicht: ' . json_encode($future));
|
||||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => '<?php']))['ok'], 'reservierter Name wird abgelehnt');
|
||||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => '<?php']))['ok'], 'Zip-Slip wird abgelehnt');
|
||||
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"exe1","name":"X","version":"1.0.0"}', 'a.exe' => 'MZ']))['ok'], 'unzulässiger Dateityp im Plugin wird abgelehnt');
|
||||
@@ -90,6 +92,13 @@ $themeCatalog = site_catalog('theme');
|
||||
$check(count($pluginCatalog['plugins']) === 1 && str_starts_with($pluginCatalog['plugins'][0]['downloadUrl'], 'https://plugins.example.test/download.php'), 'Plugin-Katalog enthält https-Download-Adresse');
|
||||
$check(count($themeCatalog['themes']) === 1 && $themeCatalog['themes'][0]['version'] === '1.1.0', 'Theme-Katalog enthält nur die neueste Version');
|
||||
|
||||
// Empfehlung: im Katalog markiert, in DeskHub zuerst
|
||||
$check(!isset($pluginCatalog['plugins'][0]['featured']), 'ohne Markierung kein Feld „featured“');
|
||||
$firstId = (int) site_item('plugin', $pluginCatalog['plugins'][0]['slug'])['id'];
|
||||
$check(site_set_featured($firstId, true) && site_catalog('plugin')['plugins'][0]['featured'] === true, 'Empfehlung steht im Katalog');
|
||||
$check(!site_set_featured(99999, true), 'unbekannter Eintrag');
|
||||
$pluginCatalog = site_catalog('plugin');
|
||||
|
||||
$files = [];
|
||||
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
|
||||
foreach ($list as $e) {
|
||||
@@ -110,6 +119,7 @@ set_setting('plugin_require_signature', '1');
|
||||
|
||||
$cat = plugin_catalog_fetch();
|
||||
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite');
|
||||
$check($cat['plugins'][0]['featured'] === true, 'DeskHub übernimmt die Empfehlung aus dem Katalog');
|
||||
$r = plugin_catalog_install($pdo, 'tagesbericht');
|
||||
$check($r['ok'] && $r['signed'] === true, 'DeskHub installiert das Plugin mit geprüfter Signatur („Signatur erforderlich“ an): ' . json_encode($r));
|
||||
$check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner');
|
||||
@@ -230,6 +240,34 @@ clearstatcache();
|
||||
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
|
||||
|
||||
// Aufräumen
|
||||
// ── Sprachpakete im Katalog ─────────────────────────────────────────────────
|
||||
$langPkg = fn(array $lang) => $makeZip([
|
||||
'plugin.json' => json_encode(['slug' => 'lang-xx', 'name' => 'Testsprache', 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => [], 'language' => $lang]),
|
||||
'plugin.php' => "<?php\n",
|
||||
'flag.svg' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2"/></svg>',
|
||||
'strings.json' => '{"Neues Ticket":"New ticket"}',
|
||||
]);
|
||||
$bad = site_publish('plugin', $langPkg(['code' => 'Englisch!', 'name' => 'X']));
|
||||
$check(!$bad['ok'] && str_contains($bad['error'], 'language.code'), 'Sprachpaket mit ungültigem Sprachcode wird abgelehnt');
|
||||
$okl = site_publish('plugin', $langPkg(['code' => 'xx', 'name' => 'Testsprache']));
|
||||
$check($okl['ok'] && $okl['signed'] === true, 'Sprachpaket lässt sich veröffentlichen (Flagge, Texte, keine Berechtigungen)');
|
||||
$entry = null;
|
||||
foreach (site_catalog('plugin')['plugins'] as $e) if ($e['slug'] === 'lang-xx') $entry = $e;
|
||||
$check($entry && $entry['language'] === ['code' => 'xx', 'name' => 'Testsprache'] && $entry['permissions'] === [], 'Katalog nennt Sprachcode und Namen');
|
||||
$others = array_filter(site_catalog('plugin')['plugins'], fn($e) => $e['slug'] !== 'lang-xx');
|
||||
$check(count($others) >= 1 && !array_filter($others, fn($e) => isset($e['language'])), 'normale Plugins haben kein Feld „language“');
|
||||
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($pluginCatalog, $entry): array {
|
||||
$c = site_catalog('plugin');
|
||||
return str_contains($url, 'type=plugins') ? [200, json_encode($c), null] : [404, null, 'HTTP 404'];
|
||||
};
|
||||
$cat2 = plugin_catalog_fetch();
|
||||
$lx = null;
|
||||
foreach ($cat2['plugins'] ?? [] as $e) if ($e['slug'] === 'lang-xx') $lx = $e;
|
||||
$check($lx && $lx['language'] === ['code' => 'xx', 'name' => 'Testsprache'], 'DeskHub übernimmt die Sprachangabe aus dem Katalog');
|
||||
$nl = null;
|
||||
foreach ($cat2['plugins'] ?? [] as $e) if ($e['slug'] !== 'lang-xx') $nl = $e;
|
||||
$check($nl && $nl['language'] === null, 'DeskHub: normale Plugins ohne Sprachangabe');
|
||||
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
|
||||
@@ -272,6 +272,38 @@ $c2 = plugin_catalog_cached();
|
||||
$check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)');
|
||||
plugin_catalog_cached(true);
|
||||
$check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf');
|
||||
// Zwischenspeicher ist kurz (5 Minuten), die Plugin-Seite verlangt höchstens eine Minute Alter, Serverausfall zeigt den letzten Stand
|
||||
$setAge = function (int $seconds): void {
|
||||
$c = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
|
||||
$c['at'] = time() - $seconds;
|
||||
set_setting('plugin_catalog_cache', json_encode($c));
|
||||
};
|
||||
$fetches0 = $fetches;
|
||||
$setAge(200);
|
||||
plugin_catalog_cached();
|
||||
$check($fetches === $fetches0, 'Katalog ist nach 3 Minuten noch frisch genug (kein Abruf)');
|
||||
plugin_catalog_cached(false, 60);
|
||||
$check($fetches === $fetches0 + 1, 'die Plugin-Seite verlangt höchstens eine Minute Alter: neuer Abruf beim Öffnen');
|
||||
$setAge(400);
|
||||
plugin_catalog_cached();
|
||||
$check($fetches === $fetches0 + 2, 'nach über 5 Minuten wird der Katalog von selbst neu geladen (kein Klick auf „Katalog aktualisieren“ nötig)');
|
||||
$setAge(400);
|
||||
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$fetches): array { $fetches++; return [0, null, 'Verbindung fehlgeschlagen']; };
|
||||
$stale = plugin_catalog_cached();
|
||||
$check($stale['ok'] && !empty($stale['stale']) && count($stale['plugins']) === 2 && $fetches === $fetches0 + 3, 'Server nicht erreichbar: die Seite zeigt den letzten Stand (stale)');
|
||||
plugin_catalog_cached();
|
||||
plugin_catalog_cached(false, 60);
|
||||
$check($fetches === $fetches0 + 3, 'danach wird zwei Minuten lang nicht erneut gefragt (die Seite wartet nicht bei jedem Aufruf)');
|
||||
$btn = plugin_catalog_cached(true);
|
||||
$check(!$btn['ok'] && $fetches === $fetches0 + 4, 'der Knopf „Katalog aktualisieren“ fragt trotzdem und meldet den Fehler');
|
||||
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array {
|
||||
if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; }
|
||||
return [200, file_get_contents($offPkg), null];
|
||||
};
|
||||
$setAge(400);
|
||||
set_setting('plugin_catalog_fail_at', '0');
|
||||
$ok = plugin_catalog_cached();
|
||||
$check($ok['ok'] && empty($ok['stale']), 'nach der Erholung des Servers wieder ein frischer Katalog');
|
||||
$r = plugin_catalog_install($pdo, 'offiziell');
|
||||
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
|
||||
plugin_uninstall($pdo, 'offiziell');
|
||||
|
||||
@@ -171,6 +171,53 @@ try {
|
||||
$gc = (int) $gcBody;
|
||||
@unlink($pj);
|
||||
$check($gc >= 30 * 60 + 600, 'PHP räumt Sitzungsdaten erst nach der Inaktivitätszeit plus Reserve auf (gc_maxlifetime ' . $gc . ' s)');
|
||||
|
||||
// G: Anmeldeseite, deren Sitzung serverseitig aufgeräumt wurde (zu lange offen): freundliche Meldung statt „CSRF-Token ungültig“, danach klappt die Anmeldung
|
||||
foreach ([['/staff/login.php', 'chef@test.de', 302], ['/login.php', 'kunde@test.de', 200]] as [$loginPath, $mail, $okStatus]) {
|
||||
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||
[, $page] = $call('GET', $loginPath, $gj, $USER_NAV);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $page, $tk);
|
||||
@unlink($sessionFile($gj)); // der Server hat die Sitzung inzwischen gelöscht
|
||||
[$s, $body] = $call('POST', $loginPath, $gj, $USER_NAV, ['csrf_token' => $tk[1] ?? '', 'email' => $mail, 'password' => 'Sehr-Sicher-1234']);
|
||||
$check($s === 200 && str_contains($body, 'zu lange geöffnet') && !str_contains($body, 'CSRF-Token fehlt'), "{$loginPath}: nach aufgeräumter Sitzung kommt eine freundliche Meldung statt der CSRF-Fehlerseite (HTTP {$s})");
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $body, $tk2);
|
||||
$check(($tk2[1] ?? '') !== '' && ($tk2[1] ?? '') !== ($tk[1] ?? ''), "{$loginPath}: die Meldung bringt ein neues, gültiges Formular mit");
|
||||
if ($loginPath === '/staff/login.php') {
|
||||
[$s2, , $loc2] = $call('POST', $loginPath, $gj, $USER_NAV, ['csrf_token' => $tk2[1] ?? '', 'email' => $mail, 'password' => 'Sehr-Sicher-1234']);
|
||||
$check($s2 === 302 && str_contains($loc2, 'reports'), 'zweiter Versuch mit dem neuen Formular meldet an (HTTP ' . $s2 . ')');
|
||||
}
|
||||
@unlink($gj);
|
||||
}
|
||||
// G2: Bei anderen Formularen bleibt die Prüfung streng (kein Umgehen des Schutzes)
|
||||
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||
[$s, $body] = $call('POST', '/staff/login.php', $gj, $USER_NAV, ['csrf_token' => 'erfunden', 'email' => 'chef@test.de', 'password' => 'Sehr-Sicher-1234']);
|
||||
$check($s === 200 && str_contains($body, 'zu lange geöffnet'), 'ein falscher Token meldet nie an, auch nicht mit richtigem Passwort (HTTP ' . $s . ')');
|
||||
[$s, $body] = $call('POST', '/staff/profile.php', $gj, $USER_NAV, ['csrf_token' => 'erfunden']);
|
||||
$check(in_array($s, [302, 403], true), 'geschützte Formulare ohne gültigen Token werden weiter abgewiesen (HTTP ' . $s . ')');
|
||||
@unlink($gj);
|
||||
// H: Die Anmeldeseiten halten ihre Sitzung am Leben
|
||||
foreach (['/staff/login.php', '/login.php'] as $loginPath) {
|
||||
$gj = tempnam(sys_get_temp_dir(), 'ses');
|
||||
[, $page] = $call('GET', $loginPath, $gj, $USER_NAV);
|
||||
$check(str_contains($page, 'session_ping.php?check=1') && str_contains($page, '8 * 60 * 1000'), "{$loginPath}: ein kurzer Abruf alle 8 Minuten hält die Sitzung am Leben");
|
||||
@unlink($gj);
|
||||
}
|
||||
// I: Die Live-Anzeige neuer Tickets verlängert die Sitzung nie und endet mit ihr
|
||||
$jar = $login();
|
||||
[$s, $body] = $call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||
$d = json_decode($body, true);
|
||||
$check($s === 200 && isset($d['counts']['open']), 'Live-Anzeige liefert bei angemeldeter Sitzung die Zahlen (HTTP ' . $s . ')');
|
||||
$setAge($jar, 29 * 60);
|
||||
$call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||
$age = time() - (int) $lastActivity($jar);
|
||||
$check($age >= 29 * 60 - 3, 'Live-Anzeige verlängert die Sitzung nicht (Alter ' . $age . ' s)');
|
||||
$setAge($jar, 31 * 60);
|
||||
[$s, $body] = $call('GET', '/staff/live_tickets.php?since=0', $jar, $POLL);
|
||||
$check($s === 401 && str_contains($body, 'sessionExpired'), 'nach Ablauf der Sitzung antwortet die Live-Anzeige mit 401 (HTTP ' . $s . ')');
|
||||
@unlink($jar);
|
||||
[$s] = $call('GET', '/staff/live_tickets.php?since=0', $pj = tempnam(sys_get_temp_dir(), 'ses'), $POLL);
|
||||
$check($s === 401, 'ohne Anmeldung antwortet die Live-Anzeige mit 401');
|
||||
@unlink($pj);
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Prüft den Chat der Mitarbeiter untereinander: Abruf über laufende Nummern (keine Doppelten), Anzeige-Angaben für neue Nachrichten,
|
||||
* gleiche Darstellung beim Nachladen wie beim Laden der Seite.
|
||||
*/
|
||||
|
||||
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
|
||||
$root = dirname(__DIR__);
|
||||
$work = sys_get_temp_dir() . '/deskhub-staffchat-' . bin2hex(random_bytes(4));
|
||||
$tmp = $work . '/inst';
|
||||
mkdir($tmp, 0755, true);
|
||||
$webPort = random_int(37000, 37499);
|
||||
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
|
||||
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
|
||||
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
|
||||
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
|
||||
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
|
||||
copy($f->getPathname(), $tmp . '/' . $rel);
|
||||
}
|
||||
@mkdir($tmp . '/uploads');
|
||||
@mkdir($tmp . '/config');
|
||||
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
|
||||
'app_key' => bin2hex(random_bytes(32)),
|
||||
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
|
||||
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
|
||||
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
|
||||
], true) . ';');
|
||||
|
||||
putenv('DESKHUB_CONFIG_JSON');
|
||||
putenv('DESKHUB_SQLITE_PATH');
|
||||
putenv('DESKHUB_TESTING');
|
||||
putenv('DESKHUB_API_ALLOW_INSECURE=1');
|
||||
require_once $root . '/database/sqlite_schema.php';
|
||||
require_once $root . '/database/migrate.php';
|
||||
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec('PRAGMA foreign_keys = ON');
|
||||
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
|
||||
$mig = run_migrations($db, true);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->exec('DELETE FROM staff');
|
||||
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
|
||||
$ids = [];
|
||||
foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) {
|
||||
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
|
||||
$ids[$n] = (int) $db->lastInsertId();
|
||||
}
|
||||
$db = null;
|
||||
|
||||
$failures = [];
|
||||
$total = 0;
|
||||
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
|
||||
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
|
||||
|
||||
try {
|
||||
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
|
||||
$pdo = function () use ($tmp): PDO {
|
||||
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
|
||||
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
return $p;
|
||||
};
|
||||
$call = function (string $method, string $path, string $jar, array $form = []) use ($webPort): array {
|
||||
$ch = curl_init("http://127.0.0.1:$webPort$path");
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-User: ?1', 'X-Requested-With: XMLHttpRequest']]);
|
||||
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); }
|
||||
$b = (string) curl_exec($ch);
|
||||
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return [$s, $b];
|
||||
};
|
||||
$login = function (string $email) use ($call): string {
|
||||
$jar = tempnam(sys_get_temp_dir(), 'chat');
|
||||
[, $lp] = $call('GET', '/staff/login.php', $jar);
|
||||
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
|
||||
$call('POST', '/staff/login.php', $jar, ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234']);
|
||||
return $jar;
|
||||
};
|
||||
$token = function (string $jar, string $path) use ($call): string { [, $p] = $call('GET', $path, $jar); preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $m); return $m[1] ?? ''; };
|
||||
$ids = $pdo()->query('SELECT email, id FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$chefId = (int) $ids['chef@test.de']; $agentId = (int) $ids['agent@test.de'];
|
||||
$jarA = $login('chef@test.de');
|
||||
$jarB = $login('agent@test.de');
|
||||
|
||||
$send = function (string $jar, int $toId, string $text) use ($call, $token): void {
|
||||
$t = $token($jar, '/staff/chat.php?with=' . $toId);
|
||||
$call('POST', '/staff/chat.php', $jar, ['csrf_token' => $t, 'to_id' => (string) $toId, 'message' => $text]);
|
||||
};
|
||||
$poll = function (string $jar, int $withId, int $after) use ($call): array { [, $b] = $call('GET', "/staff/chat.php?with=$withId&action=poll&after=$after", $jar); return json_decode($b, true) ?? []; };
|
||||
|
||||
$send($jarA, $agentId, 'Hallo Agent');
|
||||
$send($jarA, $agentId, "Zweite Zeile\nmit Umbruch <b>fett</b>");
|
||||
$m = $poll($jarB, $chefId, 0);
|
||||
$check(count($m) === 2 && $m[0]['message'] === 'Hallo Agent' && $m[0]['id'] < $m[1]['id'], 'Abruf liefert die neuen Nachrichten in der Reihenfolge ihrer Nummer');
|
||||
$check(($m[0]['initial'] ?? '') === 'C' && !empty($m[0]['av_class']) && $m[0]['from_name'] === 'Chef' && (int) $m[0]['from_staff_id'] === $chefId, 'Abruf enthält Initiale, Farbklasse und Absender für die Anzeige');
|
||||
$check(count($poll($jarB, $chefId, (int) $m[1]['id'])) === 0, 'Nach der letzten Nummer kommt nichts mehr (keine Doppelten)');
|
||||
$check(count($poll($jarB, $chefId, (int) $m[0]['id'])) === 1, 'Ab einer mittleren Nummer kommt nur die jüngere Nachricht');
|
||||
|
||||
// Auch die eigene Nachricht kommt mit Nummer zurück, damit die Seite sie ohne Neuladen richtig zeigt
|
||||
$own = $poll($jarA, $agentId, 0);
|
||||
$check(count($own) === 2 && (int) $own[0]['from_staff_id'] === $chefId, 'Der Absender sieht seine Nachrichten im Abruf (mit Nummer und Serverzeit)');
|
||||
|
||||
// Seite: Nachrichten tragen ihre Nummer, die Skripte nutzen die Klassen der Seite (nicht die alten)
|
||||
[, $page] = $call('GET', '/staff/chat.php?with=' . $chefId, $jarB);
|
||||
$check(preg_match_all('/class="ac-msg [a-z]+" data-id="\d+"/', $page) === 2, 'Die Seite zeigt beide Nachrichten mit ihrer Nummer');
|
||||
$check(str_contains($page, "'ac-msg-bubble'") && str_contains($page, "'ac-msg-time'") && !str_contains($page, 'chat-bubble') && !str_contains($page, 'bubble-text'), 'Neue Nachrichten werden mit denselben Klassen gebaut wie beim Laden der Seite');
|
||||
$check(str_contains($page, 'var lastId = ' . (int) $m[1]['id'] . ';'), 'Der Abruf startet bei der letzten bekannten Nummer');
|
||||
$check(!str_contains($page, 'toISOString'), 'Die Seite benutzt keine Browser-Uhrzeit mehr (Zeitzonen-Fehler)');
|
||||
$check(str_contains($page, 'Zweite Zeile<br />') && str_contains($page, '<b>fett</b>'), 'Text wird maskiert, Zeilenumbrüche bleiben');
|
||||
|
||||
// fremde Konversation bleibt fremd
|
||||
$jarC = $login('agent@test.de');
|
||||
$check(count($poll($jarC, $chefId, 0)) === 2, 'Gegenstelle sieht dieselben Nachrichten');
|
||||
@unlink($jarA); @unlink($jarB); @unlink($jarC);
|
||||
} finally {
|
||||
if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
|
||||
}
|
||||
|
||||
usleep(300000);
|
||||
$rm = function (string $d) use (&$rm) {
|
||||
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
|
||||
@rmdir($d);
|
||||
};
|
||||
if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-staffchat-')) $rm($work);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo "Staff chat checks passed ({$total} Prüfungen).\n";
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft, dass die Anmeldeseite der Mitarbeiter themefähig ist: Kopf-, Fußzeile und Hintergrund kommen aus dem Stylesheet (Klassen und
|
||||
* --staff-login-*-Variablen) statt aus fest eingebauten Farben, das bisherige Aussehen bleibt der Standard, und Kendo-Center nutzt es.
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
$php = (string) file_get_contents($root . '/staff/login.php');
|
||||
$css = (string) file_get_contents($root . '/assets/css/style.css');
|
||||
|
||||
$check(str_contains($php, '<header class="staff-login-header">') && str_contains($php, '<footer class="staff-login-footer">') && str_contains($php, 'staff-login-title') && str_contains($php, 'staff-login-back'), 'Kopfzeile, Fußzeile, Titel und Link nutzen Klassen');
|
||||
$check(!preg_match('/<(header|footer|body)[^>]*style="[^"]*(background:\s*(rgba|#)|color:)/', $php), 'keine fest eingebauten Farben mehr in Kopfzeile, Fußzeile und Body');
|
||||
$check(preg_match('/<body class="login-page staff-login-page"(<\?php if \(\$banner\): \?> style="background-image:[^"]*"<\?php endif; \?>)?>/', $php) === 1, 'am Body bleibt nur das (veränderliche) Bannerbild als Inline-Stil');
|
||||
$check(str_contains($css, '.staff-login-header') && str_contains($css, 'var(--staff-login-chrome, rgba(14,26,34,.95))') && str_contains($css, 'var(--staff-login-bg, #16202a)'), 'Stylesheet: Standardwerte entsprechen dem bisherigen Aussehen (Kopfzeile rgba(14,26,34,.95), Hintergrund #16202a)');
|
||||
foreach (['--staff-login-chrome', '--staff-login-line', '--staff-login-chrome-ink', '--staff-login-chrome-muted', '--staff-login-chrome-faint', '--staff-login-bg'] as $v) {
|
||||
$check(str_contains($css, 'var(' . $v), "Stylesheet nutzt die Variable {$v}");
|
||||
}
|
||||
// Das Staff-Layout der übrigen Seiten ist nicht betroffen
|
||||
$check(!str_contains((string) file_get_contents($root . '/staff/_header.php'), 'staff-login'), 'der Mitarbeiter-Bereich nach der Anmeldung bleibt unverändert');
|
||||
|
||||
// Theme „Kendo-Center“ (liegt im Projektordner neben DeskHub-Webseite)
|
||||
$zip = dirname($root) . '/theme/Kendo-Center.zip';
|
||||
if (is_file($zip) && class_exists('ZipArchive')) {
|
||||
$z = new ZipArchive();
|
||||
$z->open($zip);
|
||||
$theme = (string) $z->getFromName('theme.css');
|
||||
$z->close();
|
||||
$check(preg_match('/body\.staff-login-page\s*\{[^}]*--staff-login-chrome:\s*#0c2130[^}]*--staff-login-bg:|body\.staff-login-page\s*\{[^}]*--staff-login-bg:[^}]*--staff-login-chrome:\s*#0c2130/s', $theme) === 1, 'Kendo-Center: Kopf- und Fußzeile in der Farbe der Kopfzeile des Kundenportals (#0c2130)');
|
||||
$check(str_contains($theme, '--staff-login-line: 3px solid var(--primary)') && str_contains($theme, '--staff-login-bg: #f0f5f5'), 'Kendo-Center: türkise Linie und Hintergrund passend zum Anmeldebereich');
|
||||
$check(preg_match('/Version:\s*1\.0\.[2-9]/', $theme) === 1, 'Kendo-Center: Versionsnummer wurde erhöht (damit das Update erkannt wird)');
|
||||
$check(substr_count($theme, '{') === substr_count($theme, '}'), 'Kendo-Center: Klammern im Stylesheet stimmen');
|
||||
$check(!str_contains($theme, '8px 8px 0 rgba(22,43,58,.14)'), 'Kendo-Center: weicher Schatten an der Anmeldekarte');
|
||||
}
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Staff login theme checks passed.' . "\n";
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prüft die Dateinamen-Regeln beim Upload: gefährliche Endungen bleiben gesperrt, harmlose Namen mit Punkten
|
||||
* (zum Beispiel „www.example.com.pdf“) werden nicht mehr abgelehnt.
|
||||
*/
|
||||
|
||||
putenv('DESKHUB_TESTING=1');
|
||||
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
||||
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
||||
|
||||
$failures = [];
|
||||
$check = function (bool $ok, string $what) use (&$failures): void {
|
||||
if (!$ok) $failures[] = $what;
|
||||
};
|
||||
get_db();
|
||||
set_setting('upload_allowed_extensions', 'png,jpg,jpeg,gif,pdf,txt,log,docx,xlsx,zip,doc,exe,php');
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'up');
|
||||
file_put_contents($tmp, 'harmlos');
|
||||
$try = fn(string $name): string => (string) (handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => 7])['error'] ?? '');
|
||||
// Weiter als bis zum Verschieben kommt der Test nicht (move_uploaded_file geht nur bei echten Uploads): diese Meldung heißt „alle Prüfungen bestanden“
|
||||
$passed = 'Die Datei konnte nicht gespeichert werden.';
|
||||
|
||||
foreach (['www.example.com.pdf', 'scan.js.2024.jpg', 'Bericht v1.2.final.pdf', 'foto.cmd.png', 'IMG_2024.07.01.JPG', 'notizen.xml.txt'] as $ok) {
|
||||
$check($try($ok) === $passed, 'harmloser Name wird akzeptiert: ' . $ok . ' → ' . $try($ok));
|
||||
}
|
||||
foreach (['shell.php', 'start.exe', 'seite.html', 'bild.svg', 'x.php.exe', 'a.sh'] as $bad) {
|
||||
$check(str_contains($try($bad), 'aus Sicherheitsgründen nicht erlaubt'), 'gefährliche Endung bleibt gesperrt: ' . $bad);
|
||||
}
|
||||
$check(str_contains($try('shell.php'), 'shell.php'), 'die Meldung nennt die Datei');
|
||||
$check(str_contains($try('.htaccess'), 'Dateiname'), '.htaccess bleibt gesperrt');
|
||||
$check(str_contains($try('datei.dwg'), 'nicht erlaubt'), 'nicht freigegebene Endung bleibt abgelehnt');
|
||||
@unlink($tmp);
|
||||
|
||||
if ($failures) {
|
||||
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
echo 'Upload name checks passed.' . "\n";
|
||||
@@ -108,7 +108,7 @@ try {
|
||||
// ── Welche Seiten sieht wer? ───────────────────────────────────────────
|
||||
$keys = fn(string $t): array => array_column(($api('GET', '/api/staff/v1/web_pages.php', null, $t)[1]['pages'] ?? []), 'key');
|
||||
$kc = $keys($chef); $ka = $keys($agent); $kp = $keys($pfleger);
|
||||
$check(count($kc) === 17 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 17 Seiten');
|
||||
$check(count($kc) === 19 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 19 Seiten');
|
||||
$check($ka === ['mentions', 'agent_directory'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen und Verzeichnis ' . json_encode($ka));
|
||||
$check(in_array('assistant', $kp, true) && !in_array('settings', $kp, true) && !in_array('backup', $kp, true) && !in_array('plugins', $kp, true), 'Mit dem Recht „Assistent“ kommt genau diese Seite dazu, aber keine Systemseiten ' . json_encode($kp));
|
||||
|
||||
|
||||
@@ -252,6 +252,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
}
|
||||
|
||||
// Ticket-Vorlagen: ?template=ID füllt das Formular vor (nur Vorlagen, die für Kunden sichtbar sind)
|
||||
$ticketTemplates = [];
|
||||
$activeTemplateId = 0;
|
||||
try {
|
||||
$ticketTemplates = ticket_templates_list(get_db(), true);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && (int) ($_GET['template'] ?? 0) > 0) {
|
||||
$tpl = ticket_template_get(get_db(), (int) $_GET['template'], true);
|
||||
if ($tpl) {
|
||||
$activeTemplateId = (int) $tpl['id'];
|
||||
$_POST['subject'] = (string) $tpl['subject'];
|
||||
$_POST['message'] = (string) ($tpl['body'] ?? '');
|
||||
if ($tpl['department_id']) $_POST['department_id'] = (string) $tpl['department_id'];
|
||||
if ($tpl['help_topic_id']) $_POST['help_topic_id'] = (string) $tpl['help_topic_id'];
|
||||
if ($tpl['priority_id']) $_POST['priority_id'] = (string) $tpl['priority_id'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$ticketTemplates = []; // Datenbank noch nicht aktualisiert: Formular funktioniert wie bisher
|
||||
}
|
||||
$pageTitle = 'Neues Ticket';
|
||||
$activePage = 'new';
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
@@ -266,6 +285,17 @@ require __DIR__ . '/includes/header_customer.php';
|
||||
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
|
||||
</div>
|
||||
|
||||
<?php if ($ticketTemplates): ?>
|
||||
<div class="ticket-templates" style="margin:0 0 18px">
|
||||
<div class="muted" style="font-size:.82rem;margin-bottom:6px">Womit können wir helfen? Eine Vorlage füllt das Formular für Sie vor:</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<?php foreach ($ticketTemplates as $__tp): ?>
|
||||
<a href="/ticket_new.php?template=<?= (int) $__tp['id'] ?>" class="btn <?= $activeTemplateId === (int) $__tp['id'] ? '' : 'secondary' ?>" style="font-size:.85rem" <?= $__tp['description'] ? 'title="' . e((string) $__tp['description']) . '"' : '' ?>><?= e($__tp['name']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($activeTemplateId): ?><a href="/ticket_new.php" class="muted" style="align-self:center;font-size:.82rem">Ohne Vorlage</a><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/demo-video/build_video.sh: baut aus den Bildern (Reihenfolge nach Dateiname) das Demo-Video und eine kurze GIF-Vorschau.
|
||||
# bash tools/demo-video/build_video.sh <Bilderordner> <Zielordner>
|
||||
# Benötigt ffmpeg.
|
||||
set -euo pipefail
|
||||
FRAMES="${1:?Bilderordner fehlt}"
|
||||
OUT="${2:?Zielordner fehlt}"
|
||||
SEC="${SECONDS_PER_SLIDE:-3.6}"
|
||||
mkdir -p "$OUT"
|
||||
TMP="$OUT/.build"; rm -rf "$TMP"; mkdir -p "$TMP"
|
||||
LIST="$TMP/list.txt"
|
||||
: > "$LIST"
|
||||
i=0
|
||||
for f in $(ls "$FRAMES"/*.png | sort); do
|
||||
i=$((i + 1))
|
||||
clip="$TMP/clip_$(printf '%02d' $i).mp4"
|
||||
ffmpeg -loglevel error -y -loop 1 -t "$SEC" -i "$f" \
|
||||
-vf "scale=1440:900,format=yuv420p,fade=t=in:st=0:d=0.45,fade=t=out:st=$(awk "BEGIN{print $SEC-0.45}"):d=0.45" \
|
||||
-r 25 -c:v libx264 -crf 22 -preset medium -pix_fmt yuv420p "$clip"
|
||||
echo "file 'clip_$(printf "%02d" $i).mp4'" >> "$LIST"
|
||||
done
|
||||
ffmpeg -loglevel error -y -f concat -safe 0 -i "$LIST" -c copy -movflags +faststart "$OUT/deskhub-demo.mp4"
|
||||
# GIF-Vorschau (kleiner, für die README, weil nicht jede Seite Videos direkt anzeigt)
|
||||
ffmpeg -loglevel error -y -i "$OUT/deskhub-demo.mp4" -vf "fps=4,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" "$OUT/deskhub-demo.gif"
|
||||
rm -rf "$TMP"
|
||||
ls -la "$OUT"/deskhub-demo.*
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
/**
|
||||
* tools/demo-video/capture.js: nimmt die Bilder für das Demo-Video auf (Weboberfläche mit erfundenen Beispieldaten).
|
||||
* 1. php tools/demo-video/seed_demo.php <Ordner> (Demo-Installation anlegen)
|
||||
* 2. php -S 127.0.0.1:8097 -t <Ordner> (im Hintergrund starten)
|
||||
* 3. electron tools/demo-video/capture.js <Bilderordner>
|
||||
* Danach baut build_video.sh aus den Bildern das Video.
|
||||
*/
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BASE = 'http://127.0.0.1:8097';
|
||||
const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames'));
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let win, n = 0;
|
||||
|
||||
const run = (js) => win.webContents.executeJavaScript(js, true);
|
||||
/** Seite laden und Hinweise entfernen, die nur zur Demo-Installation gehören (SQLite-Hinweis, Abmelde-Zähler). */
|
||||
const load = async (url) => {
|
||||
await win.loadURL(BASE + url);
|
||||
await sleep(700);
|
||||
await run(`(() => { document.querySelectorAll('div,p,section').forEach((e) => { if (e.children.length < 6 && /SQLite-Modus aktiv/.test(e.textContent) && e.textContent.length < 260) e.remove(); });
|
||||
document.getElementById('session-timer-bar')?.remove(); })()`);
|
||||
};
|
||||
|
||||
/** Bildunterschrift unten einblenden, Bild speichern, Unterschrift wieder entfernen. */
|
||||
async function shot(caption, name) {
|
||||
n += 1;
|
||||
await run(`(() => { const d = document.createElement('div'); d.id = '__cap'; d.textContent = ${JSON.stringify(caption)};
|
||||
d.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:2147483647;background:rgba(10,30,45,.92);color:#fff;font:600 21px Segoe UI,Arial,sans-serif;padding:13px 26px;border-radius:40px;box-shadow:0 6px 24px rgba(0,0,0,.35);max-width:1180px;text-align:center';
|
||||
document.body.appendChild(d); })()`);
|
||||
await sleep(450);
|
||||
const img = await win.webContents.capturePage();
|
||||
fs.writeFileSync(path.join(OUT, `${String(n).padStart(2, '0')}_${name}.png`), img.toPNG());
|
||||
await run(`document.getElementById('__cap')?.remove()`);
|
||||
}
|
||||
|
||||
async function ask(question) {
|
||||
await run(`(() => { const i = document.getElementById('as-input'); i.value = ${JSON.stringify(question)}; document.getElementById('as-form').dispatchEvent(new Event('submit', { cancelable: true })); })()`);
|
||||
await sleep(1300);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true, webPreferences: { contextIsolation: true, backgroundThrottling: false } });
|
||||
try {
|
||||
// ── Kundenportal ──
|
||||
await load('/home.php');
|
||||
await shot('Kundenportal: Hilfe finden und Tickets verfolgen', 'portal');
|
||||
await run(`document.getElementById('as-bubble').click()`);
|
||||
await sleep(900);
|
||||
await ask('Wie lange dauert der Versand?');
|
||||
await shot('Der digitale Assistent antwortet aus der Wissensdatenbank', 'assistent');
|
||||
await ask('Wann seid ihr erreichbar?');
|
||||
await ask('Wie kann ich meinen Vertrag beenden?');
|
||||
await shot('Und bietet ein Ticket an, wenn er nichts findet', 'assistent_ticket');
|
||||
await load('/ticket_new.php');
|
||||
await shot('Tickets mit Thema, Priorität und Anhängen erstellen', 'neues_ticket');
|
||||
|
||||
// ── Mitarbeiterbereich ──
|
||||
await load('/staff/login.php');
|
||||
await run(`(() => { document.getElementById('email').value = 'lena@demo.example'; document.getElementById('password').value = 'Demo-Passwort-1234'; document.querySelector('form button[type=submit]').click(); })()`);
|
||||
await sleep(1500);
|
||||
await load('/staff/dashboard.php');
|
||||
await shot('Übersicht: Zahlen, Status des Teams und priorisierte Tickets', 'uebersicht');
|
||||
await load('/staff/tickets.php?tab=open');
|
||||
await shot('Ticketliste mit Filtern, Tabs und gespeicherten Ansichten', 'tickets');
|
||||
await load('/staff/ticket_view.php?n=260102');
|
||||
await shot('Ticket bearbeiten: Verlauf, Antworten, interne Notizen', 'ticket');
|
||||
await run(`(() => { const el = [...document.querySelectorAll('span')].find((s) => /Wiedervorlage/.test(s.textContent)); if (el) el.scrollIntoView({ block: 'center' }); })()`);
|
||||
await sleep(500);
|
||||
await shot('Wiedervorlage, Beobachter und passende Artikel zum Ticket', 'ticket_extras');
|
||||
await load('/staff/tickets.php?tab=snoozed');
|
||||
await shot('Wiedervorlage: Tickets später wieder vorlegen lassen', 'wiedervorlage');
|
||||
|
||||
// ── Digitaler Assistent (Verwaltung) ──
|
||||
await load('/staff/assistant.php');
|
||||
await shot('Digitaler Assistent: Einstellungen, Name und Begrüßung', 'assistent_admin');
|
||||
await load('/staff/assistant.php?tab=answers');
|
||||
await shot('Eigene Antworten zusätzlich zur Wissensdatenbank', 'assistent_antworten');
|
||||
await load('/staff/assistant.php?tab=log');
|
||||
await shot('Fragen-Log: Was wurde gefragt, was blieb ohne Antwort?', 'assistent_log');
|
||||
|
||||
await load('/staff/reports.php');
|
||||
await shot('Berichte mit Bericht-Baukasten und CSV-Export', 'berichte');
|
||||
} catch (e) {
|
||||
console.error('Fehler:', e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log(`${n} Bilder in ${OUT}`);
|
||||
app.quit();
|
||||
});
|
||||