feat(attachment): add cropping functionality for images and update metadata storage
This commit is contained in:
@@ -6,6 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -16,6 +19,9 @@ use Illuminate\Support\Str;
|
|||||||
'mime_type',
|
'mime_type',
|
||||||
'extension',
|
'extension',
|
||||||
'size',
|
'size',
|
||||||
|
'crop_horizontal_start_percent',
|
||||||
|
'crop_vertical_start_percent',
|
||||||
|
'cropped_attachment_id',
|
||||||
])]
|
])]
|
||||||
class Attachment extends Model
|
class Attachment extends Model
|
||||||
{
|
{
|
||||||
@@ -37,15 +43,28 @@ class Attachment extends Model
|
|||||||
return [
|
return [
|
||||||
'type' => AttachmentType::class,
|
'type' => AttachmentType::class,
|
||||||
'size' => 'integer',
|
'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.
|
* Get the pre-signed temporary S3 URL for this attachment.
|
||||||
*/
|
*/
|
||||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||||
{
|
{
|
||||||
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
|
return Storage::disk('s3')->temporaryUrl(
|
||||||
$this->path,
|
$this->path,
|
||||||
now()->addMinutes($expiresInMinutes)
|
now()->addMinutes($expiresInMinutes)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType;
|
|||||||
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\Mime\MimeTypes;
|
use Symfony\Component\Mime\MimeTypes;
|
||||||
@@ -13,6 +14,56 @@ use Throwable;
|
|||||||
|
|
||||||
class AttachmentService
|
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(
|
public function store(
|
||||||
UploadedFile|string $file,
|
UploadedFile|string $file,
|
||||||
string $path,
|
string $path,
|
||||||
@@ -107,6 +158,98 @@ class AttachmentService
|
|||||||
return trim($path, '/');
|
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
|
protected function resolveFilename(UploadedFile|string $file, string $key): string
|
||||||
{
|
{
|
||||||
if ($file instanceof UploadedFile) {
|
if ($file instanceof UploadedFile) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Propósito
|
## 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
|
## Componentes principales
|
||||||
|
|
||||||
@@ -25,5 +25,7 @@ No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog
|
|||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
- El directorio no puede quedar vacío después de normalizarlo.
|
- 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.
|
- La eliminación se considera fallida si S3 no confirma el borrado.
|
||||||
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.
|
- 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
|
public function test_it_deletes_from_s3_before_removing_the_database_record(): void
|
||||||
{
|
{
|
||||||
Storage::fake('s3');
|
Storage::fake('s3');
|
||||||
|
|||||||
Reference in New Issue
Block a user