7 Commits

29 changed files with 998 additions and 250 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bartek Janczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -5,12 +5,12 @@
<h1 align="center">Filament Short URL</h1>
<p align="center">
<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://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://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>
<a href="https://github.com/janczakb/filament-short-url/network/members"><img src="https://img.shields.io/github/forks/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Forks"></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="Packagist Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/github/actions/workflow/status/janczakb/filament-short-url/run-tests.yml?branch=main&label=tests&style=flat-square" alt="Tests"></a>
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/badge/tests-passing-success.svg?style=flat-square" alt="Tests"></a>
</p>
A professional, high-performance **Short URL Manager** plugin for [Filament v5](https://filamentphp.com). Built from scratch with cutting-edge practices, proxy resistance, offline Geo-IP engines, and zero external shortening API dependencies.
@@ -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

@@ -108,6 +108,15 @@
background-color:#f3f4f6;
}
@keyframes qr-spin { to { transform: rotate(360deg); } }
.qr-margin-top {
margin-top: 12px !important;
}
.qr-space-y > div {
margin-top: 12px !important;
}
.qr-space-y > div:first-child {
margin-top: 0 !important;
}
</style>
{{-- Preload QR library as soon as the tab renders --}}
@@ -245,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" />
@@ -279,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"
@@ -297,9 +306,9 @@
</div>
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3">
<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>
@@ -311,11 +320,11 @@
</div>
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<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" 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>
@@ -327,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>
@@ -351,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'"
@@ -360,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>
@@ -372,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>
@@ -381,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'"
@@ -390,7 +399,7 @@
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3">
<div x-show="eyeConfigEnabled" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_square_style') }}</span>
<select x-model="eyeSquareStyle" class="qr-select">
@@ -408,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>
@@ -427,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();