isLoggedIn()) { TaskCertResponse::error('Nicht eingeloggt', 401, false); } $csrfToken = TaskCertRequest::queryString('csrf_token', ''); if (!TaskCertCsrf::validate($csrfToken)) { TaskCertResponse::error('CSRF validation failed', 419, false); } $submissionId = TaskCertRequest::queryInt('submission_id', 0); $fieldKey = TaskCertRequest::queryString('field_key', ''); $index = TaskCertRequest::queryInt('index', -1); if ($submissionId <= 0 || $fieldKey === '' || $index < 0) { TaskCertResponse::error('Ungültige Parameter.', 400, false); } try { $services = task_cert_services(); $submission = $services['submissionRepo']->findById($submissionId); if ($submission === null) { TaskCertResponse::error('Einreichung nicht gefunden.', 404, false); } // Zugriff: Admin ODER Eigentümer der Einreichung. $isAdmin = $auth->hasPermission('r-task-cert-admin'); $isOwner = (int) ($submission['main_contact_id'] ?? 0) === $auth->contactId(); if (!$isAdmin && !$isOwner) { TaskCertResponse::error('Keine Berechtigung', 403, false); } $payload = is_array($submission['payload'] ?? null) ? $submission['payload'] : []; $formValues = is_array($payload['form_values'] ?? null) ? $payload['form_values'] : []; $fieldFiles = is_array($formValues[$fieldKey] ?? null) ? $formValues[$fieldKey] : []; $fileMeta = is_array($fieldFiles[$index] ?? null) ? $fieldFiles[$index] : null; if ($fileMeta === null || !isset($fileMeta['file_path'])) { TaskCertResponse::error('Datei nicht gefunden.', 404, false); } $absolutePath = TaskUploadStorage::absolutePath((string) $fileMeta['file_path']); if ($absolutePath === null || !is_file($absolutePath)) { TaskCertResponse::error('Datei nicht gefunden.', 404, false); } $fileName = (string) ($fileMeta['file_name'] ?? 'datei'); // Content-Type IMMER aus der (erlaubten) Endung ableiten — nie aus dem // gespeicherten/Client-MIME (schützt auch Alt-Datensätze vor Stored-XSS, // z. B. als Bild getarntes SVG). $mime = TaskUploadPolicy::mimeForFilename($fileName); // RFC-5987: ASCII-Fallback für alte Clients + UTF-8-URL-encoded für moderne. $asciiFallback = preg_replace('/[^\x20-\x7E]+/', '_', $fileName) ?: 'datei'; // Inline nur für eine feste, sichere MIME-Menge (kein SVG/HTML/XML), // alles andere als Download. $disposition = TaskUploadPolicy::isInlineSafeMime($mime) ? 'inline' : 'attachment'; if (!headers_sent()) { http_response_code(200); header('Content-Type: ' . $mime); header(sprintf( 'Content-Disposition: %s; filename="%s"; filename*=UTF-8\'\'%s', $disposition, addcslashes($asciiFallback, '"\\'), rawurlencode($fileName) )); header('Content-Length: ' . (int) filesize($absolutePath)); header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); header('Cache-Control: private, max-age=0, must-revalidate'); } while (ob_get_level() > 0) { ob_end_clean(); } readfile($absolutePath); exit; } catch (Throwable $throwable) { TaskCertLogger::error('Submission-Datei-Download fehlgeschlagen', [ 'submission_id' => $submissionId, 'field_key' => $fieldKey, 'index' => $index, 'exception' => $throwable, ]); TaskCertResponse::error('Download fehlgeschlagen.', 500, false); }