4 Commits

15 changed files with 266 additions and 103 deletions

View File

@@ -243,7 +243,7 @@ Settings are stored dynamically in `storage/app/filament-short-url-settings.json
The settings panel allows you to configure:
### 1. General Routing & Queueing
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`).
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`). Can be left empty to serve links directly from the root domain (e.g. `domain.com/{key}`).
* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
* *Note: `302` is highly recommended for analytics accuracy because browsers cache `301` redirects, skipping subsequent logs.*
* **Key Length**: Default character count (base62) for auto-generated keys (default: `6`).
@@ -263,27 +263,17 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
### 4. Counter Buffering (Write-back Caching)
For extremely high-traffic applications, direct database writes for click counts can cause row-locking bottlenecks.
* **Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache.
* **Cron Synchronization**: When enabled, you must schedule the synchronization command to run periodically (e.g., every minute) to flush counts to the database:
```bash
php artisan short-url:sync-counters
```
In your scheduler (`routes/console.php` or `app/Console/Kernel.php`):
```php
$schedule->command('short-url:sync-counters')->everyMinute();
```
* **Cron Synchronization**: When enabled, the synchronization command flushes counts to the database. The package automatically registers this in the Laravel Scheduler to run every minute when counter buffering is active, so you only need to ensure the standard Laravel schedule runner (`* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1`) is running on your server.
* Command: `php artisan short-url:sync-counters`
### 5. Performance & Security Tab (new in v1.2.0)
#### High-Traffic Log Management (Aggregation & Pruning)
At scale, the `short_url_visits` table can grow to tens of gigabytes. The aggregation system solves this:
* **Enable Daily Aggregation**: When enabled, the nightly `short-url:aggregate-and-prune` command summarizes the previous day's raw visit records into the compact `short_url_daily_stats` table.
* **Enable Daily Aggregation**: When enabled, the stats are summarized. The package automatically registers the aggregation command in the Laravel Scheduler to run daily at 02:00, so you only need to ensure the standard Laravel schedule runner is running on your server.
* Command: `php artisan short-url:aggregate-and-prune`
* **Prune Raw Logs After (days)**: Raw visit records older than this threshold are permanently deleted after aggregation. Set to `0` to disable pruning. Default: `90` days.
Schedule the command in your scheduler:
```php
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
```
#### Rate Limiting / Bot Protection
Prevent redirect abuse and bot traffic flooding:
* **Enable Rate Limiting**: Activates per-IP rate limiting on all redirect routes.
@@ -373,7 +363,23 @@ $shortUrl->update([
]);
```
#### 3. A/B Split Rotation
#### 3. Browser Language-Based Redirects (new in v2.0.5)
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'language',
'language' => [
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
['language_code' => 'de', 'url' => 'https://de.example.com'],
],
],
]);
```
#### 4. A/B Split Rotation
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
```php
@@ -586,6 +592,11 @@ curl -X POST https://yourdomain.com/api/short-url/links \
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
| `password` | string | ❌ | Access protection password |
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
**Response:** `201 Created` with the created link object.

View File

