Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -150,12 +150,16 @@ class EventPublicController extends BaseController
Response::HTTP_FORBIDDEN
);
return response()->json([
'error' => [
'code' => 'event_not_public',
'message' => 'This event is not publicly accessible.',
],
], Response::HTTP_FORBIDDEN);
return ApiError::response(
'event_not_public',
'Event Not Public',
'This event is not publicly accessible.',
Response::HTTP_FORBIDDEN,
[
'token' => Str::limit($token, 12),
'event_id' => $event->id ?? null,
]
);
}
RateLimiter::clear($rateLimiterKey);
@@ -199,12 +203,15 @@ class EventPublicController extends BaseController
$event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id);
if (! $event) {
return response()->json([
'error' => [
'code' => 'event_not_found',
'message' => 'The event associated with this gallery could not be located.',
],
], Response::HTTP_NOT_FOUND);
return ApiError::response(
'event_not_found',
'Event Not Found',
'The event associated with this gallery could not be located.',
Response::HTTP_NOT_FOUND,
[
'token' => Str::limit($token, 12),
]
);
}
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
@@ -222,13 +229,16 @@ class EventPublicController extends BaseController
Response::HTTP_GONE
);
return response()->json([
'error' => [
'code' => 'gallery_expired',
'message' => 'The gallery is no longer available for this event.',
return ApiError::response(
'gallery_expired',
'Gallery Expired',
'The gallery is no longer available for this event.',
Response::HTTP_GONE,
[
'event_id' => $event->id,
'expired_at' => $expiresAt->toIso8601String(),
],
], Response::HTTP_GONE);
]
);
}
$this->recordTokenEvent(
@@ -271,12 +281,13 @@ class EventPublicController extends BaseController
Response::HTTP_TOO_MANY_REQUESTS
);
return response()->json([
'error' => [
'code' => 'token_rate_limited',
'message' => 'Too many invalid join token attempts. Try again later.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
return ApiError::response(
'token_rate_limited',
'Too Many Attempts',
'Too many invalid join token attempts. Try again later.',
Response::HTTP_TOO_MANY_REQUESTS,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
);
}
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
@@ -295,12 +306,13 @@ class EventPublicController extends BaseController
$status
);
return response()->json([
'error' => [
'code' => $code,
'message' => $this->tokenErrorMessage($code),
],
], $status);
return ApiError::response(
$code,
$this->tokenErrorTitle($code),
$this->tokenErrorMessage($code),
$status,
$context
);
}
private function tokenErrorMessage(string $code): string
@@ -313,6 +325,17 @@ class EventPublicController extends BaseController
};
}
private function tokenErrorTitle(string $code): string
{
return match ($code) {
'invalid_token' => 'Invalid Join Token',
'token_expired' => 'Join Token Expired',
'token_revoked' => 'Join Token Revoked',
'token_rate_limited' => 'Join Token Rate Limited',
default => 'Access Denied',
};
}
private function recordTokenEvent(
?EventJoinToken $joinToken,
Request $request,
@@ -347,12 +370,16 @@ class EventPublicController extends BaseController
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);
return ApiError::response(
'access_rate_limited',
'Too Many Requests',
'Too many requests. Please slow down.',
Response::HTTP_TOO_MANY_REQUESTS,
[
'limit' => $limit,
'decay_minutes' => $decay,
]
);
}
RateLimiter::hit($key, $decay * 60);
@@ -383,12 +410,16 @@ class EventPublicController extends BaseController
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);
return ApiError::response(
'download_rate_limited',
'Download Rate Limited',
'Download rate limit exceeded. Please wait a moment.',
Response::HTTP_TOO_MANY_REQUESTS,
[
'limit' => $limit,
'decay_minutes' => $decay,
]
);
}
RateLimiter::hit($key, $decay * 60);
@@ -664,12 +695,16 @@ class EventPublicController extends BaseController
->first();
if (! $record) {
return response()->json([
'error' => [
'code' => 'photo_not_found',
'message' => 'The requested photo is no longer available.',
],
], Response::HTTP_NOT_FOUND);
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'The requested photo is no longer available.',
Response::HTTP_NOT_FOUND,
[
'photo_id' => $photo,
'event_id' => $event->id,
]
);
}
$variantPreference = $variant === 'thumbnail'
@@ -697,12 +732,16 @@ class EventPublicController extends BaseController
->first();
if (! $record) {
return response()->json([
'error' => [
'code' => 'photo_not_found',
'message' => 'The requested photo is no longer available.',
],
], Response::HTTP_NOT_FOUND);
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'The requested photo is no longer available.',
Response::HTTP_NOT_FOUND,
[
'photo_id' => $photo,
'event_id' => $event->id,
]
);
}
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
@@ -763,6 +802,69 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store');
}
public function package(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$eventRecord, $joinToken] = $result;
$event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])
->findOrFail($eventRecord->id);
if (! $event->tenant) {
return ApiError::response(
'event_not_found',
'Event not accessible',
'The selected event is no longer available.',
Response::HTTP_NOT_FOUND,
['scope' => 'photos', 'event_id' => $event->id]
);
}
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$event->tenant,
$event->id,
$event
);
if (! $eventPackage || ! $eventPackage->package) {
return response()->json([
'id' => null,
'event_id' => $event->id,
'package_id' => null,
'package' => null,
'used_photos' => (int) ($eventPackage?->used_photos ?? 0),
'used_guests' => (int) ($eventPackage?->used_guests ?? 0),
'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(),
'limits' => null,
])->header('Cache-Control', 'no-store');
}
$package = $eventPackage->package;
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
return response()->json([
'id' => $eventPackage->id,
'event_id' => $event->id,
'package_id' => $eventPackage->package_id,
'package' => [
'id' => $eventPackage->package_id,
'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name,
'max_photos' => $package?->max_photos,
'max_guests' => $package?->max_guests,
'gallery_days' => $package?->gallery_days,
],
'used_photos' => (int) $eventPackage->used_photos,
'used_guests' => (int) $eventPackage->used_guests,
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
'limits' => $summary,
])->header('Cache-Control', 'no-store');
}
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
{
foreach ($variantPreference as $variant) {
@@ -852,12 +954,16 @@ class EventPublicController extends BaseController
return redirect()->away($fallbackUrl);
}
return response()->json([
'error' => [
'code' => 'photo_unavailable',
'message' => 'The requested photo could not be loaded.',
],
], Response::HTTP_NOT_FOUND);
return ApiError::response(
'photo_unavailable',
'Photo Unavailable',
'The requested photo could not be loaded.',
Response::HTTP_NOT_FOUND,
[
'photo_id' => $record->id,
'event_id' => $event->id,
]
);
}
private function resolvePhotoVariant(Photo $record, string $variant): array
@@ -1191,7 +1297,13 @@ class EventPublicController extends BaseController
->where('events.status', 'published')
->first();
if (! $row) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404);
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $id]
);
}
$row->file_path = $this->toPublicUrl((string) ($row->file_path ?? ''));
$row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? ''));
@@ -1219,7 +1331,13 @@ class EventPublicController extends BaseController
->where('events.status', 'published')
->first(['photos.id', 'photos.event_id']);
if (! $photo) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Photo not found or event not public']], 404);
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $id]
);
}
// Idempotent like per device

