325 lines
11 KiB
PHP
325 lines
11 KiB
PHP
<?php
|
|
namespace App\Services\Support;
|
|
|
|
use App\Models\TicketAttachment;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
|
|
class TicketAttachmentUploadService
|
|
{
|
|
/**
|
|
* @param UploadedFile|TemporaryUploadedFile $file
|
|
* @return array{path:string,mime:string,size:int,original_name:string}
|
|
*/
|
|
public function store(object $file, string $directory, array $options = []): array
|
|
{
|
|
$originalName = method_exists($file, 'getClientOriginalName')
|
|
? (string) $file->getClientOriginalName()
|
|
: 'file';
|
|
|
|
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
|
$storedBasename = trim((string) ($options['stored_basename'] ?? ''));
|
|
$displayName = trim((string) ($options['display_name'] ?? ''));
|
|
|
|
if ($storedBasename !== '') {
|
|
$sanitizedBase = trim((string) Str::of(pathinfo($storedBasename, PATHINFO_FILENAME))->slug('-'), '-');
|
|
$sanitizedBase = $sanitizedBase !== '' ? $sanitizedBase : 'file';
|
|
$storedName = $sanitizedBase . ($extension !== '' ? '.' . $extension : '');
|
|
$path = $file->storeAs($directory, $storedName, 'public');
|
|
} else {
|
|
$path = $file->store($directory, 'public');
|
|
$storedName = basename($path);
|
|
}
|
|
|
|
if ($displayName === '') {
|
|
$displayName = $originalName !== '' ? $originalName : ($storedName !== '' ? $storedName : 'file');
|
|
}
|
|
|
|
$sourceMetadata = $this->extractImageMetadataFromFileObject($file);
|
|
|
|
$mime = TicketAttachment::normalizeMimeType(
|
|
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
|
|
$originalName,
|
|
$path,
|
|
);
|
|
|
|
$metadata = [];
|
|
|
|
if (str_starts_with($mime, 'image/')) {
|
|
$metadata = $this->mergeImageMetadata(
|
|
$this->extractImageMetadata($path),
|
|
$sourceMetadata,
|
|
);
|
|
if ($this->shouldOptimizeImage($metadata, $options)) {
|
|
$this->optimizeImage($path, $mime, $metadata);
|
|
}
|
|
$mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path);
|
|
}
|
|
|
|
$size = 0;
|
|
try {
|
|
$size = (int) Storage::disk('public')->size($path);
|
|
} catch (\Throwable) {
|
|
$size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0);
|
|
}
|
|
|
|
return [
|
|
'path' => $path,
|
|
'mime' => $mime,
|
|
'size' => $size,
|
|
'original_name' => $displayName,
|
|
'source_original_name' => $originalName,
|
|
'stored_name' => $storedName,
|
|
'metadata' => $metadata,
|
|
];
|
|
}
|
|
|
|
private function shouldOptimizeImage(array $metadata, array $options = []): bool
|
|
{
|
|
if (array_key_exists('optimize_image', $options)) {
|
|
return (bool) $options['optimize_image'];
|
|
}
|
|
|
|
return ! $this->hasEmbeddedImageMetadata($metadata);
|
|
}
|
|
|
|
private function mergeImageMetadata(array $storedMetadata, array $sourceMetadata): array
|
|
{
|
|
$merged = $storedMetadata;
|
|
|
|
foreach ($sourceMetadata as $key => $value) {
|
|
if (is_array($value)) {
|
|
if ($value === []) {
|
|
continue;
|
|
}
|
|
|
|
$merged[$key] = array_replace(is_array($merged[$key] ?? null) ? $merged[$key] : [], $value);
|
|
continue;
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
continue;
|
|
}
|
|
|
|
$merged[$key] = $value;
|
|
}
|
|
|
|
return $merged;
|
|
}
|
|
|
|
private function hasEmbeddedImageMetadata(array $metadata): bool
|
|
{
|
|
return filled($metadata['exif_datetime'] ?? null)
|
|
|| filled($metadata['device'] ?? null)
|
|
|| isset($metadata['gps_decimal'])
|
|
|| ((int) ($metadata['orientation'] ?? 1)) !== 1;
|
|
}
|
|
|
|
private function optimizeImage(string $path, string $mime, array $metadata = []): void
|
|
{
|
|
$absolutePath = Storage::disk('public')->path($path);
|
|
if (! is_file($absolutePath) || ! function_exists('getimagesize')) {
|
|
return;
|
|
}
|
|
|
|
$source = $this->createImageResource($absolutePath, $mime);
|
|
if (! $source) {
|
|
return;
|
|
}
|
|
|
|
$source = $this->applyOrientation($source, (int) ($metadata['orientation'] ?? 1));
|
|
$width = imagesx($source);
|
|
$height = imagesy($source);
|
|
|
|
if ($width <= 0 || $height <= 0) {
|
|
imagedestroy($source);
|
|
return;
|
|
}
|
|
|
|
$maxWidth = 1600;
|
|
$maxHeight = 1600;
|
|
$ratio = min($maxWidth / $width, $maxHeight / $height, 1);
|
|
$targetW = (int) max(1, round($width * $ratio));
|
|
$targetH = (int) max(1, round($height * $ratio));
|
|
|
|
$target = imagecreatetruecolor($targetW, $targetH);
|
|
if ($target === false) {
|
|
imagedestroy($source);
|
|
return;
|
|
}
|
|
|
|
if (in_array($mime, ['image/png', 'image/webp'], true)) {
|
|
imagealphablending($target, false);
|
|
imagesavealpha($target, true);
|
|
$transparent = imagecolorallocatealpha($target, 255, 255, 255, 127);
|
|
imagefilledrectangle($target, 0, 0, $targetW, $targetH, $transparent);
|
|
}
|
|
|
|
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetW, $targetH, $width, $height);
|
|
|
|
switch ($mime) {
|
|
case 'image/png':
|
|
imagepng($target, $absolutePath, 6);
|
|
break;
|
|
case 'image/webp':
|
|
if (function_exists('imagewebp')) {
|
|
imagewebp($target, $absolutePath, 82);
|
|
}
|
|
break;
|
|
default:
|
|
imagejpeg($target, $absolutePath, 82);
|
|
break;
|
|
}
|
|
|
|
imagedestroy($target);
|
|
imagedestroy($source);
|
|
}
|
|
|
|
private function createImageResource(string $absolutePath, string $mime)
|
|
{
|
|
return match ($mime) {
|
|
'image/png' => function_exists('imagecreatefrompng') ? @imagecreatefrompng($absolutePath) : false,
|
|
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($absolutePath) : false,
|
|
default => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($absolutePath) : false,
|
|
};
|
|
}
|
|
|
|
private function applyOrientation($source, int $orientation)
|
|
{
|
|
$angle = match ($orientation) {
|
|
3 => 180,
|
|
6 => 270,
|
|
8 => 90,
|
|
default => 0,
|
|
};
|
|
|
|
if ($angle === 0 || ! function_exists('imagerotate')) {
|
|
return $source;
|
|
}
|
|
|
|
$rotated = @imagerotate($source, $angle, 0);
|
|
if ($rotated === false) {
|
|
return $source;
|
|
}
|
|
|
|
imagedestroy($source);
|
|
|
|
return $rotated;
|
|
}
|
|
|
|
private function extractImageMetadata(string $path): array
|
|
{
|
|
$absolutePath = Storage::disk('public')->path($path);
|
|
return $this->extractImageMetadataFromAbsolutePath($absolutePath);
|
|
}
|
|
|
|
private function extractImageMetadataFromFileObject(object $file): array
|
|
{
|
|
$absolutePath = null;
|
|
|
|
if (method_exists($file, 'getRealPath')) {
|
|
$absolutePath = $file->getRealPath();
|
|
}
|
|
|
|
if ((! is_string($absolutePath) || $absolutePath === '') && method_exists($file, 'path')) {
|
|
$absolutePath = $file->path();
|
|
}
|
|
|
|
if (! is_string($absolutePath) || $absolutePath === '' || ! is_file($absolutePath)) {
|
|
return [];
|
|
}
|
|
|
|
return $this->extractImageMetadataFromAbsolutePath($absolutePath);
|
|
}
|
|
|
|
private function extractImageMetadataFromAbsolutePath(string $absolutePath): array
|
|
{
|
|
$metadata = [];
|
|
|
|
$imageSize = @getimagesize($absolutePath);
|
|
if (is_array($imageSize)) {
|
|
$metadata['width'] = $imageSize[0] ?? null;
|
|
$metadata['height'] = $imageSize[1] ?? null;
|
|
$metadata['mime'] = $imageSize['mime'] ?? null;
|
|
}
|
|
|
|
if (! function_exists('exif_read_data')) {
|
|
return $metadata;
|
|
}
|
|
|
|
try {
|
|
$exif = @exif_read_data($absolutePath);
|
|
if (! is_array($exif)) {
|
|
return $metadata;
|
|
}
|
|
|
|
$metadata['exif_datetime'] = $exif['DateTimeOriginal'] ?? ($exif['DateTime'] ?? null);
|
|
$metadata['device'] = trim(implode(' ', array_filter([
|
|
$exif['Make'] ?? null,
|
|
$exif['Model'] ?? null,
|
|
])));
|
|
$metadata['orientation'] = (int) ($exif['Orientation'] ?? 1);
|
|
|
|
$lat = $this->gpsCoordinateToDecimal($exif['GPSLatitude'] ?? null, $exif['GPSLatitudeRef'] ?? null);
|
|
$lng = $this->gpsCoordinateToDecimal($exif['GPSLongitude'] ?? null, $exif['GPSLongitudeRef'] ?? null);
|
|
|
|
if ($lat !== null && $lng !== null) {
|
|
$metadata['gps_decimal'] = ['lat' => $lat, 'lng' => $lng];
|
|
$metadata['google_maps_url'] = 'https://maps.google.com/?q=' . $lat . ',' . $lng;
|
|
}
|
|
} catch (\Throwable) {
|
|
}
|
|
|
|
return $metadata;
|
|
}
|
|
|
|
private function gpsCoordinateToDecimal(mixed $coordinate, mixed $reference): ?float
|
|
{
|
|
if (! is_array($coordinate) || count($coordinate) < 3) {
|
|
return null;
|
|
}
|
|
|
|
$degrees = $this->gpsValueToFloat($coordinate[0] ?? null);
|
|
$minutes = $this->gpsValueToFloat($coordinate[1] ?? null);
|
|
$seconds = $this->gpsValueToFloat($coordinate[2] ?? null);
|
|
|
|
if ($degrees === null || $minutes === null || $seconds === null) {
|
|
return null;
|
|
}
|
|
|
|
$decimal = $degrees + ($minutes / 60) + ($seconds / 3600);
|
|
$ref = strtoupper(trim((string) $reference));
|
|
|
|
if (in_array($ref, ['S', 'W'], true)) {
|
|
$decimal *= -1;
|
|
}
|
|
|
|
return round($decimal, 6);
|
|
}
|
|
|
|
private function gpsValueToFloat(mixed $value): ?float
|
|
{
|
|
if (is_numeric($value)) {
|
|
return (float) $value;
|
|
}
|
|
|
|
$raw = trim((string) $value);
|
|
if ($raw === '') {
|
|
return null;
|
|
}
|
|
|
|
if (! str_contains($raw, '/')) {
|
|
return is_numeric($raw) ? (float) $raw : null;
|
|
}
|
|
|
|
[$numerator, $denominator] = array_pad(explode('/', $raw, 2), 2, null);
|
|
if (! is_numeric($numerator) || ! is_numeric($denominator) || (float) $denominator === 0.0) {
|
|
return null;
|
|
}
|
|
|
|
return (float) $numerator / (float) $denominator;
|
|
}
|
|
}
|