'error', 'message' => 'Missing path for antivirus scan.', ]; } if (! config('security.antivirus.enabled', false)) { return [ 'status' => 'skipped', 'message' => 'Antivirus scanning disabled.', ]; } [$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath); if (! $absolutePath) { return [ 'status' => 'skipped', 'message' => $message ?? 'Unable to resolve path for antivirus.', ]; } $binary = config('security.antivirus.binary', '/usr/bin/clamscan'); $arguments = config('security.antivirus.arguments', '--no-summary'); $timeout = config('security.antivirus.timeout', 60); $command = $binary.' '.$arguments.' '.escapeshellarg($absolutePath); try { $process = Process::fromShellCommandline($command); $process->setTimeout($timeout); $process->run(); if ($process->isSuccessful()) { return [ 'status' => 'clean', 'message' => trim($process->getOutput()), ]; } if ($process->getExitCode() === 1) { return [ 'status' => 'infected', 'message' => trim($process->getOutput() ?: $process->getErrorOutput()), ]; } return [ 'status' => 'error', 'message' => trim($process->getErrorOutput() ?: 'Unknown antivirus error.'), ]; } catch (ProcessFailedException $exception) { return [ 'status' => 'error', 'message' => $exception->getMessage(), ]; } catch (\Throwable $exception) { Log::warning('[PhotoSecurity] Antivirus scan failed', [ 'disk' => $disk, 'path' => $relativePath, 'error' => $exception->getMessage(), ]); return [ 'status' => 'error', 'message' => $exception->getMessage(), ]; } } public function stripExif(string $disk, ?string $relativePath): array { if (! config('security.exif.strip', true)) { return [ 'status' => 'skipped', 'message' => 'EXIF stripping disabled.', ]; } if (! $relativePath) { return [ 'status' => 'error', 'message' => 'Missing path for EXIF stripping.', ]; } [$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath); if (! $absolutePath) { return [ 'status' => 'skipped', 'message' => $message ?? 'Unable to resolve path for EXIF stripping.', ]; } $extension = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION)); if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { return [ 'status' => 'skipped', 'message' => 'Unsupported format for EXIF stripping.', ]; } try { $contents = @file_get_contents($absolutePath); if ($contents === false) { throw new \RuntimeException('Unable to read file for EXIF stripping.'); } $image = @imagecreatefromstring($contents); if (! $image) { return [ 'status' => 'skipped', 'message' => 'Unable to decode image for EXIF stripping.', ]; } $result = match ($extension) { 'png' => imagepng($image, $absolutePath), 'webp' => function_exists('imagewebp') ? imagewebp($image, $absolutePath) : false, default => imagejpeg($image, $absolutePath, 90), }; imagedestroy($image); if (! $result) { return [ 'status' => 'error', 'message' => 'Failed to re-encode image without EXIF.', ]; } return [ 'status' => 'stripped', 'message' => 'EXIF metadata removed via re-encode.', ]; } catch (\Throwable $exception) { Log::warning('[PhotoSecurity] EXIF stripping failed', [ 'disk' => $disk, 'path' => $relativePath, 'error' => $exception->getMessage(), ]); return [ 'status' => 'error', 'message' => $exception->getMessage(), ]; } } /** * @return array{0: string|null, 1: string|null} */ private function resolveAbsolutePath(string $disk, string $relativePath): array { try { $storage = Storage::disk($disk); if (! method_exists($storage, 'path')) { return [null, 'Storage driver does not expose local paths.']; } $absolute = $storage->path($relativePath); if (! file_exists($absolute)) { return [null, 'File not found on disk.']; } return [$absolute, null]; } catch (\Throwable $exception) { Log::warning('[PhotoSecurity] Unable to resolve absolute path', [ 'disk' => $disk, 'path' => $relativePath, 'error' => $exception->getMessage(), ]); return [null, $exception->getMessage()]; } } }