- Introduced AttachmentCrop model to manage crop variants for attachments. - Updated Attachment model to remove direct crop fields and establish relationships with AttachmentCrop. - Modified AttachmentService to handle storing and updating multiple crop variants (desktop and mobile). - Adjusted validation rules to accommodate new crop structure. - Updated database migration to create attachment_crops table and migrate existing crop data. - Refactored tests to ensure compatibility with the new cropping structure and validate multiple crop variants. - Enhanced documentation to reflect changes in attachment handling and cropping capabilities.
575 lines
19 KiB
PHP
575 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Domains\Attachable\Services;
|
|
|
|
use App\Domains\Attachable\Enums\AttachmentType;
|
|
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
|
|
use App\Domains\Attachable\Models\Attachment;
|
|
use App\Domains\Attachable\Models\AttachmentCrop;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Mime\MimeTypes;
|
|
use Throwable;
|
|
|
|
class AttachmentService
|
|
{
|
|
/**
|
|
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
|
|
*/
|
|
public function storeCroppedImageVariants(
|
|
UploadedFile|string $image,
|
|
string $path,
|
|
array $crops,
|
|
): Attachment {
|
|
$crops = $this->validateCropVariants($crops);
|
|
$storedPaths = [];
|
|
|
|
try {
|
|
return DB::transaction(function () use (
|
|
$image,
|
|
$path,
|
|
$crops,
|
|
&$storedPaths,
|
|
): Attachment {
|
|
$original = $this->store($image, $path);
|
|
$storedPaths[] = $original->path;
|
|
$contents = $this->imageContents($image);
|
|
|
|
foreach ($crops as $variant => $crop) {
|
|
$cropped = $this->storeCropVariant($contents, $path, $crop);
|
|
$storedPaths[] = $cropped->path;
|
|
$original->cropVariants()->create([
|
|
'variant' => $variant,
|
|
'crop_horizontal' => $crop['crop_horizontal'],
|
|
'crop_vertical' => $crop['crop_vertical'],
|
|
'cropped_attachment_id' => $cropped->id,
|
|
]);
|
|
}
|
|
|
|
return $original->load('cropVariants.croppedAttachment');
|
|
});
|
|
} catch (Throwable $throwable) {
|
|
if ($storedPaths !== []) {
|
|
Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
|
|
}
|
|
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
|
|
*/
|
|
public function updateImageCropVariants(
|
|
Attachment $original,
|
|
array $crops,
|
|
): Attachment {
|
|
if ($original->type !== AttachmentType::Image) {
|
|
throw new AttachmentStorageException('Only image attachments can be cropped.');
|
|
}
|
|
|
|
$crops = $this->validateCropVariants($crops);
|
|
$contents = Storage::disk('s3')->get($original->path);
|
|
|
|
if (! is_string($contents) || $contents === '') {
|
|
throw new AttachmentStorageException('The original image could not be read from the s3 disk.');
|
|
}
|
|
|
|
$directory = trim(str_replace('\\', '/', dirname($original->path)), './');
|
|
$directory = $directory !== '' ? $directory : 'attachments';
|
|
$previousCrops = $original->cropVariants()->with('croppedAttachment')->get();
|
|
$storedCrops = [];
|
|
|
|
try {
|
|
foreach ($crops as $variant => $crop) {
|
|
$storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop);
|
|
}
|
|
|
|
DB::transaction(function () use ($original, $crops, $storedCrops): void {
|
|
foreach ($crops as $variant => $crop) {
|
|
$original->cropVariants()->updateOrCreate(
|
|
['variant' => $variant],
|
|
[
|
|
'crop_horizontal' => $crop['crop_horizontal'],
|
|
'crop_vertical' => $crop['crop_vertical'],
|
|
'cropped_attachment_id' => $storedCrops[$variant]->id,
|
|
]
|
|
);
|
|
}
|
|
});
|
|
} catch (Throwable $throwable) {
|
|
foreach ($storedCrops as $storedCrop) {
|
|
$this->delete($storedCrop);
|
|
}
|
|
|
|
throw $throwable;
|
|
}
|
|
|
|
foreach ($previousCrops as $previousCrop) {
|
|
if ($previousCrop->croppedAttachment !== null) {
|
|
$this->delete($previousCrop->croppedAttachment);
|
|
}
|
|
}
|
|
|
|
return $original->refresh()->load('cropVariants.croppedAttachment');
|
|
}
|
|
|
|
public function store(
|
|
UploadedFile|string $file,
|
|
string $path,
|
|
): Attachment {
|
|
$normalizedPath = $this->normalizeDirectory($path);
|
|
|
|
if ($normalizedPath === '') {
|
|
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
|
}
|
|
|
|
$key = (string) Str::uuid();
|
|
$fileData = $this->resolveFileData($file, $key);
|
|
$storedPath = $this->storeFile(
|
|
$fileData,
|
|
$normalizedPath,
|
|
$this->buildStoredFilename($key, $fileData['extension']),
|
|
);
|
|
|
|
if (! is_string($storedPath) || $storedPath === '') {
|
|
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
|
|
}
|
|
|
|
try {
|
|
/** @var Attachment $attachment */
|
|
$attachment = Attachment::query()->create([
|
|
'key' => $key,
|
|
'path' => $storedPath,
|
|
'filename' => $this->resolveFilename($file, $key),
|
|
'type' => $this->resolveAttachmentType($fileData['mime_type']),
|
|
'mime_type' => $fileData['mime_type'],
|
|
'extension' => $fileData['extension'],
|
|
'size' => $fileData['size'],
|
|
]);
|
|
|
|
return $attachment;
|
|
} catch (Throwable $throwable) {
|
|
Storage::disk('s3')->delete($storedPath);
|
|
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
public function delete(Attachment $attachment): void
|
|
{
|
|
$croppedAttachments = $attachment->cropVariants()
|
|
->with('croppedAttachment')
|
|
->get()
|
|
->pluck('croppedAttachment')
|
|
->filter();
|
|
$paths = $croppedAttachments
|
|
->pluck('path')
|
|
->prepend($attachment->path)
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
$deleted = Storage::disk('s3')->delete(
|
|
count($paths) === 1 ? $paths[0] : $paths
|
|
);
|
|
|
|
if (! $deleted) {
|
|
throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.');
|
|
}
|
|
|
|
DB::transaction(function () use ($attachment, $croppedAttachments): void {
|
|
$attachment->delete();
|
|
Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete();
|
|
});
|
|
}
|
|
|
|
public function copy(Attachment $source, string $path): Attachment
|
|
{
|
|
$normalizedPath = $this->normalizeDirectory($path);
|
|
|
|
if ($normalizedPath === '') {
|
|
throw new AttachmentStorageException('The attachment path cannot be empty.');
|
|
}
|
|
|
|
$key = (string) Str::uuid();
|
|
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
|
|
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
|
|
|
|
if (! $copied) {
|
|
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
|
|
}
|
|
|
|
try {
|
|
/** @var Attachment $attachment */
|
|
$attachment = Attachment::query()->create([
|
|
'key' => $key,
|
|
'path' => $storedPath,
|
|
'filename' => $source->filename,
|
|
'type' => $source->type,
|
|
'mime_type' => $source->mime_type,
|
|
'extension' => $source->extension,
|
|
'size' => $source->size,
|
|
]);
|
|
|
|
return $attachment;
|
|
} catch (Throwable $throwable) {
|
|
Storage::disk('s3')->delete($storedPath);
|
|
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
protected function normalizeDirectory(string $path): string
|
|
{
|
|
return trim($path, '/');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{crop_horizontal?: mixed, crop_vertical?: mixed}> $crops
|
|
* @return array<string, array{crop_horizontal: array{start_percentage: float, end_percentage: float}, crop_vertical: array{start_percentage: float, end_percentage: float}}>
|
|
*/
|
|
protected function validateCropVariants(array $crops): array
|
|
{
|
|
$validated = [];
|
|
|
|
foreach (AttachmentCrop::VARIANTS as $variant) {
|
|
$crop = $crops[$variant] ?? null;
|
|
|
|
if (! is_array($crop)) {
|
|
throw new AttachmentStorageException("The {$variant} crop is required.");
|
|
}
|
|
|
|
$horizontal = $crop['crop_horizontal'] ?? null;
|
|
$vertical = $crop['crop_vertical'] ?? null;
|
|
|
|
if (! is_array($horizontal) || ! is_array($vertical)) {
|
|
throw new AttachmentStorageException(
|
|
"The {$variant} crop must contain crop_horizontal and crop_vertical ranges."
|
|
);
|
|
}
|
|
|
|
$validated[$variant] = [
|
|
'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"),
|
|
'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"),
|
|
];
|
|
}
|
|
|
|
return $validated;
|
|
}
|
|
|
|
/**
|
|
* @param array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>} $crop
|
|
*/
|
|
protected function storeCropVariant(string $contents, string $path, array $crop): Attachment
|
|
{
|
|
$croppedContents = $this->cropImage(
|
|
$contents,
|
|
$crop['crop_horizontal'],
|
|
$crop['crop_vertical'],
|
|
);
|
|
|
|
return $this->store(
|
|
'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
|
|
$path,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array{start_percentage?: mixed, end_percentage?: mixed} $range
|
|
* @return array{start_percentage: float, end_percentage: float}
|
|
*/
|
|
protected function validateCropRange(array $range, string $axis): array
|
|
{
|
|
$start = $range['start_percentage'] ?? null;
|
|
$end = $range['end_percentage'] ?? null;
|
|
|
|
if (! is_numeric($start) || ! is_numeric($end)) {
|
|
throw new AttachmentStorageException(
|
|
"The {$axis} crop range must contain numeric start_percentage and end_percentage values."
|
|
);
|
|
}
|
|
|
|
$start = (float) $start;
|
|
$end = (float) $end;
|
|
|
|
if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) {
|
|
throw new AttachmentStorageException(
|
|
"The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100."
|
|
);
|
|
}
|
|
|
|
return [
|
|
'start_percentage' => $start,
|
|
'end_percentage' => $end,
|
|
];
|
|
}
|
|
|
|
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,
|
|
array $cropHorizontal,
|
|
array $cropVertical,
|
|
): 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 * $cropHorizontal['start_percentage'] / 100));
|
|
$right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100));
|
|
$y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100));
|
|
$bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100));
|
|
$cropped = imagecrop($source, [
|
|
'x' => $x,
|
|
'y' => $y,
|
|
'width' => $right - $x,
|
|
'height' => $bottom - $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) {
|
|
$originalName = trim($file->getClientOriginalName());
|
|
|
|
if ($originalName !== '') {
|
|
return $originalName;
|
|
}
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
|
|
*/
|
|
protected function resolveFileData(UploadedFile|string $file, string $filename): array
|
|
{
|
|
if ($file instanceof UploadedFile) {
|
|
return $this->fileDataFromUploadedFile($file);
|
|
}
|
|
|
|
return $this->fileDataFromBase64($file, $filename);
|
|
}
|
|
|
|
/**
|
|
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
|
|
*/
|
|
protected function fileDataFromUploadedFile(UploadedFile $file): array
|
|
{
|
|
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
|
|
|
|
return [
|
|
'contents' => null,
|
|
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
|
|
'file' => $file,
|
|
'mime_type' => $mimeType,
|
|
'size' => $file->getSize() ?? 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
|
|
*/
|
|
protected function fileDataFromBase64(string $file, string $filename): array
|
|
{
|
|
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
|
|
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
|
|
|
|
return [
|
|
'contents' => $contents,
|
|
'extension' => $this->resolveExtension($filename, $mimeType),
|
|
'file' => null,
|
|
'mime_type' => $mimeType,
|
|
'size' => strlen($contents),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{data: string, mime_type: string|null}
|
|
*/
|
|
protected function decodeBase64File(string $file): array
|
|
{
|
|
$payload = trim($file);
|
|
|
|
if ($payload === '') {
|
|
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
|
|
}
|
|
|
|
$declaredMimeType = null;
|
|
|
|
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
|
|
$declaredMimeType = strtolower($matches['mime']);
|
|
$payload = $matches['data'];
|
|
}
|
|
|
|
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
|
|
|
|
if ($decoded === false || $decoded === '') {
|
|
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
|
|
}
|
|
|
|
return [
|
|
'data' => $decoded,
|
|
'mime_type' => $declaredMimeType,
|
|
];
|
|
}
|
|
|
|
protected function detectMimeType(string $contents, ?string $fallback = null): string
|
|
{
|
|
if (is_string($fallback) && $fallback !== '') {
|
|
return strtolower($fallback);
|
|
}
|
|
|
|
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
|
|
|
|
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
|
|
return strtolower($detectedMimeType);
|
|
}
|
|
|
|
return strtolower($fallback ?? 'application/octet-stream');
|
|
}
|
|
|
|
protected function resolveExtension(string $filename, string $mimeType): string
|
|
{
|
|
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
|
|
if ($extension !== '') {
|
|
return $extension;
|
|
}
|
|
|
|
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
|
|
}
|
|
|
|
/**
|
|
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
|
|
*/
|
|
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
|
|
{
|
|
if ($fileData['file'] instanceof UploadedFile) {
|
|
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
|
|
}
|
|
|
|
$storedPath = $directory !== ''
|
|
? $directory.'/'.$storedFilename
|
|
: $storedFilename;
|
|
|
|
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
|
|
|
|
if (! $stored) {
|
|
return '';
|
|
}
|
|
|
|
return $storedPath;
|
|
}
|
|
|
|
protected function resolveAttachmentType(string $mimeType): AttachmentType
|
|
{
|
|
$mimeType = strtolower($mimeType);
|
|
|
|
if (str_starts_with($mimeType, 'image/')) {
|
|
return AttachmentType::Image;
|
|
}
|
|
|
|
if (str_starts_with($mimeType, 'video/')) {
|
|
return AttachmentType::Video;
|
|
}
|
|
|
|
if ($mimeType === 'application/pdf') {
|
|
return AttachmentType::Pdf;
|
|
}
|
|
|
|
if (str_starts_with($mimeType, 'audio/')) {
|
|
return AttachmentType::Audio;
|
|
}
|
|
|
|
if (
|
|
str_starts_with($mimeType, 'text/')
|
|
|| str_contains($mimeType, 'document')
|
|
|| str_contains($mimeType, 'word')
|
|
|| str_contains($mimeType, 'excel')
|
|
|| str_contains($mimeType, 'spreadsheet')
|
|
|| str_contains($mimeType, 'presentation')
|
|
|| str_contains($mimeType, 'officedocument')
|
|
) {
|
|
return AttachmentType::Document;
|
|
}
|
|
|
|
return AttachmentType::Other;
|
|
}
|
|
|
|
protected function buildStoredFilename(string $key, string $extension): string
|
|
{
|
|
if ($extension === '') {
|
|
return $key;
|
|
}
|
|
|
|
return $key.'.'.$extension;
|
|
}
|
|
}
|