Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty
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)
This commit is contained in:
112
app/Services/Analytics/JoinTokenAnalyticsRecorder.php
Normal file
112
app/Services/Analytics/JoinTokenAnalyticsRecorder.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Analytics;
|
||||
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventJoinTokenEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class JoinTokenAnalyticsRecorder
|
||||
{
|
||||
public function record(
|
||||
?EventJoinToken $joinToken,
|
||||
string $eventType,
|
||||
Request $request,
|
||||
array $context = [],
|
||||
?string $providedToken = null,
|
||||
?int $httpStatus = null
|
||||
): void {
|
||||
try {
|
||||
EventJoinTokenEvent::create($this->buildPayload(
|
||||
$joinToken,
|
||||
$eventType,
|
||||
$request,
|
||||
$context,
|
||||
$providedToken,
|
||||
$httpStatus
|
||||
));
|
||||
} catch (\Throwable $exception) {
|
||||
// Never block the main request if analytics fails
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPayload(
|
||||
?EventJoinToken $joinToken,
|
||||
string $eventType,
|
||||
Request $request,
|
||||
array $context,
|
||||
?string $providedToken,
|
||||
?int $httpStatus
|
||||
): array {
|
||||
$route = $request->route();
|
||||
$routeName = $route ? $route->getName() : null;
|
||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
||||
$deviceId = $deviceId !== '' ? substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) : null;
|
||||
|
||||
$tokenHash = null;
|
||||
$tokenPreview = null;
|
||||
|
||||
if ($joinToken) {
|
||||
$tokenHash = $joinToken->token_hash ?? null;
|
||||
$tokenPreview = $joinToken->token_preview ?? null;
|
||||
} elseif ($providedToken) {
|
||||
$tokenHash = hash('sha256', $providedToken);
|
||||
$tokenPreview = $this->buildPreview($providedToken);
|
||||
}
|
||||
|
||||
$eventId = $joinToken?->event_id;
|
||||
$tenantId = null;
|
||||
|
||||
if ($joinToken && $joinToken->relationLoaded('event')) {
|
||||
$tenantId = $joinToken->event?->tenant_id;
|
||||
} elseif ($joinToken) {
|
||||
$tenantId = $joinToken->event()->value('tenant_id');
|
||||
}
|
||||
|
||||
return [
|
||||
'event_join_token_id' => $joinToken?->getKey(),
|
||||
'event_id' => $eventId,
|
||||
'tenant_id' => $tenantId,
|
||||
'token_hash' => $tokenHash,
|
||||
'token_preview' => $tokenPreview,
|
||||
'event_type' => $eventType,
|
||||
'route' => $routeName,
|
||||
'http_method' => $request->getMethod(),
|
||||
'http_status' => $httpStatus,
|
||||
'device_id' => $deviceId,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $this->shortenUserAgent($request->userAgent()),
|
||||
'context' => $context ?: null,
|
||||
'occurred_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
private function shortenUserAgent(?string $userAgent): ?string
|
||||
{
|
||||
if ($userAgent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::length($userAgent) > 1024) {
|
||||
return Str::substr($userAgent, 0, 1024);
|
||||
}
|
||||
|
||||
return $userAgent;
|
||||
}
|
||||
|
||||
private function buildPreview(string $token): string
|
||||
{
|
||||
$token = trim($token);
|
||||
$length = Str::length($token);
|
||||
|
||||
if ($length <= 10) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return Str::substr($token, 0, 6).'…'.Str::substr($token, -4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,16 @@ class CheckoutAssignmentService
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Mail::to($user)->queue(new Welcome($user));
|
||||
$mailLocale = $user->preferred_locale ?? app()->getLocale();
|
||||
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new Welcome($user));
|
||||
|
||||
if ($purchase->wasRecentlyCreated) {
|
||||
Mail::to($user)->queue(new PurchaseConfirmation($purchase));
|
||||
Mail::to($user)
|
||||
->locale($mailLocale)
|
||||
->queue(new PurchaseConfirmation($purchase));
|
||||
}
|
||||
|
||||
AbandonedCheckout::query()
|
||||
|
||||
279
app/Services/Checkout/CheckoutWebhookService.php
Normal file
279
app/Services/Checkout/CheckoutWebhookService.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckoutWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['type'] ?? null;
|
||||
$intent = $event['data']['object'] ?? null;
|
||||
|
||||
if (! $eventType || ! is_array($intent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_starts_with($eventType, 'payment_intent.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$intentId = $intent['id'] ?? null;
|
||||
|
||||
if (! $intentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session = $this->locateStripeSession($intent);
|
||||
|
||||
if (! $session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] Stripe intent lock busy', [
|
||||
'intent_id' => $intentId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$session->forceFill([
|
||||
'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId,
|
||||
'provider' => CheckoutSession::PROVIDER_STRIPE,
|
||||
])->save();
|
||||
|
||||
$metadata = [
|
||||
'stripe_last_event' => $eventType,
|
||||
'stripe_last_event_id' => $event['id'] ?? null,
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
'stripe_last_update_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$this->mergeProviderMetadata($session, $metadata);
|
||||
|
||||
return $this->applyStripeIntent($session, $eventType, $intent);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePayPalEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$resource = $event['resource'] ?? [];
|
||||
|
||||
if (! $eventType || ! is_array($resource)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderId = $resource['order_id'] ?? $resource['id'] ?? null;
|
||||
|
||||
$session = $this->locatePayPalSession($resource, $orderId);
|
||||
|
||||
if (! $session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id);
|
||||
$lock = Cache::lock($lockKey, 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] PayPal lock busy', [
|
||||
'order_id' => $orderId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
|
||||
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
||||
])->save();
|
||||
|
||||
$metadata = [
|
||||
'paypal_last_event' => $eventType,
|
||||
'paypal_last_event_id' => $event['id'] ?? null,
|
||||
'paypal_last_update_at' => now()->toIso8601String(),
|
||||
'paypal_order_id' => $orderId,
|
||||
'paypal_capture_id' => $resource['id'] ?? null,
|
||||
];
|
||||
|
||||
$this->mergeProviderMetadata($session, $metadata);
|
||||
|
||||
return $this->applyPayPalEvent($session, $eventType, $resource);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool
|
||||
{
|
||||
switch ($eventType) {
|
||||
case 'payment_intent.processing':
|
||||
case 'payment_intent.amount_capturable_updated':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
]);
|
||||
return true;
|
||||
|
||||
case 'payment_intent.requires_action':
|
||||
$reason = $intent['next_action']['type'] ?? 'requires_action';
|
||||
$this->sessions->markRequiresCustomerAction($session, $reason);
|
||||
return true;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
|
||||
$this->sessions->markFailed($session, $failure);
|
||||
return true;
|
||||
|
||||
case 'payment_intent.succeeded':
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'stripe_webhook',
|
||||
'stripe_payment_intent_id' => $intent['id'] ?? null,
|
||||
'stripe_charge_id' => $this->extractStripeChargeId($intent),
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool
|
||||
{
|
||||
switch ($eventType) {
|
||||
case 'CHECKOUT.ORDER.APPROVED':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_order_status' => $resource['status'] ?? null,
|
||||
]);
|
||||
return true;
|
||||
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_order_status' => $resource['status'] ?? null,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paypal_webhook',
|
||||
'paypal_order_id' => $resource['order_id'] ?? null,
|
||||
'paypal_capture_id' => $resource['id'] ?? null,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
$this->sessions->markFailed($session, 'paypal_capture_denied');
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
|
||||
$session->save();
|
||||
}
|
||||
|
||||
protected function locateStripeSession(array $intent): ?CheckoutSession
|
||||
{
|
||||
$intentId = $intent['id'] ?? null;
|
||||
|
||||
if ($intentId) {
|
||||
$session = CheckoutSession::query()
|
||||
->where('stripe_payment_intent_id', $intentId)
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $intent['metadata'] ?? [];
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
|
||||
if ($sessionId) {
|
||||
return CheckoutSession::find($sessionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
|
||||
{
|
||||
if ($orderId) {
|
||||
$session = CheckoutSession::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $this->extractPayPalMetadata($resource);
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
|
||||
if ($sessionId) {
|
||||
return CheckoutSession::find($sessionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function extractPayPalMetadata(array $resource): array
|
||||
{
|
||||
$customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null);
|
||||
if ($customId) {
|
||||
$decoded = json_decode($customId, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$meta = Arr::get($resource, 'supplementary_data.related_ids', []);
|
||||
return is_array($meta) ? $meta : [];
|
||||
}
|
||||
|
||||
protected function extractStripeChargeId(array $intent): ?string
|
||||
{
|
||||
$charges = $intent['charges']['data'] ?? null;
|
||||
if (is_array($charges) && count($charges) > 0) {
|
||||
return $charges[0]['id'] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\EventJoinToken;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventJoinTokenService
|
||||
@@ -15,10 +16,13 @@ class EventJoinTokenService
|
||||
{
|
||||
return DB::transaction(function () use ($event, $attributes) {
|
||||
$tokenValue = $this->generateUniqueToken();
|
||||
$tokenHash = $this->hashToken($tokenValue);
|
||||
|
||||
$payload = [
|
||||
'event_id' => $event->id,
|
||||
'token' => $tokenValue,
|
||||
'token_hash' => $tokenHash,
|
||||
'token_encrypted' => Crypt::encryptString($tokenValue),
|
||||
'token_preview' => $this->previewToken($tokenValue),
|
||||
'label' => Arr::get($attributes, 'label'),
|
||||
'usage_limit' => Arr::get($attributes, 'usage_limit'),
|
||||
'metadata' => Arr::get($attributes, 'metadata', []),
|
||||
@@ -34,7 +38,9 @@ class EventJoinTokenService
|
||||
$payload['created_by'] = $createdBy;
|
||||
}
|
||||
|
||||
return EventJoinToken::create($payload);
|
||||
return tap(EventJoinToken::create($payload), function (EventJoinToken $model) use ($tokenValue) {
|
||||
$model->setAttribute('plain_token', $tokenValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,8 +66,16 @@ class EventJoinTokenService
|
||||
|
||||
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken
|
||||
{
|
||||
$hash = $this->hashToken($token);
|
||||
|
||||
return EventJoinToken::query()
|
||||
->where('token', $token)
|
||||
->where(function ($query) use ($hash, $token) {
|
||||
$query->where('token_hash', $hash)
|
||||
->orWhere(function ($inner) use ($token) {
|
||||
$inner->whereNull('token_hash')
|
||||
->where('token', $token);
|
||||
});
|
||||
})
|
||||
->when(! $includeInactive, function ($query) {
|
||||
$query->whereNull('revoked_at')
|
||||
->where(function ($query) {
|
||||
@@ -85,8 +99,25 @@ class EventJoinTokenService
|
||||
{
|
||||
do {
|
||||
$token = Str::random($length);
|
||||
} while (EventJoinToken::where('token', $token)->exists());
|
||||
$hash = $this->hashToken($token);
|
||||
} while (EventJoinToken::where('token_hash', $hash)->exists());
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
protected function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
protected function previewToken(string $token): string
|
||||
{
|
||||
$length = strlen($token);
|
||||
|
||||
if ($length <= 10) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return substr($token, 0, 6).'…'.substr($token, -4);
|
||||
}
|
||||
}
|
||||
|
||||
195
app/Services/Security/PhotoSecurityScanner.php
Normal file
195
app/Services/Security/PhotoSecurityScanner.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user