View File

@@ -3,8 +3,10 @@
namespace App\Http\Controllers\Api;
use App\Models\LegalPage;
use App\Support\ApiError;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\Response;
class LegalController extends BaseController
{
@@ -27,7 +29,13 @@ class LegalController extends BaseController
->orderByDesc('version')
->first();
if (! $page) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Legal page not found']], 404);
return ApiError::response(
'legal_page_not_found',
'Legal Page Not Found',
'The requested legal document does not exist.',
Response::HTTP_NOT_FOUND,
['slug' => $resolved]
);
}
$title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug;

View File

@@ -16,9 +16,7 @@ use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(private CheckoutWebhookService $checkoutWebhooks)
{
}
public function __construct(private CheckoutWebhookService $checkoutWebhooks) {}
public function handleWebhook(Request $request)
{
@@ -33,9 +31,19 @@ class StripeWebhookController extends Controller
$endpointSecret
);
} catch (SignatureVerificationException $e) {
return response()->json(['error' => 'Invalid signature'], 400);
return ApiError::response(
'stripe_invalid_signature',
'Ungültige Signatur',
'Die Signatur der Stripe-Anfrage ist ungültig.',
400
);
} catch (\UnexpectedValueException $e) {
return response()->json(['error' => 'Invalid payload'], 400);
return ApiError::response(
'stripe_invalid_payload',
'Ungültige Daten',
'Der Stripe Payload konnte nicht gelesen werden.',
400
);
}
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
@@ -78,6 +86,7 @@ class StripeWebhookController extends Controller
if (! $packageId || ! $type) {
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
return;
}
@@ -178,4 +187,3 @@ class StripeWebhookController extends Controller
]);
}
}

View File

