6 Commits

25 changed files with 1703 additions and 151 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

@@ -0,0 +1,155 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create short_url_pixels table
Schema::create('short_url_pixels', function (Blueprint $table) {
$table->id();
$table->string('name', 150);
$table->string('type', 50); // meta, google, linkedin, tiktok, pinterest
$table->string('pixel_id', 100);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'pixel_id']);
});
// 2. Create short_url_pixel pivot table
Schema::create('short_url_pixel', function (Blueprint $table) {
$table->foreignId('short_url_id')->constrained('short_urls')->cascadeOnDelete();
$table->foreignId('pixel_id')->constrained('short_url_pixels')->cascadeOnDelete();
$table->primary(['short_url_id', 'pixel_id']);
});
// 3. Data migration: Migrate existing pixel IDs from short_urls to short_url_pixels and pivot
if (Schema::hasColumn('short_urls', 'pixel_meta_id')) {
$oldUrls = DB::table('short_urls')
->whereNotNull('pixel_meta_id')
->orWhereNotNull('pixel_google_id')
->orWhereNotNull('pixel_linkedin_id')
->get();
foreach ($oldUrls as $url) {
// Migrate Meta Pixel
if (! empty($url->pixel_meta_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'meta')
->where('pixel_id', $url->pixel_meta_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'Meta Pixel ('.$url->pixel_meta_id.')',
'type' => 'meta',
'pixel_id' => $url->pixel_meta_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
// Migrate Google Tag / GA4
if (! empty($url->pixel_google_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'google')
->where('pixel_id', $url->pixel_google_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'Google Tag ('.$url->pixel_google_id.')',
'type' => 'google',
'pixel_id' => $url->pixel_google_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
// Migrate LinkedIn Insight
if (! empty($url->pixel_linkedin_id)) {
$pixelId = DB::table('short_url_pixels')
->where('type', 'linkedin')
->where('pixel_id', $url->pixel_linkedin_id)
->value('id');
if (! $pixelId) {
$pixelId = DB::table('short_url_pixels')->insertGetId([
'name' => 'LinkedIn Insight ('.$url->pixel_linkedin_id.')',
'type' => 'linkedin',
'pixel_id' => $url->pixel_linkedin_id,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('short_url_pixel')->insertOrIgnore([
'short_url_id' => $url->id,
'pixel_id' => $pixelId,
]);
}
}
// 4. Drop the old columns
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn(['pixel_meta_id', 'pixel_google_id', 'pixel_linkedin_id']);
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 1. Re-add the columns
Schema::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
});
// 2. Re-populate the old columns from pivot data
$associations = DB::table('short_url_pixel')
->join('short_url_pixels', 'short_url_pixel.pixel_id', '=', 'short_url_pixels.id')
->select('short_url_pixel.short_url_id', 'short_url_pixels.type', 'short_url_pixels.pixel_id')
->get();
foreach ($associations as $assoc) {
if ($assoc->type === 'meta') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_meta_id' => $assoc->pixel_id]);
} elseif ($assoc->type === 'google') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_google_id' => $assoc->pixel_id]);
} elseif ($assoc->type === 'linkedin') {
DB::table('short_urls')->where('id', $assoc->short_url_id)->update(['pixel_linkedin_id' => $assoc->pixel_id]);
}
}
// 3. Drop tables
Schema::dropIfExists('short_url_pixel');
Schema::dropIfExists('short_url_pixels');
}
};

View File

@@ -199,7 +199,8 @@ return [
'settings_site_name' => 'Site Name Override',
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
'settings_route_prefix' => 'Route Prefix',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
'settings_key_length' => 'Auto-generated Key Length',
'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.',
'settings_cache_ttl' => 'Redirect Cache TTL',
@@ -298,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.',
@@ -456,4 +460,10 @@ return [
'badge_expires' => 'Expires: :date',
'badge_no_expiry' => 'No expiry',
'badge_redirect' => ':code redirect',
'pixels_navigation_label' => 'Retargeting Pixels',
'pixel_resource_title' => 'Retargeting Pixel',
'pixel_name' => 'Pixel Name',
'pixel_type' => 'Provider',
'pixel_id_label' => 'Pixel ID / Tag ID',
'pixel_status_active' => 'Active',
];

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

@@ -200,7 +200,8 @@ return [
'settings_site_name' => 'Niestandardowa nazwa witryny',
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
'settings_route_prefix' => 'Prefiks trasy',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
'settings_key_length' => 'Długość auto-generowanego klucza',
'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.',
'settings_cache_ttl' => 'TTL cache przekierowania',
@@ -299,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.',
@@ -457,4 +461,10 @@ return [
'badge_expires' => 'Wygasa: :date',
'badge_no_expiry' => 'Bez wygasania',
'badge_redirect' => 'Przekierowanie :code',
'pixels_navigation_label' => 'Piksele retargetingowe',
'pixel_resource_title' => 'Piksel retargetingowy',
'pixel_name' => 'Nazwa piksela',
'pixel_type' => 'Dostawca',
'pixel_id_label' => 'ID Piksela / Tagu',
'pixel_status_active' => 'Aktywny',
];

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

@@ -33,8 +33,16 @@
}
</script>
@php
$pixelMetaIds = $pixels->where('type', 'meta')->pluck('pixel_id');
$pixelGoogleIds = $pixels->where('type', 'google')->pluck('pixel_id');
$pixelLinkedinIds = $pixels->where('type', 'linkedin')->pluck('pixel_id');
$pixelTiktokIds = $pixels->where('type', 'tiktok')->pluck('pixel_id');
$pixelPinterestIds = $pixels->where('type', 'pinterest')->pluck('pixel_id');
@endphp
<!-- Meta / Facebook Pixel -->
@if(!empty($pixelMetaId))
@if($pixelMetaIds->isNotEmpty())
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
@@ -44,29 +52,37 @@
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{{ $pixelMetaId }}');
@foreach($pixelMetaIds as $id)
fbq('init', '{{ $id }}');
fbq('track', 'PageView');
@endforeach
</script>
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
@foreach($pixelMetaIds as $id)
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $id }}&ev=PageView&noscript=1" /></noscript>
@endforeach
@endif
<!-- Google Analytics / GTM -->
@if(!empty($pixelGoogleId))
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
@if($pixelGoogleIds->isNotEmpty())
@php $firstGoogleId = $pixelGoogleIds->first(); @endphp
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $firstGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ $pixelGoogleId }}');
@foreach($pixelGoogleIds as $id)
gtag('config', '{{ $id }}');
@endforeach
</script>
@endif
<!-- LinkedIn Insight -->
@if(!empty($pixelLinkedinId))
@if($pixelLinkedinIds->isNotEmpty())
<script type="text/javascript">
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
@foreach($pixelLinkedinIds as $id)
window._linkedin_data_partner_ids.push("{{ $id }}");
@endforeach
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
@@ -76,7 +92,36 @@
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
@foreach($pixelLinkedinIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $id }}&fmt=gif" /></noscript>
@endforeach
@endif
<!-- TikTok Pixel -->
@if($pixelTiktokIds->isNotEmpty())
<script>
!function (w, d, t) {
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js",o=n&&n.mixpanel;ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=o||{};var a=d.createElement("script");a.type="text/javascript",a.async=!0,a.src=r+"?sdkid="+e+"&lib="+t;var c=d.getElementsByTagName("script")[0];c.parentNode.insertBefore(a,c)};
@foreach($pixelTiktokIds as $id)
ttq.load('{{ $id }}');
ttq.page();
@endforeach
}(window, document, 'ttq');
</script>
@endif
<!-- Pinterest Tag -->
@if($pixelPinterestIds->isNotEmpty())
<script>
!function(e,n,t,r,a,s,o){e[r]||(e[r]=function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].q=e[r].q||[],s=n.createElement(t),s.async=!0,s.src="https://s.pntrac.com/tag.js",o=n.getElementsByTagName(t)[0],o.parentNode.insertBefore(s,o))}(window,document,"script","pintrk");
@foreach($pixelPinterestIds as $id)
pintrk('load', '{{ $id }}');
pintrk('page');
@endforeach
</script>
@foreach($pixelPinterestIds as $id)
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://ct.pinterest.com/v3/?event=init&tid={{ $id }}&noscript=1" /></noscript>
@endforeach
@endif
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">

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

