diff --git a/app/Domains/Attachable/Models/Attachment.php b/app/Domains/Attachable/Models/Attachment.php index 82de3a5..22ae80a 100644 --- a/app/Domains/Attachable/Models/Attachment.php +++ b/app/Domains/Attachable/Models/Attachment.php @@ -6,6 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; #[Fillable([ @@ -16,6 +19,9 @@ use Illuminate\Support\Str; 'mime_type', 'extension', 'size', + 'crop_horizontal_start_percent', + 'crop_vertical_start_percent', + 'cropped_attachment_id', ])] class Attachment extends Model { @@ -37,15 +43,28 @@ class Attachment extends Model return [ 'type' => AttachmentType::class, 'size' => 'integer', + 'crop_horizontal_start_percent' => 'float', + 'crop_vertical_start_percent' => 'float', + 'cropped_attachment_id' => 'integer', ]; } + public function croppedAttachment(): BelongsTo + { + return $this->belongsTo(self::class, 'cropped_attachment_id'); + } + + public function originalAttachment(): HasOne + { + return $this->hasOne(self::class, 'cropped_attachment_id'); + } + /** * Get the pre-signed temporary S3 URL for this attachment. */ public function getTemporaryUrl(int $expiresInMinutes = 10): string { - return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl( + return Storage::disk('s3')->temporaryUrl( $this->path, now()->addMinutes($expiresInMinutes) ); diff --git a/app/Domains/Attachable/Services/AttachmentService.php b/app/Domains/Attachable/Services/AttachmentService.php index d3c473e..b3df0a4 100644 --- a/app/Domains/Attachable/Services/AttachmentService.php +++ b/app/Domains/Attachable/Services/AttachmentService.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Exceptions\AttachmentStorageException; use App\Domains\Attachable\Models\Attachment; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Symfony\Component\Mime\MimeTypes; @@ -13,6 +14,56 @@ use Throwable; class AttachmentService { + public function storeCroppedImage( + UploadedFile|string $image, + string $path, + float $horizontalCropPercentage, + float $verticalCropPercentage, + ): Attachment { + $this->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, @@ -107,6 +158,98 @@ class AttachmentService 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) { diff --git a/app/Domains/Attachable/documentacion/README.md b/app/Domains/Attachable/documentacion/README.md index fec8cbd..cc6d629 100644 --- a/app/Domains/Attachable/documentacion/README.md +++ b/app/Domains/Attachable/documentacion/README.md @@ -2,7 +2,7 @@ ## Propósito -Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. +Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con una versión recortada desde porcentajes horizontales y verticales. ## Componentes principales @@ -25,5 +25,7 @@ No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog ## Consideraciones - El directorio no puede quedar vacío después de normalizarlo. +- Los porcentajes de inicio del crop deben estar en el rango `[0, 100)`; el recorte se extiende desde ese punto hasta los bordes derecho e inferior. +- El attachment original guarda los porcentajes y la relación `croppedAttachment` con la versión procesada. - La eliminación se considera fallida si S3 no confirma el borrado. - Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos. diff --git a/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php b/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php new file mode 100644 index 0000000..e65105e --- /dev/null +++ b/database/migrations/2026_08_18_000000_add_crop_data_to_attachments_table.php @@ -0,0 +1,33 @@ +decimal('crop_horizontal_start_percent', 7, 4)->nullable()->after('size'); + $table->decimal('crop_vertical_start_percent', 7, 4)->nullable()->after('crop_horizontal_start_percent'); + $table->foreignId('cropped_attachment_id') + ->nullable() + ->after('crop_vertical_start_percent') + ->constrained('attachments') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('attachments', function (Blueprint $table): void { + $table->dropForeign(['cropped_attachment_id']); + $table->dropColumn([ + 'cropped_attachment_id', + 'crop_horizontal_start_percent', + 'crop_vertical_start_percent', + ]); + }); + } +}; diff --git a/tests/Feature/Attachable/AttachmentTest.php b/tests/Feature/Attachable/AttachmentTest.php index 10efecc..3bf69f8 100644 --- a/tests/Feature/Attachable/AttachmentTest.php +++ b/tests/Feature/Attachable/AttachmentTest.php @@ -70,6 +70,76 @@ class AttachmentTest extends TestCase } } + public function test_it_stores_an_original_image_and_its_crop(): void + { + Storage::fake('s3'); + + $original = app(AttachmentService::class)->storeCroppedImage( + UploadedFile::fake()->image('product.jpg', 200, 100), + 'attachments/acme', + 50, + 25, + ); + + $cropped = $original->croppedAttachment; + + $this->assertNotNull($cropped); + $this->assertSame(50.0, $original->crop_horizontal_start_percent); + $this->assertSame(25.0, $original->crop_vertical_start_percent); + $this->assertTrue($cropped->originalAttachment->is($original)); + $this->assertDatabaseCount('attachments', 2); + $this->assertDatabaseHas('attachments', [ + 'id' => $original->id, + 'cropped_attachment_id' => $cropped->id, + ]); + Storage::disk('s3')->assertExists($original->path); + Storage::disk('s3')->assertExists($cropped->path); + + $croppedSize = getimagesizefromstring(Storage::disk('s3')->get($cropped->path)); + + $this->assertIsArray($croppedSize); + $this->assertSame(100, $croppedSize[0]); + $this->assertSame(75, $croppedSize[1]); + } + + public function test_it_rejects_invalid_crop_percentages_without_storing_files(): void + { + Storage::fake('s3'); + + try { + app(AttachmentService::class)->storeCroppedImage( + UploadedFile::fake()->image('product.png'), + 'attachments/acme', + 100, + 0, + ); + + $this->fail('Expected an AttachmentStorageException to be thrown.'); + } catch (AttachmentStorageException) { + $this->assertDatabaseCount('attachments', 0); + $this->assertSame([], Storage::disk('s3')->allFiles()); + } + } + + public function test_it_rolls_back_the_original_when_the_file_is_not_a_valid_image(): void + { + Storage::fake('s3'); + + try { + app(AttachmentService::class)->storeCroppedImage( + UploadedFile::fake()->createWithContent('invalid.png', 'not-an-image'), + 'attachments/acme', + 10, + 10, + ); + + $this->fail('Expected an AttachmentStorageException to be thrown.'); + } catch (AttachmentStorageException) { + $this->assertDatabaseCount('attachments', 0); + $this->assertSame([], Storage::disk('s3')->allFiles()); + } + } + public function test_it_deletes_from_s3_before_removing_the_database_record(): void { Storage::fake('s3');