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\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.
|
||||
|
||||
Reference in New Issue
Block a user