Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e6ded015 | ||
|
|
c5147ac170 | ||
|
|
0cdc4ad0db |
20
README.md
20
README.md
@@ -243,7 +243,7 @@ Settings are stored dynamically in `storage/app/filament-short-url-settings.json
|
||||
The settings panel allows you to configure:
|
||||
|
||||
### 1. General Routing & Queueing
|
||||
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`).
|
||||
* **Route Prefix**: The slug prepended to short URLs (e.g. `s` for `/s/{key}`). Can be left empty to serve links directly from the root domain (e.g. `domain.com/{key}`).
|
||||
* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
|
||||
* *Note: `302` is highly recommended for analytics accuracy because browsers cache `301` redirects, skipping subsequent logs.*
|
||||
* **Key Length**: Default character count (base62) for auto-generated keys (default: `6`).
|
||||
@@ -263,27 +263,17 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
|
||||
### 4. Counter Buffering (Write-back Caching)
|
||||
For extremely high-traffic applications, direct database writes for click counts can cause row-locking bottlenecks.
|
||||
* **Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache.
|
||||
* **Cron Synchronization**: When enabled, you must schedule the synchronization command to run periodically (e.g., every minute) to flush counts to the database:
|
||||
```bash
|
||||
php artisan short-url:sync-counters
|
||||
```
|
||||
In your scheduler (`routes/console.php` or `app/Console/Kernel.php`):
|
||||
```php
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
```
|
||||
* **Cron Synchronization**: When enabled, the synchronization command flushes counts to the database. The package automatically registers this in the Laravel Scheduler to run every minute when counter buffering is active, so you only need to ensure the standard Laravel schedule runner (`* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1`) is running on your server.
|
||||
* Command: `php artisan short-url:sync-counters`
|
||||
|
||||
### 5. Performance & Security Tab (new in v1.2.0)
|
||||
|
||||
#### High-Traffic Log Management (Aggregation & Pruning)
|
||||
At scale, the `short_url_visits` table can grow to tens of gigabytes. The aggregation system solves this:
|
||||
* **Enable Daily Aggregation**: When enabled, the nightly `short-url:aggregate-and-prune` command summarizes the previous day's raw visit records into the compact `short_url_daily_stats` table.
|
||||
* **Enable Daily Aggregation**: When enabled, the stats are summarized. The package automatically registers the aggregation command in the Laravel Scheduler to run daily at 02:00, so you only need to ensure the standard Laravel schedule runner is running on your server.
|
||||
* Command: `php artisan short-url:aggregate-and-prune`
|
||||
* **Prune Raw Logs After (days)**: Raw visit records older than this threshold are permanently deleted after aggregation. Set to `0` to disable pruning. Default: `90` days.
|
||||
|
||||
Schedule the command in your scheduler:
|
||||
```php
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
```
|
||||
|
||||
#### Rate Limiting / Bot Protection
|
||||
Prevent redirect abuse and bot traffic flooding:
|
||||
* **Enable Rate Limiting**: Activates per-IP rate limiting on all redirect routes.
|
||||
|
||||
@@ -199,7 +199,8 @@ return [
|
||||
'settings_site_name' => 'Site Name Override',
|
||||
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
|
||||
'settings_route_prefix' => 'Route Prefix',
|
||||
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.',
|
||||
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Leave empty to serve links directly from the root domain (e.g. "domain.com/abc123"). Change requires a config:clear.',
|
||||
'settings_redirect_code_helper' => 'Temporary redirect (302) forces browsers to request the short URL every time, ensuring accurate tracking of all visits. Permanent redirect (301) is cached by browsers and search engines (better for SEO), but subsequent visits from the same device might not be logged in statistics.',
|
||||
'settings_key_length' => 'Auto-generated Key Length',
|
||||
'settings_key_length_helper' => 'Number of characters for auto-generated short keys (base62). 6 chars = ~56 billion unique keys.',
|
||||
'settings_cache_ttl' => 'Redirect Cache TTL',
|
||||
|
||||
@@ -200,7 +200,8 @@ return [
|
||||
'settings_site_name' => 'Niestandardowa nazwa witryny',
|
||||
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
|
||||
'settings_route_prefix' => 'Prefiks trasy',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
|
||||
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Pozostaw puste, aby serwować linki bezpośrednio w głównej domenie (np. "domena.com/abc123"). Zmiana wymaga php artisan config:clear.',
|
||||
'settings_redirect_code_helper' => 'Przekierowanie tymczasowe (302) zmusza przeglądarki do każdorazowego odpytywania serwera, co pozwala na dokładne zliczanie wszystkich wizyt. Przekierowanie stałe (301) jest zapisywane w pamięci podręcznej przeglądarek i wyszukiwarek (lepsze pod SEO), przez co kolejne przejścia tego samego użytkownika mogą nie być rejestrowane w statystykach.',
|
||||
'settings_key_length' => 'Długość auto-generowanego klucza',
|
||||
'settings_key_length_helper' => 'Liczba znaków klucza (base62). 6 znaków = ~56 miliardów unikalnych kluczy.',
|
||||
'settings_cache_ttl' => 'TTL cache przekierowania',
|
||||
|
||||
@@ -47,8 +47,26 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
$statsByUrl = [];
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
|
||||
ShortUrlVisit::where('visited_at', '>=', $date.' 00:00:00')
|
||||
DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $date.' 00:00:00')
|
||||
->where('visited_at', '<', $nextDate.' 00:00:00')
|
||||
->select([
|
||||
'short_url_id',
|
||||
'is_qr_scan',
|
||||
'ip_hash',
|
||||
'device_type',
|
||||
'browser',
|
||||
'operating_system',
|
||||
'country',
|
||||
'country_code',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'browser_language',
|
||||
'city',
|
||||
'referer_host',
|
||||
])
|
||||
->orderBy('id')
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
|
||||
@@ -25,9 +25,13 @@ class SyncBufferedCountersCommand extends Command
|
||||
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$tempKey = "{$dirtyKey}:temp:".time();
|
||||
try {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
if (Redis::exists($dirtyKey)) {
|
||||
Redis::rename($dirtyKey, $tempKey);
|
||||
$dirtyIds = Redis::smembers($tempKey);
|
||||
Redis::del($tempKey);
|
||||
} else {
|
||||
$dirtyIds = [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If key does not exist or rename fails, fallback
|
||||
$dirtyIds = [];
|
||||
|
||||
@@ -152,6 +152,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
|
||||
Select::make('redirect_status_code')
|
||||
->label(__('filament-short-url::default.redirect_code'))
|
||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||
->options([
|
||||
302 => __('filament-short-url::default.redirect_code_302'),
|
||||
301 => __('filament-short-url::default.redirect_code_301'),
|
||||
|
||||
@@ -71,9 +71,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->booted(function (): void {
|
||||
$schedule = $this->app->make(Schedule::class);
|
||||
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
}
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
|
||||
@@ -89,20 +89,8 @@ class ShortUrlRedirectController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve Destination URL (evaluating targeting rules)
|
||||
$destination = $shortUrl->resolveDestinationUrl($request);
|
||||
|
||||
// Forward query parameters if configured
|
||||
if ($shortUrl->forward_query_params) {
|
||||
$queryParams = $request->query();
|
||||
// Remove routing/auth parameters so they don't leak to destination
|
||||
unset($queryParams['confirmed'], $queryParams['password']);
|
||||
|
||||
if (! empty($queryParams)) {
|
||||
$separator = str_contains($destination, '?') ? '&' : '?';
|
||||
$destination .= $separator.http_build_query($queryParams);
|
||||
}
|
||||
}
|
||||
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
|
||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
|
||||
// 5. Warning / Intermediate Page Check
|
||||
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
|
||||
|
||||
@@ -276,7 +276,21 @@ class ShortUrl extends Model
|
||||
|
||||
public function getRealTimeTotalVisits(): int
|
||||
{
|
||||
return $this->total_visits;
|
||||
$buffered = 0;
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
|
||||
}
|
||||
|
||||
if ($this->max_visits !== null) {
|
||||
$dbVal = (int) DB::table($this->table)
|
||||
->where('id', $this->id)
|
||||
->value('total_visits');
|
||||
|
||||
return $dbVal + $buffered;
|
||||
}
|
||||
|
||||
return $this->total_visits + $buffered;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
@@ -285,6 +299,13 @@ class ShortUrl extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->single_use) {
|
||||
$realEnabled = DB::table($this->table)->where('id', $this->id)->value('is_enabled');
|
||||
if (! $realEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet active
|
||||
if ($this->activated_at && $this->activated_at->isFuture()) {
|
||||
return false;
|
||||
|
||||
@@ -91,7 +91,7 @@ class GeoIpService
|
||||
|
||||
if (class_exists(\Locale::class)) {
|
||||
try {
|
||||
$name = \Locale::getDisplayRegion('-'.$code, 'en');
|
||||
$name = \Locale::getDisplayRegion('en-'.$code, 'en');
|
||||
if ($name && $name !== $code) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class UserAgentParser
|
||||
// Order matters — check specific first, generic last
|
||||
$patterns = [
|
||||
'Edg/' => 'Edge',
|
||||
'EdgiOS' => 'Edge',
|
||||
'EdgA' => 'Edge',
|
||||
'OPR/' => 'Opera',
|
||||
'Opera' => 'Opera',
|
||||
'SamsungBrowser' => 'Samsung Browser',
|
||||
@@ -67,6 +69,8 @@ class UserAgentParser
|
||||
{
|
||||
$patterns = [
|
||||
'/Edg\/([0-9.]+)/',
|
||||
'/EdgiOS\/([0-9.]+)/',
|
||||
'/EdgA\/([0-9.]+)/',
|
||||
'/OPR\/([0-9.]+)/',
|
||||
'/SamsungBrowser\/([0-9.]+)/',
|
||||
'/CriOS\/([0-9.]+)/',
|
||||
|
||||
Reference in New Issue
Block a user