2 Commits

14 changed files with 83 additions and 32 deletions

View File

@@ -356,13 +356,14 @@ You can also pre-configure all parameters via your `.env` file:
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `database_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `storage_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
| `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). |
| `SHORT_URL_TRUST_CDN_HEADERS` | `trust_cdn_headers` | `false` | Trust proxy/CDN headers to extract real client IP and country. |
| `SHORT_URL_PRUNING_ENABLED` | `pruning.enabled` | `true` | Enable daily aggregation and log pruning. |
| `SHORT_URL_PRUNING_DAYS` | `pruning.retention_days` | `90` | Number of days to retain raw visit logs. |
| `SHORT_URL_RATE_LIMITING` | `rate_limiting.enabled` | `false` | Enable per-IP redirect rate limiting. |

View File

@@ -157,6 +157,33 @@ return [
*/
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
/*
|--------------------------------------------------------------------------
| Data Pruning & Aggregation
|--------------------------------------------------------------------------
| To keep the database clean and fast, raw visit logs can be aggregated
| into daily statistics and pruned after a retention period.
|
*/
'pruning' => [
'enabled' => env('SHORT_URL_PRUNING_ENABLED', true),
'retention_days' => env('SHORT_URL_PRUNING_DAYS', 90),
],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
| Protect redirect routes from bot abuse and brute force by enabling
| rate limiting.
|
*/
'rate_limiting' => [
'enabled' => env('SHORT_URL_RATE_LIMITING', false),
'max_attempts' => env('SHORT_URL_RATE_LIMIT_MAX', 60),
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
],
/*
|--------------------------------------------------------------------------
| Redirect Route Middleware

View File

@@ -34,4 +34,3 @@ return new class extends Migration
});
}
};

View File

@@ -26,4 +26,3 @@ return new class extends Migration
});
}
};

View File

@@ -266,7 +266,7 @@ return [
'rotation_url' => 'Destination URL',
'rotation_weight' => 'Traffic Weight',
'rotation_weight_helper' => 'Percentage of traffic to route to this URL (e.g. 50 for 50%). Weights are balanced proportionally.',
// New Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
'settings_section_aggregation' => 'High-Traffic Log Management',

View File

@@ -192,6 +192,11 @@ 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_ga4_verify' => 'Testuj połączenie',
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
'settings_section_buffering' => 'Buforowanie liczników wizyt',
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
@@ -262,7 +267,7 @@ return [
'rotation_url' => 'Docelowy URL',
'rotation_weight' => 'Waga ruchu',
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
// New Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',

View File

@@ -2,11 +2,11 @@
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AggregateAndPruneVisitsCommand extends Command
{
@@ -19,10 +19,16 @@ class AggregateAndPruneVisitsCommand extends Command
public function handle(): int
{
$today = Carbon::today()->toDateString();
$driver = DB::connection()->getDriverName();
$dateExpression = match ($driver) {
'pgsql' => 'visited_at::date',
'sqlsrv' => 'CAST(visited_at AS DATE)',
default => 'DATE(visited_at)',
};
// 1. Find all unique dates before today that have visits
$dates = ShortUrlVisit::whereDate('visited_at', '<', $today)
->selectRaw('DATE(visited_at) as visit_date')
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
$dates = ShortUrlVisit::where('visited_at', '<', $today)
->selectRaw("{$dateExpression} as visit_date")
->distinct()
->pluck('visit_date')
->toArray();
@@ -30,14 +36,14 @@ class AggregateAndPruneVisitsCommand extends Command
if (empty($dates)) {
$this->info('No historical visits to aggregate.');
} else {
$this->info('Found ' . count($dates) . ' days to aggregate.');
$this->info('Found '.count($dates).' days to aggregate.');
foreach ($dates as $date) {
// Accumulate stats per short_url_id using chunked reads — avoids loading
// potentially millions of rows into PHP memory at once.
$statsByUrl = [];
ShortUrlVisit::whereDate('visited_at', '=', $date)
ShortUrlVisit::whereBetween('visited_at', [$date.' 00:00:00', $date.' 23:59:59'])
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;

View File

@@ -43,7 +43,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
'geo_ip_driver' => $mgr->get('geo_ip_driver', 'headers'),
'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400),
'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3),
'maxmind_database_path' => $mgr->get('maxmind_database_path', database_path('geoip/GeoLite2-Country.mmdb')),
'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
'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'),

View File

@@ -118,8 +118,8 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
])
->query(function ($query, array $data) {
return $query
->when($data['visited_from'], fn ($q, $date) => $q->whereDate('visited_at', '>=', $date))
->when($data['visited_until'], fn ($q, $date) => $q->whereDate('visited_at', '<=', $date));
->when($data['visited_from'], fn ($q, $date) => $q->where('visited_at', '>=', $date.' 00:00:00'))
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
}),
])
->headerActions([

View File

@@ -354,12 +354,12 @@ class ShortUrlForm
Section::make(__('filament-short-url::default.device_targeting_rules'))
->schema([
TextInput::make('targeting_rules.device.ios')
TextInput::make('targeting_rules.device.mobile')
->label(__('filament-short-url::default.device_mobile'))
->url()
->nullable()
->maxLength(2048),
TextInput::make('targeting_rules.device.android')
TextInput::make('targeting_rules.device.tablet')
->label(__('filament-short-url::default.device_tablet'))
->url()
->nullable()
@@ -438,7 +438,7 @@ class ShortUrlForm
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
])
]),
]);
}
}

View File

@@ -67,7 +67,7 @@ class ShortUrlGlobalOverview extends BaseWidget
$agg = ShortUrl::query()
->select([
DB::raw('COUNT(*) as total'),
DB::raw('SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as active'),
DB::raw('SUM(CASE WHEN is_enabled THEN 1 ELSE 0 END) as active'),
])
->first();
@@ -105,12 +105,12 @@ class ShortUrlGlobalOverview extends BaseWidget
->count();
$last7Uniques = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))
->distinct('ip_address')
->count('ip_address');
->distinct('ip_hash')
->count('ip_hash');
$prev7Uniques = ShortUrlVisit::whereBetween('visited_at', [now()->subDays(14), now()->subDays(7)])
->distinct('ip_address')
->count('ip_address');
->distinct('ip_hash')
->count('ip_hash');
return [
'totalVisits' => (int) ($agg->total_visits ?? 0),

View File

@@ -9,6 +9,7 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\MessageBag;
use Symfony\Component\HttpFoundation\Response;
class ShortUrlRedirectController extends Controller
@@ -35,11 +36,12 @@ class ShortUrlRedirectController extends Controller
if (config('filament-short-url.rate_limiting.enabled', false)) {
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
$limiterKey = "short_url_limit:{$key}:" . $request->ip();
$ipAddress = ClientIpExtractor::getIp($request);
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
$retryAfter = RateLimiter::availableIn($limiterKey);
abort(429, 'Too many requests. Please try again in ' . $retryAfter . ' seconds.', [
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
'Retry-After' => $retryAfter,
]);
}
@@ -54,12 +56,14 @@ class ShortUrlRedirectController extends Controller
$submitted = $request->input('password');
if ($submitted === $shortUrl->password) {
session()->put($sessionKey, true);
return redirect()->to($request->fullUrl());
}
$errors = new \Illuminate\Support\MessageBag([
$errors = new MessageBag([
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
]);
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
->header('Content-Type', 'text/html');
}
@@ -80,7 +84,7 @@ class ShortUrlRedirectController extends Controller
if (! empty($queryParams)) {
$separator = str_contains($destination, '?') ? '&' : '?';
$destination .= $separator . http_build_query($queryParams);
$destination .= $separator.http_build_query($queryParams);
}
}

View File

@@ -8,6 +8,7 @@ use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -272,12 +273,14 @@ class ShortUrl extends Model
$type = $rules['type'] ?? 'none';
if ($type === 'device') {
$ua = strtolower($request->userAgent() ?? '');
if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad') || str_contains($ua, 'ipod')) {
return $rules['device']['ios'] ?? $this->destination_url;
$parser = app(UserAgentParser::class);
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
if ($deviceType === 'mobile') {
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
}
if (str_contains($ua, 'android')) {
return $rules['device']['android'] ?? $this->destination_url;
if ($deviceType === 'tablet') {
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
}
return $rules['device']['desktop'] ?? $this->destination_url;
@@ -392,7 +395,7 @@ class ShortUrl extends Model
$rawVisits = [];
if ($includeToday) {
$rawVisits = $this->visits()->whereDate('visited_at', '=', $today)->get();
$rawVisits = $this->visits()->where('visited_at', '>=', $today.' 00:00:00')->get();
}
// Helper to merge associative stats arrays

View File

@@ -37,8 +37,15 @@ class ShortUrlVisit extends Model
'operating_system_version',
'device_type',
'referer_url',
'referer_host',
'country',
'country_code',
'city',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'visited_at',
];