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:
Codex Agent
2025-10-19 23:00:47 +02:00
parent a949c8d3af
commit 6290a3a448
95 changed files with 3708 additions and 394 deletions

View 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);
}
}

View File

@@ -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()

View 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;
}
}

View File

@@ -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);
}
}

View 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()];
}
}
}