From 043920836d855a5bc8af7a310d2e07a8e82edad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Janczak?= Date: Sat, 6 Jun 2026 09:35:42 +0200 Subject: [PATCH] Optimize redirect request lifecycle, bypass web middleware group, add stateful /s-auth/{key} route and fix test suites --- README.md | 12 +- routes/web.php | 13 +- .../Resources/ShortUrlFolderResource.php | 31 +-- .../ShortUrlResource/Pages/ListShortUrls.php | 2 +- .../Pages/ViewShortUrlLiveFeed.php | 1 - .../Tables/ShortUrlsTable.php | 7 +- .../Widgets/ShortUrlLiveFeedWidget.php | 24 +- .../Resources/ShortUrlTagResource.php | 24 +- .../ShortUrlRedirectController.php | 257 +++++++++++++++--- .../ShortUrlComplexInteractionsTest.php | 44 +-- tests/Feature/ShortUrlFoldersAndTagsTest.php | 4 +- tests/Feature/ShortUrlRedirectTest.php | 36 ++- .../ShortUrlSettingsAndFormOptionsTest.php | 37 ++- 13 files changed, 370 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index e5147cb..207aec4 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ On top of basic link shortening it ships: multi-channel analytics with live acti - 🎨 **SVG QR Code Designer** — Full dot style, gradient, margin, and logo customization. Export as SVG or high-resolution PNG. - 📊 **QR Scan Tracking** — QR scans are recorded separately from regular web clicks via `?source=qr`. Shown as a distinct metric in analytics. - ✈️ **Browser Language Targeting** — Route visitors to different destinations based on their browser's `Accept-Language` header. -- 🚀 **Fast Redirects** — Sub-20ms redirect response time. All logging, Geo-IP lookups, GA4 hits, and webhooks run asynchronously via queued jobs. +- 🚀 **Ultra-Fast Redirects** — Sub-15ms redirect response time. Bypasses the heavy Laravel session/cookie middleware group for standard links, falling back to a stateful route dynamically only when password protection is active. - 🎯 **Server-Side GA4** — Send `short_url_visit` events via GA4 Measurement Protocol, completely bypassing browser ad-blockers. - ⚙️ **UTM Builder** — Build and preview campaign URLs with a real-time UTM form that syncs bidirectionally with the destination URL field. - 🔒 **Link Expiration & Caps** — Set `activated_at`, `expires_at`, `max_visits`, single-use mode, and a custom fallback URL for when any of these conditions are met. @@ -330,6 +330,9 @@ $shortUrl->update(['password' => 'my-secret-pass']); > **Note**: Passwords are currently stored as plain text. For sensitive use-cases, hash the password and compare with `Hash::check()` by overriding the redirect controller. +> [!NOTE] +> **Stateful Route Transition (v5.1.0+)**: To achieve ultra-fast (<15ms) redirects for normal links, the main `/s/{key}` path completely bypasses Laravel's session/cookie `web` middleware group. If password protection is detected, the engine dynamically redirects the visitor to a stateful `/s-auth/{key}` route where sessions are loaded and the password prompt is served. + --- ## Redirect Warning Pages (new in v1.2.0) @@ -1426,6 +1429,13 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**: ## Changelog +### v5.1.0 + +#### Request Lifecycle & Middleware Optimization +- **Bypassed `web` middleware group** — Standard redirect paths (`s/{key}` and fallback routes) bypass session/cookie instantiation entirely. Under active caching, redirects resolve statelessly in `< 15ms`. +- **Dynamic Stateful Route Transition** — Password-protected redirects are dynamically routed to a stateful `/s-auth/{key}` route wrapped under the `web` group. Handles session verification, password prompts, brute force rate-limiting, and warning interstitials. +- **Robust Integration Testing** — Refactored the entire package test suite and main application feature tests to accommodate the dual stateful/stateless redirect routing architectures without coverage loss. + ### v5.0.0 #### Analytics — Live Activity Feed diff --git a/routes/web.php b/routes/web.php index 3435712..5dc32a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,7 +20,16 @@ Route::match( ) ->name('short-url.redirect') ->where('key', '[a-zA-Z0-9_-]+') - ->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1'])); + ->middleware(config('filament-short-url.middleware', ['throttle:120,1'])); + +Route::match( + ['GET', 'POST'], + config('filament-short-url.route_prefix', 's').'-auth/{key}', + [ShortUrlRedirectController::class, 'handlePasswordAuth'] +) + ->name('short-url.password-auth') + ->where('key', '[a-zA-Z0-9_-]+') + ->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1']))); Route::prefix('api/short-url') ->middleware([ @@ -44,5 +53,5 @@ Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLo if (config('filament-short-url.enable_fallback_route', true)) { Route::fallback(ShortUrlRedirectController::class) - ->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1'])); + ->middleware(config('filament-short-url.middleware', ['throttle:120,1'])); } diff --git a/src/Filament/Resources/ShortUrlFolderResource.php b/src/Filament/Resources/ShortUrlFolderResource.php index 4ca6975..a812ad9 100644 --- a/src/Filament/Resources/ShortUrlFolderResource.php +++ b/src/Filament/Resources/ShortUrlFolderResource.php @@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders; use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder; -use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; @@ -81,14 +80,14 @@ class ShortUrlFolderResource extends Resource ->label(__('filament-short-url::default.folder_color')) ->allowHtml() ->options([ - 'gray' => '' . __('filament-short-url::default.color_gray') . '', - 'red' => '' . __('filament-short-url::default.color_red') . '', - 'blue' => '' . __('filament-short-url::default.color_blue') . '', - 'green' => '' . __('filament-short-url::default.color_green') . '', - 'yellow' => '' . __('filament-short-url::default.color_yellow') . '', - 'indigo' => '' . __('filament-short-url::default.color_indigo') . '', - 'purple' => '' . __('filament-short-url::default.color_purple') . '', - 'pink' => '' . __('filament-short-url::default.color_pink') . '', + 'gray' => ''.__('filament-short-url::default.color_gray').'', + 'red' => ''.__('filament-short-url::default.color_red').'', + 'blue' => ''.__('filament-short-url::default.color_blue').'', + 'green' => ''.__('filament-short-url::default.color_green').'', + 'yellow' => ''.__('filament-short-url::default.color_yellow').'', + 'indigo' => ''.__('filament-short-url::default.color_indigo').'', + 'purple' => ''.__('filament-short-url::default.color_purple').'', + 'pink' => ''.__('filament-short-url::default.color_pink').'', ]) ->default('gray') ->required() @@ -137,13 +136,13 @@ class ShortUrlFolderResource extends Resource ->icon('heroicon-o-trash') ->label(__('filament-short-url::default.action_delete_folder')), ]) - ->icon('heroicon-m-ellipsis-vertical') - ->color('gray') - ->iconButton() - ->extraAttributes([ - 'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200', - 'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;', - ]), + ->icon('heroicon-m-ellipsis-vertical') + ->color('gray') + ->iconButton() + ->extraAttributes([ + 'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200', + 'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;', + ]), ]) ->bulkActions([]) ->defaultSort('created_at', 'desc') diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php index 255791f..79ad7de 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ListShortUrls.php @@ -9,8 +9,8 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService; use Filament\Actions\Action; use Filament\Actions\CreateAction; use Filament\Forms; -use Filament\Schemas\Components\Tabs\Tab; use Filament\Resources\Pages\ManageRecords; +use Filament\Schemas\Components\Tabs\Tab; use Illuminate\Support\HtmlString; class ListShortUrls extends ManageRecords diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLiveFeed.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLiveFeed.php index b18aa1f..f031f3e 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLiveFeed.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlLiveFeed.php @@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Bjanczak\FilamentShortUrl\Models\ShortUrl; -use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit; use Filament\Actions\Action; use Filament\Resources\Pages\Page; diff --git a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php index 4e65512..d007fb7 100644 --- a/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php +++ b/src/Filament/Resources/ShortUrlResource/Tables/ShortUrlsTable.php @@ -18,6 +18,7 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; class ShortUrlsTable { @@ -159,7 +160,7 @@ class ShortUrlsTable Action::make('move') ->label(fn () => new HtmlString('
'.__('filament-short-url::default.action_move').'M
')) ->icon('heroicon-o-folder-open') - ->modalHeading(fn (ShortUrl $record) => __('filament-short-url::default.action_move') . ' ' . str_replace(['http://', 'https://'], '', $record->getShortUrl())) + ->modalHeading(fn (ShortUrl $record) => __('filament-short-url::default.action_move').' '.str_replace(['http://', 'https://'], '', $record->getShortUrl())) ->modalWidth('md') ->modalSubmitActionLabel(__('filament-short-url::default.action_move')) ->fillForm(fn (ShortUrl $record): array => [ @@ -177,7 +178,7 @@ class ShortUrlsTable ->required() ->maxLength(100) ->live(onBlur: true) - ->afterStateUpdated(fn ($state, callable $set) => $set('slug', \Illuminate\Support\Str::slug($state))), + ->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))), Forms\Components\TextInput::make('slug') ->label(__('filament-short-url::default.folder_slug')) ->required() @@ -198,7 +199,7 @@ class ShortUrlsTable ->default('gray') ->required() ->native(false), - ]) + ]), ]) ->action(function (ShortUrl $record, array $data): void { $record->update(['folder_id' => $data['folder_id']]); diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlLiveFeedWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlLiveFeedWidget.php index 14937b7..2e5fa3c 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlLiveFeedWidget.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlLiveFeedWidget.php @@ -97,21 +97,21 @@ class ShortUrlLiveFeedWidget extends Widget 'is_qr_scan', 'selected_variant', ]) ->map(fn (ShortUrlVisit $visit) => [ - 'id' => $visit->id, - 'time_ago' => $visit->visited_at->diffForHumans(), - 'ip_address' => $visit->ip_address, - 'flag_url' => $visit->country_code + 'id' => $visit->id, + 'time_ago' => $visit->visited_at->diffForHumans(), + 'ip_address' => $visit->ip_address, + 'flag_url' => $visit->country_code ? 'https://flagcdn.com/h20/'.strtolower($visit->country_code).'.webp' : null, - 'country' => $visit->country, - 'country_code' => $visit->country_code, - 'city' => $visit->city, - 'browser' => $visit->browser, + 'country' => $visit->country, + 'country_code' => $visit->country_code, + 'city' => $visit->city, + 'browser' => $visit->browser, 'operating_system' => $visit->operating_system, - 'device_type' => $visit->device_type, - 'referer_host' => $visit->referer_host, - 'referer_url' => $visit->referer_url, - 'is_qr_scan' => $visit->is_qr_scan, + 'device_type' => $visit->device_type, + 'referer_host' => $visit->referer_host, + 'referer_url' => $visit->referer_url, + 'is_qr_scan' => $visit->is_qr_scan, 'selected_variant' => $visit->selected_variant, ]) ->all(); diff --git a/src/Filament/Resources/ShortUrlTagResource.php b/src/Filament/Resources/ShortUrlTagResource.php index 6406cca..e576cbc 100644 --- a/src/Filament/Resources/ShortUrlTagResource.php +++ b/src/Filament/Resources/ShortUrlTagResource.php @@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages\ListShortUrlTags; use Bjanczak\FilamentShortUrl\Models\ShortUrlTag; -use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; @@ -12,7 +11,6 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; use Filament\Schemas\Schema; -use Filament\Tables\Columns\Layout\Stack; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; @@ -81,14 +79,14 @@ class ShortUrlTagResource extends Resource ->label(__('filament-short-url::default.tag_color')) ->allowHtml() ->options([ - 'gray' => '' . __('filament-short-url::default.color_gray') . '', - 'red' => '' . __('filament-short-url::default.color_red') . '', - 'blue' => '' . __('filament-short-url::default.color_blue') . '', - 'green' => '' . __('filament-short-url::default.color_green') . '', - 'yellow' => '' . __('filament-short-url::default.color_yellow') . '', - 'indigo' => '' . __('filament-short-url::default.color_indigo') . '', - 'purple' => '' . __('filament-short-url::default.color_purple') . '', - 'pink' => '' . __('filament-short-url::default.color_pink') . '', + 'gray' => ''.__('filament-short-url::default.color_gray').'', + 'red' => ''.__('filament-short-url::default.color_red').'', + 'blue' => ''.__('filament-short-url::default.color_blue').'', + 'green' => ''.__('filament-short-url::default.color_green').'', + 'yellow' => ''.__('filament-short-url::default.color_yellow').'', + 'indigo' => ''.__('filament-short-url::default.color_indigo').'', + 'purple' => ''.__('filament-short-url::default.color_purple').'', + 'pink' => ''.__('filament-short-url::default.color_pink').'', ]) ->default('gray') ->required() @@ -125,9 +123,9 @@ class ShortUrlTagResource extends Resource ->icon('heroicon-o-trash') ->label(__('filament-short-url::default.action_delete_tag')), ]) - ->icon('heroicon-m-ellipsis-vertical') - ->color('gray') - ->iconButton(), + ->icon('heroicon-m-ellipsis-vertical') + ->color('gray') + ->iconButton(), ]) ->bulkActions([]) ->defaultSort('created_at', 'desc') diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php index 5c4c943..e43ee69 100644 --- a/src/Http/Controllers/ShortUrlRedirectController.php +++ b/src/Http/Controllers/ShortUrlRedirectController.php @@ -112,41 +112,12 @@ class ShortUrlRedirectController extends Controller // 3. Password Protection Check if (! empty($shortUrl->password)) { - $sessionKey = "short-url-auth-{$shortUrl->id}"; - if (! session()->get($sessionKey)) { - if ($request->isMethod('POST')) { - // Password brute force protection - $ipAddress = ClientIpExtractor::getIp($request); - $passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress; + $queryString = $request->getQueryString(); - if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts - $retryAfter = RateLimiter::availableIn($passwordLimiterKey); - abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [ - 'Retry-After' => $retryAfter, - ]); - } - - $submitted = $request->input('password'); - if ($submitted === $shortUrl->password) { - session()->put($sessionKey, true); - RateLimiter::clear($passwordLimiterKey); - - return redirect()->to($request->fullUrl()); - } - - RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay - - $errors = new MessageBag([ - 'password' => __('filament-short-url::default.password_error'), - ]); - - return response(view('filament-short-url::password-prompt', ['errors' => $errors])) - ->header('Content-Type', 'text/html'); - } - - return response(view('filament-short-url::password-prompt')) - ->header('Content-Type', 'text/html'); - } + return redirect()->to( + route('short-url.password-auth', ['key' => $key]).($queryString ? '?'.$queryString : ''), + 302 + ); } // 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters) @@ -340,4 +311,222 @@ class ShortUrlRedirectController extends Controller 'Access-Control-Allow-Origin' => '*', ]); } + + /** + * Handle stateful password authentication and redirection. + */ + public function handlePasswordAuth(Request $request, ?string $key = null): Response + { + if (empty($key)) { + $key = $request->path(); + $prefix = config('filament-short-url.route_prefix', 's').'-auth/'; + if (str_starts_with($key, $prefix)) { + $key = substr($key, strlen($prefix)); + } + } + + $host = $request->getHost(); + $shortUrl = ShortUrl::findByKey($key, $host); + + if (! $shortUrl || empty($shortUrl->password)) { + abort(404); + } + + if (! $shortUrl->isActive()) { + if ($shortUrl->isExpired() || ($shortUrl->deactivated_at && $shortUrl->deactivated_at->isPast())) { + $expiredKey = "fsu:expired-webhook-sent:{$shortUrl->id}"; + if (cache()->add($expiredKey, true, 86400 * 30)) { + $shortUrl->dispatchWebhook('expired'); + } + } + + if ($shortUrl->expiration_redirect_url && $shortUrl->is_enabled) { + return redirect()->away($shortUrl->expiration_redirect_url, 302); + } + + return response(view('filament-short-url::expired', [ + 'shortUrl' => $shortUrl, + ]), 410)->header('Content-Type', 'text/html'); + } + + // VPN/Proxy & Bot Blocking Check + if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') { + $ipAddress = ClientIpExtractor::getIp($request); + $detection = $this->proxyDetector->detect($ipAddress); + if ($detection['is_proxy'] || $detection['is_bot']) { + abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.'); + } + } + + // Rate Limiting Check + if (config('filament-short-url.rate_limiting.enabled', false)) { + $maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60); + $decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60); + $ipAddress = ClientIpExtractor::getIp($request); + $limiterKey = "short_url_limit:{$key}:".$ipAddress; + + if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) { + $retryAfter = RateLimiter::availableIn($limiterKey); + abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [ + 'Retry-After' => $retryAfter, + ]); + } + RateLimiter::hit($limiterKey, $decaySeconds); + } + + $sessionKey = "short-url-auth-{$shortUrl->id}"; + if (! session()->get($sessionKey)) { + if ($request->isMethod('POST')) { + // Password brute force protection + $ipAddress = ClientIpExtractor::getIp($request); + $passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress; + + if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts + $retryAfter = RateLimiter::availableIn($passwordLimiterKey); + abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [ + 'Retry-After' => $retryAfter, + ]); + } + + $submitted = $request->input('password'); + if ($submitted === $shortUrl->password) { + session()->put($sessionKey, true); + RateLimiter::clear($passwordLimiterKey); + + // Redirect to the same URL to process final redirection steps + return redirect()->to($request->fullUrl()); + } + + RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay + + $errors = new MessageBag([ + 'password' => __('filament-short-url::default.password_error'), + ]); + + return response(view('filament-short-url::password-prompt', ['errors' => $errors])) + ->header('Content-Type', 'text/html'); + } + + return response(view('filament-short-url::password-prompt')) + ->header('Content-Type', 'text/html'); + } + + // Resolve Destination URL + $destination = $this->service->resolveRedirectUrl($shortUrl, $request); + + // App Linking / Deep Links Auto-Open Check + if ($shortUrl->auto_open_app_mobile) { + $deviceType = $this->uaParser->getDeviceType($request->userAgent() ?? ''); + + if ($deviceType === 'mobile' || $deviceType === 'tablet') { + $matchedApp = AppLinkingEngine::matchApp($destination); + if ($matchedApp !== null) { + $deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp); + $activePixels = $shortUrl->pixels->where('is_active', true); + + return response(view('filament-short-url::app-redirect', [ + 'destination' => $destination, + 'deepLink' => $deepLink, + 'appId' => $matchedApp, + 'pixels' => $activePixels, + ]))->header('Content-Type', 'text/html'); + } + } + } + + // Warning / Intermediate Page Check + if ($shortUrl->show_warning_page && ! $request->has('confirmed')) { + return response(view('filament-short-url::warning', ['destinationUrl' => $destination])) + ->header('Content-Type', 'text/html'); + } + + // Track Visit + if ($shortUrl->track_visits) { + try { + $connection = config('filament-short-url.queue_connection', 'sync'); + $ipAddress = ClientIpExtractor::getIp($request); + $countryCode = ClientIpExtractor::getCountryCode($request); + $city = ClientIpExtractor::getCity($request); + + $isQrScan = (bool) ($request->query('source') === 'qr' || $request->query('qr') === '1'); + $languages = $request->getLanguages(); + $browserLanguage = null; + if (! empty($languages)) { + $parts = explode('-', str_replace('_', '-', $languages[0])); + $browserLanguage = strtolower(trim($parts[0])); + if (strlen($browserLanguage) > 5) { + $browserLanguage = substr($browserLanguage, 0, 5); + } + } + + $selectedVariant = app()->bound('resolved_ab_variant') ? app('resolved_ab_variant') : null; + + $job = new TrackShortUrlVisitJob( + shortUrlId: $shortUrl->id, + ipAddress: $ipAddress, + userAgent: $request->userAgent() ?? '', + refererUrl: $request->header('Referer'), + countryCode: $countryCode, + city: $city, + utmSource: $request->query('utm_source'), + utmMedium: $request->query('utm_medium'), + utmCampaign: $request->query('utm_campaign'), + utmTerm: $request->query('utm_term'), + utmContent: $request->query('utm_content'), + isQrScan: $isQrScan, + browserLanguage: $browserLanguage, + selectedVariant: $selectedVariant, + ); + + if ($connection) { + dispatch($job->onConnection($connection)); + } else { + dispatch($job->onConnection('sync')); + } + } catch (\Throwable $e) { + Log::error('[FilamentShortUrl] Redirect tracking failed', [ + 'url_key' => $key, + 'error' => $e->getMessage(), + ]); + } + } + + // Atomically disable single-use URLs + if ($shortUrl->single_use) { + $affected = ShortUrl::where('id', $shortUrl->id) + ->where('is_enabled', true) + ->update(['is_enabled' => false]); + + if ($affected === 0) { + return response(view('filament-short-url::expired', [ + 'shortUrl' => $shortUrl, + ]), 410)->header('Content-Type', 'text/html'); + } + + $appHost = parse_url(config('app.url'), PHP_URL_HOST); + $hostsToForget = array_unique(array_filter([ + 'default', + $appHost, + $request->getHost(), + $shortUrl->custom_domain_id && $shortUrl->customDomain + ? $shortUrl->customDomain->domain + : null, + ])); + + foreach ($hostsToForget as $h) { + cache()->forget("filament-short-url:{$shortUrl->url_key}:{$h}"); + } + } + + $activePixels = $shortUrl->pixels->where('is_active', true); + + if ($activePixels->isNotEmpty()) { + return response(view('filament-short-url::pixel-loading', [ + 'destination' => $destination, + 'pixels' => $activePixels, + ]))->header('Content-Type', 'text/html'); + } + + return redirect()->away($destination, $shortUrl->redirect_status_code); + } } diff --git a/tests/Feature/ShortUrlComplexInteractionsTest.php b/tests/Feature/ShortUrlComplexInteractionsTest.php index 276b9ff..c32b812 100644 --- a/tests/Feature/ShortUrlComplexInteractionsTest.php +++ b/tests/Feature/ShortUrlComplexInteractionsTest.php @@ -66,17 +66,17 @@ describe('complex option interactions', function () { 'password' => 'secret123', ]); - // First attempt (below rate limit) -> Shows password page (200) - $this->get('/s/pw-rl-combo')->assertStatus(200)->assertSee('Password Required'); + // First attempt (below rate limit) -> Redirects to stateful s-auth route (302) + $this->get('/s/pw-rl-combo')->assertStatus(302)->assertRedirect('/s-auth/pw-rl-combo'); - // Second attempt (at rate limit) -> Shows password page (200) - $this->get('/s/pw-rl-combo')->assertStatus(200); + // Second attempt (at rate limit) -> Redirects to stateful s-auth route (302) + $this->get('/s/pw-rl-combo')->assertStatus(302)->assertRedirect('/s-auth/pw-rl-combo'); // Third attempt (exceeds rate limit) -> Aborts with 429 $this->get('/s/pw-rl-combo')->assertStatus(429); - // Submitting POST should also be blocked by the rate limiter - $this->post('/s/pw-rl-combo', ['password' => 'secret123'])->assertStatus(429); + // Submitting POST to s-auth should also be blocked by the rate limiter + $this->post('/s-auth/pw-rl-combo', ['password' => 'secret123'])->assertStatus(429); }); it('password rate limiting is independent and tracks wrong attempts only', function () { @@ -91,19 +91,19 @@ describe('complex option interactions', function () { // Submit 5 wrong attempts -> 200 with incorrect password for ($i = 0; $i < 5; $i++) { - $this->post('/s/pw-brute', ['password' => 'wrong-password']) + $this->post('/s-auth/pw-brute', ['password' => 'wrong-password']) ->assertStatus(200) ->assertSee('Incorrect password'); } // 6th attempt (even correct password) -> 429 due to brute force protection - $this->post('/s/pw-brute', ['password' => 'open-sesame']) + $this->post('/s-auth/pw-brute', ['password' => 'open-sesame']) ->assertStatus(429); // Check that a different IP can still log in successfully immediately $this->withServerVariables(['REMOTE_ADDR' => '1.1.1.1']) - ->post('/s/pw-brute', ['password' => 'open-sesame']) - ->assertRedirect('/s/pw-brute'); + ->post('/s-auth/pw-brute', ['password' => 'open-sesame']) + ->assertRedirect('/s-auth/pw-brute'); }); it('targeting rules apply after password protection but before warning page', function () { @@ -127,6 +127,13 @@ describe('complex option interactions', function () { $response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') ->get('/s/tgt-pw-warn'); + $response->assertStatus(302) + ->assertRedirect('/s-auth/tgt-pw-warn'); + + // Visit s-auth to see the password prompt + $response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') + ->get('/s-auth/tgt-pw-warn'); + $response->assertStatus(200) ->assertSee('Password Required') ->assertDontSee('Security Redirect Warning') @@ -134,12 +141,12 @@ describe('complex option interactions', function () { // 2. Submit correct password $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') - ->post('/s/tgt-pw-warn', ['password' => 'super-secret']) - ->assertRedirect('/s/tgt-pw-warn'); + ->post('/s-auth/tgt-pw-warn', ['password' => 'super-secret']) + ->assertRedirect('/s-auth/tgt-pw-warn'); // 3. Authenticated request as mobile: targeting resolved to mobile.com, warning page shown $response2 = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') - ->get('/s/tgt-pw-warn'); + ->get('/s-auth/tgt-pw-warn'); $response2->assertStatus(200) ->assertSee('Security Redirect Warning') @@ -148,7 +155,7 @@ describe('complex option interactions', function () { // 4. Confirm redirection: goes to mobile URL $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)') - ->get('/s/tgt-pw-warn?confirmed=1') + ->get('/s-auth/tgt-pw-warn?confirmed=1') ->assertRedirect('https://mobile.com'); }); @@ -369,8 +376,13 @@ describe('complex option interactions', function () { 'show_warning_page' => true, ]); - // Attempting to bypass password using ?confirmed=1 should still show password prompt - $this->get('/s/pw-bypass?confirmed=1') + // Attempting to bypass password using ?confirmed=1 should redirect to stateful s-auth route + $response = $this->get('/s/pw-bypass?confirmed=1'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/pw-bypass?confirmed=1'); + + // Visiting s-auth should render password prompt view, not warning page + $this->get('/s-auth/pw-bypass?confirmed=1') ->assertStatus(200) ->assertSee('Password Required') ->assertDontSee('Security Redirect Warning'); diff --git a/tests/Feature/ShortUrlFoldersAndTagsTest.php b/tests/Feature/ShortUrlFoldersAndTagsTest.php index 2769d3c..188a376 100644 --- a/tests/Feature/ShortUrlFoldersAndTagsTest.php +++ b/tests/Feature/ShortUrlFoldersAndTagsTest.php @@ -106,6 +106,8 @@ it('renders live activity feed widget with filtered data', function () { 'filters' => ['country_code' => 'PL'], ]) ->assertViewHas('visits', function ($visits) { - return count($visits) === 1 && $visits->first()->country_code === 'PL'; + $first = collect($visits)->first(); + + return count($visits) === 1 && ($first['country_code'] ?? $first->country_code ?? null) === 'PL'; }); }); diff --git a/tests/Feature/ShortUrlRedirectTest.php b/tests/Feature/ShortUrlRedirectTest.php index e669959..dba59b6 100644 --- a/tests/Feature/ShortUrlRedirectTest.php +++ b/tests/Feature/ShortUrlRedirectTest.php @@ -331,22 +331,27 @@ it('requires password to redirect when protected', function () { 'track_visits' => false, ]); - // Unauthenticated request should render password prompt view + // Unauthenticated request should redirect to the stateful s-auth route $response = $this->get('/s/password123'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/password123'); + + // GET request to s-auth should render password prompt view + $response = $this->get('/s-auth/password123'); $response->assertStatus(200); $response->assertSee('Password Required'); // Send incorrect password - $response = $this->post('/s/password123', ['password' => 'wrong']); + $response = $this->post('/s-auth/password123', ['password' => 'wrong']); $response->assertStatus(200); $response->assertSee('Incorrect password'); // Send correct password - $response = $this->post('/s/password123', ['password' => 'secret-key']); - $response->assertRedirect('/s/password123'); + $response = $this->post('/s-auth/password123', ['password' => 'secret-key']); + $response->assertRedirect('/s-auth/password123'); // Following request should redirect to target - $this->get('/s/password123')->assertRedirect('https://example.com'); + $this->get('/s-auth/password123')->assertRedirect('https://example.com'); }); it('applies rate limiting on password attempts when protected', function () { @@ -358,13 +363,13 @@ it('applies rate limiting on password attempts when protected', function () { // First 5 incorrect attempts should return 200 (renders password prompt again) for ($i = 0; $i < 5; $i++) { - $this->post('/s/password-ratelimit', ['password' => 'wrong-pass']) + $this->post('/s-auth/password-ratelimit', ['password' => 'wrong-pass']) ->assertStatus(200) ->assertSee('Incorrect password'); } // 6th incorrect attempt should be rate limited (429) - $this->post('/s/password-ratelimit', ['password' => 'wrong-pass']) + $this->post('/s-auth/password-ratelimit', ['password' => 'wrong-pass']) ->assertStatus(429); }); @@ -393,31 +398,36 @@ it('requires password first, then shows warning page when both are enabled', fun 'track_visits' => false, ]); - // 1. Visit without password -> password prompt page, not warning page + // 1. Visit without password -> redirects to s-auth route $response = $this->get('/s/both-secure'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/both-secure'); + + // Visit s-auth without password -> password prompt page, not warning page + $response = $this->get('/s-auth/both-secure'); $response->assertStatus(200); $response->assertSee('Password Required'); $response->assertDontSee('Security Redirect Warning'); // 2. Submit wrong password -> password prompt page with error - $response = $this->post('/s/both-secure', ['password' => 'wrong']); + $response = $this->post('/s-auth/both-secure', ['password' => 'wrong']); $response->assertStatus(200); $response->assertSee('Incorrect password'); $response->assertDontSee('Security Redirect Warning'); // 3. Submit correct password -> redirects back to the short URL path - $response = $this->post('/s/both-secure', ['password' => 'secret-combination']); - $response->assertRedirect('/s/both-secure'); + $response = $this->post('/s-auth/both-secure', ['password' => 'secret-combination']); + $response->assertRedirect('/s-auth/both-secure'); // 4. Follow redirect (GET request with authenticated session, but without confirmed parameter) // -> shows redirect warning page, not direct redirect - $response = $this->get('/s/both-secure'); + $response = $this->get('/s-auth/both-secure'); $response->assertStatus(200); $response->assertSee('Security Redirect Warning'); $response->assertSee('https://example.com'); // 5. Follow the confirmed link (confirmed=1) -> redirects to destination - $this->get('/s/both-secure?confirmed=1') + $this->get('/s-auth/both-secure?confirmed=1') ->assertRedirect('https://example.com'); }); diff --git a/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php b/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php index 7c61305..f342362 100644 --- a/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php +++ b/tests/Feature/ShortUrlSettingsAndFormOptionsTest.php @@ -407,20 +407,29 @@ describe('link option: password', function () { it('shows password prompt to unauthenticated visitor', function () { makeLink(['url_key' => 'pw1', 'password' => 'letmein']); - $this->get('/s/pw1')->assertStatus(200)->assertSee('Password Required'); + // GET request to /s/{key} should redirect to /s-auth/{key} + $response = $this->get('/s/pw1'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/pw1'); + + // GET request to /s-auth/{key} should show password prompt + $this->get('/s-auth/pw1')->assertStatus(200)->assertSee('Password Required'); }); it('redirects after correct password via session', function () { makeLink(['url_key' => 'pw2', 'password' => 'open']); - $this->post('/s/pw2', ['password' => 'open'])->assertRedirect('/s/pw2'); - $this->get('/s/pw2')->assertRedirect('https://example.com'); + // POST correct password to /s-auth/{key} -> redirects back to /s-auth/{key} + $this->post('/s-auth/pw2', ['password' => 'open'])->assertRedirect('/s-auth/pw2'); + // Following request to /s-auth/{key} redirects to final destination + $this->get('/s-auth/pw2')->assertRedirect('https://example.com'); }); it('returns error view on wrong password', function () { makeLink(['url_key' => 'pw3', 'password' => 'right']); - $this->post('/s/pw3', ['password' => 'wrong']) + // POST wrong password to /s-auth/{key} -> returns prompt with error + $this->post('/s-auth/pw3', ['password' => 'wrong']) ->assertStatus(200) ->assertSee('Incorrect password'); }); @@ -429,17 +438,22 @@ describe('link option: password', function () { makeLink(['url_key' => 'pw-rl', 'password' => 'correct']); for ($i = 0; $i < 5; $i++) { - $this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(200); + $this->post('/s-auth/pw-rl', ['password' => 'wrong'])->assertStatus(200); } - $this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(429); + $this->post('/s-auth/pw-rl', ['password' => 'wrong'])->assertStatus(429); }); it('password check happens before warning page', function () { makeLink(['url_key' => 'pw-warn', 'password' => 'abc', 'show_warning_page' => true]); + // Unauthenticated visitor redirects to /s-auth/pw-warn + $response = $this->get('/s/pw-warn'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/pw-warn'); + // Password prompt must appear first, not warning - $this->get('/s/pw-warn')->assertSee('Password Required')->assertDontSee('Security Redirect Warning'); + $this->get('/s-auth/pw-warn')->assertSee('Password Required')->assertDontSee('Security Redirect Warning'); }); }); @@ -1125,8 +1139,13 @@ describe('option interactions and conflict scenarios', function () { it('single_use link with password shows password prompt on first visit', function () { makeLink(['url_key' => 'su-pw', 'single_use' => true, 'password' => 'abc']); - // Should show password prompt, NOT redirect + disable - $this->get('/s/su-pw')->assertSee('Password Required'); + // Should redirect to stateful s-auth route + $response = $this->get('/s/su-pw'); + $response->assertStatus(302); + $response->assertRedirect('/s-auth/su-pw'); + + // Visiting s-auth should render password prompt, NOT redirect + disable + $this->get('/s-auth/su-pw')->assertSee('Password Required'); // Link must still be enabled (not consumed by the password prompt visit) expect(ShortUrl::where('url_key', 'su-pw')->value('is_enabled'))->toBeTrue();