states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
(resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
- Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
resources/js/admin/router.tsx, routes/web.php)
196 lines
6.0 KiB
PHP
196 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Security;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class PhotoSecurityScanner
|
|
{
|
|
public function scan(string $disk, ?string $relativePath): array
|
|
{
|
|
if (! $relativePath) {
|
|
return [
|
|
'status' => '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()];
|
|
}
|
|
}
|
|
}
|
|
|