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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user