feat(attachment): add cropping functionality for images and update metadata storage

This commit is contained in:
ncoronel 2026-08-18 12:04:39 -03:00
parent 1c2f6c4127
commit 460ed528cf
5 changed files with 269 additions and 2 deletions

View File

@ -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)
);

View File

@ -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) {

View File

@ -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.

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('attachments', function (Blueprint $table): void {
$table->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',
]);
});
}
};

View File

@ -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');