- Reworked the tenant admin login page

- Updated the User model to implement Filament’s tenancy contracts
- Seeded a ready-to-use demo tenant (user, tenant, active package, purchase)
- Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits
- Removed the public “Register” links from the marketing header
- hardened join event logic and improved error handling in the guest pwa.
This commit is contained in:
Codex Agent
2025-10-13 12:50:46 +02:00
parent 9394c3171e
commit 64a5411fb9
69 changed files with 5447 additions and 588 deletions

View File

@@ -6,11 +6,15 @@ use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Support\ImageHelper;
use App\Services\EventJoinTokenService;
use App\Models\EventJoinToken;
use Illuminate\Http\JsonResponse;
class EventPublicController extends BaseController
{
@@ -19,36 +23,122 @@ class EventPublicController extends BaseController
}
/**
* @return array{0: object|null, 1: \App\Models\EventJoinToken|null}
* @return JsonResponse|array{0: object, 1: EventJoinToken}
*/
private function resolvePublishedEvent(string $identifier, array $columns = ['id']): array
private function resolvePublishedEvent(Request $request, string $token, array $columns = ['id']): JsonResponse|array
{
$event = DB::table('events')
->where('slug', $identifier)
->where('status', 'published')
->first($columns);
$rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->ip());
if ($event) {
return [$event, null];
}
$joinToken = $this->joinTokenService->findToken($token, true);
$joinToken = $this->joinTokenService->findActiveToken($identifier);
if (! $joinToken) {
return [null, null];
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
]);
}
if ($joinToken->revoked_at !== null) {
return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [
'token' => Str::limit($token, 12),
]);
}
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(),
]);
}
}
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,
]);
}
$columns = array_unique(array_merge($columns, ['status']));
$event = DB::table('events')
->where('id', $joinToken->event_id)
->where('status', 'published')
->first($columns);
if (! $event) {
return [null, null];
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
'token' => Str::limit($token, 12),
'reason' => 'event_missing',
]);
}
if (($event->status ?? null) !== 'published') {
Log::notice('Join token event not public', [
'token' => Str::limit($token, 12),
'event_id' => $event->id ?? null,
'ip' => $request->ip(),
]);
return response()->json([
'error' => [
'code' => 'event_not_public',
'message' => 'This event is not publicly accessible.',
],
], Response::HTTP_FORBIDDEN);
}
RateLimiter::clear($rateLimiterKey);
if (isset($event->status)) {
unset($event->status);
}
return [$event, $joinToken];
}
private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse
{
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
Log::warning('Join token rate limit exceeded', array_merge([
'ip' => $request->ip(),
], $context));
return response()->json([
'error' => [
'code' => 'token_rate_limited',
'message' => 'Too many invalid join token attempts. Try again later.',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($rateLimiterKey, 300);
Log::notice('Join token access denied', array_merge([
'code' => $code,
'ip' => $request->ip(),
], $context));
return response()->json([
'error' => [
'code' => $code,
'message' => $this->tokenErrorMessage($code),
],
], $status);
}
private function tokenErrorMessage(string $code): string
{
return match ($code) {
'invalid_token' => 'The provided join token is invalid.',
'token_expired' => 'The join token has expired.',
'token_revoked' => 'The join token has been revoked.',
default => 'Access denied.',
};
}
private function getLocalized($value, $locale, $default = '') {
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -81,9 +171,9 @@ class EventPublicController extends BaseController
return $path; // fallback as-is
}
public function event(string $identifier)
public function event(Request $request, string $token)
{
[$event, $joinToken] = $this->resolvePublishedEvent($identifier, [
$result = $this->resolvePublishedEvent($request, $token, [
'id',
'slug',
'name',
@@ -92,11 +182,14 @@ class EventPublicController extends BaseController
'updated_at',
'event_type_id',
]);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
if ($result instanceof JsonResponse) {
return $result;
}
$locale = request()->query('locale', $event->default_locale ?? 'de');
[$event, $joinToken] = $result;
$locale = $request->query('locale', $event->default_locale ?? 'de');
$nameData = json_decode($event->name, true);
$localizedName = $nameData[$locale] ?? $nameData['de'] ?? $event->name;
@@ -133,13 +226,15 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store');
}
public function stats(string $identifier)
public function stats(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
// Approximate online guests as distinct recent uploaders in last 10 minutes.
@@ -162,7 +257,7 @@ class EventPublicController extends BaseController
];
$etag = sha1(json_encode($payload));
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -172,14 +267,17 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function emotions(string $identifier)
public function emotions(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$locale = $request->query('locale', 'de');
$rows = DB::table('emotions')
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
@@ -195,8 +293,7 @@ class EventPublicController extends BaseController
->orderBy('emotions.sort_order')
->get();
$payload = $rows->map(function ($r) {
$locale = request()->query('locale', 'de');
$payload = $rows->map(function ($r) use ($locale) {
$nameData = json_decode($r->name, true);
$name = $nameData[$locale] ?? $nameData['de'] ?? $r->name;
@@ -213,7 +310,7 @@ class EventPublicController extends BaseController
});
$etag = sha1($payload->toJson());
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -223,13 +320,15 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function tasks(string $identifier, Request $request)
public function tasks(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$query = DB::table('tasks')
@@ -249,11 +348,11 @@ class EventPublicController extends BaseController
->limit(20);
$rows = $query->get();
$locale = $request->query('locale', 'de');
$payload = $rows->map(function ($r) {
$payload = $rows->map(function ($r) use ($locale) {
// Handle JSON fields for multilingual content
$getLocalized = function ($field, $default = '') use ($r) {
$locale = request()->query('locale', 'de');
$getLocalized = function ($field, $default = '') use ($r, $locale) {
$value = $r->$field;
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -267,7 +366,6 @@ class EventPublicController extends BaseController
if ($r->emotion_id) {
$emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']);
if ($emotionRow && isset($emotionRow->name)) {
$locale = request()->query('locale', 'de');
$value = $emotionRow->name;
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
@@ -299,7 +397,7 @@ class EventPublicController extends BaseController
}
$etag = sha1($payload->toJson());
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -309,12 +407,15 @@ class EventPublicController extends BaseController
->header('ETag', $etag);
}
public function photos(Request $request, string $identifier)
public function photos(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon');
@@ -345,7 +446,7 @@ class EventPublicController extends BaseController
if ($since) {
$query->where('photos.created_at', '>', $since);
}
$locale = request()->query('locale', 'de');
$locale = $request->query('locale', 'de');
$rows = $query->get()->map(function ($r) use ($locale) {
$r->file_path = $this->toPublicUrl((string)($r->file_path ?? ''));
@@ -364,7 +465,7 @@ class EventPublicController extends BaseController
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt]));
$reqEtag = request()->headers->get('If-None-Match');
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
@@ -455,12 +556,15 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function upload(Request $request, string $identifier)
public function upload(Request $request, string $token)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$deviceId = (string) $request->header('X-Device-Id', 'anon');
@@ -556,11 +660,13 @@ class EventPublicController extends BaseController
}
public function achievements(Request $request, string $identifier)
{
[$event] = $this->resolvePublishedEvent($identifier, ['id']);
if (! $event) {
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
$result = $this->resolvePublishedEvent($request, $identifier, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$eventId = $event->id;
$locale = $request->query('locale', 'de');