Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:18 +00:00
parent 56deade877
commit 6cced3beda
50 changed files with 11148 additions and 0 deletions
+906
View File
@@ -0,0 +1,906 @@
# DeskHub
**DeskHub** ist ein vollständiges, selbst gehostetes Helpdesk-System. Kunden erstellen und verfolgen Supportanfragen über ein individuell gestaltbares Kundenportal. Das Support-Team bearbeitet Tickets, E-Mails, Aufgaben und Wissensartikel zentral über die Weboberfläche, die Windows-App oder die Android-App.
![DeskHub Kundenportal](docs/images/customer-portal.png)
> 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
- **Mitarbeiterbereich** mit Ticketlisten, Filtern, gespeicherten Ansichten, Zuweisungen und internen Notizen
- **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
- **Berichte** mit frei zusammenstellbarem Bericht-Baukasten und CSV-Export
- **Integrationen**: API-Schlüssel, Webhooks und eine Schnittstelle zum Anlegen von Tickets aus anderen Systemen
- **Überwachung**: Warnung bei Störungen des E-Mail-Abrufs, Verbindungsanzeige in den Apps
- **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
## Einblicke
### Neues Ticket erstellen
[![Neues Ticket erstellen](docs/images/new-ticket.png)](docs/images/new-ticket.png)
### Kunden-Login
[![Kunden-Login](docs/images/customer-login.png)](docs/images/customer-login.png)
### Mitarbeiter-Login
[![Mitarbeiter-Login](docs/images/staff-login.png)](docs/images/staff-login.png)
---
## Inhaltsverzeichnis
- [Was ist was?](#was-ist-was)
- [Erstinstallation](#erstinstallation)
- [Schnellstart](#schnellstart)
- [Anmelden](#anmelden)
- [Für Kunden: Ein Ticket erstellen](#für-kunden-ein-ticket-erstellen)
- [Für Mitarbeiter: Tickets bearbeiten](#für-mitarbeiter-tickets-bearbeiten)
- [Für Admins: Das Adminportal](#für-admins-das-adminportal)
- [E-Mails versenden (SMTP)](#e-mails-versenden-smtp)
- [E-Mails automatisch abrufen (Postfächer)](#e-mails-automatisch-abrufen-postfächer)
- [Automatisierung (Cronjobs)](#automatisierung-cronjobs)
- [SLA-Pläne](#sla-pläne)
- [Gespeicherte Ansichten](#gespeicherte-ansichten)
- [Berichte und Bericht-Baukasten](#berichte-und-bericht-baukasten)
- [Integrationen: API-Schlüssel und Webhooks](#integrationen-api-schlüssel-und-webhooks)
- [Rechte und Rollen](#rechte-und-rollen)
- [Überwachung und Störungsmeldungen](#überwachung-und-störungsmeldungen)
- [Windows-App und Android-App](#windows-app-und-android-app)
- [System-Update und Datenbank-Update](#system-update-und-datenbank-update)
- [Import aus osTicket](#import-aus-osticket)
- [Plugins verwenden](#plugins-verwenden)
- [Plugins entwickeln](#plugins-entwickeln)
- [Plugin- und Theme-Webseite](#plugin--und-theme-webseite)
- [API und Webhooks (für Entwickler)](#api-und-webhooks-für-entwickler)
- [Sicherheit und Datenschutz](#sicherheit-und-datenschutz)
- [Backup](#backup)
- [Hilfe, etwas funktioniert nicht](#hilfe-etwas-funktioniert-nicht)
---
## Was ist was?
DeskHub hat drei Bereiche:
| Bereich | Für wen? | Adresse |
|---|---|---|
| **Kundenportal** | Eure Kunden | `eure-domain.de/` |
| **Mitarbeiterbereich** | Support-Team und Admins | `eure-domain.de/staff/` |
| **Apps** | Support-Team und Admins am PC und unterwegs | Windows-App, Android-App |
Ein Admin-Konto ist ein Mitarbeiter-Konto mit der Rolle „Admin“. Beim Anmelden gibt es keinen Unterschied, nur bei dem, was man danach sieht.
---
## Erstinstallation
**Voraussetzungen** (im Zweifel euren Hosting-Anbieter fragen):
- PHP 8.2 oder neuer
- Eine MySQL-Datenbank (für kleine Installationen und Tests geht auch SQLite)
- PHP-Erweiterung `imap`, wenn E-Mails automatisch abgerufen werden sollen
- PHP-Erweiterung `zip` für Theme-Upload, Plugin-Installation und System-Update per ZIP
**So geht’s:**
1. Alle DeskHub-Dateien auf den Server hochladen (zum Beispiel per FTP).
2. Im Browser `eure-domain.de/install/` aufrufen.
3. Dem Assistenten folgen: Datenbank-Zugangsdaten, Website-Name, erstes Admin-Konto.
4. Danach unter `eure-domain.de/staff/login.php` mit dem neuen Konto anmelden.
**Ganz wichtig:** Löscht anschließend den Ordner `install` vom Server (oder sperrt ihn). Sonst könnte jemand die Installation ein zweites Mal starten und eure Daten überschreiben.
---
## Schnellstart
Direkt nach der Installation läuft DeskHub, kann aber noch keine E-Mails verschicken oder abrufen. Arbeitet diese Liste einmal von oben nach unten ab:
| # | Was | Wo | Warum |
|---|---|---|---|
| 1 | **Abteilungen anlegen** (z. B. „Technik“, „Vertrieb“) | Adminportal → Abteilungen | Jedes Ticket gehört zu genau einer Abteilung |
| 2 | **SMTP einrichten** (E-Mail-Versand) | Adminportal → Einstellungen → E-Mail | Damit Kunden Bestätigungen bekommen |
| 3 | **Postfach einrichten** (E-Mail-Abruf, optional) | Adminportal → Postfächer (Abruf) | Damit eingehende Mails zu Tickets werden |
| 4 | **Automatisierung prüfen** | Adminportal → Einstellungen → Cron-Jobs | Damit Versand, Abruf und Prüfungen von selbst laufen |
| 5 | **Mitarbeiter anlegen** | Adminportal → Mitarbeiter | Damit nicht nur ihr allein Tickets bearbeitet |
---
## Anmelden
Es gibt zwei getrennte Logins:
- **Kunden** melden sich unter `/login.php` an (oder erstellen ohne Konto ein Ticket).
- **Mitarbeiter und Admins** melden sich unter `/staff/login.php` an.
Ein Kunden-Konto hat keinen Zugang zum Mitarbeiterbereich und umgekehrt.
---
## Für Kunden: Ein Ticket erstellen
1. Auf der Startseite **„Neues Ticket eröffnen“** wählen.
2. Name, E-Mail-Adresse und Anliegen eintragen.
3. Nach dem Absenden gibt es eine **Ticketnummer** auf der Seite und per E-Mail.
4. Mit dieser Nummer lässt sich jederzeit unter **„Ticketstatus überprüfen“** nachsehen, ob es eine Antwort gibt, auch ohne Konto.
---
## Für Mitarbeiter: Tickets bearbeiten
Im Mitarbeiterbereich gibt es links im Menü:
- **Übersicht**: wie viele Tickets offen, euch zugewiesen oder überfällig sind
- **Tickets**: die komplette Liste mit Suche, Filtern und gespeicherten Ansichten
- **Aufgaben**: eigene To-dos, unabhängig von Tickets
- **Berichte**: Auswertungen zu Antwortzeiten und Ticketaufkommen
- **Sperrliste**: einzelne E-Mail-Adressen oder ganze Mail-Anbieter blockieren
- **Verzeichnis**: Kontaktliste aller Kollegen
In einem Ticket könnt ihr dem Kunden antworten, eine **interne Notiz** hinterlassen (nur das Team sieht sie), den Status ändern, das Ticket zuweisen, lösen oder schließen. Welche dieser Aktionen möglich sind, hängt von eurer Rolle in der Abteilung des Tickets ab.
---
## Für Admins: Das Adminportal
Im Menü gibt es den Punkt **„Adminportal“**. Er ist für echte Admins immer sichtbar und für Co-Admins mit passenden Einzelrechten (siehe [Rechte und Rollen](#rechte-und-rollen)). Sieht ein Admin den Punkt nicht: einmal ab- und wieder anmelden und prüfen, ob der Bereich eingeklappt ist (Pfeil daneben).
**Verwalten**
| Punkt | Wofür |
|---|---|
| Abteilungen | Themenbereiche, denen Tickets zugeordnet werden; mit Status, Abteilungsleiter, Mitgliedern und SLA-Plan |
| Prioritäten / SLA | Dringlichkeitsstufen mit Reaktions- und Lösungszeiten |
| SLA-Pläne | Eigene Zeiten pro Abteilung oder Hilfethema |
| Hilfethemen | Auswahl, die Kunden beim Ticket-Erstellen sehen, mit Zuordnung zu Abteilung, Priorität und Team |
| Ticket-Felder, Benutzerdefinierte Listen | Eigene Zusatzfelder für Tickets |
| Vorlagen | Textbausteine für schnelle Antworten |
| Seiten | Frei gestaltbare Inhaltsseiten im Kundenportal (z. B. AGB, Impressum) |
| E-Mail-Adressen | Absender- und Eingangsadressen, jeweils mit Abteilung, Hilfethema, optional eigenem SMTP und Test-Knopf |
| Postfächer (Abruf) | Von wo E-Mails abgerufen werden |
| E-Mail-Vorlagen | Texte der automatisch verschickten Mails |
| Wissensdatenbank | Öffentliche Hilfe-Artikel für Kunden |
| API-Schlüssel, Webhooks | Anbindung anderer Systeme |
| Plugins | Zusatzfunktionen installieren und verwalten |
**Kunden**
| Punkt | Wofür |
|---|---|
| Kundenverzeichnis | Alle Kunden-Konten im Überblick |
| Organisationen | Mehrere Kunden zu einer Firma bündeln |
**Agenten**
| Punkt | Wofür |
|---|---|
| Mitarbeiter | Konten anlegen, Abteilungen, Rolle je Abteilung vergeben |
| Teams | Mitarbeiter-Gruppen, z. B. für Zuweisungen |
| Rollen | Berechtigungssets |
**System**
| Punkt | Wofür |
|---|---|
| Admin-Protokoll | Alle wichtigen Änderungen und fehlgeschlagene automatische E-Mails |
| Ticket-Regeln | Automatische Aktionen bei neuen Tickets |
| Eskalationsregeln | Automatische Aktionen bei überfälligen oder liegengebliebenen Tickets |
| Wiederkehrende Tickets | Regelmäßig neu erstellte Tickets (z. B. monatliche Checkliste) |
| Zeiterfassung | Erfasste Bearbeitungszeiten einsehen |
| Geschäftszeiten | Zeitfenster, in denen die SLA-Uhr läuft, inklusive Feiertage |
| Einstellungen | Name, Logo, Zeitzone, E-Mail, Cron-Jobs, Datenschutz, Design u. v. m. |
| Backup | Manuelle und automatische Sicherungen |
| System-Update | Programm und Datenbank aktualisieren |
### Kundenportal-Design (Themes)
Unter **Einstellungen → Design** lassen sich mehrere Themes als ZIP-Datei hochladen, aktivieren und löschen. Ein aktiviertes Theme gilt für das ganze Kundenportal. Mit **„Standarddesign nutzen“** wird es wieder deaktiviert. Der Anzeigename wird aus dem Dateinamen der ZIP übernommen; lädt man ein Theme mit gleichem Namen erneut hoch, ersetzt es die vorhandene Version. Fertige Themes liegen im Ordner `theme-packages/`. Außerdem könnt ihr Themes direkt aus dem Katalog der Plugin-Webseite installieren: Auf derselben Seite erscheint unter **„Themes aus dem Katalog“** das Angebot automatisch. Prüfsumme und Signatur (mit dem unter Plugins hinterlegten Herausgeber-Schlüssel) werden vor der Installation geprüft; aktiviert wird das Theme erst, wenn ihr es selbst aktiviert.
---
## E-Mails versenden (SMTP)
Das ist der wichtigste erste Schritt: Damit Kunden eine Bestätigung mit ihrer Ticketnummer bekommen, muss der E-Mail-Versand eingerichtet sein.
1. Adminportal → **Einstellungen → E-Mail**.
2. Server, Port, Benutzername, Passwort und Absenderadresse eures E-Mail-Anbieters eintragen. Diese Daten findet ihr im Kundenlogin eures Anbieters unter „E-Mail-Konten“ oder „Postfach-Einstellungen“.
3. Auf **„Test-Mail senden“** klicken. Kommt sie an, funktioniert der Versand.
**Eigener Versand pro Absenderadresse:** Unter Adminportal → **E-Mail-Adressen** kann jede Adresse einen eigenen SMTP-Server bekommen (Reiter „Abgehend (SMTP)“). Mit **„Testen“** prüft ihr Abruf und Versand jeder Adresse einzeln. Gibt es für eine Adresse keinen eigenen Server, wird der allgemeine verwendet.
**Häufigste Stolperfalle:** Die Absenderadresse einer Abteilung muss bei eurem Mail-Anbieter existieren und freigeschaltet sein. Sonst blockt der Anbieter den Versand. Im Zweifel die Adresse deaktivieren oder löschen, dann gilt die Standard-Absenderadresse.
Kommt trotz erfolgreicher Test-Mail eine bestimmte automatische Mail nicht an, zeigt das **Admin-Protokoll** die genaue Fehlermeldung des Anbieters.
---
## E-Mails automatisch abrufen (Postfächer)
Damit eine Mail an z. B. `support@eure-domain.de` automatisch als Ticket erscheint, braucht DeskHub Zugriff auf das Postfach per **IMAP**.
### Ein Postfach einrichten
1. Adminportal → **Postfächer (Abruf)** → „Neues Postfach“.
2. Server, Port, Benutzername und Passwort eintragen (meist unter „IMAP-Zugang“ beim Anbieter).
3. Optional eine **Standardabteilung** wählen, sonst entscheidet die Empfängeradresse.
4. Speichern und in der Liste auf **„Testen“** klicken. Läuft der Test, ruft DeskHub das Postfach ab, sobald die [Automatisierung](#automatisierung-cronjobs) aktiv ist.
### Mehrere Postfächer
Habt ihr getrennte Postfächer pro Abteilung (z. B. `technik@` und `vertrieb@`), legt ihr für jedes ein eigenes Postfach mit eigener Standardabteilung an. DeskHub ruft alle aktiven Postfächer nacheinander ab.
### Eine Adresse einer Abteilung zuordnen
Läuft alles in einem Sammelpostfach zusammen (z. B. per Weiterleitung), muss DeskHub wissen, welche Empfängeradresse zu welcher Abteilung gehört:
- Tragt bei einer Abteilung eine E-Mail-Adresse ein, dann legt DeskHub den Eintrag automatisch an.
- Oder legt unter Adminportal → **E-Mail-Adressen** weitere Adressen an, auch mehrere auf einmal (eine pro Zeile). Dort lässt sich zusätzlich ein **Hilfethema** festlegen, das Tickets über diese Adresse automatisch bekommen.
Ist eine Adresse nirgends hinterlegt, landet das Ticket in der Standardabteilung. Ist die zugeordnete Abteilung deaktiviert, wird ebenfalls die Standardabteilung genutzt.
### Nach dem Abruf
Pro Postfach legt ihr fest, was mit einer verarbeiteten Mail passiert: als gelesen markieren (Voreinstellung und sicherste Wahl), in einen Ordner verschieben oder löschen.
---
## Automatisierung (Cronjobs)
Einige Aufgaben müssen regelmäßig von selbst laufen:
- **E-Mail-Abruf** (alle 5 Minuten empfohlen)
- **SLA-Prüfung** (alle 15 Minuten)
- **Eskalationsregeln** (stündlich)
- **Wiederkehrende Tickets** (täglich)
- **Automatische Backups** (nach Zeitplan)
- **Datenanonymisierung** (täglich)
Dafür gibt es unter Adminportal → **Einstellungen → Cron-Jobs** drei Wege:
**Option A – Automatischer Hintergrund-Betrieb (Standard):** Ist von Anfang an aktiv. Sobald jemand die Website, das Backend oder eine App nutzt, prüft DeskHub im Hintergrund, ob etwas fällig ist, und führt es aus. Hat eure Seite regelmäßig Besucher, reicht das für die meisten Fälle.
**Option B – Echter Server-Cronjob:** Mit eigenem (v)Server und Shell-Zugriff trägt DeskHub per Klick auf **„Jetzt installieren“** die nötigen Cronjobs selbst ein. Dann läuft alles zuverlässig zur exakten Zeit, auch nachts ohne Besucher. Auf einfachem Webhosting ohne Shell ist der Knopf ausgeblendet, dort übernimmt Option A.
**Option C – Manuell:** Jede Aufgabe hat einen Knopf **„Jetzt ausführen“**.
Nach der Installation müsst ihr nichts tun, Option A läuft von selbst.
---
## SLA-Pläne
Ein SLA-Plan legt fest, wie schnell reagiert und gelöst werden soll. Unter Adminportal → **SLA-Pläne** legt ihr Pläne mit Reaktions- und Lösungszeit an und weist sie Abteilungen oder Hilfethemen zu.
Welche Zeiten für ein Ticket gelten, entscheidet diese Reihenfolge:
1. SLA-Plan des **Hilfethemas**
2. SLA-Plan der **Abteilung**
3. Zeiten der **Priorität**
So kann ein Premium-Hilfethema schneller reagieren müssen als der Rest der Abteilung. Die Geschäftszeiten und Feiertage gelten weiterhin.
---
## Gespeicherte Ansichten
In der Ticketliste lassen sich die aktuellen Filter (Status, Zuweisung, Abteilung, Priorität, Überfälligkeit usw.) als **Ansicht** speichern. Gespeicherte Ansichten erscheinen als Schnellauswahl über der Liste. Ihr könnt sie für euch behalten oder mit dem ganzen Team teilen. Gemeinsame Ansichten sind in Web, Windows-App und Android-App gleich verfügbar. Speichern und Löschen funktioniert direkt über der Ticketliste („Aktuelle Filter als Ansicht speichern“, ✕ neben der Ansicht). Löschen können der Ersteller einer Ansicht und alle Admins.
---
## Berichte und Bericht-Baukasten
Unter **Berichte** gibt es die Standardauswertungen. Der **Bericht-Baukasten** erlaubt eigene Auswertungen:
- **Gruppieren nach:** Abteilung, Hilfethema, Mitarbeiter, Team, Priorität, Status, Quelle, Tag, Woche oder Monat
- **Kennzahlen:** Tickets gesamt, offen, gelöst/geschlossen, überfällig, durchschnittliche erste Antwort und Lösungszeit sowie SLA-Erfüllung bei Antwort und Lösung
- **Zeitraum und Filter:** nach Abteilung, Priorität, Mitarbeiter, Hilfethema, Status und Quelle
- **Speichern und teilen:** Berichte lassen sich benennen, speichern und mit dem Team teilen
- **Export** als CSV (kompatibel mit Excel)
Jeder sieht nur Zahlen aus Abteilungen, auf die er Zugriff hat. Der Baukasten ist auch in Windows-App und Android-App verfügbar.
---
## Integrationen: API-Schlüssel und Webhooks
Mit **API-Schlüsseln** und **Webhooks** verbindet ihr DeskHub mit anderen Systemen (z. B. Monitoring, Formulare, Chat-Tools).
**API-Schlüssel** (Adminportal → API-Schlüssel): Ein Schlüssel erlaubt einem anderen System, Tickets in DeskHub anzulegen. Beim Erstellen wird der Schlüssel **einmalig** angezeigt, danach nie wieder; notiert ihn sofort. Pro Schlüssel legt ihr fest, ob er Tickets anlegen und/oder lesen darf, und optional, von welchen IP-Adressen er benutzt werden darf. Schlüssel lassen sich jederzeit deaktivieren. Es gilt eine Begrenzung auf 60 Tickets pro Minute.
**Webhooks** (Adminportal → Webhooks): DeskHub meldet Ereignisse an eine Adresse eures Systems: Ticket erstellt, Antwort geschrieben, Status geändert, Ticket zugewiesen. Jede Meldung ist mit eurem geheimen Schlüssel signiert, damit das Empfängersystem die Echtheit prüfen kann. Der Zustellstatus steht in der Liste. Schlägt eine Adresse zehnmal hintereinander fehl, wird der Webhook automatisch deaktiviert. Adressen im internen Netzwerk sind aus Sicherheitsgründen nicht erlaubt.
Die technische Beschreibung für Entwickler steht im Abschnitt [API und Webhooks (für Entwickler)](#api-und-webhooks-für-entwickler).
---
## Rechte und Rollen
Nicht jeder im Team soll vollen Admin-Zugriff haben. Dafür gibt es **Rollen**:
1. Adminportal → **Rollen** → „Neue Rolle“.
2. Namen vergeben (z. B. „Teamleitung Technik“).
3. Ticket-Berechtigungen ankreuzen (antworten, Notizen, zuweisen, schließen, löschen …).
4. Optional einzelne **Adminportal-Bereiche** freischalten (z. B. nur „Abteilungen“ und „Mitarbeiter“). Das macht die Person zum **Co-Admin** für genau diese Bereiche.
5. Unter **Mitarbeiter** die Rolle zuweisen.
### Rolle pro Abteilung
Eine Person kann in verschiedenen Abteilungen unterschiedliche Rollen haben, etwa „Teamleitung“ in Technik und „Agent“ in Vertrieb. Das stellt ihr im Mitarbeiter-Konto unter den zusätzlichen Abteilungen ein. Die Rechte in einem Ticket richten sich nach der Abteilung dieses Tickets, in Web und in beiden Apps. Eine deaktivierte Rolle ersetzt DeskHub durch die Standardrechte eines Agenten.
Einige Bereiche (Backup, System-Update, Ticket-Regeln, Ticket-Felder, Seiten, Kundenverzeichnis, Plugins, API-Schlüssel, Webhooks) bleiben bewusst immer echten Admins vorbehalten. Ein Co-Admin kann sich oder andere nie zum echten Admin machen.
---
## Überwachung und Störungsmeldungen
DeskHub überwacht sich selbst und meldet Probleme, bevor Kunden sie bemerken:
- **Postfach nicht erreichbar:** Schlägt der E-Mail-Abruf mehrfach hintereinander fehl, erscheint für Verantwortliche ein rotes Banner im Mitarbeiterbereich und in den Apps. Außerdem bekommen die Admins eine E-Mail (höchstens einmal pro 24 Stunden) und eine weitere, sobald das Postfach wieder funktioniert.
- **Abruf veraltet:** Wurde länger nicht abgerufen, gibt es einen Hinweis.
- **Versand fehlgeschlagen:** Scheitern ausgehende Mails, steht das ebenfalls im Banner.
- **Keine Verbindung zum Server:** Die Windows-App und die Android-App zeigen ein Banner, wenn der Server nicht erreichbar ist. Sie versuchen automatisch wieder, die Verbindung herzustellen, zusätzlich gibt es einen Knopf **„Jetzt erneut versuchen“**.
Die Details zu einer Störung seht ihr unter Adminportal → E-Mail-Adressen und Postfächer (Abruf).
---
## Windows-App und Android-App
Beide Apps sind für Mitarbeiter und Admins gedacht und haben ein helles und ein dunkles Design (umschaltbar oder automatisch nach System).
**Download:** Im Mitarbeiterbereich gibt es im Menü den Punkt **„Apps“**. Dort laden angemeldete Mitarbeiter die Windows- und die Android-App herunter und sehen Version, Größe, Prüfsumme, eine Kurzanleitung und die Serveradresse zum Kopieren. Ohne Anmeldung sind die Dateien nicht erreichbar.
**Für Admins – Apps bereitstellen:** Legt die fertigen Dateien per FTP in den Ordner `app_downloads` auf dem Server, genau mit diesen Namen: `DeskHub-Setup.exe` (Windows) und `DeskHub.apk` (Android). Zum Aktualisieren ersetzt ihr die Datei einfach. Die Seite „Apps“ zeigt automatisch Größe, Datum und Prüfsumme der neuen Datei. Der Ordner wird bei System-Updates nie überschrieben und ist nur über die Anmeldung erreichbar.
**Einrichtung:**
1. App installieren und öffnen.
2. Beim ersten Start die Adresse eurer DeskHub-Installation eingeben (z. B. `https://eure-domain.de`). Die App merkt sie sich.
3. Mit dem Mitarbeiter-Konto anmelden.
**Was die Apps können:**
- Tickets bearbeiten (antworten, Notizen, zuweisen, Status ändern, lösen, schließen), Übersicht, Kundenverzeichnis, internen Chat mit Kollegen
- 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
**Updates:** Die Android-App wird nur dann über die alte Version installiert, wenn sie mit demselben Signaturschlüssel erstellt wurde und eine höhere Versionsnummer hat. Nach einem Update die App einmal ganz schließen und neu öffnen.
---
## System-Update und Datenbank-Update
Adminportal → **System-Update** hat zwei Teile:
**Neue Programmversion installieren:** Zeigt verfügbare Versionen und installiert die gewählte mit einem Klick (oder per hochgeladener ZIP). Vor jedem Update wird automatisch ein Backup erstellt. Eure Konfiguration, die Datenbank, hochgeladene Dateien, bereitgestellte Apps, Backups und Plugins werden nie überschrieben. Der Download erfolgt nur über eine geprüfte, verschlüsselte Verbindung. Danach wird die Datenbank automatisch angepasst.
**Datenbank aktualisieren:** Die Karte „Datenbank“ zeigt, ob die Datenbank zum installierten Programm passt. Wenn nicht, erscheint dort und für Admins oben auf jeder Seite ein Hinweis. Der Knopf **„Datenbank aktualisieren“** erstellt zuerst ein Backup und spielt dann alle fehlenden Änderungen ein. Das ist der richtige Weg, wenn ihr Dateien per FTP hochgeladen habt. Scheitert das Backup, wird die Datenbank nicht angefasst.
**Signierte Updates:** Ist in DeskHub ein Herausgeber-Schlüssel eingebaut (`includes/catalog_defaults.php`), installiert das System-Update nur Releases, die mit diesem Schlüssel signiert sind. Ein manipuliertes Paket oder ein Release ohne Signatur wird abgelehnt und im Admin-Protokoll vermerkt. Ein manuell hochgeladenes ZIP wird nicht geprüft, ladet nur Pakete aus vertrauenswürdiger Quelle hoch. Es steckt kein Zugangstoken mehr im Programmcode: Die Releases werden öffentlich gelesen. Ist das Repository privat, tragt ihr in `config/config.php` ein Lese-Token ein: `'update' => ['token' => '…']` (nur Leserecht vergeben).
**Release veröffentlichen (für den Herausgeber):**
1. ZIP des Projekts als `deskhub-<version>.zip` packen (ohne `config/config.php`, Datenbank, Backups, Uploads, `plugins/`, `node_modules`).
2. Signieren: `php tools/release-sign.php deskhub-1.0.7.zip herausgeber-privat.pem 1.0.7` (derselbe private Schlüssel wie für Plugins und Themes). Das erzeugt `deskhub-1.0.7.zip.sig`.
3. Im Gitea-Release **beide Dateien als Anhänge** hochladen, mit genau diesen Namen.
Nach jedem Update: Browser-Cache leeren (Strg + F5).
---
## Import aus osTicket
Wer von osTicket auf DeskHub umzieht, kann den Bestand übernehmen: Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten (Kundennachrichten, Antworten, interne Notizen), Mitlesende und Anhänge. Unterstützt werden SQL-Dumps von osTicket 1.10 bis 1.18, als phpMyAdmin-Export oder mit `mysqldump`, auch mit einem anderen Tabellenpräfix als `ost_`.
### Vorbereitung
1. **Backup von DeskHub erstellen** (Adminportal → Backup). Der Import lässt sich wiederholen, ohne etwas doppelt anzulegen, ein Backup ist trotzdem sinnvoll.
2. **Mitarbeiter in DeskHub anlegen.** osTicket-Mitarbeiter werden über die **E-Mail-Adresse** den DeskHub-Mitarbeitern zugeordnet. Wer in DeskHub fehlt, kann nicht zugewiesen werden: Seine Tickets werden unzugewiesen importiert, seine Antworten ohne Mitarbeiter übernommen. Legt deshalb vorher alle Kollegen mit derselben E-Mail-Adresse an wie in osTicket.
3. **Dump erstellen.** In osTicket (oder phpMyAdmin) die komplette Datenbank als SQL exportieren. Der Dump darf groß sein: Ein Export mit mehreren hundert MB (Anhänge und Sitzungsdaten) funktioniert, die Tabellen werden blockweise gelesen.
4. **Nur bei MySQL-Hosting mit knappen Limits:** Große Dateien legt ihr besser per FTP in den DeskHub-Ordner (Option 1), statt sie im Browser hochzuladen.
### Durchführung
Adminportal → **Einstellungen → „📥 osTicket Import“**.
1. **Hochladen:** Entweder den Dateinamen eintragen, wenn die SQL-Datei im DeskHub-Hauptordner liegt (**empfohlen**, vor allem bei großen Dateien), oder die Datei direkt hochladen (sie wird in 5-MB-Teilen übertragen).
2. **Analysieren:** DeskHub liest die Datei und zeigt, was sie enthält (Kunden, Tickets, Nachrichten, Abteilungen, Hilfethemen, Anhänge). Der Fortschrittsbalken läuft von selbst, das Fenster muss offen bleiben.
3. **Optionen:** Auswählen, was importiert werden soll. Die Reihenfolge ist fest: Kunden → Abteilungen und Hilfethemen → Tickets → Nachrichten → Anhänge. Anhänge setzen die Nachrichten voraus.
4. **Import:** Läuft in kleinen Schritten, mit Fortschritt je Bereich. Am Ende steht eine Zusammenfassung mit Hinweisen.
### Was dabei passiert
| osTicket | DeskHub |
|---|---|
| Kunden | Kunden mit Name und E-Mail-Adresse (ohne Adresse werden sie übersprungen). Zugangsdaten werden nicht übernommen, Kunden vergeben bei Bedarf ein neues Passwort. |
| Abteilungen, Hilfethemen | Fehlende werden angelegt, vorhandene mit **gleichem Namen** wiederverwendet. Hilfethemen kommen mit der richtigen Abteilung und Priorität. |
| Ticketstatus | Offen → offen, Gelöst → gelöst, Geschlossen und Archiviert → geschlossen. **Gelöschte Tickets werden nicht importiert.** |
| Priorität | Nach Namen, sonst nach Rangfolge (dringendste zuerst). |
| Mitarbeiter | Über die E-Mail-Adresse (siehe Vorbereitung). |
| Quelle | Web, E-Mail, Telefon und API werden übernommen, Sonstiges wird zu Web. |
| Zeiten | Erstellt, zuletzt geändert und gelöst/geschlossen bleiben erhalten. Die erste Antwort wird aus der ersten Mitarbeiter-Antwort abgeleitet. Offene Tickets bekommen neue SLA-Fristen nach euren Zeiten. |
| Nachrichten | Kundennachrichten werden als Text übernommen (Zitate früherer Mails werden entfernt), Antworten und interne Notizen behalten ihre Formatierung. Nachrichten ohne Text werden ausgelassen, außer sie haben einen Anhang. |
| Anhänge | Dateien bis 50 MB. Eingebettete Bilder in Mails (Logos, Signaturen) werden nicht als Anhang übernommen. Gespeichert wird unter einem zufälligen Namen, die Endung bleibt bei bekannten Dateitypen erhalten, alles andere wird `.bin`. |
### Gut zu wissen
- **Wiederholbar:** Vorhandene Ticketnummern und Kunden werden übersprungen, bereits importierte Nachrichten und Anhänge nicht doppelt angelegt. Ein abgebrochener Import lässt sich also einfach neu starten.
- **Löschen:** Auf der Optionsseite gibt es „Nur Nachrichten löschen“ und „Alle Tickets löschen“, falls ein Import neu beginnen soll. Beides löscht Daten in DeskHub unwiderruflich, auch **echte** Tickets, die nicht aus dem Import stammen.
- **Nach dem Import:** Stichproben ansehen (Ticketliste, ein paar Tickets mit Anhängen), ob die Abteilungen stimmen und ob Kunden ihre Tickets finden.
- **Nicht importiert:** Eigene Formularfelder, Aufgaben, Wissensdatenbank (FAQ), Benutzerkonten und Passwörter, Mitarbeiter-Konten, Vorlagen, Regeln und Einstellungen. Diese richtet ihr in DeskHub neu ein.
- **Dauer:** Ein Dump mit rund 1.700 Tickets und 450 MB Anhangsdaten braucht gut 3 Minuten für das Hochladen (bei Direkt-Upload) und etwa eine halbe Minute für Analyse und Import.
---
## Plugins verwenden
Mit Plugins rüstet ihr zusätzliche Funktionen nach, ohne DeskHub selbst zu verändern. Alles läuft unter Adminportal → **Plugins** (nur für echte Admins).
**Geprüfter Modus (Standard, wenn euer DeskHub einen offiziellen Katalog mitbringt):** Der Herausgeber eurer DeskHub-Version trägt vor dem Release seinen eigenen Katalog und seinen Signierschlüssel ein (siehe [Plugin- und Theme-Webseite](#plugin--und-theme-webseite)). Dann zeigt die Plugin-Seite oben **„Geprüfte Plugins“** mit dem aktuellen Angebot, und es gilt:
- Plugins **aus dem Katalog** müssen mit dem Schlüssel des Herausgebers **signiert** sein. Nicht signierte oder fremd signierte Katalog-Pakete werden abgelehnt.
- **Eigene Plugins** lassen sich zusätzlich über „Eigenes Plugin hochladen“ installieren, auch ohne Signatur (siehe unten). Sie sind in der Liste als „Eigenes Plugin / nicht vom Herausgeber geprüft“ markiert.
- Der Katalog lädt **automatisch**: Er erscheint ohne Klick, DeskHub hält ihn im Hintergrund alle 30 Minuten aktuell (über die Automatisierung), und bei Ausfall des Katalog-Servers bleibt der zuletzt geladene Stand sichtbar. Gibt es für ein installiertes Plugin eine neuere Version, erscheint für Admins oben auf jeder Seite der Hinweis „Plugin-Updates verfügbar“. Installiert wird ein Update nie von selbst, sondern erst, wenn ihr „Aktualisieren“ wählt. Mit „Katalog aktualisieren“ lässt sich der Abruf auf Wunsch sofort auslösen.
Katalog und Schlüssel sind **fest in DeskHub eingebaut** (`includes/catalog_defaults.php`, Teil des Programmcodes). Es gibt keine Einstellung, Umgebungsvariable oder Oberfläche, mit der Admins den Katalog ändern oder den Signaturzwang für Katalog-Plugins abschalten können. Der Katalog ist zusätzlich durch einen **Zugangsschlüssel** geschützt, den DeskHub bei jedem Abruf mitschickt (nur an den offiziellen Katalog, nur über https). Er hält zufällige Besucher fern, ist aber kein Geheimnis gegenüber Admins, die den DeskHub-Code lesen können. Ändern lässt sich das nur durch den Herausgeber mit einer neuen DeskHub-Version. Themes sind davon nur beim Katalog betroffen: Er ist vorgegeben und Themes müssen signiert sein; der manuelle Theme-Upload (nur CSS, Bilder, Schriften) bleibt möglich.
**Aus dem Katalog installieren (ohne offiziellen Katalog):** Die Plugin-Webseite (siehe [Plugin- und Theme-Webseite](#plugin--und-theme-webseite)) liefert einen Katalog. Tragt unter Plugins → „Katalog und Herausgeber-Schlüssel“ die Katalog-Adresse und den öffentlichen Herausgeber-Schlüssel ein (den Schlüssel liefert der Betreiber der Webseite, abrufbar unter `/publisher-key.php`) und aktiviert „Nur signierte Plugins installieren“. Danach seht ihr alle Plugins mit Version, Beschreibung und angeforderten Berechtigungen und installiert sie mit einem Klick. DeskHub lädt nur über https, vergleicht die Prüfsumme und prüft die Signatur, bevor etwas installiert wird.
**Manuell / eigene Plugins installieren** (auch im geprüften Modus möglich, damit ihr selbst entwickelte Plugins nutzen könnt):
1. Plugin als ZIP-Datei bereithalten. Nennt der Anbieter eine Prüfsumme (SHA-256) oder eine Signatur, tragt sie ein, dann wird das Paket vor der Installation geprüft.
2. ZIP hochladen und bestätigen, dass ihr der Quelle vertraut.
3. Das Plugin ist danach **noch inaktiv**. Mit **„Aktivieren“** schaltet ihr es ein. Vorher bestätigt ihr die **Berechtigungen**, die das Plugin anfordert (siehe unten). Dabei wird ein Backup erstellt und das Plugin legt bei Bedarf eigene Datenbank-Tabellen an.
**Berechtigungen:** Jedes Plugin nennt, welche Erweiterungspunkte es nutzen will: auf Ticket-Ereignisse reagieren, im Hintergrund laufen, Menüpunkte und Seiten anlegen, Bausteine in der Ticketansicht zeigen, eigene API-Endpunkte bereitstellen, eigene Datenbank-Tabellen anlegen. Ihr seht die Liste vor dem Aktivieren und bestätigt sie. DeskHub lässt das Plugin nur diese Erweiterungspunkte registrieren. Fordert eine neue Version zusätzliche Berechtigungen an, wird das Plugin **automatisch angehalten**, bis ihr die neuen bestätigt habt. Das gilt auch, wenn jemand Dateien per FTP austauscht.
**Verwalten:** Plugins lassen sich deaktivieren, durch eine neuere Version ersetzen (gleiche Datei erneut hochladen) und entfernen. Beim Entfernen könnt ihr wählen, ob die Daten des Plugins gelöscht werden sollen.
**Sicherheit, bitte unbedingt beachten:**
- Ein Plugin ist Programmcode und läuft mit **allen Rechten des Systems**, also auch mit Zugriff auf die gesamte Datenbank. Es gibt keine Abschirmung: Die Berechtigungen begrenzen nur, welche Erweiterungspunkte ein Plugin nutzen darf, nicht was sein Code sonst tut. Wirklichen Schutz bietet die **Herkunft**: Installiert nur Plugins aus Quellen, denen ihr vertraut, und aktiviert „Nur signierte Plugins installieren“.
- **Signatur:** Eine Signatur belegt, dass ein Paket unverändert vom Inhaber des hinterlegten Herausgeber-Schlüssels stammt. Ein verändertes Paket, eine fremde Signatur oder eine auf eine andere Version übertragene Signatur wird abgelehnt. In der Plugin-Liste steht bei jedem Plugin „Signiert“ oder „Nicht signiert“ und woher es kam.
- Fehler in einem Plugin legen das System nicht lahm: Das Ticket oder die Seite funktioniert weiter. Nach drei Fehlern wird das Plugin automatisch deaktiviert, der Fehlertext steht in der Plugin-Liste.
- **Notausschalter:** Legt auf dem Server im Ordner `plugins` eine leere Datei namens `.disabled` an, dann wird kein Plugin mehr geladen. Zum Wiederaktivieren die Datei löschen.
- Der Ordner `plugins` wird bei System-Updates nie überschrieben.
### Mitgeliefertes Plugin: Tagesbericht per E-Mail
Unter `docs/plugins/tagesbericht-1.0.0.zip` liegt ein fertiges Plugin. Es schickt Mitarbeitern jeden Morgen eine Übersicht: offene, unzugewiesene, überfällige und eigene Tickets, Zugänge und Erledigungen der letzten 24 Stunden sowie die fünf ältesten offenen Tickets. Jeder sieht nur Tickets seiner Abteilungen.
Nach Installation und Aktivierung findet ihr im Menü den Punkt **„Tagesbericht“**: Dort schaltet ihr den Versand ein, legt Uhrzeit, Werktage und Empfänger fest und könnt einen **Testbericht** an euch schicken. Voraussetzung ist der eingerichtete E-Mail-Versand.
---
## Plugins entwickeln
Dieser Abschnitt richtet sich an Entwickler. Ein vollständiges, lauffähiges Beispiel liegt in `docs/plugin-beispiel/`, ein praxisnahes in `docs/plugins/tagesbericht/`.
### Aufbau
Ein Plugin ist ein Ordner, der als ZIP installiert wird:
```
mein-plugin/
plugin.json Pflicht: Beschreibung
plugin.php Startdatei (Name über "entry" änderbar), registriert die Erweiterungen
pages/*.php Seiten, die als Menüpunkt registriert sind
migrations/ Datenbank-Änderungen (*.sql)
assets/ js, css, Bilder
```
Die ZIP darf die Dateien direkt im Wurzelverzeichnis oder in genau einem Unterordner enthalten. Erlaubte Dateitypen: `php, json, sql, js, css, png, jpg, jpeg, gif, svg, webp, md, txt, html`. Versteckte Dateien (`.htaccess` usw.) und Pfade mit `..` werden abgelehnt. Größe: maximal 10 MB. Direkte Aufrufe von `.php`, `.sql` und `.json` im Plugin-Ordner sind per `.htaccess` gesperrt (nginx: eigene Regel nötig).
### plugin.json
```json
{
"slug": "mein-plugin",
"name": "Mein Plugin",
"version": "1.0.0",
"description": "Kurze Beschreibung",
"author": "Name",
"requires": "1.0.0",
"entry": "plugin.php",
"permissions": ["hooks.tickets", "ui.menu"]
}
```
| Feld | Bedeutung |
|---|---|
| `slug` | Pflicht. Eindeutiger Name: Kleinbuchstaben, Ziffern, `_` und `-`, 2–41 Zeichen. Reserviert sind `core`, `system`, `admin`, `staff`, `api`. |
| `name`, `version` | Pflicht. Version im Format `1.0.0`. |
| `requires` | Optional. Mindestversion von DeskHub; ist die installierte kleiner, wird die Installation abgelehnt. |
| `entry` | Optional. Startdatei, Standard `plugin.php`. |
| `permissions` | Optional. Liste der Erweiterungspunkte, die das Plugin nutzen will (siehe unten). Unbekannte Werte lehnt DeskHub ab. |
### Berechtigungen
Ein Plugin muss die Erweiterungspunkte, die es registriert, in `permissions` angeben. Der Admin bestätigt die Liste bei der Aktivierung. Versucht ein Plugin einen nicht deklarierten Erweiterungspunkt zu registrieren, wird die Registrierung abgelehnt und im Plugin vermerkt (das Plugin läuft sonst weiter).
| Berechtigung | erlaubt |
|---|---|
| `hooks.tickets` | `plugin_add_hook` für `ticket.*`-Ereignisse und Filter mit `ticket.`-Präfix |
| `hooks.cron` | `plugin_add_hook('cron.run', …)` |
| `ui.menu` | `plugin_register_menu` (Menüpunkt und Seite) |
| `ui.sidebar` | `plugin_add_filter('ticket.view.sidebar', …)` |
| `api.endpoints` | `plugin_register_api` |
| `db.migrations` | Dateien in `migrations/` (werden beim Aktivieren eingespielt) |
Nicht geschützt sind `plugin_setting`, `plugin.uninstall.<slug>` und eigene Hooks zwischen Plugins. Wichtig: Das ist eine Prüfung der **Erweiterungspunkte**, keine Sandbox. Beliebiger PHP-Code im Plugin kann weiterhin auf Datenbank und Dateien zugreifen.
### Erweiterungen registrieren
Die Startdatei wird bei jedem Seitenaufruf geladen, solange das Plugin aktiv ist. Sie sollte nur registrieren und schnell sein. Die Funktionen DeskHubs (`get_db()`, `e()`, `send_mail()`, `has_permission()` usw.) stehen zur Verfügung.
| Funktion | Zweck |
|---|---|
| `plugin_add_hook($hook, $fn, $priority = 10)` | Auf ein Ereignis reagieren. Rückgabewert wird ignoriert. |
| `plugin_add_filter($hook, $fn, $priority = 10)` | Einen Wert ergänzen oder verändern. Der Rückgabewert ersetzt den Wert, die Funktion erhält `($wert, $kontext)`. |
| `plugin_register_menu($label, $seite, $recht = null)` | Menüpunkt, der `pages/<seite>.php` öffnet. Optional ein Recht (z. B. `'view_reports'`), das der Mitarbeiter haben muss. |
| `plugin_register_api($name, $fn, $recht = null)` | Eigener Endpunkt `GET/POST /api/staff/v1/plugin.php?p=<slug>&endpoint=<name>` mit Bearer-Token der Mitarbeiter-API. `$fn($staff, $request)` gibt ein Array zurück, das als JSON ausgeliefert wird. |
| `plugin_setting($slug, $key, $default)` / `plugin_set_setting($slug, $key, $wert)` | Eigene Einstellungen. Sie werden beim Entfernen des Plugins mitgelöscht. |
| `plugin_hook($hook, $daten)` | Eigenes Ereignis auslösen, auf das andere Plugins reagieren können. |
### Ereignisse (Hooks)
| Hook | Wann | Daten |
|---|---|---|
| `ticket.created` | Ticket wurde erstellt (Portal, E-Mail, Chat, Mitarbeiter, API) | `event`, `ticketId` |
| `ticket.replied` | Antwort oder Notiz wurde geschrieben | `event`, `ticketId`, `author` (`staff`/`customer`), `internal` |
| `ticket.status_changed` | Status hat sich geändert | `event`, `ticketId`, `previousStatus` |
| `ticket.assigned` | Zuweisung hat sich geändert | `event`, `ticketId` |
| `cron.run` | Etwa alle 5 Minuten durch die Automatisierung | `time` |
| `plugin.uninstall.<slug>` | Beim Entfernen mit „Daten löschen“ | `slug` |
Filter: `ticket.view.sidebar` ergänzt einen HTML-Block in der Seitenleiste der Ticketansicht. Kontext: `ticketId`, `ticket`, `staff`. Das Plugin liefert fertiges HTML und muss alle Ausgaben selbst mit `e()` maskieren.
Ereignisse enthalten nur die ID. Weitere Daten holt das Plugin selbst aus der Datenbank.
### Seiten
Eine registrierte Seite `pages/log.php` erscheint im normalen Mitarbeiter-Layout (Kopf, Menü, Fuß). Verfügbar sind `$staff` (angemeldeter Mitarbeiter) und `$pdo`. Es wird nur ausgeliefert, was das Plugin selbst per `plugin_register_menu` registriert hat. Formulare müssen `csrf_field()` enthalten und beim Absenden `verify_csrf()` aufrufen. Weiterleitungen nach dem Speichern sind nicht möglich (die Seite wird innerhalb des Layouts eingebunden), zeigt das Ergebnis also direkt auf derselben Seite an.
### Datenbank
Dateien in `migrations/` werden beim Aktivieren in Dateinamen-Reihenfolge einmalig ausgeführt und protokolliert. Statements trennt ein `;`. Unterschiede zwischen den Datenbanken lösen Dialekt-Dateien:
- `001_tabelle.sqlite.sql` läuft nur auf SQLite
- `001_tabelle.mysql.sql` läuft nur auf MySQL
- `001_tabelle.sql` läuft auf beiden
Präfixt eure Tabellen mit dem Plugin-Namen (z. B. `plugin_meinplugin_log`). Zum Aufräumen beim Entfernen reagiert ihr auf `plugin.uninstall.<slug>` und löscht dort eure Tabellen.
### Fehlerverhalten
Jeder Aufruf von Plugin-Code ist abgesichert: Ein geworfener Fehler wird protokolliert und dem Plugin zugeordnet, die aufrufende Seite läuft normal weiter. Nach 3 Fehlern seit der letzten Aktivierung wird das Plugin automatisch deaktiviert. Schwere Fehler (Fatal Error, Syntaxfehler) lassen sich nicht abfangen; DeskHub deaktiviert das gerade ausgeführte Plugin beim nächsten Seitenaufruf. Plugins sollten Fehler daher möglichst selbst behandeln und nur Unerwartetes werfen.
### Mini-Beispiel
`plugin.json`:
```json
{ "slug": "ticket-zaehler", "name": "Ticket-Zähler", "version": "1.0.0" }
```
`plugin.php`:
```php
<?php
// Zählt neue Tickets und zeigt die Zahl in der Ticketansicht und auf einer eigenen Seite.
plugin_add_hook('ticket.created', function (array $e) {
$n = (int) plugin_setting('ticket-zaehler', 'anzahl', '0');
plugin_set_setting('ticket-zaehler', 'anzahl', (string) ($n + 1));
});
plugin_add_filter('ticket.view.sidebar', function (string $html, array $ctx) {
$n = (int) plugin_setting('ticket-zaehler', 'anzahl', '0');
return $html . '<div class="card" style="margin-top:16px;padding:14px 16px">Seit Plugin-Start: '
. $n . ' neue Tickets</div>';
});
plugin_register_menu('Ticket-Zähler', 'uebersicht');
```
`pages/uebersicht.php`:
```php
<h1>Ticket-Zähler</h1>
<p><?= (int) plugin_setting('ticket-zaehler', 'anzahl', '0') ?> neue Tickets seit der Aktivierung.</p>
```
### Paketieren und testen
1. Den Plugin-Ordner als ZIP packen (die Dateien direkt im Wurzelverzeichnis oder in genau einem Unterordner).
2. Als Admin unter Plugins hochladen, aktivieren, ausprobieren.
3. Tritt ein Fehler auf, steht er in der Plugin-Liste. Notausschalter: leere Datei `plugins/.disabled`.
### Signieren und veröffentlichen
Wer Plugins oder Themes verteilt, signiert sie mit einem eigenen Schlüsselpaar (RSA, SHA-256). Das Werkzeug dafür liegt im Entwickler-Paket unter `tools/plugin-sign.php`:
```bash
php tools/plugin-sign.php keygen ./schluessel # einmalig: privater + öffentlicher Schlüssel
php tools/plugin-sign.php sign mein-plugin.zip ./schluessel/herausgeber-privat.pem
php tools/plugin-sign.php sign-theme mein-theme.zip ./schluessel/herausgeber-privat.pem nordlicht 1.0.0
```
Die Ausgabe nennt SHA-256 und Signatur. Signiert wird die Nachricht `deskhub-plugin`, Name (bei Themes `theme-<slug>`), Version und SHA-256 des ZIP, getrennt durch Zeilenumbrüche, mit RSA-SHA256, Ergebnis in Base64. So lässt sich eine Signatur nicht auf eine andere Version oder ein anderes Paket übertragen. Den **privaten Schlüssel nie weitergeben**, den öffentlichen geben Admins in DeskHub unter Plugins ein.
Einfacher ist die [Plugin- und Theme-Webseite](#plugin--und-theme-webseite): Sie prüft hochgeladene Pakete, signiert automatisch und stellt den Katalog bereit. Das Katalogformat für DeskHub (zum Nachbauen einer eigenen Quelle):
```json
{ "plugins": [ {
"slug": "tagesbericht", "name": "Tagesbericht per E-Mail", "version": "1.0.0",
"description": "…", "author": "…", "requires": "1.0.0", "permissions": ["hooks.cron", "ui.menu"],
"downloadUrl": "https://plugins.example.de/download.php?type=plugin&slug=tagesbericht&version=1.0.0",
"sha256": "<SHA-256 der ZIP, 64 Hexzeichen>", "signature": "<Base64-Signatur, optional>"
} ] }
```
Für Themes heißt die Liste `themes` (ohne `requires` und `permissions`). Adressen müssen `https://` verwenden und dürfen nicht auf interne Ziele zeigen.
---
## Plugin- und Theme-Webseite
Im Ordner `plugin-webseite/` liegt eine fertige, eigenständige Webseite, von der DeskHub-Installationen Plugins und Themes laden. Sie läuft auf eigenem Webspace (PHP 8.1, SQLite) und braucht kein DeskHub. Sie bietet den Katalog für DeskHub und eine geschützte Verwaltung zum Veröffentlichen mit allen Versionen, Prüfsummen und Signaturstatus. Es gibt keine öffentliche Übersichtsseite. Hochgeladene Pakete werden geprüft (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und automatisch mit eurem Schlüssel signiert. Einrichtung in zwei Minuten per `php einrichten.php`, Details in `plugin-webseite/README.md`.
**Geprüfte Plugins für eure Kunden:** `php einrichten.php` (und in der Verwaltung der Bereich „Einstellungen für DeskHub“) gibt die drei Werte aus, die in `includes/catalog_defaults.php` von DeskHub eingetragen werden: Katalog-Adresse für Plugins, Katalog-Adresse für Themes und euren öffentlichen Schlüssel. Tragt sie vor dem Bauen eines Releases ein. Alle Installationen mit diesem Release laden dann nur Plugins, die ihr auf der Webseite veröffentlicht, also geprüft und signiert habt. Die Datei wird bei System-Updates durch die des Releases ersetzt, Kunden können die Vorgabe also nicht versehentlich verlieren. Dieser Ordner wird bei System-Updates nicht auf Kunden-Installationen kopiert.
**Zugangsschutz des Katalogs:** `docker-build.sh` / `docker-build.ps1` erzeugen beim ersten Bauen einen Zugangsschlüssel (`schluessel/katalog-zugangsschluessel.txt`) und geben ihn am Ende aus. Tragt ihn in `includes/catalog_defaults.php` als `access_key` ein. Ohne diesen Schlüssel liefern Katalog, Downloads, Vorschaubilder und `publisher-key.php` nur `401`. `health.php` bleibt offen (Docker-Healthcheck). Nach dem Bauen: Image auf dem Server austauschen und ein DeskHub-Release mit dem eingetragenen Schlüssel veröffentlichen.
---
## API und Webhooks (für Entwickler)
Dieser Abschnitt beschreibt die Schnittstellen für Fremdsysteme. Einrichten (Schlüssel erzeugen, Webhooks eintragen) tun Admins im Adminportal, siehe [Integrationen](#integrationen-api-schlüssel-und-webhooks). Alle Beispiele nutzen `https://support.example.de` als Adresse eurer Installation.
### Ticket-API
Basis-Adresse: `https://support.example.de/api/public/v1/tickets.php`
**Authentifizierung:** Jeder Aufruf braucht einen API-Schlüssel (Format `dh_` plus 48 Hexzeichen), entweder im Header `X-API-Key: dh_…` oder als `Authorization: Bearer dh_…`. Der Schlüssel wird im Adminportal unter **API-Schlüssel** erzeugt und nur einmal angezeigt. Er hat die Rechte „Tickets anlegen“ und/oder „Tickets lesen“ und kann auf IP-Adressen beschränkt sein. Aufrufe von nicht erlaubten Adressen werden wie ein falscher Schlüssel behandelt (401). Verwendet die API nur über HTTPS und nur in serverseitigem Code, nie im Browser eurer Besucher.
Anfragen und Antworten sind JSON (UTF-8). Fehler haben immer diese Form:
```json
{ "error": { "code": "INVALID_EMAIL", "message": "Bitte eine gültige E-Mail-Adresse angeben." } }
```
#### Ticket anlegen
`POST /api/public/v1/tickets.php`, Recht „Tickets anlegen“, Header `Content-Type: application/json`.
| Feld | Pflicht | Beschreibung |
|---|---|---|
| `name` | ja | Name des Anfragenden, höchstens 120 Zeichen |
| `email` | ja | Gültige E-Mail-Adresse des Anfragenden (hierhin gehen die Antworten) |
| `subject` | ja | Betreff, höchstens 255 Zeichen |
| `message` | ja | Nachricht, höchstens 50.000 Zeichen |
| `helpTopicId` | nein | ID eines aktiven Hilfethemas; legt Abteilung und Priorität fest, sofern nicht selbst angegeben |
| `departmentId` | nein | ID der Abteilung |
| `priorityId` | nein | ID der Priorität |
Die IDs seht ihr im Adminportal in der Adresszeile beim Bearbeiten (`…?edit=<ID>`). Ohne Angaben gelten die Standardabteilung und die erste Priorität der Prioritätenliste. Ticket-Regeln, SLA-Pläne und Benachrichtigungen laufen wie bei jedem anderen Ticket. Der Kunde bekommt die übliche Bestätigungs-E-Mail. Das Ticket hat die Quelle `api`.
```bash
curl -X POST https://support.example.de/api/public/v1/tickets.php \
-H "X-API-Key: dh_0123456789abcdef0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"name":"Erika Muster","email":"erika@example.de","subject":"Rechnung fehlt","message":"Bitte Rechnung 4711 erneut senden.","helpTopicId":3}'
```
Antwort `201 Created`:
```json
{ "number": "T-1042", "status": "open" }
```
#### Ticketstatus abfragen
`GET /api/public/v1/tickets.php?number=T-1042&email=erika@example.de`, Recht „Tickets lesen“.
Beide Parameter sind Pflicht. Die E-Mail-Adresse muss zum Ticket passen, sonst antwortet die API mit 404 (so erfährt niemand etwas über fremde Tickets). Interne Notizen werden nie ausgegeben.
```json
{
"ticket": {
"number": "T-1042",
"subject": "Rechnung fehlt",
"status": "answered",
"createdAt": "2026-10-15 09:12:03",
"updatedAt": "2026-10-15 10:40:51",
"messages": [
{ "author": "customer", "message": "Bitte Rechnung 4711 erneut senden.", "createdAt": "2026-10-15 09:12:03" },
{ "author": "staff", "message": "Die Rechnung ist unterwegs.", "createdAt": "2026-10-15 10:40:51" }
]
}
}
```
`status` ist einer von `open`, `pending`, `answered`, `on_hold`, `resolved`, `closed`. Zeiten sind Serverzeit im Format `JJJJ-MM-TT hh:mm:ss`. `author` ist `customer` oder `staff`.
#### Fehler und Grenzen
| HTTP | `code` | Bedeutung |
|---|---|---|
| 400 | `INVALID_JSON` | Der Anfragekörper ist kein gültiges JSON |
| 401 | `UNAUTHENTICATED` | Schlüssel fehlt, ist falsch, deaktiviert oder von dieser IP nicht erlaubt |
| 403 | `FORBIDDEN` | Dem Schlüssel fehlt das Recht für diese Aktion |
| 403 | `BANNED`, `REJECTED` | Absender ist gesperrt oder eine Ticket-Regel hat das Ticket abgelehnt |
| 404 | `NOT_FOUND` | Ticket nicht gefunden (oder falsche E-Mail-Adresse) |
| 405 | `METHOD_NOT_ALLOWED` | Erlaubt sind nur `GET` und `POST` |
| 413 | `TOO_LARGE` | Anfrage größer als 200 KB |
| 422 | `INVALID_NAME`, `INVALID_EMAIL`, `INVALID_SUBJECT`, `INVALID_MESSAGE`, `INVALID_TOPIC`, `INVALID_PRIORITY`, `MISSING_PARAMS` | Eingaben ungültig oder unvollständig |
| 429 | `RATE_LIMITED` | Mehr als 60 über die API angelegte Tickets pro Minute (gilt gemeinsam für alle Schlüssel) |
| 503 | `NO_DEPARTMENT` | Es ist keine aktive Abteilung eingerichtet |
Beim Anlegen gibt es keine Wiederholungssicherung: Sendet ihr dieselbe Anfrage zweimal, entstehen zwei Tickets. Wiederholt nach einem Netzwerkfehler also nur, wenn ihr prüfen könnt, ob das erste Ticket angekommen ist.
### Webhooks
Mit Webhooks meldet DeskHub Ereignisse sofort an eine Adresse eures Systems. Einrichten: Adminportal → **Webhooks** → Name, URL und die gewünschten Ereignisse wählen. Das **Secret** zum Prüfen der Signatur legt ihr selbst fest oder lasst es von DeskHub erzeugen. Es wird nur verschlüsselt gespeichert.
**Ereignisse:**
| Ereignis | Wann | Zusätzliche Felder |
|---|---|---|
| `ticket.created` | Neues Ticket (Portal, E-Mail, Chat, Mitarbeiter oder API) | – |
| `ticket.replied` | Antwort oder interne Notiz | `author` (`staff` oder `customer`); bei Mitarbeitern zusätzlich `internal` (`true` bei interner Notiz) |
| `ticket.status_changed` | Status hat sich geändert | `previousStatus` |
| `ticket.assigned` | Zuweisung hat sich geändert | – |
**Anfrage:** DeskHub sendet `POST` mit JSON (UTF-8) an eure URL, mit diesen Headern:
| Header | Inhalt |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `DeskHub-Webhook/1` |
| `X-DeskHub-Event` | Ereignisname, z. B. `ticket.created` |
| `X-DeskHub-Signature` | `sha256=` plus HMAC-SHA256 des **rohen Anfragekörpers** mit eurem Secret als Schlüssel (Hex) |
**Beispiel für den Anfragekörper** (`ticket.status_changed`):
```json
{
"event": "ticket.status_changed",
"occurredAt": "2026-10-15T08:41:12+00:00",
"ticket": {
"number": "T-1042",
"subject": "Rechnung fehlt",
"status": "resolved",
"source": "web",
"priority": "Normal",
"department": "Buchhaltung",
"assignedTo": "Max Muster",
"requesterName": "Erika Muster",
"requesterEmail": "erika@example.de",
"createdAt": "2026-10-15 09:12:03",
"updatedAt": "2026-10-15 10:40:51"
},
"previousStatus": "answered"
}
```
`occurredAt` ist UTC im ISO-8601-Format, `assignedTo` ist `null`, wenn niemand zugewiesen ist. Es werden nur diese Angaben gesendet, nicht der Nachrichtentext.
**Signatur prüfen** (immer mit dem rohen Körper, vor dem JSON-Parsen, und mit einem zeitkonstanten Vergleich):
```php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_DESKHUB_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
```
```python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get("X-DeskHub-Signature", ""))
```
**Zustellung:**
- Eure Adresse muss binnen 5 Sekunden mit einem Status `2xx` antworten. Alles andere (anderer Status, Timeout, Verbindungsfehler) zählt als Fehler. Weiterleitungen werden nicht verfolgt.
- Es gibt **keine automatische Wiederholung**. Antwortet euer System gerade nicht, geht dieses Ereignis verloren. Baut bei kritischen Abläufen einen Abgleich ein, etwa über die Ticket-API oder den Status in der Webhook-Liste.
- Die Zustellung läuft nach der Antwort an den Benutzer und bremst DeskHub nicht. Die Reihenfolge mehrerer Ereignisse ist nicht garantiert; `occurredAt` und `updatedAt` helfen beim Einordnen.
- Nach **10 Fehlern in Folge** wird der Webhook automatisch deaktiviert. Im Adminportal stehen letzter Status, Fehlertext und Zeitpunkt. Ein erfolgreicher Aufruf setzt den Fehlerzähler zurück. Nach dem Beheben aktiviert ihr den Webhook wieder.
- Erlaubt sind nur `http://` und `https://`, und die Adresse darf nicht auf interne Ziele zeigen (localhost, private Netze). Nutzt `https://`.
- Das Webhook-Secret ist von den API-Schlüsseln unabhängig.
---
## Sicherheit und Datenschutz
- **Sperrliste:** Einzelne E-Mail-Adressen oder ganze Domains blockieren. Mails von dort erzeugen kein Ticket mehr.
- **Automatische Anonymisierung:** Unter Einstellungen → Datenschutz legt ihr fest, nach wie vielen Monaten persönliche Daten (Name, E-Mail) in geschlossenen Tickets anonymisiert werden. Der Ticketinhalt bleibt für Statistiken erhalten.
- **Automatische Abmeldung:** Die Zeit bis zur Abmeldung bei Inaktivität ist einstellbar.
- **Passwörter und Zugangsdaten:** SMTP-/IMAP-Passwörter und Tokens werden verschlüsselt gespeichert. API-Schlüssel werden nur als Prüfsumme abgelegt und sind nach dem Erstellen nicht mehr einsehbar.
- **Updates und Plugins:** Updates werden über eine geprüfte, verschlüsselte Verbindung geladen. Plugins sind Programmcode mit vollen Rechten, installiert deshalb nur vertrauenswürdige.
- **Sicherheitsprüfung:** Adminportal → **Sicherheitsprüfung** prüft die Einrichtung dieser Installation und ruft interne Adressen selbst ab, um zu sehen, ob Konfiguration, Datenbank, Backups oder Programmcode von außen erreichbar wären. Nach der Installation und nach jeder Änderung am Webserver einmal ausführen.
- **Schutz vor HTML-Angriffen:** Texte in Antworten und HTML-E-Mails werden vor der Anzeige durch einen strengen Filter geschickt. Skripte, Formulare, eingebettete Inhalte und riskante Formatierungen werden entfernt; auch kaputtes oder absichtlich verstümmeltes HTML wird als Text maskiert.
- **Abteilungsgrenzen:** Mitarbeiter sehen Tickets, Anhänge, Ausdrucke und Kundenprofile nur für ihre Abteilungen, auch über direkte Links.
### Betrieb absichern (Checkliste)
1. **HTTPS** einrichten und die Website-URL (Einstellungen) auf `https://` stellen.
2. **Interne Ordner sperren.** Die mitgelieferte `.htaccess` erledigt das auf Apache. Auf **nginx** und anderen Servern gibt es keine `.htaccess`: Tragt diese Regeln in den `server`-Block ein (Pfad zu PHP-FPM anpassen):
```nginx
# Interne Ordner und Dateien nie ausliefern
location ~ ^/(config|database|includes|cron|backups|tests|tools|docs|desktop|mobile|theme-packages|vorschaubilder|plugin-webseite|app_downloads|\.git|\.gitea|\.github)(/|$) { return 404; }
location ~ ^/plugins/.+\.(php|sql|json|md|txt|log)$ { return 404; }
location ~ ^/(version\.php|offen\.txt|install\.sh|install_windows\.bat)$ { return 404; }
location ~* \.(sqlite3?|db|sql|log|bak|old|md|sh|bat|ps1|pem|key|env|ini|lock)$ { return 404; }
# Hochgeladene Dateien dürfen nie als Programm laufen
location ~* ^/uploads/.*\.(php\d?|phtml|phar|html?|xhtml|svgz?|xml|js)$ { return 403; }
# Nach der Installation: Installationsassistent sperren
location ^~ /install/ { return 404; }
```
Diese Regeln müssen **vor** dem `location ~ \.php$`-Block stehen, weil nginx den ersten passenden Regex-Block nimmt. Danach in der **Sicherheitsprüfung** kontrollieren, dass alles gesperrt ist.
3. **Reverse-Proxy (nginx, Traefik, Caddy, Cloudflare …):** Tragt in `config/config.php` unter `site` die Adresse oder den Bereich des Proxys ein, damit DeskHub die echte Besucher-IP und HTTPS erkennt. Der Proxy muss `X-Forwarded-For` und `X-Forwarded-Proto` setzen. Ohne diese Angabe sehen alle Besucher aus Sicht von DeskHub wie eine einzige IP aus, und die Anmelde-Sperre trifft alle gleichzeitig:
```php
'site' => [
// ...
'trusted_proxies' => ['127.0.0.1', '172.16.0.0/12'],
],
```
Tragt nur Proxys ein, die ihr selbst betreibt. Wer in dieser Liste steht, kann die IP-Adresse der Besucher festlegen.
4. **`app_key`** in `config/config.php` setzen (der Installer tut das) und nie ändern. Die Datei nach der Installation auf nur lesbar stellen (`chmod 640`).
5. **Ordner `install/` löschen**, **PHP-Fehleranzeige abschalten** (`display_errors = Off`), **Zwei-Faktor-Anmeldung** für alle Administratoren aktivieren (Profil → Sicherheit).
6. **Backups** einrichten und prüfen (siehe [Backup](#backup)), den Ordner `backups/` nie öffentlich machen.
7. **Eigenes Repository:** `config/config.php`, die Datenbank, Backups und Uploads gehören nie in ein Git-Repository. Die mitgelieferte `.gitignore` schließt sie aus.
8. Updates installieren: Sicherheitsverbesserungen kommen mit neuen Versionen.
---
## Backup
Adminportal → **Backup**:
- **Manuell:** Jederzeit ein Backup erstellen und herunterladen.
- **Automatisch:** Zeitplan (täglich, wöchentlich, monatlich), optional mit Upload per FTP auf einen zweiten Server und automatischer Rotation (alte Backups werden nach einer einstellbaren Anzahl gelöscht).
Außerdem erstellt DeskHub automatisch ein Backup vor jedem System-Update, jedem Datenbank-Update und jeder Plugin-Aktivierung.
**Prüfen und Wiederherstellen:**
- Jedes Backup enthält einen konsistenten Datenbank-Schnappschuss (SQLite) bzw. einen SQL-Dump (MySQL) und eine Inhaltsliste mit Prüfsummen.
- **✔️ Prüfen** kontrolliert ein Backup, ohne etwas zu verändern. Nach jedem manuellen und automatischen Backup passiert das von selbst; schlägt es fehl, erscheint im Adminportal eine Störungsmeldung („Backup defekt“).
- **♻️ Wiederherstellen** spielt Datenbank und (wählbar) Uploads, Plugins und App-Downloads zurück. Davor wird automatisch ein Sicherheits-Backup des aktuellen Stands erstellt. Zur Bestätigung gebt ihr `WIEDERHERSTELLEN` ein. Programmcode und `config.php` werden nie überschrieben. Ein Backup einer anderen Datenbankart (SQLite ↔ MySQL) wird abgelehnt.
---
## Automatische Tests (CI)
`php tests/run_all.php` führt alle PHP-Tests nacheinander aus (Exit-Code 1 bei Fehlern); mit Argument nur passende Tests, z. B. `php tests/run_all.php plugin`. Die Workflow-Datei `.gitea/workflows/tests.yml` startet bei jedem Push, jeder Pull-Request und nachts um 03:17 UTC: PHP 8.2 (Syntax und alle Tests), PHP 8.4 (nur Warnung), Windows-App-Oberflächentests (`npm test` unter xvfb), Android-Tests (`./gradlew testDebugUnitTest`) und der Bau des Docker-Images der Plugin-Webseite samt Zugangsschutz-Prüfung. Für GitHub die Datei nach `.github/workflows/` kopieren. Der Runner braucht für den Docker-Job Docker, für den Android-Job das Android-SDK.
---
## Hilfe, etwas funktioniert nicht
- **Admin-Protokoll** (Adminportal → Admin-Protokoll): Änderungen und fehlgeschlagene automatische E-Mails mit genauer Fehlermeldung.
- **E-Mail-Abruf:** Adminportal → Postfächer (Abruf) → beim betroffenen Postfach „Testen“. Das zeigt die Fehlermeldung des Mail-Servers.
- **E-Mail-Versand:** Einstellungen → E-Mail → „Test-Mail senden“, oder bei E-Mail-Adressen die einzelne Adresse testen.
- **Rotes oder gelbes Banner oben:** Das ist eine Störungsmeldung der Überwachung, siehe [Überwachung und Störungsmeldungen](#überwachung-und-störungsmeldungen).
- **„Datenbank veraltet“:** Unter System-Update auf „Datenbank aktualisieren“ klicken.
- **Ein Plugin macht Probleme:** Unter Plugins deaktivieren. Lässt sich die Seite nicht mehr öffnen, hilft die Datei `plugins/.disabled`.
- **Die App zeigt „Keine Verbindung“:** Erreichbarkeit der Seite im Browser prüfen. Sobald der Server wieder antwortet, verschwindet das Banner von selbst.
@@ -0,0 +1,148 @@
<?php
/**
* includes/crontab_installer.php
*
* Installiert/entfernt die 6 DeskHub-Cronjobs direkt in der System-Crontab des
* aktuellen Benutzers (crontab -l / crontab -). Nur sinnvoll auf einem eigenen
* (v)Server mit Shell-Zugriff für den Web-User – auf klassischem Shared-Hosting
* ist exec()/proc_open() fast immer gesperrt, siehe crontab_capability_check().
*
* Sicherheitsprinzip: Es wird NIE ein von außen beeinflussbarer String als
* Shell-Befehl ausgeführt. Die Crontab-Zeilen sind fest im Code hinterlegt
* (nur der erkannte PHP-Pfad und der DeskHub-Installationspfad sind variabel,
* beide serverseitig ermittelt, nicht aus einer Nutzereingabe). Nur der klar
* markierte "# DESKHUB-CRON-…"-Block wird verwaltet, alle anderen vorhandenen
* Crontab-Einträge des Nutzers bleiben unangetastet.
*/
const DESKHUB_CRON_MARKER_START = '# DESKHUB-CRON-START (automatisch verwaltet von DeskHub – Einstellungen → Cron-Jobs. Manuelle Änderungen hier werden beim erneuten Installieren überschrieben.)';
const DESKHUB_CRON_MARKER_END = '# DESKHUB-CRON-END';
/** Prüft, ob dieser Server eine automatische Installation überhaupt technisch zulässt. */
function crontab_capability_check(): array
{
// Crontab ist ein Unix-Mechanismus; auf Windows (z.B. lokale Entwicklung) von vornherein ausschließen,
// statt uns auf lokalisierte/unvorhersehbare Fehlertexte von cmd.exe verlassen zu müssen.
if (PHP_OS_FAMILY === 'Windows') {
return ['available' => false, 'reason' => 'Windows-Server werden nicht unterstützt (Crontab ist ein Unix/Linux-Mechanismus).'];
}
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
foreach (['proc_open', 'shell_exec'] as $fn) {
if (!function_exists($fn) || in_array($fn, $disabled, true)) {
return ['available' => false, 'reason' => "PHP-Funktion \"$fn\" ist auf diesem Server gesperrt (typisch für Shared-Hosting)."];
}
}
$out = @shell_exec('crontab -l 2>&1');
if ($out === null) {
return ['available' => false, 'reason' => 'Der Befehl "crontab" konnte nicht ausgeführt werden.'];
}
// "no crontab for <user>" ist der normale Zustand, wenn noch keine Crontab existiert – das ist OK.
if (stripos($out, 'not found') !== false || stripos($out, 'command not found') !== false) {
return ['available' => false, 'reason' => 'Das Programm "crontab" ist auf diesem Server nicht installiert.'];
}
if (stripos($out, 'permission denied') !== false) {
return ['available' => false, 'reason' => 'Der Web-Server-Benutzer hat keine Berechtigung für crontab.'];
}
return ['available' => true, 'reason' => ''];
}
/** Versucht, den echten CLI-PHP-Pfad zu ermitteln (PHP_BINARY zeigt unter Apache/FPM auf den falschen Binary). */
function detect_php_cli_binary(): string
{
$which = trim((string) @shell_exec('command -v php 2>/dev/null'));
if ($which !== '' && is_executable($which)) {
return $which;
}
foreach (['/usr/bin/php', '/usr/local/bin/php', '/opt/php/bin/php'] as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
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). */
function crontab_marker_block(): string
{
$php = detect_php_cli_binary();
$root = realpath(__DIR__ . '/..');
$lines = [
DESKHUB_CRON_MARKER_START,
"*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.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 2 * * * $php $root/cron/process_backups.php >> $root/cron/process_backups.log 2>&1",
"0 3 * * * $php $root/cron/anonymize_data.php >> $root/cron/anonymize_data.log 2>&1",
DESKHUB_CRON_MARKER_END,
];
return implode("\n", $lines);
}
function crontab_get_current(): string
{
$out = @shell_exec('crontab -l 2>/dev/null');
return $out !== null ? $out : '';
}
function crontab_is_installed(): bool
{
return str_contains(crontab_get_current(), DESKHUB_CRON_MARKER_START);
}
/** Entfernt einen ggf. vorhandenen DeskHub-Block aus einem Crontab-Text (Hilfsfunktion für install+remove). */
function crontab_strip_marker_block(string $content): string
{
$pattern = '/^' . preg_quote(DESKHUB_CRON_MARKER_START, '/') . '.*?^' . preg_quote(DESKHUB_CRON_MARKER_END, '/') . '\n?/ms';
return preg_replace($pattern, '', $content) ?? $content;
}
/** Schreibt einen kompletten neuen Crontab-Inhalt für den aktuellen Benutzer. */
function crontab_write(string $content): array
{
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open('crontab -', $descriptors, $pipes);
if (!is_resource($proc)) {
return ['ok' => false, 'error' => 'crontab-Prozess konnte nicht gestartet werden.'];
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
return ['ok' => false, 'error' => trim($stderr) ?: trim($stdout) ?: "crontab beendete mit Fehlercode $code"];
}
return ['ok' => true, 'error' => ''];
}
/** Installiert/aktualisiert den DeskHub-Block in der Crontab des aktuellen Benutzers. */
function crontab_install(): array
{
$check = crontab_capability_check();
if (!$check['available']) {
return ['ok' => false, 'error' => $check['reason']];
}
$current = crontab_strip_marker_block(crontab_get_current());
$current = rtrim($current, "\n");
$new = ($current !== '' ? $current . "\n\n" : '') . crontab_marker_block() . "\n";
return crontab_write($new);
}
/** Entfernt den DeskHub-Block aus der Crontab, lässt alle anderen Einträge unangetastet. */
function crontab_uninstall(): array
{
$check = crontab_capability_check();
if (!$check['available']) {
return ['ok' => false, 'error' => $check['reason']];
}
$current = crontab_get_current();
if (!str_contains($current, DESKHUB_CRON_MARKER_START)) {
return ['ok' => true, 'error' => '']; // war gar nicht installiert
}
$new = crontab_strip_marker_block($current);
return crontab_write($new);
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* Datenbankverbindung (PDO).
* Unterstützt sowohl MySQL (Produktivbetrieb) als auch SQLite (lokale Tests),
* gesteuert über config/config.php -> db.driver
*/
require_once __DIR__ . '/../database/sqlite_schema.php';
function config(): array
{
static $config = null;
if ($config === null) {
$config = require __DIR__ . '/../config/config.php';
// Nur für Tests: einzelne Werte überschreiben (wirkt ausschließlich mit DESKHUB_TESTING=1)
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $extra);
}
}
return $config;
}
function get_db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$cfg = config()['db'];
if ($cfg['driver'] === 'sqlite') {
$dbPath = $cfg['sqlite_path'];
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0;
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;');
if ($needInit) {
$schemaFile = __DIR__ . '/../database/schema.sql';
if (!file_exists($schemaFile)) {
throw new \RuntimeException("Schema-Datei nicht gefunden: $schemaFile");
}
$sql = file_get_contents($schemaFile);
$pdo->exec(convert_schema_to_sqlite($sql));
}
} else {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $cfg['host'], $cfg['name']);
$pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4",
]);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Ausstehende Schema-Migrationen automatisch einspielen.
// Bereits angewendete werden übersprungen → Einstellungen bleiben erhalten.
$migrateFile = __DIR__ . '/../database/migrate.php';
if (file_exists($migrateFile)) {
require_once $migrateFile;
$migrationResult = run_migrations($pdo);
if ($migrationResult['errors']) {
throw new RuntimeException('Datenbankmigration fehlgeschlagen: ' . implode('; ', $migrationResult['errors']));
}
}
return $pdo;
}
/** Liefert NOW() als DATETIME-String, kompatibel zu beiden Treibern */
function db_now(): string
{
return date('Y-m-d H:i:s');
}
/** SQL-Datum-Differenz (Stunden addieren), kompatibel zu beiden Treibern -> wird in PHP berechnet */
function add_hours(string $datetime, int $hours): string
{
$ts = strtotime($datetime) + ($hours * 3600);
return date('Y-m-d H:i:s', $ts);
}
@@ -0,0 +1,227 @@
<?php
/**
* Gemeinsame Logik für E-Mail-Adressen (Konto, Abruf per IMAP, Versand per SMTP).
* Wird von staff/email_addresses.php (Weboberfläche) und api/staff/v1/email_addresses.php
* (Mobile-/Desktop-App) genutzt, damit beide exakt dasselbe tun.
*/
class EmailAddressException extends RuntimeException
{
public function __construct(string $message, private string $errorCode = 'INVALID', private int $httpStatus = 422)
{
parent::__construct($message);
}
public function errorCode(): string { return $this->errorCode; }
public function httpStatus(): int { return $this->httpStatus; }
}
/** Verknüpftes IMAP-Postfach einer Adresse (oder null). */
function email_address_imap(PDO $pdo, int $addressId): ?array
{
$stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE email_address_id = ? ORDER BY id LIMIT 1');
$stmt->execute([$addressId]);
return $stmt->fetch() ?: null;
}
/** Eigener SMTP-Zugang einer Absenderadresse (nur aktive Adresse mit aktiviertem SMTP), sonst null. */
function email_address_smtp_for(PDO $pdo, string $fromHeader): ?array
{
if (!preg_match('/<([^>]+)>/', $fromHeader, $m)) $m = [1 => $fromHeader];
$email = strtolower(trim($m[1]));
if ($email === '') return null;
$stmt = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE LOWER(email) = ? AND is_active = 1 AND smtp_enabled = 1');
$stmt->execute([$email]);
$row = $stmt->fetch();
return ($row && trim((string) $row['smtp_host']) !== '') ? $row : null;
}
/**
* Legt eine E-Mail-Adresse an oder aktualisiert sie – inklusive IMAP-Postfach und eigenem SMTP.
*
* @param array $in Schlüssel: email,label,department_id,priority_id,help_topic_id,is_default,is_active,notes,
* imap_enabled,imap_host,imap_port,imap_encryption,imap_username,imap_password,imap_folder,
* imap_after_fetch,imap_archive_folder,imap_spam_folder,
* smtp_enabled,smtp_host,smtp_port,smtp_encryption,smtp_username,smtp_password
* @return int ID der Adresse
* @throws EmailAddressException bei ungültigen Eingaben
*/
function email_address_save(PDO $pdo, int $id, array $in): int
{
$email = strtolower(trim((string) ($in['email'] ?? '')));
$label = trim((string) ($in['label'] ?? ''));
$deptId = (int) ($in['department_id'] ?? 0);
$prioId = isset($in['priority_id']) && $in['priority_id'] !== '' && $in['priority_id'] !== null ? (int) $in['priority_id'] : null;
$topicId = isset($in['help_topic_id']) && $in['help_topic_id'] !== '' && $in['help_topic_id'] !== null ? (int) $in['help_topic_id'] : null;
$isDefault = !empty($in['is_default']) ? 1 : 0;
if (array_key_exists('is_active', $in)) {
$isActive = !empty($in['is_active']) ? 1 : 0;
} else { // nicht angegeben: bisherigen Status behalten (neu = aktiv)
$cur = $pdo->prepare('SELECT is_active FROM email_addresses WHERE id = ?');
$cur->execute([$id]);
$isActive = $id ? (int) ($cur->fetchColumn() ?? 1) : 1;
}
$notes = trim((string) ($in['notes'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new EmailAddressException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
$chk = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ?');
$chk->execute([$deptId]);
if (!(int) $chk->fetchColumn()) throw new EmailAddressException('Bitte eine Abteilung wählen.', 'INVALID_DEPARTMENT');
if ($topicId !== null) {
$chk = $pdo->prepare('SELECT COUNT(*) FROM help_topics WHERE id = ?');
$chk->execute([$topicId]);
if (!(int) $chk->fetchColumn()) throw new EmailAddressException('Das gewählte Hilfethema existiert nicht.', 'INVALID_TOPIC');
}
$dup = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ? AND id <> ?');
$dup->execute([$email, $id]);
if ($dup->fetchColumn()) throw new EmailAddressException('Diese E-Mail-Adresse ist bereits angelegt.', 'DUPLICATE_EMAIL');
// IMAP
$existingImap = $id ? email_address_imap($pdo, $id) : null;
$imapOn = !empty($in['imap_enabled']) && $isActive;
$imap = [
'host' => trim((string) ($in['imap_host'] ?? '')),
'port' => (int) ($in['imap_port'] ?? 993) ?: 993,
'enc' => in_array((string) ($in['imap_encryption'] ?? ''), ['ssl', 'tls', 'none'], true) ? $in['imap_encryption'] : 'ssl',
'username' => trim((string) ($in['imap_username'] ?? '')) ?: $email,
'password' => trim((string) ($in['imap_password'] ?? '')),
'folder' => trim((string) ($in['imap_folder'] ?? '')) ?: 'INBOX',
'after' => in_array((string) ($in['imap_after_fetch'] ?? ''), ['seen', 'archive', 'delete'], true) ? $in['imap_after_fetch'] : 'seen',
'archive' => trim((string) ($in['imap_archive_folder'] ?? '')) ?: 'Processed',
'spam' => trim((string) ($in['imap_spam_folder'] ?? '')) ?: 'Junk',
];
if (!empty($in['imap_enabled'])) {
if ($imap['host'] === '') throw new EmailAddressException('Für den Abruf bitte einen Server (Host) angeben.', 'IMAP_HOST_REQUIRED');
if (!$existingImap && $imap['password'] === '') throw new EmailAddressException('Für den Abruf bitte ein Passwort angeben.', 'IMAP_PASSWORD_REQUIRED');
}
// SMTP
$existing = null;
if ($id) {
$s = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE id = ?');
$s->execute([$id]);
$existing = $s->fetch() ?: null;
}
$smtpOn = !empty($in['smtp_enabled']) ? 1 : 0;
$smtp = [
'host' => trim((string) ($in['smtp_host'] ?? '')),
'port' => (int) ($in['smtp_port'] ?? 587) ?: 587,
'enc' => in_array((string) ($in['smtp_encryption'] ?? ''), ['ssl', 'tls', 'none'], true) ? $in['smtp_encryption'] : 'tls',
'username' => trim((string) ($in['smtp_username'] ?? '')),
'password' => (string) ($in['smtp_password'] ?? ''),
];
if ($smtpOn && $smtp['host'] === '') throw new EmailAddressException('Für den eigenen Versand bitte einen SMTP-Server angeben.', 'SMTP_HOST_REQUIRED');
$smtpPassEnc = $smtp['password'] !== '' ? encrypt_secret($smtp['password']) : ($existing['smtp_password_enc'] ?? null);
if (!$smtpOn && $existing) {
// Eigener Versand ausgeschaltet: hinterlegte Zugangsdaten bleiben für später erhalten
$smtp = ['host' => (string) $existing['smtp_host'], 'port' => (int) $existing['smtp_port'], 'enc' => $existing['smtp_encryption'] ?: 'tls',
'username' => (string) $existing['smtp_username'], 'password' => ''];
}
if ($isDefault) {
$pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$deptId]);
}
$now = db_now();
if ($id === 0) {
$pdo->prepare(
'INSERT INTO email_addresses (email, label, department_id, priority_id, help_topic_id, is_default, is_active, notes,
smtp_enabled, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$email, $label, $deptId, $prioId, $topicId, $isDefault, $isActive, $notes ?: null,
$smtpOn, $smtp['host'] ?: null, $smtp['port'], $smtp['enc'], $smtp['username'] ?: null, $smtpPassEnc, $now, $now]);
$id = (int) $pdo->lastInsertId();
} else {
$pdo->prepare(
'UPDATE email_addresses SET email=?, label=?, department_id=?, priority_id=?, help_topic_id=?, is_default=?, is_active=?, notes=?,
smtp_enabled=?, smtp_host=?, smtp_port=?, smtp_encryption=?, smtp_username=?, smtp_password_enc=?, updated_at=? WHERE id=?'
)->execute([$email, $label, $deptId, $prioId, $topicId, $isDefault, $isActive, $notes ?: null,
$smtpOn, $smtp['host'] ?: null, $smtp['port'], $smtp['enc'], $smtp['username'] ?: null, $smtpPassEnc, $now, $id]);
}
// Verknüpftes Postfach: Abteilung/Priorität/Hilfethema kommen aus dem Konto
if ($imapOn) {
if ($existingImap) {
$sql = 'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=1';
$params = [$email, $imap['host'], $imap['port'], $imap['enc'], $imap['username'], $imap['folder'], $imap['after'], $imap['archive'], $imap['spam'], $deptId, $prioId];
if ($imap['password'] !== '') { $sql .= ', password_enc=?'; $params[] = encrypt_secret($imap['password']); }
$params[] = $existingImap['id'];
$pdo->prepare($sql . ' WHERE id=?')->execute($params);
} else {
$pdo->prepare(
'INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, department_id, priority_id, is_active, email_address_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)'
)->execute([$email, $imap['host'], $imap['port'], $imap['enc'], $imap['username'], encrypt_secret($imap['password']), $imap['folder'], $imap['after'], $imap['archive'], $imap['spam'], $deptId, $prioId, $id, $now]);
}
} elseif ($existingImap) {
$pdo->prepare('UPDATE imap_accounts SET is_active = 0, department_id = ?, priority_id = ? WHERE id = ?')->execute([$deptId, $prioId, $existingImap['id']]);
}
return $id;
}
/** Löscht eine Adresse samt Postfach (oder löst das Postfach, falls Tickets darauf verweisen). Gibt die gelöschte E-Mail zurück. */
function email_address_delete(PDO $pdo, int $id): string
{
$stmt = $pdo->prepare('SELECT email FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$email = (string) ($stmt->fetchColumn() ?: $id);
if ($imap = email_address_imap($pdo, $id)) {
$used = $pdo->prepare('SELECT COUNT(*) FROM ticket_email_sources WHERE imap_account_id = ?');
$used->execute([$imap['id']]);
if ((int) $used->fetchColumn() > 0) {
$pdo->prepare('UPDATE imap_accounts SET is_active = 0, email_address_id = NULL WHERE id = ?')->execute([$imap['id']]);
} else {
$pdo->prepare('DELETE FROM imap_accounts WHERE id = ?')->execute([$imap['id']]);
}
}
$pdo->prepare('DELETE FROM email_addresses WHERE id = ?')->execute([$id]);
return $email;
}
/** Sendet eine Test-Mail von dieser Adresse an $recipient. Gibt die Erfolgsmeldung zurück. */
function email_address_send_test(PDO $pdo, int $id, string $recipient): string
{
$stmt = $pdo->prepare('SELECT email, label, smtp_enabled FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$addr = $stmt->fetch();
if (!$addr) throw new EmailAddressException('E-Mail-Adresse nicht gefunden.', 'NOT_FOUND', 404);
$from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email'];
$via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server';
$html = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>';
if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from)) {
throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502);
}
return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via}).";
}
/** Prüft die IMAP-Verbindung des verknüpften Postfachs. Gibt die Erfolgsmeldung zurück. */
function email_address_test_imap(PDO $pdo, int $id): string
{
$acc = email_address_imap($pdo, $id);
if (!$acc) throw new EmailAddressException('Für diese Adresse ist kein Postfach hinterlegt. Bitte zuerst speichern.', 'NO_MAILBOX');
if (!function_exists('imap_open')) throw new EmailAddressException('PHP-IMAP-Erweiterung (php-imap) ist auf diesem Server nicht installiert.', 'NO_IMAP', 501);
$flag = match ($acc['encryption']) { 'ssl' => '/imap/ssl', 'tls' => '/imap/tls', default => '/imap/notls' };
$mbox = @imap_open('{' . $acc['host'] . ':' . $acc['port'] . $flag . '}' . $acc['folder'], $acc['username'], decrypt_secret($acc['password_enc']), 0, 1);
if (!$mbox) throw new EmailAddressException('Verbindung fehlgeschlagen: ' . imap_last_error(), 'IMAP_FAILED', 502);
$unseen = count(imap_search($mbox, 'UNSEEN') ?: []);
imap_close($mbox);
return "Verbindung erfolgreich. {$unseen} ungelesene Mail(s) im Postfach.";
}
/** Prüft die SMTP-Anmeldung dieser Adresse, ohne eine Mail zu senden. Gibt die Erfolgsmeldung zurück. */
function email_address_test_smtp(PDO $pdo, int $id): string
{
$stmt = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row || trim((string) $row['smtp_host']) === '') throw new EmailAddressException('Für diese Adresse ist kein eigener SMTP-Server hinterlegt. Bitte zuerst speichern.', 'NO_SMTP');
$timeout = 8;
$scheme = $row['smtp_encryption'] === 'ssl' ? 'ssl://' : '';
$errno = 0; $errstr = '';
$sock = @stream_socket_client($scheme . $row['smtp_host'] . ':' . (int) $row['smtp_port'], $errno, $errstr, $timeout);
if (!$sock) throw new EmailAddressException("Verbindung zu {$row['smtp_host']}:{$row['smtp_port']} fehlgeschlagen: {$errstr}", 'SMTP_FAILED', 502);
$banner = fgets($sock, 512);
fclose($sock);
if (!is_string($banner) || strpos($banner, '220') !== 0) throw new EmailAddressException('Der Server hat keine SMTP-Begrüßung gesendet.', 'SMTP_FAILED', 502);
return "SMTP-Server {$row['smtp_host']}:{$row['smtp_port']} ist erreichbar. Die Anmeldung wird beim Versenden der Test-Mail geprüft.";
}
@@ -0,0 +1,545 @@
<?php
$footerPages = get_db()->query("SELECT title, slug FROM site_pages WHERE is_active=1 AND show_in_nav=1 ORDER BY sort_order, title")->fetchAll();
$privacyLabel = get_setting('footer_privacy_label', 'Datenschutz');
$privacyUrl = get_setting('footer_privacy_url', '');
$privacyHtml = get_setting('footer_privacy_html', '');
$imprintLabel = get_setting('footer_imprint_label', 'Impressum');
$imprintUrl = get_setting('footer_imprint_url', '');
$imprintHtml = get_setting('footer_imprint_html', '');
$hasPrivacy = $privacyUrl || $privacyHtml;
$hasImprint = $imprintUrl || $imprintHtml;
$hasPopup = (!$privacyUrl && $privacyHtml) || (!$imprintUrl && $imprintHtml);
?>
</div><!-- /.page-content -->
<?php if ($hasPopup): ?>
<!-- Popup-Overlay für Datenschutz / Impressum -->
<div id="legal-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; overflow-y:auto; padding:40px 16px">
<div style="background:#fff; max-width:760px; margin:0 auto; border-radius:10px; padding:32px 36px; position:relative; font-size:.9rem; line-height:1.7">
<button onclick="closeLegalPopup()" style="position:absolute; top:14px; right:18px; background:none; border:none; font-size:1.4rem; cursor:pointer; color:#6b7280">&times;</button>
<div id="legal-popup-content"></div>
</div>
</div>
<script>
function openLegalPopup(html) {
document.getElementById('legal-popup-content').innerHTML = html;
document.getElementById('legal-overlay').style.display = 'block';
document.body.style.overflow = 'hidden';
}
function closeLegalPopup() {
document.getElementById('legal-overlay').style.display = 'none';
document.body.style.overflow = '';
}
document.getElementById('legal-overlay').addEventListener('click', function(e){
if (e.target === this) closeLegalPopup();
});
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLegalPopup(); });
</script>
<?php endif; ?>
<footer class="public-footer">
<nav style="margin-bottom:8px; display:flex; gap:20px; justify-content:center; flex-wrap:wrap">
<?php if ($footerPages): ?>
<?php foreach ($footerPages as $fp): ?>
<a href="/page.php?slug=<?= e($fp['slug']) ?>" style="color:#6b7280; font-size:.78rem"><?= e($fp['title']) ?></a>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($hasPrivacy): ?>
<?php if ($privacyUrl): ?>
<a href="<?= e($privacyUrl) ?>" target="_blank" rel="noopener" style="color:#6b7280; font-size:.78rem"><?= e($privacyLabel) ?></a>
<?php else: ?>
<a href="#" onclick="openLegalPopup(<?= htmlspecialchars(json_encode($privacyHtml), ENT_QUOTES) ?>); return false;" style="color:#6b7280; font-size:.78rem"><?= e($privacyLabel) ?></a>
<?php endif; ?>
<?php endif; ?>
<?php if ($hasImprint): ?>
<?php if ($imprintUrl): ?>
<a href="<?= e($imprintUrl) ?>" target="_blank" rel="noopener" style="color:#6b7280; font-size:.78rem"><?= e($imprintLabel) ?></a>
<?php else: ?>
<a href="#" onclick="openLegalPopup(<?= htmlspecialchars(json_encode($imprintHtml), ENT_QUOTES) ?>); return false;" style="color:#6b7280; font-size:.78rem"><?= e($imprintLabel) ?></a>
<?php endif; ?>
<?php endif; ?>
</nav>
Copyright &copy; <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> &ndash; Alle Rechte vorbehalten.
</footer>
<?php if (get_setting('live_chat_enabled', '0') === '1'): ?>
<!-- Live-Chat-Widget: kann jederzeit unter Einstellungen → Allgemein wieder deaktiviert werden -->
<style>
@keyframes lc-pop { 0%{transform:scale(.8);opacity:0} 100%{transform:scale(1);opacity:1} }
@keyframes lc-pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }
#lc-bubble.lc-unread{animation:lc-pulse 1.4s ease-in-out infinite}
.lc-msg-row{animation:lc-pop .18s ease-out}
</style>
<div id="lc-bubble" style="position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:1.5rem;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.25);z-index:9998;user-select:none">
💬
<span id="lc-badge" style="display:none;position:absolute;top:-3px;right:-3px;background:#dc2626;color:#fff;border-radius:10px;min-width:20px;height:20px;font-size:.68rem;font-weight:700;align-items:center;justify-content:center;padding:0 5px;border:2px solid #fff"></span>
</div>
<div id="lc-panel" style="display:none;position:fixed;bottom:88px;right:20px;width:340px;max-width:calc(100vw - 32px);max-height:min(560px, calc(100vh - 120px));background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:9998;overflow:hidden;flex-direction:column">
<div style="background:var(--primary);color:#fff;padding:14px 16px;flex-shrink:0">
<strong style="font-size:.92rem">💬 Live-Chat</strong>
<span id="lc-close" style="cursor:pointer;font-size:1.2rem;line-height:1;opacity:.85;float:right">&times;</span>
<div style="font-size:.72rem;opacity:.9;margin-top:3px;display:flex;align-items:center;gap:5px">
<span id="lc-status-dot" style="width:7px;height:7px;border-radius:50%;background:#9ca3af;display:inline-block;flex-shrink:0"></span>
<span id="lc-status-text">Verfügbarkeit wird geprüft…</span>
</div>
</div>
<div id="lc-messages" style="flex:1;overflow-y:auto;padding:12px 14px;display:none;background:#f7f8fa"></div>
<div id="lc-typing" style="display:none;padding:0 14px 8px;font-size:.76rem;color:#9ca3af;background:#f7f8fa;font-style:italic">Mitarbeiter tippt …</div>
<div id="lc-ended" style="display:none;padding:16px;font-size:.85rem;color:#6b7280;text-align:center">
Dieser Chat wurde beendet.<br><a href="#" id="lc-restart" style="color:var(--primary);font-weight:600">Neuen Chat starten</a>
</div>
<div id="lc-converted" style="display:none;padding:20px 16px;font-size:.85rem;color:#374151;text-align:center">
<div style="font-size:1.6rem;margin-bottom:8px">✅</div>
<div style="font-weight:600;margin-bottom:4px">Vielen Dank! Ihr Ticket wurde erstellt.</div>
<div id="lc-converted-number" style="color:#6b7280;margin-bottom:8px"></div>
<div style="color:#6b7280">Da gerade niemand direkt verfügbar ist, antworten wir Ihnen per E-Mail, sobald wir Ihre Anfrage bearbeitet haben.</div>
</div>
<form id="lc-start-form" style="padding:14px 16px;display:flex;flex-direction:column;gap:8px">
<p style="margin:0 0 2px;font-size:.83rem;color:#374151">👋 Hallo! Wie können wir Ihnen helfen?</p>
<div id="lc-unavailable-hint" style="display:none;background:#fffbeb;border:1px solid #fde68a;color:#92400e;font-size:.78rem;padding:8px 10px;border-radius:6px">Gerade ist niemand verfügbar – wir antworten aber, sobald wir wieder da sind.</div>
<div id="lc-error" style="display:none;color:#dc2626;font-size:.78rem"></div>
<input type="text" id="lc-name" placeholder="Ihr Name" required style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<input type="email" id="lc-email" placeholder="Ihre E-Mail-Adresse" required style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<div style="position:absolute;left:-9999px" aria-hidden="true"><input type="text" id="lc-website" tabindex="-1" autocomplete="off"></div>
<textarea id="lc-message" placeholder="Ihre Nachricht…" required rows="3" style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem;resize:vertical"></textarea>
<div style="display:flex;align-items:center;gap:8px">
<label for="lc-start-attachment" style="cursor:pointer;font-size:.78rem;color:#374151;display:flex;align-items:center;gap:4px;padding:5px 9px;border:1px solid #d1d5db;border-radius:6px">📎 Datei</label>
<input type="file" id="lc-start-attachment" style="position:absolute;left:-9999px" accept="image/*,.pdf,.doc,.docx,.txt">
<span id="lc-start-attachment-name" style="font-size:.76rem;color:#6b7280"></span>
</div>
<button type="submit" class="btn" id="lc-start-btn" style="justify-content:center">Chat starten</button>
</form>
<form id="lc-send-form" style="display:none;padding:10px 12px;gap:8px;border-top:1px solid #eee;flex-shrink:0;flex-direction:column">
<div id="lc-send-attachment-name" style="display:none;font-size:.76rem;color:#6b7280;padding:0 2px"></div>
<div style="display:flex;gap:8px">
<label for="lc-send-attachment" style="cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;border:1px solid #d1d5db;border-radius:6px;font-size:1rem">📎</label>
<input type="file" id="lc-send-attachment" style="position:absolute;left:-9999px" accept="image/*,.pdf,.doc,.docx,.txt">
<input type="text" id="lc-send-input" placeholder="Nachricht…" style="flex:1;padding:9px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<button type="submit" class="btn" id="lc-send-btn" style="padding:8px 14px">Senden</button>
</div>
</form>
</div>
<script>
(function () {
var CSRF = <?= json_encode(csrf_token()) ?>;
var bubble = document.getElementById('lc-bubble');
var badge = document.getElementById('lc-badge');
var panel = document.getElementById('lc-panel');
var closeBtn = document.getElementById('lc-close');
var messagesEl = document.getElementById('lc-messages');
var typingEl = document.getElementById('lc-typing');
var endedEl = document.getElementById('lc-ended');
var convertedEl = document.getElementById('lc-converted');
var convertedNumberEl = document.getElementById('lc-converted-number');
var startForm = document.getElementById('lc-start-form');
var sendForm = document.getElementById('lc-send-form');
var sendInput = document.getElementById('lc-send-input');
var errorEl = document.getElementById('lc-error');
var statusDot = document.getElementById('lc-status-dot');
var statusText = document.getElementById('lc-status-text');
var pollTimer = null;
var renderedIds = {};
var opened = false;
var unreadCount = 0;
var lastTypingPingAt = 0;
var caughtUp = false; // wird nach dem ersten Laden true – erst danach lösen neue Nachrichten Ton/Badge aus
function playNotifySound() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 660;
gain.gain.setValueAtTime(0.12, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.start(); osc.stop(ctx.currentTime + 0.3);
} catch (e) {}
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function setUnread(n) {
unreadCount = n;
if (n > 0 && !opened) {
badge.textContent = n > 9 ? '9+' : String(n);
badge.style.display = 'flex';
bubble.classList.add('lc-unread');
} else {
badge.style.display = 'none';
bubble.classList.remove('lc-unread');
}
}
function updateStatus(available) {
if (available === false) {
statusDot.style.background = '#9ca3af';
statusText.textContent = 'Gerade nicht erreichbar';
} else {
statusDot.style.background = '#22c55e';
statusText.textContent = 'Wir sind für Sie da';
}
}
function setStaffTyping(isTyping) {
typingEl.style.display = isTyping ? 'block' : 'none';
}
// Signalisiert dem Mitarbeiter "Kunde tippt gerade" – höchstens alle 3s, damit nicht
// bei jedem einzelnen Tastenanschlag eine Anfrage rausgeht. Fehler werden bewusst
// ignoriert (verpasstes Signal ist folgenlos, siehe live_chat.php Aktion "typing").
function pingTyping() {
var now = Date.now();
if (now - lastTypingPingAt < 3000) return;
lastTypingPingAt = now;
var body = new URLSearchParams();
body.set('action', 'typing');
body.set('csrf_token', CSRF);
fetch('/live_chat.php', { method: 'POST', body: body }).catch(function () {});
}
if (sendInput) sendInput.addEventListener('input', pingTyping);
/** Hängt nur wirklich neue Nachrichten an (statt alles neu zu rendern) – dadurch
* bleibt die Scroll-Position stabil und neue Blasen können sanft einblenden. */
function appendNewMessages(messages) {
var appended = 0;
messages.forEach(function (m) {
if (renderedIds[m.id]) return;
renderedIds[m.id] = true;
appended++;
var row = document.createElement('div');
row.className = 'lc-msg-row';
row.style.marginBottom = '4px';
row.style.display = 'flex';
row.style.flexDirection = 'column';
row.style.alignItems = m.authorType === 'customer' ? 'flex-end' : 'flex-start';
if (m.authorType === 'staff' && m.authorName) {
var nameEl = document.createElement('div');
nameEl.style.fontSize = '.68rem';
nameEl.style.color = '#9ca3af';
nameEl.style.margin = '6px 2px 2px';
nameEl.textContent = m.authorName;
row.appendChild(nameEl);
}
if (m.message) {
var bubbleEl = document.createElement('div');
bubbleEl.style.maxWidth = '78%';
bubbleEl.style.padding = '8px 11px';
bubbleEl.style.borderRadius = '10px';
bubbleEl.style.fontSize = '.85rem';
bubbleEl.style.whiteSpace = 'pre-wrap';
bubbleEl.style.wordBreak = 'break-word';
bubbleEl.style.marginBottom = '2px';
if (m.authorType === 'customer') {
bubbleEl.style.background = 'var(--primary)';
bubbleEl.style.color = '#fff';
} else {
bubbleEl.style.background = '#fff';
bubbleEl.style.border = '1px solid #e5e7eb';
bubbleEl.style.color = '#1f2937';
}
bubbleEl.textContent = m.message; // textContent = automatisch XSS-sicher, kein HTML-Parsing
row.appendChild(bubbleEl);
}
var imgExts = ['jpg','jpeg','png','gif','webp'];
(m.attachments || []).forEach(function (a) {
var ext = (a.name.split('.').pop() || '').toLowerCase();
if (imgExts.indexOf(ext) !== -1) {
var link = document.createElement('a');
link.href = '/download.php?id=' + a.id + '&preview=1';
link.target = '_blank';
link.style.cssText = 'display:block;margin-bottom:4px;border-radius:8px;overflow:hidden;max-width:180px';
var img = document.createElement('img');
img.src = '/download.php?id=' + a.id + '&preview=1';
img.alt = a.name;
img.style.cssText = 'display:block;max-width:100%;max-height:160px;object-fit:cover';
link.appendChild(img);
row.appendChild(link);
} else {
var fileLink = document.createElement('a');
fileLink.href = '/download.php?id=' + a.id;
fileLink.target = '_blank';
fileLink.style.cssText = 'display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px';
fileLink.textContent = '📎 ' + a.name;
row.appendChild(fileLink);
}
});
var timeEl = document.createElement('div');
timeEl.style.fontSize = '.65rem';
timeEl.style.color = '#9ca3af';
timeEl.textContent = (m.createdAt || '').substring(11, 16);
row.appendChild(timeEl);
messagesEl.appendChild(row);
// Beim allerersten Laden (Historie "nachladen") nie Ton/Badge auslösen – nur für
// Nachrichten, die tatsächlich NACH diesem Zeitpunkt neu reinkommen.
if (caughtUp && m.authorType === 'staff') {
if (!opened) setUnread(unreadCount + 1);
playNotifySound();
}
});
if (appended) messagesEl.scrollTop = messagesEl.scrollHeight;
caughtUp = true;
}
function showActiveChat(data) {
// Wurde der Chat (z.B. im Backend) inzwischen beendet, direkt den "Beendet"-Zustand
// zeigen, statt ein scheinbar aktives Eingabefeld für ein bereits geschlossenes Ticket.
// "Beendet" hat Vorrang vor "Ticket erstellt" – ein abgeschlossenes Ticket ist der
// endgültigere Zustand, egal ob es vorher ein normaler Chat war oder nicht.
if (data.status === 'closed') {
showEnded();
return;
}
// War gerade niemand verfügbar, wurde aus dem Chat ein ganz normales Ticket (kein
// Live-Chat mehr) – statt der eigenen Nachricht als Chat-Bubble zeigen wir dem Kunden
// eine klare Bestätigung, dass sein Anliegen als Ticket angekommen ist. Live-Polling
// lohnt hier nicht: das Ticket wird nicht nachträglich wieder zum Chat.
if (data.convertedToTicket) {
showConverted(data);
return;
}
startForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'none';
messagesEl.style.display = 'block';
sendForm.style.display = 'flex';
updateStatus(data.staffAvailable);
setStaffTyping(!!data.staffTyping);
appendNewMessages(data.messages || []);
startPolling();
}
function showConverted(data) {
startForm.style.display = 'none';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'block';
convertedNumberEl.textContent = data.number ? ('Ticket #' + data.number) : '';
setStaffTyping(false);
stopPolling();
}
function showEnded() {
startForm.style.display = 'none';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
convertedEl.style.display = 'none';
endedEl.style.display = 'block';
setStaffTyping(false);
stopPolling();
}
function showStartForm(staffAvailable) {
startForm.style.display = 'flex';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'none';
updateStatus(staffAvailable);
setStaffTyping(false);
document.getElementById('lc-unavailable-hint').style.display = (staffAvailable === false) ? 'block' : 'none';
stopPolling();
}
function updateCsrf(data) {
if (data && data.csrfToken) CSRF = data.csrfToken;
}
function checkState() {
fetch('/live_chat.php?action=state')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
// Wurde der vorherige Chat bereits (z.B. beim letzten Besuch, ohne dass der Kunde
// live dabei war) beendet ODER zu einem normalen Ticket, nicht extra die "Beendet"-
// bzw. "Ticket erstellt"-Meldung zeigen – der Kunde müsste sonst erst auf "Neuen
// Chat starten" klicken. Stattdessen beim erneuten Laden der Seite (bzw. erneuten
// Öffnen des Bubbles) direkt wieder das normale Startformular anzeigen, damit er bei
// Bedarf sofort einen neuen Chat/eine neue Anfrage starten kann. Passiert das
// Beenden/Umwandeln dagegen WÄHREND der Kunde live zusieht (Panel offen), bleibt die
// jeweilige Meldung bestehen (siehe startPolling() bzw. showActiveChat()) – da ist
// sie sinnvolles Feedback.
if (data.active && (data.status === 'closed' || data.convertedToTicket)) {
renderedIds = {};
messagesEl.innerHTML = '';
showStartForm(data.staffAvailable);
return;
}
if (data.active) showActiveChat(data);
else showStartForm(data.staffAvailable);
})
.catch(function () { showStartForm(); });
}
// Hält die Verfügbarkeitsanzeige ("Wir sind für Sie da" / "Gerade nicht erreichbar") live,
// unabhängig davon, ob gerade ein Chat läuft oder nur das Startformular gezeigt wird – ändert
// ein Mitarbeiter seinen Status, wird das hier binnen weniger Sekunden sichtbar, ohne dass der
// Kunde die Seite neu laden muss. Rührt bewusst nur Status/Hinweisbanner an, nicht das Formular.
function pollAvailability() {
fetch('/live_chat.php?action=state')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
updateStatus(data.staffAvailable);
var hint = document.getElementById('lc-unavailable-hint');
if (hint && startForm.style.display !== 'none') {
hint.style.display = (data.staffAvailable === false) ? 'block' : 'none';
}
})
.catch(function () {});
}
setInterval(pollAvailability, 15000);
// Läuft dauerhaft im Hintergrund weiter (auch bei eingeklapptem Widget), damit Antworten
// per Ton + Zähler-Badge am Bubble-Icon auffallen, statt erst beim nächsten Öffnen bemerkt zu werden.
function startPolling() {
stopPolling();
pollTimer = setInterval(function () {
fetch('/live_chat.php?action=poll')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (!data.active) { stopPolling(); return; }
if (data.status === 'closed') { if (opened) showEnded(); stopPolling(); return; }
if (opened) { updateStatus(data.staffAvailable); setStaffTyping(!!data.staffTyping); }
appendNewMessages(data.messages || []);
})
.catch(function () {});
}, 8000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function setBusy(btn, busy, busyLabel, idleLabel) {
btn.disabled = busy;
btn.textContent = busy ? busyLabel : idleLabel;
}
bubble.addEventListener('click', function () {
opened = panel.style.display !== 'flex';
panel.style.display = opened ? 'flex' : 'none';
if (opened) {
setUnread(0);
checkState();
}
});
closeBtn.addEventListener('click', function () {
opened = false;
panel.style.display = 'none';
});
// Dateiauswahl anzeigen/zurücksetzen – gemeinsam für Start- und Send-Formular.
function wireAttachmentPreview(inputEl, nameEl, isSpan) {
inputEl.addEventListener('change', function () {
var file = inputEl.files[0];
if (!file) { nameEl.textContent = ''; if (!isSpan) nameEl.style.display = 'none'; return; }
if (file.size > 15 * 1024 * 1024) {
showError('Die Datei ist zu groß (max. 15 MB).');
inputEl.value = '';
nameEl.textContent = '';
if (!isSpan) nameEl.style.display = 'none';
return;
}
nameEl.textContent = '📎 ' + file.name;
if (!isSpan) nameEl.style.display = 'block';
});
}
var startAttachment = document.getElementById('lc-start-attachment');
var startAttachmentName = document.getElementById('lc-start-attachment-name');
var sendAttachment = document.getElementById('lc-send-attachment');
var sendAttachmentName = document.getElementById('lc-send-attachment-name');
wireAttachmentPreview(startAttachment, startAttachmentName, true);
wireAttachmentPreview(sendAttachment, sendAttachmentName, false);
startForm.addEventListener('submit', function (e) {
e.preventDefault();
errorEl.style.display = 'none';
var startBtn = document.getElementById('lc-start-btn');
var body = new FormData();
body.set('action', 'start');
body.set('csrf_token', CSRF);
body.set('name', document.getElementById('lc-name').value);
body.set('email', document.getElementById('lc-email').value);
body.set('message', document.getElementById('lc-message').value);
body.set('website', document.getElementById('lc-website').value);
if (startAttachment.files[0]) body.set('attachment', startAttachment.files[0]);
setBusy(startBtn, true, 'Wird gestartet…', 'Chat starten');
fetch('/live_chat.php', { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (data.error) { showError(data.error); return; }
document.getElementById('lc-message').value = '';
startAttachment.value = '';
startAttachmentName.textContent = '';
showActiveChat(data);
})
.catch(function () { showError('Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'); })
.finally(function () { setBusy(startBtn, false, 'Wird gestartet…', 'Chat starten'); });
});
sendForm.addEventListener('submit', function (e) {
e.preventDefault();
var input = document.getElementById('lc-send-input');
var sendBtn = document.getElementById('lc-send-btn');
var text = input.value.trim();
var file = sendAttachment.files[0];
if (!text && !file) return;
var body = new FormData();
body.set('action', 'send');
body.set('csrf_token', CSRF);
body.set('message', text);
if (file) body.set('attachment', file);
input.value = '';
setBusy(sendBtn, true, '…', 'Senden');
fetch('/live_chat.php', { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (data.ended) { showEnded(); return; }
if (data.error) { return; }
sendAttachment.value = '';
sendAttachmentName.textContent = '';
sendAttachmentName.style.display = 'none';
appendNewMessages(data.messages || []);
})
.catch(function () {})
.finally(function () { setBusy(sendBtn, false, '…', 'Senden'); input.focus(); });
});
document.getElementById('lc-restart').addEventListener('click', function (e) {
e.preventDefault();
renderedIds = {};
messagesEl.innerHTML = '';
showStartForm();
});
// Beim Laden der Seite bereits prüfen, ob ein Chat weiterläuft, damit Antworten auch
// erkannt werden (Ton + Badge), ohne dass der Kunde das Widget vorher öffnen muss.
checkState();
})();
</script>
<?php endif; ?>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
<?php
/** @var array|null $user (current_user(), wird von der aufrufenden Seite gesetzt) */
$user = current_user();
$activePage = $activePage ?? '';
$logo = get_setting('logo_path');
$displayName = get_setting('display_name', config()['site']['name']);
// Wartungsmodus: Wenn der Helpdesk-Status auf "offline" steht, wird dem
// Kundenportal eine Wartungsmeldung angezeigt. Der Mitarbeiterbereich ist
// davon nicht betroffen (eigenes Header-Template), damit Admins den Status
// jederzeit wieder zurücksetzen können.
if (get_setting('helpdesk_status', 'online') === 'offline') {
$offlineMessage = get_setting('offline_message', 'Das Supportcenter ist derzeit nicht erreichbar. Bitte versuchen Sie es später erneut.');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($displayName) ?> – Wartungsmodus</title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
</script>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card" style="text-align:center">
<?php require __DIR__ . '/brand_block.php'; ?>
<h1>Vorübergehend nicht erreichbar</h1>
<p class="sub"><?= nl2br(e($offlineMessage)) ?></p>
<p style="margin-top:18px;font-size:.8rem"><a href="/staff/login.php" class="muted">Mitarbeiter-Login →</a></p>
</div>
</div>
</body>
</html>
<?php
exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Verhindert, dass Safari/iOS Zahlenfolgen im Live-Chat-Widget automatisch als
Telefonnummern erkennt und farbig/als Link darstellt (z.B. Postleitzahlen, Codes). -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<title><?= e($pageTitle ?? $displayName) ?> – <?= e($displayName) ?></title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?php $customThemeStylesheet = active_custom_theme_stylesheet(); ?>
<?php if ($customThemeStylesheet): ?>
<link rel="stylesheet" href="<?= e($customThemeStylesheet) ?>">
<?php endif; ?>
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
</script>
</head>
<?php $globalBanner = get_setting('banner_path'); ?>
<body>
<?php if (!$globalBanner && !$customThemeStylesheet): ?>
<style>
.auth-wrap { background: linear-gradient(135deg, #16202a 0%, #20303d 55%, #16202a 100%) !important; }
.auth-wrap::before { background-image: radial-gradient(rgba(255,255,255,.06) 1px, transparent 1px); background-size: 24px 24px; }
.hero-search { background: linear-gradient(135deg, #16202a 0%, #20303d 55%, #16202a 100%) !important; }
.hero-search::before { background-image: radial-gradient(rgba(255,255,255,.07) 1px, transparent 1px); background-size: 24px 24px; }
</style>
<?php endif; ?>
<header class="site-header">
<div class="topbar-thin">
<span class="welcome">Willkommen, <?= $user ? e($user['name']) : 'Gast' ?></span>
<nav>
<a href="/home.php" class="<?= $activePage==='home'?'active':'' ?>">Supportcenter-Startseite</a>
<a href="/ticket_new.php" class="<?= $activePage==='new'?'active':'' ?>">Neues Ticket eröffnen</a>
<?php if ($user): ?>
<a href="/dashboard.php" class="<?= $activePage==='dashboard'?'active':'' ?>">Meine Tickets</a>
<?php else: ?>
<a href="/ticket_lookup.php" class="<?= $activePage==='lookup'?'active':'' ?>">Ticketstatus überprüfen</a>
<?php endif; ?>
<?php if (get_setting('kb_enabled', '1') === '1'): ?>
<a href="/kb.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</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; ?>
</nav>
</div>
<div class="logo-row" style="display:flex; align-items:center; gap:12px">
<a href="/home.php" style="line-height:0; flex-shrink:0">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e($displayName) ?>">
<?php else: ?>
<span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?>
</a>
<?php if ($logo && $displayName): ?>
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
</header>
<?php if (!empty($showHeroBanner)): ?>
<?php
$kbEnabledForHero = get_setting('kb_enabled', '1') === '1';
$heroEyebrow = $heroEyebrow ?? 'Supportcenter';
$heroTitle = $heroTitle ?? ($kbEnabledForHero ? 'Wie können wir helfen?' : 'Willkommen im Supportcenter');
$heroSubtitle = $heroSubtitle ?? ($kbEnabledForHero
? 'Wissensdatenbank durchsuchen oder direkt ein Ticket eröffnen – wir antworten so schnell wie möglich.'
: 'Eröffnen Sie ein Ticket – wir antworten so schnell wie möglich.');
$heroSearchPlaceholder = $heroSearchPlaceholder ?? 'z. B. Passwort zurücksetzen, Rechnung, Versand …';
$heroSearchValue = $heroSearchValue ?? '';
$banner = !empty($showHeroImage) ? get_setting('banner_path') : null;
?>
<div class="hero-search<?= $banner ? ' has-image' : '' ?>"<?= $banner ? ' style="background-image:url(' . e($banner) . ')"' : '' ?>>
<div class="hero-inner">
<span class="eyebrow"><?= e($heroEyebrow) ?></span>
<h1><?= e($heroTitle) ?></h1>
<p class="sub"><?= e($heroSubtitle) ?></p>
<?php if ($kbEnabledForHero): ?>
<form method="get" action="/kb.php" class="search-form">
<input type="search" name="q" placeholder="<?= e($heroSearchPlaceholder) ?>" value="<?= e($heroSearchValue) ?>">
<button type="submit">Suchen</button>
</form>
<div class="quick-links">oder direkt <a href="/ticket_new.php">ein Ticket eröffnen</a></div>
<?php else: ?>
<div class="btn-row" style="justify-content:center">
<a href="/ticket_new.php" class="btn">Neues Ticket eröffnen</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="page-content">
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
+113
View File
@@ -0,0 +1,113 @@
<?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;
}
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* includes/imap_spam.php
*
* Markiert per E-Mail eingegangene Tickets als Spam (und macht das rückgängig) –
* verschiebt dabei die Original-E-Mail im zugehörigen IMAP-Postfach in den
* konfigurierten Spam-/Junk-Ordner bzw. bei "Rückgängig" wieder zurück.
*
* set_ticket_spam() ist der gemeinsame Einstiegspunkt für Web (staff/ticket_view.php)
* und die mobile App (api/staff/v1/ticket_spam.php) – beide rufen dieselbe Funktion auf,
* damit sich das Verhalten nie auseinanderentwickelt.
*/
function imap_connect_account(array $account, string $folder): mixed
{
if (!function_exists('imap_open')) return false;
$sslFlag = match ($account['encryption']) {
'ssl' => '/imap/ssl',
'tls' => '/imap/tls',
default => '/imap/notls',
};
$mailbox = '{' . $account['host'] . ':' . $account['port'] . $sslFlag . '}' . $folder;
return @imap_open($mailbox, $account['username'], decrypt_secret($account['password_enc']), 0, 1);
}
function imap_normalize_message_id(?string $value): string
{
return trim(strtolower((string) $value), " \t\r\n<>");
}
/**
* Verschiebt EINE bereits als Ticket importierte Mail in einen Ziel-Ordner
* (Spam beim Markieren, ursprünglicher Posteingang beim Rückgängigmachen).
* Aktualisiert bei Erfolg current_folder in ticket_email_sources, damit
* nachfolgende Aufrufe (erneutes Markieren, Rückgängig machen) wissen, wo die
* Mail gerade tatsächlich liegt.
*/
function move_ticket_email_source(PDO $pdo, array $source, array $account, string $targetFolder): array
{
if (!function_exists('imap_open')) {
return ['ok' => false, 'message' => 'PHP-IMAP ist nicht installiert.'];
}
$sourceFolder = trim((string) ($source['current_folder'] ?? ''));
$targetFolder = trim($targetFolder);
if ($sourceFolder === '' || $targetFolder === '') {
return ['ok' => false, 'message' => 'IMAP-Quell- oder Zielordner fehlt.'];
}
if (strcasecmp($sourceFolder, $targetFolder) === 0) {
return ['ok' => true, 'message' => 'Die E-Mail befindet sich bereits in „' . $targetFolder . '“.'];
}
$mbox = imap_connect_account($account, $sourceFolder);
if (!$mbox) {
return ['ok' => false, 'message' => 'IMAP-Quellordner konnte nicht geöffnet werden: ' . (imap_last_error() ?: 'unbekannter Fehler')];
}
$emailNum = null;
$uid = (int) ($source['message_uid'] ?? 0);
if ($uid > 0) {
$candidate = @imap_msgno($mbox, $uid);
if ($candidate > 0) $emailNum = $candidate;
}
// Nach einem vorherigen Verschieben kann sich die UID geändert haben. Dann über Message-ID suchen.
if (!$emailNum && !empty($source['message_id'])) {
$targetId = imap_normalize_message_id($source['message_id']);
$hits = @imap_search($mbox, 'ALL');
if ($hits) {
foreach ($hits as $num) {
$h = @imap_headerinfo($mbox, $num);
$mid = imap_normalize_message_id($h->message_id ?? '');
if ($mid !== '' && $mid === $targetId) {
$emailNum = (int) $num;
break;
}
}
}
}
if (!$emailNum) {
imap_close($mbox);
return ['ok' => false, 'message' => 'Original-E-Mail wurde im IMAP-Ordner nicht gefunden.'];
}
if (!@imap_mail_move($mbox, (string) $emailNum, $targetFolder)) {
$err = imap_last_error() ?: 'unbekannter IMAP-Fehler';
imap_close($mbox);
return ['ok' => false, 'message' => 'Verschieben nach „' . $targetFolder . '“ fehlgeschlagen: ' . $err];
}
@imap_expunge($mbox);
imap_close($mbox);
if (!empty($source['id'])) {
$pdo->prepare('UPDATE ticket_email_sources SET current_folder = ? WHERE id = ?')
->execute([$targetFolder, (int) $source['id']]);
}
return ['ok' => true, 'message' => 'E-Mail nach „' . $targetFolder . '“ verschoben.'];
}
/**
* Markiert ein E-Mail-Ticket als Spam, oder macht das (mit $restore = true)
* wieder rückgängig. Gemeinsam genutzt von Web und App.
*
* @return array{error: ?string, moved: int, already: int, failed: string[]}
*/
function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore = false): array
{
if (($ticket['source'] ?? '') !== 'email') {
return ['error' => 'Nur per E-Mail eingegangene Tickets können als Spam markiert werden.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
if ($restore && empty($ticket['is_spam'])) {
return ['error' => 'Dieses Ticket ist nicht als Spam markiert.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
if (!$restore && !empty($ticket['is_spam'])) {
return ['error' => 'Dieses Ticket ist bereits als Spam markiert.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
$srcStmt = $pdo->prepare(
'SELECT tes.*, ia.label, ia.spam_folder, ia.folder AS inbox_folder, ia.host, ia.port, ia.encryption, ia.username, ia.password_enc
FROM ticket_email_sources tes
JOIN imap_accounts ia ON ia.id = tes.imap_account_id
WHERE tes.ticket_id = ? ORDER BY tes.id'
);
$srcStmt->execute([(int) $ticket['id']]);
$sources = $srcStmt->fetchAll();
$moved = 0;
$already = 0;
$failed = [];
foreach ($sources as $source) {
$targetFolder = $restore
? ($source['inbox_folder'] ?: 'INBOX')
: ($source['spam_folder'] ?: 'Junk');
$result = move_ticket_email_source($pdo, $source, $source, $targetFolder);
if ($result['ok']) {
if (str_contains($result['message'], 'befindet sich bereits')) {
$already++;
} else {
$moved++;
}
} else {
$failed[] = ($source['label'] ?: 'Postfach') . ': ' . $result['message'];
}
}
$now = db_now();
if ($restore) {
$pdo->prepare('UPDATE tickets SET is_spam = 0, updated_at = ? WHERE id = ?')->execute([$now, $ticket['id']]);
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
} else {
$pdo->prepare(
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]);
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
}
return ['error' => null, 'moved' => $moved, 'already' => $already, 'failed' => $failed];
}
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* Schnittstellen für Fremdsysteme:
* - API-Schlüssel für die öffentliche Ticket-API (api/public/v1/tickets.php)
* - Webhooks: DeskHub meldet Ereignisse (Ticket erstellt/beantwortet/Status/Zuweisung) per HTTP-POST an eine URL.
*/
/** Alle Ereignisse, die Webhooks abonnieren können. */
function webhook_event_labels(): array
{
return [
'ticket.created' => 'Ticket erstellt',
'ticket.replied' => 'Antwort/Notiz zu einem Ticket',
'ticket.status_changed' => 'Status geändert',
'ticket.assigned' => 'Ticket zugewiesen',
];
}
// ── API-Schlüssel ───────────────────────────────────────────────────────────
/** Erzeugt einen Schlüssel. Der Klartext wird nur hier zurückgegeben und nie gespeichert (nur der SHA-256-Hash). */
function api_key_create(PDO $pdo, string $name, bool $canCreate, bool $canRead, string $ipWhitelist, ?int $staffId): array
{
$name = trim($name);
if ($name === '') throw new AdminModelException('Bitte einen Namen für den Schlüssel angeben.', 'NAME_REQUIRED');
if (!$canCreate && !$canRead) throw new AdminModelException('Der Schlüssel braucht mindestens ein Recht (Tickets anlegen oder lesen).', 'NO_PERMISSION');
$ips = [];
foreach (preg_split('/[\s,;]+/', $ipWhitelist) ?: [] as $ip) {
if ($ip === '') continue;
if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new AdminModelException("Ungültige IP-Adresse: {$ip}", 'INVALID_IP');
$ips[] = $ip;
}
$key = 'dh_' . bin2hex(random_bytes(24));
$pdo->prepare('INSERT INTO api_keys (name, key_hash, key_prefix, can_create_tickets, can_read_tickets, ip_whitelist, is_active, created_by, created_at) VALUES (?,?,?,?,?,?,1,?,?)')
->execute([$name, hash('sha256', $key), substr($key, 0, 9), $canCreate ? 1 : 0, $canRead ? 1 : 0, $ips ? implode(',', $ips) : null, $staffId, db_now()]);
return ['id' => (int) $pdo->lastInsertId(), 'key' => $key];
}
/** Prüft einen Schlüssel samt IP-Beschränkung. Gibt die Zeile zurück oder null. */
function api_key_verify(PDO $pdo, string $key, string $ip): ?array
{
if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return null;
$stmt = $pdo->prepare('SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1');
$stmt->execute([hash('sha256', $key)]);
$row = $stmt->fetch();
if (!$row) return null;
if (!empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true)) return null;
$pdo->prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
return $row;
}
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
/**
* Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an,
* benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus.
*
* @return array{number:string,id:int}
* @throws AdminModelException bei ungültigen Eingaben
*/
function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
{
$name = trim((string) ($in['name'] ?? ''));
$email = trim((string) ($in['email'] ?? ''));
$subject = trim((string) ($in['subject'] ?? ''));
$message = trim((string) ($in['message'] ?? ''));
if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT');
if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE');
if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403);
$topicId = am_int_or_null($in, 'helpTopicId');
$deptId = am_int_or_null($in, 'departmentId');
$prioId = am_int_or_null($in, 'priorityId');
if ($topicId !== null) {
$t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1');
$t->execute([$topicId]);
$topic = $t->fetch();
if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC');
$deptId = $deptId ?? (int) $topic['department_id'];
$prioId = $prioId ?? (int) $topic['priority_id'];
}
$ruleResult = evaluate_ticket_rules($pdo, ['subject' => $subject, 'message' => $message, 'requester_email' => $email, 'requester_name' => $name, 'source' => 'api']);
if ($ruleResult['reject']) throw new AdminModelException('Das Ticket wurde abgelehnt.', 'REJECTED', 403);
if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $topicId = $ruleResult['set_help_topic'];
$deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1'));
if (!$deptId) throw new AdminModelException('Es ist keine Abteilung eingerichtet.', 'NO_DEPARTMENT', 503);
if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$pStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$pStmt->execute([$prioId]);
$priority = $pStmt->fetch();
if (!$priority) throw new AdminModelException('Die Priorität ist ungültig.', 'INVALID_PRIORITY');
$sla = calc_sla_dates($priority, null, $deptId, $topicId);
$number = generate_ticket_number($pdo);
$now = db_now();
if (get_setting('ip_anonymize', '0') === '1') {
$clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp);
$clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp);
}
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, help_topic_id, priority_id,
status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$number, $subject, $name, $email, $deptId, $topicId, $prioId, 'open', 'api', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
->execute([$ticketId, 'customer', $message, $now]);
log_activity($pdo, $ticketId, null, 'Ticket über die API erstellt');
if ($ruleResult['assign_staff'] !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$ruleResult['assign_staff'], $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$d = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$d->execute([$deptId]);
$dept = $d->fetch();
$forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email,
'department_id' => $deptId, 'department_name' => $dept['name'] ?? null];
notify_new_ticket_channels($forNotify, $dept['email'] ?? null);
notify_staff_new_ticket($forNotify);
notify_customer_ticket_created($forNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
return ['number' => $number, 'id' => $ticketId];
}
// ── Webhooks ────────────────────────────────────────────────────────────────
/** Prüft eine Webhook-URL: nur http(s), und der Zielhost darf nicht intern (localhost/private Netze) sein. */
function webhook_validate_url(string $url): void
{
$parts = parse_url($url);
if (!$parts || !in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
throw new AdminModelException('Bitte eine gültige URL mit http:// oder https:// angeben.', 'INVALID_URL');
}
$ip = filter_var($parts['host'], FILTER_VALIDATE_IP) ? $parts['host'] : gethostbyname($parts['host']);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new AdminModelException('Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel und ist nicht erlaubt.', 'INVALID_URL');
}
}
/** Legt einen Webhook an oder aktualisiert ihn. Ein leeres Secret lässt das bisherige unverändert. */
function webhook_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$url = trim((string) ($in['url'] ?? ''));
$secret = (string) ($in['secret'] ?? '');
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1;
$events = array_values(array_intersect(array_keys(webhook_event_labels()), array_map('strval', (array) ($in['events'] ?? []))));
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
if (!$events) throw new AdminModelException('Bitte mindestens ein Ereignis wählen.', 'NO_EVENTS');
webhook_validate_url($url);
$now = db_now();
if ($id === 0) {
if ($secret === '') $secret = bin2hex(random_bytes(20));
$pdo->prepare('INSERT INTO webhooks (name, url, secret_enc, events, is_active, fail_count, created_at) VALUES (?,?,?,?,?,0,?)')
->execute([$name, $url, encrypt_secret($secret), implode(',', $events), $isActive, $now]);
return (int) $pdo->lastInsertId();
}
$sql = 'UPDATE webhooks SET name=?, url=?, events=?, is_active=?';
$params = [$name, $url, implode(',', $events), $isActive];
if ($secret !== '') { $sql .= ', secret_enc=?'; $params[] = encrypt_secret($secret); }
$pdo->prepare($sql . ' WHERE id=?')->execute([...$params, $id]);
return $id;
}
/** Baut die Ereignis-Nutzlast für ein Ticket. */
function webhook_payload(PDO $pdo, string $event, int $ticketId, array $extra = []): ?array
{
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, d.name AS department_name, s.name AS staff_name
FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id WHERE t.id = ?'
);
$stmt->execute([$ticketId]);
$t = $stmt->fetch();
if (!$t) return null;
return [
'event' => $event,
'occurredAt' => gmdate('c'),
'ticket' => [
'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status'], 'source' => $t['source'],
'priority' => $t['priority_name'], 'department' => $t['department_name'], 'assignedTo' => $t['staff_name'],
'requesterName' => $t['requester_name'], 'requesterEmail' => $t['requester_email'],
'createdAt' => $t['created_at'], 'updatedAt' => $t['updated_at'],
],
] + $extra;
}
/** Sendet einen Webhook sofort. Gibt [HTTP-Status, Fehlertext|null] zurück. */
function webhook_deliver(array $hook, array $payload): array
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$secret = decrypt_secret($hook['secret_enc']);
$headers = [
'Content-Type: application/json', 'User-Agent: DeskHub-Webhook/1', 'X-DeskHub-Event: ' . $payload['event'],
'X-DeskHub-Signature: sha256=' . hash_hmac('sha256', $body, $secret),
];
// Ziel beim Senden erneut prüfen und die Adresse festnageln (verhindert DNS-Rebinding: der Name zeigte beim Speichern
// auf eine öffentliche, zeigt jetzt aber auf eine interne Adresse). Nur Tests dürfen an lokale Empfänger senden.
$parts = parse_url((string) $hook['url']) ?: [];
$host = (string) ($parts['host'] ?? '');
$pinned = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
$testing = getenv('DESKHUB_TESTING') === '1';
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| (!$testing && !filter_var($pinned, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))) {
return [0, 'Ziel ist intern oder nicht auflösbar (blockiert)'];
}
if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => 5, 'ignore_errors' => true]]);
$res = @file_get_contents($hook['url'], false, $ctx);
$status = 0;
foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
return [$status, $res === false ? 'Verbindung fehlgeschlagen' : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")];
}
$ch = curl_init($hook['url']);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_RESOLVE => [$host . ':' . (int) ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)) . ':' . $pinned]]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$status, $err !== '' ? $err : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")];
}
/**
* Meldet ein Ereignis an alle aktiven Webhooks, die es abonniert haben. Die Zustellung läuft nach dem
* Absenden der Antwort (Shutdown), damit sie den Aufrufer nicht aufhält. Fehler werden am Webhook vermerkt;
* nach 10 Fehlern in Folge wird er automatisch deaktiviert.
*/
function webhook_dispatch(PDO $pdo, string $event, int $ticketId, array $extra = []): void
{
// Plugins informieren (läuft isoliert, Fehler in Plugins beeinflussen das Ticket nie)
plugin_hook($event, ['event' => $event, 'ticketId' => $ticketId] + $extra);
try {
$hooks = $pdo->query('SELECT * FROM webhooks WHERE is_active = 1')->fetchAll();
$hooks = array_values(array_filter($hooks, fn($h) => in_array($event, explode(',', (string) $h['events']), true)));
if (!$hooks) return;
$payload = webhook_payload($pdo, $event, $ticketId, $extra);
if (!$payload) return;
} catch (Throwable $e) {
error_log('webhook_dispatch: ' . $e->getMessage());
return;
}
$run = function () use ($pdo, $hooks, $payload): void {
foreach ($hooks as $hook) {
try {
[$status, $error] = webhook_deliver($hook, $payload);
if ($error === null) {
$pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = NULL, last_delivery_at = ?, fail_count = 0 WHERE id = ?')->execute([$status, db_now(), $hook['id']]);
} else {
$fails = (int) $hook['fail_count'] + 1;
$pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = ?, last_delivery_at = ?, fail_count = ?, is_active = ? WHERE id = ?')
->execute([$status ?: null, mb_substr($error, 0, 300), db_now(), $fails, $fails >= 10 ? 0 : 1, $hook['id']]);
}
} catch (Throwable $e) {
error_log('webhook_deliver: ' . $e->getMessage());
}
}
};
if (PHP_SAPI === 'cli') { $run(); return; } // Tests/Cron: sofort
register_shutdown_function(function () use ($run) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
$run();
});
}
+470
View File
@@ -0,0 +1,470 @@
<?php
/**
* Mailer mit zwei Versandwegen:
* 1) Eigener SMTP-Client (includes/smtp_mailer.php), sobald unter Einstellungen
* → E-Mail-Versand ein SMTP-Server hinterlegt ist – funktioniert unabhängig
* von der Hosting-Konfiguration und ohne externe Bibliothek.
* 2) Fallback auf die einfache PHP-Funktion mail() (config.php → mail.enabled),
* falls kein SMTP-Server hinterlegt ist.
* Der Rest der Anwendung ruft ausschließlich send_mail() auf.
*/
function smtp_last_error(): string
{
static $lastError = '';
return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError;
}
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
$smtpHost = get_setting('smtp_host', '');
$fromHeader = $fromOverride ?: smtp_default_from();
if ($smtpHost === '' && !$cfg['enabled'] && !($fromOverride && email_address_smtp_for(get_db(), $fromHeader))) {
// Weder eigener SMTP-Server konfiguriert noch PHP mail() aktiviert.
// Wird still ignoriert, damit der Rest der Anwendung normal funktioniert.
return false;
}
// Eigener SMTP-Server der Absenderadresse (osTicket-Reiter "Abgehend") hat Vorrang vor dem globalen.
$own = $fromOverride ? email_address_smtp_for(get_db(), $fromHeader) : null;
if ($own !== null) {
$mailer = new SmtpMailer(
$own['smtp_host'],
(int) $own['smtp_port'],
$own['smtp_encryption'] ?: 'tls',
(string) $own['smtp_username'],
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand (eigener Server) fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
if ($smtpHost !== '') {
$mailer = new SmtpMailer(
$smtpHost,
(int) get_setting('smtp_port', '587'),
get_setting('smtp_encryption', 'tls'),
get_setting('smtp_username', ''),
decrypt_secret(get_setting('smtp_password', '')),
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "From: {$fromHeader}\r\n";
return @mail($to, $subject, $htmlBody, $headers);
}
/**
* Wie send_mail(), protokolliert einen Fehlschlag aber zusätzlich sichtbar im
* Admin-Protokoll (staff/audit_log.php). System-Mails scheitern sonst komplett
* unbemerkt – der Kunde bekommt keine Fehlermeldung und niemand wird informiert.
*/
function email_send_and_log(string $to, string $subject, string $htmlBody, ?string $from, string $context, string $ticketNumber = ''): bool
{
$ok = send_mail($to, $subject, $htmlBody, $from);
if (!$ok) {
$ref = $ticketNumber !== '' ? " (Ticket #{$ticketNumber})" : '';
// admin_audit_log.action ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein
// zu langer Wert (z.B. eine ausführliche SMTP-Fehlermeldung) sonst eine harte Exception.
$message = mb_substr("{$context}{$ref} an {$to} fehlgeschlagen: " . smtp_last_error(), 0, 255);
audit_log(null, $message);
}
return $ok;
}
/** Standard-Absenderadresse: eigene SMTP-Absenderangabe, sonst die Werte aus config.php */
function smtp_default_from(): string
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
$name = get_setting('smtp_from_name') ?: $cfg['from_name'];
$email = get_setting('smtp_from_email') ?: $cfg['from_email'];
return sprintf('%s <%s>', $name, $email);
}
/**
* Ermittelt die passende Absenderadresse für eine Abteilung (optional nach
* Priorität verfeinert) aus der E-Mail-Adressen-Verwaltung. Gibt null zurück,
* wenn keine passt – dann verwendet send_mail() die globale Standardadresse.
*/
function resolve_from_address(?int $departmentId, ?int $priorityId = null): ?string
{
if (!$departmentId) return null;
$pdo = get_db();
if ($priorityId) {
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND priority_id = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId, $priorityId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_default = 1 AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_active = 1 ORDER BY id LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
return null;
}
/**
* Liest eine anpassbare E-Mail-Vorlage aus den Einstellungen (Adminportal → E-Mail-Vorlagen)
* und ersetzt Platzhalter wie {ticket_number}. Fällt auf die mitgegebenen Standardtexte
* zurück, solange der Admin nichts Eigenes hinterlegt hat.
*
* @param array<string,string> $vars Platzhalter ohne geschweifte Klammern, z.B. ['ticket_number' => '...']
* @return array{0:string,1:string} [Betreff, HTML-Text]
*/
function render_email_template(string $key, array $vars, string $defaultSubject, string $defaultBody): array
{
$subject = get_setting("email_template_{$key}_subject") ?: $defaultSubject;
$body = get_setting("email_template_{$key}_body") ?: $defaultBody;
$search = [];
$replace = [];
foreach ($vars as $name => $value) {
$search[] = '{' . $name . '}';
$replace[] = $value;
}
return [str_replace($search, $replace, $subject), str_replace($search, $replace, $body)];
}
// ====================================================================
// Einheitliches, modernes E-Mail-Design
// ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
// Bausteine für die Standardtexte der einzelnen Vorlagen.
/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */
function email_wrap(string $innerHtml): string
{
$siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'));
return <<<HTML
<div style="background:#f5f7f9;padding:32px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="background:#0f6f6a;padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span>
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
{$innerHtml}
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;">
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p>
</td>
</tr>
</table>
</div>
HTML;
}
/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
{
return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background:{$bg};border-radius:8px;padding:14px 18px;">
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div>
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
</td>
</tr>
</table>
HTML;
}
/** Button-artiger Call-to-Action-Link. */
function email_button(string $label, string $url, string $color = '#0f6f6a'): string
{
return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background:{$color};border-radius:8px;">
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a>
</td>
</tr>
</table>
HTML;
}
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
function email_p(string $html, string $marginBottom = '16px'): string
{
return "<p style=\"margin:0 0 {$marginBottom};color:#16202a;font-size:15px;line-height:1.5;\">{$html}</p>";
}
/**
* Standard-Textkörper der vier anpassbaren Vorlagen (Adminportal → E-Mail-Vorlagen).
* Enthalten nur den Innenbereich – der Branding-Rahmen kommt beim Versand
* automatisch über email_wrap() dazu, auch wenn ein Admin den Text anpasst.
*/
function email_ticket_created_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
. email_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}')
. '<p style="margin:24px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.</p>';
}
function email_new_reply_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:')
. '<div style="background:#f8fafc;border-left:3px solid #0f6f6a;border-radius:0 6px 6px 0;padding:14px 18px;margin:0 0 24px;color:#16202a;font-size:14px;line-height:1.6;">{message}</div>'
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_new_ticket_department_default_body(): string
{
return email_p('Ein neues Ticket wurde erstellt.')
. email_badge('Ticket', '#{ticket_number}')
. email_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '6px')
. email_p('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_ticket_overdue_default_body(): string
{
return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
. email_p('<strong>Betreff:</strong> {subject}', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '24px')
. email_button('Ticket ansehen', '{ticket_url}', '#dc2626');
}
function email_live_chat_transcript_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
. '{transcript}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}');
}
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
function email_password_reset_body(string $link): string
{
$inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist <strong>1 Stunde</strong> lang gültig.')
. email_button('Passwort zurücksetzen', e($link))
. '<p style="margin:20px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.</p>';
return email_wrap($inner);
}
/** @param string[] $collaboratorEmails Zusätzliche Empfänger (Kollaboratoren), die dieselbe Antwort-Mail erhalten */
function notify_customer_new_reply(array $ticket, string $message, string $signature = '', array $collaboratorEmails = []): void
{
// $message ist bereits über sanitize_staff_html() bereinigtes, sicheres HTML – nicht erneut escapen.
// $signature stammt aus der Mitarbeiter- oder Abteilungspflege und wurde dort ebenfalls bereinigt.
$messageWithSignature = $message . ($signature !== '' ? "<div style=\"margin-top:18px;color:#64748b\">{$signature}</div>" : '');
[$subject, $body] = render_email_template('new_reply', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'message' => $messageWithSignature,
'ticket_url' => e(ticket_access_url($ticket)),
], "[#{$ticket['ticket_number']}] {$ticket['subject']}", email_new_reply_default_body());
$body = email_wrap($body);
$from = resolve_from_address($ticket['department_id'] ?? null, $ticket['priority_id'] ?? null);
email_send_and_log($ticket['requester_email'], $subject, $body, $from, "Neue-Antwort-Mail", $ticket['ticket_number']);
foreach ($collaboratorEmails as $ccEmail) {
if ($ccEmail === '' || strcasecmp($ccEmail, $ticket['requester_email']) === 0) continue;
email_send_and_log($ccEmail, $subject, $body, $from, "Neue-Antwort-Mail (Kollaborateur)", $ticket['ticket_number']);
}
}
function notify_customer_ticket_created(array $ticket): void
{
[$subject, $body] = render_email_template('ticket_created', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => e(ticket_access_url($ticket)),
'lookup_url' => base_url() . '/ticket_lookup.php',
], "Ihr Ticket #{ticket_number} wurde erstellt", email_ticket_created_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Ticket-erstellt-Mail', $ticket['ticket_number']);
}
/** Rendert eine Chat-Nachricht als E-Mail-taugliche (Inline-Styles statt CSS-Klassen) Zeile. */
function email_chat_transcript_row(string $authorLabel, string $message, string $time, bool $isStaff): string
{
$align = $isStaff ? 'right' : 'left';
$bg = $isStaff ? '#0f6f6a' : '#f1f5f9';
$color = $isStaff ? '#ffffff' : '#16202a';
$safeMessage = nl2br(e($message));
return <<<HTML
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 10px;">
<tr>
<td align="{$align}">
<div style="font-size:11px;color:#94a3b8;margin-bottom:3px;">{$authorLabel} · {$time}</div>
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:80%;display:inline-block;">
<tr>
<td style="background:{$bg};color:{$color};border-radius:10px;padding:8px 12px;font-size:14px;line-height:1.5;text-align:left;">{$safeMessage}</td>
</tr>
</table>
</td>
</tr>
</table>
HTML;
}
/**
* Schickt dem Kunden nach Ende eines Live-Chats (staff/live_chat.php, Aktion "end")
* den vollständigen Verlauf als E-Mail zu – abschaltbar über die Einstellung
* "live_chat_send_transcript" (Standard: an). $messages im Format wie von
* chat_messages() in live_chat.php: [['authorType'=>..,'authorName'=>..,'message'=>..,'createdAt'=>..], ...]
*/
function notify_customer_chat_transcript(array $ticket, array $messages): void
{
if (get_setting('live_chat_send_transcript', '1') !== '1') return;
if (!$messages) return;
$rows = '';
foreach ($messages as $m) {
$isStaff = $m['authorType'] === 'staff';
$label = $isStaff ? ($m['authorName'] ?: 'Support') : ($ticket['requester_name'] ?? 'Sie');
$time = substr((string) $m['createdAt'], 0, 16);
// Mitarbeiter-Nachrichten sind sanitiztes HTML (Rich-Text-Editor), Kunden-Nachrichten
// rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
$rawMessage = (string) $m['message'];
$plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage)
? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
}
$transcriptBlock = '<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:0 0 20px;">' . $rows . '</div>';
[$subject, $body] = render_email_template('live_chat_transcript', [
'ticket_number' => $ticket['ticket_number'],
'customer_name' => e($ticket['requester_name'] ?? ''),
'transcript' => $transcriptBlock,
'ticket_url' => e(ticket_access_url($ticket)),
], 'Ihr Chatverlauf – Ticket #{ticket_number}', email_live_chat_transcript_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Chatverlauf-Mail', $ticket['ticket_number']);
}
function notify_department_new_ticket(array $ticket, string $departmentEmail): void
{
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
email_send_and_log($departmentEmail, $subject, email_wrap($body), null, 'Neues-Ticket-Mail (Abteilung)', $ticket['ticket_number']);
}
/**
* Benachrichtigt per E-Mail: alle aktiven Admins (immer) sowie Mitarbeiter, deren
* Hauptabteilung der Ticket-Abteilung entspricht oder die für diese Abteilung als
* zusätzliche Abteilung mit aktivierter Benachrichtigung (staff_department.notify) eingetragen sind.
* Ergänzt die bestehende generische Abteilungs-E-Mail (notify_department_new_ticket), die weiterhin
* zusätzlich an die in der E-Mail-Adressen-Verwaltung hinterlegte Postfach-Adresse geht.
*/
function notify_staff_new_ticket(array $ticket): void
{
$pdo = get_db();
$deptId = $ticket['department_id'] ?? null;
if ($deptId) {
$stmt = $pdo->prepare(
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
role = 'admin'
OR department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
)"
);
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
}
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$recipients) return;
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
$body = email_wrap($body);
foreach ($recipients as $email) {
email_send_and_log($email, $subject, $body, null, 'Neues-Ticket-Mail (Mitarbeiter)', $ticket['ticket_number']);
}
}
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
function notify_admins_overdue_ticket(array $ticket): void
{
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
if (!$admins) return;
[$subject, $body] = render_email_template('ticket_overdue', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
$body = email_wrap($body);
foreach ($admins as $email) {
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Discord- und Telegram-Benachrichtigungen für Mitarbeiter.
*
* Konfiguration erfolgt unter "Einstellungen" im Mitarbeiterbereich (nicht in
* config/config.php), da es sich um Werte handelt, die Admins ohne Dateizugriff
* ändern können sollen. Gespeichert in der settings-Tabelle.
*
* Datenschutz-Hinweis: Bei aktivierten Benachrichtigungen werden Ticket-
* Ausschnitte (Betreff, Kundenname/-E-Mail, Nachrichtenanfang) an Discord
* bzw. Telegram als Drittanbieter (außerhalb der EU) übermittelt. Bitte vor
* der Aktivierung die eigenen Datenschutzanforderungen prüfen.
*
* Voraussetzung: PHP-Einstellung allow_url_fopen muss aktiviert sein
* (Standard auf den meisten Hosting-Paketen).
*/
function http_post_json(string $url, array $data, int $timeoutSeconds = 5): bool
{
$opts = ['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($data),
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
]];
$result = @file_get_contents($url, false, stream_context_create($opts));
return $result !== false;
}
function http_post_form(string $url, array $data, int $timeoutSeconds = 5): bool
{
$opts = ['http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($data),
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
]];
$result = @file_get_contents($url, false, stream_context_create($opts));
return $result !== false;
}
function discord_notify(string $message): bool
{
$url = decrypt_secret(get_setting('discord_webhook_url') ?? '');
if (!$url) {
return false;
}
return http_post_json($url, ['content' => $message]);
}
function telegram_notify(string $message): bool
{
$token = decrypt_secret(get_setting('telegram_bot_token') ?? '');
$chatId = get_setting('telegram_chat_id');
if (!$token || !$chatId) {
return false;
}
$url = "https://api.telegram.org/bot{$token}/sendMessage";
return http_post_form($url, ['chat_id' => $chatId, 'text' => $message]);
}
/** Benachrichtigt E-Mail (Abteilung) + Discord + Telegram über ein neues Ticket */
function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): void
{
if ($departmentEmail) {
notify_department_new_ticket($ticket, $departmentEmail);
}
$text = "🆕 Neues Ticket #{$ticket['ticket_number']}\n"
. "Betreff: {$ticket['subject']}\n"
. "Von: {$ticket['requester_name']} ({$ticket['requester_email']})";
if (!empty($ticket['department_name'])) {
$text .= "\nAbteilung: {$ticket['department_name']}";
}
$text .= "\n" . base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
discord_notify($text);
telegram_notify($text);
}
/** Benachrichtigt Discord + Telegram über eine neue Kundenantwort (kein Mail-Duplikat nötig) */
function notify_new_reply_channels(array $ticket, string $excerpt): void
{
$short = mb_strlen($excerpt) > 200 ? mb_substr($excerpt, 0, 200) . '…' : $excerpt;
$text = "💬 Neue Kundenantwort zu Ticket #{$ticket['ticket_number']}\n"
. "Betreff: {$ticket['subject']}\n"
. "„{$short}“\n"
. base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
discord_notify($text);
telegram_notify($text);
}
/**
* Benachrichtigt die Mitglieder eines Teams per E-Mail, wenn ihm ein Ticket zugewiesen wurde.
* Entfällt bei deaktivierten Teams und wenn im Team "Benachrichtigung bei Ticketzuweisung
* deaktivieren" gesetzt ist. Der auslösende Mitarbeiter ($actorId) bekommt keine Mail.
*
* @return int Anzahl verschickter Benachrichtigungen
*/
function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $actorId = null): int
{
$stmt = $pdo->prepare('SELECT name, is_active, no_alerts FROM teams WHERE id = ?');
$stmt->execute([$teamId]);
$team = $stmt->fetch();
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
$members->execute([$teamId]);
$sent = 0;
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
. e($team['name']) . '</strong> zugewiesen.</p>';
foreach ($members->fetchAll() as $m) {
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
if (send_mail($m['email'], $subject, $body)) $sent++;
}
return $sent;
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
/**
* osTicket-Import (Kern): liest einen SQL-Dump (phpMyAdmin oder mysqldump, auch mit anderem Tabellenpräfix als "ost_")
* und übernimmt Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten und Anhänge in DeskHub.
*
* Ablauf (jeweils in kleinen Schritten, damit auch Dumps mit mehreren hundert MB im Browser laufen):
* 1. osticket_analyze_step(): liest die Datei blockweise, merkt sich die nötigen Daten (Zustandsordner) und schreibt
* Nachrichtentexte zeilenweise in entries.jsonl.
* 2. osticket_import_step(): pro Durchgang (users, structure, tickets, threads, attachments) werden Datensätze ab
* einem Offset übernommen. Jeder Schritt meldet next_offset, damit der Aufrufer nichts überspringt.
*/
const OSTICKET_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
/** Tabellen, die gelesen werden (Schlüssel = Name ohne Präfix) mit den Spalten von osTicket 1.18 für Dumps ohne Spaltenliste. */
function osticket_tables(): array
{
return [
'user' => ['id', 'org_id', 'default_email_id', 'status', 'name', 'created', 'updated'],
'user_email' => ['id', 'user_id', 'flags', 'address'],
'ticket' => ['ticket_id', 'ticket_pid', 'number', 'user_id', 'user_email_id', 'status_id', 'dept_id', 'sla_id', 'topic_id', 'staff_id', 'team_id', 'email_id', 'lock_id', 'flags', 'sort', 'ip_address', 'source', 'source_extra', 'isoverdue', 'isanswered', 'duedate', 'est_duedate', 'reopened', 'closed', 'lastupdate', 'created', 'updated'],
'ticket__cdata' => ['ticket_id', 'subject', 'priority'],
'ticket_priority' => ['priority_id', 'priority', 'priority_desc', 'priority_color', 'priority_urgency', 'ispublic'],
'ticket_status' => ['id', 'name', 'state', 'mode', 'flags', 'sort', 'properties', 'created', 'updated'],
'thread' => ['id', 'object_id', 'object_type', 'extra', 'lastresponse', 'lastmessage', 'created'],
'thread_collaborator' => ['id', 'flags', 'thread_id', 'user_id', 'role', 'created', 'updated'],
'thread_entry' => ['id', 'pid', 'thread_id', 'staff_id', 'user_id', 'type', 'flags', 'poster', 'editor', 'editor_type', 'source', 'title', 'body', 'format', 'ip_address', 'extra', 'recipients', 'created', 'updated'],
'department' => ['id', 'pid', 'tpl_id', 'sla_id', 'schedule_id', 'email_id', 'autoresp_email_id', 'manager_id', 'flags', 'name', 'signature', 'ispublic', 'group_membership', 'ticket_auto_response', 'message_auto_response', 'path', 'updated', 'created'],
'help_topic' => ['topic_id', 'topic_pid', 'ispublic', 'isactive', 'ticket_auto_response', 'noautoresp', 'flags', 'status_id', 'priority_id', 'dept_id', 'staff_id', 'team_id', 'sla_id', 'page_id', 'sequence_id', 'sort', 'topic', 'number_format', 'notes', 'created', 'updated'],
'staff' => ['staff_id', 'dept_id', 'role_id', 'username', 'firstname', 'lastname', 'passwd', 'backend', 'email'],
'attachment' => ['id', 'object_id', 'type', 'file_id', 'name', 'inline', 'lang'],
'file' => ['id', 'ft', 'bk', 'type', 'size', 'key', 'signature', 'name', 'attrs', 'created'],
'file_chunk' => ['file_id', 'chunk_id', 'filedata'],
];
}
/** Tabellenname mit beliebigem Präfix -> Schlüssel aus osticket_tables() oder null. */
function osticket_table_key(string $table): ?string
{
static $keys = null;
$keys ??= (function () { $k = array_keys(osticket_tables()); usort($k, fn($a, $b) => strlen($b) <=> strlen($a)); return $k; })();
foreach ($keys as $key) {
if ($table === $key) return $key;
if (str_ends_with($table, '_' . $key) || str_ends_with($table, $key) && strlen($table) > strlen($key) && $table[strlen($table) - strlen($key) - 1] === '_') return $key;
}
return null;
}
/** Inhalt zwischen Anführungszeichen (MySQL-Maskierung) in den echten Text umwandeln. */
function osticket_unescape(string $s): string
{
if (!str_contains($s, '\\') && !str_contains($s, "''")) return $s;
return (string) preg_replace_callback('/\\\\(.)|\'\'/s', function (array $m): string {
if ($m[0] === "''") return "'";
return match ($m[1]) { '0' => "\0", 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'Z' => "\x1a", '%' => '\\%', '_' => '\\_', default => $m[1] };
}, $s);
}
/**
* Zerlegt "(a,'b'),(c,NULL)" in Wertelisten. Strings werden entmaskiert, NULL wird null, alles andere (Zahlen, 0x…) bleibt Text.
* Funktioniert für eine Zeile pro Datensatz (phpMyAdmin) genauso wie für mehrere Datensätze in einer Zeile (mysqldump).
*
* @return Generator<int, array<int, ?string>>
*/
function osticket_rows(string $s): Generator
{
$n = strlen($s);
$i = 0;
while ($i < $n) {
$i = strpos($s, '(', $i);
if ($i === false) return;
$i++;
$vals = [];
$closed = false;
while ($i < $n) {
while ($i < $n && ($s[$i] === ' ' || $s[$i] === "\t")) $i++;
if ($i >= $n) break;
if ($s[$i] === "'") {
$j = $i + 1;
$buf = '';
while (true) {
$k = $j + strcspn($s, "'\\", $j);
if ($k >= $n) { $buf .= substr($s, $j); $j = $n; break; }
$buf .= substr($s, $j, $k - $j);
if ($s[$k] === '\\') { $buf .= substr($s, $k, 2); $j = $k + 2; continue; }
if ($k + 1 < $n && $s[$k + 1] === "'") { $buf .= "''"; $j = $k + 2; continue; }
$j = $k + 1;
break;
}
$vals[] = osticket_unescape($buf);
$i = $j;
} else {
$k = $i + strcspn($s, ',)', $i);
$tok = trim(substr($s, $i, $k - $i));
$vals[] = strcasecmp($tok, 'NULL') === 0 ? null : $tok;
$i = $k;
}
while ($i < $n && ($s[$i] === ' ' || $s[$i] === "\t")) $i++;
if ($i < $n && $s[$i] === ',') { $i++; continue; }
if ($i < $n && $s[$i] === ')') { $i++; $closed = true; break; }
}
if ($closed) yield $vals;
}
}
/** "INSERT INTO `t` (`a`,`b`) VALUES (..." -> [Tabelle, Spalten|null, Rest nach VALUES] oder null. */
function osticket_parse_header(string $line): ?array
{
if (!preg_match('/^INSERT\s+(?:IGNORE\s+)?INTO\s+`?([A-Za-z0-9_]+)`?\s*(?:\(([^)]*)\))?\s*VALUES\s*(.*)$/s', $line, $m)) return null;
$cols = isset($m[2]) && $m[2] !== '' ? array_map(fn($c) => trim($c, " `\t"), explode(',', $m[2])) : null;
return [$m[1], $cols, $m[3]];
}
// ─── Zustand ─────────────────────────────────────────────────────────────────
function osticket_state_dir(): string
{
$dir = sys_get_temp_dir() . '/deskhub_import_' . preg_replace('/[^a-zA-Z0-9]/', '', session_id() ?: 'cli');
if (!is_dir($dir)) @mkdir($dir, 0700, true);
return $dir;
}
function osticket_state_empty(): array
{
return ['users' => [], 'emails' => [], 'tickets' => [], 'cdata' => [], 'thread_ticket' => [], 'ticket_thread' => [], 'collab' => [], 'first_resp' => [],
'departments' => [], 'topics' => [], 'staff' => [], 'prios' => [], 'statuses' => [], 'att' => [], 'files' => [], 'inline_att' => 0,
'entry_count' => 0, 'chunk_start' => null, 'ctx' => null, 'done' => false, 'maps' => null];
}
function osticket_state_load(string $dir): array
{
$f = $dir . '/state.json';
$st = is_file($f) ? json_decode((string) file_get_contents($f), true) : null;
return is_array($st) ? $st + osticket_state_empty() : osticket_state_empty();
}
function osticket_state_save(string $dir, array $st): void
{
file_put_contents($dir . '/state.json', json_encode($st, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), LOCK_EX);
}
function osticket_state_clear(string $dir): void
{
foreach (glob($dir . '/*') ?: [] as $f) @unlink($f);
@rmdir($dir);
}
// ─── Analyse ─────────────────────────────────────────────────────────────────
/** Liest einen Block der Datei ab $offset. Gibt ['offset' => neuer Offset, 'done' => bool, 'counts' => [...]] zurück. */
function osticket_analyze_step(string $sqlPath, string $dir, int $offset, int $maxBytes = 4 * 1024 * 1024): array
{
if ($offset === 0) { foreach (['state.json', 'entries.jsonl', 'entry_map.jsonl'] as $f) @unlink($dir . '/' . $f); }
$st = osticket_state_load($dir);
$fh = fopen($sqlPath, 'rb');
if (!$fh) throw new RuntimeException('Die SQL-Datei konnte nicht geöffnet werden.');
fseek($fh, $offset);
$entries = fopen($dir . '/entries.jsonl', 'ab');
$tables = osticket_tables();
$read = 0;
$ctx = $st['ctx']; // ['key' => Tabellenschlüssel|null, 'cols' => Spalten]
while (!feof($fh) && $read < $maxBytes) {
$lineStart = $offset + $read;
$line = fgets($fh, 64 * 1024 * 1024);
if ($line === false) break;
$read += strlen($line);
if ($line[0] === 'I' && str_starts_with($line, 'INSERT')) {
$h = osticket_parse_header(rtrim($line));
$ctx = null;
if (!$h) continue;
$key = osticket_table_key($h[0]);
if ($key === null) continue;
if ($key === 'file_chunk') { $st['chunk_start'] ??= $lineStart; continue; }
$ctx = ['key' => $key, 'cols' => $h[1] ?? $tables[$key]];
$rest = $h[2];
if ($rest !== '') osticket_consume($st, $ctx, $rest, $entries);
if (str_ends_with(rtrim($line), ';')) $ctx = null;
continue;
}
if ($ctx === null) continue;
if ($line[0] !== '(') { $ctx = null; continue; }
osticket_consume($st, $ctx, $line, $entries);
if (substr(rtrim($line), -1) === ';') $ctx = null;
}
$newOffset = $offset + $read;
$done = feof($fh) || $line === false;
fclose($fh);
fclose($entries);
$st['ctx'] = $done ? null : $ctx;
if ($done) $st['done'] = true;
osticket_state_save($dir, $st);
return ['offset' => $newOffset, 'done' => $done, 'counts' => osticket_counts($st)];
}
/** Verarbeitet die Datensätze einer Zeile für die aktuelle Tabelle. */
function osticket_consume(array &$st, array $ctx, string $text, $entriesFh): void
{
$key = $ctx['key'];
$cols = $ctx['cols'];
$nc = count($cols);
foreach (osticket_rows($text) as $vals) {
if (count($vals) !== $nc) continue;
$r = array_combine($cols, $vals);
switch ($key) {
case 'user':
$st['users'][$r['id']] = ['n' => (string) ($r['name'] ?? ''), 'd' => (int) ($r['default_email_id'] ?? 0)];
break;
case 'user_email':
$st['emails'][$r['id']] = [(int) $r['user_id'], (string) $r['address']];
break;
case 'ticket':
$st['tickets'][$r['ticket_id']] = ['n' => (string) $r['number'], 'u' => (int) $r['user_id'], 's' => (int) $r['status_id'], 'd' => (int) $r['dept_id'], 't' => (int) $r['topic_id'],
'a' => (int) $r['staff_id'], 'src' => (string) ($r['source'] ?? ''), 'c' => $r['created'], 'cl' => $r['closed'] ?? null, 'lu' => $r['lastupdate'] ?? null,
'od' => (int) ($r['isoverdue'] ?? 0), 'ip' => (string) ($r['ip_address'] ?? '')];
break;
case 'ticket__cdata':
$st['cdata'][$r['ticket_id']] = [(string) ($r['subject'] ?? ''), (int) ($r['priority'] ?? 0)];
break;
case 'thread':
if (($r['object_type'] ?? '') === 'T') { $st['thread_ticket'][$r['id']] = (int) $r['object_id']; $st['ticket_thread'][$r['object_id']] = (int) $r['id']; }
break;
case 'thread_collaborator':
if ((int) $r['user_id'] > 0) $st['collab'][$r['thread_id']][] = (int) $r['user_id'];
break;
case 'thread_entry':
$st['entry_count']++;
if (($r['type'] ?? '') === 'R') {
$tid = (string) $r['thread_id'];
if (!isset($st['first_resp'][$tid]) || strcmp((string) $r['created'], $st['first_resp'][$tid]) < 0) $st['first_resp'][$tid] = (string) $r['created'];
}
fwrite($entriesFh, json_encode(['id' => (int) $r['id'], 'th' => (int) $r['thread_id'], 'st' => (int) $r['staff_id'], 'us' => (int) $r['user_id'], 'ty' => (string) $r['type'],
'po' => (string) ($r['poster'] ?? ''), 'b' => (string) ($r['body'] ?? ''), 'f' => (string) ($r['format'] ?? 'html'), 'c' => $r['created']], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . "\n");
break;
case 'department':
$st['departments'][$r['id']] = (string) $r['name'];
break;
case 'help_topic':
$st['topics'][$r['topic_id']] = ['n' => (string) $r['topic'], 'd' => (int) $r['dept_id'], 'p' => (int) ($r['priority_id'] ?? 0), 'on' => (int) ($r['isactive'] ?? 1), 'pub' => (int) ($r['ispublic'] ?? 1)];
break;
case 'staff':
$st['staff'][$r['staff_id']] = ['n' => trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')), 'e' => strtolower((string) ($r['email'] ?? ''))];
break;
case 'ticket_priority':
$st['prios'][$r['priority_id']] = ['n' => (string) ($r['priority'] ?? ''), 'd' => (string) ($r['priority_desc'] ?? ''), 'u' => (int) ($r['priority_urgency'] ?? 0)];
break;
case 'ticket_status':
$st['statuses'][$r['id']] = ['n' => (string) $r['name'], 's' => (string) ($r['state'] ?? '')];
break;
case 'attachment':
if (($r['type'] ?? '') === 'H') {
if ((int) ($r['inline'] ?? 0) === 1) $st['inline_att']++;
else $st['att'][$r['object_id']][] = [(int) $r['file_id'], $r['name'] === null ? null : (string) $r['name']];
}
break;
case 'file':
$st['files'][$r['id']] = ['n' => (string) $r['name'], 't' => (string) ($r['type'] ?? ''), 's' => (int) ($r['size'] ?? 0)];
break;
}
}
}
/** E-Mail-Adresse und Name je osTicket-Kunde. */
function osticket_contacts(array $st): array
{
$byUser = [];
foreach ($st['emails'] as $id => [$uid, $addr]) $byUser[$uid][(int) $id] = $addr;
$out = [];
foreach ($st['users'] as $uid => $u) {
$addr = $byUser[(int) $uid][$u['d']] ?? (isset($byUser[(int) $uid]) ? reset($byUser[(int) $uid]) : '');
$out[(int) $uid] = ['name' => $u['n'], 'email' => trim((string) $addr)];
}
// Adressen ohne passenden Kundeneintrag trotzdem verfügbar machen
foreach ($byUser as $uid => $list) if (!isset($out[$uid])) $out[$uid] = ['name' => '', 'email' => trim((string) reset($list))];
return $out;
}
/** Status eines osTicket-Tickets -> DeskHub-Status oder null (gelöschte Tickets werden nicht importiert). */
function osticket_status(array $st, int $statusId): ?string
{
$s = $st['statuses'][$statusId] ?? null;
if ($s) {
$state = strtolower($s['s']);
if ($state === 'deleted') return null;
if ($state === 'open') return 'open';
if ($state === 'archived') return 'closed';
if ($state === 'closed') return preg_match('/gelöst|geloest|resolved/i', $s['n']) ? 'resolved' : 'closed';
}
return [1 => 'open', 2 => 'resolved', 3 => 'closed', 4 => 'closed'][$statusId] ?? ($statusId === 5 ? null : 'closed');
}
function osticket_counts(array $st): array
{
$tickets = 0;
foreach ($st['tickets'] as $t) if (osticket_status($st, $t['s']) !== null) $tickets++;
$attFiles = [];
foreach ($st['att'] as $list) foreach ($list as [$fid]) $attFiles[$fid] = $st['files'][$fid]['s'] ?? 0;
return ['users' => count(array_filter(osticket_contacts($st), fn($c) => $c['email'] !== '')), 'departments' => count($st['departments']), 'topics' => count($st['topics']),
'tickets' => $tickets, 'threads' => (int) $st['entry_count'], 'attachments' => count($attFiles), 'attachment_bytes' => array_sum($attFiles)];
}
@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
/**
* osTicket-Import, zweiter Teil: Übernahme der analysierten Daten in DeskHub (Durchgänge users, structure, tickets,
* threads, attachments). Siehe osticket_import.php für Analyse und Zustand.
*/
require_once __DIR__ . '/osticket_import.php';
const OSTICKET_CHUNK = 200;
function osticket_fallback_name(string $email, ?string $poster = null): string
{
$poster = trim((string) $poster);
if ($poster !== '') return $poster;
$email = trim($email);
if ($email !== '' && str_contains($email, '@')) return trim((string) strtok($email, '@')) ?: $email;
return $email !== '' ? $email : 'Kunde';
}
function osticket_html_to_text(string $body, string $format = 'html'): string
{
$body = trim($body);
if ($body === '') return '';
if (strtolower($format) !== 'html') {
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace("/\r\n?|\n/u", "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace("/\n{3,}/u", "\n\n", trim($body));
}
$body = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', "\n", $body);
$body = preg_replace('/<(br|\/p|\/div|\/li|\/tr|\/h[1-6])\b[^>]*>/i', "\n", $body);
$body = preg_replace('/<(p|div|li|tr|table|ul|ol|h[1-6])\b[^>]*>/i', "\n", $body);
$body = strip_tags($body);
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace('/\r\n?|\n/u', "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace('/\n{3,}/u', "\n\n", trim($body));
}
function normalize_osticket_body(string $body, string $format, string $authorType): string
{
$body = trim($body);
if ($body === '') return '';
$format = strtolower(trim($format));
if ($authorType === 'staff' || $authorType === 'system') {
if ($format === 'html') return sanitize_staff_html($body);
$parts = [];
foreach (preg_split('/\R{2,}/', preg_replace('/\r\n?/', "\n", $body)) ?: [$body] as $segment) {
$segment = trim($segment);
if ($segment !== '') $parts[] = '<p>' . nl2br(e($segment)) . '</p>';
}
return implode('', $parts) ?: nl2br(e($body));
}
if ($format === 'html') return osticket_html_to_text($body);
return preg_replace('/\n{3,}/', "\n\n", trim(preg_replace('/\r\n?/', "\n", $body)));
}
/** Datum aus dem Dump prüfen; sonst Ersatzwert. */
function osticket_date(?string $d, ?string $fallback = null): ?string
{
return $d !== null && preg_match('/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?/', $d) && !str_starts_with($d, '0000') ? $d : $fallback;
}
// ─── Zuordnungen (Abteilungen, Themen, Mitarbeiter, Prioritäten) ─────────────
/**
* Baut die Zuordnung osTicket-ID -> DeskHub-ID. Mit $create legt sie fehlende Abteilungen und Hilfethemen an, sonst
* werden nur vorhandene (gleicher Name) gefunden. Das Ergebnis wird im Zustand gespeichert.
*
* @return array{dept:array, topic:array, staff:array, prio:array, created:array{departments:int, topics:int}}
*/
function osticket_maps(PDO $pdo, array &$st, bool $create): array
{
$maps = ['dept' => [], 'topic' => [], 'staff' => [], 'prio' => [], 'created' => ['departments' => 0, 'topics' => 0]];
$defaultDept = (int) (get_setting('default_department_id') ?: 0);
if (!$defaultDept) $defaultDept = (int) $pdo->query('SELECT id FROM departments ORDER BY is_default DESC, id LIMIT 1')->fetchColumn();
$maps['default_dept'] = $defaultDept;
// Prioritäten: erst über den Namen, sonst über die Rangfolge (dringendste zuerst)
$dh = $pdo->query('SELECT id, name, resolution_hours FROM priorities ORDER BY resolution_hours, id')->fetchAll();
$maps['default_prio'] = (int) ($pdo->query("SELECT id FROM priorities WHERE LOWER(name) IN ('normal','mittel') ORDER BY id LIMIT 1")->fetchColumn() ?: ($dh[(int) floor((count($dh) - 1) / 2)]['id'] ?? 1));
$ost = $st['prios'];
uasort($ost, fn($a, $b) => ($a['u'] ?: 99) <=> ($b['u'] ?: 99));
$rank = 0;
$n = max(1, count($ost));
foreach ($ost as $id => $p) {
$match = null;
foreach ($dh as $d) if (strcasecmp($d['name'], $p['d']) === 0 || strcasecmp($d['name'], $p['n']) === 0) { $match = (int) $d['id']; break; }
$maps['prio'][(int) $id] = $match ?? (int) ($dh[(int) round($rank * (count($dh) - 1) / max(1, $n - 1))]['id'] ?? $maps['default_prio']);
$rank++;
}
// Abteilungen
$byName = [];
foreach ($pdo->query('SELECT id, name FROM departments')->fetchAll() as $d) $byName[mb_strtolower($d['name'])] = (int) $d['id'];
foreach ($st['departments'] as $id => $name) {
$key = mb_strtolower(trim((string) $name));
if ($key === '') continue;
if (isset($byName[$key])) { $maps['dept'][(int) $id] = $byName[$key]; continue; }
if ($create) {
$new = department_save($pdo, 0, ['name' => trim((string) $name)]);
$byName[$key] = $new;
$maps['dept'][(int) $id] = $new;
$maps['created']['departments']++;
}
}
// Hilfethemen
$topicByName = [];
foreach ($pdo->query('SELECT id, name FROM help_topics')->fetchAll() as $t) $topicByName[mb_strtolower($t['name'])] = (int) $t['id'];
foreach ($st['topics'] as $id => $t) {
$key = mb_strtolower(trim($t['n']));
if ($key === '') continue;
if (isset($topicByName[$key])) { $maps['topic'][(int) $id] = $topicByName[$key]; continue; }
if ($create) {
$new = help_topic_save($pdo, 0, ['name' => trim($t['n']), 'department_id' => $maps['dept'][$t['d']] ?? $defaultDept, 'priority_id' => $maps['prio'][$t['p']] ?? $maps['default_prio'],
'is_active' => $t['on'] ? 1 : 0, 'is_public' => $t['pub'] ? 1 : 0]);
$topicByName[$key] = $new;
$maps['topic'][(int) $id] = $new;
$maps['created']['topics']++;
}
}
// Mitarbeiter über die E-Mail-Adresse
$staffByEmail = [];
foreach ($pdo->query('SELECT id, email FROM staff')->fetchAll() as $s) $staffByEmail[strtolower((string) $s['email'])] = (int) $s['id'];
foreach ($st['staff'] as $id => $s) if ($s['e'] !== '' && isset($staffByEmail[$s['e']])) $maps['staff'][(int) $id] = $staffByEmail[$s['e']];
$st['maps'] = $maps;
return $maps;
}
// ─── Durchgänge ──────────────────────────────────────────────────────────────
/**
* Führt einen Schritt eines Durchgangs aus.
*
* @return array{inserted:int, skipped:int, processed:int, next_offset:int, done:bool, notes:array}
*/
function osticket_import_step(PDO $pdo, string $dir, string $pass, int $offset, string $sqlPath = ''): array
{
@set_time_limit(300);
$st = osticket_state_load($dir);
$r = ['inserted' => 0, 'skipped' => 0, 'processed' => 0, 'next_offset' => $offset, 'done' => false, 'notes' => []];
switch ($pass) {
case 'users': osticket_pass_users($pdo, $st, $offset, $r); break;
case 'structure':
$maps = osticket_maps($pdo, $st, true);
$r['inserted'] = $maps['created']['departments'] + $maps['created']['topics'];
$r['notes'][] = "{$maps['created']['departments']} Abteilung(en) und {$maps['created']['topics']} Hilfethema(en) angelegt, vorhandene gleichen Namens wiederverwendet.";
$r['done'] = true;
break;
case 'tickets': osticket_pass_tickets($pdo, $st, $offset, $r); break;
case 'threads': osticket_pass_threads($pdo, $st, $dir, $offset, $r); break;
case 'attachments': osticket_pass_attachments($pdo, $st, $dir, $sqlPath, $offset, $r); break;
default: throw new InvalidArgumentException('Unbekannter Durchgang.');
}
osticket_state_save($dir, $st);
return $r;
}
function osticket_pass_users(PDO $pdo, array &$st, int $offset, array &$r): void
{
$contacts = osticket_contacts($st);
$ids = array_keys($contacts);
$slice = array_slice($ids, $offset, OSTICKET_CHUNK);
$pw = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT);
$chk = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)');
foreach ($slice as $uid) {
$c = $contacts[$uid];
if ($c['email'] === '' || !str_contains($c['email'], '@')) { $r['skipped']++; continue; }
$chk->execute([$c['email']]);
if ($chk->fetchColumn()) { $r['skipped']++; continue; }
$ins->execute([mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), mb_substr($c['email'], 0, 150), $pw, db_now()]);
$r['inserted']++;
}
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_tickets(PDO $pdo, array &$st, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$prios = [];
foreach ($pdo->query('SELECT * FROM priorities')->fetchAll() as $p) $prios[(int) $p['id']] = $p;
$keys = array_keys($st['tickets']);
$slice = array_slice($keys, $offset, OSTICKET_CHUNK);
$chk = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, help_topic_id, assigned_staff_id, priority_id, status, source, requester_ip,
first_response_due_at, first_responded_at, resolution_due_at, resolved_at, closed_at, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
$sources = ['web' => 'web', 'email' => 'email', 'phone' => 'phone', 'api' => 'api'];
foreach ($slice as $tid) {
$t = $st['tickets'][$tid];
$status = osticket_status($st, $t['s']);
if ($status === null) { $r['skipped']++; $r['notes']['deleted'] = 'Gelöschte osTicket-Tickets wurden nicht importiert.'; continue; }
if ($t['n'] === '') { $r['skipped']++; continue; }
$chk->execute([$t['n']]);
if ($chk->fetchColumn()) { $r['skipped']++; $r['notes']['existing'] = 'Bereits vorhandene Ticketnummern wurden übersprungen.'; continue; }
$contact = $contacts[$t['u']] ?? ['name' => '', 'email' => ''];
$email = $contact['email'];
$name = mb_substr(osticket_fallback_name($email, $contact['name']), 0, 120);
$userId = null;
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
$cdata = $st['cdata'][$tid] ?? ['', 0];
$subject = mb_substr(trim($cdata[0]) !== '' ? trim($cdata[0]) : 'Ticket #' . $t['n'], 0, 255);
$deptId = $maps['dept'][$t['d']] ?? $maps['default_dept'];
$topicId = $maps['topic'][$t['t']] ?? null;
$staffId = $maps['staff'][$t['a']] ?? null;
if ($t['a'] > 0 && !$staffId) $r['unassigned'] = ($r['unassigned'] ?? 0) + 1;
$prioId = $maps['prio'][$cdata[1]] ?? $maps['default_prio'];
$created = osticket_date($t['c'], db_now());
$updated = osticket_date($t['lu'], $created);
$closedAt = in_array($status, ['resolved', 'closed'], true) ? osticket_date($t['cl'], $updated) : null;
$threadId = $st['ticket_thread'][$tid] ?? null;
$firstResp = $threadId !== null ? osticket_date($st['first_resp'][(string) $threadId] ?? null) : null;
$firstDue = $resDue = null;
if (!in_array($status, ['resolved', 'closed'], true) && isset($prios[$prioId])) {
$sla = calc_sla_dates($prios[$prioId], $created, $deptId, $topicId);
$firstDue = $firstResp ? null : $sla['first_response_due_at'];
$resDue = $sla['resolution_due_at'];
}
$ins->execute([$t['n'], $subject, $userId, $name, mb_substr($email, 0, 150), $deptId, $topicId, $staffId, $prioId, $status, $sources[strtolower($t['src'])] ?? 'web',
$t['ip'] !== '' ? mb_substr($t['ip'], 0, 45) : null, $firstDue, $firstResp, $resDue, $closedAt, $closedAt, ($t['od'] && $closedAt === null) ? 1 : 0, $created, $updated]);
$r['inserted']++;
}
if (!empty($r['unassigned'])) $r['notes']['unassigned'] = $r['unassigned'] . ' Ticket(s) waren Mitarbeitern zugewiesen, die es in DeskHub nicht gibt (Zuordnung über gleiche E-Mail-Adresse). Sie sind jetzt unzugewiesen.';
unset($r['unassigned']);
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_threads(PDO $pdo, array &$st, string $dir, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$file = $dir . '/entries.jsonl';
$fh = is_file($file) ? fopen($file, 'rb') : null;
$lines = 0;
$end = true;
$ticketByNumber = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$exists = $pdo->prepare('SELECT id FROM ticket_threads WHERE ticket_id = ? AND created_at = ? AND author_type = ? AND message = ? LIMIT 1');
$ins = $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?,?)');
$entryMap = $fh ? fopen($dir . '/entry_map.jsonl', 'ab') : null;
$ticketCache = [];
$lastTouched = [];
if ($fh) {
fseek($fh, $offset);
while ($lines < OSTICKET_CHUNK && ($line = fgets($fh)) !== false) {
$lines++;
$e = json_decode($line, true);
if (!is_array($e)) { $r['skipped']++; continue; }
$osTid = $st['thread_ticket'][(string) $e['th']] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if ($number === null || $number === '') { $r['skipped']++; continue; }
if (!array_key_exists($number, $ticketCache)) { $ticketByNumber->execute([$number]); $ticketCache[$number] = (int) $ticketByNumber->fetchColumn() ?: null; }
$dhTid = $ticketCache[$number];
if (!$dhTid) { $r['skipped']++; $r['notes']['no_ticket'] = 'Nachrichten zu nicht importierten Tickets wurden übersprungen.'; continue; }
$type = strtoupper($e['ty']);
$staffId = $e['st'] > 0 ? ($maps['staff'][$e['st']] ?? null) : null;
$userId = null;
$internal = 0;
if ($type === 'N') { $author = $staffId ? 'staff' : 'system'; $internal = 1; }
elseif ($e['st'] > 0 || $type === 'R') { $author = 'staff'; }
else {
$author = 'customer';
$email = $contacts[$e['us']]['email'] ?? '';
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
}
if (($e['st'] > 0 || $type === 'R') && !$staffId) $r['notes']['staff'] = 'Antworten von Mitarbeitern, die es in DeskHub nicht gibt (gleiche E-Mail-Adresse nötig), wurden ohne Mitarbeiter übernommen.';
$hasAtt = !empty($st['att'][(string) $e['id']]);
$body = normalize_osticket_body($e['b'], $e['f'], $author);
if (trim(strip_tags($body)) === '') {
if (!$hasAtt) { $r['skipped']++; continue; }
$body = $author === 'customer' ? '(Nachricht ohne Text, siehe Anhang)' : '<p>(Nachricht ohne Text, siehe Anhang)</p>';
}
$created = osticket_date($e['c'], db_now());
$exists->execute([$dhTid, $created, $author, $body]);
$threadId = $exists->fetchColumn();
if (!$threadId) {
$ins->execute([$dhTid, $author, $staffId, $userId, $body, $internal, $created]);
$threadId = (int) $pdo->lastInsertId();
$r['inserted']++;
} else {
$r['skipped']++;
}
if ($hasAtt && $entryMap) fwrite($entryMap, json_encode([(int) $e['id'], (int) $threadId]) . "\n");
}
$r['next_offset'] = ftell($fh);
$end = feof($fh) || fgets($fh) === false;
fclose($fh);
if ($entryMap) fclose($entryMap);
}
$r['processed'] = $lines;
$r['done'] = $end;
if ($end) { // zum Schluss: Mitlesende (Collaborators)
$addedCollab = 0;
$existsC = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)');
$insC = $pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)');
foreach ($st['collab'] as $threadId => $uids) {
$osTid = $st['thread_ticket'][(string) $threadId] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if (!$number) continue;
$ticketByNumber->execute([$number]);
$dhTid = (int) $ticketByNumber->fetchColumn();
if (!$dhTid) continue;
foreach (array_unique($uids) as $uid) {
$c = $contacts[$uid] ?? null;
if (!$c || $c['email'] === '') continue;
$existsC->execute([$dhTid, $c['email']]);
if ((int) $existsC->fetchColumn() > 0) continue;
$insC->execute([$dhTid, $c['email'], mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), db_now()]);
$addedCollab++;
}
}
if ($addedCollab) $r['notes']['collab'] = "{$addedCollab} Mitlesende übernommen.";
}
}
/** Dateiendung für gespeicherte Anhänge: nur bekannte, ungefährliche Typen, alles andere wird .bin. */
function osticket_safe_extension(string $name): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$safe = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'pdf', 'txt', 'log', 'csv', 'rtf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'zip', 'eml', 'msg', 'mp3', 'mp4', 'mov', 'heic'];
return in_array($ext, $safe, true) ? $ext : 'bin';
}
function osticket_pass_attachments(PDO $pdo, array &$st, string $dir, string $sqlPath, int $offset, array &$r): void
{
// Welche Dateien werden gebraucht? (nur Anhänge von Nachrichten, die importiert wurden und nicht zu groß sind)
$wanted = [];
foreach ($st['att'] as $entryId => $list) foreach ($list as [$fileId]) if (isset($st['files'][$fileId]) && $st['files'][$fileId]['s'] <= OSTICKET_ATTACHMENT_MAX_BYTES) $wanted[$fileId] = true;
$parts = $dir . '/files';
if (!is_dir($parts)) @mkdir($parts, 0700, true);
if (!$wanted || $st['chunk_start'] === null || $sqlPath === '' || !is_file($sqlPath)) {
$r['notes'][] = $wanted ? 'In der Datei wurden keine Anhangsdaten gefunden.' : 'Keine Anhänge zum Übernehmen.';
$r['done'] = true;
return;
}
$start = $offset > 0 ? $offset : (int) $st['chunk_start'];
$fh = fopen($sqlPath, 'rb');
fseek($fh, $start);
$read = 0;
$ended = false;
$inChunks = $offset > 0;
while (!feof($fh) && $read < 16 * 1024 * 1024) {
$line = fgets($fh, 64 * 1024 * 1024);
if ($line === false) break;
$read += strlen($line);
$text = null;
if ($line[0] === '(') {
$text = $line;
} elseif (str_starts_with($line, 'INSERT')) {
$h = osticket_parse_header(rtrim($line));
if (!$h || osticket_table_key($h[0]) !== 'file_chunk') { $ended = true; break; }
$inChunks = true;
$text = $h[2]; // mysqldump: die Datensätze stehen in derselben Zeile wie INSERT
}
if ($text !== null && $text !== '') {
foreach (osticket_rows($text) as $row) {
if (count($row) < 3) continue;
$fileId = (int) $row[0];
if (!isset($wanted[$fileId])) continue;
$raw = (string) $row[2];
$data = str_starts_with($raw, '0x') ? (strlen($raw) > 2 ? (string) hex2bin(strlen($raw) % 2 ? '0' . substr($raw, 2) : substr($raw, 2)) : '') : $raw;
file_put_contents($parts . '/' . $fileId . '.part', $data, FILE_APPEND);
}
continue;
}
if (str_starts_with($line, 'CREATE') || str_starts_with($line, 'ALTER') || str_starts_with($line, 'COMMIT')) { $ended = $inChunks || $ended || true; break; }
}
$next = $start + $read;
$atEof = feof($fh);
fclose($fh);
$r['processed'] = 1;
$r['next_offset'] = $next;
if (!$ended && !$atEof) return;
// Fertig mit dem Lesen: Dateien in den Upload-Ordner legen und mit den Nachrichten verknüpfen
$entryThread = [];
$mapFile = $dir . '/entry_map.jsonl';
if (is_file($mapFile)) foreach (file($mapFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) { $p = json_decode($l, true); if (is_array($p)) $entryThread[$p[0]] = $p[1]; }
$uploads = rtrim((string) config()['uploads']['path'], '/' . chr(92));
if (!is_dir($uploads)) @mkdir($uploads, 0755, true);
$ins = $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)');
$dup = $pdo->prepare('SELECT id FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ? LIMIT 1');
$broken = 0; $tooBig = 0; $noThread = 0;
foreach ($st['att'] as $entryId => $list) {
$threadId = $entryThread[(int) $entryId] ?? null;
foreach ($list as [$fileId, $attName]) {
$meta = $st['files'][$fileId] ?? null;
if (!$meta) continue;
if ($meta['s'] > OSTICKET_ATTACHMENT_MAX_BYTES) { $tooBig++; continue; }
if (!$threadId) { $noThread++; continue; }
$part = $parts . '/' . $fileId . '.part';
if (!is_file($part) || filesize($part) !== $meta['s']) { $broken++; continue; }
$orig = mb_substr(trim(basename(str_replace(chr(92), '/', ($attName !== null && $attName !== '' ? $attName : $meta['n']) ?: 'Anhang'))), 0, 255) ?: 'Anhang';
$dup->execute([$threadId, $orig, $meta['s']]);
if ($dup->fetchColumn()) { $r['skipped']++; continue; }
$stored = bin2hex(random_bytes(16)) . '.' . osticket_safe_extension($orig);
if (!copy($part, $uploads . '/' . $stored)) { $broken++; continue; }
$ins->execute([$threadId, $orig, $stored, $meta['s'], db_now()]);
$r['inserted']++;
}
}
foreach (glob($parts . '/*.part') ?: [] as $f) @unlink($f);
if ($broken) $r['notes']['broken'] = "{$broken} Anhang/Anhänge unvollständig oder nicht lesbar und übersprungen.";
if ($tooBig) $r['notes']['big'] = "{$tooBig} Anhang/Anhänge größer als " . (OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB übersprungen.';
if ($noThread) $r['notes']['nothread'] = "{$noThread} Anhang/Anhänge gehören zu Nachrichten, die nicht importiert wurden (Nachrichten zuerst importieren).";
$r['done'] = true;
}
+852
View File
@@ -0,0 +1,852 @@
<?php
declare(strict_types=1);
/**
* Plugin-System.
*
* Ein Plugin ist ein Ordner plugins/<slug>/ mit plugin.json und einer Startdatei (Standard: plugin.php).
* Die Startdatei registriert beim Laden ihre Erweiterungen:
*
* plugin_add_hook('ticket.created', function (array $e) { ... }); // auf Ereignisse reagieren
* plugin_add_filter('ticket.view.sidebar', fn(string $html, array $c) => $html . '...'); // Inhalte ergänzen
* plugin_register_menu('Mein Plugin', 'meine_seite'); // Menüpunkt -> plugins/<slug>/pages/meine_seite.php
*
* SICHERHEIT: Plugins sind PHP-Code und laufen mit allen Rechten des Systems. Es gibt KEINE Sandbox.
* - Berechtigungen (plugin.json "permissions") begrenzen, welche ERWEITERUNGSPUNKTE ein Plugin registrieren darf
* (Ticket-Ereignisse, Cron, Menü, Seitenleiste, API, eigene Tabellen). Der Admin bestätigt sie bei der Aktivierung.
* Sie verhindern NICHT, dass beliebiger PHP-Code im Plugin Dateien oder die Datenbank anfasst.
* - Signaturen (RSA-SHA256, Herausgeber-Schlüssel) belegen die Herkunft eines Pakets; das ist der eigentliche Schutz.
* - Fehler-Isolation und automatische Abschaltung verhindern, dass ein defektes Plugin das System lahmlegt.
*/
const PLUGIN_FAIL_LIMIT = 3; // so viele Fehler seit der letzten Aktivierung, dann wird das Plugin automatisch deaktiviert
const PLUGIN_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
const PLUGIN_ALLOWED_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
/** Bekannte Berechtigungen (Erweiterungspunkte), die ein Plugin in plugin.json anfordern kann. */
function plugin_known_permissions(): array
{
return [
'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)',
'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)',
'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',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
];
}
/** 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];
return $state;
}
function plugins_dir(): string
{
return rtrim((string) (getenv('DESKHUB_PLUGINS_DIR') ?: dirname(__DIR__) . '/plugins'), '/\\');
}
function plugin_valid_slug(string $slug): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
}
/** Notausschalter: Datei plugins/.disabled anlegen oder in config.php 'plugins' => ['enabled' => false]. */
function plugins_globally_disabled(): bool
{
return is_file(plugins_dir() . '/.disabled') || (config()['plugins']['enabled'] ?? true) === false;
}
/** Liest und prüft plugin.json. Gibt [manifest|null, Fehlertext|null] zurück. */
function plugin_read_manifest(string $dir): array
{
$file = $dir . '/plugin.json';
if (!is_file($file)) return [null, 'plugin.json fehlt.'];
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return [null, 'plugin.json ist kein gültiges JSON.'];
foreach (['name', 'version'] as $required) {
if (!isset($data[$required]) || !is_string($data[$required]) || trim($data[$required]) === '') return [null, "plugin.json: Feld \"{$required}\" fehlt."];
}
if (!preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $data['version'])) return [null, 'plugin.json: ungültige Versionsnummer.'];
$entry = (string) ($data['entry'] ?? 'plugin.php');
if (!preg_match('/^[A-Za-z0-9_\-]+\.php$/', $entry)) return [null, 'plugin.json: ungültige Startdatei.'];
if (!is_file($dir . '/' . $entry)) return [null, "Startdatei {$entry} fehlt."];
$requires = (string) ($data['requires'] ?? '');
if ($requires !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $requires, '<')) {
return [null, "Benötigt DeskHub {$requires} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
$permissions = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? []))));
foreach ($permissions as $perm) {
if (!isset(plugin_known_permissions()[$perm])) return [null, "plugin.json: unbekannte Berechtigung \"{$perm}\". Erlaubt: " . implode(', ', array_keys(plugin_known_permissions())) . '.'];
}
sort($permissions);
return [[
'name' => mb_substr(trim($data['name']), 0, 120),
'version' => $data['version'],
'description' => mb_substr((string) ($data['description'] ?? ''), 0, 500),
'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120),
'requires' => $requires,
'entry' => $entry,
'permissions' => $permissions,
], null];
}
// ─── Registrierung (wird von Plugins aufgerufen) ─────────────────────────────
/**
* Darf das gerade ladende Plugin diesen Erweiterungspunkt nutzen? Ohne laufendes Plugin (Kerncode) immer ja.
* Verweigerte Registrierungen werden am Plugin vermerkt (Fehlertext im Adminportal), zählen aber nicht als Absturz.
*/
function plugin_permission_allowed(?string $permission): bool
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || $permission === null) return true;
if (in_array($permission, $state['perms'][$slug] ?? [], true)) return true;
$msg = "Nicht deklarierte Berechtigung \"{$permission}\": Die Registrierung wurde abgelehnt. Bitte in plugin.json ergänzen.";
error_log("plugin[{$slug}]: {$msg}");
try {
get_db()->prepare('UPDATE plugins SET last_error = ? WHERE slug = ?')->execute([$msg, $slug]);
} catch (Throwable $e) {
}
return false;
}
/** Erforderliche Berechtigung für das Abonnieren eines Ereignisses. */
function plugin_hook_permission(string $hook): ?string
{
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'cron.run') 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 (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
return null;
}
/** Reagiert auf ein Ereignis (Rückgabewert wird ignoriert). */
function plugin_add_hook(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_hook_permission($hook))) return;
$state = &plugin_state();
$state['hooks'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/** Verändert einen Wert (Rückgabewert ersetzt den Wert). */
function plugin_add_filter(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_filter_permission($hook))) return;
$state = &plugin_state();
$state['filters'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/** Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php). */
function plugin_register_menu(string $label, string $page, ?string $permission = null): void
{
if (!plugin_permission_allowed('ui.menu')) return;
$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];
}
/**
* 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.
*/
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{
if (!plugin_permission_allowed('api.endpoints')) return;
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $endpoint)) return;
$state['api'][$slug][$endpoint] = ['fn' => $handler, 'permission' => $permission];
}
function plugin_setting(string $slug, string $key, ?string $default = null): ?string
{
return get_setting("plugin.{$slug}.{$key}", $default);
}
function plugin_set_setting(string $slug, string $key, ?string $value): void
{
set_setting("plugin.{$slug}.{$key}", $value);
}
/** Slug des Plugins, dessen Code gerade ausgeführt wird (für Einstellungen u. Ä. innerhalb des Plugins). */
function plugin_current_slug(): ?string
{
return plugin_state()['current'];
}
// ─── Ausführung mit Fehler-Isolation ─────────────────────────────────────────
/** Führt Plugin-Code aus; Fehler werden dem Plugin zugeordnet und legen nie die aufrufende Seite lahm. */
function plugin_guard(?string $slug, callable $fn, $fallback = null)
{
$state = &plugin_state();
$previous = $state['current'];
$state['current'] = $slug;
try {
return $fn();
} catch (Throwable $e) {
if ($slug !== null) plugin_record_failure($slug, $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
else error_log('plugin: ' . $e->getMessage());
return $fallback;
} finally {
$state['current'] = $previous;
}
}
function plugin_record_failure(string $slug, string $message): void
{
error_log("plugin[{$slug}]: {$message}");
try {
$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) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
} catch (Throwable $e) {
// Die Fehlerbehandlung selbst darf nie einen weiteren Fehler auslösen.
}
}
/** Löst ein Ereignis aus. Alle registrierten Plugin-Funktionen laufen isoliert. */
function plugin_hook(string $hook, array $payload = []): void
{
$state = &plugin_state();
if (empty($state['hooks'][$hook])) return;
$byPriority = $state['hooks'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) plugin_guard($entry['plugin'], fn() => ($entry['fn'])($payload));
}
}
/** Reicht einen Wert durch alle Filter. Bei Fehlern bleibt der bisherige Wert erhalten. */
function plugin_filter(string $hook, $value, array $context = [])
{
$state = &plugin_state();
if (empty($state['filters'][$hook])) return $value;
$byPriority = $state['filters'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) $value = plugin_guard($entry['plugin'], fn() => ($entry['fn'])($value, $context), $value);
}
return $value;
}
// ─── Laden ───────────────────────────────────────────────────────────────────
/** Lädt alle aktiven Plugins (einmal pro Request). */
function plugins_boot(): void
{
$state = &plugin_state();
if ($state['booted']) return;
$state['booted'] = true;
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();
} catch (Throwable $e) {
return; // Tabelle fehlt (Datenbank noch nicht aktualisiert)
}
if (!$rows) return;
// Harte Fehler (Fatal Error) lassen sich nicht abfangen: Sie dem Plugin zuordnen und es abschalten.
if (!$state['shutdown']) {
$state['shutdown'] = true;
register_shutdown_function(function () {
$err = error_get_last();
$current = plugin_state()['current'];
if ($current !== null && $err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
try {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute([mb_substr('Schwerer Fehler: ' . $err['message'], 0, 500), $current]);
} catch (Throwable $e) {
}
}
});
}
foreach ($rows as $row) {
$slug = (string) $row['slug'];
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
[$manifest, $error] = plugin_read_manifest($dir);
if (!$manifest) { plugin_record_failure($slug, (string) $error); continue; }
// Nur laden, wenn der Admin alle angeforderten Berechtigungen bestätigt hat (z. B. nach einem Update mit neuen Rechten)
$approved = array_filter(explode(',', (string) ($row['approved_permissions'] ?? '')));
$missing = array_diff($manifest['permissions'], $approved);
if ($missing) {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Neue Berechtigungen müssen bestätigt werden: ' . implode(', ', $missing) . '. Das Plugin wurde deaktiviert.', $slug]);
continue;
}
$state['perms'][$slug] = $manifest['permissions'];
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;
});
}
}
/** Menüpunkte der geladenen Plugins, gefiltert nach Rechten des Mitarbeiters. */
function plugin_menu_items(?array $staff): array
{
$items = [];
foreach (plugin_state()['menus'] as $m) {
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
// ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
function plugins_installed(PDO $pdo): array
{
$rows = [];
try {
foreach ($pdo->query('SELECT * FROM plugins ORDER BY name') as $r) $rows[$r['slug']] = $r;
} catch (Throwable $e) {
return [];
}
return $rows;
}
// ─── Signaturen ──────────────────────────────────────────────────────────────
/**
* Offizielle Vorgaben dieser DeskHub-Version (includes/catalog_defaults.php, Teil des Programmcodes).
* Es gibt KEINE Einstellung, Umgebungsvariable oder Oberfläche, die sie überschreibt. Nur Tests dürfen sie ersetzen, und nur
* wenn die Umgebungsvariable DESKHUB_TESTING=1 gesetzt ist (auf einem Server nie der Fall).
*/
function catalog_defaults(): array
{
static $file = null;
$testing = getenv('DESKHUB_TESTING') === '1';
$override = $testing ? getenv('DESKHUB_CATALOG_DEFAULTS') : false;
$d = ($testing ? ($GLOBALS['deskhub_catalog_defaults'] ?? null) : null)
?? ($override && is_file($override) ? (array) require $override : ($file ??= (array) require __DIR__ . '/catalog_defaults.php'));
return ['plugins_url' => trim((string) ($d['plugins_url'] ?? '')), 'themes_url' => trim((string) ($d['themes_url'] ?? '')), 'publisher_key' => trim((string) ($d['publisher_key'] ?? '')),
'access_key' => trim((string) ($d['access_key'] ?? ''))];
}
/**
* Zusätzliche Header für einen Abruf beim offiziellen Katalog-Server: der feste Zugangsschlüssel. Er wird NUR an diesen Server
* geschickt (gleicher Host wie die offiziellen Adressen, nur https), nie an andere Adressen.
*/
function catalog_access_headers(string $url): array
{
$d = catalog_defaults();
if ($d['access_key'] === '' || !str_starts_with($url, 'https://')) return [];
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
foreach ([$d['plugins_url'], $d['themes_url']] as $official) {
if ($official !== '' && $host !== '' && $host === strtolower((string) parse_url($official, PHP_URL_HOST))) return ['X-DeskHub-Key: ' . $d['access_key']];
}
return [];
}
/** Hat der Herausgeber dieser Version einen offiziellen, geprüften Katalog eingetragen? */
function plugin_official_configured(): bool
{
$d = catalog_defaults();
return $d['plugins_url'] !== '' && $d['publisher_key'] !== '';
}
/**
* "Katalog fest": gilt immer, sobald ein offizieller Katalog eingetragen ist. Katalog-Adresse und Herausgeber-Schlüssel sind dann
* fest vorgegeben, und aus dem Katalog lässt sich nur installieren, was der Herausgeber signiert hat. Der Admin kann das nicht
* ändern. Eigene Plugins (manueller Upload, nicht signiert) bleiben ausdrücklich erlaubt, sie gelten aber als "nicht geprüft".
*/
function plugin_strict_mode(): bool
{
return plugin_official_configured();
}
/** Öffentlicher Herausgeber-Schlüssel (PEM). Im strengen Modus immer der offizielle. Leer = Signaturen können nicht geprüft werden. */
function plugin_publisher_key(): string
{
if (plugin_strict_mode()) return catalog_defaults()['publisher_key'];
$own = trim((string) get_setting('plugin_publisher_key', ''));
return $own !== '' ? $own : catalog_defaults()['publisher_key'];
}
/**
* Muss das Paket signiert sein? Aus dem Katalog: immer, sobald der offizielle Katalog fest eingebaut ist. Eigene Uploads dürfen
* unsigniert sein, es sei denn, ohne offiziellen Katalog hat der Admin "Nur signierte Plugins" eingeschaltet.
*/
function plugin_signatures_required(string $source = 'upload'): bool
{
$setting = get_setting('plugin_require_signature', '0') === '1';
if ($source === 'catalog') return plugin_strict_mode() || $setting;
return !plugin_strict_mode() && $setting;
}
/** Die signierte Nachricht bindet Name, Version und Inhalt (SHA-256) des Pakets aneinander. */
function plugin_signature_message(string $slug, string $version, string $sha256): string
{
return "deskhub-plugin\n{$slug}\n{$version}\n" . strtolower($sha256);
}
/** Prüft eine Base64-Signatur (RSA, SHA-256) mit einem öffentlichen PEM-Schlüssel. */
function plugin_verify_signature(string $message, string $signatureBase64, string $publicKeyPem): bool
{
if (!function_exists('openssl_verify')) return false;
$sig = base64_decode(trim($signatureBase64), true);
$key = @openssl_pkey_get_public($publicKeyPem);
if ($sig === false || $sig === '' || $key === false) return false;
return openssl_verify($message, $sig, $key, OPENSSL_ALGO_SHA256) === 1;
}
/** Ist das ein brauchbarer öffentlicher RSA-Schlüssel (PEM)? */
function plugin_public_key_valid(string $pem): bool
{
$key = @openssl_pkey_get_public($pem);
if ($key === false) return false;
$details = openssl_pkey_get_details($key);
return ($details['type'] ?? null) === OPENSSL_KEYTYPE_RSA && ($details['bits'] ?? 0) >= 2048;
}
// ─── Katalog (Plugin-Webseite) ───────────────────────────────────────────────
function plugin_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['plugins_url'];
$own = trim((string) get_setting('plugin_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['plugins_url'];
}
/**
* 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.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int}
*/
function plugin_catalog_cached(bool $refresh = false): 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']];
}
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung alle 30 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
{
if (!plugin_official_configured()) return;
try {
if (plugin_catalog_url() !== '') plugin_catalog_cached(true);
if (function_exists('theme_catalog_cached') && theme_catalog_url() !== '') theme_catalog_cached(true);
} catch (Throwable $e) {
error_log('catalog_refresh_background: ' . $e->getMessage());
}
}
/**
* Installierte Plugins, für die der (zwischengespeicherte) Katalog eine neuere Version kennt. Fragt nie den Server ab,
* ist also in jeder Seite billig.
*
* @return array<int, array{slug:string, name:string, installed:string, available:string}>
*/
function plugin_updates_available(PDO $pdo): array
{
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (!is_array($cached) || empty($cached['plugins'])) return [];
$installed = plugins_installed($pdo);
$out = [];
foreach ($cached['plugins'] as $c) {
$have = $installed[$c['slug']] ?? null;
if ($have && version_compare($c['version'], $have['version'], '>')) $out[] = ['slug' => $c['slug'], 'name' => $c['name'], 'installed' => $have['version'], 'available' => $c['version']];
}
return $out;
}
/** Vorschau-Adresse aus dem Katalog: nur https und nur auf demselben Server wie der Katalog, sonst leer. */
function catalog_preview_url(string $catalogUrl, string $previewUrl): string
{
if ($previewUrl === '' || !str_starts_with($previewUrl, 'https://') || strlen($previewUrl) > 500) return '';
return strcasecmp((string) parse_url($previewUrl, PHP_URL_HOST), (string) parse_url($catalogUrl, PHP_URL_HOST)) === 0 ? $previewUrl : '';
}
/**
* Vorschaubild eines Katalogeintrags (Plugin oder Theme). DeskHub lässt im Browser nur eigene Bilder zu, deshalb wird das Bild
* hier geladen, geprüft (PNG/JPEG/WebP, höchstens 1 MB) und einen Tag lang zwischengespeichert.
*
* @return array{mime:string, data:string}|null
*/
function catalog_preview_image(string $kind, string $slug): ?array
{
if (!plugin_valid_slug($slug) || !in_array($kind, ['plugin', 'theme'], true)) return null;
// Die Adresse kommt aus dem (zwischengespeicherten) Katalog; der Zwischenspeicher des Bildes hängt an dieser Adresse,
// ein neues Bild wird also sofort erkannt. Fehlschläge werden nur kurz gemerkt (5 Minuten), Erfolge einen Tag.
$catalog = $kind === 'theme' ? theme_catalog_cached() : plugin_catalog_cached();
$list = $catalog['ok'] ? ($catalog[$kind === 'theme' ? 'themes' : 'plugins'] ?? []) : [];
$url = '';
foreach ($list as $e) if ($e['slug'] === $slug) { $url = (string) ($e['preview'] ?? ''); break; }
if ($url === '') return null;
$cacheFile = sys_get_temp_dir() . '/deskhub_preview_' . hash('sha256', $url) . '.bin';
if (is_file($cacheFile)) {
$c = json_decode((string) file_get_contents($cacheFile), true);
$ttl = is_array($c) && !empty($c['data']) ? 86400 : 300;
if (is_array($c) && time() - filemtime($cacheFile) < $ttl) return !empty($c['data']) ? ['mime' => $c['mime'], 'data' => base64_decode($c['data'])] : null;
}
$result = null;
if (plugin_url_check($url) === null) {
[, $body, $error] = plugin_http_get($url, 1048576, 8);
$info = $error === null && $body !== null ? @getimagesizefromstring($body) : false;
$types = [IMAGETYPE_PNG => 'image/png', IMAGETYPE_JPEG => 'image/jpeg', IMAGETYPE_WEBP => 'image/webp'];
if ($info && isset($types[$info[2]])) $result = ['mime' => $types[$info[2]], 'data' => $body];
}
@file_put_contents($cacheFile, json_encode($result ? ['mime' => $result['mime'], 'data' => base64_encode($result['data'])] : ['data' => '']), LOCK_EX);
return $result;
}
/** Prüft eine Katalog-/Download-Adresse: nur https, kein internes Ziel. Gibt einen Fehlertext oder null zurück. */
function plugin_url_check(string $url): ?string
{
$parts = parse_url($url);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return 'Bitte eine https://-Adresse angeben.';
$host = $parts['host'];
// Tests ersetzen den Abruf (deskhub_http_get) und brauchen keine Namensauflösung; IP-Adressen werden immer geprüft
if (!filter_var($host, FILTER_VALIDATE_IP) && isset($GLOBALS['deskhub_http_get'])) return null;
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return 'Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel.';
}
return null;
}
/**
* Lädt eine Adresse (nur https, keine Weiterleitungen, Größenlimit, Zertifikatsprüfung).
* Tests können $GLOBALS['deskhub_http_get'] = fn(string $url, int $maxBytes): array setzen.
*
* @return array{0:int,1:?string,2:?string} [HTTP-Status, Inhalt, Fehlertext]
*/
function plugin_http_get(string $url, int $maxBytes, int $timeout = 10): array
{
$extraHeaders = catalog_access_headers($url);
if (isset($GLOBALS['deskhub_http_get']) && is_callable($GLOBALS['deskhub_http_get'])) return ($GLOBALS['deskhub_http_get'])($url, $maxBytes, $extraHeaders);
if (!function_exists('curl_init')) return [0, null, 'Die PHP-Erweiterung curl fehlt.'];
$body = '';
$tooBig = false;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_USERAGENT => 'DeskHub-Plugins/1', CURLOPT_HTTPHEADER => array_merge(['Accept: application/json, application/zip, */*'], $extraHeaders),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$body, &$tooBig, $maxBytes): int {
$body .= $chunk;
if (strlen($body) > $maxBytes) { $tooBig = true; return 0; }
return strlen($chunk);
},
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($tooBig) return [$status, null, 'Die Antwort ist größer als erlaubt.'];
if ($err !== '') return [$status, null, $err];
return [$status, $body, $status >= 200 && $status < 300 ? null : "HTTP {$status}"];
}
/**
* Lädt den Katalog. Erwartetes JSON: {"plugins":[{slug,name,version,description,author,requires,permissions[],downloadUrl,sha256,signature}]}
*
* @return array{ok:bool, error?:string, plugins?:array}
*/
function plugin_catalog_fetch(): array
{
$url = plugin_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[$status, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['plugins']) || !is_array($data['plugins'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$known = plugin_known_permissions();
$plugins = [];
foreach ($data['plugins'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
$perms = array_values(array_unique(array_map('strval', (array) ($e['permissions'] ?? []))));
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha)) continue;
if (!str_starts_with($dl, 'https://') || array_diff($perms, array_keys($known))) continue;
$plugins[] = [
'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'] ?? '')),
];
}
return ['ok' => true, 'plugins' => $plugins];
}
/** Installiert ein Plugin aus dem Katalog: Download, Prüfsumme, Signatur, Installation. Aktiviert wird nicht. */
function plugin_catalog_install(PDO $pdo, string $slug): array
{
$catalog = plugin_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['plugins'] as $e) if ($e['slug'] === $slug) { $entry = $e; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Plugin steht nicht im Katalog.'];
$have = plugins_installed($pdo)[$slug] ?? null;
if ($have && version_compare($entry['version'], $have['version'], '<=')) return ['ok' => false, 'error' => 'Dieses Plugin ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
if ($entry['requires'] !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $entry['requires'], '<')) {
return ['ok' => false, 'error' => "Benötigt DeskHub {$entry['requires']} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
[$status, $body, $error] = plugin_http_get($entry['downloadUrl'], PLUGIN_MAX_PACKAGE_BYTES, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$tmp = tempnam(sys_get_temp_dir(), 'dhp_');
file_put_contents($tmp, $body);
try {
return plugin_install_zip($pdo, $tmp, $entry['sha256'], $entry['signature'], 'catalog', ['slug' => $entry['slug'], 'version' => $entry['version']]);
} finally {
@unlink($tmp);
}
}
/** Prüft den Dateinamen im Paket. */
function plugin_package_path_ok(string $rel): bool
{
$rel = str_replace(chr(92), '/', $rel);
if ($rel === '' || $rel[0] === '/' || str_contains($rel, "\0") || preg_match('#(^|/)\.\.(/|$)#', $rel) || preg_match('#^[A-Za-z]:#', $rel)) return false;
foreach (explode('/', $rel) as $part) {
if ($part === '' ) continue;
if ($part[0] === '.' ) return false; // keine versteckten Dateien (.htaccess usw.)
}
$ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
return in_array($ext, PLUGIN_ALLOWED_EXTENSIONS, true);
}
/**
* Installiert (oder aktualisiert) ein Plugin aus einer ZIP-Datei. Das Plugin wird NICHT automatisch aktiviert.
*
* Mit Signatur (Base64, RSA-SHA256 über plugin_signature_message) und hinterlegtem Herausgeber-Schlüssel wird die Herkunft
* geprüft; ist "Signatur erforderlich" eingestellt, werden unsignierte Pakete abgelehnt. $expect = ['slug'=>…,'version'=>…]
* lässt das Paket scheitern, wenn sein Inhalt nicht zu dem passt, was der Katalog versprochen hat.
*
* @return array{ok:bool, error?:string, slug?:string, name?:string, version?:string, updated?:bool, hash?:string, signed?:bool, needs_reapproval?:array}
*/
function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 = null, ?string $signature = null, string $source = 'upload', ?array $expect = null): array
{
if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
$size = (int) @filesize($zipPath);
if ($size <= 0 || $size > PLUGIN_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (PLUGIN_MAX_PACKAGE_BYTES / 1048576) . ' MB.'];
$hash = hash_file('sha256', $zipPath);
if ($expectedSha256 !== null && $expectedSha256 !== '' && !hash_equals(strtolower(trim($expectedSha256)), $hash)) {
return ['ok' => false, 'error' => "Die Prüfsumme (SHA-256) stimmt nicht überein. Erhalten: {$hash}"];
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'ZIP-Datei konnte nicht geöffnet werden.'];
// Gemeinsames Wurzelverzeichnis erkennen (plugin.json liegt entweder im Wurzelordner oder in einem Unterordner)
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) $names[] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
$prefix = '';
if (!in_array('plugin.json', $names, true)) {
foreach ($names as $n) {
if (preg_match('#^([^/]+/)plugin\.json$#', $n, $m)) { $prefix = $m[1]; break; }
}
}
if ($prefix === '' && !in_array('plugin.json', $names, true)) { $zip->close(); return ['ok' => false, 'error' => 'plugin.json nicht im Paket gefunden.']; }
$files = [];
$total = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $names[$i];
if (substr($name, -1) === '/') continue;
if ($prefix !== '' && strpos($name, $prefix) !== 0) continue;
$rel = substr($name, strlen($prefix));
if (!plugin_package_path_ok($rel)) { $zip->close(); return ['ok' => false, 'error' => "Unzulässige Datei im Paket: {$rel}"]; }
$stat = $zip->statIndex($i);
$total += (int) ($stat['size'] ?? 0);
if ($total > PLUGIN_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das entpackte Paket ist zu groß.']; }
$files[$i] = $rel;
}
// In ein temporäres Verzeichnis entpacken und dort prüfen, bevor etwas Installiertes verändert wird
$tmp = plugins_dir() . '/.tmp_' . bin2hex(random_bytes(6));
if (!@mkdir($tmp, 0755, true)) { $zip->close(); return ['ok' => false, 'error' => 'Das Plugin-Verzeichnis ist nicht beschreibbar.']; }
try {
foreach ($files as $i => $rel) {
$dest = $tmp . '/' . $rel;
if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);
file_put_contents($dest, (string) $zip->getFromIndex($i));
}
$zip->close();
[$manifest, $error] = plugin_read_manifest($tmp);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$raw = json_decode((string) file_get_contents($tmp . '/plugin.json'), true);
$slug = (string) ($raw['slug'] ?? '');
if (!plugin_valid_slug($slug)) return ['ok' => false, 'error' => 'plugin.json: "slug" fehlt oder ist ungültig (a–z, 0–9, _ und -; 2–41 Zeichen).'];
if (in_array($slug, ['core', 'system', 'admin', 'staff', 'api'], true)) return ['ok' => false, 'error' => 'Dieser Plugin-Name ist reserviert.'];
if ($expect && (($expect['slug'] ?? $slug) !== $slug || ($expect['version'] ?? $manifest['version']) !== $manifest['version'])) {
return ['ok' => false, 'error' => 'Das Paket passt nicht zur Katalog-Angabe (Name oder Version weichen ab).'];
}
// Herkunft prüfen
$signed = false;
$signature = trim((string) $signature);
if ($signature !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message($slug, $manifest['version'], $hash), $signature, plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required($source)) {
return ['ok' => false, 'error' => 'Plugins müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$target = plugins_dir() . '/' . $slug;
$existing = plugins_installed($pdo)[$slug] ?? null;
$backup = null;
if (is_dir($target)) {
$backup = plugins_dir() . '/.old_' . $slug . '_' . bin2hex(random_bytes(3));
if (!@rename($target, $backup)) return ['ok' => false, 'error' => 'Das vorhandene Plugin konnte nicht ersetzt werden.'];
}
if (!@rename($tmp, $target)) {
if ($backup) @rename($backup, $target);
return ['ok' => false, 'error' => 'Das Plugin konnte nicht installiert werden.'];
}
if ($backup) plugin_rmdir($backup);
$now = db_now();
$needsReapproval = [];
if ($existing) {
$pdo->prepare('UPDATE plugins SET name = ?, version = ?, package_hash = ?, updated_at = ?, fail_count = 0, last_error = NULL, signature_ok = ?, source = ? WHERE slug = ?')
->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'] ?? '')))));
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]);
}
} else {
$pdo->prepare('INSERT INTO plugins (slug, name, version, is_active, package_hash, installed_at, updated_at, signature_ok, source) VALUES (?,?,?,0,?,?,?,?,?)')
->execute([$slug, $manifest['name'], $manifest['version'], $hash, $now, $now, $signed ? 1 : 0, $source]);
}
$tmp = null;
return ['ok' => true, 'slug' => $slug, 'name' => $manifest['name'], 'version' => $manifest['version'], 'updated' => (bool) $existing, 'hash' => $hash, 'signed' => $signed, 'needs_reapproval' => $needsReapproval];
} finally {
if ($tmp !== null && is_dir($tmp)) plugin_rmdir($tmp);
}
}
function plugin_rmdir(string $dir): void
{
if (!is_dir($dir)) return;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $f) {
$f->isDir() && !$f->isLink() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
@rmdir($dir);
}
/** Datenbank-Änderungen eines Plugins (plugins/<slug>/migrations/*.sql) einspielen; Protokoll in schema_migrations. */
function plugin_run_migrations(PDO $pdo, string $slug): array
{
$applied = 0;
$errors = [];
$files = glob(plugins_dir() . '/' . $slug . '/migrations/*.sql') ?: [];
natsort($files);
$done = $pdo->query('SELECT migration FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
foreach ($files as $file) {
// Dialekt-spezifische Dateien: *.sqlite.sql / *.mysql.sql laufen nur auf der passenden Datenbank
if (preg_match('/\.(sqlite|mysql)\.sql$/', $file, $dm) && ($dm[1] === 'sqlite') !== ($driver === 'sqlite')) continue;
$key = 'plugin_' . $slug . '_' . basename($file);
if (in_array($key, $done, true)) continue;
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
try {
$pdo->beginTransaction();
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) $pdo->exec($stmt);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]);
$pdo->commit();
$applied++;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errors[] = basename($file) . ': ' . $e->getMessage();
break;
}
}
return ['applied' => $applied, 'errors' => $errors];
}
/**
* Schaltet ein Plugin ein/aus. Beim Aktivieren muss der Admin die angeforderten Berechtigungen bestätigen
* ($approvedPermissions muss alle enthalten); danach werden die Datenbank-Änderungen des Plugins eingespielt.
*/
function plugin_set_active(PDO $pdo, string $slug, bool $active, ?array $approvedPermissions = null): array
{
$row = plugins_installed($pdo)[$slug] ?? null;
if (!$row) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
$declared = [];
if ($active) {
[$manifest, $error] = plugin_read_manifest(plugins_dir() . '/' . $slug);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$declared = $manifest['permissions'];
if ($declared && ($approvedPermissions === null || array_diff($declared, $approvedPermissions))) {
return ['ok' => false, 'error' => 'Bitte die angeforderten Berechtigungen bestätigen.', 'needs_confirmation' => $declared];
}
$hasMigrations = (bool) glob(plugins_dir() . '/' . $slug . '/migrations/*.sql');
if ($hasMigrations && !in_array('db.migrations', $declared, true)) {
return ['ok' => false, 'error' => 'Das Plugin bringt Datenbank-Änderungen mit, fordert aber die Berechtigung "db.migrations" nicht an.'];
}
$mig = plugin_run_migrations($pdo, $slug);
if ($mig['errors']) return ['ok' => false, 'error' => 'Datenbank-Änderung des Plugins fehlgeschlagen: ' . implode('; ', $mig['errors'])];
}
if ($active) {
$pdo->prepare('UPDATE plugins SET is_active = 1, fail_count = 0, last_error = NULL, approved_permissions = ? WHERE slug = ?')->execute([implode(',', $declared), $slug]);
} else {
$pdo->prepare('UPDATE plugins SET is_active = 0, fail_count = 0, last_error = NULL WHERE slug = ?')->execute([$slug]);
}
return ['ok' => true];
}
/** Entfernt ein Plugin. $removeData löst vorher den Hook "plugin.uninstall.<slug>" aus, damit es seine Daten löschen kann. */
function plugin_uninstall(PDO $pdo, string $slug, bool $removeData = false): array
{
if (!plugin_valid_slug($slug) || !isset(plugins_installed($pdo)[$slug])) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
if ($removeData) plugin_hook('plugin.uninstall.' . $slug, ['slug' => $slug]);
$pdo->prepare('DELETE FROM plugins WHERE slug = ?')->execute([$slug]);
$pdo->prepare("DELETE FROM settings WHERE setting_key LIKE ?")->execute(['plugin.' . $slug . '.%']);
plugin_rmdir(plugins_dir() . '/' . $slug);
return ['ok' => true];
}
/** Mini-Vorschau (96x54) für Katalogeinträge: das Bild aus dem Katalog oder ein Platzhalter mit dem Anfangsbuchstaben. */
function catalog_thumb(string $kind, array $c): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
if (!empty($c['preview'])) {
return '<img src="/staff/catalog_preview.php?kind=' . e($kind) . '&amp;slug=' . e($c['slug']) . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
}
+145
View File
@@ -0,0 +1,145 @@
<?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'],
];
}
/**
* 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();
}
// 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();
});
}
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* includes/push.php
*
* Browser Push Notifications via Web Push API (VAPID).
*
* Kein externer Composer-Paket nötig – minimale VAPID-Implementierung
* mit PHP-OpenSSL und JWT.
*
* Einrichtung:
* 1. VAPID-Keys einmalig generieren: php includes/push.php --generate-keys
* 2. Keys in config/config.php eintragen:
* 'push' => ['public_key' => '...', 'private_key' => '...']
* 3. Service Worker erweitert (sw-staff.js) – bereits erledigt
*/
// ── VAPID Key Generator (CLI) ─────────────────────────────────────────────────
if (php_sapi_name() === 'cli' && in_array('--generate-keys', $argv ?? [])) {
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
$details = openssl_pkey_get_details($key);
$pub = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
openssl_pkey_export($key, $privPem);
$priv = base64url_encode($details['ec']['d']);
echo "VAPID Keys generiert – in config/config.php eintragen:\n\n";
echo "'push' => [\n";
echo " 'public_key' => '$pub',\n";
echo " 'private_key' => '$priv',\n";
echo "],\n\n";
echo "Public Key für Frontend (push_subscribe.php):\n$pub\n";
exit(0);
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
/**
* Sendet eine Web-Push-Benachrichtigung an einen Subscriber.
*
* @param array $sub ['endpoint'=>..., 'p256dh'=>..., 'auth'=>...]
* @param string $title Titel der Benachrichtigung
* @param string $body Nachrichtentext
* @param string $url Ziel-URL beim Klick
* @param string $icon Icon-URL (optional)
*/
/**
* Gibt die VAPID-Keys zurück. Existieren sie noch nicht, werden sie
* automatisch generiert und in der settings-Tabelle gespeichert.
* Kein manueller Einrichtungsschritt nötig.
*/
function get_or_create_vapid_keys(): ?array
{
// 1. Config hat explizite Keys → Vorrang
$cfg = config()['push'] ?? [];
if (!empty($cfg['public_key']) && !empty($cfg['private_key'])) {
return $cfg;
}
// 2. Keys aus DB laden
try {
$pub = get_setting('vapid_public_key');
$priv = get_setting('vapid_private_key');
if ($pub && $priv) {
return ['public_key' => $pub, 'private_key' => $priv];
}
// 3. Neu generieren und speichern
if (!function_exists('openssl_pkey_new')) return null;
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$key) return null;
$details = openssl_pkey_get_details($key);
$pubKey = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
$privKey = base64url_encode($details['ec']['d']);
set_setting('vapid_public_key', $pubKey);
set_setting('vapid_private_key', $privKey);
return ['public_key' => $pubKey, 'private_key' => $privKey];
} catch (Throwable $e) {
return null;
}
}
function send_push(array $sub, string $title, string $body, string $url = '/staff/', string $icon = '/assets/icons/icon-192.png'): bool
{
$keys = get_or_create_vapid_keys();
if (!$keys) return false;
$payload = json_encode([
'title' => $title,
'body' => $body,
'url' => $url,
'icon' => $icon,
'badge' => '/assets/icons/icon-192.png',
]);
// Payload mit AES-128-GCM verschlüsseln (RFC 8291)
$encrypted = encrypt_push_payload($payload, $sub['p256dh'], $sub['auth']);
if (!$encrypted) return false;
// VAPID JWT erstellen
$endpoint = $sub['endpoint'];
$parsedUrl = parse_url($endpoint);
$audience = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
$jwt = create_vapid_jwt($audience, $keys['public_key'], $keys['private_key']);
$headers = [
'Authorization: vapid t=' . $jwt . ',k=' . $keys['public_key'],
'Content-Type: application/octet-stream',
'Content-Encoding: aes128gcm',
'TTL: 86400',
'Content-Length: ' . strlen($encrypted['ciphertext']),
];
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['ciphertext'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 410 Gone = Subscription abgelaufen, aus DB löschen
if ($status === 410) {
try {
get_db()->prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')->execute([$endpoint]);
} catch (Throwable $e) {}
}
return $status >= 200 && $status < 300;
}
/**
* Sendet Push an alle Subscriptions eines Mitarbeiters.
*/
function push_to_staff(int $staffId, string $title, string $body, string $url = '/staff/'): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM push_subscriptions WHERE staff_id = ?');
$stmt->execute([$staffId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push an alle aktiven Mitarbeiter einer Abteilung (neues Ticket).
*/
function push_new_ticket(array $ticket): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$ticket['department_id']]);
foreach ($stmt->fetchAll() as $sub) {
send_push(
$sub,
'Neues Ticket: ' . mb_strimwidth($ticket['subject'], 0, 60, '…'),
'Von: ' . $ticket['requester_name'],
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push bei neuer Antwort an den zugewiesenen Mitarbeiter.
*/
function push_new_reply(array $ticket, string $authorName): void
{
if (empty($ticket['assigned_staff_id']) || empty(config()['push']['public_key'])) return;
push_to_staff(
(int)$ticket['assigned_staff_id'],
'Neue Antwort: #' . $ticket['ticket_number'],
'Von: ' . $authorName . ' – ' . mb_strimwidth($ticket['subject'], 0, 50, '…'),
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
/**
* Push bei neuer Live-Chat-Nachricht: an den zugewiesenen Mitarbeiter, oder – solange
* der Chat noch niemandem zugewiesen ist – an alle aktiven Mitarbeiter der Abteilung
* (wie bei einem neuen Ticket), damit ein wartender Chat niemals unbemerkt bleibt.
*/
function push_new_chat_message(int $departmentId, ?int $assignedStaffId, string $ticketNumber, string $requesterName): void
{
if (!get_or_create_vapid_keys()) return;
$title = 'Neue Chat-Nachricht: #' . $ticketNumber;
$body = 'Von: ' . $requesterName;
$url = '/staff/live_chat.php?n=' . urlencode($ticketNumber);
if ($assignedStaffId) {
push_to_staff($assignedStaffId, $title, $body, $url);
return;
}
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$departmentId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
// ── Crypto ────────────────────────────────────────────────────────────────────
function create_vapid_jwt(string $audience, string $publicKey, string $privateKeyB64): string
{
$header = base64url_encode(json_encode(['typ' => 'JWT', 'alg' => 'ES256']));
$payload = base64url_encode(json_encode([
'aud' => $audience,
'exp' => time() + 43200,
'sub' => 'mailto:' . (get_setting('admin_email') ?: 'admin@localhost'),
]));
$signing = $header . '.' . $payload;
// Private Key aus base64url laden
$privDer = base64url_decode($privateKeyB64);
// Reconstruct PEM from raw EC private key bytes
$privPem = ec_private_key_to_pem($privDer, base64url_decode($publicKey));
$key = openssl_pkey_get_private($privPem);
openssl_sign($signing, $sig, $key, OPENSSL_ALGO_SHA256);
// DER-Signatur in r||s umwandeln (P-256 = 32 Bytes je)
$sig = der_to_raw_ec_sig($sig);
return $signing . '.' . base64url_encode($sig);
}
function ec_private_key_to_pem(string $d, string $pubPoint): string
{
// SEC1 EC private key DER für prime256v1
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // prime256v1
$pub = "\x03" . chr(strlen($pubPoint) + 1) . "\x00" . $pubPoint;
$prv = "\x04" . chr(strlen($d)) . $d;
$seq = "\x02\x01\x01" . $prv . "\xa1" . chr(strlen($pub)) . $pub;
$inner = "\x30" . chr(strlen($seq)) . $seq;
$wrap = "\x30\x77\x02\x01\x00" . "\x30" . chr(strlen($oid)) . $oid . "\x04" . chr(strlen($inner)) . $inner;
return "-----BEGIN PRIVATE KEY-----\n" . chunk_split(base64_encode($wrap), 64) . "-----END PRIVATE KEY-----\n";
}
function der_to_raw_ec_sig(string $der): string
{
// Parse DER SEQUENCE { INTEGER r, INTEGER s }
$pos = 2; // skip SEQUENCE tag + length
$rLen = ord($der[$pos + 1]);
$r = substr($der, $pos + 2, $rLen);
$pos += 2 + $rLen;
$sLen = ord($der[$pos + 1]);
$s = substr($der, $pos + 2, $sLen);
// Pad to 32 bytes
return str_pad(ltrim($r, "\x00"), 32, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), 32, "\x00", STR_PAD_LEFT);
}
function encrypt_push_payload(string $payload, string $p256dhB64, string $authB64): ?array
{
// RFC 8291 aes128gcm content encoding
$recipientPubKey = base64url_decode($p256dhB64);
$authSecret = base64url_decode($authB64);
// Generate ephemeral key pair
$eph = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$eph) return null;
$ephDetails = openssl_pkey_get_details($eph);
$ephPublicKey = "\x04" . $ephDetails['ec']['x'] . $ephDetails['ec']['y'];
// Recipient public key as EC key
$recipientKey = ec_public_key_from_uncompressed($recipientPubKey);
if (!$recipientKey) return null;
// ECDH
if (!openssl_dh_compute_key($sharedSecret, $recipientKey, $eph)) return null;
// HKDF to derive content encryption key and nonce (RFC 8291)
$salt = random_bytes(16);
$prk = hkdf_sha256($authSecret, $sharedSecret, "WebPush: info\x00" . $recipientPubKey . $ephPublicKey, 32);
$cek = hkdf_sha256($salt, $prk, "Content-Encoding: aes128gcm\x00", 16);
$nonce = hkdf_sha256($salt, $prk, "Content-Encoding: nonce\x00", 12);
// Encrypt with AES-128-GCM
$tag = '';
$ciphertext = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
if ($ciphertext === false) return null;
// RFC 8291 header: salt(16) + rs(4) + keyid_len(1) + keyid
$keyId = $ephPublicKey;
$rs = pack('N', strlen($payload) + 16 + 2);
$header = $salt . $rs . chr(strlen($keyId)) . $keyId;
return ['ciphertext' => $header . $ciphertext . $tag];
}
function ec_public_key_from_uncompressed(string $point): mixed
{
// Build PEM from uncompressed EC point
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07";
$bitStr = "\x03" . chr(strlen($point) + 1) . "\x00" . $point;
$algId = "\x30" . chr(strlen($oid) + 2) . "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . $oid;
$spki = "\x30" . chr(strlen($algId) + strlen($bitStr)) . $algId . $bitStr;
$pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64) . "-----END PUBLIC KEY-----\n";
return openssl_pkey_get_public($pem);
}
function hkdf_sha256(string $salt, string $ikm, string $info, int $length): string
{
$prk = hash_hmac('sha256', $ikm, $salt, true);
$t = '';
$okm = '';
for ($i = 1; strlen($okm) < $length; $i++) {
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
$okm .= $t;
}
return substr($okm, 0, $length);
}
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* Gespeicherte Ansichten (Warteschlangen) für die Ticketliste.
*
* Gespeichert werden neutrale Filter-Schlüssel, die Web, Desktop-App und Android-App gleichermaßen
* verstehen: q, status, assigned, departmentId, priorityId, sla, tab, quick, spam, tagId.
* Eine Ansicht gehört einem Mitarbeiter; "geteilte" Ansichten sehen alle Mitarbeiter.
*/
const QUEUE_FILTER_KEYS = ['q', 'status', 'assigned', 'departmentId', 'priorityId', 'sla', 'tab', 'quick', 'spam', 'tagId'];
/** Übersetzt die GET-Parameter der Weboberfläche in neutrale Filter (leere Werte entfallen). */
function queue_filters_from_web(array $get): array
{
$map = ['q' => 'q', 'status' => 'status', 'assigned' => 'assigned', 'department_id' => 'departmentId', 'priority_id' => 'priorityId',
'sla' => 'sla', 'tab' => 'tab', 'quick' => 'quick', 'spam' => 'spam', 'tag_id' => 'tagId'];
$out = [];
foreach ($map as $webKey => $key) {
$v = isset($get[$webKey]) && is_scalar($get[$webKey]) ? trim((string) $get[$webKey]) : '';
if ($v !== '') $out[$key] = mb_substr($v, 0, 200);
}
return $out;
}
/** Wandelt neutrale Filter zurück in GET-Parameter der Weboberfläche. */
function queue_filters_to_web(array $filters): array
{
$map = ['q' => 'q', 'status' => 'status', 'assigned' => 'assigned', 'departmentId' => 'department_id', 'priorityId' => 'priority_id',
'sla' => 'sla', 'tab' => 'tab', 'quick' => 'quick', 'spam' => 'spam', 'tagId' => 'tag_id'];
$out = [];
foreach ($map as $key => $webKey) {
if (isset($filters[$key]) && $filters[$key] !== '') $out[$webKey] = (string) $filters[$key];
}
// Apps speichern mehrere Status als Liste; die Weboberfläche kennt dafür die Reiter
if (isset($out['status']) && str_contains($out['status'], ',')) {
$out['tab'] = str_contains($out['status'], 'resolved') ? ($out['tab'] ?? 'all') : ($out['tab'] ?? 'open');
unset($out['status']);
}
return $out;
}
/** Übersetzt neutrale Filter in die Abfrageparameter der Mobile-API (tickets.php): status, assigned, sla, q, departmentId, priorityId. */
function queue_filters_to_api(array $filters): array
{
$out = [];
foreach (['q', 'assigned', 'sla', 'departmentId', 'priorityId'] as $k) {
if (isset($filters[$k]) && $filters[$k] !== '') $out[$k] = $filters[$k];
}
$status = $filters['status'] ?? '';
if ($status === '') {
$status = match ($filters['tab'] ?? '') {
'on_hold' => 'on_hold', 'resolved' => 'resolved', 'closed' => 'closed',
'all' => 'open,pending,answered,on_hold,resolved,closed',
default => 'open,pending,answered',
};
}
$out['status'] = $status;
switch ($filters['quick'] ?? '') {
case 'me': $out['assigned'] = 'me'; break;
case 'unassigned': $out['assigned'] = 'unassigned'; break;
case 'overdue': $out['sla'] = 'overdue'; break;
}
return $out;
}
/** Bereinigt beliebige Eingaben auf die erlaubten Filter-Schlüssel. */
function queue_clean_filters($filters): array
{
$out = [];
if (is_array($filters)) { // ältere Ansichten nutzten 'department'/'priority'
if (isset($filters['department']) && !isset($filters['departmentId'])) $filters['departmentId'] = $filters['department'];
if (isset($filters['priority']) && !isset($filters['priorityId'])) $filters['priorityId'] = $filters['priority'];
}
foreach (QUEUE_FILTER_KEYS as $key) {
if (is_array($filters) && isset($filters[$key]) && is_scalar($filters[$key]) && trim((string) $filters[$key]) !== '') {
$out[$key] = mb_substr(trim((string) $filters[$key]), 0, 200);
}
}
return $out;
}
/** Eigene und geteilte Ansichten, eigene zuerst. Jede Zeile bekommt 'filters' (Array) und 'mine' (bool). */
function queues_for(PDO $pdo, int $staffId): array
{
$stmt = $pdo->prepare(
'SELECT q.*, s.name AS owner_name FROM saved_queues q LEFT JOIN staff s ON s.id = q.staff_id
WHERE q.staff_id = ? OR q.is_shared = 1 ORDER BY (q.staff_id = ?) DESC, q.sort_order, q.name'
);
$stmt->execute([$staffId, $staffId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['filters'] = queue_clean_filters(json_decode((string) $r['filters'], true));
$r['mine'] = (int) $r['staff_id'] === $staffId;
}
return $rows;
}
/** Legt eine Ansicht an. Gibt die ID zurück. */
function queue_create(PDO $pdo, int $staffId, string $name, array $filters, bool $shared): int
{
$name = trim($name);
if ($name === '') throw new AdminModelException('Bitte einen Namen für die Ansicht angeben.', 'NAME_REQUIRED');
if (mb_strlen($name) > 80) throw new AdminModelException('Der Name ist zu lang (höchstens 80 Zeichen).', 'NAME_TOO_LONG');
$dup = $pdo->prepare('SELECT COUNT(*) FROM saved_queues WHERE staff_id = ? AND LOWER(name) = LOWER(?)');
$dup->execute([$staffId, $name]);
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Du hast bereits eine Ansicht mit diesem Namen.', 'NAME_TAKEN');
$max = $pdo->prepare('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM saved_queues WHERE staff_id = ?');
$max->execute([$staffId]);
$now = db_now();
$pdo->prepare('INSERT INTO saved_queues (staff_id, name, filters, is_shared, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$staffId, $name, json_encode(queue_clean_filters($filters), JSON_UNESCAPED_UNICODE), $shared ? 1 : 0, (int) $max->fetchColumn(), $now, $now]);
return (int) $pdo->lastInsertId();
}
/** Darf der Mitarbeiter die Ansicht ändern/löschen? Eigene Ansichten immer; fremde nur Administratoren. */
function queue_can_edit(array $queueRow, array $staff): bool
{
return (int) $queueRow['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin';
}
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* Bericht-Baukasten: frei konfigurierbare Auswertungen über Tickets.
* Alle SQL-Bausteine stammen aus festen Listen (kein Nutzereingabe-SQL), Werte laufen über Parameter.
*/
/** Gruppierungen: key => [Bezeichnung, SQL-Ausdruck-Generator, JOIN, Anzeige-Spalte]. */
function report_dimensions(PDO $pdo): array
{
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$bucket = fn(string $fmtSqlite, string $fmtMysql) => $sqlite ? "strftime('{$fmtSqlite}', t.created_at)" : "DATE_FORMAT(t.created_at, '{$fmtMysql}')";
return [
'department' => ['Abteilung', 'COALESCE(d.name, \'–\')', 'LEFT JOIN departments d ON d.id = t.department_id'],
'help_topic' => ['Hilfethema', 'COALESCE(h.name, \'ohne Thema\')', 'LEFT JOIN help_topics h ON h.id = t.help_topic_id'],
'agent' => ['Mitarbeiter', 'COALESCE(s.name, \'nicht zugewiesen\')', 'LEFT JOIN staff s ON s.id = t.assigned_staff_id'],
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
'status' => ['Status', 't.status', ''],
'source' => ['Quelle', 't.source', ''],
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
'month' => ['Monat', $bucket('%Y-%m', '%Y-%m'), ''],
];
}
/** Kennzahlen: key => [Bezeichnung, SQL-Ausdruck, Einheit]. */
function report_metrics(PDO $pdo): array
{
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$hours = fn(string $from, string $to) => $sqlite
? "(julianday({$to}) - julianday({$from})) * 24"
: "TIMESTAMPDIFF(MINUTE, {$from}, {$to}) / 60";
return [
'total' => ['Tickets gesamt', 'COUNT(*)', ''],
'open' => ['Offen', "SUM(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'resolved' => ['Gelöst/Geschlossen', "SUM(CASE WHEN t.status IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'overdue' => ['Überfällig', "SUM(CASE WHEN t.is_overdue = 1 AND t.status NOT IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'avg_response' => ['Ø Erste Antwort', 'AVG(CASE WHEN t.first_responded_at IS NOT NULL THEN ' . $hours('t.created_at', 't.first_responded_at') . ' END)', 'Std'],
'avg_resolve' => ['Ø Lösungszeit', 'AVG(CASE WHEN t.resolved_at IS NOT NULL THEN ' . $hours('t.created_at', 't.resolved_at') . ' END)', 'Std'],
'sla_response' => ['SLA Erste Antwort', 'AVG(CASE WHEN t.first_responded_at IS NOT NULL AND t.first_response_due_at IS NOT NULL THEN (CASE WHEN t.first_responded_at <= t.first_response_due_at THEN 100.0 ELSE 0.0 END) END)', '%'],
'sla_resolve' => ['SLA Lösung', 'AVG(CASE WHEN t.resolved_at IS NOT NULL AND t.resolution_due_at IS NOT NULL THEN (CASE WHEN t.resolved_at <= t.resolution_due_at THEN 100.0 ELSE 0.0 END) END)', '%'],
];
}
/** Zulässige Filter: key => SQL-Spalte. */
function report_filter_columns(): array
{
return ['departmentId' => 't.department_id', 'priorityId' => 't.priority_id', 'agentId' => 't.assigned_staff_id',
'helpTopicId' => 't.help_topic_id', 'status' => 't.status', 'source' => 't.source'];
}
/** Bereinigt eine Berichtskonfiguration auf erlaubte Werte (mit sinnvollen Standards). */
function report_clean_config(PDO $pdo, array $in): array
{
$dims = report_dimensions($pdo); $metrics = report_metrics($pdo);
$dimension = isset($dims[$in['dimension'] ?? '']) ? $in['dimension'] : 'department';
$chosen = array_values(array_filter((array) ($in['metrics'] ?? []), fn($m) => is_string($m) && isset($metrics[$m])));
$valid = fn($d) => is_string($d) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
$to = $valid($in['to'] ?? null) ? $in['to'] : date('Y-m-d');
$from = $valid($in['from'] ?? null) ? $in['from'] : date('Y-m-d', strtotime('-30 days'));
if ($from > $to) [$from, $to] = [$to, $from];
$filters = [];
foreach (array_keys(report_filter_columns()) as $k) {
if (isset($in['filters'][$k]) && is_scalar($in['filters'][$k]) && trim((string) $in['filters'][$k]) !== '') $filters[$k] = trim((string) $in['filters'][$k]);
}
$sort = $in['sort'] ?? 'label';
$sort = ($sort === 'label' || isset($metrics[$sort])) ? $sort : 'label';
return [
'dimension' => $dimension, 'metrics' => $chosen ?: ['total', 'open', 'resolved'],
'from' => $from, 'to' => $to, 'filters' => $filters, 'sort' => $sort, 'desc' => !empty($in['desc']),
];
}
/**
* Führt einen Bericht aus. $deptScope = erlaubte Abteilungs-IDs des Mitarbeiters (null = alle).
*
* @return array{columns: array<int,array{key:string,label:string,unit:string}>, rows: array<int,array>, totals: array}
*/
function report_run(PDO $pdo, array $config, ?array $deptScope): array
{
$config = report_clean_config($pdo, $config);
$dims = report_dimensions($pdo); $metrics = report_metrics($pdo);
[$dimLabel, $dimExpr, $dimJoin] = $dims[$config['dimension']];
$select = ["{$dimExpr} AS label"];
foreach ($config['metrics'] as $m) $select[] = $metrics[$m][1] . " AS m_{$m}";
$where = ['t.created_at >= ?', 't.created_at < ?'];
$params = [$config['from'] . ' 00:00:00', date('Y-m-d', strtotime($config['to'] . ' +1 day')) . ' 00:00:00'];
foreach ($config['filters'] as $k => $v) {
$where[] = report_filter_columns()[$k] . ' = ?';
$params[] = $v;
}
[$scopeSql, $scopeParams] = dept_filter_sql($deptScope, 't.department_id');
$joins = [$dimJoin];
$sql = 'SELECT ' . implode(', ', $select) . ' FROM tickets t ' . implode(' ', array_filter($joins))
. ' WHERE ' . implode(' AND ', $where) . $scopeSql . ' AND t.is_spam = 0 GROUP BY label';
$stmt = $pdo->prepare($sql);
$stmt->execute([...$params, ...$scopeParams]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
foreach ($config['metrics'] as $m) {
$v = $r["m_{$m}"];
$r[$m] = $v === null ? null : ($metrics[$m][2] === '' ? (int) $v : round((float) $v, 1));
unset($r["m_{$m}"]);
}
}
unset($r);
$key = $config['sort'];
usort($rows, function ($a, $b) use ($key) {
$c = $key === 'label' ? strnatcasecmp((string) $a['label'], (string) $b['label']) : (($a[$key] ?? -1) <=> ($b[$key] ?? -1));
return $c;
});
if ($config['desc']) $rows = array_reverse($rows);
$totals = [];
foreach ($config['metrics'] as $m) {
$vals = array_filter(array_column($rows, $m), fn($v) => $v !== null);
if ($metrics[$m][2] === '') $totals[$m] = array_sum($vals);
else $totals[$m] = $vals ? round(array_sum($vals) / count($vals), 1) : null; // Durchschnitt der Zeilen
}
$columns = [['key' => 'label', 'label' => $dimLabel, 'unit' => '']];
foreach ($config['metrics'] as $m) $columns[] = ['key' => $m, 'label' => $metrics[$m][0], 'unit' => $metrics[$m][2]];
return ['config' => $config, 'columns' => $columns, 'rows' => $rows, 'totals' => $totals];
}
/** Bericht als CSV (Semikolon-getrennt mit UTF-8-BOM für Excel). */
function report_to_csv(array $report): string
{
$out = fopen('php://temp', 'r+');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(fn($c) => $c['label'] . ($c['unit'] ? " ({$c['unit']})" : ''), $report['columns']), ';');
foreach ($report['rows'] as $row) {
$line = [];
foreach ($report['columns'] as $c) {
$v = $row[$c['key']] ?? '';
// Schutz vor Formel-Injektion in Tabellenkalkulationen
if ($c['key'] === 'label' && is_string($v) && preg_match('/^[=+\-@]/', $v)) $v = "'" . $v;
$line[] = is_float($v) ? str_replace('.', ',', (string) $v) : $v;
}
fputcsv($out, $line, ';');
}
$tot = ['Gesamt'];
foreach (array_slice($report['columns'], 1) as $c) $tot[] = $report['totals'][$c['key']] ?? '';
fputcsv($out, $tot, ';');
rewind($out);
return (string) stream_get_contents($out);
}
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* Sicherheitsprüfung für den Betrieb (Adminportal → Sicherheitsprüfung).
*
* Prüft Einstellungen (Schlüssel, HTTPS, Konten ohne 2FA, Dateirechte, Proxy-Konfiguration) und ob interne Dateien
* (Konfiguration, Datenbank, Backups, Quelltext, Tests) vom Internet aus abrufbar sind. Das ist die häufigste Lücke
* bei Installationen ohne Apache-.htaccess (z. B. nginx), deshalb ruft DeskHub diese Adressen selbst ab.
*
* Jede Prüfung liefert ['level' => 'ok'|'warn'|'error'|'info', 'title' => ..., 'detail' => ...].
*/
/** Adressen, die niemals öffentlich abrufbar sein dürfen. */
function security_exposure_paths(): array
{
return [
'/config/config.php' => 'Konfiguration mit Datenbank-Passwort und Schlüssel',
'/database/deskhub.sqlite' => 'Datenbank-Datei',
'/database/schema.sql' => 'Datenbank-Schema',
'/backups/' => 'Backups (enthalten die komplette Datenbank)',
'/includes/functions.php' => 'Programmcode',
'/cron/fetch_emails.log' => 'Cron-Protokolle',
'/tests/run_all.php' => 'Testskripte',
'/version.php' => 'Versionsdatei',
'/.git/config' => 'Git-Verzeichnis',
'/install/.installed' => 'Installations-Sperrdatei',
];
}
/**
* Standard-Abruf über cURL (kein Weiterleiten, kurze Zeitgrenze). Gibt [HTTP-Status, Anfang des Inhalts] zurück
* oder [0, ''] bei Verbindungsfehler.
*/
function security_http_probe(string $url): array
{
if (!function_exists('curl_init')) return [0, ''];
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_RANGE => '0-511', CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_USERAGENT => 'DeskHub-Sicherheitspruefung/1']);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$body === false ? 0 : $status, is_string($body) ? $body : ''];
}
/**
* Prüft per Selbstabruf, ob interne Adressen erreichbar sind.
* $probe: fn(string $url): array{0:int,1:string}; austauschbar für Tests.
*/
function security_check_exposure(string $baseUrl, ?callable $probe = null): array
{
$probe ??= 'security_http_probe';
$baseUrl = rtrim($baseUrl, '/');
$out = [];
// Kontrollabruf: liefert der Server für eine nicht existierende Adresse "200", sagt ein 200 bei den echten Adressen nichts aus
[$controlStatus] = $probe($baseUrl . '/deskhub-pruefung-' . bin2hex(random_bytes(4)) . '.txt');
if ($controlStatus === 0) {
return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Die eigene Adresse (' . $baseUrl . ') ließ sich vom Server aus nicht abrufen. Prüfen Sie "Website-URL" in den Einstellungen oder testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']];
}
if ($controlStatus >= 200 && $controlStatus < 300) {
return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Der Server antwortet auf jede Adresse mit "200". Testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']];
}
$exposed = [];
$unknown = 0;
foreach (security_exposure_paths() as $path => $what) {
[$status] = $probe($baseUrl . $path);
if ($status === 0) { $unknown++; continue; }
if ($status >= 200 && $status < 300) $exposed[] = $path . ' (' . $what . ')';
}
if ($exposed) {
$out[] = ['level' => 'error', 'title' => 'Interne Dateien sind aus dem Internet abrufbar', 'detail' => 'Diese Adressen liefern Inhalt aus, obwohl sie gesperrt sein müssen: ' . implode('; ', $exposed) . '. Auf nginx und anderen Servern ohne .htaccess-Unterstützung fehlen die Sperrregeln (README, Abschnitt Sicherheit → nginx). Bis zur Behebung: Passwörter und Schlüssel als kompromittiert betrachten.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Interne Dateien sind gesperrt', 'detail' => 'Konfiguration, Datenbank, Backups, Programmcode und Tests sind von außen nicht abrufbar.' . ($unknown ? " ({$unknown} Adresse(n) ließen sich nicht abrufen.)" : '')];
}
return $out;
}
/** Prüft, ob hochgeladene PHP-Dateien ausgeführt würden (schreibt eine kurze Testdatei, ruft sie ab, löscht sie wieder). */
function security_check_upload_execution(string $baseUrl, string $uploadsDir, ?callable $probe = null): array
{
$probe ??= 'security_http_probe';
$name = 'pruefung-' . bin2hex(random_bytes(6)) . '.php';
$file = rtrim($uploadsDir, '/' . chr(92)) . '/' . $name;
$marker = bin2hex(random_bytes(8));
if (@file_put_contents($file, '<?php echo "' . $marker . '";') === false) {
return ['level' => 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: Der Ordner uploads/ ist nicht beschreibbar.'];
}
try {
[$status, $body] = $probe(rtrim($baseUrl, '/') . '/uploads/' . $name);
} finally {
@unlink($file);
}
if ($status === 0) return ['level' => 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: die eigene Adresse ließ sich nicht abrufen.'];
if (str_contains($body, $marker) && !str_contains($body, '<?php')) { // Ausgabe ohne Quelltext = der Code wurde ausgeführt (wird nur der Quelltext geliefert, läuft er nicht)
return ['level' => 'error', 'title' => 'Hochgeladene PHP-Dateien würden ausgeführt', 'detail' => 'Im Ordner uploads/ läuft PHP-Code. Wer eine Datei hochladen kann, könnte den Server übernehmen. Sperren Sie die PHP-Ausführung in uploads/ (README, Abschnitt Sicherheit → nginx).'];
}
return ['level' => 'ok', 'title' => 'Hochgeladene Dateien werden nicht ausgeführt', 'detail' => 'PHP-Code im Ordner uploads/ wird nicht ausgeführt.'];
}
/** Einstellungs- und Konfigurationsprüfungen (ohne Netzwerkzugriffe). */
function security_check_settings(PDO $pdo): array
{
$cfg = config();
$out = [];
// Verschlüsselungsschlüssel
$hasKey = !empty($cfg['app_key']) || (string) get_setting('app_key', '') !== '';
$out[] = $hasKey
? ['level' => 'ok', 'title' => 'Verschlüsselungsschlüssel gesetzt', 'detail' => 'Gespeicherte Zugangsdaten (SMTP, IMAP, Webhooks) sind mit einem eigenen Schlüssel verschlüsselt.']
: ['level' => 'error', 'title' => 'Kein Verschlüsselungsschlüssel (app_key)', 'detail' => 'Ohne app_key wird ein schwacher Ersatzschlüssel aus den Datenbank-Zugangsdaten abgeleitet. Eintragen in config/config.php: php -r "echo bin2hex(random_bytes(32));". Danach nie mehr ändern.'];
// HTTPS
$siteUrl = base_url();
if (!str_starts_with($siteUrl, 'https://')) {
$out[] = ['level' => 'error', 'title' => 'Website-URL nutzt kein HTTPS', 'detail' => 'Anmeldedaten und Sitzungen würden unverschlüsselt übertragen. Stellen Sie die Website-URL auf https:// um und richten Sie ein Zertifikat ein.'];
} elseif (!request_is_https()) {
$out[] = ['level' => 'warn', 'title' => 'Diese Seite wurde nicht per HTTPS aufgerufen', 'detail' => 'Die Website-URL ist https, aber DeskHub erkennt die Verbindung nicht als verschlüsselt. Läuft DeskHub hinter einem Reverse-Proxy, tragen Sie dessen Adresse in config/config.php unter site → trusted_proxies ein.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'HTTPS aktiv', 'detail' => 'Die Seite wird verschlüsselt ausgeliefert, Sitzungs-Cookies sind als "secure" markiert.'];
}
// Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre)
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && !request_from_trusted_proxy()) {
$out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse ist nicht als vertrauenswürdig eingetragen. DeskHub sieht dadurch für alle Besucher dieselbe IP: Anmelde-Sperren treffen dann alle gleichzeitig. Eintragen unter site → trusted_proxies in config/config.php (Adresse oder Bereich des Proxys, z. B. \'172.16.0.0/12\').'];
}
// Fehlerausgabe
if (filter_var(ini_get('display_errors'), FILTER_VALIDATE_BOOLEAN)) {
$out[] = ['level' => 'warn', 'title' => 'PHP-Fehler werden im Browser angezeigt', 'detail' => 'display_errors ist aktiv. Fehlermeldungen können Pfade und Zugangsdaten verraten. In php.ini auf "Off" stellen.'];
}
// Installationsassistent
$installDir = dirname(__DIR__) . '/install';
if (is_dir($installDir) && !is_file($installDir . '/.installed')) {
$out[] = ['level' => 'error', 'title' => 'Installationsassistent ist nicht gesperrt', 'detail' => 'Der Ordner install/ existiert ohne Sperrdatei. Löschen Sie den Ordner install/ vom Server.'];
} elseif (is_dir($installDir)) {
$out[] = ['level' => 'info', 'title' => 'Ordner install/ vorhanden', 'detail' => 'Der Assistent ist gesperrt. Zusätzlich empfohlen: Ordner install/ vom Server löschen.'];
}
// Konfigurationsdatei nicht durch den Webserver beschreibbar
$configFile = dirname(__DIR__) . '/config/config.php';
if (is_file($configFile) && is_writable($configFile) && DIRECTORY_SEPARATOR === '/') {
$out[] = ['level' => 'warn', 'title' => 'config/config.php ist für den Webserver beschreibbar', 'detail' => 'Nach der Installation sollte die Datei nur lesbar sein (chmod 640, Besitzer root/Deploy-Benutzer, Gruppe des Webservers).'];
}
// Datenbanktyp
if (($cfg['db']['driver'] ?? 'sqlite') === 'sqlite') {
$out[] = ['level' => 'warn', 'title' => 'Datenbank: SQLite', 'detail' => 'SQLite ist für Tests und kleine Installationen gedacht. Für den Produktivbetrieb mit mehreren Mitarbeitern wird MySQL/MariaDB empfohlen.'];
}
// Konten ohne Zwei-Faktor-Anmeldung
$noTotp = $pdo->query("SELECT name FROM staff WHERE is_active = 1 AND role = 'admin' AND (totp_enabled IS NULL OR totp_enabled = 0)")->fetchAll(PDO::FETCH_COLUMN);
if ($noTotp) {
$out[] = ['level' => 'warn', 'title' => 'Administratoren ohne Zwei-Faktor-Anmeldung', 'detail' => implode(', ', array_map('strval', $noTotp)) . '. Unter Profil → Sicherheit lässt sich 2FA aktivieren.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Alle Administratoren nutzen Zwei-Faktor-Anmeldung', 'detail' => ''];
}
// Backups
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (!is_array($bk)) {
$out[] = ['level' => 'warn', 'title' => 'Noch kein geprüftes Backup', 'detail' => 'Erstellen Sie unter Backup eines und prüfen Sie es.'];
} elseif (empty($bk['ok'])) {
$out[] = ['level' => 'error', 'title' => 'Letztes Backup nicht wiederherstellbar', 'detail' => implode(' ', (array) ($bk['errors'] ?? []))];
} elseif (!empty($bk['at']) && strtotime((string) $bk['at']) < time() - 8 * 86400) {
$out[] = ['level' => 'warn', 'title' => 'Letztes geprüftes Backup ist älter als 8 Tage', 'detail' => 'Stand: ' . $bk['at'] . '. Automatisches Backup einrichten (Backup → Zeitplan).'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Aktuelles Backup geprüft', 'detail' => 'Stand: ' . ($bk['at'] ?? '')];
}
// Plugins
$unsigned = $pdo->query("SELECT name FROM plugins WHERE is_active = 1 AND (signature_ok IS NULL OR signature_ok = 0)")->fetchAll(PDO::FETCH_COLUMN);
if ($unsigned) {
$out[] = ['level' => 'info', 'title' => 'Aktive Plugins ohne Herausgeber-Signatur', 'detail' => implode(', ', array_map('strval', $unsigned)) . ': Eigene Plugins laufen mit allen Rechten des Systems. Nur Code aus vertrauenswürdiger Quelle einsetzen.'];
}
return $out;
}
/** Führt alle Prüfungen aus. $withNetwork = false überspringt die Selbstabrufe (z. B. in Tests). */
function security_check_all(PDO $pdo, bool $withNetwork = true, ?callable $probe = null): array
{
$results = security_check_settings($pdo);
if ($withNetwork) {
$base = base_url();
$results = array_merge(security_check_exposure($base, $probe), [security_check_upload_execution($base, (string) (config()['uploads']['path'] ?? dirname(__DIR__) . '/uploads'), $probe)], $results);
}
$order = ['error' => 0, 'warn' => 1, 'info' => 2, 'ok' => 3];
usort($results, fn($a, $b) => $order[$a['level']] <=> $order[$b['level']]);
return $results;
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* Eigenständiger ZIP-Writer ohne php_zip Erweiterung.
* Implementiert ZIP-Format (PKZIP 2.0) direkt in PHP.
*/
class SimpleZip
{
private string $path;
private $fh;
private array $entries = [];
private int $offset = 0;
public function __construct(string $path)
{
$this->path = $path;
$this->fh = fopen($path, 'wb');
if (!$this->fh) throw new RuntimeException("Kann ZIP nicht erstellen: $path");
}
public function addFromString(string $localName, string $data): void
{
$this->addData($localName, $data, time());
}
public function addFile(string $filePath, string $localName): void
{
$data = file_get_contents($filePath);
$mtime = filemtime($filePath);
$this->addData($localName, $data, $mtime);
}
private function addData(string $localName, string $data, int $mtime): void
{
$localName = str_replace('\\', '/', $localName);
$crc = crc32($data);
$size = strlen($data);
$compressed = gzdeflate($data, 6);
$compSize = strlen($compressed);
// Lokaler Header
$dosTime = $this->dosTime(filemtime($filePath));
$nameLen = strlen($localName);
$localHeader = pack('VvvvvVVVvv',
0x04034b50, // Signatur
20, // Version needed
0, // Flags
8, // Deflate
$dosTime, // Mod time
$crc,
$compSize,
$size,
$nameLen,
0 // Extra length
) . $localName;
fwrite($this->fh, $localHeader);
fwrite($this->fh, $compressed);
$this->entries[] = [
'name' => $localName,
'crc' => $crc,
'size' => $size,
'compSize' => $compSize,
'offset' => $this->offset,
'dosTime' => $dosTime,
];
$this->offset += strlen($localHeader) + $compSize;
}
public function close(): void
{
$cdOffset = $this->offset;
$cdSize = 0;
foreach ($this->entries as $e) {
$nameLen = strlen($e['name']);
$cd = pack('VvvvvVVVvvvvvVV',
0x02014b50, // CD Signatur
20, // Version made by
20, // Version needed
0, // Flags
8, // Deflate
$e['dosTime'],
$e['crc'],
$e['compSize'],
$e['size'],
$nameLen,
0, // Extra length
0, // Comment length
0, // Disk start
0, // Internal attr
0, // External attr
$e['offset']
) . $e['name'];
fwrite($this->fh, $cd);
$cdSize += strlen($cd);
}
// End of central directory
$count = count($this->entries);
fwrite($this->fh, pack('VvvvvVVv',
0x06054b50, // EOCD Signatur
0, // Disk number
0, // Disk with CD
$count, // Entries on disk
$count, // Total entries
$cdSize,
$cdOffset,
0 // Comment length
));
fclose($this->fh);
}
private function dosTime(int $unixTime): int
{
$d = getdate($unixTime);
return (($d['year'] - 1980) << 25)
| ($d['mon'] << 21)
| ($d['mday'] << 16)
| ($d['hours'] << 11)
| ($d['minutes'] << 5)
| ($d['seconds'] >> 1);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
/**
* Einfacher SMTP-Client ohne externe Abhängigkeiten (kein Composer/PHPMailer).
*
* Grund: PHP mail() ist auf vielen Hosting-Paketen unzuverlässig (hängt vom
* lokal konfigurierten Sendmail/Postfix ab, landet oft im Spam-Ordner ohne
* korrekte SPF/DKIM-Anbindung des Hosters). Mit eigenen SMTP-Zugangsdaten
* (z.B. vom E-Mail-Provider der Domain) wird stattdessen direkt und
* zuverlässig über deren Mailserver versendet.
*
* Unterstützt: SMTP mit STARTTLS (Port 587), implizites SSL (Port 465),
* unverschlüsselt (Port 25, nicht empfohlen) sowie AUTH LOGIN.
*/
class SmtpMailer
{
private string $host;
private int $port;
private string $encryption; // 'none' | 'tls' | 'ssl'
private string $username;
private string $password;
private int $timeout;
private $socket = null;
private string $lastError = '';
public function __construct(string $host, int $port, string $encryption, string $username, string $password, int $timeout = 12)
{
$this->host = $host;
$this->port = $port;
$this->encryption = $encryption;
$this->username = $username;
$this->password = $password;
$this->timeout = $timeout;
}
public function getLastError(): string
{
return $this->lastError;
}
/**
* Sendet eine E-Mail. $to/$from können "Name <adresse>" oder nur die
* Adresse sein. $htmlBody wird als text/html (UTF-8, Base64) versendet.
*/
public function send(string $to, string $from, string $subject, string $htmlBody): bool
{
try {
$this->connect();
$this->ehlo();
if ($this->encryption === 'tls') {
$this->startTls();
$this->ehlo(); // EHLO muss nach STARTTLS wiederholt werden
}
if ($this->username !== '') {
$this->authLogin();
}
$fromEmail = $this->extractEmail($from);
$toEmail = $this->extractEmail($to);
$this->command("MAIL FROM:<{$fromEmail}>", [250]);
$this->command("RCPT TO:<{$toEmail}>", [250, 251]);
$this->command('DATA', [354]);
$headers = $this->buildHeaders($to, $from, $subject);
$encodedBody = chunk_split(base64_encode($htmlBody), 76, "\r\n");
$payload = $headers . "\r\n" . $encodedBody . "\r\n.";
$this->write($payload);
$this->readResponse([250]);
$this->command('QUIT', [221], false);
$this->close();
return true;
} catch (\Throwable $e) {
$this->lastError = $e->getMessage();
$this->close();
return false;
}
}
private function connect(): void
{
$prefix = $this->encryption === 'ssl' ? 'ssl://' : 'tcp://';
$this->socket = @stream_socket_client(
"{$prefix}{$this->host}:{$this->port}",
$errno, $errstr, $this->timeout,
STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
);
if (!$this->socket) {
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})");
}
stream_set_timeout($this->socket, $this->timeout);
$this->readResponse([220]);
}
private function ehlo(): void
{
$domain = $_SERVER['SERVER_NAME'] ?? 'localhost';
$this->command("EHLO {$domain}", [250]);
}
private function startTls(): void
{
$this->command('STARTTLS', [220]);
$ok = @stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$ok) {
throw new \RuntimeException('STARTTLS-Verschlüsselung konnte nicht aktiviert werden.');
}
}
private function authLogin(): void
{
$this->command('AUTH LOGIN', [334]);
$this->command(base64_encode($this->username), [334]);
$this->command(base64_encode($this->password), [235]);
}
private function buildHeaders(string $to, string $from, string $subject): string
{
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$headers = [];
$headers[] = "Date: " . date('r');
$headers[] = "To: {$to}";
$headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}";
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/html; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n";
}
/** Extrahiert nur die reine Adresse aus "Name <adresse@example.com>" für die SMTP-Hüllkommandos */
private function extractEmail(string $addr): string
{
if (preg_match('/<([^>]+)>/', $addr, $m)) {
return trim($m[1]);
}
return trim($addr);
}
private function command(string $cmd, array $expectedCodes, bool $expectResponse = true): void
{
$this->write($cmd);
if ($expectResponse) {
$this->readResponse($expectedCodes);
}
}
private function write(string $data): void
{
if (@fwrite($this->socket, $data . "\r\n") === false) {
throw new \RuntimeException('Schreiben zum SMTP-Server fehlgeschlagen.');
}
}
private function readResponse(array $expectedCodes): string
{
$response = '';
$code = null;
while (!feof($this->socket)) {
$line = fgets($this->socket, 515);
if ($line === false) break;
$response .= $line;
// Letzte Zeile einer mehrzeiligen Antwort hat ein Leerzeichen statt "-" nach dem Code
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
$code = (int) $m[1];
if ($m[2] === ' ') break;
} else {
break;
}
}
if ($code === null || !in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unerwartete SMTP-Antwort: " . trim($response));
}
return $response;
}
private function close(): void
{
if ($this->socket) {
@fclose($this->socket);
$this->socket = null;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* Themes aus dem Katalog (Plugin-/Theme-Webseite) installieren. Nutzt dieselben Sicherheitsbausteine wie die Plugins:
* https-only, Prüfsumme, optionale Signatur mit dem hinterlegten Herausgeber-Schlüssel.
*
* Katalogformat: {"themes":[{"slug","name","version","description","author","downloadUrl","sha256","signature"}]}
* Signiert wird plugin_signature_message('theme-<slug>', <version>, <sha256>).
*/
function theme_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['themes_url'];
$own = trim((string) get_setting('theme_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['themes_url'];
}
/** @return array{ok:bool, error?:string, themes?:array} */
function theme_catalog_fetch(): array
{
$url = theme_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse für Themes hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['themes']) || !is_array($data['themes'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$themes = [];
foreach ($data['themes'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha) || !str_starts_with($dl, 'https://')) continue;
$themes[] = [
'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),
'downloadUrl' => $dl, 'sha256' => $sha, 'signature' => trim((string) ($e['signature'] ?? '')),
'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
];
}
return ['ok' => true, 'themes' => $themes];
}
/**
* Theme-Katalog mit Zwischenspeicher (1 Stunde), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen Abruf.
*
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int}
*/
function theme_catalog_cached(bool $refresh = false): 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']];
}
}
$res = theme_catalog_fetch();
if ($res['ok']) {
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Installierte Version eines Katalog-Themes (Vergleich über den Anzeigenamen) oder null, wenn es nicht installiert ist.
* Hat das installierte Theme keine Versionsangabe, gilt es als aktuell ("0"), damit es nicht endlos zum Update angeboten wird.
*/
function theme_installed_version(string $catalogName): ?string
{
$wanted = normalize_custom_theme_name($catalogName);
foreach (list_custom_themes() as $t) {
if (normalize_custom_theme_name((string) $t['name']) === $wanted) return (string) ($t['theme_version'] ?? '') !== '' ? (string) $t['theme_version'] : '0';
}
return null;
}
/**
* Vorschau für ein installiertes Theme: bevorzugt das Bild aus dem Katalog (gleicher Name), sonst eine preview.png/.jpg/.webp
* im Theme-Ordner (wer ein eigenes Theme hochlädt, kann so selbst eine Vorschau mitgeben). Ohne beides null.
*
* @return array{kind:string, slug?:string, url?:string}|null
*/
function theme_installed_preview(array $theme, ?array $catalogThemes = null): ?array
{
$wanted = normalize_custom_theme_name((string) $theme['name']);
foreach ($catalogThemes ?? [] as $c) {
if (!empty($c['preview']) && normalize_custom_theme_name((string) $c['name']) === $wanted) return ['kind' => 'catalog', 'slug' => (string) $c['slug']];
}
foreach (['png', 'jpg', 'webp'] as $ext) {
if (is_file(custom_themes_directory() . '/' . $theme['id'] . '/preview.' . $ext)) return ['kind' => 'file', 'url' => '/assets/themes/' . rawurlencode((string) $theme['id']) . '/preview.' . $ext];
}
return null;
}
/** Mini-Vorschau (96x54) eines installierten Themes oder Platzhalter mit Anfangsbuchstaben. */
function installed_theme_thumb(array $theme, ?array $catalogThemes = null): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
$p = theme_installed_preview($theme, $catalogThemes);
if ($p) {
$src = $p['kind'] === 'catalog' ? '/staff/catalog_preview.php?kind=theme&amp;slug=' . e($p['slug']) : e($p['url']);
return '<img src="' . $src . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $theme['name'], 0, 1))) . '</div>';
}
/** Ist das Katalog-Theme schon installiert, und braucht es ein Update? ['installed' => bool, 'update' => bool, 'version' => ?string] */
function theme_catalog_state(array $entry): array
{
$have = theme_installed_version($entry['name']);
if ($have === null) return ['installed' => false, 'update' => false, 'version' => null];
return ['installed' => true, 'update' => $have !== '0' && version_compare($entry['version'], $have, '>'), 'version' => $have];
}
/** Installiert ein Theme aus dem Katalog (aktiviert es nicht). */
function theme_catalog_install(string $slug): array
{
$catalog = theme_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['themes'] as $t) if ($t['slug'] === $slug) { $entry = $t; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Theme steht nicht im Katalog.'];
$state = theme_catalog_state($entry);
if ($state['installed'] && !$state['update']) return ['ok' => false, 'error' => 'Dieses Theme ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
[, $body, $error] = plugin_http_get($entry['downloadUrl'], CUSTOM_THEME_MAX_ARCHIVE_SIZE, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$sha = hash('sha256', $body);
if (!hash_equals($entry['sha256'], $sha)) return ['ok' => false, 'error' => 'Die Prüfsumme (SHA-256) des Downloads stimmt nicht mit dem Katalog überein.'];
$signed = false;
if ($entry['signature'] !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message('theme-' . $slug, $entry['version'], $sha), $entry['signature'], plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required('catalog')) {
return ['ok' => false, 'error' => 'Themes müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$tmp = tempnam(sys_get_temp_dir(), 'dht_');
file_put_contents($tmp, $body);
try {
$theme = install_custom_theme_archive($tmp, normalize_custom_theme_name($entry['name']));
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
} finally {
@unlink($tmp);
}
return ['ok' => true, 'theme' => $theme, 'signed' => $signed, 'hash' => $sha];
}
+412
View File
@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
const CUSTOM_THEME_MAX_ARCHIVE_SIZE = 10 * 1024 * 1024;
const CUSTOM_THEME_MAX_EXTRACTED_SIZE = 20 * 1024 * 1024;
const CUSTOM_THEME_MAX_FILES = 100;
function custom_themes_directory(): string
{
return rtrim((string) (getenv('DESKHUB_THEMES_DIR') ?: dirname(__DIR__) . '/assets/themes'), '/' . chr(92));
}
/**
* @return array<int, array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string}>
*/
function list_custom_themes(?string $themesDirectory = null): array
{
$themesDirectory ??= custom_themes_directory();
if (!is_dir($themesDirectory)) {
return [];
}
$themes = [];
foreach (scandir($themesDirectory) ?: [] as $id) {
if (!preg_match('/^[a-f0-9]{24}$/', $id)) {
continue;
}
$directory = $themesDirectory . DIRECTORY_SEPARATOR . $id;
$metadataPath = $directory . DIRECTORY_SEPARATOR . 'theme.json';
$stylesheetPath = $directory . DIRECTORY_SEPARATOR . 'theme.css';
if (!is_dir($directory) || !is_file($metadataPath) || !is_file($stylesheetPath)) {
continue;
}
$metadata = json_decode((string) file_get_contents($metadataPath), true);
if (!is_array($metadata) || !isset($metadata['name'], $metadata['installed_at'], $metadata['version'])) {
continue;
}
$themes[] = [
'id' => $id,
'name' => (string) $metadata['name'],
'author' => (string) ($metadata['author'] ?? 'DeskHub'),
'installed_at' => (string) $metadata['installed_at'],
'version' => (string) $metadata['version'],
'theme_version' => (string) ($metadata['theme_version'] ?? '1.0.0'),
];
}
usort($themes, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $themes;
}
function active_custom_theme_stylesheet(): ?string
{
$activeId = get_setting('active_custom_theme', '');
if (!$activeId || !preg_match('/^[a-f0-9]{24}$/', $activeId)) {
return null;
}
foreach (list_custom_themes() as $theme) {
if ($theme['id'] === $activeId) {
return '/assets/themes/' . rawurlencode($theme['id']) . '/theme.css?v=' . rawurlencode($theme['version']);
}
}
return null;
}
function normalize_custom_theme_name(string $name): string
{
return trim((string) preg_replace(
'/\s+/',
' ',
str_replace(['_', '-'], ' ', $name)
));
}
/**
* @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool}
*/
function install_custom_theme_upload(array $file, ?string $themesDirectory = null): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.');
}
if (empty($file['name'])) {
throw new RuntimeException('Bitte eine ZIP-Datei auswählen.');
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Der Theme-Upload ist fehlgeschlagen.');
}
if (($file['size'] ?? 0) > CUSTOM_THEME_MAX_ARCHIVE_SIZE) {
throw new RuntimeException('Das Theme darf maximal 10 MB groß sein.');
}
if (strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION)) !== 'zip') {
throw new RuntimeException('Bitte ein Theme als ZIP-Datei hochladen.');
}
$mimeType = null;
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
$mimeType = finfo_file($finfo, (string) $file['tmp_name']) ?: null;
finfo_close($finfo);
}
}
if ($mimeType !== null && !in_array($mimeType, [
'application/zip',
'application/x-zip',
'application/x-zip-compressed',
'application/octet-stream',
], true)) {
throw new RuntimeException('Die hochgeladene Datei ist kein gültiges ZIP-Archiv.');
}
$name = normalize_custom_theme_name(pathinfo((string) $file['name'], PATHINFO_FILENAME));
if ($name === '') {
throw new RuntimeException('Der Theme-Dateiname ist ungültig.');
}
return install_custom_theme_archive((string) $file['tmp_name'], $name, $themesDirectory);
}
/**
* @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool}
*/
function install_custom_theme_archive(
string $archivePath,
string $displayName,
?string $themesDirectory = null
): array {
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.');
}
$displayName = normalize_custom_theme_name($displayName);
if ($displayName === '') {
throw new RuntimeException('Bitte einen Namen für das Theme angeben.');
}
if (mb_strlen($displayName) > 80) {
throw new RuntimeException('Der Theme-Name darf maximal 80 Zeichen lang sein.');
}
$themesDirectory ??= custom_themes_directory();
if (!is_dir($themesDirectory) && !mkdir($themesDirectory, 0755, true) && !is_dir($themesDirectory)) {
throw new RuntimeException('Das Theme-Verzeichnis konnte nicht erstellt werden.');
}
$zip = new ZipArchive();
if ($zip->open($archivePath) !== true) {
throw new RuntimeException('Das ZIP-Archiv konnte nicht geöffnet werden.');
}
$matchingThemes = array_values(array_filter(
list_custom_themes($themesDirectory),
static fn(array $theme): bool => strcasecmp(
normalize_custom_theme_name($theme['name']),
$displayName
) === 0
));
$activeThemeId = function_exists('get_setting')
? (string) get_setting('active_custom_theme', '')
: '';
$existingTheme = null;
foreach ($matchingThemes as $matchingTheme) {
if ($matchingTheme['id'] === $activeThemeId) {
$existingTheme = $matchingTheme;
break;
}
}
$existingTheme ??= $matchingThemes[0] ?? null;
$replaced = $existingTheme !== null;
$themeId = $existingTheme['id'] ?? bin2hex(random_bytes(12));
$operationId = bin2hex(random_bytes(12));
$stagingDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.tmp-' . $operationId;
$finalDirectory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId;
$backupDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.backup-' . $operationId;
$allowedExtensions = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
$fileCount = 0;
$totalSize = 0;
$hasRootStylesheet = false;
try {
if (!mkdir($stagingDirectory, 0755, true)) {
throw new RuntimeException('Das Theme konnte nicht vorbereitet werden.');
}
for ($index = 0; $index < $zip->numFiles; $index++) {
$entry = $zip->statIndex($index);
if ($entry === false) {
throw new RuntimeException('Das ZIP-Archiv enthält einen ungültigen Eintrag.');
}
$entryName = str_replace('\\', '/', (string) $entry['name']);
if (!is_safe_custom_theme_path($entryName)) {
throw new RuntimeException('Das ZIP-Archiv enthält einen unsicheren Dateipfad.');
}
if (str_ends_with($entryName, '/')) {
continue;
}
$fileCount++;
$declaredSize = (int) ($entry['size'] ?? 0);
$totalSize += $declaredSize;
if ($fileCount > CUSTOM_THEME_MAX_FILES || $totalSize > CUSTOM_THEME_MAX_EXTRACTED_SIZE) {
throw new RuntimeException('Das Theme enthält zu viele oder zu große Dateien.');
}
$extension = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
throw new RuntimeException("Dateityp nicht erlaubt: {$entryName}");
}
if ($entryName === 'theme.css') {
$hasRootStylesheet = true;
}
$destination = $stagingDirectory . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $entryName);
$destinationDirectory = dirname($destination);
if (!is_dir($destinationDirectory)
&& !mkdir($destinationDirectory, 0755, true)
&& !is_dir($destinationDirectory)) {
throw new RuntimeException('Ein Theme-Unterverzeichnis konnte nicht erstellt werden.');
}
$input = $zip->getStream((string) $entry['name']);
$output = fopen($destination, 'xb');
if ($input === false || $output === false) {
if (is_resource($input)) {
fclose($input);
}
if (is_resource($output)) {
fclose($output);
}
throw new RuntimeException("Theme-Datei konnte nicht entpackt werden: {$entryName}");
}
$copied = stream_copy_to_stream($input, $output, CUSTOM_THEME_MAX_EXTRACTED_SIZE + 1);
fclose($input);
fclose($output);
if ($copied === false || $copied !== $declaredSize) {
throw new RuntimeException("Theme-Datei ist beschädigt: {$entryName}");
}
}
if (!$hasRootStylesheet) {
throw new RuntimeException('Im Stammverzeichnis des ZIP-Archivs fehlt die Datei theme.css.');
}
$stylesheetMetadata = read_custom_theme_stylesheet_metadata(
$stagingDirectory . DIRECTORY_SEPARATOR . 'theme.css'
);
$metadata = [
'name' => $displayName,
'author' => $stylesheetMetadata['author'],
'theme_version' => $stylesheetMetadata['version'],
'installed_at' => date('Y-m-d H:i:s'),
'version' => time() . '-' . bin2hex(random_bytes(4)),
];
$metadataJson = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (file_put_contents($stagingDirectory . DIRECTORY_SEPARATOR . 'theme.json', $metadataJson) === false) {
throw new RuntimeException('Die Theme-Metadaten konnten nicht gespeichert werden.');
}
if ($replaced) {
if (!rename($finalDirectory, $backupDirectory)) {
throw new RuntimeException('Das vorhandene Theme konnte nicht für die Aktualisierung vorbereitet werden.');
}
if (!rename($stagingDirectory, $finalDirectory)) {
if (!rename($backupDirectory, $finalDirectory)) {
throw new RuntimeException(
'Das Theme konnte nicht aktualisiert und die vorherige Version nicht wiederhergestellt werden.'
);
}
throw new RuntimeException('Das Theme konnte nicht aktualisiert werden.');
}
remove_custom_theme_directory($backupDirectory);
} elseif (!rename($stagingDirectory, $finalDirectory)) {
throw new RuntimeException('Das Theme konnte nicht installiert werden.');
}
foreach ($matchingThemes as $matchingTheme) {
if ($matchingTheme['id'] !== $themeId
&& !delete_custom_theme($matchingTheme['id'], $themesDirectory)) {
throw new RuntimeException(
'Das Theme wurde aktualisiert, aber ein vorhandenes Duplikat konnte nicht entfernt werden.'
);
}
}
return ['id' => $themeId] + $metadata + ['replaced' => $replaced];
} finally {
$zip->close();
if (is_dir($stagingDirectory)) {
remove_custom_theme_directory($stagingDirectory);
}
}
}
/**
* @return array{author: string, version: string}
*/
function read_custom_theme_stylesheet_metadata(string $stylesheetPath): array
{
$header = (string) file_get_contents($stylesheetPath, false, null, 0, 4096);
$author = preg_match('/^\s*\*\s*Author:\s*(.+?)\s*$/mi', $header, $match)
? trim($match[1])
: 'Nicht angegeben';
$version = preg_match('/^\s*\*\s*Version:\s*([0-9]+(?:\.[0-9]+){0,2})\s*$/mi', $header, $match)
? $match[1]
: '1.0.0';
if (mb_strlen($author) > 80) {
throw new RuntimeException('Der Theme-Autor darf maximal 80 Zeichen lang sein.');
}
return [
'author' => $author !== '' ? $author : 'Nicht angegeben',
'version' => $version,
];
}
function delete_custom_theme(string $themeId, ?string $themesDirectory = null): bool
{
if (!preg_match('/^[a-f0-9]{24}$/', $themeId)) {
return false;
}
$themesDirectory ??= custom_themes_directory();
$directory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId;
if (!is_dir($directory)) {
return false;
}
$trashDirectory = $themesDirectory . DIRECTORY_SEPARATOR
. '.delete-' . $themeId . '-' . bin2hex(random_bytes(4));
for ($attempt = 0; $attempt < 4; $attempt++) {
if (@rename($directory, $trashDirectory)) {
return remove_custom_theme_directory($trashDirectory);
}
if ($attempt < 3) {
usleep(250000);
}
}
return remove_custom_theme_directory($directory);
}
function is_safe_custom_theme_path(string $path): bool
{
if ($path === '' || str_contains($path, "\0") || str_starts_with($path, '/') || str_contains($path, ':')) {
return false;
}
foreach (explode('/', rtrim($path, '/')) as $part) {
if ($part === '' || $part === '.' || $part === '..' || str_starts_with($part, '.')) {
return false;
}
}
return true;
}
function remove_custom_theme_directory(string $directory): bool
{
if (!is_dir($directory)) {
return true;
}
foreach (scandir($directory) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory . DIRECTORY_SEPARATOR . $entry;
if (is_dir($path) && !is_link($path)) {
if (!remove_custom_theme_directory($path)) {
return false;
}
} else {
$deleted = false;
for ($attempt = 0; $attempt < 4; $attempt++) {
@chmod($path, 0666);
if (@unlink($path) || !file_exists($path)) {
$deleted = true;
break;
}
clearstatcache(true, $path);
if ($attempt < 3) {
usleep(250000);
}
}
if (!$deleted) {
return false;
}
}
}
for ($attempt = 0; $attempt < 4; $attempt++) {
if (@rmdir($directory) || !is_dir($directory)) {
return true;
}
clearstatcache(true, $directory);
if ($attempt < 3) {
usleep(250000);
}
}
return false;
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* includes/time_tracking.php
*
* Automatische Zeiterfassung pro Ticket: Sobald ein Mitarbeiter ein Ticket
* öffnet, startet eine "Arbeits-Sitzung" (ticket_work_sessions). Beendet er
* die Bearbeitung (Antwort/Notiz senden, anderes Ticket öffnen, Logout) oder
* bleibt länger als WORK_SESSION_STALE_MINUTES untätig, wird die Sitzung
* automatisch als Eintrag in ticket_time_entries protokolliert – ganz ohne
* dass jemand manuell die Minuten eintragen muss.
*
* Kann in Einstellungen über das Setting "auto_time_tracking_enabled"
* komplett abgeschaltet werden (Standard: aktiv).
*/
const WORK_SESSION_STALE_MINUTES = 10;
/** Startet eine neue Arbeits-Sitzung, oder frischt eine bestehende auf (Startzeit bleibt erhalten). */
function start_or_touch_work_session(PDO $pdo, int $ticketId, int $staffId): void
{
if (get_setting('auto_time_tracking_enabled', '1') !== '1') return;
$now = db_now();
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$pdo->prepare(
'INSERT INTO ticket_work_sessions (ticket_id, staff_id, started_at, last_seen_at) VALUES (?,?,?,?)
ON CONFLICT(ticket_id, staff_id) DO UPDATE SET last_seen_at = excluded.last_seen_at'
)->execute([$ticketId, $staffId, $now, $now]);
} else {
$pdo->prepare(
'INSERT INTO ticket_work_sessions (ticket_id, staff_id, started_at, last_seen_at) VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at)'
)->execute([$ticketId, $staffId, $now, $now]);
}
}
/**
* Schließt eine offene Arbeits-Sitzung ab und protokolliert die verstrichene Zeit
* (auf ganze Minuten gerundet, mindestens 1). Sitzungen unter 1 Minute werden
* verworfen statt als "0 Min."-Rauschen gespeichert. $endAt erlaubt, statt "jetzt"
* den letzten bekannten Aktivitätszeitpunkt zu verwenden (Stale-Sweep).
*/
function finalize_work_session(PDO $pdo, int $ticketId, int $staffId, ?string $endAt = null): void
{
$stmt = $pdo->prepare('SELECT * FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?');
$stmt->execute([$ticketId, $staffId]);
$session = $stmt->fetch();
if (!$session) return;
$pdo->prepare('DELETE FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?')->execute([$ticketId, $staffId]);
$end = $endAt ?? db_now();
$minutes = (int) round((strtotime($end) - strtotime($session['started_at'])) / 60);
if ($minutes < 1) return;
$pdo->prepare(
'INSERT INTO ticket_time_entries (ticket_id, staff_id, minutes, note, logged_at) VALUES (?,?,?,?,?)'
)->execute([$ticketId, $staffId, $minutes, 'Automatisch erfasst', db_now()]);
log_activity($pdo, $ticketId, $staffId, "Zeit automatisch erfasst: {$minutes} Min.");
}
/** Schließt alle offenen Sitzungen eines Mitarbeiters außer der auf $exceptTicketId ab (Ticket gewechselt). */
function finalize_other_open_sessions(PDO $pdo, int $staffId, int $exceptTicketId): void
{
$stmt = $pdo->prepare('SELECT ticket_id FROM ticket_work_sessions WHERE staff_id = ? AND ticket_id != ?');
$stmt->execute([$staffId, $exceptTicketId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $otherTicketId) {
finalize_work_session($pdo, (int) $otherTicketId, $staffId);
}
}
/** Schließt ALLE offenen Sitzungen eines Mitarbeiters ab (z.B. beim Logout). */
function finalize_all_sessions_for_staff(PDO $pdo, int $staffId): void
{
$stmt = $pdo->prepare('SELECT ticket_id FROM ticket_work_sessions WHERE staff_id = ?');
$stmt->execute([$staffId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $ticketId) {
finalize_work_session($pdo, (int) $ticketId, $staffId);
}
}
/**
* Räumt "verwaiste" Sitzungen auf (Tab geschlossen, Browser zu, Rechner aus –
* ohne dass jemand explizit geantwortet oder sich abgemeldet hat). Wird
* regelmäßig vom Auto-Cron (includes/pseudo_cron.php) aufgerufen. Nutzt den
* letzten bekannten Aktivitätszeitpunkt (last_seen_at) als Sitzungsende,
* nicht "jetzt" – die erfasste Zeit spiegelt also die tatsächliche Aktivität
* wider, nicht die Zeit bis zum nächsten Cron-Durchlauf.
*
* @return array{finalized: int, log: string[]}
*/
function finalize_stale_work_sessions(PDO $pdo, bool $verbose = false): array
{
$log = [];
$threshold = date('Y-m-d H:i:s', time() - WORK_SESSION_STALE_MINUTES * 60);
$stmt = $pdo->prepare('SELECT ticket_id, staff_id, last_seen_at FROM ticket_work_sessions WHERE last_seen_at < ?');
$stmt->execute([$threshold]);
$stale = $stmt->fetchAll();
foreach ($stale as $row) {
finalize_work_session($pdo, (int) $row['ticket_id'], (int) $row['staff_id'], $row['last_seen_at']);
}
if ($verbose) {
$log[] = "[" . date('Y-m-d H:i:s') . "] " . count($stale) . " inaktive Zeiterfassungs-Sitzung(en) abgeschlossen.";
}
return ['finalized' => count($stale), 'log' => $log];
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Minimale TOTP-Implementierung (RFC 6238) ohne externe Abhängigkeiten.
* Kompatibel mit Google Authenticator, Authy, etc.
*/
function totp_generate_secret(int $length = 16): string
{
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= $chars[ord($bytes[$i]) & 31];
}
return $secret;
}
function totp_get_code(string $secret, int $timeSlot = 0): string
{
$time = floor(time() / 30) + $timeSlot;
$key = base32_decode($secret);
$msg = pack('N*', 0) . pack('N*', $time);
$hash = hash_hmac('sha1', $msg, $key, true);
$offset = ord($hash[19]) & 0xF;
$code = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
((ord($hash[$offset + 3]) & 0xFF))
) % 1000000;
return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
}
function totp_verify(string $secret, string $code, int $window = 1): bool
{
$code = preg_replace('/\s+/', '', $code);
if (!preg_match('/^\d{6}$/', $code)) return false;
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(totp_get_code($secret, $i), $code)) return true;
}
return false;
}
function totp_qr_url(string $secret, string $email, string $issuer = 'DeskHub'): string
{
$label = rawurlencode($issuer . ':' . $email);
$params = http_build_query([
'secret' => $secret,
'issuer' => $issuer,
'algorithm' => 'SHA1',
'digits' => 6,
'period' => 30,
]);
$otpauth = 'otpauth://totp/' . $label . '?' . $params;
return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' . rawurlencode($otpauth);
}
function base32_decode(string $input): string
{
$map = array_flip(str_split('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'));
$input = strtoupper(rtrim($input, '='));
$binary = '';
$buffer = 0;
$bits = 0;
foreach (str_split($input) as $char) {
if (!isset($map[$char])) continue;
$buffer = ($buffer << 5) | $map[$char];
$bits += 5;
if ($bits >= 8) {
$binary .= chr(($buffer >> ($bits - 8)) & 0xFF);
$bits -= 8;
}
}
return $binary;
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Sicherung des System-Updates.
*
* Ein Update ersetzt Programmcode. Würde das Konto beim Update-Server übernommen, könnten sonst Schadcode-Updates an
* alle Installationen gehen. Deshalb prüft DeskHub bei hinterlegtem Herausgeber-Schlüssel (includes/catalog_defaults.php)
* eine Signatur: Der Herausgeber signiert jedes Release-Paket mit seinem privaten Schlüssel (tools/release-sign.php),
* ohne passende Signatur wird nichts installiert. Der Schlüssel ist derselbe wie bei Plugins und Themes.
*
* Release-Dateien (Anhänge des Releases): deskhub-<Version>.zip und deskhub-<Version>.zip.sig
*/
/** Wird für Updates eine Herausgeber-Signatur verlangt? Ja, sobald ein Herausgeber-Schlüssel eingebaut ist. */
function update_signature_required(): bool
{
return function_exists('plugin_publisher_key') && plugin_publisher_key() !== '';
}
/** Zu signierende Nachricht: bindet Signatur an Version und Dateiinhalt. */
function update_signature_message(string $tag, string $sha256): string
{
return "deskhub-update\n" . ltrim(trim($tag), 'vV') . "\n" . strtolower($sha256);
}
/** Prüft die Signatur eines heruntergeladenen Pakets. Gibt null bei Erfolg zurück, sonst eine Fehlermeldung. */
function update_verify_package(string $tag, string $zipPath, string $signature): ?string
{
$signature = trim($signature);
if ($signature === '') return 'Dem Release fehlt die Signatur des Herausgebers.';
$key = plugin_publisher_key();
if ($key === '') return 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.';
$sha = hash_file('sha256', $zipPath);
if ($sha === false) return 'Das Paket konnte nicht gelesen werden.';
if (!plugin_verify_signature(update_signature_message($tag, $sha), $signature, $key)) {
return 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber. Es wird nicht installiert.';
}
return null;
}
/**
* Sucht in den Anhängen eines Releases (Gitea-API) das Paket und die Signatur.
*
* @return array{zip: ?string, sig: ?string} Download-Adressen oder null
*/
function update_find_assets(array $release, string $tag): array
{
$version = ltrim(trim($tag), 'vV');
$names = ['zip' => "deskhub-{$version}.zip", 'sig' => "deskhub-{$version}.zip.sig"];
$out = ['zip' => null, 'sig' => null];
foreach ((array) ($release['assets'] ?? []) as $asset) {
$name = (string) ($asset['name'] ?? '');
$url = (string) ($asset['browser_download_url'] ?? '');
foreach ($names as $kind => $expected) {
if ($name === $expected && str_starts_with($url, 'https://')) $out[$kind] = $url;
}
}
return $out;
}
/** Kopfzeilen für Abrufe beim Update-Server; ein Zugangstoken ist nur nötig, wenn das Repository nicht öffentlich ist. */
function update_http_headers(): array
{
$token = (string) (config()['update']['token'] ?? '');
return $token !== '' ? ['Authorization: token ' . $token] : [];
}
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
redirect('/home.php');
+178
View File
@@ -0,0 +1,178 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# DeskHub Installer
# Verwendung: bash install.sh [Zielordner]
# Beispiel: bash install.sh /var/www/html/deskhub
# ═══════════════════════════════════════════════════════════════
set -e
# ── Farben ──────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# ── Konfiguration ───────────────────────────────────────────────
GITEA_TOKEN="bc90d44cf0d8adc61a30fec6fde4cbcc64cff429"
GITEA_BASE="https://git.viper.ipv64.net"
REPO="M_Viper/deskhub"
API_URL="$GITEA_BASE/api/v1/repos/$REPO/releases?limit=50&type=semver"
# ── Banner ──────────────────────────────────────────────────────
echo ""
echo -e "${CYAN}${BOLD}"
echo " ██████╗ ███████╗███████╗██╗ ██╗██╗ ██╗██╗ ██╗██████╗ "
echo " ██╔══██╗██╔════╝██╔════╝██║ ██╔╝██║ ██║██║ ██║██╔══██╗"
echo " ██║ ██║█████╗ ███████╗█████╔╝ ███████║██║ ██║██████╔╝"
echo " ██║ ██║██╔══╝ ╚════██║██╔═██╗ ██╔══██║██║ ██║██╔══██╗"
echo " ██████╔╝███████╗███████║██║ ██╗██║ ██║╚██████╔╝██████╔╝"
echo " ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ "
echo -e "${NC}"
echo -e "${BOLD} DeskHub Installer${NC}"
echo " ─────────────────────────────────────────────────────────"
echo ""
# ── Zielordner bestimmen ────────────────────────────────────────
INSTALL_DIR="${1:-/var/www/html/deskhub}"
echo -e " ${YELLOW}Installationsordner:${NC} $INSTALL_DIR"
echo ""
# ── Abhängigkeiten prüfen ───────────────────────────────────────
echo -e " ${BOLD}[1/5]${NC} Abhängigkeiten prüfen..."
for cmd in curl unzip php; do
if ! command -v $cmd &>/dev/null; then
echo -e " ${RED}✗ '$cmd' nicht gefunden. Bitte installieren:${NC}"
echo " apt install $cmd oder yum install $cmd"
exit 1
fi
done
echo -e " ${GREEN}✓ curl, unzip, php gefunden${NC}"
# ── Neuestes Release ermitteln ──────────────────────────────────
echo ""
echo -e " ${BOLD}[2/5]${NC} Neuestes Release von Gitea abrufen..."
RELEASE_JSON=$(curl -s --insecure \
-H "Authorization: token $GITEA_TOKEN" \
"$API_URL")
TAG_NAME=$(echo "$RELEASE_JSON" | php -r "
\$data = json_decode(file_get_contents('php://stdin'), true);
// Neueste Version per version_compare ermitteln
\$latest = '';
if (is_array(\$data)) {
foreach (\$data as \$r) {
\$tag = \$r['tag_name'] ?? '';
if (\$tag && (!\$latest || version_compare(ltrim(\$tag,'v'), ltrim(\$latest,'v'), '>'))) {
\$latest = \$tag;
}
}
}
echo \$latest;
")
if [ -z "$TAG_NAME" ]; then
echo -e " ${RED}✗ Konnte Release-Info nicht abrufen. Token oder Verbindung prüfen.${NC}"
exit 1
fi
echo -e " ${GREEN}✓ Neuestes Release: ${BOLD}$TAG_NAME${NC}"
# ── ZIP herunterladen ───────────────────────────────────────────
echo ""
echo -e " ${BOLD}[3/5]${NC} ZIP herunterladen..."
TMP_ZIP="/tmp/deskhub_${TAG_NAME}.zip"
ZIP_URL="$GITEA_BASE/$REPO/archive/${TAG_NAME}.zip"
curl -L --insecure --progress-bar \
-H "Authorization: token $GITEA_TOKEN" \
-o "$TMP_ZIP" \
"$ZIP_URL"
if [ ! -f "$TMP_ZIP" ] || [ ! -s "$TMP_ZIP" ]; then
echo -e " ${RED}✗ Download fehlgeschlagen.${NC}"
exit 1
fi
echo -e " ${GREEN}✓ Download abgeschlossen: $TMP_ZIP${NC}"
# ── Entpacken ───────────────────────────────────────────────────
echo ""
echo -e " ${BOLD}[4/5]${NC} Entpacken nach $INSTALL_DIR ..."
mkdir -p "$INSTALL_DIR"
TMP_EXTRACT="/tmp/deskhub_extract_$$"
mkdir -p "$TMP_EXTRACT"
unzip -q "$TMP_ZIP" -d "$TMP_EXTRACT"
# Unterordner-Prefix erkennen (z.B. deskhub-1.0.0/)
INNER=$(ls "$TMP_EXTRACT" | head -1)
if [ -d "$TMP_EXTRACT/$INNER" ]; then
cp -r "$TMP_EXTRACT/$INNER/." "$INSTALL_DIR/"
else
cp -r "$TMP_EXTRACT/." "$INSTALL_DIR/"
fi
rm -rf "$TMP_EXTRACT" "$TMP_ZIP"
echo -e " ${GREEN}✓ Dateien erfolgreich entpackt${NC}"
# ── Berechtigungen setzen ───────────────────────────────────────
chmod -R 755 "$INSTALL_DIR"
chmod -R 775 "$INSTALL_DIR/uploads" 2>/dev/null || true
chmod -R 775 "$INSTALL_DIR/assets/branding" 2>/dev/null || true
chmod -R 775 "$INSTALL_DIR/assets/avatars" 2>/dev/null || true
chmod -R 775 "$INSTALL_DIR/database" 2>/dev/null || true
# ── Datenbank einrichten ─────────────────────────────────────────
echo ""
echo -e " ${BOLD}[5/5]${NC} Datenbank einrichten..."
if [ -f "$INSTALL_DIR/database/setup_sqlite.php" ]; then
php "$INSTALL_DIR/database/setup_sqlite.php" && \
echo -e " ${GREEN}✓ SQLite-Datenbank eingerichtet${NC}" || \
echo -e " ${YELLOW}⚠ Datenbank-Setup fehlgeschlagen – bitte manuell ausführen${NC}"
fi
# ── Fertig ──────────────────────────────────────────────────────
echo ""
echo -e " ─────────────────────────────────────────────────────────"
echo -e " ${GREEN}${BOLD}✓ DeskHub $TAG_NAME erfolgreich installiert!${NC}"
echo ""
# Server-IP ermitteln
SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -z "$SERVER_IP" ]; then
SERVER_IP=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "DEINE-IP")
fi
# Webroot-Pfad relativ zu /var/www/html oder /var/www ermitteln
WEB_PATH=""
for webroot in "/var/www/html" "/var/www" "/srv/www/htdocs" "/usr/share/nginx/html"; do
if [[ "$INSTALL_DIR" == "$webroot"* ]]; then
WEB_PATH="${INSTALL_DIR#$webroot}"
break
fi
done
echo -e " ${BOLD}Aufruf im Browser:${NC}"
if [ -n "$WEB_PATH" ]; then
echo -e " ${CYAN}${BOLD} http://$SERVER_IP$WEB_PATH${NC}"
echo -e " ${CYAN}${BOLD} http://$SERVER_IP$WEB_PATH/staff/login.php${NC} ← Admin-Login"
else
echo -e " ${CYAN}${BOLD} http://$SERVER_IP/${INSTALL_DIR##*/}${NC}"
echo -e " ${CYAN}${BOLD} http://$SERVER_IP/${INSTALL_DIR##*/}/staff/login.php${NC} ← Admin-Login"
fi
echo ""
echo -e " ${YELLOW}Nächste Schritte:${NC}"
echo " 1. Im Browser öffnen und Installation abschließen"
echo " 2. Admin-Konto unter /staff/login.php anlegen"
echo " 3. Einstellungen unter /staff/settings.php konfigurieren"
echo ""
echo " ─────────────────────────────────────────────────────────"
echo ""
+12
View File
@@ -0,0 +1,12 @@
# Nach der Installation: gesamtes Verzeichnis sperren wenn .installed existiert
<Files ".installed">
Require all denied
</Files>
Options -Indexes
# Wenn .installed existiert, alle PHP-Ausführung blockieren
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{DOCUMENT_ROOT}/install/.installed -f
RewriteRule ^ - [F,L]
</IfModule>
+910
View File
@@ -0,0 +1,910 @@
<?php
/**
* install/index.php
*
* Eigenständiger Installations-Assistent (unabhängig von includes/bootstrap.php,
* da config/config.php zu diesem Zeitpunkt ja erst erstellt wird).
*
* Nach erfolgreicher Installation sollte dieses Verzeichnis aus
* Sicherheitsgründen vom Server gelöscht oder zumindest gesperrt werden.
*/
session_start();
define('ROOT_DIR', dirname(__DIR__));
define('LOCK_FILE', __DIR__ . '/.installed');
require_once ROOT_DIR . '/database/sqlite_schema.php';
function h(?string $s): string
{
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
// URL der aktuellen Installation automatisch erkennen
$detectedUrl = (function(): string {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
// install/ vom Pfad entfernen → Root-URL
$path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
$path = preg_replace('#/install$#', '', $path);
return rtrim($scheme . '://' . $host . $path, '/');
})();
/**
* Fehlt die Sperrdatei (z. B. weil Dateien neu hochgeladen wurden), darf der Assistent trotzdem keine bestehende
* Installation überschreiben: Gibt es schon eine Konfiguration mit Datenbank und Mitarbeiterkonten, gilt sie als installiert.
*/
$existingInstallation = (function (): bool {
$cfgFile = ROOT_DIR . '/config/config.php';
if (!is_file($cfgFile)) return false;
try {
$cfg = (array) (require $cfgFile);
$db = (array) ($cfg['db'] ?? []);
if (($db['driver'] ?? 'sqlite') === 'mysql') {
$pdo = new PDO('mysql:host=' . ($db['host'] ?? 'localhost') . ';dbname=' . ($db['name'] ?? '') . ';charset=utf8mb4', (string) ($db['user'] ?? ''), (string) ($db['pass'] ?? ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 3]);
} else {
$path = (string) ($db['sqlite_path'] ?? '');
if ($path === '' || !is_file($path)) return false;
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}
return (int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn() > 0;
} catch (Throwable $e) {
return false; // keine nutzbare Datenbank: Erstinstallation ist in Ordnung
}
})();
$alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation;
$errors = [];
$success = false;
// ---------------------------------------------------------------
// Anforderungsprüfung
// ---------------------------------------------------------------
$checks = [
['label' => 'PHP-Version 8.1 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.1.0', '>=')],
['label' => 'PDO-Erweiterung', 'ok' => extension_loaded('pdo')],
['label' => 'PDO MySQL-Treiber (für Produktivbetrieb)', 'ok' => extension_loaded('pdo_mysql'), 'optional' => true],
['label' => 'PDO SQLite-Treiber (optional, für lokale Tests)', 'ok' => extension_loaded('pdo_sqlite'), 'optional' => true],
['label' => 'mbstring-Erweiterung', 'ok' => extension_loaded('mbstring')],
['label' => 'IMAP-Erweiterung (optional, nur für E-Mail-zu-Ticket)', 'ok' => extension_loaded('imap'), 'optional' => true],
['label' => 'Verzeichnis config/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/config')],
['label' => 'Verzeichnis uploads/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/uploads')],
['label' => 'Verzeichnis database/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/database')],
];
$requiredOk = true;
foreach ($checks as $c) {
if (empty($c['optional']) && !$c['ok']) {
$requiredOk = false;
}
}
// ---------------------------------------------------------------
// Installation durchführen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk) {
if (empty($_SESSION['install_csrf']) || !hash_equals($_SESSION['install_csrf'], $_POST['csrf_token'] ?? '')) {
$errors[] = 'Ungültige Anfrage (Sitzung abgelaufen). Bitte die Seite neu laden und erneut versuchen.';
} else {
$driver = ($_POST['driver'] ?? '') === 'mysql' ? 'mysql' : 'sqlite';
$siteName = trim($_POST['site_name'] ?? '') ?: 'DeskHub Support';
$siteUrl = rtrim(trim($_POST['site_url'] ?? ''), '/');
$adminName = trim($_POST['admin_name'] ?? '');
$adminEmail = trim($_POST['admin_email'] ?? '');
$adminPass = $_POST['admin_password'] ?? '';
$adminPass2 = $_POST['admin_password2'] ?? '';
if ($siteUrl === '') $errors[] = 'Bitte die URL der Installation angeben (z.B. https://support.firma.de).';
if ($adminName === '') $errors[] = 'Bitte einen Namen für das Admin-Konto angeben.';
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige Admin-E-Mail-Adresse angeben.';
if (strlen($adminPass) < 8) $errors[] = 'Das Admin-Passwort muss mindestens 8 Zeichen lang sein.';
if ($adminPass !== $adminPass2) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
if (!extension_loaded('pdo_' . $driver)) $errors[] = "Die PHP-Erweiterung pdo_{$driver} ist nicht installiert.";
$dbConfig = [
'driver' => $driver,
'host' => 'localhost',
'name' => 'deskhub',
'user' => 'deskhub_user',
'pass' => 'change_me',
'sqlite_path' => ROOT_DIR . '/database/deskhub.sqlite',
];
if ($driver === 'mysql') {
$dbConfig['host'] = trim($_POST['db_host'] ?? 'localhost') ?: 'localhost';
$dbConfig['name'] = trim($_POST['db_name'] ?? '');
$dbConfig['user'] = trim($_POST['db_user'] ?? '');
$dbConfig['pass'] = $_POST['db_pass'] ?? '';
if ($dbConfig['name'] === '' || $dbConfig['user'] === '') {
$errors[] = 'Bitte Datenbankname und Datenbankbenutzer angeben.';
}
}
$pdo = null;
if (!$errors) {
try {
if ($driver === 'mysql') {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']);
} else {
@unlink($dbConfig['sqlite_path']);
$pdo = new PDO('sqlite:' . $dbConfig['sqlite_path']);
$pdo->exec('PRAGMA foreign_keys = ON;');
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Throwable $e) {
$errors[] = 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage();
}
}
if (!$errors && $pdo) {
try {
$sql = file_get_contents(ROOT_DIR . '/database/schema.sql');
if ($driver === 'sqlite') {
$sql = convert_schema_to_sqlite($sql);
}
$pdo->exec($sql);
// Vordefiniertes Demo-Admin-Konto entfernen und durch das hier
// eingegebene, echte Admin-Konto ersetzen.
$pdo->exec("DELETE FROM staff WHERE email = 'admin@example.com'");
$stmt = $pdo->prepare(
'INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'
);
$stmt->execute([$adminName, $adminEmail, password_hash($adminPass, PASSWORD_DEFAULT), 'admin', 1, date('Y-m-d H:i:s')]);
// ── Initiale Einstellungen in die DB seeden ───────────────────────
// Alle Kerneinstellungen sofort in der settings-Tabelle anlegen,
// damit sie Updates überleben (config.php wird beim Update nie
// überschrieben, aber so sind die Werte auch bei Neuinstallationen
// sofort über get_setting() abrufbar).
// Sicherer, zufälliger App-Key – direkt hier generieren, damit er
// sowohl in die DB (überlebt Updates) als auch in config.php geschrieben wird.
$appKey = bin2hex(random_bytes(32));
$installSettings = [
'app_key' => $appKey, // ← in DB: überlebt Updates
'display_name' => $siteName,
'site_url' => $siteUrl,
'timezone' => 'Europe/Berlin',
'smtp_from_email' => $adminEmail,
'smtp_from_name' => $siteName,
'mail_enabled' => '0',
'helpdesk_status' => 'online',
'kb_enabled' => '1',
'ticket_ratings_enabled' => '1',
'staff_session_timeout' => '30',
'retention_enabled' => '0',
'retention_months' => '24',
'ip_anonymize' => '0',
'upload_allowed_extensions' => 'png, jpg, jpeg, gif, pdf, txt, log, docx, xlsx, zip',
'upload_max_size_mb' => '10',
];
$upsertSql = $driver === 'sqlite'
? 'INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)'
: 'INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)';
foreach ($installSettings as $k => $v) {
$pdo->prepare($upsertSql)->execute([$k, $v]);
}
// Alle vorhandenen Migrationsdateien sofort als "eingespielt" markieren,
// da das Schema gerade frisch erstellt wurde.
$migrTable = $driver === 'sqlite'
? "CREATE TABLE IF NOT EXISTS schema_migrations (migration VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT (datetime('now')))"
: "CREATE TABLE IF NOT EXISTS schema_migrations (migration VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$pdo->exec($migrTable);
$insertMigr = $driver === 'sqlite'
? 'INSERT OR IGNORE INTO schema_migrations (migration) VALUES (?)'
: 'INSERT IGNORE INTO schema_migrations (migration) VALUES (?)';
foreach (glob(ROOT_DIR . '/database/migrations/*.sql') ?: [] as $mf) {
$pdo->prepare($insertMigr)->execute([basename($mf)]);
}
} catch (Throwable $e) {
$errors[] = 'Fehler beim Einrichten der Datenbank: ' . $e->getMessage();
}
}
if (!$errors) {
$configArray = [
'app_key' => $appKey,
'db' => $dbConfig,
'site' => [
'name' => $siteName,
'url' => $siteUrl,
'admin_email' => $adminEmail,
'timezone' => 'Europe/Berlin',
],
'mail' => [
'enabled' => false,
'from_email' => $adminEmail,
'from_name' => $siteName,
],
'imap' => [
'enabled' => false,
'mailbox' => '{imap.example.com:993/imap/ssl}INBOX',
'user' => 'support@example.com',
'pass' => 'change_me',
'default_department_id' => 1,
'default_priority_id' => 2,
],
'uploads' => [
'max_size_bytes' => 10 * 1024 * 1024,
'allowed_extensions' => ['png','jpg','jpeg','gif','pdf','txt','log','docx','xlsx','zip'],
'path' => ROOT_DIR . '/uploads',
],
];
$php = "<?php\n/**\n * DeskHub – Konfiguration\n"
. " * Automatisch erstellt durch den Installations-Assistenten am " . date('d.m.Y H:i') . "\n"
. " * WICHTIG: app_key niemals ändern – verschlüsselte Werte in der DB werden sonst unlesbar.\n"
. " */\n\nreturn " . var_export($configArray, true) . ";\n";
if (file_put_contents(ROOT_DIR . '/config/config.php', $php) === false) {
$errors[] = 'config/config.php konnte nicht geschrieben werden. Bitte Schreibrechte des Verzeichnisses prüfen.';
} else {
file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . " von " . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
$success = true;
}
}
}
}
if (empty($_SESSION['install_csrf'])) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(32));
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeskHub – Installation</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #16202a;
--primary: #0f6f6a;
--primary-dk:#0b5853;
--primary-lt:#e6f4f3;
--accent: #d97706;
--danger: #dc2626;
--danger-lt: #fef2f2;
--success: #16a34a;
--success-lt:#f0fdf4;
--border: #dde3ec;
--muted: #64748b;
--surface: #ffffff;
--bg: #f1f5f9;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--radius: 10px;
--shadow: 0 4px 24px rgba(15,23,42,.10), 0 1px 4px rgba(15,23,42,.06);
}
html, body {
min-height: 100vh;
font-family: var(--font);
font-size: 15px;
line-height: 1.55;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ── */
.i-shell {
display: flex;
min-height: 100vh;
}
/* ── Left brand panel ── */
.i-brand {
width: 340px;
flex-shrink: 0;
background: var(--ink);
background-image: radial-gradient(ellipse at 20% 50%, rgba(15,111,106,.35) 0%, transparent 65%),
radial-gradient(ellipse at 80% 10%, rgba(217,119,6,.15) 0%, transparent 55%);
display: flex;
flex-direction: column;
padding: 48px 36px;
position: sticky;
top: 0;
height: 100vh;
}
.i-brand-logo {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
letter-spacing: -.03em;
margin-bottom: 8px;
}
.i-brand-logo span { color: var(--accent); }
.i-brand-tagline {
font-size: .82rem;
color: #94a3b8;
margin-bottom: 48px;
}
.i-brand-features {
list-style: none;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1;
}
.i-brand-features li {
display: flex;
gap: 12px;
align-items: flex-start;
}
.i-brand-feat-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
margin-top: 1px;
}
.i-brand-feat-text strong {
display: block;
font-size: .82rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 2px;
}
.i-brand-feat-text span {
font-size: .75rem;
color: #64748b;
}
.i-brand-footer {
font-size: .72rem;
color: #334155;
margin-top: 32px;
}
/* ── Right content area ── */
.i-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px 64px;
overflow-y: auto;
}
.i-box {
width: 100%;
max-width: 560px;
}
/* ── Section title ── */
.i-section-title {
font-size: .7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--primary);
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary-lt);
display: flex;
align-items: center;
gap: 8px;
}
/* ── Cards ── */
.i-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px 28px 24px;
margin-bottom: 20px;
}
.i-card-title {
font-size: 1rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.i-card-title .num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: .72rem;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Check list ── */
.i-checks { list-style: none; display: flex; flex-direction: column; gap: 0; }
.i-check-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--border);
font-size: .875rem;
}
.i-check-row:last-child { border-bottom: none; }
.i-check-badge {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: .7rem;
font-weight: 800;
flex-shrink: 0;
}
.i-check-badge.ok { background: var(--success-lt); color: var(--success); }
.i-check-badge.err { background: var(--danger-lt); color: var(--danger); }
.i-check-badge.opt { background: #f1f5f9; color: var(--muted); }
.i-check-row.optional { opacity: .65; }
/* ── Form elements ── */
.i-field { margin-bottom: 16px; }
.i-field label {
display: block;
font-size: .8rem;
font-weight: 600;
color: #374151;
margin-bottom: 5px;
}
.i-field input,
.i-field select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 7px;
font-size: .9rem;
font-family: var(--font);
color: var(--ink);
background: #fff;
transition: border-color .15s, box-shadow .15s;
outline: none;
}
.i-field input:focus,
.i-field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(15,111,106,.12);
}
.i-field input::placeholder { color: #b0bac6; }
.i-field .i-hint {
font-size: .74rem;
color: var(--muted);
margin-top: 4px;
}
.i-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* ── DB driver tabs ── */
.i-driver-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.i-driver-tab {
flex: 1;
border: 2px solid var(--border);
border-radius: 9px;
padding: 12px 14px;
cursor: pointer;
background: #fff;
transition: border-color .15s, background .15s;
text-align: left;
}
.i-driver-tab input[type=radio] { display: none; }
.i-driver-tab.active {
border-color: var(--primary);
background: var(--primary-lt);
}
.i-driver-tab-label {
font-size: .85rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 2px;
}
.i-driver-tab-desc {
font-size: .73rem;
color: var(--muted);
}
.i-driver-tab.active .i-driver-tab-label { color: var(--primary-dk); }
/* ── Submit button ── */
.i-submit-btn {
width: 100%;
padding: 13px;
background: var(--primary);
color: #fff;
font-size: .95rem;
font-weight: 700;
font-family: var(--font);
border: none;
border-radius: 9px;
cursor: pointer;
transition: background .15s, transform .1s;
margin-top: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
letter-spacing: .01em;
}
.i-submit-btn:hover { background: var(--primary-dk); }
.i-submit-btn:active { transform: scale(.99); }
/* ── Alerts ── */
.i-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: .875rem;
margin-bottom: 14px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.i-alert.error { background: var(--danger-lt); border: 1px solid #fca5a5; color: #991b1b; }
.i-alert.success { background: var(--success-lt); border: 1px solid #86efac; color: #166534; }
.i-alert.warning { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; }
.i-alert-icon { font-size: 1rem; line-height: 1.4; flex-shrink: 0; }
/* ── Success screen ── */
.i-success-icon {
width: 64px;
height: 64px;
background: var(--success-lt);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
margin: 0 auto 20px;
}
.i-action-btns { display: flex; gap: 12px; margin-top: 20px; }
.i-btn-primary {
flex: 1;
padding: 11px;
background: var(--primary);
color: #fff;
border-radius: 8px;
font-weight: 700;
font-size: .875rem;
text-align: center;
text-decoration: none;
transition: background .15s;
}
.i-btn-primary:hover { background: var(--primary-dk); color: #fff; text-decoration: none; }
.i-btn-secondary {
flex: 1;
padding: 11px;
background: #fff;
color: var(--ink);
border: 1px solid var(--border);
border-radius: 8px;
font-weight: 600;
font-size: .875rem;
text-align: center;
text-decoration: none;
transition: background .15s;
}
.i-btn-secondary:hover { background: var(--bg); color: var(--ink); text-decoration: none; }
code {
font-family: var(--mono);
font-size: .82em;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
.i-divider {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
/* ── Password strength ── */
.i-pw-bar {
height: 4px;
border-radius: 2px;
background: var(--border);
margin-top: 6px;
overflow: hidden;
}
.i-pw-fill {
height: 100%;
border-radius: 2px;
width: 0%;
transition: width .3s, background .3s;
}
@media (max-width: 768px) {
.i-shell { flex-direction: column; }
.i-brand { width: 100%; height: auto; position: static; padding: 28px 24px; }
.i-brand-features { display: none; }
.i-content { padding: 28px 16px 48px; }
.i-row { grid-template-columns: 1fr; gap: 0; }
}
</style>
</head>
<body>
<div class="i-shell">
<!-- ── Brand Sidebar ── -->
<aside class="i-brand">
<div class="i-brand-logo">Desk<span>Hub</span></div>
<div class="i-brand-tagline">Helpdesk & Ticketsystem</div>
<ul class="i-brand-features">
<li>
<div class="i-brand-feat-icon">🎫</div>
<div class="i-brand-feat-text">
<strong>Ticket-Management</strong>
<span>Abteilungen, Prioritäten, SLA, Regeln</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">📧</div>
<div class="i-brand-feat-text">
<strong>E-Mail-Integration</strong>
<span>SMTP-Versand & IMAP-Abruf</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">👥</div>
<div class="i-brand-feat-text">
<strong>Kundenportal</strong>
<span>Self-Service, Wissensdatenbank</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">📊</div>
<div class="i-brand-feat-text">
<strong>Berichte & Statistiken</strong>
<span>SLA-Raten, Agenten-Performance</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">🔔</div>
<div class="i-brand-feat-text">
<strong>Benachrichtigungen</strong>
<span>Push, Discord, Telegram</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">🔒</div>
<div class="i-brand-feat-text">
<strong>Sicherheit</strong>
<span>2FA, verschlüsselte Zugangsdaten</span>
</div>
</li>
</ul>
<div class="i-brand-footer">DeskHub · Viper Network</div>
</aside>
<!-- ── Main content ── -->
<main class="i-content">
<div class="i-box">
<?php if ($alreadyInstalled): ?>
<!-- Already installed -->
<div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">🔒</div>
<h2 style="margin-bottom:10px">Bereits installiert</h2>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">
Eingerichtet am <strong><?= h(is_file(LOCK_FILE) ? (string) file_get_contents(LOCK_FILE) : "bereits vorhandener Konfiguration") ?></strong>
</p>
<div class="i-alert warning" style="margin-top:20px;text-align:left">
<span class="i-alert-icon">⚠️</span>
<span>Bitte das Verzeichnis <code>install/</code> vom Server löschen, um diese Seite zu deaktivieren.</span>
</div>
<div class="i-action-btns">
<a href="/login.php" class="i-btn-primary">Kundenportal</a>
<a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:16px">
Zum Wiederholen: <code>install/.installed</code> löschen
</p>
</div>
<?php elseif ($success): ?>
<!-- Success screen -->
<div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">✅</div>
<h2 style="margin-bottom:8px;color:var(--success)">Installation abgeschlossen!</h2>
<p style="color:var(--muted);font-size:.9rem">DeskHub wurde erfolgreich eingerichtet.</p>
<div class="i-action-btns">
<a href="/staff/login.php" class="i-btn-primary">→ Jetzt einloggen</a>
<a href="/login.php" class="i-btn-secondary">Kundenportal</a>
</div>
<hr class="i-divider">
<div class="i-alert warning" style="text-align:left">
<span class="i-alert-icon">🗑️</span>
<span><strong>Wichtig:</strong> Löschen Sie jetzt das Verzeichnis <code>install/</code> vom Server, damit niemand die Installation wiederholen kann.</span>
</div>
</div>
<?php else: ?>
<!-- Step 1: Requirements -->
<div class="i-card">
<div class="i-card-title">
<span class="num">1</span> Systemvoraussetzungen
</div>
<ul class="i-checks">
<?php foreach ($checks as $c):
$cls = $c['ok'] ? 'ok' : (empty($c['optional']) ? 'err' : 'opt');
$ico = $c['ok'] ? '✓' : (empty($c['optional']) ? '✗' : '–');
?>
<li class="i-check-row <?= empty($c['optional']) ? '' : 'optional' ?>">
<span class="i-check-badge <?= $cls ?>"><?= $ico ?></span>
<span><?= h($c['label']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if (!$requiredOk): ?>
<div class="i-alert error" style="margin-top:16px">
<span class="i-alert-icon">✗</span>
<span>Nicht alle Pflichtvoraussetzungen erfüllt. Bitte die rot markierten Punkte beheben und die Seite neu laden.</span>
</div>
<?php endif; ?>
</div>
<?php if ($requiredOk): ?>
<?php foreach ($errors as $err): ?>
<div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div>
<?php endforeach; ?>
<!-- Step 2: Configuration form -->
<div class="i-card">
<div class="i-card-title">
<span class="num">2</span> Konfiguration
</div>
<form method="post" id="install-form">
<input type="hidden" name="csrf_token" value="<?= h($_SESSION['install_csrf']) ?>">
<!-- DB driver -->
<div class="i-section-title">🗄️ Datenbank</div>
<div class="i-driver-tabs" id="driver-tabs">
<label class="i-driver-tab <?= ($_POST['driver'] ?? 'mysql') === 'mysql' ? 'active' : '' ?>" id="tab-mysql">
<input type="radio" name="driver" value="mysql" <?= ($_POST['driver'] ?? 'mysql') === 'mysql' ? 'checked' : '' ?>>
<div class="i-driver-tab-label">MySQL / MariaDB</div>
<div class="i-driver-tab-desc">Produktivbetrieb empfohlen</div>
</label>
<label class="i-driver-tab <?= ($_POST['driver'] ?? '') === 'sqlite' ? 'active' : '' ?>" id="tab-sqlite">
<input type="radio" name="driver" value="sqlite" <?= ($_POST['driver'] ?? '') === 'sqlite' ? 'checked' : '' ?>>
<div class="i-driver-tab-label">SQLite</div>
<div class="i-driver-tab-desc">Nur für lokale Tests</div>
</label>
</div>
<div id="mysql-fields" style="display:<?= ($_POST['driver'] ?? 'mysql') === 'sqlite' ? 'none' : 'block' ?>">
<div class="i-row">
<div class="i-field">
<label for="db_host">Host</label>
<input type="text" id="db_host" name="db_host" value="<?= h($_POST['db_host'] ?? 'localhost') ?>" placeholder="localhost">
</div>
<div class="i-field">
<label for="db_name">Datenbankname</label>
<input type="text" id="db_name" name="db_name" value="<?= h($_POST['db_name'] ?? '') ?>" placeholder="deskhub">
</div>
</div>
<div class="i-row">
<div class="i-field">
<label for="db_user">Benutzer</label>
<input type="text" id="db_user" name="db_user" value="<?= h($_POST['db_user'] ?? '') ?>" placeholder="deskhub_user">
</div>
<div class="i-field">
<label for="db_pass">Passwort</label>
<input type="password" id="db_pass" name="db_pass" placeholder="••••••••">
</div>
</div>
<p class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer) — Tabellen werden automatisch angelegt.</p>
</div>
<!-- Website -->
<div class="i-section-title" style="margin-top:8px">🌐 Website</div>
<div class="i-field">
<label for="site_name">Name der Installation</label>
<input type="text" id="site_name" name="site_name" value="<?= h($_POST['site_name'] ?? 'DeskHub Support') ?>" required>
</div>
<div class="i-field">
<label for="site_url">URL der Installation</label>
<input type="text" id="site_url" name="site_url" value="<?= h($_POST['site_url'] ?? $detectedUrl) ?>" placeholder="https://support.firma.de" required>
<div class="i-hint">Wird in E-Mail-Links verwendet. Kein abschließendes /</div>
</div>
<!-- Admin account -->
<div class="i-section-title" style="margin-top:8px">👤 Administrator-Konto</div>
<div class="i-row">
<div class="i-field">
<label for="admin_name">Name</label>
<input type="text" id="admin_name" name="admin_name" value="<?= h($_POST['admin_name'] ?? '') ?>" placeholder="Max Mustermann" required>
</div>
<div class="i-field">
<label for="admin_email">E-Mail</label>
<input type="email" id="admin_email" name="admin_email" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
</div>
</div>
<div class="i-row">
<div class="i-field">
<label for="admin_password">Passwort</label>
<input type="password" id="admin_password" name="admin_password" placeholder="••••••••" required>
<div class="i-pw-bar"><div class="i-pw-fill" id="pw-fill"></div></div>
</div>
<div class="i-field">
<label for="admin_password2">Passwort wiederholen</label>
<input type="password" id="admin_password2" name="admin_password2" placeholder="••••••••" required>
</div>
</div>
<button type="submit" class="i-submit-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Installation starten
</button>
</form>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</main>
</div>
<script>
// Driver tab switching
document.querySelectorAll('.i-driver-tab input[type=radio]').forEach(function(radio) {
radio.addEventListener('change', function() {
document.querySelectorAll('.i-driver-tab').forEach(function(t) { t.classList.remove('active'); });
this.closest('.i-driver-tab').classList.add('active');
document.getElementById('mysql-fields').style.display = this.value === 'mysql' ? 'block' : 'none';
});
});
// Password strength bar
var pwInput = document.getElementById('admin_password');
var pwFill = document.getElementById('pw-fill');
if (pwInput && pwFill) {
pwInput.addEventListener('input', function() {
var v = this.value, score = 0;
if (v.length >= 8) score++;
if (v.length >= 12) score++;
if (/[A-Z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
var colors = ['#ef4444','#f97316','#eab308','#22c55e','#16a34a'];
var widths = ['20%','40%','60%','80%','100%'];
pwFill.style.width = score > 0 ? widths[score-1] : '0%';
pwFill.style.background = score > 0 ? colors[score-1] : 'transparent';
});
}
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
if (get_setting('kb_enabled', '1') !== '1') {
flash('error', 'Die Wissensdatenbank ist derzeit nicht verfügbar.');
redirect('/home.php');
}
$pdo = get_db();
$q = trim($_GET['q'] ?? '');
if ($q !== '') {
$stmt = $pdo->prepare(
"SELECT a.*, c.name AS category_name FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.is_published = 1 AND (a.title LIKE ? OR a.body LIKE ?)
ORDER BY a.views DESC"
);
$like = '%' . $q . '%';
$stmt->execute([$like, $like]);
$articles = $stmt->fetchAll();
$categories = [];
} else {
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
$artStmt = $pdo->prepare('SELECT * FROM kb_articles WHERE category_id = ? AND is_published = 1 ORDER BY title');
$articles = null;
}
$pageTitle = 'Wissensdatenbank';
$activePage = 'kb';
$showHeroBanner = true;
$showHeroImage = true;
$heroEyebrow = 'Wissensdatenbank';
$heroTitle = 'Wonach suchen Sie?';
$heroSubtitle = 'Antworten auf häufige Fragen – oft schneller als der Weg über ein Ticket.';
$heroSearchPlaceholder = 'Suchbegriff eingeben …';
$heroSearchValue = $q;
require __DIR__ . '/includes/header_customer.php';
?>
<?php if ($articles !== null): ?>
<h3><?= count($articles) ?> Ergebnis(se) für „<?= e($q) ?>“</h3>
<?php if (!$articles): ?>
<div class="card empty-state">Keine Artikel gefunden. <a href="/ticket_new.php">Erstellen Sie ein Ticket</a>, falls Sie weitere Hilfe benötigen.</div>
<?php endif; ?>
<?php foreach ($articles as $a): ?>
<div class="card kb-result-card">
<a href="/kb_article.php?slug=<?= urlencode($a['slug']) ?>"><h3 style="margin:0"><?= e($a['title']) ?></h3></a>
<span class="muted"><?= e($a['category_name']) ?></span>
</div>
<?php endforeach; ?>
<?php else: ?>
<?php $anyArticles = false; ?>
<div class="kb-category-grid">
<?php foreach ($categories as $i => $cat): ?>
<?php
$artStmt->execute([$cat['id']]);
$arts = $artStmt->fetchAll();
if (!$arts) continue;
$anyArticles = true;
?>
<div class="card kb-category-card">
<div class="kb-category-head">
<div class="icon-badge"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5"/></svg></div>
<div>
<h3 style="margin:0"><?= e($cat['name']) ?></h3>
<span class="muted" style="font-size:.78rem"><?= count($arts) ?> Artikel</span>
</div>
</div>
<?php foreach ($arts as $a): ?>
<a class="kb-article-link" href="/kb_article.php?slug=<?= urlencode($a['slug']) ?>">
<span><?= e($a['title']) ?></span>
<span class="arrow">→</span>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php if (!$anyArticles): ?>
<div class="card empty-state">Es wurden noch keine Artikel veröffentlicht. <a href="/ticket_new.php">Erstellen Sie ein Ticket</a>, falls Sie Hilfe benötigen.</div>
<?php endif; ?>
<?php endif; ?>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
+40
View File
@@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
if (get_setting('kb_enabled', '1') !== '1') {
flash('error', 'Die Wissensdatenbank ist derzeit nicht verfügbar.');
redirect('/home.php');
}
$pdo = get_db();
$slug = $_GET['slug'] ?? '';
$stmt = $pdo->prepare(
'SELECT a.*, c.name AS category_name FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.slug = ? AND a.is_published = 1'
);
$stmt->execute([$slug]);
$article = $stmt->fetch();
if (!$article) {
flash('error', 'Dieser Artikel wurde nicht gefunden.');
redirect('/kb.php');
}
$pdo->prepare('UPDATE kb_articles SET views = views + 1 WHERE id = ?')->execute([$article['id']]);
$pageTitle = $article['title'];
$activePage = 'kb';
require __DIR__ . '/includes/header_customer.php';
?>
<p><a href="/kb.php">← Zurück zur Wissensdatenbank</a></p>
<div class="card">
<span class="muted"><?= e($article['category_name']) ?></span>
<h1><?= e($article['title']) ?></h1>
<div><?= $article['body'] /* in der Admin-Pflege als vertrauenswürdiges HTML gepflegt */ ?></div>
</div>
<p class="muted">War dieser Artikel hilfreich? Wenn nicht, <a href="/ticket_new.php">erstellen Sie ein Ticket</a>.</p>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
+511
View File
@@ -0,0 +1,511 @@
<?php
/**
* live_chat.php
*
* Öffentlicher, nicht angemeldeter Live-Chat-Endpunkt für das Kundenportal-Widget
* (siehe includes/footer_customer.php). Jeder Chat wird als ganz normales Ticket
* angelegt (source = 'chat') – Nachrichten sind reguläre ticket_threads-Einträge,
* Mitarbeiter beantworten den Chat einfach über die normale Ticketansicht oder das
* eigene Postfach staff/live_chat.php.
*
* Zugriffsschutz für den Gast: genau wie bei einem normal per Formular erstellten
* Gast-Ticket über $_SESSION['guest_ticket_access'][$ticketNumber] (siehe ticket_new.php).
* Zusätzlich merkt sich die Session unter live_chat_ticket_number den "aktiven" Chat,
* damit das Widget beim nächsten Seitenaufruf automatisch weitermacht.
*
* Wichtig, aus Fehlern dieser Funktion gelernt (MySQL-Produktivbetrieb weicht von der
* lokalen SQLite-Testdatenbank ab):
* - Nie eine Abteilungs-/Prioritäts-ID fest annehmen, immer gegen die echte Tabelle
* prüfen (siehe chat_resolve_department()/chat_resolve_priority()).
* - Feldlängen serverseitig begrenzen/prüfen, MySQL-Spalten sind strikt (VARCHAR).
* - Jeder neue tickets.source-Wert braucht eine Migration (ENUM in MySQL).
* - Nie eine Rohfehlermeldung an den Kunden ausliefern – nur sauber loggen.
*/
require_once __DIR__ . '/includes/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
function chat_json($data, int $code = 200): never
{
http_response_code($code);
// Aktuellen CSRF-Token immer mitschicken: das Widget nutzt ihn für die jeweils
// nächste Aktion, statt sich auf den (ggf. durch Caching veralteten oder nach
// Rotation ungültigen) beim Seitenaufruf eingebetteten Token zu verlassen.
if (is_array($data) && !isset($data['csrfToken'])) {
$data['csrfToken'] = csrf_token();
}
echo json_encode($data);
exit;
}
/**
* Wie chat_json(), schickt die Antwort aber sofort an den Kunden raus und beendet das
* Skript NICHT – der Aufrufer kann danach noch weiterarbeiten (E-Mail-Versand an ggf.
* mehrere Mitarbeiter, Discord/Telegram/Push), ohne dass der Kunde darauf warten muss.
* Jeder einzelne SMTP-Handshake kostet auf Shared-Hosting spürbar Zeit – bei mehreren
* Empfängern summiert sich das schnell zu einem Chat-Start, der sich alles andere als
* "live" anfühlt. Gleiche Technik wie pseudo_cron_schedule_background()
* (siehe includes/pseudo_cron.php).
*/
function chat_flush_response(array $data): void
{
if (!isset($data['csrfToken'])) {
$data['csrfToken'] = csrf_token();
}
if (ob_get_level() === 0) ob_start();
http_response_code(200);
echo json_encode($data);
if (!headers_sent()) {
header('Content-Length: ' . ob_get_length());
header('Connection: close');
}
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
} else {
while (ob_get_level() > 0) { @ob_end_flush(); }
@flush();
}
// Session-Schreibsperre freigeben: wir haben oben bereits alles Nötige in $_SESSION
// eingetragen, ein direkt danach folgender Poll desselben Besuchers soll nicht auf
// die Hintergrundarbeit hier warten müssen.
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
}
/** Prüft, ob der aktuelle Besucher Zugriff auf dieses Ticket hat (Gast-Session oder eingeloggter Kunde). */
function chat_has_access(array $ticket, ?array $user): bool
{
if ($user && $ticket['user_id'] == $user['id']) return true;
return !empty($_SESSION['guest_ticket_access'][$ticket['ticket_number']]);
}
function chat_load_ticket(PDO $pdo, string $number): ?array
{
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
$stmt->execute([$number]);
return $stmt->fetch() ?: null;
}
/** Aktuelle Nachrichten (kundensichtbar) für die Widget-Darstellung. */
function chat_messages(PDO $pdo, int $ticketId): array
{
$stmt = $pdo->prepare(
"SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC"
);
$stmt->execute([$ticketId]);
$attStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
return array_map(function (array $m) use ($attStmt) {
$attStmt->execute([$m['id']]);
return [
'id' => (int) $m['id'],
'authorType' => $m['author_type'],
'authorName' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?: 'Support') : null,
'message' => chat_plain_text($m['message']),
'createdAt' => $m['created_at'],
'attachments' => array_map(fn(array $a) => [
'id' => (int) $a['id'],
'name' => $a['original_name'],
'size' => (int) $a['filesize'],
], $attStmt->fetchAll()),
];
}, $stmt->fetchAll());
}
// api_plain_text() lebt in api/staff/v1/common.php (nicht in jedem Kontext geladen) –
// hier eine schlanke, eigenständige Variante fürs Kundenportal.
function chat_plain_text(string $html): string
{
$text = preg_replace('/<(br|p|div)[^>]*>/i', "\n", $html);
return trim(html_entity_decode(strip_tags($text ?? $html), ENT_QUOTES));
}
/**
* Liefert eine garantiert existierende Abteilungs-ID: Regel-Vorgabe zuerst, sonst die
* konfigurierte Live-Chat-/Standardabteilung – aber nur, wenn diese ID auch wirklich noch
* existiert (Abteilungen können im Adminportal jederzeit umbenannt/gelöscht werden).
* Gibt null zurück, wenn es überhaupt keine Abteilung gibt (Grundvoraussetzung fehlt).
*/
function chat_resolve_department(PDO $pdo, ?int $ruleDeptId): ?int
{
$candidate = $ruleDeptId ?? (int) (get_setting('live_chat_department_id') ?: get_setting('default_department_id', '1'));
if ($candidate) {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1');
$stmt->execute([$candidate]);
if ($stmt->fetchColumn()) return $candidate;
}
$fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn()
?: $pdo->query('SELECT id FROM departments ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn();
return $fallback ? (int) $fallback : null;
}
/**
* Liefert eine garantiert existierende Priorität (volle Zeile, für calc_sla_dates()):
* Regel-Vorgabe zuerst, sonst die mit der niedrigsten sort_order. Gibt null zurück,
* wenn gar keine Priorität existiert.
*/
function chat_resolve_priority(PDO $pdo, ?int $rulePrioId): ?array
{
if ($rulePrioId) {
$stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$stmt->execute([$rulePrioId]);
$row = $stmt->fetch();
if ($row) return $row;
}
return $pdo->query('SELECT * FROM priorities ORDER BY sort_order ASC, id ASC LIMIT 1')->fetch() ?: null;
}
/**
* Ist gerade mindestens ein aktiver Mitarbeiter "Verfügbar" (siehe staff/availability.php)?
* Wer seinen Status noch nie gesetzt hat, gilt wie dort als "Verfügbar" (Standardwert).
* Rein informativ fürs Widget – blockiert das Chatten selbst nicht, der Kunde kann
* trotzdem schreiben, die Nachricht wartet dann eben bis jemand wieder verfügbar ist.
*/
function chat_any_staff_available(PDO $pdo): bool
{
// Wichtig: Ein Mitarbeiter ohne staff_availability-Zeile (Status nie gesetzt) zählt
// NICHT als verfügbar. Das entspricht der Team-Übersicht in staff/availability.php,
// die eine fehlende Zeile ebenfalls als "Offline" behandelt – sonst würde der Chat
// "online" anzeigen, obwohl kein einziger Mitarbeiter seinen Status je auf
// "Verfügbar" gestellt hat.
$stmt = $pdo->query(
"SELECT COUNT(*) FROM staff s
INNER JOIN staff_availability sa ON sa.staff_id = s.id
WHERE s.is_active = 1 AND sa.status = 'available'"
);
return (int) $stmt->fetchColumn() > 0;
}
if (get_setting('live_chat_enabled', '0') !== '1') {
chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar.'], 404);
}
if (get_setting('helpdesk_status', 'online') === 'offline') {
chat_json(['error' => 'Das Supportcenter ist derzeit nicht erreichbar.'], 503);
}
$pdo = get_db();
$user = current_user();
$action = $_SERVER['REQUEST_METHOD'] === 'POST' ? ($_POST['action'] ?? '') : ($_GET['action'] ?? '');
// ── Aktiven Chat der Session ermitteln (für state/send/poll) ───────────────────
$activeNumber = $_SESSION['live_chat_ticket_number'] ?? null;
$activeTicket = $activeNumber ? chat_load_ticket($pdo, $activeNumber) : null;
if ($activeTicket && !chat_has_access($activeTicket, $user)) {
$activeTicket = null;
unset($_SESSION['live_chat_ticket_number']);
}
// "Mitarbeiter tippt gerade" fürs Widget: true, solange typing_staff_until (siehe
// staff/live_chat.php Aktion "typing") noch in der Zukunft liegt.
function chat_staff_is_typing(?array $ticket): bool
{
return $ticket && !empty($ticket['typing_staff_until']) && strtotime($ticket['typing_staff_until']) > time();
}
if ($action === 'state') {
if (!$activeTicket) chat_json(['active' => false, 'staffAvailable' => chat_any_staff_available($pdo)]);
chat_json([
'active' => true,
'number' => $activeTicket['ticket_number'],
'status' => $activeTicket['status'],
'staffAvailable' => chat_any_staff_available($pdo),
'staffTyping' => chat_staff_is_typing($activeTicket),
'convertedToTicket' => $activeTicket['source'] !== 'chat',
'messages' => chat_messages($pdo, (int) $activeTicket['id']),
]);
}
if ($action === 'poll') {
if (!$activeTicket) chat_json(['active' => false]);
chat_json([
'active' => true,
'status' => $activeTicket['status'],
'staffAvailable' => chat_any_staff_available($pdo),
'staffTyping' => chat_staff_is_typing($activeTicket),
'convertedToTicket' => $activeTicket['source'] !== 'chat',
'messages' => chat_messages($pdo, (int) $activeTicket['id']),
]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
chat_json(['error' => 'Methode nicht erlaubt.'], 405);
}
// Eigene, JSON-taugliche CSRF-Prüfung statt der gemeinsamen verify_csrf(): die bricht bei
// ungültigem Token mit einer reinen Textausgabe ab, die das Widget nicht als JSON parsen kann.
$submittedCsrf = $_POST['csrf_token'] ?? '';
if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) {
chat_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'csrfExpired' => true], 403);
}
// Token nach Verbrauch rotieren, wie verify_csrf() es auch tut – außer bei "typing":
// das feuert bei jedem Tastenanschlag (gedrosselt) und würde sonst mit einem parallel
// abgeschickten "send" um denselben Token wettlaufen und diesen gelegentlich ungültig
// machen. Ein verpasstes Tipp-Signal ist folgenlos, ein fehlgeschlagenes "send" nicht.
if ($action !== 'typing') {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($action === 'typing') {
if ($activeTicket && $activeTicket['status'] !== 'closed') {
$pdo->prepare('UPDATE tickets SET typing_customer_until = ? WHERE id = ?')
->execute([date('Y-m-d H:i:s', time() + 6), $activeTicket['id']]);
}
chat_json(['ok' => true]);
}
// Sicherheitsnetz: Ein unerwarteter Fehler soll dem Kunden nie eine leere/kaputte Antwort
// liefern (das Widget könnte sie dann nicht auswerten) – stattdessen sauberes JSON
// zurückgeben und den echten Fehler nur server-seitig loggen (nicht an den Kunden ausliefern).
try {
if ($action === 'start') {
chat_handle_start($pdo, $user);
} elseif ($action === 'send') {
chat_handle_send($pdo, $user, $activeTicket);
} else {
chat_json(['error' => 'Unbekannte Aktion.'], 422);
}
} catch (Throwable $e) {
error_log('live_chat.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
chat_json(['error' => 'Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es später erneut.'], 500);
}
/**
* Prüft einen optionalen Datei-Anhang (Feld "attachment", wie beim normalen
* Ticket-Formular in ticket_new.php) über die gemeinsame handle_ticket_upload().
* Gibt null zurück, wenn keine Datei mitgeschickt wurde. Bei einem ungültigen
* Upload wird direkt mit chat_json() geantwortet (never zurückkehrend).
*/
function chat_validate_upload(): ?array
{
if (empty($_FILES['attachment']['name'])) return null;
$result = handle_ticket_upload($_FILES['attachment']);
if (isset($result['error'])) {
chat_json(['error' => $result['error']], 422);
}
return $result;
}
/** Startet einen neuen Live-Chat: legt ein Ticket + die erste Nachricht an. */
function chat_handle_start(PDO $pdo, ?array $user): never
{
if (!rate_limit_check('live_chat_start', client_ip(), 10, 20)) {
chat_json(['error' => 'Zu viele Chat-Anfragen. Bitte versuchen Sie es in ein paar Minuten erneut.'], 429);
}
// Honeypot wie beim normalen Ticket-Formular
if (!empty($_POST['website'])) {
chat_json(['error' => 'Es ist ein Fehler aufgetreten.'], 422);
}
$name = trim($_POST['name'] ?? ($user['name'] ?? ''));
$email = trim($_POST['email'] ?? ($user['email'] ?? ''));
$message = trim($_POST['message'] ?? '');
if ($name === '') chat_json(['error' => 'Bitte geben Sie Ihren Namen an.'], 422);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) chat_json(['error' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.'], 422);
// Anhang schon hier prüfen (nicht erst nach dem Ticket-Insert) – ein ungültiger
// Upload soll kein leeres/halbfertiges Ticket hinterlassen (wie in ticket_new.php).
$uploadInfo = chat_validate_upload();
// Nachricht darf leer sein, WENN ein Anhang mitgeschickt wurde (z.B. nur ein Foto).
if ($message === '' && !$uploadInfo) chat_json(['error' => 'Bitte geben Sie eine Nachricht ein.'], 422);
if (mb_strlen($message) > 5000) chat_json(['error' => 'Nachricht ist zu lang.'], 422);
// Spaltenbreiten in der Datenbank sind strikt (requester_name VARCHAR(120),
// requester_email VARCHAR(150)) – Name großzügig kürzen statt abzulehnen,
// eine zu lange E-Mail dagegen ablehnen (Kürzen würde sie ungültig machen).
if (mb_strlen($name) > 120) $name = mb_substr($name, 0, 120);
if (mb_strlen($email) > 150) chat_json(['error' => 'Die E-Mail-Adresse ist zu lang.'], 422);
if (is_banned($email)) {
// Wie beim Ticket-Formular: kommentarlos abweisen, nicht verraten dass gezielt gesperrt wurde.
chat_json(['error' => 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'], 422);
}
// Ist gerade niemand als "Verfügbar" markiert, wird daraus kein "Live-Chat" (keine
// Live-Chat-Markierung/Tag, taucht nicht im Live-Chat-Postfach auf) – stattdessen ein
// ganz normales Ticket, genau wie über das Kontaktformular. Für den Kunden ändert sich
// an der Widget-Bedienung nichts (er kann trotzdem weiterschreiben und bekommt im
// selben Fenster eine Antwort, sobald wieder jemand da ist).
$staffAvailable = chat_any_staff_available($pdo);
$ticketSource = $staffAvailable ? 'chat' : 'web';
$ruleResult = evaluate_ticket_rules($pdo, [
'subject' => 'Live-Chat: ' . mb_substr($message, 0, 60),
'message' => $message,
'requester_email' => $email,
'requester_name' => $name,
'source' => $ticketSource,
]);
if ($ruleResult['reject']) {
chat_json(['error' => 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'], 422);
}
$deptId = chat_resolve_department($pdo, $ruleResult['set_department']);
if (!$deptId) {
chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar (keine Abteilung eingerichtet).'], 503);
}
$priority = chat_resolve_priority($pdo, $ruleResult['set_priority']);
if (!$priority) {
chat_json(['error' => 'Live-Chat ist derzeit nicht verfügbar (keine Priorität eingerichtet).'], 503);
}
$prioId = (int) $priority['id'];
$helpTopicId = $ruleResult['set_help_topic'];
$sla = calc_sla_dates($priority, null, (int) $deptId, $helpTopicId ? (int) $helpTopicId : null);
$clientIp = client_ip();
if (get_setting('ip_anonymize', '0') === '1') {
$clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp);
$clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp);
}
$subject = 'Live-Chat: ' . mb_substr($message, 0, 80);
$ticketNumber = generate_ticket_number($pdo);
$now = db_now();
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
help_topic_id, priority_id, status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$ticketNumber, $subject, $user['id'] ?? null, $name, $email, $deptId,
$helpTopicId, $prioId, 'open', $ticketSource, $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
)->execute([$ticketId, 'customer', $user['id'] ?? null, $message, $now]);
$threadId = (int) $pdo->lastInsertId();
if ($uploadInfo) {
$pdo->prepare(
'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)'
)->execute([$threadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]);
}
log_activity($pdo, $ticketId, null, 'Live-Chat gestartet');
$assignTo = $ruleResult['assign_staff'];
if ($assignTo === null) {
$prevStmt = $pdo->prepare(
"SELECT assigned_staff_id FROM tickets
WHERE LOWER(requester_email) = LOWER(?) AND assigned_staff_id IS NOT NULL
AND status NOT IN ('closed','resolved') AND id != ? ORDER BY updated_at DESC LIMIT 1"
);
$prevStmt->execute([$email, $ticketId]);
$prevId = $prevStmt->fetchColumn();
if ($prevId) $assignTo = (int) $prevId;
}
if ($assignTo !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) {
apply_tag_to_ticket($pdo, $ticketId, $tagName);
}
if ($ticketSource === 'chat') {
apply_tag_to_ticket($pdo, $ticketId, 'Live-Chat');
}
// Vor dem Flush (nicht danach), damit der Kunde die Auto-Antwort sofort in seiner
// ersten Chat-Antwort sieht, statt erst beim nächsten Poll bis zu 8s später.
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$_SESSION['guest_ticket_access'][$ticketNumber] = true;
$_SESSION['live_chat_ticket_number'] = $ticketNumber;
// Ab hier ist der Chat für den Kunden fertig eingerichtet – Antwort sofort raus,
// damit sich der Start "live" anfühlt. Benachrichtigungen (die je nach Anzahl
// Mitarbeiter mehrere SMTP-Verbindungen brauchen) laufen danach im Hintergrund.
chat_flush_response([
'active' => true,
'number' => $ticketNumber,
'status' => 'open',
'convertedToTicket' => $ticketSource !== 'chat',
'messages' => chat_messages($pdo, $ticketId),
]);
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$deptStmt->execute([$deptId]);
$dept = $deptStmt->fetch();
$ticketForNotify = [
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $name,
'requester_email' => $email,
'department_id' => $deptId,
'department_name' => $dept['name'] ?? null,
];
// Bestätigungs-Mail an den Kunden – genau wie beim normalen Ticket-Formular
// (ticket_new.php). Ohne die hat der Kunde keinerlei Nachweis/Ticketnummer, an die
// er sich halten kann, falls er den Tab schließt – besonders wichtig, wenn gerade
// kein Mitarbeiter online ist und er nicht sofort eine Antwort im Chat bekommt.
try {
notify_customer_ticket_created($ticketForNotify);
} catch (Throwable $e) {
error_log('live_chat.php Bestätigungs-Mail: ' . $e->getMessage());
}
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
notify_staff_new_ticket($ticketForNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
if (file_exists(__DIR__ . '/includes/push.php')) {
require_once __DIR__ . '/includes/push.php';
push_new_chat_message($deptId, $assignTo, $ticketNumber, $name);
}
exit;
}
/** Sendet eine weitere Nachricht im bereits laufenden Chat. */
function chat_handle_send(PDO $pdo, ?array $user, ?array $activeTicket): never
{
if (!$activeTicket) chat_json(['error' => 'Kein aktiver Chat gefunden.'], 404);
if (!rate_limit_check('live_chat_send', client_ip(), 30, 60)) {
chat_json(['error' => 'Zu viele Nachrichten. Bitte kurz warten.'], 429);
}
// Ein bereits geschlossener Chat wird nicht stillschweigend wieder geöffnet –
// die nächste Nachricht startet stattdessen einen neuen Chat/Ticket.
if ($activeTicket['status'] === 'closed') {
unset($_SESSION['live_chat_ticket_number']);
chat_json(['error' => 'Dieser Chat wurde beendet.', 'ended' => true], 410);
}
$message = trim($_POST['message'] ?? '');
$uploadInfo = chat_validate_upload();
// Nachricht darf leer sein, WENN ein Anhang mitgeschickt wurde (z.B. nur ein Foto
// ohne Begleittext) – sonst wie gehabt Pflichtfeld.
if ($message === '' && !$uploadInfo) chat_json(['error' => 'Bitte geben Sie eine Nachricht ein.'], 422);
if (mb_strlen($message) > 5000) chat_json(['error' => 'Nachricht ist zu lang.'], 422);
$now = db_now();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
)->execute([$activeTicket['id'], 'customer', $user['id'] ?? null, $message, $now]);
$newThreadId = (int) $pdo->lastInsertId();
if ($uploadInfo) {
$pdo->prepare(
'INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)'
)->execute([$newThreadId, $uploadInfo['original_name'], $uploadInfo['stored_name'], $uploadInfo['filesize'], $now]);
}
$newStatus = in_array($activeTicket['status'], ['answered', 'resolved'], true) ? 'open' : $activeTicket['status'];
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')->execute([$newStatus, $now, $activeTicket['id']]);
log_activity($pdo, $activeTicket['id'], null, 'Live-Chat-Nachricht empfangen');
// Antwort sofort raus, Discord/Telegram/Push (können pro Aufruf mehrere Netzwerk-
// Requests bedeuten) laufen danach im Hintergrund – siehe chat_flush_response().
chat_flush_response([
'active' => true,
'number' => $activeTicket['ticket_number'],
'status' => $newStatus,
'convertedToTicket' => $activeTicket['source'] !== 'chat',
'messages' => chat_messages($pdo, (int) $activeTicket['id']),
]);
notify_new_reply_channels($activeTicket, $message);
if (file_exists(__DIR__ . '/includes/push.php')) {
require_once __DIR__ . '/includes/push.php';
push_new_chat_message(
(int) $activeTicket['department_id'],
$activeTicket['assigned_staff_id'] !== null ? (int) $activeTicket['assigned_staff_id'] : null,
$activeTicket['ticket_number'],
$activeTicket['requester_name']
);
}
exit;
}
+153
View File
@@ -0,0 +1,153 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
if (current_user()) { redirect('/home.php'); }
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$email = trim($_POST['email'] ?? '');
$pass = $_POST['password'] ?? '';
if (!rate_limit_check('login_attempt', client_ip(), 10, 15)) {
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
} elseif ($email && !rate_limit_check('login_email', $email, 5, 15)) {
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
} else {
$stmt = get_db()->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($pass, $user['password_hash'])) {
rate_limit_reset('login_email', $email);
customer_login($user);
redirect('/home.php');
}
$errors[] = 'E-Mail-Adresse oder Passwort ist falsch.';
}
}
$logo = get_setting('logo_path');
$banner = get_setting('banner_path');
$displayName = get_setting('display_name', config()['site']['name']);
$kbEnabled = get_setting('kb_enabled', '1') === '1';
$privacyLabel = get_setting('footer_privacy_label', 'Datenschutz');
$privacyUrl = get_setting('footer_privacy_url', '');
$privacyHtml = get_setting('footer_privacy_html', '');
$imprintLabel = get_setting('footer_imprint_label', 'Impressum');
$imprintUrl = get_setting('footer_imprint_url', '');
$imprintHtml = get_setting('footer_imprint_html', '');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden – <?= e($displayName) ?></title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/assets/css/style.css") ?: time() ?>">
<?php $customThemeStylesheet = active_custom_theme_stylesheet(); ?>
<?php if ($customThemeStylesheet): ?>
<link rel="stylesheet" href="<?= e($customThemeStylesheet) ?>">
<?php endif; ?>
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script>if('serviceWorker'in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
<?php if (!$banner && !$customThemeStylesheet): ?>
<style>
.login-bg { background: linear-gradient(135deg, #16202a 0%, #20303d 55%, #16202a 100%); }
</style>
<?php endif; ?>
</head>
<body class="login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh">
<!-- Header -->
<header class="site-header">
<div class="topbar-thin">
<span class="welcome">Willkommen, Gast</span>
<nav>
<a href="/home.php">Supportcenter-Startseite</a>
<a href="/ticket_new.php">Neues Ticket eröffnen</a>
<a href="/ticket_lookup.php">Ticketstatus überprüfen</a>
<?php if ($kbEnabled): ?><a href="/kb.php">Wissensdatenbank</a><?php endif; ?>
<a href="/login.php" class="active">Anmelden</a>
</nav>
</div>
<div class="logo-row" style="display:flex; align-items:center; gap:12px">
<a href="/home.php" style="line-height:0; flex-shrink:0">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e($displayName) ?>">
<?php else: ?>
<span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?>
</a>
<?php if ($logo && $displayName): ?>
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
</header>
<!-- Login-Bereich: volle Breite, dunkler Hintergrund -->
<div class="login-bg login-screen<?= $banner ? ' has-image' : '' ?>"<?php if ($banner): ?> style="background-image:url('<?= e($banner) ?>');"<?php endif; ?>>
<div class="auth-card login-card">
<div class="brand-block brand-block-lg">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e($displayName) ?>">
<?php else: ?>
<span class="brand-block-text">Desk<span>Hub</span></span>
<?php endif; ?>
</div>
<h1>Anmelden</h1>
<p class="sub">Melden Sie sich an, um alle Ihre Tickets im Überblick zu sehen.</p>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
<form method="post">
<?= csrf_field() ?>
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autofocus autocomplete="email">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<div style="text-align:right; margin-top:6px">
<a href="/forgot_password.php" class="login-forgot">Passwort vergessen?</a>
</div>
<div class="btn-row">
<button type="submit" class="btn login-submit">Anmelden</button>
</div>
</form>
<div class="login-links">
<span>Noch kein Konto? <a href="/register.php">Jetzt registrieren</a></span>
<span class="login-guest-links">Kein Konto nötig: <a href="/ticket_new.php">Ticket erstellen</a> · <a href="/ticket_lookup.php">Status prüfen</a></span>
<a href="/staff/login.php" class="login-staff-link">Mitarbeiter-Login →</a>
</div>
</div>
</div>
<!-- Footer -->
<footer class="public-footer">
<?php if ($privacyUrl || $privacyHtml || $imprintUrl || $imprintHtml): ?>
<nav style="margin-bottom:8px; display:flex; gap:20px; justify-content:center; flex-wrap:wrap">
<?php if ($privacyUrl || $privacyHtml): ?>
<a href="<?= $privacyUrl ? e($privacyUrl) : '#' ?>" style="color:#6b7280; font-size:.78rem"><?= e($privacyLabel) ?></a>
<?php endif; ?>
<?php if ($imprintUrl || $imprintHtml): ?>
<a href="<?= $imprintUrl ? e($imprintUrl) : '#' ?>" style="color:#6b7280; font-size:.78rem"><?= e($imprintLabel) ?></a>
<?php endif; ?>
</nav>
<?php endif; ?>
Copyright &copy; <?= date('Y') ?> <?= e($displayName) ?> &ndash; Alle Rechte vorbehalten.
</footer>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
customer_logout();
redirect('/login.php');
+19
View File
@@ -0,0 +1,19 @@
{
"name": "DeskHub Staff",
"short_name": "DeskHub",
"description": "DeskHub Mitarbeiterbereich",
"start_url": "/staff/tickets.php",
"display": "standalone",
"background_color": "#0e1a22",
"theme_color": "#0f6f6a",
"orientation": "portrait-primary",
"icons": [
{ "src": "/assets/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Tickets", "url": "/staff/tickets.php", "description": "Ticket-Übersicht" },
{ "name": "Dashboard", "url": "/staff/dashboard.php", "description": "Übersicht" },
{ "name": "Chat", "url": "/staff/chat.php", "description": "Interner Chat" }
]
}
+36
View File
@@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
header('Content-Type: application/manifest+json');
$logo = get_setting('logo_path');
$siteName = get_setting('display_name', config()['site']['name']);
$logoClean = $logo ? preg_replace('/\?.*$/', '', $logo) : null;
$icons = $logoClean ? [
['src' => $logoClean, 'sizes' => 'any', 'type' => 'image/png', 'purpose' => 'any maskable'],
] : [
['src' => '/assets/icons/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any maskable'],
['src' => '/assets/icons/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any maskable'],
];
echo json_encode([
'id' => '/staff/',
'name' => $siteName . ' Staff',
'short_name' => $siteName . ' Staff',
'description' => $siteName . ' Mitarbeiterbereich',
'start_url' => '/staff/tickets.php',
'scope' => '/staff/',
'display' => 'standalone',
'background_color' => '#0e1a22',
'theme_color' => '#0f6f6a',
'orientation' => 'any',
'lang' => 'de',
'icons' => $icons,
'screenshots' => [],
'shortcuts' => [
['name' => 'Tickets', 'url' => '/staff/tickets.php', 'description' => 'Ticket-Übersicht'],
['name' => 'Übersicht', 'url' => '/staff/reports.php','description' => 'Status, Kennzahlen & Berichte'],
['name' => 'Chat', 'url' => '/staff/chat.php', 'description' => 'Interner Chat'],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+18
View File
@@ -0,0 +1,18 @@
{
"name": "DeskHub Support",
"short_name": "DeskHub",
"description": "DeskHub Ticketsystem",
"start_url": "/home.php",
"display": "standalone",
"background_color": "#f0f4f8",
"theme_color": "#0f6f6a",
"orientation": "portrait-primary",
"icons": [
{ "src": "/assets/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/assets/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "Neues Ticket", "url": "/ticket_new.php", "description": "Neues Support-Ticket erstellen" },
{ "name": "Meine Tickets", "url": "/dashboard.php", "description": "Meine offenen Tickets" }
]
}
+36
View File
@@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
header('Content-Type: application/manifest+json');
$logo = get_setting('logo_path');
$siteName = get_setting('display_name', config()['site']['name']);
// Logo-URL ohne Cache-Buster für das Icon verwenden
$logoClean = $logo ? preg_replace('/\?.*$/', '', $logo) : null;
$icons = $logoClean ? [
['src' => $logoClean, 'sizes' => 'any', 'type' => 'image/png', 'purpose' => 'any maskable'],
] : [
['src' => '/assets/icons/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any maskable'],
['src' => '/assets/icons/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any maskable'],
];
echo json_encode([
'id' => '/',
'name' => $siteName . ' Support',
'short_name' => $siteName,
'description' => $siteName . ' Ticketsystem',
'start_url' => '/home.php',
'scope' => '/',
'display' => 'standalone',
'background_color' => '#f0f4f8',
'theme_color' => '#0f6f6a',
'orientation' => 'any',
'lang' => 'de',
'icons' => $icons,
'screenshots' => [],
'shortcuts' => [
['name' => 'Neues Ticket', 'url' => '/ticket_new.php', 'description' => 'Neues Support-Ticket erstellen'],
['name' => 'Meine Tickets','url' => '/dashboard.php', 'description' => 'Meine offenen Tickets'],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline – DeskHub</title>
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body style="display:flex; align-items:center; justify-content:center; min-height:100vh; background:var(--bg)">
<div style="text-align:center; padding:40px 20px">
<div style="font-size:3rem; margin-bottom:16px">📡</div>
<h1 style="font-size:1.5rem; margin-bottom:8px">Keine Verbindung</h1>
<p style="color:var(--muted); margin-bottom:24px">Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.</p>
<button onclick="location.reload()" class="btn">Erneut versuchen</button>
</div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower($_GET['slug'] ?? ''));
$stmt = get_db()->prepare('SELECT * FROM site_pages WHERE slug = ? AND is_active = 1');
$stmt->execute([$slug]);
$page = $stmt->fetch();
if (!$page) {
http_response_code(404);
$pageTitle = 'Seite nicht gefunden';
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;
}
$pageTitle = $page['title'];
require __DIR__ . '/includes/header_customer.php';
?>
<h1><?= e($page['title']) ?></h1>
<div style="line-height:1.7; margin-top:20px">
<?= $page['content'] ?>
</div>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
+12
View File
@@ -0,0 +1,12 @@
# Plugin-Code ist nie direkt per URL aufrufbar – nur über die Hostseiten (staff/plugin.php, api/staff/v1/plugin.php).
# Statische Dateien (js, css, Bilder) bleiben erreichbar.
<FilesMatch "\.(php|phtml|sql|json|md|txt|log)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
Options -Indexes
+56
View File
@@ -0,0 +1,56 @@
# DeskHub-Plugins
Jedes Plugin liegt in einem eigenen Ordner `plugins/<slug>/`. Installation, Aktivierung und Entfernung erfolgen im Adminportal unter **Plugins**.
Dieser Ordner wird bei System-Updates nie überschrieben.
> **Sicherheit:** Plugins sind PHP-Code und laufen mit allen Rechten des Systems (auch Datenbankzugriff). Es gibt keine Sandbox.
> Nur Plugins aus vertrauenswürdiger Quelle installieren. Bei Problemen: Datei `plugins/.disabled` anlegen – dann wird kein Plugin geladen.
> nginx: PHP-Dateien unter `/plugins/` per `location ~ ^/plugins/.*\.php$ { deny all; }` sperren (die mitgelieferte `.htaccess` gilt nur für Apache).
## Aufbau
```
plugins/mein-plugin/
plugin.json Pflicht
plugin.php Startdatei (Standard), registriert Hooks/Menüs/API
pages/*.php Seiten (als Menüpunkt registriert)
migrations/*.sql Datenbank-Änderungen (beim Aktivieren einmalig). Dialekt: name.sqlite.sql / name.mysql.sql laufen nur auf der jeweiligen Datenbank, name.sql auf beiden
assets/ js/css/Bilder
```
`plugin.json`:
```json
{
"slug": "mein-plugin",
"name": "Mein Plugin",
"version": "1.0.0",
"description": "Kurze Beschreibung",
"author": "Name",
"requires": "1.0.6",
"permissions": ["hooks.tickets", "ui.menu"]
}
```
`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.
## Erweiterungspunkte
| Funktion | Zweck |
|---|---|
| `plugin_add_hook($hook, $fn)` | auf Ereignisse reagieren |
| `plugin_add_filter($hook, $fn)` | Inhalte/Werte ergänzen oder verändern |
| `plugin_register_menu($label, $seite, $recht = null)` | Menüpunkt → `pages/<seite>.php` (Variablen `$staff`, `$pdo`) |
| `plugin_register_api($name, $fn, $recht = null)` | Endpunkt `/api/staff/v1/plugin.php?p=<slug>&endpoint=<name>` |
| `plugin_setting($slug, $key)` / `plugin_set_setting(...)` | eigene Einstellungen |
Ereignisse (Hooks): `ticket.created`, `ticket.replied`, `ticket.status_changed`, `ticket.assigned` (Daten: `event`, `ticketId`, …),
`cron.run` (alle ~5 Minuten), `plugin.uninstall.<slug>` (beim Entfernen mit „Daten löschen“).
Filter: `ticket.view.sidebar` (HTML-Block in der Ticketansicht; Plugin ist für das Escapen von Ausgaben selbst verantwortlich).
## Fehlerverhalten
Wirft ein Plugin einen Fehler, wird er protokolliert und das Ticket/die Seite läuft normal weiter. Nach 3 Fehlern wird das Plugin automatisch
deaktiviert (Fehlertext im Adminportal sichtbar). Schwere Fehler (Fatal Error) deaktivieren das gerade ausgeführte Plugin beim nächsten Start.
Ein Beispiel liegt unter `docs/plugin-beispiel/`.
+83
View File
@@ -0,0 +1,83 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
if (current_user()) {
redirect('/home.php');
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
check_honeypot();
if (!rate_limit_check('register', client_ip(), 5, 60)) {
$errors[] = 'Zu viele Registrierungsversuche. Bitte warten Sie eine Stunde.';
} else {
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$pass = $_POST['password'] ?? '';
$pass2 = $_POST['password2'] ?? '';
if ($name === '') $errors[] = 'Bitte geben Sie Ihren Namen an.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
$pwError = validate_password_strength($pass);
if ($pwError) $errors[] = $pwError;
if ($pass !== $pass2) $errors[] = 'Die Passwörter stimmen nicht überein.';
if (!$errors) {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
$errors[] = 'Für diese E-Mail-Adresse existiert bereits ein Konto.';
} else {
$stmt = $pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)');
$stmt->execute([$name, $email, password_hash($pass, PASSWORD_DEFAULT), db_now()]);
$user = ['id' => $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
customer_login($user);
flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
redirect('/home.php');
}
}
} // end else
} // end POST
$pageTitle = 'Konto erstellen';
$activePage = 'register';
require __DIR__ . '/includes/header_customer.php';
?>
<style>.page-content{padding:0!important;margin:0!important}</style>
</div><!-- page-content -->
<?php $__banner = get_setting('banner_path'); ?>
<div class="auth-wrap<?= $__banner ? ' has-image' : '' ?>"<?= $__banner ? ' style="background-image:url(' . e($__banner) . ')"' : '' ?>>
<div class="auth-card">
<?php require __DIR__ . '/includes/brand_block.php'; ?>
<h1>Konto erstellen</h1>
<div class="sub">Erstellen Sie ein Kundenkonto, um Tickets zu verfolgen.</div>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<form method="post">
<?= csrf_field() ?>
<div style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden" aria-hidden="true">
<input type="text" name="hp_website" tabindex="-1" autocomplete="off" value="">
</div>
<label for="name">Name</label>
<input type="text" id="name" name="name" value="<?= e($_POST['name'] ?? '') ?>" required>
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" required>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required minlength="10"
placeholder="mind. 10 Zeichen, Groß-/Klein, Ziffer, Sonderzeichen">
<p style="font-size:.75rem;color:var(--muted);margin:4px 0 0">Mindestens 10 Zeichen mit Groß- und Kleinbuchstaben, einer Ziffer und einem Sonderzeichen.</p>
<label for="password2">Passwort wiederholen</label>
<input type="password" id="password2" name="password2" required>
<div class="btn-row">
<button type="submit" class="btn">Konto erstellen</button>
</div>
</form>
<p style="margin-top:18px;font-size:.85rem">Bereits registriert? <a href="/login.php">Jetzt anmelden</a></p>
</div>
</div>
<footer class="public-footer">Copyright &copy; <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> &ndash; Alle Rechte vorbehalten.</footer>
+63
View File
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
$token = $_GET['token'] ?? $_POST['token'] ?? '';
$email = $token ? verify_password_reset('customer', $token) : null;
$errors = [];
$done = false;
if (!$token || !$email) {
$errors[] = 'Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.';
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$pass1 = $_POST['password'] ?? '';
$pass2 = $_POST['password2'] ?? '';
$pwError = validate_password_strength($pass1);
if ($pwError) {
$errors[] = $pwError;
} elseif ($pass1 !== $pass2) {
$errors[] = 'Die Passwörter stimmen nicht überein.';
} else {
get_db()->prepare('UPDATE users SET password_hash = ? WHERE email = ?')
->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]);
consume_password_reset('customer', $token);
$done = true;
}
}
$pageTitle = 'Passwort zurücksetzen';
$activePage = 'login';
require __DIR__ . '/includes/header_customer.php';
?>
</div><!-- page-content -->
<?php $__banner = get_setting('banner_path'); ?>
<div class="auth-wrap<?= $__banner ? ' has-image' : '' ?>"<?= $__banner ? ' style="background-image:url(' . e($__banner) . ')"' : '' ?>>
<div class="auth-card">
<?php require __DIR__ . '/includes/brand_block.php'; ?>
<h1>Neues Passwort festlegen</h1>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php if ($done): ?>
<div class="flash success">Ihr Passwort wurde geändert. Sie können sich jetzt anmelden.</div>
<p style="margin-top:18px;font-size:.85rem"><a href="/login.php">Zur Anmeldung →</a></p>
<?php elseif ($email): ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="token" value="<?= e($token) ?>">
<label for="password">Neues Passwort</label>
<input type="password" id="password" name="password" required autofocus>
<label for="password2">Neues Passwort wiederholen</label>
<input type="password" id="password2" name="password2" required>
<div class="btn-row">
<button type="submit" class="btn" style="width:100%">Passwort speichern</button>
</div>
</form>
<?php else: ?>
<p style="margin-top:18px;font-size:.85rem"><a href="/forgot_password.php">Neuen Link anfordern →</a></p>
<?php endif; ?>
</div>
</div>
<footer class="public-footer">Copyright &copy; <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> &ndash; Alle Rechte vorbehalten.</footer>
+10
View File
@@ -0,0 +1,10 @@
User-agent: *
Disallow: /staff/
Disallow: /install/
Disallow: /config/
Disallow: /database/
Disallow: /backups/
Disallow: /cron/
Disallow: /includes/
Disallow: /uploads/
Allow: /
@@ -0,0 +1,67 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$agents = $pdo->query(
"SELECT s.*, d.name AS dept_name,
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_staff_id=s.id AND t.status NOT IN ('closed','resolved')) AS open_tickets,
r.name AS role_name
FROM staff s
LEFT JOIN departments d ON d.id = s.department_id
LEFT JOIN roles r ON r.id = s.role_id
WHERE s.is_active = 1
ORDER BY s.name"
)->fetchAll();
$pageTitle = 'Mitarbeiter-Verzeichnis'; $activePage = 'agent_directory';
require __DIR__ . '/_header.php';
?>
<h1>Mitarbeiter-Verzeichnis</h1>
<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:16px; margin-top:8px">
<?php foreach ($agents as $a): ?>
<div class="card" style="padding:18px 20px; position:relative; <?= $a['is_on_vacation'] ? 'opacity:.75' : '' ?>">
<?php if ($a['is_on_vacation']): ?>
<span class="badge" style="position:absolute;top:14px;right:14px;background:var(--accent);font-size:.68rem">🌴 Urlaub</span>
<?php endif; ?>
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px">
<?php if ($a['avatar_path']): ?>
<img src="<?= e($a['avatar_path']) ?>" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;flex-shrink:0;border:2px solid var(--border)">
<?php else: ?>
<div style="width:48px;height:48px;border-radius:50%;background:var(--primary-lt);display:flex;align-items:center;justify-content:center;font-size:1.2rem;font-weight:700;color:var(--primary);flex-shrink:0">
<?= mb_strtoupper(mb_substr($a['name'], 0, 1)) ?>
</div>
<?php endif; ?>
<div>
<div style="font-weight:700;color:var(--ink)"><?= e($a['name']) ?></div>
<div style="font-size:.76rem;color:var(--muted)"><?= $a['role'] === 'admin' ? 'Administrator' : ($a['role_name'] ?? 'Mitarbeiter') ?></div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:5px;font-size:.82rem">
<div style="display:flex;gap:8px">
<span style="color:var(--muted);width:70px;flex-shrink:0">E-Mail</span>
<a href="mailto:<?= e($a['email']) ?>" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($a['email']) ?></a>
</div>
<?php if ($a['phone']): ?>
<div style="display:flex;gap:8px">
<span style="color:var(--muted);width:70px;flex-shrink:0">Telefon</span>
<span><?= e($a['phone']) ?></span>
</div>
<?php endif; ?>
<div style="display:flex;gap:8px">
<span style="color:var(--muted);width:70px;flex-shrink:0">Abteilung</span>
<span><?= $a['dept_name'] ? e($a['dept_name']) : '<span class="muted">–</span>' ?></span>
</div>
<div style="display:flex;gap:8px">
<span style="color:var(--muted);width:70px;flex-shrink:0">Offen</span>
<span>
<a href="/staff/tickets.php?assigned=<?= $a['id'] ?>" style="font-weight:600"><?= (int)$a['open_tickets'] ?></a>
<span class="muted"> Tickets</span>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
+110
View File
@@ -0,0 +1,110 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
$newKey = $_SESSION['new_api_key'] ?? null; // wird nur einmal angezeigt
unset($_SESSION['new_api_key']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create') {
try {
$created = api_key_create($pdo, (string) ($_POST['name'] ?? ''), !empty($_POST['can_create']), !empty($_POST['can_read']), (string) ($_POST['ip_whitelist'] ?? ''), (int) $staff['id']);
$_SESSION['new_api_key'] = ['name' => trim($_POST['name']), 'key' => $created['key']];
audit_log($staff['id'], 'API-Schlüssel erstellt: ' . trim($_POST['name']));
redirect('/staff/api_keys.php');
} catch (AdminModelException $e) {
$errors[] = $e->getMessage();
}
}
if ($action === 'toggle') {
$pdo->prepare('UPDATE api_keys SET is_active = 1 - is_active WHERE id = ?')->execute([(int) $_POST['id']]);
audit_log($staff['id'], 'API-Schlüssel umgeschaltet: #' . (int) $_POST['id']);
redirect('/staff/api_keys.php');
}
if ($action === 'delete') {
$pdo->prepare('DELETE FROM api_keys WHERE id = ?')->execute([(int) $_POST['id']]);
audit_log($staff['id'], 'API-Schlüssel gelöscht: #' . (int) $_POST['id']);
flash('success', 'Schlüssel gelöscht. Systeme, die ihn nutzen, werden ab sofort abgewiesen.');
redirect('/staff/api_keys.php');
}
}
$keys = $pdo->query('SELECT k.*, s.name AS creator FROM api_keys k LEFT JOIN staff s ON s.id = k.created_by ORDER BY k.created_at DESC')->fetchAll();
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'ihre-domain.de');
$pageTitle = 'API-Schlüssel';
$activePage = 'api_keys';
require __DIR__ . '/_header.php';
?>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($newKey): ?>
<div class="flash success">
<strong>Schlüssel „<?= e($newKey['name']) ?>“ wurde erstellt.</strong> Kopiere ihn jetzt – aus Sicherheitsgründen wird er nie wieder angezeigt:
<div style="margin-top:8px"><code style="font-size:1rem;user-select:all;padding:6px 10px;background:rgba(0,0,0,.08);border-radius:6px;display:inline-block"><?= e($newKey['key']) ?></code></div>
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h1 style="margin:0">API-Schlüssel</h1>
</div>
<p class="muted">Mit einem Schlüssel können Fremdsysteme (Website-Formular, Shop, Monitoring …) Tickets über die öffentliche API anlegen oder deren Status abfragen. Ereignisse an andere Systeme meldest du über <a href="/staff/webhooks.php">Webhooks</a>.</p>
<div class="card">
<table>
<thead><tr><th>Name</th><th>Schlüssel</th><th>Rechte</th><th>Erlaubte IPs</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt</th><th></th></tr></thead>
<tbody>
<?php foreach ($keys as $k): ?>
<tr>
<td><strong><?= e($k['name']) ?></strong><div class="muted" style="font-size:.76rem"><?= e($k['creator'] ?? '') ?></div></td>
<td><code><?= e($k['key_prefix']) ?>…</code></td>
<td style="font-size:.85rem"><?= $k['can_create_tickets'] ? 'Tickets anlegen' : '' ?><?= $k['can_create_tickets'] && $k['can_read_tickets'] ? '<br>' : '' ?><?= $k['can_read_tickets'] ? 'Tickets lesen' : '' ?></td>
<td class="muted" style="font-size:.82rem"><?= e($k['ip_whitelist'] ?: 'alle') ?></td>
<td><strong><?= $k['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
<td class="muted" style="font-size:.82rem"><?= e(cron_last_run_label($k['last_used_at'] ?? null)) ?></td>
<td class="muted" style="font-size:.82rem"><?= e(ui_date($k['created_at'] ?? null)) ?></td>
<td style="white-space:nowrap">
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="toggle"><input type="hidden" name="id" value="<?= (int) $k['id'] ?>">
<button type="submit" style="background:none;border:none;color:var(--primary);cursor:pointer;padding:0;font-size:.9rem"><?= $k['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?></button></form> ·
<form method="post" style="display:inline" onsubmit="return confirm('Schlüssel endgültig löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $k['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button></form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$keys): ?><tr><td colspan="8" class="muted">Noch keine API-Schlüssel angelegt.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<div class="card">
<h3 style="margin-top:0">Neuen Schlüssel erstellen</h3>
<form method="post" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:760px">
<div><label for="name">Name *</label><input type="text" id="name" name="name" placeholder="z. B. Website-Kontaktformular" required></div>
<div><label for="ip_whitelist">Erlaubte IP-Adressen (optional)</label><input type="text" id="ip_whitelist" name="ip_whitelist" placeholder="leer = von überall; sonst kommagetrennt"></div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:12px"><input type="checkbox" name="can_create" value="1" checked style="width:auto"> Darf Tickets anlegen</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="can_read" value="1" style="width:auto"> Darf Ticketstatus lesen (mit Ticketnummer + E-Mail des Anfragenden)</label>
<div class="btn-row"><button type="submit" class="btn">Schlüssel erstellen</button></div>
</form>
</div>
<div class="card">
<h3 style="margin-top:0">So nutzt du die API</h3>
<p class="muted" style="margin-top:0">Ticket anlegen (JSON). Antwort: <code>201</code> mit der Ticketnummer. Höchstens 60 Tickets pro Minute.</p>
<pre style="background:var(--panel2,#f1f4f7);padding:12px;border-radius:8px;overflow:auto;font-size:.82rem"><?= e("curl -X POST {$base}/api/public/v1/tickets.php \\\n -H \"X-API-Key: dh_…\" -H \"Content-Type: application/json\" \\\n -d '{\"name\":\"Max Muster\",\"email\":\"max@example.de\",\"subject\":\"Frage zur Rechnung\",\"message\":\"Hallo …\",\"helpTopicId\":1}'") ?></pre>
<p class="muted">Status abfragen:</p>
<pre style="background:var(--panel2,#f1f4f7);padding:12px;border-radius:8px;overflow:auto;font-size:.82rem"><?= e("curl \"{$base}/api/public/v1/tickets.php?number=240915-0012&email=max@example.de\" -H \"X-API-Key: dh_…\"") ?></pre>
<p class="muted" style="font-size:.82rem;margin-bottom:0">Optionale Felder beim Anlegen: <code>departmentId</code>, <code>priorityId</code>, <code>helpTopicId</code>. Ticket-Regeln und SLA-Pläne gelten wie bei jedem anderen Ticket.</p>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
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);
$serverUrl = base_url();
$pageTitle = 'Apps';
$activePage = 'apps';
require __DIR__ . '/_header.php';
?>
<h1 style="margin-top:0">Apps für Mitarbeiter</h1>
<p class="muted">Mit den Apps bearbeitet ihr Tickets am PC oder unterwegs. Beide haben ein helles und dunkles Design.</p>
<div class="card" style="margin-bottom:20px">
<strong>Serveradresse für die Anmeldung</strong>
<div style="display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap">
<input type="text" id="server-url" value="<?= e($serverUrl) ?>" readonly style="max-width:420px;margin:0">
<button type="button" class="btn secondary" onclick="navigator.clipboard.writeText(document.getElementById('server-url').value); this.textContent='Kopiert'">Kopieren</button>
</div>
<div class="muted" style="font-size:.82rem;margin-top:6px">Diese Adresse gebt ihr beim ersten Start der App ein, danach meldet ihr euch mit eurem Mitarbeiter-Konto an.</div>
</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]; ?>
<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 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>
<div class="muted" style="font-size:.72rem;margin-top:10px;word-break:break-all">SHA-256: <?= e($a['sha256']) ?></div>
<?php else: ?>
<div class="muted">Hier ist noch keine <?= e($p['label']) ?>-App hinterlegt.<?= $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>
<?php endif; ?>
<?php endif; ?>
<div style="margin-top:14px;font-size:.85rem;border-top:1px solid var(--border);padding-top:12px">
<strong>Installation</strong>
<?php if ($key === 'windows'): ?>
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
<li>Datei herunterladen und starten.</li>
<li>Falls Windows warnt („Der Computer wurde geschützt“): <em>Weitere Informationen → Trotzdem ausführen</em>.</li>
<li>App öffnen, Serveradresse eingeben und anmelden.</li>
</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>
<li>Android fragt einmalig, ob der Browser Apps installieren darf. Das bitte erlauben.</li>
<li>App öffnen, Serveradresse eingeben und anmelden.</li>
<li>Updates: neue Datei laden und darüber installieren. Eure Anmeldung bleibt erhalten.</li>
</ol>
<?php endif; ?>
</div>
</div>
<?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 require __DIR__ . '/_footer.php'; ?>