, crop_vertical: array}> $crops */ public function storeCroppedImageVariants( UploadedFile|string $image, string $path, array $crops, ): Attachment { $crops = $this->validateCropVariants($crops); $storedPaths = []; try { return DB::transaction(function () use ( $image, $path, $crops, &$storedPaths, ): Attachment { $original = $this->store($image, $path); $storedPaths[] = $original->path; $contents = $this->imageContents($image); foreach ($crops as $variant => $crop) { $cropped = $this->storeCropVariant($contents, $path, $crop); $storedPaths[] = $cropped->path; $original->cropVariants()->create([ 'variant' => $variant, 'crop_horizontal' => $crop['crop_horizontal'], 'crop_vertical' => $crop['crop_vertical'], 'cropped_attachment_id' => $cropped->id, ]); } return $original->load('cropVariants.croppedAttachment'); }); } catch (Throwable $throwable) { if ($storedPaths !== []) { Storage::disk('s3')->delete(array_values(array_unique($storedPaths))); } throw $throwable; } } /** * @param array, crop_vertical: array}> $crops */ public function updateImageCropVariants( Attachment $original, array $crops, ): Attachment { if ($original->type !== AttachmentType::Image) { throw new AttachmentStorageException('Only image attachments can be cropped.'); } $crops = $this->validateCropVariants($crops); $contents = Storage::disk('s3')->get($original->path); if (! is_string($contents) || $contents === '') { throw new AttachmentStorageException('The original image could not be read from the s3 disk.'); } $directory = trim(str_replace('\\', '/', dirname($original->path)), './'); $directory = $directory !== '' ? $directory : 'attachments'; $previousCrops = $original->cropVariants()->with('croppedAttachment')->get(); $storedCrops = []; try { foreach ($crops as $variant => $crop) { $storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop); } DB::transaction(function () use ($original, $crops, $storedCrops): void { foreach ($crops as $variant => $crop) { $original->cropVariants()->updateOrCreate( ['variant' => $variant], [ 'crop_horizontal' => $crop['crop_horizontal'], 'crop_vertical' => $crop['crop_vertical'], 'cropped_attachment_id' => $storedCrops[$variant]->id, ] ); } }); } catch (Throwable $throwable) { foreach ($storedCrops as $storedCrop) { $this->delete($storedCrop); } throw $throwable; } foreach ($previousCrops as $previousCrop) { if ($previousCrop->croppedAttachment !== null) { $this->delete($previousCrop->croppedAttachment); } } return $original->refresh()->load('cropVariants.croppedAttachment'); } 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 { $croppedAttachments = $attachment->cropVariants() ->with('croppedAttachment') ->get() ->pluck('croppedAttachment') ->filter(); $paths = $croppedAttachments ->pluck('path') ->prepend($attachment->path) ->filter() ->unique() ->values() ->all(); $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, $croppedAttachments): void { $attachment->delete(); Attachment::query()->whereKey($croppedAttachments->pluck('id'))->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, '/'); } /** * @param array $crops * @return array */ protected function validateCropVariants(array $crops): array { $validated = []; foreach (AttachmentCrop::VARIANTS as $variant) { $crop = $crops[$variant] ?? null; if (! is_array($crop)) { throw new AttachmentStorageException("The {$variant} crop is required."); } $horizontal = $crop['crop_horizontal'] ?? null; $vertical = $crop['crop_vertical'] ?? null; if (! is_array($horizontal) || ! is_array($vertical)) { throw new AttachmentStorageException( "The {$variant} crop must contain crop_horizontal and crop_vertical ranges." ); } $validated[$variant] = [ 'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"), 'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"), ]; } return $validated; } /** * @param array{crop_horizontal: array, crop_vertical: array} $crop */ protected function storeCropVariant(string $contents, string $path, array $crop): Attachment { $croppedContents = $this->cropImage( $contents, $crop['crop_horizontal'], $crop['crop_vertical'], ); return $this->store( 'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']), $path, ); } /** * @param array{start_percentage?: mixed, end_percentage?: mixed} $range * @return array{start_percentage: float, end_percentage: float} */ protected function validateCropRange(array $range, string $axis): array { $start = $range['start_percentage'] ?? null; $end = $range['end_percentage'] ?? null; if (! is_numeric($start) || ! is_numeric($end)) { throw new AttachmentStorageException( "The {$axis} crop range must contain numeric start_percentage and end_percentage values." ); } $start = (float) $start; $end = (float) $end; if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) { throw new AttachmentStorageException( "The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100." ); } return [ 'start_percentage' => $start, 'end_percentage' => $end, ]; } 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, array $cropHorizontal, array $cropVertical, ): 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 * $cropHorizontal['start_percentage'] / 100)); $right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100)); $y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100)); $bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100)); $cropped = imagecrop($source, [ 'x' => $x, 'y' => $y, 'width' => $right - $x, 'height' => $bottom - $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; } }