Files
deskhub/DeskHub-Webseite/api/staff/v1/common.php
T
2026-11-05 21:21:27 +00:00

430 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../includes/bootstrap.php';
require_once __DIR__ . '/../../../includes/ticket_extras.php'; // falls bootstrap.php noch die alte Version ist
require_once __DIR__ . '/../../../includes/totp.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('Pragma: no-cache');
// Browser-Erweiterungen (Chrome/Edge/Brave: chrome-extension://…, Firefox: moz-extension://…) dürfen die API aufrufen. Ohne diese Antwort hängt der
// Zugriff davon ab, ob der Browser die Server-Berechtigung der Erweiterung schon angewendet hat (nach der ersten Erlaubnis oft erst nach einem Neustart).
// Es werden keine Cookies gesendet; wer die API nutzt, braucht weiterhin das Token bzw. Passwort.
$__origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
if ($__origin !== '' && preg_match('#^(chrome|moz)-extension://[a-z0-9-]{8,64}$#', $__origin)) {
header('Access-Control-Allow-Origin: ' . $__origin);
header('Vary: Origin');
header('Access-Control-Allow-Headers: Authorization, X-DeskHub-Authorization, Content-Type, Cache-Control, Accept');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Max-Age: 600');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
}
unset($__origin);
const MOBILE_API_STATUSES =['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
set_exception_handler(function (Throwable $error): void {
error_log('Mobile Staff API: ' . $error->getMessage());
api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.');
});
function api_json(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function api_error(int $status, string $code, string $message, array $details = []): never
{
api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status);
}
function api_require_method(string ...$methods): void
{
if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) {
header('Allow: ' . implode(', ', $methods));
api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.');
}
}
function api_request_body(): array
{
$length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($length > 1024 * 1024) {
api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.');
}
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
try {
$body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.');
}
if (!is_array($body)) {
api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.');
}
return $body;
}
function api_require_https(): void
{
$https = request_is_https();
$local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
$allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1';
if (!$https && !$local && !$allowInsecure) {
api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.');
}
}
/**
* Liest die Anmeldung der Apps. Viele Hoster (Apache mit PHP-FPM/FastCGI, z. B. Plesk) reichen den Header "Authorization" nicht an PHP weiter.
* Deshalb senden die Apps denselben Wert zusätzlich als "X-DeskHub-Authorization", der überall ankommt; hier gilt der erste vorhandene.
*/
function api_authorization_header(): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if (trim((string) $header) === '') $header = $_SERVER['HTTP_X_DESKHUB_AUTHORIZATION'] ?? '';
if (trim((string) $header) === '' && function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (in_array(strtolower((string) $name), ['authorization', 'x-deskhub-authorization'], true) && trim((string) $value) !== '') { $header = $value; break; }
}
}
return trim((string) $header);
}
function api_bearer_token(): ?string
{
$header = api_authorization_header();
if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) {
return null;
}
return $matches[1];
}
function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array
{
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30')));
$expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400));
$deviceName = trim((string) $deviceName);
if ($deviceName === '') {
$deviceName = null;
} else {
$deviceName = mb_substr($deviceName, 0, 120);
}
$pdo->prepare(
'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at)
VALUES (?,?,?,?,?,?)'
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now(), db_now()]);
return ['token' => $token, 'expires_at' => $expiresAt];
}
function api_require_staff(): array
{
$token = api_bearer_token();
if ($token === null) {
api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.');
}
$pdo = get_db();
$stmt = $pdo->prepare(
'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at
FROM mobile_api_tokens mt
JOIN staff s ON s.id = mt.staff_id
WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1
LIMIT 1'
);
$stmt->execute([hash('sha256', $token), db_now()]);
$staff = $stmt->fetch();
if (!$staff) {
api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.');
}
// Jede authentifizierte Anfrage ist ein App-Heartbeat. So bleibt der Status
// auch bei regelmaessiger Nutzung mit weniger als 5 Minuten Abstand aktuell.
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
// Aktionen in der App (Schreib-Aufrufe) zählen als Aktivität; reines Abfragen im Hintergrund und die Push-Anmeldung nicht
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !in_array(basename((string) ($_SERVER['SCRIPT_NAME'] ?? '')), ['push_device.php', 'crash_report.php', 'logout.php'], true)) {
staff_touch_activity($pdo, (int) $staff['id']);
}
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
// auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen.
pseudo_cron_schedule_background();
$GLOBALS['__api_staff'] = $staff; // für api_is_following()
return $staff;
}
function api_staff_payload(array $staff): array
{
$permissionNames = array_keys(permission_labels()); // alle Rechte des Katalogs (includes/permissions.php)
$permissions = [];
foreach ($permissionNames as $permission) {
$permissions[$permission] = has_permission($staff, $permission);
}
return [
'id' => (int) $staff['id'],
'name' => $staff['name'],
'email' => $staff['email'],
'role' => $staff['role'],
'permissions' => $permissions,
];
}
/** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */
function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void
{
if (!has_permission($staff, $permission, $departmentId)) {
api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.');
}
}
function api_require_admin(array $staff): void
{
if ($staff['role'] !== 'admin') {
api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.');
}
}
/** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */
function api_require_admin_or_permission(array $staff, string $permission): void
{
if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
}
}
function api_find_ticket(PDO $pdo, array $staff, string $number): array
{
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, 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.ticket_number = ?'
);
$stmt->execute([$number]);
$ticket = $stmt->fetch();
if (!$ticket) {
api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.');
}
$departmentIds = staff_department_ids($staff);
if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) {
api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.');
}
return $ticket;
}
/** Adresse des ersten Bildes in einem HTML-Text (absolut, nur http/https); leer, wenn keines vorhanden ist. */
function api_first_image_url(?string $html): string
{
if (!preg_match('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1/is', (string) $html, $m)) return '';
$src = trim(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if (str_starts_with($src, '//')) $src = 'https:' . $src;
elseif (str_starts_with($src, '/')) $src = base_url() . $src;
return preg_match('#^https?://#i', $src) ? $src : '';
}
function api_plain_text(?string $html): string
{
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
// Weitere Blöcke (div, Listen, Tabellenzeilen, Überschriften …) enden ebenfalls mit einem Zeilenumbruch, Listenpunkte bekommen einen Punkt
$text = preg_replace('/<li\b[^>]*>/i', '• ', $text);
$text = preg_replace('/<\/(div|li|tr|h[1-6]|blockquote|pre|ul|ol|table)\s*>/i', "\n", $text);
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/[ \t]+\n/', "\n", $text); // Leerzeichen am Zeilenende
$text = preg_replace('/\n{3,}/', "\n\n", $text); // höchstens eine Leerzeile
return trim($text);
}
function api_ticket_summary(array $ticket): array
{
return [
'id' => (int) $ticket['id'],
'number' => $ticket['ticket_number'],
'subject' => $ticket['subject'],
'requesterName' => $ticket['requester_name'],
'requesterEmail' => $ticket['requester_email'],
'status' => $ticket['status'],
'resolutionCode' => $ticket['resolution_code'] ?? null,
'resolutionNote' => $ticket['resolution_note'] ?? null,
'priority' => [
'id' => (int) $ticket['priority_id'],
'name' => $ticket['priority_name'],
'color' => $ticket['priority_color'],
],
'department' => [
'id' => (int) $ticket['department_id'],
'name' => $ticket['department_name'],
],
'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [
'id' => (int) $ticket['assigned_staff_id'],
'name' => $ticket['staff_name'],
],
'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']),
'slaPausedAt' => $ticket['sla_paused_at'] ?? null,
'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at'])
&& strtotime($ticket['resolution_due_at']) < time()
)),
// Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
// (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
// Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
// den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
'dueLabel' => in_array($ticket['status'], ['resolved', 'closed'], true) ? null : (!empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null)),
'isSpam' => !empty($ticket['is_spam']),
'createdAt' => $ticket['created_at'],
'updatedAt' => $ticket['updated_at'],
'snoozedUntil' => !empty($ticket['snoozed_until']) && $ticket['snoozed_until'] > db_now() ? $ticket['snoozed_until'] : null,
'following' => api_is_following((int) $ticket['id']),
];
}
/** Beobachtet der angemeldete Mitarbeiter dieses Ticket? (für die App) */
function api_is_following(int $ticketId): bool
{
static $mine = null;
if ($mine === null) {
$mine = [];
try {
$staff = $GLOBALS['__api_staff'] ?? null;
if ($staff) {
$stmt = get_db()->prepare('SELECT ticket_id FROM ticket_followers WHERE staff_id = ?');
$stmt->execute([(int) $staff['id']]);
$mine = array_flip(array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
} catch (Throwable $e) {
}
}
return isset($mine[$ticketId]);
}
/** Cc-Empfänger eines Tickets für die Apps. */
function api_ticket_collaborators(PDO $pdo, int $ticketId): array
{
$s = $pdo->prepare('SELECT id, email, name FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at, id');
$s->execute([$ticketId]);
return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll());
}
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): int
{
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
api_require_permission($staff, $permission, (int) $ticket['department_id']);
if (!in_array($type, ['reply', 'note'], true)) {
api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.');
}
$message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff));
if (trim(strip_tags($message)) === '') {
api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.');
}
if (strlen($message) > 100000) {
api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
}
$isNote = $type === 'note';
// Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf
// einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder
// App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt,
// die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden.
if (!$isNote && $ticket['source'] === 'chat' && $staff['role'] !== 'admin') {
$lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByOtherName) {
api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
}
}
// Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET-
// Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur
// den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein
// anderer Mitarbeiter sie in der Zwischenzeit übernommen hat).
// (Administratoren dürfen auch bei fremder Sperre antworten.)
if ($ticket['source'] !== 'chat' && $staff['role'] !== 'admin') {
$lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByOtherName) {
api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet.");
}
}
$newStatus = $setStatus ?: 'answered';
if (!$isNote) {
if (!in_array($newStatus, MOBILE_API_STATUSES, true)) {
api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.');
}
if (in_array($newStatus, ['resolved', 'closed'], true)) {
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) {
api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
}
}
}
$now = db_now();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
VALUES (?,?,?,?,?,?)'
);
$stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]);
$threadId = (int) $pdo->lastInsertId();
if ($isNote) {
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)');
} else {
$firstResponse = empty($ticket['first_responded_at']);
$autoAssign = empty($ticket['assigned_staff_id']);
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
$sql = 'UPDATE tickets SET status = ?, updated_at = ?'
. ($terminal ? ', resolution_code = ?, resolution_note = ?' : '')
. ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '')
. ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '')
. ($firstResponse ? ', first_responded_at = ?' : '')
. ($autoAssign ? ', assigned_staff_id = ?' : '')
. ' WHERE id = ?';
$args = [$newStatus, $now];
if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); }
if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now;
if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now;
if ($firstResponse) $args[] = $now;
if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id'];
$pdo->prepare($sql)->execute($args);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
if (!$isNote) {
// Signatur: optional vom Aufrufer gewählt (own, dept, none), sonst eigene, ersatzweise die der Abteilung
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $signatureChoice);
$collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
$collabStmt->execute([$ticket['id']]);
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
}
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]);
return $threadId;
}