From f8c953d37cadba874f088061887e13a9e98ff5e8 Mon Sep 17 00:00:00 2001 From: fatihalp Date: Tue, 10 Mar 2026 04:12:59 +0300 Subject: [PATCH] Add Turnstile protection demo --- .env.example | 4 + .../App/Http/Controllers/DemoController.php | 30 +++- .../Demo/App/Support/TurnstileVerifier.php | 72 ++++++++++ Modules/Demo/routes/web.php | 4 +- .../Database/Seeders/ListingSeeder.php | 28 +++- Modules/Listing/Models/Listing.php | 4 + .../Support/SampleListingImageCatalog.php | 26 ++-- README.md | 78 +++++++++- config/demo.php | 7 + resources/views/home.blade.php | 134 +++++++++++++++++- resources/views/layouts/app.blade.php | 16 ++- ...newuIrr7TSrR9tWi3mNzIaPyYcard-desktop.jpeg | Bin 0 -> 91036 bytes ...-c-at-ccom-jobs-part-time-card-mobile.webp | Bin 0 -> 22910 bytes ...t-ccom-jobs-part-time-gallery-desktop.webp | Bin 0 -> 43622 bytes ...at-ccom-jobs-part-time-gallery-mobile.webp | Bin 0 -> 27752 bytes .../gmlkdihLkwWyvqiCvHBkPyjmhWYsYC4m.jpeg | Bin 0 -> 91036 bytes 16 files changed, 373 insertions(+), 30 deletions(-) create mode 100644 Modules/Demo/App/Support/TurnstileVerifier.php create mode 100644 storage/media-library/temp/eiV46Wh0WwLAwxflypum9elLZwhJTItH/IBqFma9newuIrr7TSrR9tWi3mNzIaPyYcard-desktop.jpeg create mode 100644 storage/media-library/temp/eiV46Wh0WwLAwxflypum9elLZwhJTItH/demo-c-at-ccom-jobs-part-time-card-mobile.webp create mode 100644 storage/media-library/temp/eiV46Wh0WwLAwxflypum9elLZwhJTItH/demo-c-at-ccom-jobs-part-time-gallery-desktop.webp create mode 100644 storage/media-library/temp/eiV46Wh0WwLAwxflypum9elLZwhJTItH/demo-c-at-ccom-jobs-part-time-gallery-mobile.webp create mode 100644 storage/media-library/temp/eiV46Wh0WwLAwxflypum9elLZwhJTItH/gmlkdihLkwWyvqiCvHBkPyjmhWYsYC4m.jpeg diff --git a/.env.example b/.env.example index 4d5f3db8d..bf523d91c 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,7 @@ QUICK_LISTING_AI_MODEL=gpt-5.2 DEMO=0 DEMO_TTL_MINUTES=360 +DEMO_TURNSTILE_ENABLED=0 +TURNSTILE_SITE_KEY=0x4AAAAAACogGCt62w6ahqM4 +TURNSTILE_SECRET_KEY=0x4AAAAAACogGLdg-1mydGAW8FT_He6DTI8 +TURNSTILE_TIMEOUT_SECONDS=8 diff --git a/Modules/Demo/App/Http/Controllers/DemoController.php b/Modules/Demo/App/Http/Controllers/DemoController.php index bf6d4ac57..61ed87652 100644 --- a/Modules/Demo/App/Http/Controllers/DemoController.php +++ b/Modules/Demo/App/Http/Controllers/DemoController.php @@ -8,11 +8,16 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cookie; use Modules\Demo\App\Support\DemoSchemaManager; +use Modules\Demo\App\Support\TurnstileVerifier; use Throwable; class DemoController extends Controller { - public function prepare(Request $request, DemoSchemaManager $demoSchemaManager): RedirectResponse + public function prepare( + Request $request, + DemoSchemaManager $demoSchemaManager, + TurnstileVerifier $turnstileVerifier, + ): RedirectResponse { abort_unless(config('demo.enabled'), 404); @@ -20,6 +25,29 @@ class DemoController extends Controller $redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to')) ?? route('home'); + if ($turnstileVerifier->enabled() && ! $turnstileVerifier->configured()) { + return redirect() + ->to($redirectTo) + ->with('error', 'Security verification is unavailable right now. Please contact support.'); + } + + if (! $turnstileVerifier->verify( + $request->input('cf-turnstile-response'), + $request->ip(), + )) { + return redirect() + ->to($redirectTo) + ->with('error', 'Security verification failed. Please complete the check and try again.'); + } + + if (function_exists('set_time_limit')) { + @set_time_limit(300); + } + + if (function_exists('ignore_user_abort')) { + @ignore_user_abort(true); + } + try { $instance = $demoSchemaManager->prepare($request->cookie($cookieName)); $user = $demoSchemaManager->resolveLoginUser(); diff --git a/Modules/Demo/App/Support/TurnstileVerifier.php b/Modules/Demo/App/Support/TurnstileVerifier.php new file mode 100644 index 000000000..386c75dd9 --- /dev/null +++ b/Modules/Demo/App/Support/TurnstileVerifier.php @@ -0,0 +1,72 @@ +siteKey() !== '' && $this->secretKey() !== ''; + } + + public function verify(?string $token, ?string $ip = null): bool + { + if (! $this->enabled()) { + return true; + } + + if (! $this->configured()) { + return false; + } + + $token = trim((string) $token); + + if ($token === '') { + return false; + } + + $payload = [ + 'secret' => $this->secretKey(), + 'response' => $token, + ]; + + $ip = trim((string) $ip); + + if ($ip !== '') { + $payload['remoteip'] = $ip; + } + + try { + $response = Http::asForm() + ->acceptJson() + ->timeout(max(3, (int) config('demo.turnstile.timeout_seconds', 8))) + ->post((string) config('demo.turnstile.verify_url'), $payload); + + if (! $response->ok()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (Throwable) { + return false; + } + } + + private function secretKey(): string + { + return trim((string) config('demo.turnstile.secret_key', '')); + } +} diff --git a/Modules/Demo/routes/web.php b/Modules/Demo/routes/web.php index 01630ac1c..dd5ac6738 100644 --- a/Modules/Demo/routes/web.php +++ b/Modules/Demo/routes/web.php @@ -4,5 +4,7 @@ use Illuminate\Support\Facades\Route; use Modules\Demo\App\Http\Controllers\DemoController; Route::middleware('web')->group(function () { - Route::post('/demo/prepare', [DemoController::class, 'prepare'])->name('demo.prepare'); + Route::post('/demo/prepare', [DemoController::class, 'prepare']) + ->middleware('throttle:8,1') + ->name('demo.prepare'); }); diff --git a/Modules/Listing/Database/Seeders/ListingSeeder.php b/Modules/Listing/Database/Seeders/ListingSeeder.php index 12e2b52a1..fbdaadba9 100644 --- a/Modules/Listing/Database/Seeders/ListingSeeder.php +++ b/Modules/Listing/Database/Seeders/ListingSeeder.php @@ -30,22 +30,35 @@ class ListingSeeder extends Seeder { $users = $this->resolveSeederUsers(); $categories = $this->resolveSeedableCategories(); + $imagePool = SampleListingImageCatalog::uniquePaths(); - if ($users->isEmpty() || $categories->isEmpty()) { + if ($users->isEmpty() || $categories->isEmpty() || $imagePool->isEmpty()) { return; } $countries = $this->resolveCountries(); $turkeyCities = $this->resolveTurkeyCities(); $plannedSlugs = []; + $assignedImageIndex = 0; - foreach ($users as $userIndex => $user) { - foreach ($categories as $categoryIndex => $category) { - $listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex; - $listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user); + foreach ($categories as $category) { + foreach ($users as $user) { + if ($assignedImageIndex >= $imagePool->count()) { + continue; + } + + $listingData = $this->buildListingData( + $category, + $assignedImageIndex, + $countries, + $turkeyCities, + $user, + $imagePool->get($assignedImageIndex) + ); $listing = $this->upsertListing($listingData, $category, $user); $plannedSlugs[] = $listing->slug; $this->syncListingImage($listing, $listingData['image_path']); + $assignedImageIndex++; } } @@ -138,7 +151,8 @@ class ListingSeeder extends Seeder int $index, Collection $countries, Collection $turkeyCities, - User $user + User $user, + ?string $imagePath ): array { $location = $this->resolveLocation($index, $countries, $turkeyCities); $title = $this->buildTitle($category, $index, $user); @@ -155,7 +169,7 @@ class ListingSeeder extends Seeder 'is_featured' => $index % 7 === 0, 'expires_at' => now()->addDays(21 + ($index % 9)), 'created_at' => now()->subHours(6 + $index), - 'image_path' => SampleListingImageCatalog::pathFor($category, $index), + 'image_path' => $imagePath, ]; } diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index 4a1628c49..b958dfea9 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -539,6 +539,10 @@ class Listing extends Model implements HasMedia private function shouldSkipConversionsForSeeder(): bool { + if ((bool) config('demo.provisioning', false)) { + return true; + } + if (! app()->runningInConsole()) { return false; } diff --git a/Modules/Listing/Support/SampleListingImageCatalog.php b/Modules/Listing/Support/SampleListingImageCatalog.php index 8d4eaebde..eb92c9bbd 100644 --- a/Modules/Listing/Support/SampleListingImageCatalog.php +++ b/Modules/Listing/Support/SampleListingImageCatalog.php @@ -176,26 +176,26 @@ final class SampleListingImageCatalog ], ]; + public static function uniquePaths(): Collection + { + return self::allPaths() + ->sortBy(fn (string $path): string => strtolower((string) basename($path))) + ->values(); + } + public static function pathFor(Category $category, int $seed): ?string { - $categorySlug = trim((string) $category->slug); - $familySlug = trim((string) ($category->parent?->slug ?? $category->slug)); - - $paths = self::resolvePathsForSlug($categorySlug); - - if ($paths->isEmpty()) { - $paths = self::resolvePathsForSlug($familySlug); - } - - if ($paths->isEmpty()) { - $paths = self::allPaths(); - } + $paths = self::uniquePaths(); if ($paths->isEmpty()) { return null; } - return $paths->values()->get($seed % $paths->count()); + if ($seed < 0 || $seed >= $paths->count()) { + return null; + } + + return $paths->get($seed); } public static function fileNameFor(string $absolutePath, string $slug): string diff --git a/README.md b/README.md index 20257ff7e..bd3651cb3 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,82 @@ php artisan demo:cleanup --- +## Realtime Chat (Laravel Reverb) + +This project already uses Laravel Reverb + Echo for inbox and listing chat realtime updates. + +### 1. Environment + +Set these values in `.env`: + +```env +BROADCAST_CONNECTION=reverb + +REVERB_APP_ID=480227 +REVERB_APP_KEY=your_key +REVERB_APP_SECRET=your_secret +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http +REVERB_SERVER_HOST=0.0.0.0 +REVERB_SERVER_PORT=8080 + +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +``` + +### 2. Start Services + +Use one command: + +```bash +composer run dev +``` + +Or run separately: + +```bash +php artisan serve +php artisan reverb:start --host=0.0.0.0 --port=8080 +php artisan queue:listen --tries=1 --timeout=0 +npm run dev +``` + +### 3. How It Works in This Codebase + +- Private channel: `users.{id}.inbox` +- Channel authorization: `Modules/Conversation/App/Providers/ConversationServiceProvider.php` +- Broadcast events: + - `InboxMessageCreated` (`.inbox.message.created`) + - `ConversationReadUpdated` (`.inbox.read.updated`) +- Frontend subscriptions: `Modules/Conversation/resources/assets/js/conversation.js` +- Echo bootstrap: `resources/js/echo.js` + +### 4. Quick Verification + +1. Open two different authenticated sessions (for example `a@a.com` and `b@b.com`). +2. Go to `/panel/inbox` in both sessions. +3. Send a message from one session. +4. Confirm in the other session: + - thread updates instantly, + - inbox ordering/unread state updates, + - header inbox badge updates. + +### 5. Troubleshooting + +- No realtime updates: + - check `php artisan reverb:start` is running, + - check Vite is running (`npm run dev`) and assets are rebuilt. +- Private channel auth fails (`403`): + - verify user is authenticated in the same browser/session. +- WebSocket connection fails: + - verify `REVERB_HOST/PORT/SCHEME` and matching `VITE_REVERB_*` values, + - run `php artisan optimize:clear` after env changes. + +--- + ## Code Contributors

@@ -246,4 +322,4 @@ php artisan config:cache php artisan route:cache php artisan view:cache php artisan storage:link -``` \ No newline at end of file +``` diff --git a/config/demo.php b/config/demo.php index 63d696342..c640a145c 100644 --- a/config/demo.php +++ b/config/demo.php @@ -8,4 +8,11 @@ return [ 'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'), 'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'), 'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'), + 'turnstile' => [ + 'enabled' => (bool) env('DEMO_TURNSTILE_ENABLED', false), + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), + 'timeout_seconds' => (int) env('TURNSTILE_TIMEOUT_SECONDS', 8), + ], ]; diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index fcca05e25..3d19d862b 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -10,6 +10,10 @@ $prepareDemoRedirect = url()->full(); $hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid')); $demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession; + $demoTurnstileProtectionEnabled = (bool) config('demo.turnstile.enabled', false); + $demoTurnstileSiteKey = trim((string) config('demo.turnstile.site_key', '')); + $prepareDemoTurnstileRequired = $demoLandingMode && $demoTurnstileProtectionEnabled; + $prepareDemoTurnstileRenderable = $prepareDemoTurnstileRequired && $demoTurnstileSiteKey !== ''; $demoTtlMinutes = (int) config('demo.ttl_minutes', 360); $demoTtlHours = intdiv($demoTtlMinutes, 60); $demoTtlRemainderMinutes = $demoTtlMinutes % 60; @@ -62,7 +66,7 @@ @if($demoLandingMode && $prepareDemoRoute)

