netgescon-day0/app/Services/Support/TicketAttachmentUploadService.php

372 lines
13 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<string,mixed>
*/
public function inspectUpload(object $file): array
{
return $this->extractImageMetadataFromFileObject($file);
}
/**
* @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,
];
}
/**
* @return array{path:string,mime:string,size:int,original_name:string,source_original_name:string,stored_name:string,metadata:array<string,mixed>}
*/
public function storeRawContents(string $contents, string $directory, string $originalName, array $options = []): array
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$storedBasename = trim((string) ($options['stored_basename'] ?? ''));
$displayName = trim((string) ($options['display_name'] ?? '')) ?: $originalName;
$sanitizedBase = trim((string) Str::of(pathinfo($storedBasename !== '' ? $storedBasename : $displayName, PATHINFO_FILENAME))->slug('-'), '-');
$sanitizedBase = $sanitizedBase !== '' ? $sanitizedBase : 'file';
$storedName = $sanitizedBase . ($extension !== '' ? '.' . $extension : '');
$path = trim($directory, '/') . '/' . $storedName;
Storage::disk('public')->put($path, $contents);
$mime = TicketAttachment::normalizeMimeType((string) ($options['mime'] ?? ''), $originalName, $path);
$metadata = [];
if (str_starts_with($mime, 'image/')) {
$metadata = $this->extractImageMetadata($path);
if ($this->shouldOptimizeImage($metadata, $options)) {
$this->optimizeImage($path, $mime, $metadata);
}
$mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path);
}
return [
'path' => $path,
'mime' => $mime,
'size' => (int) Storage::disk('public')->size($path),
'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;
}
}