- 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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user