-
+ @csrf

Prepare Demo

@@ -72,8 +76,28 @@

This demo is deleted automatically after {{ $demoTtlLabel }}.

-
@@ -362,6 +386,107 @@ @endif +@if($prepareDemoTurnstileRenderable) + +@endif @endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 38634d00d..f81b6b051 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -22,6 +22,7 @@ $demoExpiresAt = session('demo_expires_at'); $demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null; $demoRemainingLabel = null; + $demoRemainingCompactLabel = null; if ($demoExpiresAt?->isFuture()) { $remainingMinutes = now()->diffInMinutes($demoExpiresAt, false); @@ -38,6 +39,11 @@ } $demoRemainingLabel = $remainingParts !== [] ? implode(' ', $remainingParts) : 'less than a minute'; + $demoRemainingCompactLabel = trim( + ($remainingHours > 0 ? $remainingHours.'h ' : '') + .($remainingRemainderMinutes > 0 ? $remainingRemainderMinutes.'m' : '') + ); + $demoRemainingCompactLabel = $demoRemainingCompactLabel !== '' ? $demoRemainingCompactLabel : '<1m'; } $availableLocales = config('app.available_locales', ['en']); $localeLabels = [ @@ -88,6 +94,7 @@ 'bg-slate-50' => $demoLandingMode, 'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode, ])> + @if(! $demoLandingMode) @if($simplePage) @endif + @endif @if($demoRemainingLabel) -
-
- Demo auto deletes in {{ $demoRemainingLabel }} +
+
+ Demo: {{ $demoRemainingCompactLabel }} left
@endif @@ -395,7 +403,7 @@ 'site-main', 'min-h-screen' => $demoLandingMode, ])>@yield('content') - @if(!$simplePage) + @if(!$simplePage && ! $demoLandingMode)