upload-preview.php 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. <?php
  2. declare(strict_types=1);
  3. use App\App\Bootstrap;
  4. use App\Security\Csrf;
  5. use App\Security\FormAccess;
  6. use App\Security\RateLimiter;
  7. use App\Storage\JsonStore;
  8. require dirname(__DIR__) . '/src/autoload.php';
  9. Bootstrap::init();
  10. if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
  11. http_response_code(405);
  12. header('Content-Type: text/plain; charset=utf-8');
  13. echo 'Method not allowed';
  14. exit;
  15. }
  16. $csrf = $_GET['csrf'] ?? '';
  17. if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
  18. http_response_code(419);
  19. header('Content-Type: text/plain; charset=utf-8');
  20. echo 'Ungültiges CSRF-Token.';
  21. exit;
  22. }
  23. $email = strtolower(trim((string) ($_GET['email'] ?? '')));
  24. if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  25. http_response_code(422);
  26. header('Content-Type: text/plain; charset=utf-8');
  27. echo 'Bitte gültige E-Mail eingeben.';
  28. exit;
  29. }
  30. $activityRaw = $_GET['last_user_activity_at'] ?? null;
  31. $lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
  32. $formAccess = new FormAccess();
  33. $auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
  34. if (($auth['ok'] ?? false) !== true) {
  35. $reason = (string) ($auth['reason'] ?? '');
  36. Bootstrap::jsonResponse([
  37. 'ok' => false,
  38. 'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
  39. 'auth_required' => $reason === 'auth_required',
  40. 'auth_expired' => $reason === 'auth_expired',
  41. ], (int) ($auth['status_code'] ?? 401));
  42. }
  43. $field = trim((string) ($_GET['field'] ?? ''));
  44. $index = (int) ($_GET['index'] ?? -1);
  45. if ($field === '' || $index < 0) {
  46. http_response_code(422);
  47. header('Content-Type: text/plain; charset=utf-8');
  48. echo 'Ungültiger Upload-Eintrag.';
  49. exit;
  50. }
  51. /** @return string|null */
  52. function resolveStoredPreviewPath(array $entry, array $app): ?string
  53. {
  54. $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
  55. if ($baseDir === '') {
  56. return null;
  57. }
  58. $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
  59. $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
  60. if ($storedDir === '' || $storedFilename === '') {
  61. return null;
  62. }
  63. if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
  64. return null;
  65. }
  66. if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
  67. return null;
  68. }
  69. if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
  70. return null;
  71. }
  72. $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
  73. $realBase = realpath($baseDir);
  74. $realPath = realpath($path);
  75. if ($realBase !== false && $realPath !== false && !str_starts_with($realPath, $realBase)) {
  76. return null;
  77. }
  78. return $path;
  79. }
  80. $app = Bootstrap::config('app');
  81. $limiter = new RateLimiter();
  82. $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
  83. $rateKey = sprintf('preview-upload:%s:%s', $ip, $email);
  84. if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
  85. http_response_code(429);
  86. header('Content-Type: text/plain; charset=utf-8');
  87. echo 'Zu viele Anfragen. Bitte später erneut versuchen.';
  88. exit;
  89. }
  90. $store = new JsonStore();
  91. $draft = $store->getDraft($email);
  92. if (!is_array($draft)) {
  93. http_response_code(404);
  94. header('Content-Type: text/plain; charset=utf-8');
  95. echo 'Entwurf nicht gefunden.';
  96. exit;
  97. }
  98. $uploads = (array) ($draft['uploads'] ?? []);
  99. $files = $uploads[$field] ?? null;
  100. $entry = (is_array($files) && isset($files[$index]) && is_array($files[$index])) ? $files[$index] : null;
  101. if (!is_array($entry)) {
  102. http_response_code(404);
  103. header('Content-Type: text/plain; charset=utf-8');
  104. echo 'Upload nicht gefunden.';
  105. exit;
  106. }
  107. $path = resolveStoredPreviewPath($entry, $app);
  108. if ($path === null || !is_file($path)) {
  109. http_response_code(404);
  110. header('Content-Type: text/plain; charset=utf-8');
  111. echo 'Datei nicht gefunden.';
  112. exit;
  113. }
  114. $mime = (string) ($entry['mime'] ?? '');
  115. if ($mime === '') {
  116. $detected = @mime_content_type($path);
  117. $mime = is_string($detected) ? $detected : 'application/octet-stream';
  118. }
  119. $downloadName = (string) ($entry['original_filename'] ?? basename($path));
  120. $fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName) ?: 'upload.bin';
  121. $encodedName = rawurlencode($downloadName);
  122. header('Content-Type: ' . $mime);
  123. header('X-Content-Type-Options: nosniff');
  124. header('Content-Length: ' . (string) filesize($path));
  125. header('Content-Disposition: inline; filename="' . $fallbackName . '"; filename*=UTF-8\'\'' . $encodedName);
  126. readfile($path);
  127. exit;