feat(attachment): add cropping functionality for images and update metadata storage
This commit is contained in:
parent
1c2f6c4127
commit
460ed528cf
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue