FileService.php 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. <?php
  2. namespace App\Services;
  3. use App\Models\File;
  4. use Exception;
  5. use FilesystemIterator;
  6. use Illuminate\Support\Facades\Storage;
  7. use Illuminate\Support\Str;
  8. use RecursiveDirectoryIterator;
  9. use RecursiveIteratorIterator;
  10. use RuntimeException;
  11. use ZipArchive;
  12. class FileService
  13. {
  14. /**
  15. * @param $path
  16. * @param $file
  17. * @return File
  18. */
  19. public function saveUploadedFile($path, $file): File
  20. {
  21. $originalName = $this->sanitizeOriginalName((string)$file->getClientOriginalName());
  22. $storedFilename = $this->buildStoredFilename($originalName);
  23. $relativePath = $this->buildUniquePath($path, $storedFilename);
  24. try {
  25. Storage::disk('public')->put($relativePath, $file->getContent());
  26. } catch (\Throwable $e) {
  27. throw new RuntimeException('Не удалось сохранить файл. Проверьте имя файла и повторите попытку.', 0, $e);
  28. }
  29. $fileModel = File::query()->create([
  30. 'link' => url('/storage/' . $relativePath),
  31. 'path' => $relativePath,
  32. 'user_id' => auth()->user()->id,
  33. 'original_name' => $originalName,
  34. 'mime_type' => $file->getClientMimeType(),
  35. ]);
  36. $this->ensureThumbnail($fileModel);
  37. return $fileModel;
  38. }
  39. public function ensureThumbnail(File $file, bool $overwrite = false): bool
  40. {
  41. if (!$this->isImageFile($file) || !$file->path || $this->isThumbnailPath($file->path)) {
  42. return false;
  43. }
  44. $disk = Storage::disk('public');
  45. if (!$disk->exists($file->path)) {
  46. return false;
  47. }
  48. $thumbnailPath = $this->thumbnailPath($file->path);
  49. if ($disk->exists($thumbnailPath) && !$overwrite) {
  50. return true;
  51. }
  52. return $this->createThumbnail($file->path, $thumbnailPath);
  53. }
  54. public function deleteFileWithThumbnail(File $file): void
  55. {
  56. if ($file->path) {
  57. Storage::disk('public')->delete($file->path);
  58. if (!$this->isThumbnailPath($file->path)) {
  59. Storage::disk('public')->delete($this->thumbnailPath($file->path));
  60. }
  61. }
  62. }
  63. public function thumbnailPath(string $path): string
  64. {
  65. $directory = pathinfo($path, PATHINFO_DIRNAME);
  66. $filename = pathinfo($path, PATHINFO_FILENAME);
  67. $extension = pathinfo($path, PATHINFO_EXTENSION);
  68. $thumbnailName = $filename . '.thumbnail' . ($extension !== '' ? '.' . $extension : '');
  69. return $directory === '.' ? $thumbnailName : $directory . '/' . $thumbnailName;
  70. }
  71. public function isThumbnailPath(string $path): bool
  72. {
  73. return Str::contains(pathinfo($path, PATHINFO_FILENAME), '.thumbnail');
  74. }
  75. public function isImageFile(File $file): bool
  76. {
  77. if (in_array((string) $file->mime_type, ['image/jpeg', 'image/png', 'image/webp'], true)) {
  78. return true;
  79. }
  80. return in_array(Str::lower(pathinfo((string) $file->path, PATHINFO_EXTENSION)), ['jpg', 'jpeg', 'png', 'webp'], true);
  81. }
  82. private function createThumbnail(string $sourcePath, string $thumbnailPath): bool
  83. {
  84. if (!extension_loaded('gd')) {
  85. return false;
  86. }
  87. $disk = Storage::disk('public');
  88. $sourceFullPath = $disk->path($sourcePath);
  89. $imageInfo = @getimagesize($sourceFullPath);
  90. if (!$imageInfo || empty($imageInfo[0]) || empty($imageInfo[1])) {
  91. return false;
  92. }
  93. [$width, $height, $type] = $imageInfo;
  94. $source = match ($type) {
  95. IMAGETYPE_JPEG => function_exists('\imagecreatefromjpeg') ? @\imagecreatefromjpeg($sourceFullPath) : false,
  96. IMAGETYPE_PNG => function_exists('\imagecreatefrompng') ? @\imagecreatefrompng($sourceFullPath) : false,
  97. IMAGETYPE_WEBP => function_exists('\imagecreatefromwebp') ? @\imagecreatefromwebp($sourceFullPath) : false,
  98. default => false,
  99. };
  100. if (!$source) {
  101. return false;
  102. }
  103. $maxSide = $this->thumbnailMaxSide();
  104. $scale = min(1, $maxSide / max($width, $height));
  105. $thumbnailWidth = max(1, (int) round($width * $scale));
  106. $thumbnailHeight = max(1, (int) round($height * $scale));
  107. $thumbnail = \imagecreatetruecolor($thumbnailWidth, $thumbnailHeight);
  108. if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) {
  109. \imagealphablending($thumbnail, false);
  110. \imagesavealpha($thumbnail, true);
  111. }
  112. \imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $thumbnailWidth, $thumbnailHeight, $width, $height);
  113. $disk->makeDirectory(dirname($thumbnailPath));
  114. $thumbnailFullPath = $disk->path($thumbnailPath);
  115. $saved = match ($type) {
  116. IMAGETYPE_JPEG => function_exists('\imagejpeg') && \imagejpeg($thumbnail, $thumbnailFullPath, 82),
  117. IMAGETYPE_PNG => function_exists('\imagepng') && \imagepng($thumbnail, $thumbnailFullPath, 7),
  118. IMAGETYPE_WEBP => function_exists('\imagewebp') && \imagewebp($thumbnail, $thumbnailFullPath, 82),
  119. default => false,
  120. };
  121. \imagedestroy($source);
  122. \imagedestroy($thumbnail);
  123. return (bool) $saved;
  124. }
  125. private function thumbnailMaxSide(): int
  126. {
  127. return max(1, (int) config('filesystems.photo_thumbnail_max_side', 150));
  128. }
  129. private function sanitizeOriginalName(string $originalName): string
  130. {
  131. $name = basename(str_replace('\\', '/', $originalName));
  132. if (class_exists('\Normalizer')) {
  133. $normalized = \Normalizer::normalize($name, \Normalizer::FORM_C);
  134. if (is_string($normalized)) {
  135. $name = $normalized;
  136. }
  137. }
  138. // Remove control/format unicode symbols (e.g. zero-width, LRM/RLM), then normalize spaces.
  139. $name = preg_replace('/\p{C}+/u', '', $name) ?? '';
  140. $name = preg_replace('/\p{Z}+/u', ' ', $name) ?? '';
  141. $name = trim($name);
  142. return $name === '' ? 'file' : $name;
  143. }
  144. private function buildStoredFilename(string $originalName): string
  145. {
  146. $baseName = pathinfo($originalName, PATHINFO_FILENAME);
  147. $extension = pathinfo($originalName, PATHINFO_EXTENSION);
  148. $baseName = preg_replace('/[<>:|?*\/\\\\]+/u', '_', $baseName) ?? '';
  149. $baseName = preg_replace('/\s+/u', ' ', $baseName) ?? '';
  150. $baseName = trim($baseName, " .\t\n\r\0\x0B");
  151. if ($baseName === '' || $baseName === '.' || $baseName === '..') {
  152. $baseName = 'file';
  153. }
  154. if (function_exists('mb_substr')) {
  155. $baseName = mb_substr($baseName, 0, 150);
  156. } else {
  157. $baseName = substr($baseName, 0, 150);
  158. }
  159. $extension = preg_replace('/[^A-Za-z0-9]+/', '', $extension) ?? '';
  160. return $extension === '' ? $baseName : $baseName . '.' . $extension;
  161. }
  162. private function buildUniquePath(string $directory, string $filename): string
  163. {
  164. $directory = trim($directory, '/');
  165. $basePath = $directory === '' ? $filename : $directory . '/' . $filename;
  166. if (!Storage::disk('public')->exists($basePath)) {
  167. return $basePath;
  168. }
  169. $baseName = pathinfo($filename, PATHINFO_FILENAME);
  170. $extension = pathinfo($filename, PATHINFO_EXTENSION);
  171. for ($index = 1; $index < 10000; $index++) {
  172. $candidateName = $baseName . ' (' . $index . ')';
  173. if ($extension !== '') {
  174. $candidateName .= '.' . $extension;
  175. }
  176. $candidatePath = $directory === '' ? $candidateName : $directory . '/' . $candidateName;
  177. if (!Storage::disk('public')->exists($candidatePath)) {
  178. return $candidatePath;
  179. }
  180. }
  181. return $basePath;
  182. }
  183. /**
  184. * @param string $path
  185. * @param string $archiveName
  186. * @param int|null $userId
  187. * @return File
  188. * @throws Exception
  189. */
  190. public function createZipArchive(string $path, string $archiveName, ?int $userId = null): File
  191. {
  192. $fullPath = storage_path('app/public/' . $path);
  193. $archivePath = $this->archivePath($path, $archiveName);
  194. $archiveFullPath = Storage::disk('public')->path($archivePath);
  195. Storage::disk('public')->makeDirectory(dirname($archivePath));
  196. $zip = new ZipArchive();
  197. if ($zip->open($archiveFullPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
  198. throw new Exception('Cant create zip archive!');
  199. }
  200. $files = new RecursiveIteratorIterator(
  201. new RecursiveDirectoryIterator($fullPath, FilesystemIterator::SKIP_DOTS),
  202. RecursiveIteratorIterator::LEAVES_ONLY
  203. );
  204. foreach ($files as $file) {
  205. $filePath = $file->getRealPath();
  206. $relativePath = substr($filePath, strlen($fullPath) + 1);
  207. if (!$file->isDir()) {
  208. $zip->addFile($filePath, $relativePath);
  209. } else {
  210. $zip->addEmptyDir($relativePath);
  211. }
  212. }
  213. $zip->close();
  214. $fileModel = File::query()->updateOrCreate([
  215. 'link' => url('/storage/') . '/' . $archivePath,
  216. ], [
  217. 'path' => $archivePath,
  218. 'user_id' => $userId ?? auth()->user()->id,
  219. 'original_name' => $archiveName,
  220. 'mime_type' => 'application/zip',
  221. 'is_generated' => true,
  222. ]);
  223. return $fileModel;
  224. }
  225. private function archivePath(string $path, string $archiveName): string
  226. {
  227. $normalizedPath = trim($path, '/');
  228. if (Str::endsWith($normalizedPath, '/tmp')) {
  229. $normalizedPath = (string) Str::beforeLast($normalizedPath, '/tmp');
  230. }
  231. return $normalizedPath . '/' . $archiveName;
  232. }
  233. }