fileuploadstore.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Storage;
  4. use App\App\Bootstrap;
  5. final class FileUploadStore
  6. {
  7. /** @var array<string, mixed> */
  8. private array $app;
  9. public function __construct()
  10. {
  11. $this->app = Bootstrap::config('app');
  12. }
  13. /**
  14. * @param array<string, mixed> $files
  15. * @param array<string, array<string, mixed>> $uploadFields
  16. * @return array{uploads: array<string, array<int, array<string, mixed>>>, errors: array<string, string>}
  17. */
  18. public function processUploads(array $files, array $uploadFields, string $applicationKey): array
  19. {
  20. $uploaded = [];
  21. $errors = [];
  22. foreach ($uploadFields as $key => $fieldConfig) {
  23. $file = $this->pickUploadedFile($files, $key);
  24. if ($file === null) {
  25. continue;
  26. }
  27. $errorCode = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
  28. if ($errorCode === UPLOAD_ERR_NO_FILE) {
  29. continue;
  30. }
  31. if ($errorCode !== UPLOAD_ERR_OK) {
  32. $errors[$key] = 'Upload fehlgeschlagen.';
  33. continue;
  34. }
  35. $tmpName = (string) ($file['tmp_name'] ?? '');
  36. if ($tmpName === '' || !is_uploaded_file($tmpName)) {
  37. $errors[$key] = 'Ungültige Upload-Datei.';
  38. continue;
  39. }
  40. $size = (int) ($file['size'] ?? 0);
  41. $maxSize = (int) ($fieldConfig['max_size'] ?? $this->app['uploads']['max_size']);
  42. if ($size <= 0 || $size > $maxSize) {
  43. $errors[$key] = 'Datei ist zu groß oder leer.';
  44. continue;
  45. }
  46. $safeName = $this->sanitizeFilename((string) ($file['name'] ?? 'datei'));
  47. $mime = $this->detectMime($tmpName);
  48. $safeName = $this->ensureExtensionForMime($safeName, $mime);
  49. $extension = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
  50. $allowedExtensions = $fieldConfig['extensions'] ?? $this->app['uploads']['allowed_extensions'];
  51. if (!in_array($extension, $allowedExtensions, true)) {
  52. $errors[$key] = 'Dateityp ist nicht erlaubt.';
  53. continue;
  54. }
  55. $allowedMimes = $fieldConfig['mimes'] ?? $this->app['uploads']['allowed_mimes'];
  56. if (!in_array($mime, $allowedMimes, true)) {
  57. $errors[$key] = 'MIME-Typ ist nicht erlaubt.';
  58. continue;
  59. }
  60. $rand8 = bin2hex(random_bytes(4));
  61. $baseDir = rtrim((string) $this->app['storage']['uploads'], '/');
  62. $targetDir = sprintf('%s/%s/%s/%s', $baseDir, $applicationKey, $key, $rand8);
  63. if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
  64. $errors[$key] = 'Upload-Ordner konnte nicht erstellt werden.';
  65. continue;
  66. }
  67. $targetName = $this->ensureUniqueName($targetDir, $safeName);
  68. $targetPath = $targetDir . '/' . $targetName;
  69. if (!move_uploaded_file($tmpName, $targetPath)) {
  70. $errors[$key] = 'Datei konnte nicht gespeichert werden.';
  71. continue;
  72. }
  73. $relativePath = sprintf('%s/%s/%s/%s', $applicationKey, $key, $rand8, $targetName);
  74. $uploaded[$key][] = [
  75. 'original_filename' => $safeName,
  76. 'stored_dir' => sprintf('%s/%s/%s', $applicationKey, $key, $rand8),
  77. 'stored_filename' => $targetName,
  78. 'relative_path' => $relativePath,
  79. 'mime' => $mime,
  80. 'size' => filesize($targetPath) ?: $size,
  81. 'uploaded_at' => date('c'),
  82. ];
  83. }
  84. return ['uploads' => $uploaded, 'errors' => $errors];
  85. }
  86. /** @param array<string, mixed> $files */
  87. private function pickUploadedFile(array $files, string $fieldKey): ?array
  88. {
  89. $primary = $this->extractFirstFile($files[$fieldKey] ?? null);
  90. $camera = $this->extractFirstFile($files[$fieldKey . '__camera'] ?? null);
  91. if (is_array($primary) && ((int) ($primary['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK)) {
  92. return $primary;
  93. }
  94. if (is_array($camera) && ((int) ($camera['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK)) {
  95. return $camera;
  96. }
  97. if (is_array($primary) && ((int) ($primary['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE)) {
  98. return $primary;
  99. }
  100. if (is_array($camera) && ((int) ($camera['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE)) {
  101. return $camera;
  102. }
  103. return null;
  104. }
  105. private function extractFirstFile(mixed $fileArray): ?array
  106. {
  107. if (!is_array($fileArray)) {
  108. return null;
  109. }
  110. if (isset($fileArray['name']) && is_array($fileArray['name'])) {
  111. return [
  112. 'name' => $fileArray['name'][0] ?? '',
  113. 'type' => $fileArray['type'][0] ?? '',
  114. 'tmp_name' => $fileArray['tmp_name'][0] ?? '',
  115. 'error' => $fileArray['error'][0] ?? UPLOAD_ERR_NO_FILE,
  116. 'size' => $fileArray['size'][0] ?? 0,
  117. ];
  118. }
  119. return $fileArray;
  120. }
  121. private function sanitizeFilename(string $name): string
  122. {
  123. $name = str_replace(["\0", '/', '\\'], '', $name);
  124. $name = trim($name);
  125. if ($name === '') {
  126. return 'datei.bin';
  127. }
  128. $name = preg_replace('/[^A-Za-z0-9._ -]/', '_', $name) ?? 'datei.bin';
  129. $name = preg_replace('/\s+/', ' ', $name) ?? $name;
  130. $name = trim($name, " .");
  131. if ($name === '' || $name === '.' || $name === '..') {
  132. return 'datei.bin';
  133. }
  134. if (strlen($name) > 120) {
  135. $ext = pathinfo($name, PATHINFO_EXTENSION);
  136. $base = pathinfo($name, PATHINFO_FILENAME);
  137. $base = substr($base, 0, 100);
  138. $name = $ext !== '' ? $base . '.' . $ext : $base;
  139. }
  140. return $name;
  141. }
  142. private function ensureUniqueName(string $dir, string $fileName): string
  143. {
  144. $candidate = $fileName;
  145. $ext = pathinfo($fileName, PATHINFO_EXTENSION);
  146. $base = pathinfo($fileName, PATHINFO_FILENAME);
  147. $i = 1;
  148. while (is_file($dir . '/' . $candidate)) {
  149. $suffix = '_' . $i;
  150. $candidate = $ext !== '' ? $base . $suffix . '.' . $ext : $base . $suffix;
  151. $i++;
  152. }
  153. return $candidate;
  154. }
  155. private function ensureExtensionForMime(string $fileName, string $mime): string
  156. {
  157. $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
  158. if ($ext !== '') {
  159. return $fileName;
  160. }
  161. $map = [
  162. 'image/jpeg' => 'jpg',
  163. 'image/png' => 'png',
  164. 'image/webp' => 'webp',
  165. 'application/pdf' => 'pdf',
  166. ];
  167. $mapped = $map[$mime] ?? '';
  168. if ($mapped === '') {
  169. return $fileName;
  170. }
  171. return $fileName . '.' . $mapped;
  172. }
  173. private function detectMime(string $path): string
  174. {
  175. $finfo = finfo_open(FILEINFO_MIME_TYPE);
  176. if ($finfo === false) {
  177. return 'application/octet-stream';
  178. }
  179. $mime = finfo_file($finfo, $path);
  180. finfo_close($finfo);
  181. return is_string($mime) ? $mime : 'application/octet-stream';
  182. }
  183. }