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();