@@ -13,6 +13,7 @@ use App\Models\Package;
use App\Models\Photo;
use App\Models\Tenant;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -204,7 +205,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$event->load([
@@ -228,7 +235,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$validated = $request->validated();
@@ -264,7 +277,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$event->delete();
@@ -279,7 +298,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$totalPhotos = Photo::where('event_id', $event->id)->count();
@@ -304,7 +329,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$event->load(['eventType', 'eventPackage.package']);
@@ -439,7 +470,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$activate = ! (bool) $event->is_active;
@@ -466,7 +503,13 @@ class EventController extends Controller
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
return ApiError::response(
'event_not_found',
'Event not accessible',
'Das Event konnte nicht gefunden werden.',
404,
['event_slug' => $event->slug ?? null]
);
}
$validated = $request->validate([

View File

@@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class PhotoController extends Controller
{
@@ -46,27 +47,16 @@ class PhotoController extends Controller
->where('tenant_id', $tenantId)
->firstOrFail();
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']);
$tenant = $event->tenant;
if ($tenant) {
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
}
$eventPackage = $tenant
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
: null;
$previousUsedPhotos = $eventPackage?->used_photos ?? 0;
$limitSummary = $eventPackage
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
: null;
$query = Photo::where('event_id', $event->id)
->with('event')->withCount('likes')
@@ -84,7 +74,9 @@ class PhotoController extends Controller
$perPage = $request->get('per_page', 20);
$photos = $query->paginate($perPage);
return PhotoResource::collection($photos);
return PhotoResource::collection($photos)->additional([
'limits' => $limitSummary,
]);
}
/**
@@ -97,6 +89,29 @@ class PhotoController extends Controller
->where('tenant_id', $tenantId)
->firstOrFail();
$event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']);
$tenant = $event->tenant;
$eventPackage = $tenant
? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event)
: null;
if ($tenant) {
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
}
$previousUsedPhotos = $eventPackage?->used_photos ?? 0;
$validated = $request->validated();
$file = $request->file('photo');
@@ -197,12 +212,17 @@ class PhotoController extends Controller
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1);
}
$limitSummary = $eventPackage
? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage)
: null;
$photo->load('event')->loadCount('likes');
return response()->json([
'message' => 'Photo uploaded successfully. Awaiting moderation.',
'data' => new PhotoResource($photo),
'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.',
'limits' => $limitSummary,
], 201);
}
@@ -217,7 +237,13 @@ class PhotoController extends Controller
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
return ApiError::response(
'photo_not_found',
'Foto nicht gefunden',
'Das Foto gehört nicht zu diesem Event.',
404,
['photo_id' => $photo->id]
);
}
$photo->load('event')->loadCount('likes');
@@ -239,7 +265,13 @@ class PhotoController extends Controller
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
return ApiError::response(
'photo_not_found',
'Foto nicht gefunden',
'Das Foto gehört nicht zu diesem Event.',
404,
['photo_id' => $photo->id]
);
}
$validated = $request->validate([
@@ -251,7 +283,13 @@ class PhotoController extends Controller
// Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
return response()->json(['error' => 'Insufficient scopes'], 403);
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write']
);
}
$photo->update($validated);
@@ -279,7 +317,13 @@ class PhotoController extends Controller
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
return ApiError::response(
'photo_not_found',
'Foto nicht gefunden',
'Das Foto gehört nicht zu diesem Event.',
404,
['photo_id' => $photo->id]
);
}
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
@@ -303,6 +347,9 @@ class PhotoController extends Controller
Storage::disk($fallbackDisk)->delete([$photo->path, $photo->thumbnail_path]);
}
$eventPackage = $event->eventPackage;
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
// Delete record and likes
DB::transaction(function () use ($photo, $assets) {
$photo->likes()->delete();
@@ -312,6 +359,15 @@ class PhotoController extends Controller
$photo->delete();
});
if ($eventPackage && $eventPackage->package) {
$previousUsed = (int) $eventPackage->used_photos;
if ($previousUsed > 0) {
$eventPackage->decrement('used_photos');
$eventPackage->refresh();
$usageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
}
}
return response()->json([
'message' => 'Photo deleted successfully',
]);
@@ -328,7 +384,13 @@ class PhotoController extends Controller
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id, 'event_id' => $event->id]
);
}
$photo->update(['is_featured' => true]);
@@ -345,7 +407,13 @@ class PhotoController extends Controller
->firstOrFail();
if ($photo->event_id !== $event->id) {
return response()->json(['error' => 'Photo not found'], 404);
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id, 'event_id' => $event->id]
);
}
$photo->update(['is_featured' => false]);
@@ -569,7 +637,13 @@ class PhotoController extends Controller
]);
if ($request->event_id !== $event->id) {
return response()->json(['error' => 'Invalid event ID'], 400);
return ApiError::response(
'event_mismatch',
'Invalid Event',
'The provided event does not match the authenticated tenant event.',
Response::HTTP_BAD_REQUEST,
['payload_event_id' => $request->event_id, 'expected_event_id' => $event->id]
);
}
$event->load('storageAssignments.storageTarget');

View File

@@ -3,10 +3,14 @@
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\NotificationPreferencesRequest;
use App\Http\Requests\Tenant\SettingsStoreRequest;
use App\Models\Tenant;
use App\Services\Packages\TenantNotificationPreferences;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SettingsController extends Controller
{
@@ -27,6 +31,62 @@ class SettingsController extends Controller
]);
}
public function notificationPreferences(
Request $request,
TenantNotificationPreferences $preferencesService
): JsonResponse {
$tenant = $request->tenant;
$defaults = TenantNotificationPreferences::defaults();
$resolved = [];
foreach (array_keys($defaults) as $key) {
$resolved[$key] = $preferencesService->shouldNotify($tenant, $key);
}
return response()->json([
'data' => [
'defaults' => $defaults,
'preferences' => $resolved,
'overrides' => $tenant->notification_preferences ?? null,
'meta' => [
'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(),
'credit_warning_threshold' => $tenant->credit_warning_threshold,
],
],
]);
}
public function updateNotificationPreferences(
NotificationPreferencesRequest $request,
TenantNotificationPreferences $preferencesService
): JsonResponse {
$tenant = $request->tenant;
$payload = $request->validated()['preferences'];
$tenant->update([
'notification_preferences' => $payload,
]);
$tenant->refresh();
$resolved = [];
foreach (array_keys(TenantNotificationPreferences::defaults()) as $key) {
$resolved[$key] = $preferencesService->shouldNotify($tenant->fresh(), $key);
}
return response()->json([
'message' => 'Benachrichtigungseinstellungen aktualisiert.',
'data' => [
'preferences' => $resolved,
'overrides' => $tenant->notification_preferences,
'meta' => [
'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(),
'credit_warning_threshold' => $tenant->credit_warning_threshold,
],
],
]);
}
/**
* Update the tenant's settings.
*/
@@ -98,7 +158,12 @@ class SettingsController extends Controller
$domain = $request->input('domain');
if (! $domain) {
return response()->json(['error' => 'Domain ist erforderlich.'], 400);
return ApiError::response(
'domain_missing',
'Domain erforderlich',
'Bitte gib eine Domain an.',
Response::HTTP_BAD_REQUEST
);
}
if (! $this->isValidDomain($domain)) {

View File

@@ -6,20 +6,19 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\TaskStoreRequest;
use App\Http\Requests\Tenant\TaskUpdateRequest;
use App\Http\Resources\Tenant\TaskResource;
use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Event;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class TaskController extends Controller
{
/**
* Display a listing of the tenant's tasks.
*
* @param Request $request
* @return AnonymousResourceCollection
*/
public function index(Request $request): AnonymousResourceCollection
{
@@ -38,7 +37,7 @@ class TaskController extends Controller
// Search and filters
if ($search = $request->get('search')) {
$query->where(function ($inner) use ($search) {
$like = '%' . $search . '%';
$like = '%'.$search.'%';
$inner->where('title->de', 'like', $like)
->orWhere('title->en', 'like', $like)
->orWhere('description->de', 'like', $like)
@@ -47,11 +46,11 @@ class TaskController extends Controller
}
if ($collectionId = $request->get('collection_id')) {
$query->whereHas('taskCollection', fn($q) => $q->where('id', $collectionId));
$query->whereHas('taskCollection', fn ($q) => $q->where('id', $collectionId));
}
if ($eventId = $request->get('event_id')) {
$query->whereHas('assignedEvents', fn($q) => $q->where('id', $eventId));
$query->whereHas('assignedEvents', fn ($q) => $q->where('id', $eventId));
}
$perPage = $request->get('per_page', 15);
@@ -62,9 +61,6 @@ class TaskController extends Controller
/**
* Store a newly created task in storage.
*
* @param TaskStoreRequest $request
* @return JsonResponse
*/
public function store(TaskStoreRequest $request): JsonResponse
{
@@ -91,10 +87,6 @@ class TaskController extends Controller
/**
* Display the specified task.
*
* @param Request $request
* @param Task $task
* @return JsonResponse
*/
public function show(Request $request, Task $task): JsonResponse
{
@@ -109,10 +101,6 @@ class TaskController extends Controller
/**
* Update the specified task in storage.
*
* @param TaskUpdateRequest $request
* @param Task $task
* @return JsonResponse
*/
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{
@@ -142,10 +130,6 @@ class TaskController extends Controller
/**
* Remove the specified task from storage.
*
* @param Request $request
* @param Task $task
* @return JsonResponse
*/
public function destroy(Request $request, Task $task): JsonResponse
{
@@ -162,11 +146,6 @@ class TaskController extends Controller
/**
* Assign task to an event.
*
* @param Request $request
* @param Task $task
* @param Event $event
* @return JsonResponse
*/
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{
@@ -187,10 +166,6 @@ class TaskController extends Controller
/**
* Bulk assign tasks to an event.
*
* @param Request $request
* @param Event $event
* @return JsonResponse
*/
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{
@@ -200,7 +175,12 @@ class TaskController extends Controller
$taskIds = $request->input('task_ids', []);
if (empty($taskIds)) {
return response()->json(['error' => 'Keine Task-IDs angegeben.'], 400);
return ApiError::response(
'task_ids_missing',
'Keine Aufgaben angegeben',
'Bitte wähle mindestens eine Aufgabe aus.',
Response::HTTP_BAD_REQUEST
);
}
$tasks = Task::whereIn('id', $taskIds)
@@ -209,7 +189,7 @@ class TaskController extends Controller
$attached = 0;
foreach ($tasks as $task) {
if (!$task->assignedEvents()->where('event_id', $event->id)->exists()) {
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
$task->assignedEvents()->attach($event->id);
$attached++;
}
@@ -222,10 +202,6 @@ class TaskController extends Controller
/**
* Get tasks for a specific event.
*
* @param Request $request
* @param Event $event
* @return AnonymousResourceCollection
*/
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
{
@@ -233,7 +209,7 @@ class TaskController extends Controller
abort(404);
}
$tasks = Task::whereHas('assignedEvents', fn($q) => $q->where('event_id', $event->id))
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
->with(['taskCollection'])
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15));
@@ -243,10 +219,6 @@ class TaskController extends Controller
/**
* Get tasks from a specific collection.
*
* @param Request $request
* @param TaskCollection $collection
* @return AnonymousResourceCollection
*/
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
{
@@ -321,9 +293,7 @@ class TaskController extends Controller
}
/**
* @param mixed $value
* @param array<string, string>|null $fallback
*
* @param array<string, string>|null $fallback
* @return array<string, string>|null
*/
protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array

View File

@@ -6,32 +6,40 @@ use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantController extends BaseController
{
public function __construct(private readonly EventJoinTokenService $joinTokenService)
{
}
public function __construct(private readonly EventJoinTokenService $joinTokenService) {}
public function login(Request $request)
{
$creds = $request->validate([
'email' => ['required','email'],
'password' => ['required','string'],
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($creds)) {
return response()->json(['error' => ['code' => 'invalid_credentials']], 401);
return ApiError::response(
'invalid_credentials',
'Invalid Credentials',
'The provided credentials are incorrect.',
Response::HTTP_UNAUTHORIZED,
['email' => $creds['email']]
);
}
/** @var User $user */
$user = Auth::user();
// naive token (cache-based), expires in 8 hours
$token = Str::random(80);
Cache::put('api_token:'.$token, $user->id, now()->addHours(8));
return response()->json([
'token' => $token,
'user' => [
@@ -46,6 +54,7 @@ class TenantController extends BaseController
public function me(Request $request)
{
$u = Auth::user();
return response()->json([
'id' => $u->id,
'name' => $u->name,
@@ -62,7 +71,8 @@ class TenantController extends BaseController
if ($tenantId) {
$q->where('tenant_id', $tenantId);
}
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id','name','slug','date','is_active'])]);
return response()->json(['data' => $q->orderByDesc('created_at')->limit(100)->get(['id', 'name', 'slug', 'date', 'is_active'])]);
}
public function showEvent(int $id)
@@ -71,9 +81,10 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.show', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
return response()->json($ev->only(['id','name','slug','date','is_active','default_locale']));
return response()->json($ev->only(['id', 'name', 'slug', 'date', 'is_active', 'default_locale']));
}
public function storeEvent(Request $request)
@@ -81,19 +92,20 @@ class TenantController extends BaseController
$u = Auth::user();
$tenantId = $u->tenant_id ?? null;
$data = $request->validate([
'name' => ['required','string','max:255'],
'slug' => ['required','string','max:255'],
'date' => ['nullable','date'],
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'date' => ['nullable', 'date'],
'is_active' => ['boolean'],
]);
$ev = new Event();
$ev = new Event;
$ev->tenant_id = $tenantId ?? $ev->tenant_id;
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
$ev->slug = $data['slug'];
$ev->date = $data['date'] ?? null;
$ev->is_active = (bool)($data['is_active'] ?? true);
$ev->is_active = (bool) ($data['is_active'] ?? true);
$ev->default_locale = 'de';
$ev->save();
return response()->json(['id' => $ev->id]);
}
@@ -103,19 +115,28 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.update', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
$data = $request->validate([
'name' => ['nullable','string','max:255'],
'slug' => ['nullable','string','max:255'],
'date' => ['nullable','date'],
'is_active' => ['nullable','boolean'],
'name' => ['nullable', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'date' => ['nullable', 'date'],
'is_active' => ['nullable', 'boolean'],
]);
if (isset($data['name'])) $ev->name = ['de' => $data['name'], 'en' => $data['name']];
if (isset($data['slug'])) $ev->slug = $data['slug'];
if (array_key_exists('date', $data)) $ev->date = $data['date'];
if (array_key_exists('is_active', $data)) $ev->is_active = (bool)$data['is_active'];
if (isset($data['name'])) {
$ev->name = ['de' => $data['name'], 'en' => $data['name']];
}
if (isset($data['slug'])) {
$ev->slug = $data['slug'];
}
if (array_key_exists('date', $data)) {
$ev->date = $data['date'];
}
if (array_key_exists('is_active', $data)) {
$ev->is_active = (bool) $data['is_active'];
}
$ev->save();
return response()->json(['ok' => true]);
}
@@ -125,11 +146,12 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.toggle', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
$ev->is_active = ! (bool) $ev->is_active;
$ev->save();
return response()->json(['is_active' => (bool)$ev->is_active]);
return response()->json(['is_active' => (bool) $ev->is_active]);
}
public function eventStats(int $id)
@@ -138,15 +160,16 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.stats', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
$total = Photo::where('event_id', $id)->count();
$featured = Photo::where('event_id', $id)->where('is_featured', 1)->count();
$likes = Photo::where('event_id', $id)->sum('likes_count');
return response()->json([
'total' => (int)$total,
'featured' => (int)$featured,
'likes' => (int)$likes,
'total' => (int) $total,
'featured' => (int) $featured,
'likes' => (int) $likes,
]);
}
@@ -156,7 +179,7 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.invite', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
$joinToken = $this->joinTokenService->createToken($ev, [
@@ -176,9 +199,10 @@ class TenantController extends BaseController
$tenantId = $u->tenant_id ?? null;
$ev = Event::findOrFail($id);
if ($tenantId && $ev->tenant_id !== $tenantId) {
return response()->json(['error' => ['code' => 'forbidden']], 403);
return $this->forbiddenResponse('events.photos', ['event_id' => $ev->id, 'tenant_id' => $tenantId]);
}
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id','thumbnail_path','file_path','likes_count','is_featured','created_at']);
$rows = Photo::where('event_id', $id)->orderByDesc('created_at')->limit(100)->get(['id', 'thumbnail_path', 'file_path', 'likes_count', 'is_featured', 'created_at']);
return response()->json(['data' => $rows]);
}
@@ -186,7 +210,9 @@ class TenantController extends BaseController
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 1; $p->save();
$p->is_featured = 1;
$p->save();
return response()->json(['ok' => true]);
}
@@ -194,7 +220,9 @@ class TenantController extends BaseController
{
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->is_featured = 0; $p->save();
$p->is_featured = 0;
$p->save();
return response()->json(['ok' => true]);
}
@@ -203,9 +231,21 @@ class TenantController extends BaseController
$p = Photo::findOrFail($photoId);
$this->authorizePhoto($p);
$p->delete();
return response()->json(['ok' => true]);
}
private function forbiddenResponse(string $action, array $meta = []): JsonResponse
{
return ApiError::response(
'forbidden',
'Forbidden',
'You are not allowed to perform this action.',
Response::HTTP_FORBIDDEN,
array_merge(['action' => $action], $meta)
);
}
protected function authorizePhoto(Photo $p): void
{
$u = Auth::user();

View File

@@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\TenantPackage;
use Illuminate\Http\Request;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TenantPackageController extends Controller
{
@@ -13,8 +15,13 @@ class TenantPackageController extends Controller
{
$tenant = $request->attributes->get('tenant');
if (!$tenant) {
return response()->json(['error' => 'Tenant not found.'], 404);
if (! $tenant) {
return ApiError::response(
'tenant_not_found',
'Tenant Not Found',
'The authenticated tenant context could not be resolved.',
Response::HTTP_NOT_FOUND
);
}
$packages = TenantPackage::where('tenant_id', $tenant->id)
@@ -33,4 +40,4 @@ class TenantPackageController extends Controller
'message' => 'Tenant packages loaded successfully.',
]);
}
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use App\Support\ApiError;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class OAuthController extends Controller
{
@@ -690,7 +692,12 @@ class OAuthController extends Controller
{
$tenant = $request->user()->tenant ?? null;
if (! $tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
return ApiError::response(
'tenant_not_found',
'Tenant not found',
'The authenticated user is not assigned to a tenant.',
Response::HTTP_NOT_FOUND
);
}
$state = Str::random(40);

View File

@@ -3,9 +3,11 @@
namespace App\Http\Controllers;
use App\Jobs\ProcessRevenueCatWebhook;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class RevenueCatWebhookController extends Controller
{
@@ -15,17 +17,33 @@ class RevenueCatWebhookController extends Controller
if ($secret === '') {
Log::error('RevenueCat webhook secret not configured');
return response()->json(['error' => 'Webhook not configured'], 500);
return ApiError::response(
'webhook_not_configured',
'Webhook Not Configured',
'RevenueCat webhook secret is missing.',
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
$signature = trim((string) $request->header('X-Signature', ''));
if ($signature === '') {
return response()->json(['error' => 'Signature missing'], 400);
return ApiError::response(
'signature_missing',
'Signature Missing',
'The RevenueCat webhook request did not include a signature.',
Response::HTTP_BAD_REQUEST
);
}
$payload = $request->getContent();
if (! $this->signatureMatches($payload, $signature, $secret)) {
return response()->json(['error' => 'Invalid signature'], 400);
return ApiError::response(
'signature_invalid',
'Invalid Signature',
'The webhook signature could not be validated.',
Response::HTTP_BAD_REQUEST
);
}
$decoded = json_decode($payload, true);
@@ -33,7 +51,14 @@ class RevenueCatWebhookController extends Controller
Log::warning('RevenueCat webhook received invalid JSON', [
'error' => json_last_error_msg(),
]);
return response()->json(['error' => 'Invalid payload'], 400);
return ApiError::response(
'payload_invalid',
'Invalid Payload',
'The webhook payload could not be decoded as JSON.',
Response::HTTP_BAD_REQUEST,
['json_error' => json_last_error_msg()]
);
}
ProcessRevenueCatWebhook::dispatch(

View File

@@ -2,14 +2,15 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Package;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use App\Models\Package;
use App\Models\Tenant;
use Stripe\Stripe;
use Symfony\Component\HttpFoundation\Response;
class StripePaymentController extends Controller
{
@@ -25,13 +26,23 @@ class StripePaymentController extends Controller
]);
$user = Auth::user();
if (!$user) {
return response()->json(['error' => 'Nicht authentifiziert'], 401);
if (! $user) {
return ApiError::response(
'unauthenticated',
'Nicht authentifiziert',
'Bitte melde dich an, um einen Kauf zu starten.',
Response::HTTP_UNAUTHORIZED
);
}
$tenant = $user->tenant;
if (!$tenant) {
return response()->json(['error' => 'Kein Tenant gefunden'], 403);
if (! $tenant) {
return ApiError::response(
'tenant_not_found',
'Tenant nicht gefunden',
'Für dein Benutzerkonto konnte kein Tenant gefunden werden.',
Response::HTTP_FORBIDDEN
);
}
$package = Package::findOrFail($request->package_id);
@@ -40,13 +51,13 @@ class StripePaymentController extends Controller
if ($package->price <= 0) {
return response()->json([
'type' => 'free',
'message' => 'Kostenloses Paket - kein Payment Intent nötig'
'message' => 'Kostenloses Paket - kein Payment Intent nötig',
]);
}
try {
$paymentIntent = PaymentIntent::create([
'amount' => (int)($package->price * 100), // In Cent
'amount' => (int) ($package->price * 100), // In Cent
'currency' => 'eur',
'metadata' => [
'package_id' => $package->id,
@@ -65,7 +76,7 @@ class StripePaymentController extends Controller
'payment_intent_id' => $paymentIntent->id,
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'amount' => $package->price
'amount' => $package->price,
]);
return response()->json([
@@ -76,10 +87,16 @@ class StripePaymentController extends Controller
Log::error('Stripe Payment Intent Fehler', [
'error' => $e->getMessage(),
'package_id' => $request->package_id,
'user_id' => $user->id
'user_id' => $user->id,
]);
return response()->json(['error' => $e->getMessage()], 400);
return ApiError::response(
'stripe_payment_error',
'Stripe Fehler',
'Die Zahlung konnte nicht vorbereitet werden.',
Response::HTTP_BAD_REQUEST,
['stripe_message' => $e->getMessage()]
);
}
}
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Auth;
use App\Models\User;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class ApiTokenAuth
{
@@ -14,19 +17,30 @@ class ApiTokenAuth
{
$header = $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer ')) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
return $this->unauthorizedResponse('missing_bearer');
}
$token = substr($header, 7);
$userId = Cache::get('api_token:'.$token);
if (! $userId) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
return $this->unauthorizedResponse('token_unknown');
}
$user = User::find($userId);
if (! $user) {
return response()->json(['error' => ['code' => 'unauthorized']], 401);
return $this->unauthorizedResponse('user_missing');
}
Auth::login($user); // for policies if needed
return $next($request);
}
}
private function unauthorizedResponse(string $reason): JsonResponse
{
return ApiError::response(
'unauthorized',
'Unauthorized',
'Authentication is required to access this resource.',
Response::HTTP_UNAUTHORIZED,
['reason' => $reason]
);
}
}

View File

@@ -2,9 +2,12 @@
namespace App\Http\Middleware;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class TenantIsolation
{
@@ -15,15 +18,15 @@ class TenantIsolation
{
$tenantId = $request->attributes->get('tenant_id');
if (!$tenantId) {
return response()->json(['error' => 'Tenant ID not found in token'], 401);
if (! $tenantId) {
return $this->missingTenantIdResponse();
}
// Get the tenant from request (query param, route param, or header)
$requestTenantId = $this->getTenantIdFromRequest($request);
if ($requestTenantId && $requestTenantId != $tenantId) {
return response()->json(['error' => 'Tenant isolation violation'], 403);
return $this->tenantIsolationViolationResponse((int) $tenantId, (int) $requestTenantId);
}
// Set tenant context for query scoping
@@ -32,7 +35,6 @@ class TenantIsolation
$connection->statement('SET @tenant_id = ?', [$tenantId]);
}
// Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId);
@@ -62,4 +64,28 @@ class TenantIsolation
// 4. For tenant-specific resources, use token tenant_id
return null;
}
private function missingTenantIdResponse(): JsonResponse
{
return ApiError::response(
'tenant_context_missing',
'Tenant Context Missing',
'Tenant ID not found in access token.',
Response::HTTP_UNAUTHORIZED
);
}
private function tenantIsolationViolationResponse(int $tokenTenantId, int $requestTenantId): JsonResponse
{
return ApiError::response(
'tenant_isolation_violation',
'Tenant Isolation Violation',
'The requested resource belongs to a different tenant.',
Response::HTTP_FORBIDDEN,
[
'token_tenant_id' => $tokenTenantId,
'request_tenant_id' => $requestTenantId,
]
);
}
}

View File

@@ -4,15 +4,18 @@ namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\TenantToken;
use App\Support\ApiError;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\File;
use Illuminate\Auth\GenericUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantTokenGuard
{
@@ -26,36 +29,76 @@ class TenantTokenGuard
$token = $this->getTokenFromRequest($request);
if (! $token) {
return response()->json(['error' => 'Token not provided'], 401);
return $this->errorResponse(
'token_missing',
'Token Missing',
'Authentication token not provided.',
Response::HTTP_UNAUTHORIZED
);
}
try {
$decoded = $this->decodeToken($token);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid token'], 401);
return $this->errorResponse(
'token_invalid',
'Invalid Token',
'Authentication token cannot be decoded.',
Response::HTTP_UNAUTHORIZED
);
}
if ($this->isTokenBlacklisted($decoded)) {
return response()->json(['error' => 'Token has been revoked'], 401);
return $this->errorResponse(
'token_revoked',
'Token Revoked',
'The provided token is no longer valid.',
Response::HTTP_UNAUTHORIZED,
['jti' => $decoded['jti'] ?? null]
);
}
if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
return response()->json(['error' => 'Insufficient scopes'], 403);
return $this->errorResponse(
'token_scope_violation',
'Insufficient Scopes',
'The provided token does not include the required scopes.',
Response::HTTP_FORBIDDEN,
['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []]
);
}
if (($decoded['exp'] ?? 0) < time()) {
$this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401);
return $this->errorResponse(
'token_expired',
'Token Expired',
'Authentication token has expired.',
Response::HTTP_UNAUTHORIZED,
['expired_at' => $decoded['exp'] ?? null]
);
}
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
if (! $tenantId) {
return response()->json(['error' => 'Invalid token payload'], 401);
return $this->errorResponse(
'token_payload_invalid',
'Invalid Token Payload',
'Authentication token does not include tenant context.',
Response::HTTP_UNAUTHORIZED
);
}
$tenant = Tenant::query()->find($tenantId);
if (! $tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
return $this->errorResponse(
'tenant_not_found',
'Tenant Not Found',
'The tenant belonging to the token could not be located.',
Response::HTTP_NOT_FOUND,
['tenant_id' => $tenantId]
);
}
$scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
@@ -127,6 +170,7 @@ class TenantTokenGuard
}
$decodedHeader = json_decode(base64_decode($segments[0]), true);
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
}
@@ -170,12 +214,14 @@ class TenantTokenGuard
if ($tokenRecord->revoked_at) {
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
return true;
}
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
$tokenRecord->update(['revoked_at' => now()]);
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
return true;
}
@@ -187,7 +233,7 @@ class TenantTokenGuard
*/
private function blacklistToken(array $decoded): void
{
$jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? ''));
$jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '').($decoded['iat'] ?? ''));
$cacheKey = "blacklisted_token:{$jti}";
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
@@ -201,6 +247,7 @@ class TenantTokenGuard
'revoked_at' => now(),
'expires_at' => $record->expires_at ?? now(),
]);
return;
}
@@ -254,5 +301,9 @@ class TenantTokenGuard
return $ttl;
}
}
private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse
{
return ApiError::response($code, $title, $message, $status, $meta);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Tenant;
use App\Services\Packages\TenantNotificationPreferences;
use Illuminate\Foundation\Http\FormRequest;
class NotificationPreferencesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'preferences' => ['required', 'array'],
];
foreach (array_keys(TenantNotificationPreferences::defaults()) as $key) {
$rules["preferences.{$key}"] = ['required', 'boolean'];
}
return $rules;
}
protected function prepareForValidation(): void
{
$this->merge([
'preferences' => $this->input('preferences', []),
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Resources\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
@@ -29,6 +30,11 @@ class EventResource extends JsonResource
}
}
$limitEvaluator = null;
if ($eventPackage) {
$limitEvaluator = app()->make(PackageLimitEvaluator::class);
}
return [
'id' => $this->id,
'name' => $this->name,
@@ -67,6 +73,9 @@ class EventResource extends JsonResource
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
] : null,
'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage)
: null,
];
}
}