Optimize redirect request lifecycle, bypass web middleware group, add stateful /s-auth/{key} route and fix test suites
This commit is contained in:
12
README.md
12
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
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
@@ -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' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
|
||||
])
|
||||
->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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_move').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">M</span></div>'))
|
||||
->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']]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
|
||||
])
|
||||
->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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user