validateCropPercentage($horizontalCropPercentage, 'horizontal'); $this->validateCropPercentage($verticalCropPercentage, 'vertical'); $storedPaths = []; try { return DB::transaction(function () use ( $image, $path, $horizontalCropPercentage, $verticalCropPercentage, &$storedPaths, ): Attachment { $original = $this->store($image, $path); $storedPaths[] = $original->path; $croppedContents = $this->cropImage( $this->imageContents($image), $horizontalCropPercentage, $verticalCropPercentage, ); $cropped = $this->store( 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), $path, ); $storedPaths[] = $cropped->path; $original->update([ 'crop_horizontal_start_percent' => $horizontalCropPercentage, 'crop_vertical_start_percent' => $verticalCropPercentage, 'cropped_attachment_id' => $cropped->id, ]); return $original->load('croppedAttachment'); }); } catch (Throwable $throwable) { if ($storedPaths !== []) { Storage::disk('s3')->delete(array_values(array_unique($storedPaths))); } throw $throwable; } } public function store( UploadedFile|string $file, string $path, ): Attachment { $normalizedPath = $this->normalizeDirectory($path); if ($normalizedPath === '') { throw new AttachmentStorageException('The attachment path cannot be empty.'); } $key = (string) Str::uuid(); $fileData = $this->resolveFileData($file, $key); $storedPath = $this->storeFile( $fileData, $normalizedPath, $this->buildStoredFilename($key, $fileData['extension']), ); if (! is_string($storedPath) || $storedPath === '') { throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.'); } try { /** @var Attachment $attachment */ $attachment = Attachment::query()->create([ 'key' => $key, 'path' => $storedPath, 'filename' => $this->resolveFilename($file, $key), 'type' => $this->resolveAttachmentType($fileData['mime_type']), 'mime_type' => $fileData['mime_type'], 'extension' => $fileData['extension'], 'size' => $fileData['size'], ]); return $attachment; } catch (Throwable $throwable) { Storage::disk('s3')->delete($storedPath); throw $throwable; } } public function delete(Attachment $attachment): void { $croppedAttachment = $attachment->croppedAttachment; $paths = array_values(array_filter([ $attachment->path, $croppedAttachment?->path, ])); $deleted = Storage::disk('s3')->delete( count($paths) === 1 ? $paths[0] : $paths ); if (! $deleted) { throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.'); } DB::transaction(function () use ($attachment, $croppedAttachment): void { $attachment->delete(); $croppedAttachment?->delete(); }); } public function copy(Attachment $source, string $path): Attachment { $normalizedPath = $this->normalizeDirectory($path); if ($normalizedPath === '') { throw new AttachmentStorageException('The attachment path cannot be empty.'); } $key = (string) Str::uuid(); $storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension); $copied = Storage::disk('s3')->copy($source->path, $storedPath); if (! $copied) { throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.'); } try { /** @var Attachment $attachment */ $attachment = Attachment::query()->create([ 'key' => $key, 'path' => $storedPath, 'filename' => $source->filename, 'type' => $source->type, 'mime_type' => $source->mime_type, 'extension' => $source->extension, 'size' => $source->size, ]); return $attachment; } catch (Throwable $throwable) { Storage::disk('s3')->delete($storedPath); throw $throwable; } } protected function normalizeDirectory(string $path): string { return trim($path, '/'); } protected function validateCropPercentage(float $percentage, string $axis): void { if (! is_finite($percentage) || $percentage < 0 || $percentage >= 100) { throw new AttachmentStorageException( "The {$axis} crop percentage must be greater than or equal to 0 and less than 100." ); } } protected function imageContents(UploadedFile|string $image): string { if (is_string($image)) { return $this->decodeBase64File($image)['data']; } $contents = file_get_contents($image->getRealPath()); if (! is_string($contents) || $contents === '') { throw new AttachmentStorageException('The image content cannot be empty.'); } return $contents; } /** * @return array{contents: string, mime_type: string} */ protected function cropImage( string $contents, float $horizontalCropPercentage, float $verticalCropPercentage, ): array { $source = @imagecreatefromstring($contents); if ($source === false) { throw new AttachmentStorageException('The attachment must contain a valid image.'); } $width = imagesx($source); $height = imagesy($source); $x = min($width - 1, (int) floor($width * $horizontalCropPercentage / 100)); $y = min($height - 1, (int) floor($height * $verticalCropPercentage / 100)); $cropped = imagecrop($source, [ 'x' => $x, 'y' => $y, 'width' => $width - $x, 'height' => $height - $y, ]); if ($cropped === false) { throw new AttachmentStorageException('The image could not be cropped.'); } return $this->encodeImage($cropped, $contents); } /** * @return array{contents: string, mime_type: string} */ protected function encodeImage(\GdImage $image, string $originalContents): array { $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents); $mimeType = is_string($mimeType) ? strtolower($mimeType) : ''; ob_start(); try { $encoded = match ($mimeType) { 'image/jpeg' => imagejpeg($image, null, 90), 'image/gif' => imagegif($image), 'image/webp' => imagewebp($image, null, 90), 'image/avif' => function_exists('imageavif') && imageavif($image, null, 90), default => imagepng($image), }; $output = ob_get_contents(); } finally { ob_end_clean(); } if (! $encoded || ! is_string($output) || $output === '') { throw new AttachmentStorageException('The cropped image could not be encoded.'); } return [ 'contents' => $output, 'mime_type' => match ($mimeType) { 'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType, default => 'image/png', }, ]; } protected function resolveFilename(UploadedFile|string $file, string $key): string { if ($file instanceof UploadedFile) { $originalName = trim($file->getClientOriginalName()); 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 $storedPath; } protected function resolveAttachmentType(string $mimeType): AttachmentType { $mimeType = strtolower($mimeType); if (str_starts_with($mimeType, 'image/')) { return AttachmentType::Image; } if (str_starts_with($mimeType, 'video/')) { return AttachmentType::Video; } if ($mimeType === 'application/pdf') { return AttachmentType::Pdf; } if (str_starts_with($mimeType, 'audio/')) { return AttachmentType::Audio; } if ( str_starts_with($mimeType, 'text/') || str_contains($mimeType, 'document') || str_contains($mimeType, 'word') || str_contains($mimeType, 'excel') || str_contains($mimeType, 'spreadsheet') || str_contains($mimeType, 'presentation') || str_contains($mimeType, 'officedocument') ) { return AttachmentType::Document; } return AttachmentType::Other; } protected function buildStoredFilename(string $key, string $extension): string { if ($extension === '') { return $key; } return $key.'.'.$extension; } }