@@ -0,0 +1,152 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
class ShortUrlPixelResource extends Resource
{
protected static ?string $model = ShortUrlPixel::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static ?int $navigationSort = 51;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
}
public static function getModelLabel(): string
{
return __('filament-short-url::default.pixel_resource_title') ?? 'Retargeting Pixel';
}
public static function getPluralModelLabel(): string
{
return __('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels';
}
public static function getNavigationGroup(): string|\UnitEnum|null
{
try {
return ShortUrlResource::getNavigationGroup();
} catch (\Throwable) {
return __('filament-short-url::default.navigation_group');
}
}
public static function getNavigationSort(): ?int
{
try {
return ShortUrlResource::getNavigationSort() + 1;
} catch (\Throwable) {
return static::$navigationSort;
}
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
->required()
->maxLength(150)
->placeholder('e.g. Meta Ads - Yacht Promo'),
Select::make('type')
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
->options([
'meta' => 'Meta / Facebook Pixel',
'google' => 'Google Tag (GA4 / GTM)',
'linkedin' => 'LinkedIn Insight Tag',
'tiktok' => 'TikTok Pixel',
'pinterest' => 'Pinterest Tag',
])
->required()
->native(false),
TextInput::make('pixel_id')
->label(__('filament-short-url::default.pixel_id_label') ?? 'Pixel ID / Tag ID')
->required()
->maxLength(100)
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
Toggle::make('is_active')
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
->default(true),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('filament-short-url::default.pixel_name') ?? 'Pixel Name')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('type')
->label(__('filament-short-url::default.pixel_type') ?? 'Provider')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'meta' => 'Meta',
'google' => 'Google',
'linkedin' => 'LinkedIn',
'tiktok' => 'TikTok',
'pinterest' => 'Pinterest',
default => ucfirst($state),
})
->color(fn (string $state): string => match ($state) {
'meta' => 'info',
'google' => 'warning',
'linkedin' => 'primary',
'tiktok' => 'gray',
'pinterest' => 'danger',
default => 'gray',
}),
TextColumn::make('pixel_id')
->label(__('filament-short-url::default.pixel_id_label') ?? 'ID')
->searchable()
->copyable()
->fontFamily('mono')
->color('gray'),
ToggleColumn::make('is_active')
->label(__('filament-short-url::default.pixel_status_active') ?? 'Active')
->sortable(),
])
->filters([])
->actions([
EditAction::make()->modalWidth('md'),
DeleteAction::make(),
])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => ListShortUrlPixels::route('/'),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
class ListShortUrlPixels extends ManageRecords
{
protected static string $resource = ShortUrlPixelResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->icon('heroicon-o-plus')
->size('sm')
->color('primary')
->modalWidth('md'),
];
}
}

