From 2ce21799c726c6ebd5b8281258a334a97d34ae44 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 25 Jun 2026 16:09:46 -0300 Subject: [PATCH] feat(attachments): enhance AttachmentService to support base64 file uploads and refactor file handling logic --- .../Attachable/Services/AttachmentService.php | 173 +++++++++++++++--- .../Controllers/S3TestController.php | 2 +- .../Requests/StoreS3TestFileRequest.php | 3 +- 3 files changed, 149 insertions(+), 29 deletions(-) diff --git a/app/Domains/Attachable/Services/AttachmentService.php b/app/Domains/Attachable/Services/AttachmentService.php index e9a14b7..0cd3cbe 100644 --- a/app/Domains/Attachable/Services/AttachmentService.php +++ b/app/Domains/Attachable/Services/AttachmentService.php @@ -8,31 +8,27 @@ use App\Domains\Attachable\Models\Attachment; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Symfony\Component\Mime\MimeTypes; use Throwable; class AttachmentService { public function store( - UploadedFile $file, + UploadedFile|string $file, string $path, ): Attachment { - $normalizedPath = $this->normalizePath($path); + $normalizedPath = $this->normalizeDirectory($path); if ($normalizedPath === '') { throw new AttachmentStorageException('The attachment path cannot be empty.'); } - $filename = basename($normalizedPath); - - if ($filename === '' || $filename === '.' || $filename === DIRECTORY_SEPARATOR) { - throw new AttachmentStorageException('The attachment filename cannot be empty.'); - } - $key = (string) Str::uuid(); - $storedPath = Storage::disk('s3')->putFileAs( - $this->directoryFromPath($normalizedPath), - $file, - $this->buildStoredFilename($key, $file), + $fileData = $this->resolveFileData($file, $key); + $storedPath = $this->storeFile( + $fileData, + $normalizedPath, + $this->buildStoredFilename($key, $fileData['extension']), ); if (! is_string($storedPath) || $storedPath === '') { @@ -44,11 +40,11 @@ class AttachmentService $attachment = Attachment::query()->create([ 'key' => $key, 'path' => $storedPath, - 'filename' => $filename, - 'type' => $this->resolveAttachmentType($file), - 'mime_type' => $file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream', - 'extension' => $file->extension(), - 'size' => $file->getSize() ?? 0, + 'filename' => $this->resolveFilename($file, $key), + 'type' => $this->resolveAttachmentType($fileData['mime_type']), + 'mime_type' => $fileData['mime_type'], + 'extension' => $fileData['extension'], + 'size' => $fileData['size'], ]); return $attachment; @@ -70,25 +66,150 @@ class AttachmentService $attachment->delete(); } - protected function normalizePath(string $path): string + protected function normalizeDirectory(string $path): string { return trim($path, '/'); } - protected function directoryFromPath(string $path): string + protected function resolveFilename(UploadedFile|string $file, string $key): string { - $directory = dirname($path); + if ($file instanceof UploadedFile) { + $originalName = trim($file->getClientOriginalName()); - if ($directory === '.' || $directory === DIRECTORY_SEPARATOR) { + if ($originalName !== '') { + return $originalName; + } + } + + return $key; + } + + /** + * @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} + */ + protected function resolveFileData(UploadedFile|string $file, string $filename): array + { + if ($file instanceof UploadedFile) { + return $this->fileDataFromUploadedFile($file); + } + + return $this->fileDataFromBase64($file, $filename); + } + + /** + * @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int} + */ + protected function fileDataFromUploadedFile(UploadedFile $file): array + { + $mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream'); + + return [ + 'contents' => null, + 'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''), + 'file' => $file, + 'mime_type' => $mimeType, + 'size' => $file->getSize() ?? 0, + ]; + } + + /** + * @return array{contents: string, extension: string, file: null, mime_type: string, size: int} + */ + protected function fileDataFromBase64(string $file, string $filename): array + { + ['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file); + $mimeType = $this->detectMimeType($contents, $declaredMimeType); + + return [ + 'contents' => $contents, + 'extension' => $this->resolveExtension($filename, $mimeType), + 'file' => null, + 'mime_type' => $mimeType, + 'size' => strlen($contents), + ]; + } + + /** + * @return array{data: string, mime_type: string|null} + */ + protected function decodeBase64File(string $file): array + { + $payload = trim($file); + + if ($payload === '') { + throw new AttachmentStorageException('The base64 attachment content cannot be empty.'); + } + + $declaredMimeType = null; + + if (preg_match('/^data:(?[-\w.+\/]+);base64,(?.+)$/s', $payload, $matches) === 1) { + $declaredMimeType = strtolower($matches['mime']); + $payload = $matches['data']; + } + + $decoded = base64_decode(preg_replace('/\s+/', '', $payload), true); + + if ($decoded === false || $decoded === '') { + throw new AttachmentStorageException('The attachment base64 payload is invalid.'); + } + + return [ + 'data' => $decoded, + 'mime_type' => $declaredMimeType, + ]; + } + + protected function detectMimeType(string $contents, ?string $fallback = null): string + { + if (is_string($fallback) && $fallback !== '') { + return strtolower($fallback); + } + + $detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents); + + if (is_string($detectedMimeType) && $detectedMimeType !== '') { + return strtolower($detectedMimeType); + } + + return strtolower($fallback ?? 'application/octet-stream'); + } + + protected function resolveExtension(string $filename, string $mimeType): string + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if ($extension !== '') { + return $extension; + } + + return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? ''); + } + + /** + * @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData + */ + protected function storeFile(array $fileData, string $directory, string $storedFilename): string + { + if ($fileData['file'] instanceof UploadedFile) { + return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename); + } + + $storedPath = $directory !== '' + ? $directory.'/'.$storedFilename + : $storedFilename; + + $stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? ''); + + if (! $stored) { return ''; } - return trim($directory, '/'); + return $storedPath; } - protected function resolveAttachmentType(UploadedFile $file): AttachmentType + protected function resolveAttachmentType(string $mimeType): AttachmentType { - $mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? ''); + $mimeType = strtolower($mimeType); if (str_starts_with($mimeType, 'image/')) { return AttachmentType::Image; @@ -121,10 +242,8 @@ class AttachmentService return AttachmentType::Other; } - protected function buildStoredFilename(string $key, UploadedFile $file): string + protected function buildStoredFilename(string $key, string $extension): string { - $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''); - if ($extension === '') { return $key; } diff --git a/app/Domains/StorageTest/Controllers/S3TestController.php b/app/Domains/StorageTest/Controllers/S3TestController.php index ab8f0a9..c645947 100644 --- a/app/Domains/StorageTest/Controllers/S3TestController.php +++ b/app/Domains/StorageTest/Controllers/S3TestController.php @@ -20,7 +20,7 @@ class S3TestController extends Controller public function store(StoreS3TestFileRequest $request): JsonResponse { $attachment = $this->attachmentService->store( - $request->file('file'), + $request->file('file') ?? (string) $request->validated('file_base64'), $request->validated('path'), ); diff --git a/app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php b/app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php index bb2df89..d46ea27 100644 --- a/app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php +++ b/app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php @@ -17,7 +17,8 @@ class StoreS3TestFileRequest extends FormRequest public function rules(): array { return [ - 'file' => ['required', 'file', 'max:10240'], + 'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'], + 'file_base64' => ['nullable', 'string', 'required_without:file'], 'path' => ['required', 'string', 'max:2048'], 'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'], ];