Add Turnstile protection demo

This commit is contained in:
fatihalp 2026-03-10 04:12:59 +03:00
parent 3e413e2fed
commit f8c953d37c
16 changed files with 373 additions and 30 deletions

View File

@ -88,3 +88,7 @@ QUICK_LISTING_AI_MODEL=gpt-5.2
DEMO=0 DEMO=0
DEMO_TTL_MINUTES=360 DEMO_TTL_MINUTES=360
DEMO_TURNSTILE_ENABLED=0
TURNSTILE_SITE_KEY=0x4AAAAAACogGCt62w6ahqM4
TURNSTILE_SECRET_KEY=0x4AAAAAACogGLdg-1mydGAW8FT_He6DTI8
TURNSTILE_TIMEOUT_SECONDS=8

View File

@ -8,11 +8,16 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Cookie;
use Modules\Demo\App\Support\DemoSchemaManager; use Modules\Demo\App\Support\DemoSchemaManager;
use Modules\Demo\App\Support\TurnstileVerifier;
use Throwable; use Throwable;
class DemoController extends Controller 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); abort_unless(config('demo.enabled'), 404);
@ -20,6 +25,29 @@ class DemoController extends Controller
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to')) $redirectTo = $this->sanitizeRedirectTarget($request->input('redirect_to'))
?? route('home'); ?? 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 { try {
$instance = $demoSchemaManager->prepare($request->cookie($cookieName)); $instance = $demoSchemaManager->prepare($request->cookie($cookieName));
$user = $demoSchemaManager->resolveLoginUser(); $user = $demoSchemaManager->resolveLoginUser();

View File

@ -0,0 +1,72 @@
<?php
namespace Modules\Demo\App\Support;
use Illuminate\Support\Facades\Http;
use Throwable;
final class TurnstileVerifier
{
public function enabled(): bool
{
return (bool) config('demo.turnstile.enabled', false);
}
public function siteKey(): string
{
return trim((string) config('demo.turnstile.site_key', ''));
}
public function configured(): bool
{
return $this->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', ''));
}
}

View File

@ -4,5 +4,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Demo\App\Http\Controllers\DemoController; use Modules\Demo\App\Http\Controllers\DemoController;
Route::middleware('web')->group(function () { 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');
}); });

View File

@ -30,22 +30,35 @@ class ListingSeeder extends Seeder
{ {
$users = $this->resolveSeederUsers(); $users = $this->resolveSeederUsers();
$categories = $this->resolveSeedableCategories(); $categories = $this->resolveSeedableCategories();
$imagePool = SampleListingImageCatalog::uniquePaths();
if ($users->isEmpty() || $categories->isEmpty()) { if ($users->isEmpty() || $categories->isEmpty() || $imagePool->isEmpty()) {
return; return;
} }
$countries = $this->resolveCountries(); $countries = $this->resolveCountries();
$turkeyCities = $this->resolveTurkeyCities(); $turkeyCities = $this->resolveTurkeyCities();
$plannedSlugs = []; $plannedSlugs = [];
$assignedImageIndex = 0;
foreach ($users as $userIndex => $user) { foreach ($categories as $category) {
foreach ($categories as $categoryIndex => $category) { foreach ($users as $user) {
$listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex; if ($assignedImageIndex >= $imagePool->count()) {
$listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user); continue;
}
$listingData = $this->buildListingData(
$category,
$assignedImageIndex,
$countries,
$turkeyCities,
$user,
$imagePool->get($assignedImageIndex)
);
$listing = $this->upsertListing($listingData, $category, $user); $listing = $this->upsertListing($listingData, $category, $user);
$plannedSlugs[] = $listing->slug; $plannedSlugs[] = $listing->slug;
$this->syncListingImage($listing, $listingData['image_path']); $this->syncListingImage($listing, $listingData['image_path']);
$assignedImageIndex++;
} }
} }
@ -138,7 +151,8 @@ class ListingSeeder extends Seeder
int $index, int $index,
Collection $countries, Collection $countries,
Collection $turkeyCities, Collection $turkeyCities,
User $user User $user,
?string $imagePath
): array { ): array {
$location = $this->resolveLocation($index, $countries, $turkeyCities); $location = $this->resolveLocation($index, $countries, $turkeyCities);
$title = $this->buildTitle($category, $index, $user); $title = $this->buildTitle($category, $index, $user);
@ -155,7 +169,7 @@ class ListingSeeder extends Seeder
'is_featured' => $index % 7 === 0, 'is_featured' => $index % 7 === 0,
'expires_at' => now()->addDays(21 + ($index % 9)), 'expires_at' => now()->addDays(21 + ($index % 9)),
'created_at' => now()->subHours(6 + $index), 'created_at' => now()->subHours(6 + $index),
'image_path' => SampleListingImageCatalog::pathFor($category, $index), 'image_path' => $imagePath,
]; ];
} }

View File