@@ -299,13 +299,16 @@ return [
'targeting_type_device' => 'Device-Based Redirects',
'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
'targeting_type_rotation' => 'A/B Split Rotation',
'targeting_type_language' => 'Browser Language-Based Redirects',
'device_targeting_rules' => 'Device Rules',
'country_targeting_rules' => 'Country Rules',
'language_targeting_rules' => 'Language Rules',
'rotation_targeting_rules' => 'Rotation Targets',
'device_mobile' => 'Mobile Destination URL',
'device_tablet' => 'Tablet Destination URL',
'device_desktop' => 'Desktop Destination URL',
'country_code' => 'Country Code',
'language_code' => 'Language Code',
'rotation_url' => 'Destination URL',
'rotation_weight' => 'Traffic Weight',
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',

View File

@@ -0,0 +1,44 @@
<?php
return [
'pl' => 'Polish',
'en' => 'English',
'de' => 'German',
'fr' => 'French',
'es' => 'Spanish',
'it' => 'Italian',
'pt' => 'Portuguese',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'ar' => 'Arabic',
'nl' => 'Dutch',
'cs' => 'Czech',
'sk' => 'Slovak',
'hu' => 'Hungarian',
'ro' => 'Romanian',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'uk' => 'Ukrainian',
'tr' => 'Turkish',
'da' => 'Danish',
'sv' => 'Swedish',
'no' => 'Norwegian',
'fi' => 'Finnish',
'el' => 'Greek',
'he' => 'Hebrew',
'hi' => 'Hindi',
'th' => 'Thai',
'vi' => 'Vietnamese',
'ko' => 'Korean',
'id' => 'Indonesian',
'ms' => 'Malay',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'is' => 'Icelandic',
'ga' => 'Irish',
'fa' => 'Persian',
];

View File

@@ -300,13 +300,16 @@ return [
'targeting_type_device' => 'Przekierowania zależne od urządzenia',
'targeting_type_country' => 'Przekierowania zależne od kraju (Geo-IP)',
'targeting_type_rotation' => 'Podział ruchu A/B (Rotacja)',
'targeting_type_language' => 'Przekierowania zależne od języka przeglądarki',
'device_targeting_rules' => 'Reguły urządzeń',
'country_targeting_rules' => 'Reguły krajów',
'language_targeting_rules' => 'Reguły języków',
'rotation_targeting_rules' => 'Cele rotacji',
'device_mobile' => 'Docelowy URL dla urządzeń mobilnych',
'device_tablet' => 'Docelowy URL dla tabletów',
'device_desktop' => 'Docelowy URL dla komputerów stacjonarnych',
'country_code' => 'Kod kraju',
'language_code' => 'Kod języka',
'rotation_url' => 'Docelowy URL',
'rotation_weight' => 'Waga ruchu',
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',

View File

@@ -0,0 +1,44 @@
<?php
return [
'pl' => 'Polski',
'en' => 'Angielski',
'de' => 'Niemiecki',
'fr' => 'Francuski',
'es' => 'Hiszpański',
'it' => 'Włoski',
'pt' => 'Portugalski',
'ru' => 'Rosyjski',
'zh' => 'Chiński',
'ja' => 'Japoński',
'ar' => 'Arabski',
'nl' => 'Holenderski',
'cs' => 'Czeski',
'sk' => 'Słowacki',
'hu' => 'Węgierski',
'ro' => 'Rumuński',
'bg' => 'Bułgarski',
'hr' => 'Chorwacki',
'sr' => 'Serbski',
'sl' => 'Słoweński',
'uk' => 'Ukraiński',
'tr' => 'Turecki',
'da' => 'Duński',
'sv' => 'Szwedzki',
'no' => 'Norweski',
'fi' => 'Fiński',
'el' => 'Grecki',
'he' => 'Hebrajski',
'hi' => 'Hindi',
'th' => 'Tajski',
'vi' => 'Wietnamski',
'ko' => 'Koreański',
'id' => 'Indonezyjski',
'ms' => 'Malajski',
'et' => 'Estoński',
'lv' => 'Łotewski',
'lt' => 'Litewski',
'is' => 'Islandzki',
'ga' => 'Irlandzki',
'fa' => 'Perski',
];

View File

@@ -47,8 +47,26 @@ class AggregateAndPruneVisitsCommand extends Command
$statsByUrl = [];
$nextDate = Carbon::parse($date)->addDay()->toDateString();
ShortUrlVisit::where('visited_at', '>=', $date.' 00:00:00')
DB::table('short_url_visits')
->where('visited_at', '>=', $date.' 00:00:00')
->where('visited_at', '<', $nextDate.' 00:00:00')
->select([
'short_url_id',
'is_qr_scan',
'ip_hash',
'device_type',
'browser',
'operating_system',
'country',
'country_code',
'utm_source',
'utm_medium',
'utm_campaign',
'browser_language',
'city',
'referer_host',
])
->orderBy('id')
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;

View File

@@ -25,9 +25,13 @@ class SyncBufferedCountersCommand extends Command
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
if (Redis::exists($dirtyKey)) {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
} else {
$dirtyIds = [];
}
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];

View File

@@ -432,6 +432,7 @@ class ShortUrlForm
'none' => __('filament-short-url::default.targeting_type_none'),
'device' => __('filament-short-url::default.targeting_type_device'),
'geo' => __('filament-short-url::default.targeting_type_country'),
'language' => __('filament-short-url::default.targeting_type_language'),
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
])
->default('none')
@@ -487,6 +488,33 @@ class ShortUrlForm
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
Repeater::make('targeting_rules.language')
->label(__('filament-short-url::default.language_targeting_rules'))
->schema([
Select::make('language_code')
->label(__('filament-short-url::default.language_code'))
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
}
return [];
})
->searchable()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->required(),
TextInput::make('url')
->label(__('filament-short-url::default.destination_url'))
->url()
->required()
->maxLength(2048),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'language'),
Repeater::make('targeting_rules.rotation')
->label(__('filament-short-url::default.rotation_targeting_rules'))
->schema([

View File

@@ -27,37 +27,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
*/
protected function getViewData(): array
{
$languageNames = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
$languageNames = __('filament-short-url::languages');
if (! is_array($languageNames)) {
$languageNames = [];
}
if (! $this->record) {
return [
@@ -84,6 +57,14 @@ class ShortUrlVisitsRightBreakdown extends Widget
public static function getLanguageTranslation(string $langCode): string
{
$langCode = strtolower(trim($langCode));
// 1. Try loading from languages translation file (languages.php)
$translatedLanguages = __('filament-short-url::languages');
if (is_array($translatedLanguages) && isset($translatedLanguages[$langCode])) {
return $translatedLanguages[$langCode];
}
// 2. Try PHP Locale extension
if (class_exists(\Locale::class)) {
try {
$name = \Locale::getDisplayLanguage($langCode, app()->getLocale());
@@ -95,39 +76,8 @@ class ShortUrlVisitsRightBreakdown extends Widget
}
}
$fallback = [
'en' => 'English',
'pl' => 'Polish',
'de' => 'German',
'es' => 'Spanish',
'fr' => 'French',
'it' => 'Italian',
'ru' => 'Russian',
'zh' => 'Chinese',
'ja' => 'Japanese',
'pt' => 'Portuguese',
'nl' => 'Dutch',
'tr' => 'Turkish',
'uk' => 'Ukrainian',
'cs' => 'Czech',
'sv' => 'Swedish',
'no' => 'Norwegian',
'da' => 'Danish',
'fi' => 'Finnish',
'ro' => 'Romanian',
'hu' => 'Hungarian',
'sk' => 'Slovak',
'bg' => 'Bulgarian',
'hr' => 'Croatian',
'sr' => 'Serbian',
'sl' => 'Slovenian',
'et' => 'Estonian',
'lv' => 'Latvian',
'lt' => 'Lithuanian',
'el' => 'Greek',
];
return $fallback[$langCode] ?? strtoupper($langCode);
// 3. Simple fallback
return strtoupper($langCode);
}
/**

View File

@@ -71,9 +71,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
$this->app->booted(function (): void {
$schedule = $this->app->make(Schedule::class);
if (config('filament-short-url.pruning.enabled', true)) {
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
}
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
if (config('filament-short-url.counter_buffering.enabled', false)) {
$schedule->command('short-url:sync-counters')->everyMinute();

View File

@@ -61,6 +61,11 @@ class ShortUrlApiController extends Controller
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
'targeting_rules' => 'nullable|array',
'password' => 'nullable|string|max:255',
'show_warning_page' => 'nullable|boolean',
'track_visits' => 'nullable|boolean',
'track_browser_language' => 'nullable|boolean',
]);
$shortUrl = $this->service->create($validated);
@@ -108,6 +113,11 @@ class ShortUrlApiController extends Controller
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'webhook_url' => $link->webhook_url,
'targeting_rules' => $link->targeting_rules,
'password' => $link->password,
'show_warning_page' => (bool) $link->show_warning_page,
'track_visits' => (bool) $link->track_visits,
'track_browser_language' => (bool) $link->track_browser_language,
'notes' => $link->notes,
'created_at' => $link->created_at->toIso8601String(),
];

View File

@@ -89,20 +89,8 @@ class ShortUrlRedirectController extends Controller
}
}
// 4. Resolve Destination URL (evaluating targeting rules)
$destination = $shortUrl->resolveDestinationUrl($request);
// Forward query parameters if configured
if ($shortUrl->forward_query_params) {
$queryParams = $request->query();
// Remove routing/auth parameters so they don't leak to destination
unset($queryParams['confirmed'], $queryParams['password']);
if (! empty($queryParams)) {
$separator = str_contains($destination, '?') ? '&' : '?';
$destination .= $separator.http_build_query($queryParams);
}
}
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
// 5. Warning / Intermediate Page Check
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {

View File

@@ -276,7 +276,21 @@ class ShortUrl extends Model
public function getRealTimeTotalVisits(): int
{
return $this->total_visits;
$buffered = 0;
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
}
if ($this->max_visits !== null) {
$dbVal = (int) DB::table($this->table)
->where('id', $this->id)
->value('total_visits');
return $dbVal + $buffered;
}
return $this->total_visits + $buffered;
}
public function isActive(): bool
@@ -285,6 +299,13 @@ class ShortUrl extends Model
return false;
}
if ($this->single_use) {
$realEnabled = DB::table($this->table)->where('id', $this->id)->value('is_enabled');
if (! $realEnabled) {
return false;
}
}
// Not yet active
if ($this->activated_at && $this->activated_at->isFuture()) {
return false;
@@ -419,6 +440,43 @@ class ShortUrl extends Model
}
}
if ($type === 'language') {
$acceptedLanguages = $request->getLanguages();
// Pass 1: Exact match (e.g. "en-us" matches "en-us" rule, or "pl" matches "pl" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $acceptedLanguage) {
return $rule['url'] ?? $this->destination_url;
}
}
}
// Pass 2: Primary language fallback match (e.g. "en-us" matches general "en" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
$parts = explode('-', $acceptedLanguage);
$primaryLang = strtolower(trim($parts[0]));
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $primaryLang) {
return $rule['url'] ?? $this->destination_url;
}
}
}
}
if ($type === 'rotation') {
$items = $rules['rotation'] ?? [];
if (! empty($items)) {

View File

@@ -91,7 +91,7 @@ class GeoIpService
if (class_exists(\Locale::class)) {
try {
$name = \Locale::getDisplayRegion('-'.$code, 'en');
$name = \Locale::getDisplayRegion('en-'.$code, 'en');
if ($name && $name !== $code) {
return $name;
}

View File

@@ -37,6 +37,8 @@ class UserAgentParser
// Order matters — check specific first, generic last
$patterns = [
'Edg/' => 'Edge',
'EdgiOS' => 'Edge',
'EdgA' => 'Edge',
'OPR/' => 'Opera',
'Opera' => 'Opera',
'SamsungBrowser' => 'Samsung Browser',
@@ -67,6 +69,8 @@ class UserAgentParser
{
$patterns = [
'/Edg\/([0-9.]+)/',
'/EdgiOS\/([0-9.]+)/',
'/EdgA\/([0-9.]+)/',
'/OPR\/([0-9.]+)/',
'/SamsungBrowser\/([0-9.]+)/',
'/CriOS\/([0-9.]+)/',