upload-preview.php 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. <?php
  2. declare(strict_types=1);
  3. use App\App\Bootstrap;
  4. use App\Security\Csrf;
  5. use App\Security\RateLimiter;
  6. use App\Storage\JsonStore;
  7. require dirname(__DIR__) . '/src/autoload.php';
  8. Bootstrap::init();
  9. if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
  10. Bootstrap::textResponse(Bootstrap::appMessage('common.method_not_allowed'), 405);
  11. }
  12. $csrf = $_GET['csrf'] ?? '';
  13. if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
  14. Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_csrf'), 419);
  15. }
  16. $email = strtolower(trim((string) ($_GET['email'] ?? '')));
  17. if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  18. Bootstrap::textResponse(Bootstrap::appMessage('common.invalid_email'), 422);
  19. }
  20. $field = trim((string) ($_GET['field'] ?? ''));
  21. $index = (int) ($_GET['index'] ?? -1);
  22. if ($field === '' || $index < 0) {
  23. Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.invalid_upload_entry'), 422);
  24. }
  25. /** @return string|null */
  26. function resolveStoredPreviewPath(array $entry, array $app): ?string
  27. {
  28. $baseDir = rtrim((string) ($app['storage']['uploads'] ?? ''), '/');
  29. if ($baseDir === '') {
  30. return null;
  31. }
  32. $storedDir = trim((string) ($entry['stored_dir'] ?? ''), '/');
  33. $storedFilename = trim((string) ($entry['stored_filename'] ?? ''));
  34. if ($storedDir === '' || $storedFilename === '') {
  35. return null;
  36. }
  37. if (str_contains($storedDir, '..') || str_contains($storedFilename, '..')) {
  38. return null;
  39. }
  40. if (!preg_match('/^[A-Za-z0-9._\/-]+$/', $storedDir)) {
  41. return null;
  42. }
  43. if (!preg_match('/^[A-Za-z0-9._ -]+$/', $storedFilename)) {
  44. return null;
  45. }
  46. $path = $baseDir . '/' . $storedDir . '/' . $storedFilename;
  47. $realBase = realpath($baseDir);
  48. $realPath = realpath($path);
  49. if ($realBase !== false && $realPath !== false && !str_starts_with($realPath, $realBase)) {
  50. return null;
  51. }
  52. return $path;
  53. }
  54. $app = Bootstrap::config('app');
  55. $limiter = new RateLimiter();
  56. $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
  57. $rateKey = sprintf('preview-upload:%s:%s', $ip, $email);
  58. if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
  59. Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.rate_limited'), 429);
  60. }
  61. $store = new JsonStore();
  62. $draft = $store->getDraft($email);
  63. if (!is_array($draft)) {
  64. Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.draft_not_found'), 404);
  65. }
  66. $uploads = (array) ($draft['uploads'] ?? []);
  67. $files = $uploads[$field] ?? null;
  68. $entry = (is_array($files) && isset($files[$index]) && is_array($files[$index])) ? $files[$index] : null;
  69. if (!is_array($entry)) {
  70. Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.upload_not_found'), 404);
  71. }
  72. $path = resolveStoredPreviewPath($entry, $app);
  73. if ($path === null || !is_file($path)) {
  74. Bootstrap::textResponse(Bootstrap::appMessage('upload_preview.file_not_found'), 404);
  75. }
  76. $mime = (string) ($entry['mime'] ?? '');
  77. if ($mime === '') {
  78. $detected = @mime_content_type($path);
  79. $mime = is_string($detected) ? $detected : 'application/octet-stream';
  80. }
  81. $downloadName = (string) ($entry['original_filename'] ?? basename($path));
  82. $fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName) ?: 'upload.bin';
  83. $encodedName = rawurlencode($downloadName);
  84. header('Content-Type: ' . $mime);
  85. header('X-Content-Type-Options: nosniff');
  86. header('Content-Length: ' . (string) filesize($path));
  87. header('Content-Disposition: inline; filename="' . $fallbackName . '"; filename*=UTF-8\'\'' . $encodedName);
  88. readfile($path);
  89. exit;