View File

@@ -152,6 +152,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),

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([
@@ -519,22 +547,14 @@ class ShortUrlForm
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
->schema([
TextInput::make('pixel_meta_id')
->label(__('filament-short-url::default.pixel_meta'))
->placeholder('e.g., 1234567890')
->maxLength(100)
->nullable(),
TextInput::make('pixel_google_id')
->label(__('filament-short-url::default.pixel_google'))
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
->maxLength(100)
->nullable(),
TextInput::make('pixel_linkedin_id')
->label(__('filament-short-url::default.pixel_linkedin'))
->placeholder('e.g., 1234567')
->maxLength(100)
->nullable(),
])->columns(3),
Select::make('pixels')
->label(__('filament-short-url::default.pixels_navigation_label') ?? 'Retargeting Pixels')
->multiple()
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
->preload()
->searchable()
->columnSpanFull(),
]),
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))

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

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Filament\Contracts\Plugin;
use Filament\Panel;
@@ -44,6 +45,7 @@ class FilamentShortUrlPlugin implements Plugin
$panel
->resources([
ShortUrlResource::class,
ShortUrlPixelResource::class,
]);
}

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

@@ -57,14 +57,65 @@ class ShortUrlApiController extends Controller
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'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',
'pixels' => 'nullable|array',
'pixels.*' => 'integer|exists:short_url_pixels,id',
// Keep for backward compatibility
'pixel_meta_id' => 'nullable|string|max:100',
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
]);
$pixelIds = $validated['pixels'] ?? [];
// Backward compatibility support for old pixel columns
if (! empty($validated['pixel_meta_id'])) {
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'meta',
'pixel_id' => $validated['pixel_meta_id'],
], [
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $metaPixelId;
}
if (! empty($validated['pixel_google_id'])) {
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'google',
'pixel_id' => $validated['pixel_google_id'],
], [
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $googlePixelId;
}
if (! empty($validated['pixel_linkedin_id'])) {
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'linkedin',
'pixel_id' => $validated['pixel_linkedin_id'],
], [
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $linkedinPixelId;
}
// Clean up parameters that shouldn't be mass assigned
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
$shortUrl = $this->service->create($validated);
if (! empty($pixelIds)) {
$shortUrl->pixels()->sync($pixelIds);
}
// Fire 'created' webhook if active
$this->dispatchCreatedWebhook($shortUrl);
@@ -92,6 +143,8 @@ class ShortUrlApiController extends Controller
*/
private function transformLink(ShortUrl $link): array
{
$pixels = $link->relationLoaded('pixels') ? $link->pixels : $link->pixels()->get();
return [
'id' => $link->id,
'destination_url' => $link->destination_url,
@@ -104,10 +157,22 @@ class ShortUrlApiController extends Controller
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
'pixel_meta_id' => $link->pixel_meta_id,
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_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,
'pixels' => $pixels->map(fn ($p) => [
'id' => $p->id,
'name' => $p->name,
'type' => $p->type,
'pixel_id' => $p->pixel_id,
'is_active' => (bool) $p->is_active,
])->toArray(),
'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')) {
@@ -174,12 +162,12 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
if ($activePixels->isNotEmpty()) {
return response(view('filament-short-url::pixel-loading', [
'destination' => $destination,
'pixelMetaId' => $shortUrl->pixel_meta_id,
'pixelGoogleId' => $shortUrl->pixel_google_id,
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
'pixels' => $activePixels,
]))->header('Content-Type', 'text/html');
}

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -82,9 +83,6 @@ class ShortUrl extends Model
'unique_visits',
'max_visits',
'expiration_redirect_url',
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
'qr_logo',
'qr_scans',
@@ -126,6 +124,11 @@ class ShortUrl extends Model
return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
}
public function pixels(): BelongsToMany
{
return $this->belongsToMany(ShortUrlPixel::class, 'short_url_pixel', 'short_url_id', 'pixel_id');
}
// ─── Scopes ──────────────────────────────────────────────────────────────
public function scopeEnabled(Builder $query): Builder
@@ -198,15 +201,6 @@ class ShortUrl extends Model
$m->expiration_redirect_url = null;
}
if (empty($m->pixel_meta_id)) {
$m->pixel_meta_id = null;
}
if (empty($m->pixel_google_id)) {
$m->pixel_google_id = null;
}
if (empty($m->pixel_linkedin_id)) {
$m->pixel_linkedin_id = null;
}
if (empty($m->webhook_url)) {
$m->webhook_url = null;
}
@@ -276,7 +270,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 +293,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 +434,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

