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)
VALUES (?,?,?,?,?)'
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, 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.');
}
$lastUsed = $staff['mobile_token_last_used_at'] ?? null;
if (!$lastUsed || strtotime($lastUsed) < time() - 300) {
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
}
// "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();
return $staff;
}
function api_staff_payload(array $staff): array
{
$permissionNames = array_merge([
'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
'create_ticket', 'view_reports',
], array_keys(admin_section_permissions()));
$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('/]*\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('/
/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('/