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

@@ -10,9 +10,11 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Support\ImageHelper;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\Storage\EventStorageManager;
use App\Models\Event;
@@ -24,9 +26,12 @@ use App\Models\EventMediaAsset;
class EventPublicController extends BaseController
{
private const SIGNED_URL_TTL_SECONDS = 1800;
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly EventStorageManager $eventStorageManager,
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
) {
}
@@ -40,34 +45,65 @@ class EventPublicController extends BaseController
$joinToken = $this->joinTokenService->findToken($token, true);
if (! $joinToken) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'invalid_token',
Response::HTTP_NOT_FOUND,
[
'token' => Str::limit($token, 12),
],
$token
);
}
if ($joinToken->revoked_at !== null) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_revoked',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
],
$token,
$joinToken
);
}
if ($joinToken->expires_at !== null) {
$expiresAt = CarbonImmutable::parse($joinToken->expires_at);
if ($expiresAt->isPast()) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'expired_at' => $expiresAt->toAtomString(),
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_expired',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
'expired_at' => $expiresAt->toAtomString(),
],
$token,
$joinToken
);
}
}
if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
'usage_count' => $joinToken->usage_count,
'usage_limit' => $joinToken->usage_limit,
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'token_expired',
Response::HTTP_GONE,
[
'token' => Str::limit($token, 12),
'usage_count' => $joinToken->usage_count,
'usage_limit' => $joinToken->usage_limit,
],
$token,
$joinToken
);
}
$columns = array_unique(array_merge($columns, ['status']));
@@ -77,10 +113,18 @@ class EventPublicController extends BaseController
->first($columns);
if (! $event) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
]);
return $this->handleTokenFailure(
$request,
$rateLimiterKey,
'invalid_token',
Response::HTTP_NOT_FOUND,
[
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
],
$token,
$joinToken
);
}
if (($event->status ?? null) !== 'published') {
@@ -90,6 +134,18 @@ class EventPublicController extends BaseController
'ip' => $request->ip(),
]);
$this->recordTokenEvent(
$joinToken,
$request,
'event_not_public',
[
'token' => Str::limit($token, 12),
'event_id' => $event->id ?? null,
],
$token,
Response::HTTP_FORBIDDEN
);
return response()->json([
'error' => [
'code' => 'event_not_public',
@@ -104,6 +160,22 @@ class EventPublicController extends BaseController
unset($event->status);
}
$this->recordTokenEvent(
$joinToken,
$request,
'access_granted',
[
'event_id' => $event->id ?? null,
],
$token,
Response::HTTP_OK
);
$throttleResponse = $this->enforceAccessThrottle($joinToken, $request, $token);
if ($throttleResponse instanceof JsonResponse) {
return $throttleResponse;
}
return [$event, $joinToken];
}
@@ -134,6 +206,18 @@ class EventPublicController extends BaseController
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
if ($expiresAt instanceof Carbon && $expiresAt->isPast()) {
$this->recordTokenEvent(
$joinToken,
$request,
'gallery_expired',
[
'event_id' => $event->id,
'expired_at' => $expiresAt->toIso8601String(),
],
$token,
Response::HTTP_GONE
);
return response()->json([
'error' => [
'code' => 'gallery_expired',
@@ -143,16 +227,47 @@ class EventPublicController extends BaseController
], Response::HTTP_GONE);
}
$this->recordTokenEvent(
$joinToken,
$request,
'gallery_access_granted',
[
'event_id' => $event->id,
],
$token,
Response::HTTP_OK
);
return [$event, $joinToken];
}
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
private function handleTokenFailure(
Request $request,
string $rateLimiterKey,
string $code,
int $status,
array $context = [],
?string $rawToken = null,
?EventJoinToken $joinToken = null
): JsonResponse
{
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
$failureLimit = max(1, (int) config('join_tokens.failure_limit', 10));
$failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5));
if (RateLimiter::tooManyAttempts($rateLimiterKey, $failureLimit)) {
Log::warning('Join token rate limit exceeded', array_merge([
'ip' => $request->ip(),
], $context));
$this->recordTokenEvent(
$joinToken,
$request,
'token_rate_limited',
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'token_rate_limited',
@@ -161,13 +276,22 @@ class EventPublicController extends BaseController
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($rateLimiterKey, 300);
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
Log::notice('Join token access denied', array_merge([
'code' => $code,
'ip' => $request->ip(),
], $context));
$this->recordTokenEvent(
$joinToken,
$request,
$code,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
$rawToken,
$status
);
return response()->json([
'error' => [
'code' => $code,
@@ -186,6 +310,89 @@ class EventPublicController extends BaseController
};
}
private function recordTokenEvent(
?EventJoinToken $joinToken,
Request $request,
string $eventType,
array $context = [],
?string $rawToken = null,
?int $status = null
): void {
$this->analyticsRecorder->record($joinToken, $eventType, $request, $context, $rawToken, $status);
}
private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.access_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.access_decay_minutes', 1));
$key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'access_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'access_rate_limited',
'message' => 'Too many requests. Please slow down.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.download_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.download_decay_minutes', 1));
$key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'download_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'download_rate_limited',
'message' => 'Download rate limit exceeded. Please wait a moment.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function getLocalized($value, $locale, $default = '') {
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -288,23 +495,50 @@ class EventPublicController extends BaseController
private function makeGalleryPhotoResource(Photo $photo, string $token): array
{
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
$full = $this->toPublicUrl($photo->file_path ?? null);
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
return [
'id' => $photo->id,
'thumbnail_url' => $thumbnail,
'full_url' => $full,
'download_url' => route('api.v1.gallery.photos.download', [
'token' => $token,
'photo' => $photo->id,
]),
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
'full_url' => $fullUrl,
'download_url' => $downloadUrl,
'likes_count' => $photo->likes_count,
'guest_name' => $photo->guest_name,
'created_at' => $photo->created_at?->toIso8601String(),
];
}
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
{
if (! in_array($variant, ['thumbnail', 'full'], true)) {
return null;
}
return URL::temporarySignedRoute(
'api.v1.gallery.photos.asset',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
'variant' => $variant,
]
);
}
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
{
return URL::temporarySignedRoute(
'api.v1.gallery.photos.download',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
]
);
}
public function gallery(Request $request, string $token)
{
$locale = $request->query('locale', app()->getLocale());
@@ -316,7 +550,11 @@ class EventPublicController extends BaseController
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$branding = $this->buildGalleryBranding($event);
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
@@ -342,7 +580,11 @@ class EventPublicController extends BaseController
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$limit = (int) $request->query('limit', 30);
$limit = max(1, min($limit, 60));
@@ -389,6 +631,39 @@ class EventPublicController extends BaseController
]);
}
public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
{
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event] = $resolved;
$record = Photo::with('mediaAsset')
->where('id', $photo)
->where('event_id', $event->id)
->where('status', 'approved')
->first();
if (! $record) {
return response()->json([
'error' => [
'code' => 'photo_not_found',
'message' => 'The requested photo is no longer available.',
],
], Response::HTTP_NOT_FOUND);
}
$variantPreference = $variant === 'thumbnail'
? ['thumbnail', 'original']
: ['original'];
return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline');
}
public function galleryPhotoDownload(Request $request, string $token, int $photo)
{
$resolved = $this->resolveGalleryEvent($request, $token);
@@ -415,52 +690,7 @@ class EventPublicController extends BaseController
], Response::HTTP_NOT_FOUND);
}
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
if ($asset) {
$disk = $asset->disk ?? config('filesystems.default');
$path = $asset->path ?? $record->file_path;
try {
if ($path && Storage::disk($disk)->exists($path)) {
$stream = Storage::disk($disk)->readStream($path);
if ($stream) {
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
$filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension);
$mime = $asset->mime_type ?? 'image/jpeg';
return response()->streamDownload(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, $filename, [
'Content-Type' => $mime,
]);
}
}
} catch (\Throwable $e) {
Log::warning('Gallery photo download failed', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $asset->disk ?? null,
'path' => $asset->path ?? null,
'error' => $e->getMessage(),
]);
}
}
$publicUrl = $this->toPublicUrl($record->file_path ?? null);
if ($publicUrl) {
return redirect()->away($publicUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be downloaded.',
],
], Response::HTTP_NOT_FOUND);
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
}
public function event(Request $request, string $token)
@@ -518,6 +748,160 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store');
}
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
{
foreach ($variantPreference as $variant) {
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
if (! $path) {
continue;
}
$diskName = $this->resolveStorageDisk($disk);
try {
$storage = Storage::disk($diskName);
} catch (\Throwable $e) {
Log::warning('Gallery asset disk unavailable', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'error' => $e->getMessage(),
]);
continue;
}
foreach ($this->storagePathCandidates($path) as $candidate) {
try {
if (! $storage->exists($candidate)) {
continue;
}
$stream = $storage->readStream($candidate);
if (! $stream) {
continue;
}
$size = null;
try {
$size = $storage->size($candidate);
} catch (\Throwable $e) {
$size = null;
}
if (! $mime) {
try {
$mime = $storage->mimeType($candidate);
} catch (\Throwable $e) {
$mime = null;
}
}
$extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
$suffix = $variant === 'thumbnail' ? '-thumb' : '';
$filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');
$headers = [
'Content-Type' => $mime ?? 'image/jpeg',
'Cache-Control' => $disposition === 'attachment'
? 'no-store, max-age=0'
: 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
];
if ($size) {
$headers['Content-Length'] = $size;
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, $headers);
} catch (\Throwable $e) {
Log::warning('Gallery asset stream error', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'path' => $candidate,
'variant' => $variant,
'error' => $e->getMessage(),
]);
}
}
}
$fallbackUrl = $this->toPublicUrl($record->file_path ?? null);
if ($fallbackUrl) {
return redirect()->away($fallbackUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be loaded.',
],
], Response::HTTP_NOT_FOUND);
}
private function resolvePhotoVariant(Photo $record, string $variant): array
{
if ($variant === 'thumbnail') {
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $asset?->mime_type ?? 'image/jpeg';
} else {
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->file_path ?? null);
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
}
return [
$disk ?: config('filesystems.default', 'public'),
$path,
$mime,
];
}
private function resolveStorageDisk(?string $disk): string
{
$disk = $disk ?: config('filesystems.default', 'public');
if (! config("filesystems.disks.{$disk}")) {
return config('filesystems.default', 'public');
}
return $disk;
}
private function storagePathCandidates(string $path): array
{
$normalized = str_replace('\\', '/', $path);
$candidates = [$normalized];
$trimmed = ltrim($normalized, '/');
if ($trimmed !== $normalized) {
$candidates[] = $trimmed;
}
if (str_starts_with($trimmed, 'storage/')) {
$candidates[] = substr($trimmed, strlen('storage/'));
}
if (str_starts_with($trimmed, 'public/')) {
$candidates[] = substr($trimmed, strlen('public/'));
}
$needle = '/storage/app/public/';
if (str_contains($normalized, $needle)) {
$candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
}
return array_values(array_unique(array_filter($candidates)));
}
public function stats(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -526,7 +910,7 @@ class EventPublicController extends BaseController
return $result;
}
[$event] = $result;
[$event, $joinToken] = $result;
$eventId = $event->id;
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
@@ -866,6 +1250,19 @@ class EventPublicController extends BaseController
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
$this->recordTokenEvent(
$joinToken,
$request,
'upload_device_limit',
[
'event_id' => $eventId,
'device_id' => $deviceId,
'device_count' => $deviceCount,
],
$token,
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
}
@@ -936,11 +1333,26 @@ class EventPublicController extends BaseController
->where('id', $photoId)
->update(['media_asset_id' => $asset->id]);
return response()->json([
$response = response()->json([
'id' => $photoId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
], 201);
$this->recordTokenEvent(
$joinToken,
$request,
'upload_completed',
[
'event_id' => $eventId,
'photo_id' => $photoId,
'device_id' => $deviceId,
],
$token,
Response::HTTP_CREATED
);
return $response;
}
/**
@@ -1201,7 +1613,6 @@ class EventPublicController extends BaseController
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
}
private function resolveDiskUrl(string $disk, string $path): string
{
@@ -1217,3 +1628,5 @@ class EventPublicController extends BaseController
return $path;
}
}
}

View File

@@ -3,10 +3,11 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PackagePurchase;
use App\Models\EventPackage;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Models\User;
use App\Services\Checkout\CheckoutWebhookService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -15,6 +16,10 @@ use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
{
}
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
@@ -23,7 +28,9 @@ class StripeWebhookController extends Controller
try {
$event = Webhook::constructEvent(
$payload, $sigHeader, $endpointSecret
$payload,
$sigHeader,
$endpointSecret
);
} catch (SignatureVerificationException $e) {
return response()->json(['error' => 'Invalid signature'], 400);
@@ -31,54 +38,81 @@ class StripeWebhookController extends Controller
return response()->json(['error' => 'Invalid payload'], 400);
}
// Handle the event
switch ($event['type']) {
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
if ($this->checkoutWebhooks->handleStripeEvent($eventArray)) {
return response()->json(['status' => 'success'], 200);
}
// Legacy handlers for legacy marketing checkout
return $this->handleLegacyEvent($eventArray);
}
private function handleLegacyEvent(array $event)
{
$type = $event['type'] ?? null;
switch ($type) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
$paymentIntent = $event['data']['object'] ?? [];
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
case 'invoice.paid':
$invoice = $event['data']['object'];
$invoice = $event['data']['object'] ?? [];
$this->handleInvoicePaid($invoice);
break;
default:
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
Log::info('Unhandled Stripe event', ['type' => $type]);
}
return response()->json(['status' => 'success'], 200);
}
private function handlePaymentIntentSucceeded(array $paymentIntent)
private function handlePaymentIntentSucceeded(array $paymentIntent): void
{
$metadata = $paymentIntent['metadata'];
$packageId = $metadata['package_id'];
$type = $metadata['type'];
$metadata = $paymentIntent['metadata'] ?? [];
$packageId = $metadata['package_id'] ?? null;
$type = $metadata['type'] ?? null;
if (! $packageId || ! $type) {
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
return;
}
DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
// Create purchase record
$purchase = PackagePurchase::create([
'package_id' => $packageId,
'type' => $type,
'provider_id' => 'stripe',
'transaction_id' => $paymentIntent['id'],
'price' => $paymentIntent['amount_received'] / 100,
'transaction_id' => $paymentIntent['id'] ?? null,
'price' => isset($paymentIntent['amount_received'])
? $paymentIntent['amount_received'] / 100
: 0,
'metadata' => $metadata,
]);
if ($type === 'endcustomer_event') {
$eventId = $metadata['event_id'];
$eventId = $metadata['event_id'] ?? null;
if (! $eventId) {
return;
}
EventPackage::create([
'event_id' => $eventId,
'package_id' => $packageId,
'package_purchase_id' => $purchase->id,
'used_photos' => 0,
'used_guests' => 0,
'expires_at' => now()->addDays(30), // Default, or from package
'expires_at' => now()->addDays(30),
]);
} elseif ($type === 'reseller_subscription') {
$tenantId = $metadata['tenant_id'];
$tenantId = $metadata['tenant_id'] ?? null;
if (! $tenantId) {
return;
}
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
@@ -88,59 +122,60 @@ class StripeWebhookController extends Controller
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id']);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
});
}
private function handleInvoicePaid(array $invoice)
{
$subscription = $invoice['subscription'];
$metadata = $subscription['metadata'] ?? [];
if (isset($metadata['tenant_id'])) {
$tenantId = $metadata['tenant_id'];
$packageId = $metadata['package_id'];
// Renew or create tenant package
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
->where('package_id', $packageId)
->where('stripe_subscription_id', $subscription)
->first();
if ($tenantPackage) {
$tenantPackage->update([
'active' => true,
'expires_at' => now()->addYear(),
]);
} else {
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'stripe_subscription_id' => $subscription,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
// Create purchase record
PackagePurchase::create([
'package_id' => $packageId,
'type' => 'reseller_subscription',
'provider_id' => 'stripe',
'transaction_id' => $invoice['id'],
'price' => $invoice['amount_paid'] / 100,
'metadata' => $metadata,
]);
}
});
}
}
private function handleInvoicePaid(array $invoice): void
{
$subscription = $invoice['subscription'] ?? null;
$metadata = $subscription['metadata'] ?? [];
if (! isset($metadata['tenant_id'], $metadata['package_id'])) {
return;
}
$tenantId = $metadata['tenant_id'];
$packageId = $metadata['package_id'];
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
->where('package_id', $packageId)
->where('stripe_subscription_id', $subscription)
->first();
if ($tenantPackage) {
$tenantPackage->update([
'active' => true,
'expires_at' => now()->addYear(),
]);
} else {
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'stripe_subscription_id' => $subscription,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
PackagePurchase::create([
'package_id' => $packageId,
'type' => 'reseller_subscription',
'provider_id' => 'stripe',
'transaction_id' => $invoice['id'] ?? null,
'price' => isset($invoice['amount_paid']) ? $invoice['amount_paid'] / 100 : 0,
'metadata' => $metadata,
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\Photo;
use App\Support\ImageHelper;
use App\Services\Storage\EventStorageManager;
use App\Jobs\ProcessPhotoSecurityScan;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -157,6 +158,8 @@ class PhotoController extends Controller
[$width, $height] = getimagesize($file->getRealPath());
$photo->update(['width' => $width, 'height' => $height]);
ProcessPhotoSecurityScan::dispatch($photo->id);
$photo->load('event')->loadCount('likes');
return response()->json([

View File

@@ -102,7 +102,9 @@ class RegisteredUserController extends Controller
event(new Registered($user));
// Send Welcome Email
Mail::to($user)->queue(new \App\Mail\Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new \App\Mail\Welcome($user));
if ($request->filled('package_id')) {
$package = \App\Models\Package::find($request->package_id);

View File

@@ -123,7 +123,9 @@ class CheckoutController extends Controller
$user->sendEmailVerificationNotification();
// Willkommens-E-Mail senden
Mail::to($user)->queue(new Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new Welcome($user));
});
return response()->json([

View File

@@ -91,7 +91,9 @@ class CheckoutGoogleController extends Controller
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
try {
Mail::to($user)->queue(new Welcome($user));
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->queue(new Welcome($user));
} catch (\Throwable $exception) {
Log::warning('Failed to queue welcome mail after Google signup', [
'user_id' => $user->id,

View File

@@ -60,14 +60,28 @@ class MarketingController extends Controller
'message' => 'required|string|max:1000',
]);
Mail::raw("Kontakt-Anfrage von {$request->name} ({$request->email}): {$request->message}", function ($message) use ($request) {
$message->to('admin@fotospiel.de')
->subject('Neue Kontakt-Anfrage');
});
$locale = app()->getLocale();
$contactAddress = config('mail.contact_address', config('mail.from.address')) ?: 'admin@fotospiel.de';
Mail::to($request->email)->queue(new ContactConfirmation($request->name));
Mail::raw(
__('emails.contact.body', [
'name' => $request->name,
'email' => $request->email,
'message' => $request->message,
], $locale),
function ($message) use ($request, $contactAddress, $locale) {
$message->to($contactAddress)
->subject(__('emails.contact.subject', [], $locale));
}
);
return redirect()->back()->with('success', 'Nachricht gesendet!');
Mail::to($request->email)
->locale($locale)
->queue(new ContactConfirmation($request->name));
return redirect()
->back()
->with('success', __('marketing.contact.success', [], $locale));
}
public function contactView()

View File

@@ -141,7 +141,6 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId,
]);
@@ -181,7 +180,6 @@ class OAuthController extends Controller
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
Log::warning('[OAuth] Authorization code missing or expired', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
]);
return $this->errorResponse('Invalid or expired authorization code', 400);
@@ -192,7 +190,7 @@ class OAuthController extends Controller
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
Log::warning('[OAuth] Authorization code validation failed', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'oauth_code_id' => $oauthCode?->id,
]);
return $this->errorResponse('Invalid authorization code', 400);
@@ -222,7 +220,7 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'oauth_code_id' => $oauthCode->id ?? null,
'tenant_id' => $tenantId,
]);
@@ -271,16 +269,33 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid refresh token', 400);
}
$storedRefreshToken->recordAudit('refresh_attempt', [
'client_id' => $request->client_id,
], null, $request);
if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
$storedRefreshToken->recordAudit('client_mismatch', [
'expected_client' => $storedRefreshToken->client_id,
'provided_client' => $request->client_id,
], null, $request);
return $this->errorResponse('Refresh token does not match client', 400);
}
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
$storedRefreshToken->update(['revoked_at' => now()]);
$storedRefreshToken->revoke('expired', null, $request, [
'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(),
]);
return $this->errorResponse('Refresh token expired', 400);
}
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
$storedRefreshToken->recordAudit('invalid_secret', [], null, $request);
$storedRefreshToken->revoke('invalid_secret', null, $request, [
'client_id' => $request->client_id,
]);
return $this->errorResponse('Invalid refresh token', 400);
}
@@ -288,8 +303,6 @@ class OAuthController extends Controller
$currentIp = (string) ($request->ip() ?? '');
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
$storedRefreshToken->update(['revoked_at' => now()]);
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
@@ -297,6 +310,11 @@ class OAuthController extends Controller
'current_ip' => $currentIp,
]);
$storedRefreshToken->revoke('ip_mismatch', null, $request, [
'stored_ip' => $storedIp,
'current_ip' => $currentIp,
]);
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
}
@@ -313,15 +331,36 @@ class OAuthController extends Controller
'tenant_id' => $storedRefreshToken->tenant_id,
]);
$storedRefreshToken->revoke('tenant_missing', null, $request, [
'missing_tenant_id' => $storedRefreshToken->tenant_id,
]);
return $this->errorResponse('Tenant not found', 404);
}
$scopes = $this->parseScopes($storedRefreshToken->scope);
$storedRefreshToken->update(['revoked_at' => now()]);
$storedRefreshToken->forceFill([
'last_used_at' => now(),
])->save();
$storedRefreshToken->recordAudit('refreshed', [
'client_id' => $request->client_id,
], null, $request);
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
$newComposite = $tokenResponse['refresh_token'] ?? null;
$newRefreshTokenId = null;
if ($newComposite && str_contains($newComposite, '|')) {
[$newRefreshTokenId] = explode('|', $newComposite, 2);
}
$storedRefreshToken->revoke('rotated', null, $request, [
'replaced_by' => $newRefreshTokenId,
]);
return response()->json($tokenResponse);
}
@@ -370,18 +409,45 @@ class OAuthController extends Controller
$composite = $refreshTokenId.'|'.$secret;
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
RefreshToken::create([
/** @var RefreshToken $refreshToken */
$refreshToken = RefreshToken::create([
'id' => $refreshTokenId,
'tenant_id' => $tenant->id,
'client_id' => $client->client_id,
'token' => Hash::make($secret),
'access_token' => $accessTokenJti,
'expires_at' => $expiresAt,
'last_used_at' => now(),
'scope' => implode(' ', $scopes),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
$refreshToken->recordAudit('issued', [
'scopes' => $scopes,
], null, $request);
$maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5);
if ($maxActive > 0) {
$activeTokens = RefreshToken::query()
->forTenant((string) $tenant->id)
->active()
->orderByDesc('created_at')
->get();
if ($activeTokens->count() > $maxActive) {
$activeTokens
->slice($maxActive)
->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void {
$token->revoke('max_active_limit', null, $request, [
'threshold' => $maxActive,
'new_token' => $refreshToken->id,
]);
});
}
}
return $composite;
}

View File

@@ -13,11 +13,14 @@ use App\Models\Package;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Services\PayPal\PaypalClientFactory;
use App\Services\Checkout\CheckoutWebhookService;
class PayPalWebhookController extends Controller
{
public function __construct(private PaypalClientFactory $clientFactory)
{
public function __construct(
private PaypalClientFactory $clientFactory,
private CheckoutWebhookService $checkoutWebhooks,
) {
}
public function verify(Request $request): JsonResponse
@@ -59,6 +62,10 @@ class PayPalWebhookController extends Controller
Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']);
if ($this->checkoutWebhooks->handlePayPalEvent($event)) {
return;
}
switch ($eventType) {
case 'CHECKOUT.ORDER.APPROVED':
// Handle order approval if needed