@@ -0,0 +1,28 @@
<?php
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class ShortUrlPixel extends Model
{
protected $fillable = [
'name',
'type',
'pixel_id',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
/**
* Get the short URLs associated with this pixel.
*/
public function shortUrls(): BelongsToMany
{
return $this->belongsToMany(ShortUrl::class, 'short_url_pixel', 'pixel_id', 'short_url_id');
}
}

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.]+)/',

View File

@@ -0,0 +1,125 @@
<?php
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
// Enable REST API and inject mock keys
app(ShortUrlSettingsManager::class)->set([
'api_enabled' => true,
'api_keys' => [
['name' => 'Active Test Token', 'key' => 'sh_key_active_token', 'is_active' => true],
['name' => 'Inactive Test Token', 'key' => 'sh_key_inactive_token', 'is_active' => false],
],
]);
});
it('rejects API requests without a key', function () {
$response = $this->getJson('/api/short-url/links');
$response->assertStatus(401)
->assertJsonFragment(['error' => 'Unauthorized. API Key is missing.']);
});
it('returns 503 when the REST API is disabled in settings', function () {
app(ShortUrlSettingsManager::class)->set(['api_enabled' => false]);
$response = $this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(503)
->assertJsonPath('error', fn ($v) => str_contains($v, 'disabled'));
});
it('rejects API requests with an invalid key', function () {
$response = $this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_invalid_value',
]);
$response->assertStatus(401)
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
});
it('rejects API requests with an inactive key', function () {
$response = $this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_inactive_token',
]);
$response->assertStatus(401)
->assertJsonFragment(['error' => 'Unauthorized. Invalid or inactive API Key.']);
});
it('allows API requests with a valid key', function () {
// Seed at least one short URL
ShortUrl::create([
'destination_url' => 'https://example.com/api-test',
'url_key' => 'apitest',
]);
$response = $this->getJson('/api/short-url/links', [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(200)
->assertJsonStructure([
'data',
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
])
->assertJsonFragment(['url_key' => 'apitest']);
});
it('allows creating a short link programmatically via POST', function () {
Queue::fake([SendWebhookJob::class]);
$response = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'googleapi',
'notes' => 'API Generated link',
'single_use' => true,
'pixel_meta_id' => '12345',
'webhook_url' => 'https://webhook.site/test',
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(201)
->assertJsonFragment(['url_key' => 'googleapi'])
->assertJsonFragment(['notes' => 'API Generated link']);
$this->assertDatabaseHas('short_urls', [
'url_key' => 'googleapi',
'single_use' => true,
'webhook_url' => 'https://webhook.site/test',
]);
$this->assertDatabaseHas('short_url_pixels', [
'type' => 'meta',
'pixel_id' => '12345',
]);
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
Queue::assertPushed(SendWebhookJob::class, function ($job) {
return $job->url === 'https://webhook.site/test' && $job->event === 'created';
});
});
it('allows deleting a short link via DELETE', function () {
$link = ShortUrl::create([
'destination_url' => 'https://delete-me.com',
'url_key' => 'delkey',
]);
$response = $this->deleteJson("/api/short-url/links/{$link->id}", [], [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(200)
->assertJsonFragment(['message' => 'Short URL deleted successfully.']);
$this->assertDatabaseMissing('short_urls', [
'id' => $link->id,
]);
});

View File

@@ -0,0 +1,126 @@
<?php
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('serves the pixel-loading interstitial view when pixels are set', function () {
$link = ShortUrl::create([
'destination_url' => 'https://example.com/target',
'url_key' => 'pixelkey',
]);
$metaPixel = ShortUrlPixel::create([
'name' => 'My Meta Pixel',
'type' => 'meta',
'pixel_id' => 'META-12345',
'is_active' => true,
]);
$googlePixel = ShortUrlPixel::create([
'name' => 'My Google Tag',
'type' => 'google',
'pixel_id' => 'G-GA54321',
'is_active' => true,
]);
$linkedinPixel = ShortUrlPixel::create([
'name' => 'My LinkedIn Tag',
'type' => 'linkedin',
'pixel_id' => 'LNK-98765',
'is_active' => true,
]);
$tiktokPixel = ShortUrlPixel::create([
'name' => 'My TikTok Tag',
'type' => 'tiktok',
'pixel_id' => 'TT-65432',
'is_active' => true,
]);
$pinterestPixel = ShortUrlPixel::create([
'name' => 'My Pinterest Tag',
'type' => 'pinterest',
'pixel_id' => 'PIN-78901',
'is_active' => true,
]);
$link->pixels()->sync([
$metaPixel->id,
$googlePixel->id,
$linkedinPixel->id,
$tiktokPixel->id,
$pinterestPixel->id,
]);
$response = $this->get('/s/pixelkey');
$response->assertStatus(200);
$response->assertViewIs('filament-short-url::pixel-loading');
// Assert Meta Pixel is rendered
$response->assertSee("fbq('init', 'META-12345')", false);
// Assert Google Analytics is rendered
$response->assertSee('https://www.googletagmanager.com/gtag/js?id=G-GA54321', false);
$response->assertSee("gtag('config', 'G-GA54321')", false);
// Assert LinkedIn Insight is rendered
$response->assertSee('window._linkedin_data_partner_ids.push("LNK-98765")', false);
// Assert TikTok Pixel is rendered
$response->assertSee("ttq.load('TT-65432')", false);
// Assert Pinterest Tag is rendered
$response->assertSee("pintrk('load', 'PIN-78901')", false);
});
it('bypasses interstitial loading when no pixels are set', function () {
$link = ShortUrl::create([
'destination_url' => 'https://example.com/direct',
'url_key' => 'directkey',
]);
$response = $this->get('/s/directkey');
$response->assertRedirect('https://example.com/direct');
});
it('does not load inactive pixels from registry', function () {
$link = ShortUrl::create([
'destination_url' => 'https://example.com/inactive-target',
'url_key' => 'inactivekey',
]);
$activePixel = ShortUrlPixel::create([
'name' => 'Active Meta Pixel',
'type' => 'meta',
'pixel_id' => 'META-ACTIVE',
'is_active' => true,
]);
$inactivePixel = ShortUrlPixel::create([
'name' => 'Inactive Google Tag',
'type' => 'google',
'pixel_id' => 'G-INACTIVE',
'is_active' => false,
]);
$link->pixels()->sync([
$activePixel->id,
$inactivePixel->id,
]);
$response = $this->get('/s/inactivekey');
$response->assertStatus(200);
$response->assertViewIs('filament-short-url::pixel-loading');
// Assert active is rendered
$response->assertSee("fbq('init', 'META-ACTIVE')", false);
// Assert inactive is NOT rendered
$response->assertDontSee('G-INACTIVE', false);
});

View File

@@ -0,0 +1,677 @@
<?php
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
if (! function_exists('createShortUrl')) {
function createShortUrl(array $attrs = []): ShortUrl
{
return app(ShortUrlService::class)->create(array_merge([
'destination_url' => 'https://example.com',
], $attrs));
}
}
it('redirects to destination url', function () {
$shortUrl = createShortUrl(['url_key' => 'abc123', 'track_visits' => false]);
$this->get('/s/abc123')
->assertRedirect('https://example.com');
});
it('returns 404 for unknown key', function () {
$this->get('/s/doesnotexist')->assertStatus(404);
});
it('returns 410 for disabled url', function () {
createShortUrl(['url_key' => 'disabled1', 'is_enabled' => false, 'track_visits' => false]);
$this->get('/s/disabled1')->assertStatus(410);
});
it('returns 410 for expired url', function () {
createShortUrl([
'url_key' => 'expired1',
'expires_at' => now()->subDay(),
'track_visits' => false,
]);
$this->get('/s/expired1')->assertStatus(410);
});
it('redirects to expiration fallback url when deactivated or expired', function () {
createShortUrl([
'url_key' => 'fallback-expired',
'expires_at' => now()->subDay(),
'expiration_redirect_url' => 'https://fallback.example.com',
'track_visits' => false,
]);
$this->get('/s/fallback-expired')
->assertRedirect('https://fallback.example.com');
});
it('redirects to expiration fallback url when deactivated_at is past', function () {
createShortUrl([
'url_key' => 'fallback-deactivated',
'activated_at' => now()->subDays(2),
'deactivated_at' => now()->subDay(),
'expiration_redirect_url' => 'https://fallback.example.com',
'track_visits' => false,
]);
$this->get('/s/fallback-deactivated')
->assertRedirect('https://fallback.example.com');
});
it('redirects to expiration fallback url when activated_at is in future', function () {
createShortUrl([
'url_key' => 'fallback-not-yet-active',
'activated_at' => now()->addDay(),
'expiration_redirect_url' => 'https://fallback.example.com',
'track_visits' => false,
]);
$this->get('/s/fallback-not-yet-active')
->assertRedirect('https://fallback.example.com');
});
it('redirects to destination when activated_at is in past and deactivated_at is in future', function () {
createShortUrl([
'url_key' => 'active-range',
'activated_at' => now()->subDay(),
'deactivated_at' => now()->addDay(),
'track_visits' => false,
]);
$this->get('/s/active-range')
->assertRedirect('https://example.com');
});
it('redirects to expiration fallback when max visits is reached', function () {
$shortUrl = createShortUrl([
'url_key' => 'fallback-max-visits',
'activated_at' => now()->subDay(),
'max_visits' => 2,
'expiration_redirect_url' => 'https://fallback.example.com',
'track_visits' => false,
]);
$shortUrl->total_visits = 2;
$shortUrl->save();
$this->get('/s/fallback-max-visits')
->assertRedirect('https://fallback.example.com');
});
it('redirects to destination when max visits is not reached', function () {
$shortUrl = createShortUrl([
'url_key' => 'under-max-visits',
'max_visits' => 2,
'expiration_redirect_url' => 'https://fallback.example.com',
'track_visits' => false,
]);
$shortUrl->total_visits = 1;
$shortUrl->save();
$this->get('/s/under-max-visits')
->assertRedirect('https://example.com');
});
it('disables single-use url after first visit', function () {
createShortUrl(['url_key' => 'single1', 'single_use' => true, 'track_visits' => false]);
$this->get('/s/single1')->assertRedirect();
$shortUrl = ShortUrl::where('url_key', 'single1')->first();
expect($shortUrl->is_enabled)->toBeFalse();
});
it('uses 302 redirect by default', function () {
createShortUrl(['url_key' => 'temp302', 'redirect_status_code' => 302, 'track_visits' => false]);
$this->get('/s/temp302')->assertStatus(302);
});
it('records visit data when tracking is enabled', function () {
Queue::fake();
createShortUrl(['url_key' => 'track1', 'track_visits' => true]);
$this->get('/s/track1', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0']);
Queue::assertPushed(
TrackShortUrlVisitJob::class
);
});
it('does not record visit when tracking is disabled', function () {
Queue::fake();
createShortUrl(['url_key' => 'notrack1', 'track_visits' => false]);
$this->get('/s/notrack1');
Queue::assertNotPushed(
TrackShortUrlVisitJob::class
);
});
it('extracts proxy-resistant client IP address', function () {
config(['filament-short-url.queue_connection' => 'sync']);
config(['filament-short-url.geo_ip.enabled' => false]);
config(['filament-short-url.trust_cdn_headers' => true]);
$shortUrl = createShortUrl(['url_key' => 'proxyip', 'track_visits' => true]);
$this->get('/s/proxyip', [
'CF-Connecting-IP' => '1.2.3.4',
'X-Real-IP' => '5.6.7.8',
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->ip_address)->toBe('1.2.3.4');
$shortUrl2 = createShortUrl(['url_key' => 'proxyip2', 'track_visits' => true]);
$this->get('/s/proxyip2', [
'X-Forwarded-For' => '9.10.11.12, 13.14.15.16',
]);
$visit2 = $shortUrl2->visits()->first();
expect($visit2->ip_address)->toBe('9.10.11.12');
});
it('resolves country from edge CDN headers offline', function () {
config(['filament-short-url.queue_connection' => 'sync']);
config(['filament-short-url.geo_ip.enabled' => true]);
config(['filament-short-url.geo_ip.driver' => 'headers']);
config(['filament-short-url.trust_cdn_headers' => true]);
$shortUrl = createShortUrl(['url_key' => 'cdngeo', 'track_visits' => true]);
$this->get('/s/cdngeo', [
'CF-IPCountry' => 'PL',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->country_code)->toBe('PL')
->and($visit->country)->toBe('Poland');
});
it('caches stats page calculations', function () {
$shortUrl = createShortUrl(['url_key' => 'cachestats']);
$page = new ViewShortUrlStats;
$page->record = $shortUrl;
// First mount: should be 0 since no visits exist
$page->mount($shortUrl);
expect($page->totalVisits)->toBe(0);
// Create a visit in the database (cache is not cleared automatically for this manual action)
$shortUrl->visits()->create([
'ip_address' => '1.2.3.4',
'visited_at' => now(),
]);
// Second mount: should still be 0 because stats are cached
$page->mount($shortUrl);
expect($page->totalVisits)->toBe(0);
// Clear the specific date-filtered cache key
$dateFrom = now()->subDays(29)->format('Y-m-d');
$dateTo = now()->format('Y-m-d');
Cache::forget("short_url_stats_{$shortUrl->id}_{$dateFrom}_{$dateTo}");
// Third mount: should now be 1 because cache is cleared and recalculated
$page->mount($shortUrl);
expect($page->totalVisits)->toBe(1);
});
it('records visit data with UTM parameters and resolved referer host', function () {
config(['filament-short-url.queue_connection' => 'sync']);
config(['filament-short-url.geo_ip.enabled' => false]);
$shortUrl = createShortUrl(['url_key' => 'utmtrack', 'track_visits' => true, 'track_referer_url' => true]);
$this->get('/s/utmtrack?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&utm_term=shoes&utm_content=banner', [
'Referer' => 'https://m.facebook.com/some/path?query=string',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull();
expect($visit->utm_source)->toBe('google');
expect($visit->utm_medium)->toBe('cpc');
expect($visit->utm_campaign)->toBe('summer_sale');
expect($visit->utm_term)->toBe('shoes');
expect($visit->utm_content)->toBe('banner');
expect($visit->referer_url)->toBe('https://m.facebook.com/some/path?query=string');
expect($visit->referer_host)->toBe('facebook.com');
});
it('resolves city from edge CDN headers offline', function () {
config(['filament-short-url.queue_connection' => 'sync']);
config(['filament-short-url.geo_ip.enabled' => true]);
config(['filament-short-url.geo_ip.driver' => 'headers']);
config(['filament-short-url.trust_cdn_headers' => true]);
$shortUrl = createShortUrl(['url_key' => 'cdncity', 'track_visits' => true]);
$this->get('/s/cdncity', [
'CF-IPCountry' => 'US',
'CF-IPCity' => 'New York',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->country_code)->toBe('US')
->and($visit->country)->toBe('United States')
->and($visit->city)->toBe('New York');
});
it('resolves country and city from ip-api driver', function () {
Http::fake([
'http://ip-api.com/*' => Http::response([
'status' => 'success',
'country' => 'Germany',
'countryCode' => 'DE',
'city' => 'Berlin',
], 200),
]);
config(['filament-short-url.geo_ip.enabled' => true]);
config(['filament-short-url.geo_ip.driver' => 'ip-api']);
$geoIpService = app(GeoIpService::class);
$result = $geoIpService->resolve('8.8.8.8');
expect($result)->toBe([
'country' => 'Germany',
'country_code' => 'DE',
'city' => 'Berlin',
]);
});
it('applies rate limiting on redirects when enabled', function () {
config(['filament-short-url.rate_limiting.enabled' => true]);
config(['filament-short-url.rate_limiting.max_attempts' => 2]);
config(['filament-short-url.rate_limiting.decay_seconds' => 10]);
$shortUrl = createShortUrl(['url_key' => 'ratelimit1', 'track_visits' => false]);
// Attempt 1: Success
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
// Attempt 2: Success
$this->get('/s/ratelimit1')->assertRedirect('https://example.com');
// Attempt 3: Rate limited (429)
$this->get('/s/ratelimit1')->assertStatus(429);
});
it('requires password to redirect when protected', function () {
$shortUrl = createShortUrl([
'url_key' => 'password123',
'password' => 'secret-key',
'track_visits' => false,
]);
// Unauthenticated request should render password prompt view
$response = $this->get('/s/password123');
$response->assertStatus(200);
$response->assertSee('Password Required');
// Send incorrect password
$response = $this->post('/s/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');
// Following request should redirect to target
$this->get('/s/password123')->assertRedirect('https://example.com');
});
it('shows warning page before redirecting when enabled', function () {
$shortUrl = createShortUrl([
'url_key' => 'warn1',
'show_warning_page' => true,
'track_visits' => false,
]);
// Unconfirmed visit should render warning page
$response = $this->get('/s/warn1');
$response->assertStatus(200);
$response->assertSee('Security Redirect Warning');
$response->assertSee('https://example.com');
// Confirmed visit should redirect
$this->get('/s/warn1?confirmed=1')->assertRedirect('https://example.com');
});
it('requires password first, then shows warning page when both are enabled', function () {
$shortUrl = createShortUrl([
'url_key' => 'both-secure',
'password' => 'secret-combination',
'show_warning_page' => true,
'track_visits' => false,
]);
// 1. Visit without password -> password prompt page, not warning page
$response = $this->get('/s/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->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');
// 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->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')
->assertRedirect('https://example.com');
});
it('targets redirect by device type', function () {
$shortUrl = createShortUrl([
'url_key' => 'device-target',
'track_visits' => false,
'targeting_rules' => [
'type' => 'device',
'device' => [
'ios' => 'https://ios.example.com',
'android' => 'https://android.example.com',
'desktop' => 'https://desktop.example.com',
],
],
]);
// iOS
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'])
->assertRedirect('https://ios.example.com');
// Android
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Linux; Android 10)'])
->assertRedirect('https://android.example.com');
// Desktop
$this->get('/s/device-target', ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'])
->assertRedirect('https://desktop.example.com');
});
it('targets redirect by country code', function () {
config(['filament-short-url.trust_cdn_headers' => true]);
$shortUrl = createShortUrl([
'url_key' => 'geo-target',
'track_visits' => false,
'targeting_rules' => [
'type' => 'geo',
'geo' => [
['country_code' => 'PL', 'url' => 'https://pl.example.com'],
['country_code' => 'US', 'url' => 'https://us.example.com'],
],
],
]);
// PL
$this->get('/s/geo-target', ['CF-IPCountry' => 'PL'])
->assertRedirect('https://pl.example.com');
// US
$this->get('/s/geo-target', ['CF-IPCountry' => 'US'])
->assertRedirect('https://us.example.com');
// Other (fallback)
$this->get('/s/geo-target', ['CF-IPCountry' => 'DE'])
->assertRedirect('https://example.com');
});
it('targets redirect by browser language', function () {
$shortUrl = createShortUrl([
'url_key' => 'lang-target',
'track_visits' => false,
'targeting_rules' => [
'type' => 'language',
'language' => [
['language_code' => 'PL', 'url' => 'https://pl.example.com'],
['language_code' => 'en-US', 'url' => 'https://enus.example.com'],
['language_code' => 'de', 'url' => 'https://de.example.com'],
],
],
]);
// PL (matching base pl)
$this->get('/s/lang-target', ['Accept-Language' => 'pl-PL,pl;q=0.9'])
->assertRedirect('https://pl.example.com');
// en-US (matching exact locale en-US)
$this->get('/s/lang-target', ['Accept-Language' => 'en-US,en;q=0.8'])
->assertRedirect('https://enus.example.com');
// de (matching base de from de-DE)
$this->get('/s/lang-target', ['Accept-Language' => 'de-DE,de;q=0.8'])
->assertRedirect('https://de.example.com');
// Other (fallback)
$this->get('/s/lang-target', ['Accept-Language' => 'fr-FR,fr;q=0.9'])
->assertRedirect('https://example.com');
});
it('targets redirect by rotation rules', function () {
$shortUrl = createShortUrl([
'url_key' => 'rotation-target',
'track_visits' => false,
'targeting_rules' => [
'type' => 'rotation',
'rotation' => [
['url' => 'https://a.example.com', 'weight' => 100],
],
],
]);
$this->get('/s/rotation-target')->assertRedirect('https://a.example.com');
});
it('aggregates old visits and prunes logs', function () {
config(['filament-short-url.pruning.enabled' => true]);
config(['filament-short-url.pruning.retention_days' => 7]);
$shortUrl = createShortUrl(['url_key' => 'agg1']);
// Create raw visit from yesterday
$visitYesterday = $shortUrl->visits()->create([
'ip_address' => '1.1.1.1',
'ip_hash' => hash('sha256', '1.1.1.1'),
'visited_at' => now()->subDay(),
'device_type' => 'desktop',
'browser' => 'Chrome',
'operating_system' => 'Windows',
'country_code' => 'PL',
'country' => 'Poland',
]);
// Create raw visit older than retention window (8 days ago)
$visitOld = $shortUrl->visits()->create([
'ip_address' => '2.2.2.2',
'ip_hash' => hash('sha256', '2.2.2.2'),
'visited_at' => now()->subDays(8),
'device_type' => 'mobile',
'browser' => 'Safari',
'operating_system' => 'iOS',
'country_code' => 'US',
'country' => 'United States',
]);
// Run aggregation command
$this->artisan('short-url:aggregate-and-prune')->assertSuccessful();
// Verify stats were aggregated for yesterday (Eloquent-based to handle date cast differences across DBs)
$statsYesterday = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
->whereDate('date', now()->subDay()->toDateString())
->first();
expect($statsYesterday)->not->toBeNull();
expect($statsYesterday->visits_count)->toBe(1);
expect($statsYesterday->unique_visits_count)->toBe(1);
// Verify stats were aggregated for 8 days ago
$statsOld = ShortUrlDailyStats::where('short_url_id', $shortUrl->id)
->whereDate('date', now()->subDays(8)->toDateString())
->first();
expect($statsOld)->not->toBeNull();
expect($statsOld->visits_count)->toBe(1);
expect($statsOld->unique_visits_count)->toBe(1);
// Check pruning: visit from 8 days ago should be deleted, yesterday should remain
$this->assertDatabaseMissing('short_url_visits', ['id' => $visitOld->id]);
$this->assertDatabaseHas('short_url_visits', ['id' => $visitYesterday->id]);
});
it('tracks QR code scan visits via query parameters', function () {
config(['filament-short-url.queue_connection' => 'sync']);
$shortUrl = createShortUrl(['url_key' => 'qrtest1', 'track_visits' => true]);
// Test ?source=qr
$this->get('/s/qrtest1?source=qr');
$visit1 = $shortUrl->visits()->latest('id')->first();
expect($visit1)->not->toBeNull()
->and($visit1->is_qr_scan)->toBeTrue();
// Test ?qr=1
$this->get('/s/qrtest1?qr=1');
$visit2 = $shortUrl->visits()->latest('id')->first();
expect($visit2)->not->toBeNull()
->and($visit2->is_qr_scan)->toBeTrue();
// Test direct visit
$this->get('/s/qrtest1');
$visit3 = $shortUrl->visits()->latest('id')->first();
expect($visit3)->not->toBeNull()
->and($visit3->is_qr_scan)->toBeFalse();
$shortUrl->refresh();
expect($shortUrl->qr_scans)->toBe(2);
});
it('extracts and tracks visitor browser language from headers', function () {
config(['filament-short-url.queue_connection' => 'sync']);
$shortUrl = createShortUrl(['url_key' => 'langtest1', 'track_visits' => true]);
$this->get('/s/langtest1', [
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->browser_language)->toBe('pl');
});
it('does not track browser language if track_browser_language is disabled', function () {
config(['filament-short-url.queue_connection' => 'sync']);
$shortUrl = createShortUrl([
'url_key' => 'langtest2',
'track_visits' => true,
'track_browser_language' => false,
]);
$this->get('/s/langtest2', [
'Accept-Language' => 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->browser_language)->toBeNull();
});
it('tracks browser language if track_browser_language is enabled', function () {
config(['filament-short-url.queue_connection' => 'sync']);
$shortUrl = createShortUrl([
'url_key' => 'langtest3',
'track_visits' => true,
'track_browser_language' => true,
]);
$this->get('/s/langtest3', [
'Accept-Language' => 'fr-FR,fr;q=0.9',
]);
$visit = $shortUrl->visits()->first();
expect($visit)->not->toBeNull()
->and($visit->browser_language)->toBe('fr');
});
it('enforces max_visits in real-time even when model caching is active', function () {
config([
'filament-short-url.cache_ttl' => 3600,
'filament-short-url.queue_connection' => 'sync',
]);
$shortUrl = createShortUrl([
'url_key' => 'max-visits-cache',
'max_visits' => 2,
'track_visits' => true,
]);
// First visit: gets cached, redirects
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
// Second visit: loaded from cache, redirects
$this->get('/s/max-visits-cache')->assertRedirect('https://example.com');
// Third visit: should detect limit reached and return 410
$this->get('/s/max-visits-cache')->assertStatus(410);
});
it('enforces single_use in real-time even when model caching is active', function () {
config([
'filament-short-url.cache_ttl' => 3600,
'filament-short-url.queue_connection' => 'sync',
]);
$shortUrl = createShortUrl([
'url_key' => 'single-use-cache',
'single_use' => true,
'track_visits' => true,
]);
// First visit: redirects and disables the URL
$this->get('/s/single-use-cache')->assertRedirect('https://example.com');
// Second visit: should return 410, even if the model exists in the cache as enabled
$this->get('/s/single-use-cache')->assertStatus(410);
});