*/ 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} */ 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; } }