Optimize anonymous public sessions

This commit is contained in:
2026-05-01 11:42:10 +02:00
parent 35011001ba
commit 961d21e91e
35 changed files with 888 additions and 66 deletions

View File

@@ -41,6 +41,14 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true
# Debug only; do not enable permanently in production
SKINBASE_SESSION_DEBUG_HEADER=false
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
{
public function handle($request, Closure $next): mixed
{
if (! $request instanceof Request) {
return parent::handle($request, $next);
}
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession;
use Symfony\Component\HttpFoundation\Response;
class ConditionalStartSession extends StartSession
{
public function handle($request, Closure $next): mixed
{
if (! $request instanceof Request || ! config('skinbase-sessions.enabled', true)) {
return parent::handle($request, $next);
}
if ($this->shouldSkipSession($request)) {
$request->attributes->set('skinbase.session_skipped', true);
$response = $next($request);
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
$response->headers->set('X-Skinbase-Session', 'skipped');
}
return $response;
}
$request->attributes->set('skinbase.session_skipped', false);
$response = parent::handle($request, $next);
if ($response instanceof Response && config('skinbase-sessions.debug_header', false)) {
$response->headers->set('X-Skinbase-Session', 'started');
}
return $response;
}
protected function shouldSkipSession(Request $request): bool
{
if (! $this->isSafeReadMethod($request)) {
return false;
}
if ($this->hasExistingSessionCookie($request)) {
return false;
}
if ($this->matchesAnyPath($request, config('skinbase-sessions.always_session_paths', []))) {
return false;
}
if (! $this->matchesAnyPath($request, config('skinbase-sessions.public_paths', []))) {
return false;
}
if (config('skinbase-sessions.skip_anonymous_public_get', true)) {
return true;
}
return config('skinbase-sessions.skip_known_crawlers_on_public_get', true)
&& $this->isKnownCrawler($request);
}
protected function isSafeReadMethod(Request $request): bool
{
return in_array($request->getMethod(), ['GET', 'HEAD'], true);
}
protected function hasExistingSessionCookie(Request $request): bool
{
$cookieName = config('session.cookie');
return is_string($cookieName)
&& $cookieName !== ''
&& $request->cookies->has($cookieName);
}
protected function matchesAnyPath(Request $request, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (! is_string($pattern) || $pattern === '') {
continue;
}
if ($pattern === '/' && $request->path() === '/') {
return true;
}
$normalizedPattern = trim($pattern, '/');
if ($normalizedPattern !== '' && $request->is($normalizedPattern)) {
return true;
}
}
return false;
}
protected function isKnownCrawler(Request $request): bool
{
$userAgent = strtolower((string) $request->userAgent());
if ($userAgent === '') {
return false;
}
foreach (config('skinbase-sessions.bot_user_agent_keywords', []) as $keyword) {
$normalizedKeyword = strtolower((string) $keyword);
if ($normalizedKeyword !== '' && str_contains($userAgent, $normalizedKeyword)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
class ConditionalValidateCsrfToken extends ValidateCsrfToken
{
public function handle($request, Closure $next): mixed
{
if ($request instanceof Request && $request->attributes->get('skinbase.session_skipped') === true) {
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -12,6 +12,15 @@ final class HandleInertiaRequests extends Middleware
{
protected $rootView = 'upload';
protected function canReadSessionAuth(Request $request): bool
{
if ($request->attributes->get('skinbase.session_skipped') === true) {
return false;
}
return $request->hasSession();
}
/**
* Select the root Blade view based on route prefix.
*/
@@ -58,13 +67,16 @@ final class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
$canReadSessionAuth = $this->canReadSessionAuth($request);
$user = $canReadSessionAuth ? $request->user() : null;
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'is_admin' => $request->user()->isAdmin(),
'is_moderator' => $request->user()->isModerator(),
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'is_admin' => $user->isAdmin(),
'is_moderator' => $user->isModerator(),
] : null,
],
'cdn' => [
@@ -84,8 +96,8 @@ final class HandleInertiaRequests extends Middleware
'group_assets' => (bool) config('features.group_assets', true),
'group_activity_feed' => (bool) config('features.group_activity_feed', true),
],
'studio_groups' => $request->user()
? app(GroupService::class)->studioOptionsForUser($request->user())
'studio_groups' => $user
? app(GroupService::class)->studioOptionsForUser($user)
: [],
]);
}

View File

@@ -151,6 +151,11 @@ class AppServiceProvider extends ServiceProvider
$displayName = null;
$userId = null;
$toolbarContentTypes = collect();
$request = request();
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request
&& $request->hasSession()
&& $request->attributes->get('skinbase.session_skipped') !== true;
$authUser = $canReadSessionAuth ? Auth::user() : null;
try {
$toolbarContentTypes = $this->app
@@ -162,8 +167,9 @@ class AppServiceProvider extends ServiceProvider
$toolbarContentTypes = collect();
}
if (Auth::check()) {
$userId = Auth::id();
if ($authUser) {
$authUser->loadMissing('profile');
$userId = (int) $authUser->id;
try {
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
@@ -200,19 +206,18 @@ class AppServiceProvider extends ServiceProvider
try {
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
->unreadCountForUser(Auth::user());
->unreadCountForUser($authUser);
} catch (\Throwable $e) {
$receivedCommentsCount = 0;
}
try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
$avatarHash = $profile->avatar_hash ?? null;
$avatarHash = $authUser->profile?->avatar_hash;
} catch (\Throwable $e) {
$avatarHash = null;
}
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
$displayName = $authUser->name ?: ($authUser->username ?? '');
}
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes'));

View File

@@ -1,8 +1,14 @@
<?php
use App\Http\Middleware\ConditionalShareErrorsFromSession;
use App\Http\Middleware\ConditionalStartSession;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -13,6 +19,12 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(replace: [
StartSession::class => ConditionalStartSession::class,
ShareErrorsFromSession::class => ConditionalShareErrorsFromSession::class,
ValidateCsrfToken::class => ConditionalValidateCsrfToken::class,
]);
$middleware->validateCsrfTokens(except: [
'chat_post',
'chat_post/*',

View File

@@ -0,0 +1,124 @@
<?php
return [
'enabled' => env('SKINBASE_CONDITIONAL_SESSIONS_ENABLED', true),
'skip_anonymous_public_get' => env('SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS', true),
'skip_known_crawlers_on_public_get' => env('SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS', true),
'debug_header' => env('SKINBASE_SESSION_DEBUG_HEADER', false),
'public_paths' => [
'/',
'featured',
'uploads/latest',
'uploads/daily',
'members/photos',
'downloads/today',
'comments/monthly',
'discover',
'discover/*',
'explore',
'explore/*',
'blog',
'blog/*',
'pages/*',
'about',
'help',
'help/*',
'contact',
'faq',
'rules-and-guidelines',
'privacy-policy',
'terms-of-service',
'staff',
'bug-report',
'rss-feeds',
'rss',
'rss/*',
'news',
'news/*',
'worlds',
'worlds/*',
'creators',
'creators/*',
'stories',
'stories/*',
'tags',
'tags/*',
'categories',
'leaderboard',
'art',
'art/*',
'sitemap.xml',
'sitemaps/*',
'robots.txt',
],
'always_session_paths' => [
'login',
'logout',
'register',
'register/*',
'auth/*',
'forgot-password',
'reset-password',
'reset-password/*',
'confirm-password',
'email/verification-notification',
'verify-email',
'verify-email/*',
'setup/*',
'dashboard',
'dashboard/*',
'manage',
'studio',
'studio/*',
'upload',
'upload/*',
'settings',
'settings/*',
'messages',
'messages/*',
'worlds/create',
'cp',
'cp/*',
'admin',
'admin/*',
'api/me',
'api/auth/*',
],
'bot_user_agent_keywords' => [
'googlebot',
'bingbot',
'slurp',
'duckduckbot',
'baiduspider',
'yandexbot',
'sogou',
'exabot',
'facebot',
'facebookexternalhit',
'ia_archiver',
'semrushbot',
'ahrefsbot',
'mj12bot',
'dotbot',
'petalbot',
'applebot',
'twitterbot',
'linkedinbot',
'discordbot',
'telegrambot',
'whatsapp',
'crawler',
'spider',
'bot',
],
];

View File

@@ -21,6 +21,7 @@
$comments = $comments ?? [];
$groupSummary = $groupSummary ?? null;
$useUnifiedSeo = true;
$canReadSessionAuth = request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped');
@endphp
@push('head')
@@ -49,7 +50,7 @@
data-canonical='@json($meta["canonical"])'
data-comments='@json($comments)'
data-group-summary='@json($groupSummary)'
data-is-authenticated='@json(auth()->check())'>
data-is-authenticated='@json($canReadSessionAuth && auth()->check())'>
</div>
@vite(['resources/js/Pages/ArtworkPage.jsx'])

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/collections.jsx'])
<style>
body.page-collections main { padding-top: 4rem; }

View File

@@ -1,5 +1,7 @@
@php
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
$deferToolbarSearch = request()->routeIs('index');
$deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index');
@@ -21,7 +23,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@if($skinbaseCanUseSession)
<meta name="csrf-token" content="{{ csrf_token() }}">
@endif
@if($shouldRenderBladeSeo)
@include('partials.seo.head', ['seo' => $seo ?? null])
@endif
@@ -235,13 +239,13 @@
<!-- React Topbar mount point -->
<div id="topbar-root"
@auth
@if($skinbaseCanUseSession && Auth::check())
data-user-id="{{ Auth::id() }}"
data-display-name="{{ Auth::user()->name ?? '' }}"
data-username="{{ Auth::user()->username ?? '' }}"
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
@endauth
@endif
></div>
@include('layouts.nova.toolbar')
<main class="flex-1 @yield('main-class', 'pt-16')">
@@ -252,9 +256,9 @@
{{-- Toast notifications (Alpine) --}}
@php
$toastMessage = session('status') ?? session('error') ?? null;
$toastType = session('error') ? 'error' : 'success';
$toastBorder = session('error') ? 'border-red-500' : 'border-green-500';
$toastMessage = $skinbaseCanUseSession ? (session('status') ?? session('error') ?? null) : null;
$toastType = $skinbaseCanUseSession && session('error') ? 'error' : 'success';
$toastBorder = $skinbaseCanUseSession && session('error') ? 'border-red-500' : 'border-green-500';
@endphp
@if($toastMessage)
<div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak
@@ -262,7 +266,7 @@
<div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nova-600 border {{ $toastBorder }}">
<div class="px-4 py-3 flex items-start gap-3">
<div class="flex-shrink-0">
@if(session('error'))
@if($skinbaseCanUseSession && session('error'))
<svg class="w-6 h-6 text-red-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/></svg>
@else
<svg class="w-6 h-6 text-green-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>

View File

@@ -1,3 +1,9 @@
@php
$skinbaseCanUseSession = ($skinbaseCanUseSession ?? false) === true;
$skinbaseToolbarUser = $skinbaseCanUseSession ? Auth::user() : null;
$skinbaseToolbarCanAuth = $skinbaseToolbarUser !== null;
@endphp
<header id="nova-toolbar" class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<div class="mx-auto w-full h-full px-3 sm:px-4 flex items-center gap-2 sm:gap-3">
@@ -80,11 +86,11 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/discover/on-this-day">
<i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day
</a>
@auth
@if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('discover.for-you') }}">
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center"></i>For You
</a>
@endauth
@endif
</div>
</div>
@@ -165,11 +171,11 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/stories">
<i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories
</a>
@auth
@if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}">
<i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following
</a>
@endauth
@endif
</div>
</div>
@@ -185,7 +191,7 @@
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}">
<i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed
</a>
@auth
@if($skinbaseToolbarCanAuth)
<a class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.comments.received') }}">
<span class="flex items-center gap-3">
<i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments
@@ -194,7 +200,7 @@
<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>
@endif
</a>
@endauth
@endif
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
</a>
@@ -241,7 +247,7 @@
</div>
</div>
@auth
@if($skinbaseToolbarCanAuth)
<!-- Notification icons -->
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">
<a href="{{ route('dashboard.favorites') }}"
@@ -458,7 +464,7 @@
</a>
</div>
</details>
@endauth
@endif
</div>
</header>
@@ -466,9 +472,9 @@
<div class="hidden fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto overscroll-contain bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
<div class="space-y-0.5 text-sm text-soft">
@guest
@if(! $skinbaseToolbarCanAuth)
<div class="my-2 border-t border-panel"></div>
@endguest
@endif
<div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionDiscover" aria-expanded="true" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
@@ -528,10 +534,10 @@
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/leaderboard"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Leaderboard</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
@auth
@if($skinbaseToolbarCanAuth)
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('creator.stories.index') }}"><i class="fa-solid fa-rectangle-list w-4 text-center text-sb-muted"></i>My Stories</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
@endauth
@endif
</div>
</div>
@@ -542,9 +548,9 @@
</button>
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a>
@auth
@if($skinbaseToolbarCanAuth)
<a class="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.comments.received') }}"><span class="flex items-center gap-3"><i class="fa-solid fa-inbox w-4 text-center text-sb-muted"></i>Received Comments</span>@if(($receivedCommentsCount ?? 0) > 0)<span class="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-2 py-0.5 text-[11px] font-semibold text-cyan-200">{{ $receivedCommentsCount }}</span>@endif</a>
@endauth
@endif
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a>
</div>

View File

@@ -3,7 +3,9 @@
@section('title', 'Messages')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@endpush
@section('content')

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/moderation.jsx'])
<style>
body.page-moderation main { padding-top: 4rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/settings.jsx'])
<style>
body.page-settings main { padding-top: 4rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/studio.jsx'])
<style>
body.page-studio main { padding-top: 2.3rem; }

View File

@@ -1,7 +1,9 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
<script>
window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, {