diff --git a/README.md b/README.md
index 09df372..bdbe971 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-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.
+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, enterprise-grade smart targeting, and zero external shortening API dependencies.
---
@@ -28,8 +28,13 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
- ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding).
- 🔒 **Link Rules & Expiry** — Disable links manually, set expiration dates, or activate single-use links that deactivate automatically after the first click.
- ➡️ **Query Parameter Forwarding** — Dynamically forward client query parameters (e.g. ad tokens, discount codes) to the destination URL.
-- 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache) directly inside the Filament panel without modifying files or `.env` files.
+- 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache, rate limiting, aggregation) directly inside the Filament panel without modifying files or `.env` files.
- 💻 **Fluent Developer Builder** — Native model query builder pattern and robust programmatic generation APIs.
+- 🔑 **Password-Protected Links** — Require a password before redirecting visitors, with session-based unlock.
+- ⚠️ **Redirect Warning Pages** — Show an interstitial security page before redirecting to external URLs (phishing/NSFW protection).
+- 🎯 **Smart Link Targeting** — Route visitors to different destination URLs based on their device type, country, or via weighted A/B split rotation.
+- 🛡️ **Rate Limiting / Bot Protection** — Configurable per-IP rate limits on redirects with automatic `429 Too Many Requests` responses.
+- 📊 **Daily Stats Aggregation & Pruning** — Automatic daily summarization of raw visit logs into compact daily stats tables. Configurable retention window prevents unbounded database growth at scale.
---
@@ -121,7 +126,7 @@ All fluent methods on the plugin are optional. If not called, the plugin falls b
## Global Settings GUI
-The package comes with a built-in admin settings dashboard. You can access it by clicking the **Settings** action button on the top-right header of the Short URLs resource.
+The package comes with a built-in admin settings dashboard. You can access it by clicking the **Settings** action button on the top-right header of the Short URLs resource.
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
@@ -129,7 +134,7 @@ 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}`).
-* **Default Redirect Status**: Choose `302 (Found / Temporary)` or `301 (Moved Permanently)`.
+* **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`).
* **Queue Connection**: Define the Laravel queue connection (e.g. `redis`, `database`, `sync`) used for processing visit analytics asynchronously.
@@ -157,6 +162,153 @@ For extremely high-traffic applications, direct database writes for click counts
$schedule->command('short-url:sync-counters')->everyMinute();
```
+### 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.
+* **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.
+* **Max Redirects Allowed**: Maximum number of redirect requests per IP within the decay window. Default: `60`.
+* **Decay Window (seconds)**: The rolling time window for the rate limiter. Default: `60` seconds.
+
+When a client exceeds the limit, a `429 Too Many Requests` response is returned with a `Retry-After` header.
+
+---
+
+## Password-Protected Links (new in v1.2.0)
+
+You can require visitors to enter a password before being redirected. Enable this in the **Targeting & Security** tab of the short URL form:
+
+- Set a plain-text password in the **Access Password** field.
+- Visitors will see a styled password prompt page before gaining access.
+- The unlock state is stored in the PHP session — visitors only need to enter the password once per session.
+
+```php
+// Programmatically — set via fillable attributes
+$shortUrl = ShortUrl::destination('https://secret.example.com')
+ ->create();
+
+$shortUrl->update(['password' => 'my-secret-pass']);
+```
+
+> **Note**: Passwords are currently stored as plain text. For sensitive use-cases, hash the password and compare with `Hash::check()` by overriding the redirect controller.
+
+---
+
+## Redirect Warning Pages (new in v1.2.0)
+
+Enable the **Show Redirect Warning Page** toggle in the **Targeting & Security** tab to display a safety interstitial before redirecting.
+
+The warning page:
+- Shows the destination URL clearly so visitors can verify they trust it.
+- Provides **Continue** and **Go Back** buttons.
+- Is confirmed via a `?confirmed=1` query parameter — no additional session storage required.
+- Is styled to match the password prompt page (glassmorphism, dark mode compatible).
+
+This feature is useful for NSFW links, external partner links, or any URL that leaves a trusted domain.
+
+---
+
+## Smart Link Targeting (new in v1.2.0)
+
+The **Targeting & Security** tab exposes a powerful rule engine that lets you route different visitors to different destinations — all from a single short URL.
+
+### Available Strategies
+
+#### 1. Device-Based Redirects
+Route visitors to different URLs based on their device type (detected from User-Agent):
+
+| Device | Detected by User-Agent containing |
+|--------|-----------------------------------|
+| iOS (Mobile) | `iphone`, `ipad`, `ipod` |
+| Android | `android` |
+| Desktop | Everything else |
+
+```php
+// Programmatic example
+$shortUrl->update([
+ 'targeting_rules' => [
+ 'type' => 'device',
+ 'device' => [
+ 'ios' => 'https://apps.apple.com/your-app',
+ 'android' => 'https://play.google.com/your-app',
+ 'desktop' => 'https://example.com/download',
+ ],
+ ],
+]);
+```
+
+#### 2. Country-Based (Geo-IP) Redirects
+Route visitors to country-specific URLs. Requires Geo-IP to be enabled in settings. Falls back to the default `destination_url` for unlisted countries.
+
+```php
+$shortUrl->update([
+ 'targeting_rules' => [
+ 'type' => 'geo',
+ 'geo' => [
+ ['country_code' => 'PL', 'url' => 'https://pl.example.com'],
+ ['country_code' => 'US', 'url' => 'https://us.example.com'],
+ ['country_code' => 'DE', 'url' => 'https://de.example.com'],
+ ],
+ ],
+]);
+```
+
+#### 3. A/B Split Rotation
+Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
+
+```php
+$shortUrl->update([
+ 'targeting_rules' => [
+ 'type' => 'rotation',
+ 'rotation' => [
+ ['url' => 'https://variant-a.example.com', 'weight' => 70],
+ ['url' => 'https://variant-b.example.com', 'weight' => 30],
+ ],
+ ],
+]);
+```
+
+> All targeting strategies fall back gracefully to the link's primary `destination_url` if no rule matches.
+
+---
+
+## High-Traffic Optimizations (new in v1.2.0)
+
+### Daily Stats Aggregation
+
+The `short_url_daily_stats` table stores pre-aggregated daily summaries per short URL. Each row contains:
+
+| Column | Description |
+|--------|-------------|
+| `date` | The calendar day |
+| `visits_count` | Total visits |
+| `unique_visits_count` | Unique visitors (by hashed IP) |
+| `device_stats` | JSON — visit counts by device type |
+| `browser_stats` | JSON — visit counts by browser |
+| `os_stats` | JSON — visit counts by operating system |
+| `country_stats` | JSON — visit counts by country |
+| `city_stats` | JSON — visit counts by city |
+| `referer_stats` | JSON — visit counts by referer domain |
+| `utm_source_stats` | JSON — visit counts by UTM source |
+| `utm_medium_stats` | JSON — visit counts by UTM medium |
+| `utm_campaign_stats` | JSON — visit counts by UTM campaign |
+
+The `getCachedStats()` model method **automatically merges** data from both tables: historical days come from `short_url_daily_stats`, while today's data comes directly from `short_url_visits` — completely transparent to the dashboard.
+
+### Queue-Based Counter Fallback
+
+When Redis is not available and counter buffering is enabled, the `IncrementVisitJob` is dispatched to the configured queue connection. This guarantees visit counts are not lost during cache evictions or restarts.
+
---
## Configuration Reference (.env)
@@ -175,6 +327,34 @@ You can also pre-configure all parameters via your `.env` file:
| `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_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. |
+| `SHORT_URL_RATE_LIMIT_MAX` | `rate_limiting.max_attempts` | `60` | Max redirect requests within the decay window. |
+| `SHORT_URL_RATE_LIMIT_DECAY` | `rate_limiting.decay_seconds` | `60` | Rate limiter rolling window in seconds. |
+
+---
+
+## Artisan Commands
+
+| Command | Description |
+|---------|-------------|
+| `short-url:sync-counters` | Flushes buffered visit counts from cache to the database. Schedule every minute when counter buffering is enabled. |
+| `short-url:aggregate-and-prune` | Aggregates previous days' raw visits into `short_url_daily_stats` and prunes raw records older than the configured retention period. Schedule daily (e.g. at `02:00`). |
+
+### Recommended Scheduler Configuration
+
+```php
+// routes/console.php
+
+use Illuminate\Support\Facades\Schedule;
+
+// Flush buffered click counts every minute (only if counter buffering is enabled)
+Schedule::command('short-url:sync-counters')->everyMinute();
+
+// Aggregate stats and prune old raw visits nightly
+Schedule::command('short-url:aggregate-and-prune')->dailyAt('02:00');
+```
---
@@ -225,8 +405,9 @@ echo $shortUrl->getShortUrl(); // https://yourdomain.com/s/promo2026
* `isActive(): bool` — Checks if the short URL is enabled and within its activation/expiration timestamps.
* `isExpired(): bool` — Checks if the URL has passed its expiration date.
* `getShortUrl(): string` — Resolves the complete URL string.
-* `incrementVisits(bool $isUnique = false): void` — Atomically increments visit counts.
-* `getCachedStats(): array` — Returns cached key metrics for dashboard charts.
+* `incrementVisits(bool $isUnique = false): void` — Atomically increments visit counts (with Redis buffering or queue fallback).
+* `getCachedStats(): array` — Returns cached key metrics for dashboard charts. Automatically merges daily aggregated stats with today's raw visits.
+* `resolveDestinationUrl(Request $request): string` — Evaluates active targeting rules (device, geo, rotation) and returns the correct destination URL for the current visitor.
### Injecting the Service
For standard creations, you can inject `ShortUrlService`:
@@ -264,6 +445,31 @@ Visits from scrapers, search bots, and web crawlers are automatically ignored to
---
+## Database Compatibility
+
+All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
+
+- No MySQL-specific `ENUM` types — `device_type` uses `VARCHAR(20)` validated at PHP level.
+- No `->after()` column ordering hints — fully cross-database.
+- JSON columns work natively on MySQL 5.7.8+ and are stored as TEXT on SQLite.
+
+---
+
+## Changelog
+
+### v1.2.0
+- **Password-protected links** — Session-based unlock flow with a styled prompt page.
+- **Redirect warning pages** — Interstitial security screen before external redirects.
+- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
+- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
+- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
+- **Counter buffering fallback** — `IncrementVisitJob` as queue-based fallback when Redis is unavailable.
+- **Database compatibility** — Replaced `ENUM` with `VARCHAR`, removed MySQL-only `->after()` calls.
+- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
+- **Polish translations** — Full `pl` locale support for all new features.
+
+---
+
## License
MIT
diff --git a/database/migrations/2024_01_01_000002_create_short_url_visits_table.php b/database/migrations/2024_01_01_000002_create_short_url_visits_table.php
index 84ef220..a0947a8 100644
--- a/database/migrations/2024_01_01_000002_create_short_url_visits_table.php
+++ b/database/migrations/2024_01_01_000002_create_short_url_visits_table.php
@@ -27,8 +27,8 @@ return new class extends Migration
$table->string('operating_system', 100)->nullable();
$table->string('operating_system_version', 50)->nullable();
- // Device classification
- $table->enum('device_type', ['desktop', 'mobile', 'tablet', 'robot'])->nullable()->index();
+ // Device classification ('desktop', 'mobile', 'tablet', 'robot') — validated at PHP level
+ $table->string('device_type', 20)->nullable()->index();
// Traffic source
$table->text('referer_url')->nullable();
diff --git a/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php b/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php
index c5385c9..0e3434c 100644
--- a/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php
+++ b/database/migrations/2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table.php
@@ -9,13 +9,13 @@ 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');
+ $table->string('city', 100)->nullable();
+ $table->string('referer_host', 255)->nullable()->index();
+ $table->string('utm_source', 100)->nullable()->index();
+ $table->string('utm_medium', 100)->nullable()->index();
+ $table->string('utm_campaign', 100)->nullable()->index();
+ $table->string('utm_term', 100)->nullable();
+ $table->string('utm_content', 100)->nullable();
});
}
@@ -34,3 +34,4 @@ return new class extends Migration
});
}
};
+
diff --git a/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php b/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php
new file mode 100644
index 0000000..88ab7d7
--- /dev/null
+++ b/database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php
@@ -0,0 +1,29 @@
+string('password', 255)->nullable();
+ $table->boolean('show_warning_page')->default(false);
+ $table->json('targeting_rules')->nullable();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('short_urls', function (Blueprint $table): void {
+ $table->dropColumn([
+ 'password',
+ 'show_warning_page',
+ 'targeting_rules',
+ ]);
+ });
+ }
+};
+
diff --git a/database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php b/database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php
new file mode 100644
index 0000000..7fb6dde
--- /dev/null
+++ b/database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('short_url_id')
+ ->constrained('short_urls')
+ ->cascadeOnDelete();
+ $table->date('date');
+ $table->integer('visits_count')->default(0);
+ $table->integer('unique_visits_count')->default(0);
+ $table->json('device_stats')->nullable();
+ $table->json('browser_stats')->nullable();
+ $table->json('os_stats')->nullable();
+ $table->json('country_stats')->nullable();
+ $table->json('city_stats')->nullable();
+ $table->json('referer_stats')->nullable();
+ $table->json('utm_source_stats')->nullable();
+ $table->json('utm_medium_stats')->nullable();
+ $table->json('utm_campaign_stats')->nullable();
+ $table->timestamps();
+
+ $table->unique(['short_url_id', 'date']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('short_url_daily_stats');
+ }
+};
diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php
index 9096fe7..ad880f1 100644
--- a/resources/lang/en/default.php
+++ b/resources/lang/en/default.php
@@ -227,4 +227,53 @@ return [
'stats_breakdown_utm_medium' => 'Campaign Mediums',
'stats_breakdown_utm_campaign' => 'Campaign Names',
'stats_no_utm_data' => 'No UTM data recorded.',
+
+ // New Targeting & Security Tab and fields
+ 'tab_targeting' => 'Targeting & Security',
+ 'security_section_title' => 'Security Controls',
+ 'password' => 'Access Password',
+ 'password_helper' => 'Require visitors to enter a password before being redirected.',
+ 'show_warning_page' => 'Show Redirect Warning Page',
+ 'show_warning_page_helper' => 'Show an intermediate screen warning visitors about external redirection (NSFW/phishing protection).',
+ 'targeting_type' => 'Targeting Strategy',
+ 'targeting_type_none' => 'None (Direct Redirect)',
+ 'targeting_type_device' => 'Device-Based Redirects',
+ 'targeting_type_country' => 'Country-Based (Geo-IP) Redirects',
+ 'targeting_type_rotation' => 'A/B Split Rotation',
+ 'device_targeting_rules' => 'Device Rules',
+ 'country_targeting_rules' => 'Country Rules',
+ 'rotation_targeting_rules' => 'Rotation Targets',
+ 'device_mobile' => 'Mobile Destination URL',
+ 'device_tablet' => 'Tablet Destination URL',
+ 'device_desktop' => 'Desktop Destination URL',
+ 'country_code' => 'Country Code',
+ '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',
+ 'settings_retention_days' => 'Prune Raw Visit Logs After (Days)',
+ 'settings_retention_days_helper' => 'Raw visit records will be aggregated daily. Older raw logs will be purged after this duration. Use 0 to disable pruning.',
+ 'settings_aggregation_enabled' => 'Enable Daily Aggregation',
+ 'settings_aggregation_enabled_helper' => 'Summarize previous days\' raw visits into daily count tables to keep the database fast.',
+ 'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
+ 'settings_rate_limiting_enabled' => 'Enable Limit Protection',
+ 'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
+ 'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
+ 'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
+ 'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
+ 'settings_rate_limiting_decay_seconds_helper' => 'Time period for the rate limiter.',
+
+ // Views
+ 'password_title' => 'Password Required',
+ 'password_description' => 'This link is password-protected. Please enter the correct password to continue.',
+ 'password_placeholder' => 'Enter password',
+ 'password_btn_unlock' => 'Unlock & Redirect',
+ 'password_error' => 'Incorrect password.',
+ 'warning_title' => 'Security Redirect Warning',
+ 'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
+ 'warning_btn_continue' => 'Continue to Destination',
+ 'warning_btn_back' => 'Go Back',
];
diff --git a/resources/lang/pl/default.php b/resources/lang/pl/default.php
index 9e66808..a57ff71 100644
--- a/resources/lang/pl/default.php
+++ b/resources/lang/pl/default.php
@@ -228,4 +228,53 @@ return [
'stats_breakdown_utm_medium' => 'Media kampanii',
'stats_breakdown_utm_campaign' => 'Nazwy kampanii',
'stats_no_utm_data' => 'Brak danych o parametrach UTM.',
+
+ // New Targeting & Security Tab and fields
+ 'tab_targeting' => 'Targetowanie i bezpieczeństwo',
+ 'security_section_title' => 'Kontrola bezpieczeństwa',
+ 'password' => 'Hasło dostępu',
+ 'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
+ 'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
+ 'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
+ 'targeting_type' => 'Strategia targetowania',
+ 'targeting_type_none' => 'Brak (Bezpośrednie przekierowanie)',
+ 'targeting_type_device' => 'Przekierowania zależne od urządzenia',
+ 'targeting_type_country' => 'Przekierowania zależne od kraju (Geo-IP)',
+ 'targeting_type_rotation' => 'Podział ruchu A/B (Rotacja)',
+ 'device_targeting_rules' => 'Reguły urządzeń',
+ 'country_targeting_rules' => 'Reguły krajów',
+ 'rotation_targeting_rules' => 'Cele rotacji',
+ 'device_mobile' => 'Docelowy URL dla urządzeń mobilnych',
+ 'device_tablet' => 'Docelowy URL dla tabletów',
+ 'device_desktop' => 'Docelowy URL dla komputerów stacjonarnych',
+ 'country_code' => 'Kod kraju',
+ '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',
+ 'settings_retention_days' => 'Usuwaj surowe logi po (dniach)',
+ 'settings_retention_days_helper' => 'Surowe logi wizyt będą codziennie agregowane. Starsze surowe wpisy zostaną usunięte po tym czasie. Wpisz 0, aby wyłączyć usuwanie.',
+ 'settings_aggregation_enabled' => 'Włącz codzienną agregację',
+ 'settings_aggregation_enabled_helper' => 'Agreguj surowe wizyty z poprzednich dni w dzienną tabelę statystyk, aby baza danych działała szybko.',
+ 'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
+ 'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
+ 'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
+ 'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
+ 'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
+ 'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
+ 'settings_rate_limiting_decay_seconds_helper' => 'Przedział czasowy dla ograniczenia liczby żądań.',
+
+ // Views
+ 'password_title' => 'Wymagane hasło',
+ 'password_description' => 'Ten link jest chroniony hasłem. Wprowadź poprawne hasło, aby kontynuować.',
+ 'password_placeholder' => 'Wpisz hasło',
+ 'password_btn_unlock' => 'Odblokuj i przekieruj',
+ 'password_error' => 'Nieprawidłowe hasło.',
+ 'warning_title' => 'Ostrzeżenie o przekierowaniu',
+ 'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
+ 'warning_btn_continue' => 'Kontynuuj do celu',
+ 'warning_btn_back' => 'Wróć',
];
diff --git a/resources/views/password-prompt.blade.php b/resources/views/password-prompt.blade.php
new file mode 100644
index 0000000..8a0dc14
--- /dev/null
+++ b/resources/views/password-prompt.blade.php
@@ -0,0 +1,53 @@
+
+
+
+
-
-
-
- {{ __('filament-short-url::default.stats_filter_date_range') }}
-
-
-
-
+
diff --git a/resources/views/warning.blade.php b/resources/views/warning.blade.php
new file mode 100644
index 0000000..79a93c9
--- /dev/null
+++ b/resources/views/warning.blade.php
@@ -0,0 +1,52 @@
+
+
+
+
+
+
{{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }}
+ @vite(['resources/css/app.css', 'resources/js/app.jsx'])
+
+
+
+
+
+
+
+ {{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
+
+
+ {{ __('filament-short-url::default.warning_description') ?? 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:' }}
+
+
+
+
+
+ {{ $destinationUrl }}
+
+
+
+
+
+
+
diff --git a/resources/views/widgets/visits-bottom-breakdown.blade.php b/resources/views/widgets/visits-bottom-breakdown.blade.php
index 3370ebc..a6c061f 100644
--- a/resources/views/widgets/visits-bottom-breakdown.blade.php
+++ b/resources/views/widgets/visits-bottom-breakdown.blade.php
@@ -28,7 +28,8 @@
-
+ {{-- Row 1: Devices, Browsers, Operating Systems --}}
+
{{-- Devices --}}
@@ -113,6 +114,11 @@
+
+
+ {{-- Row 2: Referrers, Cities --}}
+
+
{{-- Referers --}}
+ {{-- Cities --}}
+
+
+
+
+
+
{{ __('filament-short-url::default.stats_breakdown_cities') }}
+
+
+ @forelse ($visitsByCity as $city => $count)
+ @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
+
+
+ {{ $city }}
+ {{ number_format($count) }} ({{ $pct }}%)
+
+
+
+ @empty
+
{{ __('filament-short-url::default.stats_no_city_data') }}
+ @endforelse
+
+
+
{{-- UTM Campaigns Section --}}
diff --git a/resources/views/widgets/visits-right-breakdown.blade.php b/resources/views/widgets/visits-right-breakdown.blade.php
index c02ba68..23f0e4c 100644
--- a/resources/views/widgets/visits-right-breakdown.blade.php
+++ b/resources/views/widgets/visits-right-breakdown.blade.php
@@ -27,31 +27,5 @@
- {{-- Cities --}}
-
-
-
-
-
-
{{ __('filament-short-url::default.stats_breakdown_cities') }}
-
-
- @forelse ($visitsByCity as $city => $count)
- @php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
-
-
- {{ $city }}
- {{ number_format($count) }} ({{ $pct }}%)
-
-
-
- @empty
-
{{ __('filament-short-url::default.stats_no_city_data') }}
- @endforelse
-
-
-
diff --git a/routes/web.php b/routes/web.php
index b5ba86e..6563d82 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -3,7 +3,8 @@
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Illuminate\Support\Facades\Route;
-Route::get(
+Route::match(
+ ['GET', 'POST'],
config('filament-short-url.route_prefix', 's').'/{key}',
ShortUrlRedirectController::class
)
diff --git a/src/Console/Commands/AggregateAndPruneVisitsCommand.php b/src/Console/Commands/AggregateAndPruneVisitsCommand.php
new file mode 100644
index 0000000..586ffe7
--- /dev/null
+++ b/src/Console/Commands/AggregateAndPruneVisitsCommand.php
@@ -0,0 +1,118 @@
+toDateString();
+
+ // 1. Find all unique dates before today that have visits
+ $dates = ShortUrlVisit::whereDate('visited_at', '<', $today)
+ ->selectRaw('DATE(visited_at) as visit_date')
+ ->distinct()
+ ->pluck('visit_date')
+ ->toArray();
+
+ if (empty($dates)) {
+ $this->info('No historical visits to aggregate.');
+ } else {
+ $this->info('Found ' . count($dates) . ' days to aggregate.');
+
+ foreach ($dates as $date) {
+ // Find all visits on this day
+ $visits = ShortUrlVisit::whereDate('visited_at', '=', $date)->get();
+ $visitsByUrl = $visits->groupBy('short_url_id');
+
+ foreach ($visitsByUrl as $urlId => $urlVisits) {
+ $total = $urlVisits->count();
+ $unique = $urlVisits->unique('ip_hash')->count();
+
+ // Group helper
+ $groupByField = function ($collection, $field) {
+ return $collection->whereNotNull($field)
+ ->groupBy($field)
+ ->map(fn ($group) => $group->count())
+ ->toArray();
+ };
+
+ $deviceStats = $groupByField($urlVisits, 'device_type');
+ $browserStats = $groupByField($urlVisits, 'browser');
+ $osStats = $groupByField($urlVisits, 'operating_system');
+
+ // Country
+ $countryStats = $urlVisits->whereNotNull('country')
+ ->groupBy('country')
+ ->map(fn ($group) => $group->count())
+ ->toArray();
+
+ // City
+ $cityStats = $urlVisits->whereNotNull('city')
+ ->map(function ($visit) {
+ $visit->city_key = "{$visit->city} ({$visit->country_code})";
+ return $visit;
+ })
+ ->groupBy('city_key')
+ ->map(fn ($group) => $group->count())
+ ->toArray();
+
+ // Referer
+ $refererStats = $urlVisits->whereNotNull('referer_host')
+ ->groupBy('referer_host')
+ ->map(fn ($group) => $group->count())
+ ->toArray();
+
+ // UTM
+ $utmSourceStats = $groupByField($urlVisits, 'utm_source');
+ $utmMediumStats = $groupByField($urlVisits, 'utm_medium');
+ $utmCampaignStats = $groupByField($urlVisits, 'utm_campaign');
+
+ // Upsert into short_url_daily_stats
+ ShortUrlDailyStats::updateOrCreate([
+ 'short_url_id' => $urlId,
+ 'date' => $date,
+ ], [
+ 'visits_count' => $total,
+ 'unique_visits_count' => $unique,
+ 'device_stats' => $deviceStats,
+ 'browser_stats' => $browserStats,
+ 'os_stats' => $osStats,
+ 'country_stats' => $countryStats,
+ 'city_stats' => $cityStats,
+ 'referer_stats' => $refererStats,
+ 'utm_source_stats' => $utmSourceStats,
+ 'utm_medium_stats' => $utmMediumStats,
+ 'utm_campaign_stats' => $utmCampaignStats,
+ ]);
+ }
+
+ $this->info("Aggregated stats for {$date}.");
+ }
+ }
+
+ // 2. Prune old visits if enabled
+ if (config('filament-short-url.pruning.enabled', true)) {
+ $retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
+ $cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
+
+ $deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
+
+ $this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
index 235c7b8..25fdbc0 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php
@@ -47,6 +47,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
'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),
+ 'pruning_enabled' => $mgr->get('pruning_enabled', true),
+ 'pruning_retention_days' => $mgr->get('pruning_retention_days', 90),
+ 'rate_limiting_enabled' => $mgr->get('rate_limiting_enabled', false),
+ 'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
+ 'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
]);
}
@@ -210,6 +215,58 @@ class ShortUrlSettingsPage extends Page implements HasForms
->visible(fn (Get $get): bool => filled($get('ga4_api_secret'))),
]),
]),
+
+ // ── Advanced & Security ──────────────────────────────
+ Tab::make(__('filament-short-url::default.settings_tab_advanced'))
+ ->icon('heroicon-o-adjustments-horizontal')
+ ->schema([
+ Section::make(__('filament-short-url::default.settings_section_aggregation'))
+ ->columns(2)
+ ->schema([
+ Toggle::make('pruning_enabled')
+ ->label(__('filament-short-url::default.settings_aggregation_enabled'))
+ ->helperText(__('filament-short-url::default.settings_aggregation_enabled_helper'))
+ ->default(true)
+ ->live()
+ ->inline(false),
+
+ TextInput::make('pruning_retention_days')
+ ->label(__('filament-short-url::default.settings_retention_days'))
+ ->helperText(__('filament-short-url::default.settings_retention_days_helper'))
+ ->numeric()
+ ->minValue(1)
+ ->required()
+ ->visible(fn (Get $get): bool => (bool) $get('pruning_enabled')),
+ ]),
+
+ Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
+ ->columns(3)
+ ->schema([
+ Toggle::make('rate_limiting_enabled')
+ ->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
+ ->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
+ ->default(false)
+ ->live()
+ ->inline(false),
+
+ TextInput::make('rate_limiting_max_attempts')
+ ->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
+ ->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
+ ->numeric()
+ ->minValue(1)
+ ->required()
+ ->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
+
+ TextInput::make('rate_limiting_decay_seconds')
+ ->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
+ ->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
+ ->numeric()
+ ->minValue(1)
+ ->suffix('s')
+ ->required()
+ ->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
+ ]),
+ ]),
]),
])
->statePath('data');
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
index c3126ef..d1df569 100644
--- a/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
+++ b/src/Filament/Resources/ShortUrlResource/Pages/ViewShortUrlStats.php
@@ -54,7 +54,7 @@ class ViewShortUrlStats extends Page implements HasForms
return $schema
->components([
Select::make('preset')
- ->label(__('filament-short-url::default.stats_filter_date_range'))
+ ->hiddenLabel()
->options([
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
@@ -63,6 +63,7 @@ class ViewShortUrlStats extends Page implements HasForms
'custom' => __('filament-short-url::default.stats_preset_custom'),
])
->live()
+ ->columnSpan(fn ($get) => $get('preset') === 'custom' ? 1 : 'full')
->afterStateUpdated(function ($state, $set) {
$to = now()->format('Y-m-d');
$from = match ($state) {
@@ -80,19 +81,24 @@ class ViewShortUrlStats extends Page implements HasForms
}),
DatePicker::make('date_from')
- ->label(__('filament-short-url::default.stats_filter_visited_from'))
+ ->hiddenLabel()
+ ->placeholder(__('filament-short-url::default.stats_filter_visited_from'))
->native(false)
->live()
+ ->columnSpan(1)
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
DatePicker::make('date_to')
- ->label(__('filament-short-url::default.stats_filter_visited_until'))
+ ->hiddenLabel()
+ ->placeholder(__('filament-short-url::default.stats_filter_visited_until'))
->native(false)
->live()
+ ->columnSpan(1)
->visible(fn ($get) => $get('preset') === 'custom')
->required(),
])
+ ->columns(3)
->statePath('filterData');
}
diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
index 2386079..584cfd4 100644
--- a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
+++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php
@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
+use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@@ -24,6 +25,7 @@ class ShortUrlForm
return $schema->components([
Tabs::make()->tabs([
static::linkTab(),
+ static::targetingTab(),
static::trackingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
@@ -313,4 +315,130 @@ class ShortUrlForm
->dehydrated(false),
]);
}
+
+ private static function targetingTab(): Tab
+ {
+ return Tab::make(__('filament-short-url::default.tab_targeting'))
+ ->icon('heroicon-o-shield-check')
+ ->schema([
+ Section::make(__('filament-short-url::default.security_section_title'))
+ ->schema([
+ TextInput::make('password')
+ ->label(__('filament-short-url::default.password'))
+ ->helperText(__('filament-short-url::default.password_helper'))
+ ->password()
+ ->revealable()
+ ->nullable()
+ ->maxLength(255),
+
+ Toggle::make('show_warning_page')
+ ->label(__('filament-short-url::default.show_warning_page'))
+ ->helperText(__('filament-short-url::default.show_warning_page_helper'))
+ ->default(false)
+ ->inline(false),
+ ])->columns(2),
+
+ Section::make(__('filament-short-url::default.targeting_type'))
+ ->schema([
+ Select::make('targeting_rules.type')
+ ->label(__('filament-short-url::default.targeting_type'))
+ ->options([
+ 'none' => __('filament-short-url::default.targeting_type_none'),
+ 'device' => __('filament-short-url::default.targeting_type_device'),
+ 'geo' => __('filament-short-url::default.targeting_type_country'),
+ 'rotation' => __('filament-short-url::default.targeting_type_rotation'),
+ ])
+ ->default('none')
+ ->live()
+ ->required(),
+
+ Section::make(__('filament-short-url::default.device_targeting_rules'))
+ ->schema([
+ TextInput::make('targeting_rules.device.ios')
+ ->label(__('filament-short-url::default.device_mobile'))
+ ->url()
+ ->nullable()
+ ->maxLength(2048),
+ TextInput::make('targeting_rules.device.android')
+ ->label(__('filament-short-url::default.device_tablet'))
+ ->url()
+ ->nullable()
+ ->maxLength(2048),
+ TextInput::make('targeting_rules.device.desktop')
+ ->label(__('filament-short-url::default.device_desktop'))
+ ->url()
+ ->nullable()
+ ->maxLength(2048),
+ ])
+ ->columns(1)
+ ->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'device'),
+
+ Repeater::make('targeting_rules.geo')
+ ->label(__('filament-short-url::default.country_targeting_rules'))
+ ->schema([
+ Select::make('country_code')
+ ->label(__('filament-short-url::default.country_code'))
+ ->options([
+ 'PL' => 'Poland',
+ 'US' => 'United States',
+ 'GB' => 'United Kingdom',
+ 'DE' => 'Germany',
+ 'FR' => 'France',
+ 'ES' => 'Spain',
+ 'IT' => 'Italy',
+ 'CA' => 'Canada',
+ 'AU' => 'Australia',
+ 'NL' => 'Netherlands',
+ 'UA' => 'Ukraine',
+ 'IE' => 'Ireland',
+ 'BE' => 'Belgium',
+ 'AT' => 'Austria',
+ 'CH' => 'Switzerland',
+ 'SE' => 'Sweden',
+ 'NO' => 'Norway',
+ 'DK' => 'Denmark',
+ 'FI' => 'Finland',
+ 'CZ' => 'Czech Republic',
+ 'SK' => 'Slovakia',
+ 'HU' => 'Hungary',
+ 'RO' => 'Romania',
+ 'GR' => 'Greece',
+ 'PT' => 'Portugal',
+ 'BR' => 'Brazil',
+ 'MX' => 'Mexico',
+ 'CN' => 'China',
+ 'JP' => 'Japan',
+ 'IN' => 'India',
+ ])
+ ->searchable()
+ ->required(),
+ TextInput::make('url')
+ ->label(__('filament-short-url::default.destination_url'))
+ ->url()
+ ->required()
+ ->maxLength(2048),
+ ])
+ ->columns(2)
+ ->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'geo'),
+
+ Repeater::make('targeting_rules.rotation')
+ ->label(__('filament-short-url::default.rotation_targeting_rules'))
+ ->schema([
+ TextInput::make('url')
+ ->label(__('filament-short-url::default.rotation_url'))
+ ->url()
+ ->required()
+ ->maxLength(2048),
+ TextInput::make('weight')
+ ->label(__('filament-short-url::default.rotation_weight'))
+ ->helperText(__('filament-short-url::default.rotation_weight_helper'))
+ ->numeric()
+ ->required()
+ ->default(50),
+ ])
+ ->columns(2)
+ ->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
+ ])
+ ]);
+ }
}
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
index 4dd0d31..af2eedf 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsBottomBreakdown.php
@@ -33,6 +33,7 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
+ 'visitsByCity' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
@@ -47,6 +48,7 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
+ 'visitsByCity' => $stats['visitsByCity'] ?? [],
'utmSources' => $stats['utmSources'] ?? [],
'utmMediums' => $stats['utmMediums'] ?? [],
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
index 0671a68..bf2e961 100644
--- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
+++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsRightBreakdown.php
@@ -32,7 +32,6 @@ class ShortUrlVisitsRightBreakdown extends Widget
if (! $this->record) {
return [
'visitsByCountry' => [],
- 'visitsByCity' => [],
'totalVisits' => 0,
];
}
@@ -41,7 +40,6 @@ class ShortUrlVisitsRightBreakdown extends Widget
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],
- 'visitsByCity' => $stats['visitsByCity'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}
diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php
index 50cdeca..7992de5 100644
--- a/src/FilamentShortUrlServiceProvider.php
+++ b/src/FilamentShortUrlServiceProvider.php
@@ -26,8 +26,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'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',
+ '2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
+ '2026_06_01_000005_create_short_url_daily_stats_table',
+ ])
+ ->hasCommands([
+ SyncBufferedCountersCommand::class,
+ Console\Commands\AggregateAndPruneVisitsCommand::class,
])
- ->hasCommand(SyncBufferedCountersCommand::class)
->hasRoutes(['web']);
}
diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php
index f6477eb..fc41f63 100644
--- a/src/Http/Controllers/ShortUrlRedirectController.php
+++ b/src/Http/Controllers/ShortUrlRedirectController.php
@@ -6,9 +6,10 @@ use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
-use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
+use Illuminate\Support\Facades\RateLimiter;
+use Symfony\Component\HttpFoundation\Response;
class ShortUrlRedirectController extends Controller
{
@@ -16,7 +17,7 @@ class ShortUrlRedirectController extends Controller
private readonly ShortUrlService $service,
) {}
- public function __invoke(Request $request, string $key): RedirectResponse
+ public function __invoke(Request $request, string $key): Response
{
$shortUrl = ShortUrl::findByKey($key);
@@ -30,6 +31,66 @@ class ShortUrlRedirectController extends Controller
abort(410);
}
+ // 1. Rate Limiting Check
+ 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();
+
+ if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
+ $retryAfter = RateLimiter::availableIn($limiterKey);
+ abort(429, 'Too many requests. Please try again in ' . $retryAfter . ' seconds.', [
+ 'Retry-After' => $retryAfter,
+ ]);
+ }
+ RateLimiter::hit($limiterKey, $decaySeconds);
+ }
+
+ // 2. Password Protection Check
+ if (! empty($shortUrl->password)) {
+ $sessionKey = "short-url-auth-{$shortUrl->id}";
+ if (! session()->get($sessionKey)) {
+ if ($request->isMethod('POST')) {
+ $submitted = $request->input('password');
+ if ($submitted === $shortUrl->password) {
+ session()->put($sessionKey, true);
+ return redirect()->to($request->fullUrl());
+ }
+
+ $errors = new \Illuminate\Support\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');
+ }
+
+ return response(view('filament-short-url::password-prompt'))
+ ->header('Content-Type', 'text/html');
+ }
+ }
+
+ // 3. 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. Warning / Intermediate Page Check
+ if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
+ return response(view('filament-short-url::warning', ['destinationUrl' => $destination]))
+ ->header('Content-Type', 'text/html');
+ }
+
+ // 5. Track Visit
if ($shortUrl->track_visits) {
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
@@ -72,8 +133,6 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
- $redirectUrl = $this->service->resolveRedirectUrl($shortUrl, $request);
-
- return redirect()->away($redirectUrl, $shortUrl->redirect_status_code);
+ return redirect()->away($destination, $shortUrl->redirect_status_code);
}
}
diff --git a/src/Jobs/IncrementVisitJob.php b/src/Jobs/IncrementVisitJob.php
new file mode 100644
index 0000000..67c5cbb
--- /dev/null
+++ b/src/Jobs/IncrementVisitJob.php
@@ -0,0 +1,43 @@
+onQueue(config('filament-short-url.queue_name', 'default'));
+ }
+
+ public function handle(): void
+ {
+ $shortUrl = ShortUrl::find($this->shortUrlId);
+
+ if (! $shortUrl) {
+ return;
+ }
+
+ $shortUrl->newQuery()
+ ->where('id', $shortUrl->id)
+ ->increment(
+ 'total_visits',
+ 1,
+ $this->isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []
+ );
+ }
+}
diff --git a/src/Models/ShortUrl.php b/src/Models/ShortUrl.php
index 624ba7d..e6036ec 100644
--- a/src/Models/ShortUrl.php
+++ b/src/Models/ShortUrl.php
@@ -67,6 +67,9 @@ class ShortUrl extends Model
'track_referer_url',
'qr_options',
'ga_tracking_id',
+ 'password',
+ 'show_warning_page',
+ 'targeting_rules',
'total_visits',
'unique_visits',
];
@@ -85,6 +88,8 @@ class ShortUrl extends Model
'track_device_type' => 'boolean',
'track_referer_url' => 'boolean',
'qr_options' => 'array',
+ 'show_warning_page' => 'boolean',
+ 'targeting_rules' => 'array',
'activated_at' => 'datetime',
'deactivated_at' => 'datetime',
'expires_at' => 'datetime',
@@ -97,6 +102,11 @@ class ShortUrl extends Model
return $this->hasMany(ShortUrlVisit::class, 'short_url_id');
}
+ public function dailyStats(): HasMany
+ {
+ return $this->hasMany(ShortUrlDailyStats::class, 'short_url_id');
+ }
+
// ─── Scopes ──────────────────────────────────────────────────────────────
public function scopeEnabled(Builder $query): Builder
@@ -237,6 +247,69 @@ class ShortUrl extends Model
return rtrim(config('app.url'), '/')."/{$prefix}/{$this->url_key}";
}
+ /**
+ * Resolve the targeted destination URL based on request headers/context.
+ */
+ public function resolveDestinationUrl(\Illuminate\Http\Request $request): string
+ {
+ $rules = $this->targeting_rules;
+
+ if (empty($rules)) {
+ return $this->destination_url;
+ }
+
+ $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;
+ }
+ if (str_contains($ua, 'android')) {
+ return $rules['device']['android'] ?? $this->destination_url;
+ }
+ return $rules['device']['desktop'] ?? $this->destination_url;
+ }
+
+ if ($type === 'geo') {
+ $countryCode = \Bjanczak\FilamentShortUrl\Services\ClientIpExtractor::getCountryCode($request);
+ if (! $countryCode) {
+ // Try resolving via GeoIpService
+ $ip = \Bjanczak\FilamentShortUrl\Services\ClientIpExtractor::getIp($request);
+ $geo = app(\Bjanczak\FilamentShortUrl\Services\GeoIpService::class)->resolve($ip);
+ $countryCode = $geo['country_code'] ?? null;
+ }
+
+ if ($countryCode) {
+ $countryCode = strtoupper(trim($countryCode));
+ foreach ($rules['geo'] ?? [] as $rule) {
+ if (strtoupper($rule['country_code'] ?? '') === $countryCode) {
+ return $rule['url'] ?? $this->destination_url;
+ }
+ }
+ }
+ }
+
+ if ($type === 'rotation') {
+ $items = $rules['rotation'] ?? [];
+ if (! empty($items)) {
+ $totalWeight = array_sum(array_column($items, 'weight'));
+ if ($totalWeight > 0) {
+ $rand = mt_rand(1, $totalWeight);
+ $currentWeight = 0;
+ foreach ($items as $item) {
+ $currentWeight += (int) ($item['weight'] ?? 0);
+ if ($rand <= $currentWeight) {
+ return $item['url'] ?? $this->destination_url;
+ }
+ }
+ }
+ }
+ }
+
+ return $this->destination_url;
+ }
+
/**
* Atomically increment visit counters — single query when unique,
* to avoid race conditions and two round-trips. Supports write-back caching.
@@ -244,33 +317,28 @@ class ShortUrl extends Model
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 (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
+ $prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
+ try {
+ 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));
+ if ($isUnique) {
+ cache()->increment("{$prefix}unique:{$this->id}");
}
- }
- return;
- } catch (\Throwable) {
- // Fallback to database below if cache fails or doesn't support increments
+ Redis::sadd("{$prefix}dirty_ids", $this->id);
+
+ return;
+ } catch (\Throwable) {
+ // Fallback to queue job below
+ }
}
+
+ // Safe fallback: Dispatch async job so clicks are queued and not lost on cache clear
+ $connection = config('filament-short-url.queue_connection', 'sync');
+ dispatch(new \Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob($this->id, $isUnique)->onConnection($connection ?: 'sync'));
+
+ return;
}
$this->newQuery()
@@ -292,155 +360,186 @@ class ShortUrl extends Model
$cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
- $visits = $this->visits();
+ $today = Carbon::today()->toDateString();
+ // 1. Fetch daily stats (aggregated historical data)
+ $dailyQuery = $this->dailyStats()->where('date', '<', $today);
if ($dateFromClean) {
- $visits->whereDate('visited_at', '>=', $dateFromClean);
+ $dailyQuery->where('date', '>=', $dateFromClean);
}
- if ($dateToClean) {
- $visits->whereDate('visited_at', '<=', $dateToClean);
+ if ($dateToClean && $dateToClean < $today) {
+ $dailyQuery->where('date', '<=', $dateToClean);
+ }
+ $dailyStatsRows = $dailyQuery->get();
+
+ // 2. Fetch raw visits for today (if within date range)
+ $includeToday = ($dateToClean === null || $dateToClean >= $today);
+ if ($dateFromClean && $dateFromClean > $today) {
+ $includeToday = false;
}
- $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
- $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
+ $rawVisits = [];
+ if ($includeToday) {
+ $rawVisits = $this->visits()->whereDate('visited_at', '=', $today)->get();
+ }
- $totalVisits = (clone $visits)->count();
- $uniqueVisits = (clone $visits)->distinct('ip_hash')->count('ip_hash');
+ // Helper to merge associative stats arrays
+ $mergeStats = function (array $base, ?array $additional): array {
+ if (empty($additional)) {
+ return $base;
+ }
+ foreach ($additional as $key => $val) {
+ $base[$key] = ($base[$key] ?? 0) + $val;
+ }
+ return $base;
+ };
- $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();
+ // Initialize metrics
+ $totalVisits = 0;
+ $uniqueVisitsCount = 0;
+ $visitsToday = count($rawVisits);
+ $visitsThisWeek = 0;
+ $visitsThisMonth = 0;
- $daysDiff = (int) $chartFrom->diffInDays($chartTo);
+ $visitsByCountry = [];
+ $visitsByCity = [];
+ $visitsByDevice = [];
+ $visitsByBrowser = [];
+ $visitsByOs = [];
+ $visitsByReferer = [];
+ $utmSources = [];
+ $utmMediums = [];
+ $utmCampaigns = [];
- 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();
+ // Sum up daily stats
+ $startOfWeek = now()->startOfWeek()->toDateString();
+ $startOfMonth = now()->startOfMonth()->toDateString();
- $visitsByDay = [];
- for ($i = $daysDiff; $i >= 0; $i--) {
- $date = (clone $chartTo)->subDays($i)->format('Y-m-d');
- $visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
+ foreach ($dailyStatsRows as $row) {
+ $totalVisits += $row->visits_count;
+ $uniqueVisitsCount += $row->unique_visits_count;
+
+ $rowDate = $row->date->toDateString();
+ if ($rowDate >= $startOfWeek) {
+ $visitsThisWeek += $row->visits_count;
+ }
+ if ($rowDate >= $startOfMonth) {
+ $visitsThisMonth += $row->visits_count;
+ }
+
+ $visitsByCountry = $mergeStats($visitsByCountry, $row->country_stats);
+ $visitsByCity = $mergeStats($visitsByCity, $row->city_stats);
+ $visitsByDevice = $mergeStats($visitsByDevice, $row->device_stats);
+ $visitsByBrowser = $mergeStats($visitsByBrowser, $row->browser_stats);
+ $visitsByOs = $mergeStats($visitsByOs, $row->os_stats);
+ $visitsByReferer = $mergeStats($visitsByReferer, $row->referer_stats);
+ $utmSources = $mergeStats($utmSources, $row->utm_source_stats);
+ $utmMediums = $mergeStats($utmMediums, $row->utm_medium_stats);
+ $utmCampaigns = $mergeStats($utmCampaigns, $row->utm_campaign_stats);
+ }
+
+ // Combine today's raw visits
+ if ($includeToday) {
+ $totalVisits += count($rawVisits);
+ $uniqueVisitsCount += count(array_unique(array_filter($rawVisits->pluck('ip_hash')->toArray())));
+
+ $visitsThisWeek += count($rawVisits);
+ $visitsThisMonth += count($rawVisits);
+
+ foreach ($rawVisits as $visit) {
+ if ($visit->country) {
+ $visitsByCountry[$visit->country] = ($visitsByCountry[$visit->country] ?? 0) + 1;
+ }
+ if ($visit->city) {
+ $cityKey = "{$visit->city} ({$visit->country_code})";
+ $visitsByCity[$cityKey] = ($visitsByCity[$cityKey] ?? 0) + 1;
+ }
+ if ($visit->device_type) {
+ $visitsByDevice[$visit->device_type] = ($visitsByDevice[$visit->device_type] ?? 0) + 1;
+ }
+ if ($visit->browser) {
+ $visitsByBrowser[$visit->browser] = ($visitsByBrowser[$visit->browser] ?? 0) + 1;
+ }
+ if ($visit->operating_system) {
+ $visitsByOs[$visit->operating_system] = ($visitsByOs[$visit->operating_system] ?? 0) + 1;
+ }
+ $refererHost = $visit->referer_host ?: 'Direct';
+ $visitsByReferer[$refererHost] = ($visitsByReferer[$refererHost] ?? 0) + 1;
+
+ if ($visit->utm_source) {
+ $utmSources[$visit->utm_source] = ($utmSources[$visit->utm_source] ?? 0) + 1;
+ }
+ if ($visit->utm_medium) {
+ $utmMediums[$visit->utm_medium] = ($utmMediums[$visit->utm_medium] ?? 0) + 1;
+ }
+ if ($visit->utm_campaign) {
+ $utmCampaigns[$visit->utm_campaign] = ($utmCampaigns[$visit->utm_campaign] ?? 0) + 1;
+ }
}
}
- // Top countries
- $visitsByCountry = (clone $visits)
- ->whereNotNull('country')
- ->selectRaw('country, country_code, COUNT(*) as count')
- ->groupBy('country', 'country_code')
- ->orderByDesc('count')
- ->limit(10)
- ->get()
- ->mapWithKeys(fn ($row) => [$row->country => $row->count])
- ->toArray();
+ // Build visitsByDay timeline
+ $chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
+ $chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
+ $daysDiff = (int) $chartFrom->diffInDays($chartTo);
- // 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();
+ $visitsByDay = [];
+ if ($daysDiff > 90) {
+ // Group by month
+ foreach ($dailyStatsRows as $row) {
+ $m = $row->date->format('Y-m');
+ $visitsByDay[$m] = ($visitsByDay[$m] ?? 0) + $row->visits_count;
+ }
+ if ($includeToday) {
+ $mToday = Carbon::parse($today)->format('Y-m');
+ $visitsByDay[$mToday] = ($visitsByDay[$mToday] ?? 0) + count($rawVisits);
+ }
+ } else {
+ // Initialize timeline with zeros
+ for ($i = $daysDiff; $i >= 0; $i--) {
+ $d = (clone $chartTo)->subDays($i)->format('Y-m-d');
+ $visitsByDay[$d] = 0;
+ }
+ // Fill daily stats
+ foreach ($dailyStatsRows as $row) {
+ $d = $row->date->format('Y-m-d');
+ if (isset($visitsByDay[$d])) {
+ $visitsByDay[$d] = $row->visits_count;
+ }
+ }
+ // Fill today
+ if ($includeToday && isset($visitsByDay[$today])) {
+ $visitsByDay[$today] = count($rawVisits);
+ }
+ }
- // Device types
- $visitsByDevice = (clone $visits)
- ->whereNotNull('device_type')
- ->selectRaw('device_type, COUNT(*) as count')
- ->groupBy('device_type')
- ->orderByDesc('count')
- ->pluck('count', 'device_type')
- ->toArray();
-
- // Browsers
- $visitsByBrowser = (clone $visits)
- ->whereNotNull('browser')
- ->selectRaw('browser, COUNT(*) as count')
- ->groupBy('browser')
- ->orderByDesc('count')
- ->limit(8)
- ->pluck('count', 'browser')
- ->toArray();
-
- // Operating systems
- $visitsByOs = (clone $visits)
- ->whereNotNull('operating_system')
- ->selectRaw('operating_system, COUNT(*) as count')
- ->groupBy('operating_system')
- ->orderByDesc('count')
- ->limit(8)
- ->pluck('count', 'operating_system')
- ->toArray();
-
- // Top referrer hosts
- $visitsByReferer = (clone $visits)
- ->whereNotNull('referer_host')
- ->selectRaw('referer_host, COUNT(*) as count')
- ->groupBy('referer_host')
- ->orderByDesc('count')
- ->limit(10)
- ->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();
+ // Sort distributions descending
+ arsort($visitsByCountry);
+ arsort($visitsByCity);
+ arsort($visitsByDevice);
+ arsort($visitsByBrowser);
+ arsort($visitsByOs);
+ arsort($visitsByReferer);
+ arsort($utmSources);
+ arsort($utmMediums);
+ arsort($utmCampaigns);
return [
'totalVisits' => $totalVisits,
- 'uniqueVisits' => $uniqueVisits,
+ 'uniqueVisits' => $uniqueVisitsCount,
'visitsToday' => $visitsToday,
'visitsThisWeek' => $visitsThisWeek,
'visitsThisMonth' => $visitsThisMonth,
'visitsByDay' => $visitsByDay,
- 'visitsByCountry' => $visitsByCountry,
- 'visitsByCity' => $visitsByCity,
+ 'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true),
+ 'visitsByCity' => array_slice($visitsByCity, 0, 10, true),
'visitsByDevice' => $visitsByDevice,
- 'visitsByBrowser' => $visitsByBrowser,
- 'visitsByOs' => $visitsByOs,
- 'visitsByReferer' => $visitsByReferer,
- 'utmSources' => $utmSources,
- 'utmMediums' => $utmMediums,
- 'utmCampaigns' => $utmCampaigns,
+ 'visitsByBrowser' => array_slice($visitsByBrowser, 0, 8, true),
+ 'visitsByOs' => array_slice($visitsByOs, 0, 8, true),
+ 'visitsByReferer' => array_slice($visitsByReferer, 0, 10, true),
+ 'utmSources' => array_slice($utmSources, 0, 8, true),
+ 'utmMediums' => array_slice($utmMediums, 0, 8, true),
+ 'utmCampaigns' => array_slice($utmCampaigns, 0, 8, true),
];
});
}
diff --git a/src/Models/ShortUrlDailyStats.php b/src/Models/ShortUrlDailyStats.php
new file mode 100644
index 0000000..0b3d25d
--- /dev/null
+++ b/src/Models/ShortUrlDailyStats.php
@@ -0,0 +1,67 @@
+ */
+ protected $casts = [
+ 'date' => 'date',
+ 'visits_count' => 'integer',
+ 'unique_visits_count' => 'integer',
+ 'device_stats' => 'array',
+ 'browser_stats' => 'array',
+ 'os_stats' => 'array',
+ 'country_stats' => 'array',
+ 'city_stats' => 'array',
+ 'referer_stats' => 'array',
+ 'utm_source_stats' => 'array',
+ 'utm_medium_stats' => 'array',
+ 'utm_campaign_stats' => 'array',
+ ];
+
+ public function shortUrl(): BelongsTo
+ {
+ return $this->belongsTo(ShortUrl::class, 'short_url_id');
+ }
+}
diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php
index f50b518..3c1c701 100644
--- a/src/Services/ShortUrlSettingsManager.php
+++ b/src/Services/ShortUrlSettingsManager.php
@@ -51,6 +51,11 @@ class ShortUrlSettingsManager
'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),
+ 'pruning_enabled' => config('filament-short-url.pruning.enabled', true),
+ 'pruning_retention_days' => config('filament-short-url.pruning.retention_days', 90),
+ 'rate_limiting_enabled' => config('filament-short-url.rate_limiting.enabled', false),
+ 'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60),
+ 'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60),
], $stored);
return $this->cache;
@@ -91,6 +96,11 @@ class ShortUrlSettingsManager
'cache_ttl',
'counter_buffering_enabled',
'trust_cdn_headers',
+ 'pruning_enabled',
+ 'pruning_retention_days',
+ 'rate_limiting_enabled',
+ 'rate_limiting_max_attempts',
+ 'rate_limiting_decay_seconds',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -120,6 +130,21 @@ class ShortUrlSettingsManager
if (isset($filtered['trust_cdn_headers'])) {
$filtered['trust_cdn_headers'] = (bool) $filtered['trust_cdn_headers'];
}
+ if (isset($filtered['pruning_enabled'])) {
+ $filtered['pruning_enabled'] = (bool) $filtered['pruning_enabled'];
+ }
+ if (isset($filtered['pruning_retention_days'])) {
+ $filtered['pruning_retention_days'] = (int) $filtered['pruning_retention_days'];
+ }
+ if (isset($filtered['rate_limiting_enabled'])) {
+ $filtered['rate_limiting_enabled'] = (bool) $filtered['rate_limiting_enabled'];
+ }
+ if (isset($filtered['rate_limiting_max_attempts'])) {
+ $filtered['rate_limiting_max_attempts'] = (int) $filtered['rate_limiting_max_attempts'];
+ }
+ if (isset($filtered['rate_limiting_decay_seconds'])) {
+ $filtered['rate_limiting_decay_seconds'] = (int) $filtered['rate_limiting_decay_seconds'];
+ }
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->cache = null;
@@ -150,6 +175,11 @@ class ShortUrlSettingsManager
'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'],
+ 'filament-short-url.pruning.enabled' => $settings['pruning_enabled'],
+ 'filament-short-url.pruning.retention_days' => $settings['pruning_retention_days'],
+ 'filament-short-url.rate_limiting.enabled' => $settings['rate_limiting_enabled'],
+ 'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'],
+ 'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'],
]);
}
}