4 Commits

28 changed files with 962 additions and 244 deletions

View File

@@ -6,7 +6,7 @@
<p align="center">
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/l/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
@@ -145,6 +145,18 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
* **GA4 API Secret**: Create this secret in Google Analytics under `Admin -> Data Streams -> Measurement Protocol API secrets`.
* **Firebase App ID / Measurement ID**: The target analytics stream identifier.
### 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();
```
---
## Configuration Reference (.env)
@@ -162,6 +174,7 @@ You can also pre-configure all parameters via your `.env` file:
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
| `SHORT_URL_COUNTER_BUFFERING` | `counter_buffering.enabled` | `false` | Buffer click counts in cache (flushed via console command). |
---

View File

@@ -132,4 +132,42 @@ return [
'gradient_type' => 'linear',
],
/*
|--------------------------------------------------------------------------
| Counter Buffering (Write-back Caching)
|--------------------------------------------------------------------------
| For high-traffic applications, direct database writes on every visit
| can cause locks. Enable this to buffer total/unique visit counts in
| cache, then sync them to the database periodically via a scheduled task.
*/
'counter_buffering' => [
'enabled' => (bool) env('SHORT_URL_COUNTER_BUFFERING', false),
'cache_key_prefix' => 'filament-short-url:buffer:',
],
/*
|--------------------------------------------------------------------------
| Trust CDN/Proxy Headers
|--------------------------------------------------------------------------
| If your application sits behind a CDN (like Cloudflare, AWS CloudFront)
| or a reverse proxy, set this to true to parse real visitor IP addresses
| and country codes from proxy headers. Only enable this if you are
| actually behind a proxy to prevent client IP spoofing!
|
*/
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
/*
|--------------------------------------------------------------------------
| Redirect Route Middleware
|--------------------------------------------------------------------------
| The middleware list applied to the short URL redirect route.
| By default, standard web middleware and rate limiting are applied.
|
*/
'middleware' => [
'web',
'throttle:120,1',
],
];

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->string('city', 100)->nullable()->after('country_code');
$table->string('referer_host', 255)->nullable()->after('referer_url')->index();
$table->string('utm_source', 100)->nullable()->after('visited_at')->index();
$table->string('utm_medium', 100)->nullable()->after('utm_source')->index();
$table->string('utm_campaign', 100)->nullable()->after('utm_medium')->index();
$table->string('utm_term', 100)->nullable()->after('utm_campaign');
$table->string('utm_content', 100)->nullable()->after('utm_term');
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn([
'city',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
]);
});
}
};

View File

@@ -180,4 +180,51 @@ return [
'settings_ga4_api_secret_helper' => 'Generate this in GA4 → Admin → Data Streams → your stream → Measurement Protocol API secrets.',
'settings_ga4_firebase_app_id' => 'Firebase App ID (optional)',
'settings_ga4_firebase_app_id_helper' => 'Required only if tracking a Firebase / app stream. Leave empty for standard web GA4 streams.',
'settings_section_buffering' => 'Visit Counters Buffering',
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Visited From',
'stats_filter_visited_until' => 'Visited Until',
'stats_action_export' => 'Export CSV',
'stats_csv_time' => 'Time',
'stats_csv_ip' => 'IP Address',
'stats_csv_country' => 'Country',
'stats_csv_device' => 'Device',
'stats_csv_browser' => 'Browser',
'stats_csv_os' => 'OS',
'stats_csv_referer' => 'Referer',
// Bulk Actions Localization
'action_enable_selected' => 'Enable selected',
'action_disable_selected' => 'Disable selected',
// Form Sections Localization
'form_section_options' => 'Options',
'form_section_notes' => 'Internal Notes',
'form_section_tracking' => 'Visit Tracking',
'form_section_tracked_fields' => 'Tracked Fields',
'form_section_analytics' => 'Third-Party Analytics',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Top UTM Source',
'stats_card_top_country' => 'Top Country',
'stats_filter_date_range' => 'Date Range',
'stats_preset_24_hours' => 'Last 24 Hours',
'stats_preset_7_days' => 'Last 7 Days',
'stats_preset_30_days' => 'Last 30 Days',
'stats_preset_90_days' => 'Last 90 Days',
'stats_preset_custom' => 'Custom Range',
'stats_breakdown_cities' => 'Top Cities',
'stats_no_city_data' => 'No city data.',
'stats_breakdown_utm_source' => 'Campaign Sources',
'stats_breakdown_utm_medium' => 'Campaign Mediums',
'stats_breakdown_utm_campaign' => 'Campaign Names',
'stats_no_utm_data' => 'No UTM data recorded.',
];

View File

@@ -181,4 +181,51 @@ return [
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
'settings_ga4_firebase_app_id' => 'Firebase App ID (opcjonalne)',
'settings_ga4_firebase_app_id_helper' => 'Wymagane tylko przy śledzeniu strumienia Firebase/aplikacji. Zostaw puste dla standardowych strumieni GA4.',
'settings_section_buffering' => 'Buforowanie liczników wizyt',
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Odwiedzono od',
'stats_filter_visited_until' => 'Odwiedzono do',
'stats_action_export' => 'Eksportuj CSV',
'stats_csv_time' => 'Czas',
'stats_csv_ip' => 'Adres IP',
'stats_csv_country' => 'Kraj',
'stats_csv_device' => 'Urządzenie',
'stats_csv_browser' => 'Przeglądarka',
'stats_csv_os' => 'System operacyjny',
'stats_csv_referer' => 'Źródło (Referer)',
// Bulk Actions Localization
'action_enable_selected' => 'Włącz zaznaczone',
'action_disable_selected' => 'Wyłącz zaznaczone',
// Form Sections Localization
'form_section_options' => 'Opcje',
'form_section_notes' => 'Wewnętrzne notatki',
'form_section_tracking' => 'Śledzenie wizyt',
'form_section_tracked_fields' => 'Śledzone pola',
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Główne źródło UTM',
'stats_card_top_country' => 'Główny kraj',
'stats_filter_date_range' => 'Zakres dat',
'stats_preset_24_hours' => 'Ostatnie 24 godziny',
'stats_preset_7_days' => 'Ostatnie 7 dni',
'stats_preset_30_days' => 'Ostatnie 30 dni',
'stats_preset_90_days' => 'Ostatnie 90 dni',
'stats_preset_custom' => 'Własny zakres',
'stats_breakdown_cities' => 'Najpopularniejsze miasta',
'stats_no_city_data' => 'Brak danych o miastach.',
'stats_breakdown_utm_source' => 'Źródła kampanii',
'stats_breakdown_utm_medium' => 'Media kampanii',
'stats_breakdown_utm_campaign' => 'Nazwy kampanii',
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
];

View File

@@ -254,7 +254,7 @@
{{-- Size & Margin --}}
<div class="qr-section">
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
@@ -288,7 +288,7 @@
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
{{-- Mode radio --}}
<div class="flex gap-2">
<div class="flex gap-2" style="display:flex;gap:8px">
<button type="button"
:class="colorMode === 'solid' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
@@ -308,7 +308,7 @@
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
<input type="color" x-model="fgColor" />
</div>
@@ -321,10 +321,10 @@
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
<input type="color" x-model="gradientFrom" />
</div>
@@ -336,7 +336,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
<input type="color" x-model="gradientTo" />
</div>
@@ -360,7 +360,7 @@
{{-- Background --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
<button type="button"
:class="bgTransparent ? 'on' : 'off'"
@@ -369,9 +369,9 @@
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="!bgTransparent" x-transition style="display:block" class="mt-3">
<div x-show="!bgTransparent" x-transition style="display:block;margin-top:12px" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
<input type="color" x-model="bgColor" />
</div>
@@ -381,8 +381,8 @@
maxlength="7" placeholder="#ffffff" />
</div>
</div>
<div x-show="bgTransparent" x-transition style="display:none" class="mt-3">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="font-size:11px;color:#9ca3af;font-weight:600">
<div x-show="bgTransparent" x-transition style="display:none;margin-top:12px" class="mt-3 qr-margin-top">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:#9ca3af;font-weight:600">
TRANSPARENT
</div>
</div>
@@ -390,7 +390,7 @@
{{-- Eye Config --}}
<div class="qr-section">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
<button type="button"
:class="eyeConfigEnabled ? 'on' : 'off'"
@@ -417,7 +417,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
<input type="color" x-model="eyeColor" />
</div>
@@ -436,9 +436,9 @@
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
{{-- Top bar --}}
<div class="mb-4 flex items-center justify-between">
<div class="mb-4 flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>

View File

@@ -16,18 +16,52 @@
</x-filament::tabs.item>
</x-filament::tabs>
<div class="space-y-6">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record])
@php
$dateFrom = $this->filterData['date_from'] ?? null;
$dateTo = $this->filterData['date_to'] ?? null;
@endphp
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record])
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
<div class="space-y-6">
<div class="p-6 bg-white rounded-xl border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.stats_filter_date_range') }}
</h3>
<div class="flex-1 max-w-lg">
<form class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{ $this->form }}
</form>
</div>
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record])
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</x-filament-panels::page>

View File

@@ -27,118 +27,199 @@
@endphp
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{{-- Devices --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
{{-- Devices --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-device-phone-mobile" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
{{-- Browsers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
{{-- Referers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<a href="{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }}
</span>
{{-- Operating Systems --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
{{-- Referers --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@if ($referer === 'Direct')
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
@else
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
@endif
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</div>
</div>
</div>
{{-- UTM Campaigns Section --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{{-- UTM Sources --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
<x-filament::icon icon="heroicon-o-megaphone" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_source') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Mediums --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
<x-filament::icon icon="heroicon-o-tag" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_medium') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Campaigns --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
<x-filament::icon icon="heroicon-o-flag" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_utm_campaign') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -27,5 +27,31 @@
</div>
</div>
{{-- Cities --}}
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByCity as $city => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
</div>
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
@endforelse
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -9,4 +9,4 @@ Route::get(
)
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware('throttle:120,1');
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));

View File

@@ -0,0 +1,69 @@
<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
/** @var string */
protected $signature = 'short-url:sync-counters';
/** @var string */
protected $description = 'Sync buffered short URL visit counters from cache to the database';
public function handle(): int
{
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$dirtyKey = "{$prefix}dirty_ids";
// Pull the list atomically to avoid race conditions with incoming clicks
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
}
} else {
$dirtyIds = Cache::pull($dirtyKey, []);
}
if (empty($dirtyIds)) {
$this->info('No buffered counters to synchronize.');
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$processed = 0;
foreach ($dirtyIds as $id) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$totalDelta = (int) Cache::pull($totalKey, 0);
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
if ($totalDelta > 0 || $uniqueDelta > 0) {
// Perform a single atomic update query for this URL
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$totalDelta}"),
'unique_visits' => DB::raw("unique_visits + {$uniqueDelta}"),
]);
$processed++;
}
}
$this->info("Successfully synchronized counters for {$processed} short URLs.");
return 0;
}
}

View File

@@ -45,6 +45,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
'queue_connection' => $mgr->get('queue_connection', 'sync'),
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
'trust_cdn_headers' => $mgr->get('trust_cdn_headers', false),
]);
}
@@ -93,6 +95,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
->minValue(0)
->suffix('s')
->required(),
Toggle::make('trust_cdn_headers')
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
->columnSpanFull()
->inline(false),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
@@ -111,6 +119,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
})
->required(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false),
]),
]),
// ── Geo-IP ───────────────────────────────────────────

View File

@@ -112,9 +112,9 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
Tables\Filters\Filter::make('visited_at')
->form([
DatePicker::make('visited_from')
->label('Visited From'),
->label(__('filament-short-url::default.stats_filter_visited_from')),
DatePicker::make('visited_until')
->label('Visited Until'),
->label(__('filament-short-url::default.stats_filter_visited_until')),
])
->query(function ($query, array $data) {
return $query
@@ -124,19 +124,26 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
])
->headerActions([
Action::make('export_csv')
->label('Export CSV')
->label(__('filament-short-url::default.stats_action_export'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->action(function () {
return response()->streamDownload(function () {
->action(function (HasTable $livewire) {
return response()->streamDownload(function () use ($livewire) {
$handle = fopen('php://output', 'w');
// Add UTF-8 BOM for Microsoft Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($handle, ['Time', 'IP Address', 'Country', 'Device', 'Browser', 'OS', 'Referer']);
fputcsv($handle, [
__('filament-short-url::default.stats_csv_time'),
__('filament-short-url::default.stats_csv_ip'),
__('filament-short-url::default.stats_csv_country'),
__('filament-short-url::default.stats_csv_device'),
__('filament-short-url::default.stats_csv_browser'),
__('filament-short-url::default.stats_csv_os'),
__('filament-short-url::default.stats_csv_referer'),
]);
ShortUrlVisit::query()
->where('short_url_id', $this->record->id)
$livewire->getFilteredTableQuery()
->orderBy('visited_at', 'desc')
->chunk(200, function ($visits) use ($handle) {
foreach ($visits as $visit) {

View File

@@ -5,9 +5,12 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
class ViewShortUrlStats extends Page implements HasForms
{
@@ -23,10 +26,74 @@ class ViewShortUrlStats extends Page implements HasForms
public int $totalVisits = 0;
public ?array $filterData = [];
public function mount(ShortUrl $record): void
{
$this->record = $record;
$this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0;
$this->form->fill([
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
]);
if (empty($this->filterData)) {
$this->filterData = [
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
];
}
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('preset')
->label(__('filament-short-url::default.stats_filter_date_range'))
->options([
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
'30_days' => __('filament-short-url::default.stats_preset_30_days'),
'90_days' => __('filament-short-url::default.stats_preset_90_days'),
'custom' => __('filament-short-url::default.stats_preset_custom'),
])
->live()
->afterStateUpdated(function ($state, $set) {
$to = now()->format('Y-m-d');
$from = match ($state) {
'24_hours' => now()->subDay()->format('Y-m-d'),
'7_days' => now()->subDays(6)->format('Y-m-d'),
'30_days' => now()->subDays(29)->format('Y-m-d'),
'90_days' => now()->subDays(89)->format('Y-m-d'),
'custom' => now()->subDays(29)->format('Y-m-d'),
default => null,
};
if ($from) {
$set('date_from', $from);
$set('date_to', $to);
}
}),
DatePicker::make('date_from')
->label(__('filament-short-url::default.stats_filter_visited_from'))
->native(false)
->live()
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
DatePicker::make('date_to')
->label(__('filament-short-url::default.stats_filter_visited_until'))
->native(false)
->live()
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
])
->statePath('filterData');
}
protected function getHeaderWidgets(): array

View File

@@ -105,7 +105,7 @@ class ShortUrlForm
->required(),
])->columns(2),
Section::make('Options')->schema([
Section::make(__('filament-short-url::default.form_section_options'))->schema([
Toggle::make('is_enabled')
->label(__('filament-short-url::default.status'))
->default(true)
@@ -129,7 +129,7 @@ class ShortUrlForm
->native(false),
])->columns(2),
Section::make('Internal Notes')->schema([
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
->rows(3)
@@ -177,7 +177,7 @@ class ShortUrlForm
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make('Visit Tracking')
Section::make(__('filament-short-url::default.form_section_tracking'))
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
@@ -187,7 +187,7 @@ class ShortUrlForm
->columnSpanFull(),
])->columns(1),
Section::make('Tracked Fields')
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
@@ -277,7 +277,7 @@ class ShortUrlForm
])
->columns(2),
Section::make('Third-Party Analytics')
Section::make(__('filament-short-url::default.form_section_analytics'))
->schema([
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))

View File

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
@@ -101,21 +102,7 @@ class ShortUrlsTable
->icon('heroicon-o-chart-bar')
->color('gray')
->size('sm')
->url(fn (ShortUrl $record): string => route(
'filament.admin.resources.short-urls.stats',
['record' => $record->id]
)),
Action::make('copy')
->label(__('filament-short-url::default.action_copy'))
->icon('heroicon-o-clipboard')
->color('gray')
->size('sm')
->action(fn () => null) // Copy happens client-side
->extraAttributes(fn (ShortUrl $record): array => [
'x-on:click' => 'navigator.clipboard.writeText("'.$record->getShortUrl().'")',
'title' => 'Copy short URL',
]),
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
EditAction::make()
->icon('heroicon-o-pencil-square')
@@ -125,13 +112,13 @@ class ShortUrlsTable
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('enable')
->label('Enable selected')
->label(__('filament-short-url::default.action_enable_selected'))
->icon('heroicon-o-check-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => true]))
->deselectRecordsAfterCompletion(),
BulkAction::make('disable')
->label('Disable selected')
->label(__('filament-short-url::default.action_disable_selected'))
->icon('heroicon-o-x-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
->deselectRecordsAfterCompletion(),

View File

@@ -10,13 +10,27 @@ class ShortUrlStatsOverview extends BaseWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected function getStats(): array
{
if (! $this->record) {
return [];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$topSource = 'Direct';
if (! empty($stats['utmSources'])) {
$topSource = array_key_first($stats['utmSources']);
}
$topCountry = '—';
if (! empty($stats['visitsByCountry'])) {
$topCountry = array_key_first($stats['visitsByCountry']);
}
return [
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
@@ -27,17 +41,13 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-user-group')
->color('info'),
Stat::make(__('filament-short-url::default.stats_card_today'), number_format($stats['visitsToday'] ?? 0))
->icon('heroicon-o-sun')
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
->icon('heroicon-o-megaphone')
->color('warning'),
Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0))
->icon('heroicon-o-calendar-days')
Stat::make(__('filament-short-url::default.stats_card_top_country'), $topCountry)
->icon('heroicon-o-globe-alt')
->color('success'),
Stat::make(__('filament-short-url::default.stats_card_month'), number_format($stats['visitsThisMonth'] ?? 0))
->icon('heroicon-o-calendar')
->color('gray'),
];
}
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
@@ -29,17 +33,23 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
'utmSources' => $stats['utmSources'] ?? [],
'utmMediums' => $stats['utmMediums'] ?? [],
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsChart extends ChartWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
@@ -29,7 +33,7 @@ class ShortUrlVisitsChart extends ChartWidget
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$visitsByDay = $stats['visitsByDay'] ?? [];
return [

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
@@ -28,14 +32,16 @@ class ShortUrlVisitsRightBreakdown extends Widget
if (! $this->record) {
return [
'visitsByCountry' => [],
'visitsByCity' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
'visitsByCity' => $stats['visitsByCity'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}

View File

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
@@ -24,7 +25,9 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
->hasMigrations([
'2024_01_01_000001_create_short_urls_table',
'2024_01_01_000002_create_short_url_visits_table',
'2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
])
->hasCommand(SyncBufferedCountersCommand::class)
->hasRoutes(['web']);
}

View File

@@ -31,9 +31,10 @@ class ShortUrlRedirectController extends Controller
}
if ($shortUrl->track_visits) {
$connection = config('filament-short-url.queue_connection');
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
$countryCode = ClientIpExtractor::getCountryCode($request);
$city = ClientIpExtractor::getCity($request);
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
@@ -41,12 +42,18 @@ class ShortUrlRedirectController extends Controller
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'),
);
if ($connection && $connection !== 'default') {
if ($connection) {
dispatch($job->onConnection($connection));
} else {
dispatch($job);
dispatch($job->onConnection('sync'));
}
}
@@ -60,6 +67,9 @@ class ShortUrlRedirectController extends Controller
if ($affected === 0) {
abort(410);
}
// Manually forget cache since DB-level update does not trigger Eloquent events
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
$redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request);

View File

@@ -41,6 +41,12 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $countryCode = null,
public readonly ?string $city = null,
public readonly ?string $utmSource = null,
public readonly ?string $utmMedium = null,
public readonly ?string $utmCampaign = null,
public readonly ?string $utmTerm = null,
public readonly ?string $utmContent = null,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -62,7 +68,19 @@ class TrackShortUrlVisitJob implements ShouldQueue
]);
$countryCode = isset($this->countryCode) ? $this->countryCode : null;
$visit = $tracker->record($shortUrl, $request, $countryCode);
$city = isset($this->city) ? $this->city : null;
$visit = $tracker->record(
shortUrl: $shortUrl,
request: $request,
preResolvedCountryCode: $countryCode,
preResolvedCity: $city,
utmSource: $this->utmSource,
utmMedium: $this->utmMedium,
utmCampaign: $this->utmCampaign,
utmTerm: $this->utmTerm,
utmContent: $this->utmContent,
);
// Null means bot/crawler — nothing to dispatch or report
if ($visit === null) {

View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* @property int $id
@@ -238,10 +239,40 @@ class ShortUrl extends Model
/**
* Atomically increment visit counters — single query when unique,
* to avoid race conditions and two round-trips.
* to avoid race conditions and two round-trips. Supports write-back caching.
*/
public function incrementVisits(bool $isUnique = false): void
{
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
try {
// Safely increment total visits in cache
cache()->increment("{$prefix}total:{$this->id}");
if ($isUnique) {
cache()->increment("{$prefix}unique:{$this->id}");
}
$dirtyKey = "{$prefix}dirty_ids";
// Check if Redis is being used for the cache to prevent set race conditions
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
Redis::sadd($dirtyKey, $this->id);
} else {
// Fallback to array for standard cache drivers
$dirtyIds = cache()->get($dirtyKey, []);
if (! in_array($this->id, $dirtyIds)) {
$dirtyIds[] = $this->id;
cache()->put($dirtyKey, $dirtyIds, now()->addDays(7));
}
}
return;
} catch (\Throwable) {
// Fallback to database below if cache fails or doesn't support increments
}
}
$this->newQuery()
->where('id', $this->id)
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
@@ -252,33 +283,57 @@ class ShortUrl extends Model
*
* @return array<string, mixed>
*/
public function getCachedStats(): array
public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array
{
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_stats_{$this->id}";
$dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null;
$dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null;
return cache()->remember($cacheKey, $cacheTtl, function () {
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$visits = $this->visits();
$totalVisits = (int) ($this->total_visits ?? 0);
$uniqueVisits = (int) ($this->unique_visits ?? 0);
$visitsToday = (clone $visits)->where('visited_at', '>=', today()->startOfDay())->count();
$visitsThisWeek = (clone $visits)->where('visited_at', '>=', now()->startOfWeek())->count();
$visitsThisMonth = (clone $visits)->where('visited_at', '>=', now()->startOfMonth())->count();
if ($dateFromClean) {
$visits->whereDate('visited_at', '>=', $dateFromClean);
}
if ($dateToClean) {
$visits->whereDate('visited_at', '<=', $dateToClean);
}
// Visits by day — last 30 days
$visitsByDayRaw = (clone $visits)
->where('visited_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
$chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
$chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
$visitsByDay = [];
for ($i = 29; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
$totalVisits = (clone $visits)->count();
$uniqueVisits = (clone $visits)->distinct('ip_hash')->count('ip_hash');
$visitsToday = $this->visits()->where('visited_at', '>=', today()->startOfDay())->count();
$visitsThisWeek = $this->visits()->where('visited_at', '>=', now()->startOfWeek())->count();
$visitsThisMonth = $this->visits()->where('visited_at', '>=', now()->startOfMonth())->count();
$daysDiff = (int) $chartFrom->diffInDays($chartTo);
if ($daysDiff > 90) {
$visitsByDayRaw = (clone $visits)
->selectRaw('DATE_FORMAT(visited_at, "%Y-%m") as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
$visitsByDay = $visitsByDayRaw;
} else {
$visitsByDayRaw = (clone $visits)
->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
$visitsByDay = [];
for ($i = $daysDiff; $i >= 0; $i--) {
$date = (clone $chartTo)->subDays($i)->format('Y-m-d');
$visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
}
}
// Top countries
@@ -292,6 +347,17 @@ class ShortUrl extends Model
->mapWithKeys(fn ($row) => [$row->country => $row->count])
->toArray();
// Top cities
$visitsByCity = (clone $visits)
->whereNotNull('city')
->selectRaw('city, country_code, COUNT(*) as count')
->groupBy('city', 'country_code')
->orderByDesc('count')
->limit(10)
->get()
->mapWithKeys(fn ($row) => ["{$row->city} ({$row->country_code})" => $row->count])
->toArray();
// Device types
$visitsByDevice = (clone $visits)
->whereNotNull('device_type')
@@ -321,14 +387,42 @@ class ShortUrl extends Model
->pluck('count', 'operating_system')
->toArray();
// Top referers
// Top referrer hosts
$visitsByReferer = (clone $visits)
->whereNotNull('referer_url')
->selectRaw('referer_url, COUNT(*) as count')
->groupBy('referer_url')
->whereNotNull('referer_host')
->selectRaw('referer_host, COUNT(*) as count')
->groupBy('referer_host')
->orderByDesc('count')
->limit(10)
->pluck('count', 'referer_url')
->pluck('count', 'referer_host')
->toArray();
// UTM Breakdowns
$utmSources = (clone $visits)
->whereNotNull('utm_source')
->selectRaw('utm_source, COUNT(*) as count')
->groupBy('utm_source')
->orderByDesc('count')
->limit(8)
->pluck('count', 'utm_source')
->toArray();
$utmMediums = (clone $visits)
->whereNotNull('utm_medium')
->selectRaw('utm_medium, COUNT(*) as count')
->groupBy('utm_medium')
->orderByDesc('count')
->limit(8)
->pluck('count', 'utm_medium')
->toArray();
$utmCampaigns = (clone $visits)
->whereNotNull('utm_campaign')
->selectRaw('utm_campaign, COUNT(*) as count')
->groupBy('utm_campaign')
->orderByDesc('count')
->limit(8)
->pluck('count', 'utm_campaign')
->toArray();
return [
@@ -339,10 +433,14 @@ class ShortUrl extends Model
'visitsThisMonth' => $visitsThisMonth,
'visitsByDay' => $visitsByDay,
'visitsByCountry' => $visitsByCountry,
'visitsByCity' => $visitsByCity,
'visitsByDevice' => $visitsByDevice,
'visitsByBrowser' => $visitsByBrowser,
'visitsByOs' => $visitsByOs,
'visitsByReferer' => $visitsByReferer,
'utmSources' => $utmSources,
'utmMediums' => $utmMediums,
'utmCampaigns' => $utmCampaigns,
];
});
}

View File

@@ -11,29 +11,31 @@ class ClientIpExtractor
*/
public static function getIp(Request $request): string
{
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
// 5. Fallback to standard Laravel/Symfony IP resolver
// 5. Fallback to standard Laravel/Symfony IP resolver (which respects Trusted Proxies)
return $request->ip() ?? '0.0.0.0';
}
@@ -42,19 +44,39 @@ class ClientIpExtractor
*/
public static function getCountryCode(Request $request): ?string
{
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
}
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
return null;
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
/**
* Extract the edge-provided city name from CDN/proxy headers.
*/
public static function getCity(Request $request): ?string
{
if (config('filament-short-url.trust_cdn_headers', false)) {
if ($cfCity = $request->header('CF-IPCity')) {
return trim($cfCity);
}
if ($cfViewerCity = $request->header('CloudFront-Viewer-City')) {
return trim($cfViewerCity);
}
}
return null;

View File

@@ -17,31 +17,32 @@ use Illuminate\Support\Facades\Log;
*/
class GeoIpService
{
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode,city';
/** @var array<string, array{country: string|null, country_code: string|null}> */
/** @var array<string, array{country: string|null, country_code: string|null, city: string|null}> */
private static array $runtimeCache = [];
/**
* Resolve country data for the given IP address.
* Resolve country and city data for the given IP address.
*
* @return array{country: string|null, country_code: string|null}
* @return array{country: string|null, country_code: string|null, city: string|null}
*/
public function resolve(string $ip, ?string $preResolvedCountryCode = null): array
public function resolve(string $ip, ?string $preResolvedCountryCode = null, ?string $preResolvedCity = null): array
{
$empty = ['country' => null, 'country_code' => null];
$empty = ['country' => null, 'country_code' => null, 'city' => null];
if (! config('filament-short-url.geo_ip.enabled', true)) {
return $empty;
}
// 1. Prioritise edge-provided CDN country code (extremely fast, zero latency)
// 1. Prioritise edge-provided CDN country code & city (extremely fast, zero latency)
if ($preResolvedCountryCode) {
$code = strtoupper(trim($preResolvedCountryCode));
return [
'country' => $this->getCountryName($code),
'country_code' => $code,
'city' => $preResolvedCity,
];
}
@@ -129,9 +130,9 @@ class GeoIpService
}
/**
* Resolve country using local MaxMind GeoIP2 DB.
* Resolve country and city using local MaxMind GeoIP2 DB.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function lookupMaxMind(string $ip): ?array
{
@@ -151,11 +152,24 @@ class GeoIpService
try {
$reader = new Reader($dbPath);
$dbType = $reader->metadata()->databaseType;
if (str_contains($dbType, 'City')) {
$record = $reader->city($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => $record->city->name ?? null,
];
}
$record = $reader->country($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [
@@ -168,9 +182,9 @@ class GeoIpService
}
/**
* Resolve country using free ip-api.com.
* Resolve country and city using free ip-api.com.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function fetchFromApi(string $ip): ?array
{
@@ -193,6 +207,7 @@ class GeoIpService
return [
'country' => $data['country'] ?? null,
'country_code' => $data['countryCode'] ?? null,
'city' => $data['city'] ?? null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] GeoIP lookup failed', [

View File

@@ -49,6 +49,8 @@ class ShortUrlSettingsManager
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
'cache_ttl' => config('filament-short-url.cache_ttl', 3600),
'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false),
'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false),
], $stored);
return $this->cache;
@@ -87,6 +89,8 @@ class ShortUrlSettingsManager
'ga4_firebase_app_id',
'queue_connection',
'cache_ttl',
'counter_buffering_enabled',
'trust_cdn_headers',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -110,6 +114,12 @@ class ShortUrlSettingsManager
if (isset($filtered['cache_ttl'])) {
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
}
if (isset($filtered['counter_buffering_enabled'])) {
$filtered['counter_buffering_enabled'] = (bool) $filtered['counter_buffering_enabled'];
}
if (isset($filtered['trust_cdn_headers'])) {
$filtered['trust_cdn_headers'] = (bool) $filtered['trust_cdn_headers'];
}
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->cache = null;
@@ -138,6 +148,8 @@ class ShortUrlSettingsManager
'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'],
'filament-short-url.queue_connection' => $settings['queue_connection'],
'filament-short-url.cache_ttl' => $settings['cache_ttl'],
'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'],
'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'],
]);
}
}

View File

@@ -23,8 +23,17 @@ class ShortUrlTracker
*
* Returns null if the visit was from a bot/crawler (we don't track those).
*/
public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
{
public function record(
ShortUrl $shortUrl,
Request $request,
?string $preResolvedCountryCode = null,
?string $preResolvedCity = null,
?string $utmSource = null,
?string $utmMedium = null,
?string $utmCampaign = null,
?string $utmTerm = null,
?string $utmContent = null,
): ?ShortUrlVisit {
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
$ua = $request->userAgent() ?? '';
@@ -36,8 +45,8 @@ class ShortUrlTracker
}
$geo = config('filament-short-url.geo_ip.enabled', true)
? $this->geoIp->resolve($ip, $preResolvedCountryCode)
: ['country' => null, 'country_code' => null];
? $this->geoIp->resolve($ip, $preResolvedCountryCode, $preResolvedCity)
: ['country' => null, 'country_code' => null, 'city' => null];
// Determine uniqueness: first time this IP hash visits this URL.
// We check BEFORE insert to avoid a self-referential race.
@@ -56,9 +65,42 @@ class ShortUrlTracker
$visit->operating_system_version = $shortUrl->track_operating_system_version
? $parsed['operating_system_version'] : null;
$visit->device_type = $shortUrl->track_device_type ? $parsed['device_type'] : null;
$visit->referer_url = $shortUrl->track_referer_url ? $request->header('Referer') : null;
$visit->country = $geo['country'];
$visit->country_code = $geo['country_code'];
$visit->city = $geo['city'] ?? null;
$visit->utm_source = $utmSource;
$visit->utm_medium = $utmMedium;
$visit->utm_campaign = $utmCampaign;
$visit->utm_term = $utmTerm;
$visit->utm_content = $utmContent;
// Clean & normalize referer URL to host domain
if ($shortUrl->track_referer_url && $referer = $request->header('Referer')) {
$visit->referer_url = $referer;
$refererHost = parse_url($referer, PHP_URL_HOST);
if ($refererHost) {
$refererHost = preg_replace('/^www\./', '', strtolower($refererHost));
if (str_contains($refererHost, 'facebook.com')) {
$refererHost = 'facebook.com';
} elseif (str_contains($refererHost, 'linkedin.com')) {
$refererHost = 'linkedin.com';
} elseif (str_contains($refererHost, 'twitter.com') || str_contains($refererHost, 't.co')) {
$refererHost = 'twitter.com';
} elseif (str_contains($refererHost, 'google.')) {
$refererHost = 'google.com';
} elseif (str_contains($refererHost, 'instagram.com')) {
$refererHost = 'instagram.com';
} elseif (str_contains($refererHost, 'youtube.com')) {
$refererHost = 'youtube.com';
}
$visit->referer_host = $refererHost;
} else {
$visit->referer_host = 'Direct';
}
} else {
$visit->referer_host = 'Direct';
}
$visit->save();