Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e371ad139 | ||
|
|
e5437dd0a6 |
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,4 +34,3 @@ return new class extends Migration
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ return new class extends Migration
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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'),
|
||||
])
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user