@ -539,6 +539,10 @@ class Listing extends Model implements HasMedia
private function shouldSkipConversionsForSeeder(): bool private function shouldSkipConversionsForSeeder(): bool
{ {
if ((bool) config('demo.provisioning', false)) {
return true;
}
if (! app()->runningInConsole()) { if (! app()->runningInConsole()) {
return false; return false;
} }

View File

@ -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 public static function pathFor(Category $category, int $seed): ?string
{ {
$categorySlug = trim((string) $category->slug); $paths = self::uniquePaths();
$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();
}
if ($paths->isEmpty()) { if ($paths->isEmpty()) {
return null; 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 public static function fileNameFor(string $absolutePath, string $slug): string

View File

@ -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 ## Code Contributors
<p align="center"> <p align="center">

View File

@ -8,4 +8,11 @@ return [
'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'), 'cookie_name' => env('DEMO_COOKIE_NAME', 'oc2_demo'),
'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'), 'login_email' => env('DEMO_LOGIN_EMAIL', 'a@a.com'),
'public_schema' => env('DEMO_PUBLIC_SCHEMA', 'public'), '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),
],
]; ];

View File

@ -10,6 +10,10 @@
$prepareDemoRedirect = url()->full(); $prepareDemoRedirect = url()->full();
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid')); $hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && !auth()->check() && !$hasDemoSession; $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); $demoTtlMinutes = (int) config('demo.ttl_minutes', 360);
$demoTtlHours = intdiv($demoTtlMinutes, 60); $demoTtlHours = intdiv($demoTtlMinutes, 60);
$demoTtlRemainderMinutes = $demoTtlMinutes % 60; $demoTtlRemainderMinutes = $demoTtlMinutes % 60;
@ -62,7 +66,7 @@
@if($demoLandingMode && $prepareDemoRoute) @if($demoLandingMode && $prepareDemoRoute)
<div class="min-h-screen flex items-center justify-center px-5 py-10"> <div class="min-h-screen flex items-center justify-center px-5 py-10">
<form method="POST" action="{{ $prepareDemoRoute }}" class="w-full max-w-xl rounded-[32px] border border-slate-200 bg-white p-8 md:p-10 shadow-xl"> <form method="POST" action="{{ $prepareDemoRoute }}" data-demo-prepare-form data-turnstile-required="{{ $prepareDemoTurnstileRequired ? '1' : '0' }}" class="w-full max-w-xl rounded-[32px] border border-slate-200 bg-white p-8 md:p-10 shadow-xl">
@csrf @csrf
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}"> <input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
<h1 class="text-3xl md:text-5xl font-extrabold tracking-tight text-slate-950">Prepare Demo</h1> <h1 class="text-3xl md:text-5xl font-extrabold tracking-tight text-slate-950">Prepare Demo</h1>
@ -72,8 +76,28 @@
<p class="mt-4 text-base text-slate-500"> <p class="mt-4 text-base text-slate-500">
This demo is deleted automatically after {{ $demoTtlLabel }}. This demo is deleted automatically after {{ $demoTtlLabel }}.
</p> </p>
<button type="submit" class="mt-8 inline-flex min-h-16 w-full items-center justify-center rounded-full bg-blue-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition hover:bg-blue-700"> @if($prepareDemoTurnstileRenderable)
Prepare Demo <div class="mt-6 space-y-2">
<div class="cf-turnstile" data-sitekey="{{ $demoTurnstileSiteKey }}"></div>
<p class="text-xs text-slate-500">Complete the security check before starting your private demo.</p>
</div>
@elseif($prepareDemoTurnstileRequired)
<p class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium leading-6 text-amber-700">
Security check is enabled but the widget is not configured. Contact the administrator.
</p>
@endif
<p data-demo-prepare-status data-turnstile-message="Please complete the security verification first." data-loading-message="Preparing your private demo. This can take longer because a dedicated seeded environment is being provisioned for your browser." aria-live="polite" class="mt-4 hidden rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium leading-6 text-blue-800">
Preparing your private demo. This can take longer because a dedicated seeded environment is being provisioned for your browser.
</p>
<button type="submit" data-demo-prepare-button @if($prepareDemoTurnstileRequired) disabled @endif class="mt-8 inline-flex min-h-16 w-full items-center justify-center rounded-full bg-blue-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-500">
<span data-demo-prepare-idle>Prepare Demo</span>
<span data-demo-prepare-loading class="hidden items-center gap-2">
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v3a5 5 0 0 0-5 5H4z"></path>
</svg>
Preparing Demo...
</span>
</button> </button>
</form> </form>
</div> </div>
@ -362,6 +386,107 @@
@endif @endif
<script> <script>
(() => { (() => {
const setupPrepareDemoForm = () => {
const form = document.querySelector('[data-demo-prepare-form]');
if (!form) {
return;
}
const button = form.querySelector('[data-demo-prepare-button]');
const idleLabel = form.querySelector('[data-demo-prepare-idle]');
const loadingLabel = form.querySelector('[data-demo-prepare-loading]');
const status = form.querySelector('[data-demo-prepare-status]');
const turnstileRequired = form.dataset.turnstileRequired === '1';
const resolveTurnstileToken = () => {
const tokenField = form.querySelector('input[name="cf-turnstile-response"]');
if (!tokenField) {
return '';
}
return tokenField.value.trim();
};
const applyReadyState = () => {
if (!button) {
return;
}
if (!turnstileRequired) {
button.removeAttribute('disabled');
return;
}
const token = resolveTurnstileToken();
if (token === '') {
button.setAttribute('disabled', 'disabled');
return;
}
button.removeAttribute('disabled');
};
if (turnstileRequired) {
const tokenObserver = window.setInterval(() => {
applyReadyState();
}, 250);
form.addEventListener('submit', () => {
window.clearInterval(tokenObserver);
});
} else {
applyReadyState();
}
form.addEventListener('submit', (event) => {
if (form.dataset.submitting === '1') {
event.preventDefault();
return;
}
if (turnstileRequired && resolveTurnstileToken() === '') {
event.preventDefault();
if (status) {
status.textContent = status.dataset.turnstileMessage ?? 'Please complete the security verification first.';
status.classList.remove('hidden');
}
applyReadyState();
return;
}
form.dataset.submitting = '1';
if (button) {
button.setAttribute('disabled', 'disabled');
}
if (idleLabel) {
idleLabel.classList.add('hidden');
}
if (loadingLabel) {
loadingLabel.classList.remove('hidden');
loadingLabel.classList.add('inline-flex');
}
if (status) {
status.textContent = status.dataset.loadingMessage ?? status.textContent;
status.classList.remove('hidden');
}
});
};
setupPrepareDemoForm();
const setupTrendCategories = () => { const setupTrendCategories = () => {
const track = document.querySelector('[data-trend-track]'); const track = document.querySelector('[data-trend-track]');
const previousButton = document.querySelector('[data-trend-prev]'); const previousButton = document.querySelector('[data-trend-prev]');
@ -471,4 +596,7 @@
setupTrendCategories(); setupTrendCategories();
})(); })();
</script> </script>
@if($prepareDemoTurnstileRenderable)
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
@endif
@endsection @endsection

View File

@ -22,6 +22,7 @@
$demoExpiresAt = session('demo_expires_at'); $demoExpiresAt = session('demo_expires_at');
$demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null; $demoExpiresAt = filled($demoExpiresAt) ? \Illuminate\Support\Carbon::parse($demoExpiresAt) : null;
$demoRemainingLabel = null; $demoRemainingLabel = null;
$demoRemainingCompactLabel = null;
if ($demoExpiresAt?->isFuture()) { if ($demoExpiresAt?->isFuture()) {
$remainingMinutes = now()->diffInMinutes($demoExpiresAt, false); $remainingMinutes = now()->diffInMinutes($demoExpiresAt, false);
@ -38,6 +39,11 @@
} }
$demoRemainingLabel = $remainingParts !== [] ? implode(' ', $remainingParts) : 'less than a minute'; $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']); $availableLocales = config('app.available_locales', ['en']);
$localeLabels = [ $localeLabels = [
@ -88,6 +94,7 @@
'bg-slate-50' => $demoLandingMode, 'bg-slate-50' => $demoLandingMode,
'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode, 'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode,
])> ])>
@if(! $demoLandingMode)
@if($simplePage) @if($simplePage)
<nav class="sticky top-0 z-50 border-b border-black/5 bg-white/80 backdrop-blur-2xl"> <nav class="sticky top-0 z-50 border-b border-black/5 bg-white/80 backdrop-blur-2xl">
<div class="mx-auto flex min-h-[76px] max-w-[1120px] items-center justify-between gap-4 px-4"> <div class="mx-auto flex min-h-[76px] max-w-[1120px] items-center justify-between gap-4 px-4">
@ -374,10 +381,11 @@
</div> </div>
</nav> </nav>
@endif @endif
@endif
@if($demoRemainingLabel) @if($demoRemainingLabel)
<div class="sticky top-0 z-40 border-b border-amber-200 bg-amber-50/95 backdrop-blur-md"> <div class="pointer-events-none fixed bottom-4 right-4 z-40">
<div class="mx-auto flex min-h-12 max-w-[1320px] items-center justify-center px-4 py-2 text-center text-sm font-semibold text-amber-900"> <div class="rounded-full border border-amber-200 bg-white/95 px-3 py-1.5 text-[11px] font-semibold text-amber-900 shadow-lg backdrop-blur">
Demo auto deletes in {{ $demoRemainingLabel }} Demo: {{ $demoRemainingCompactLabel }} left
</div> </div>
</div> </div>
@endif @endif
@ -395,7 +403,7 @@
'site-main', 'site-main',
'min-h-screen' => $demoLandingMode, 'min-h-screen' => $demoLandingMode,
])>@yield('content')</main> ])>@yield('content')</main>
@if(!$simplePage) @if(!$simplePage && ! $demoLandingMode)
<footer class="mt-10 md:mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer> <footer class="mt-10 md:mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
<div class="max-w-[1320px] mx-auto px-4 py-8 md:py-12"> <div class="max-w-[1320px] mx-auto px-4 py-8 md:py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 md:gap-8"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 md:gap-8">

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB