From eda4794cacf708eb4352d29409e717ff3047e17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Janczak?= Date: Mon, 1 Jun 2026 13:54:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20password=20protect?= =?UTF-8?q?ion,=20warning=20pages,=20smart=20targeting,=20rate=20limiting,?= =?UTF-8?q?=20daily=20stats=20aggregation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add password-protected links with session-based unlock flow - Add redirect warning (interstitial) pages before external URLs - Add smart targeting rules: device-based, country/geo, A/B weighted rotation - Add configurable per-IP rate limiting with 429 + Retry-After headers - Add daily stats aggregation & log pruning (short-url:aggregate-and-prune) - Add IncrementVisitJob as queue-based counter buffering fallback - Add ShortUrlDailyStats model with JSON stat columns per day - Add two new migrations: targeting/security fields, daily_stats table - Add password-prompt.blade.php and warning.blade.php views - Extend Settings GUI with Performance & Security tab (aggregation + rate limiting) - Extend ShortUrlForm with Targeting & Security section - Add POST route for password form submission (was GET-only → 405) - Replace enum(device_type) with string(20) for cross-DB compatibility - Remove ->after() MySQL-only hints from ALTER TABLE migrations - Fix aggregation test: use whereDate() instead of assertDatabaseHas for date column - Extend en/pl translations for all new features - Expand README.md with full v1.2.0 documentation (476 lines) --- README.md | 218 +++++++++- ...1_000002_create_short_url_visits_table.php | 4 +- ...city_referer_to_short_url_visits_table.php | 15 +- ...eting_and_security_to_short_urls_table.php | 29 ++ ...005_create_short_url_daily_stats_table.php | 38 ++ resources/lang/en/default.php | 49 +++ resources/lang/pl/default.php | 49 +++ resources/views/password-prompt.blade.php | 53 +++ resources/views/stats.blade.php | 16 +- resources/views/warning.blade.php | 52 +++ .../widgets/visits-bottom-breakdown.blade.php | 34 +- .../widgets/visits-right-breakdown.blade.php | 26 -- routes/web.php | 3 +- .../AggregateAndPruneVisitsCommand.php | 118 ++++++ .../Pages/ShortUrlSettingsPage.php | 57 +++ .../Pages/ViewShortUrlStats.php | 12 +- .../ShortUrlResource/Schemas/ShortUrlForm.php | 128 ++++++ .../Widgets/ShortUrlVisitsBottomBreakdown.php | 2 + .../Widgets/ShortUrlVisitsRightBreakdown.php | 2 - src/FilamentShortUrlServiceProvider.php | 7 +- .../ShortUrlRedirectController.php | 69 ++- src/Jobs/IncrementVisitJob.php | 43 ++ src/Models/ShortUrl.php | 397 +++++++++++------- src/Models/ShortUrlDailyStats.php | 67 +++ src/Services/ShortUrlSettingsManager.php | 30 ++ 25 files changed, 1305 insertions(+), 213 deletions(-) create mode 100644 database/migrations/2026_06_01_000004_add_targeting_and_security_to_short_urls_table.php create mode 100644 database/migrations/2026_06_01_000005_create_short_url_daily_stats_table.php create mode 100644 resources/views/password-prompt.blade.php create mode 100644 resources/views/warning.blade.php create mode 100644 src/Console/Commands/AggregateAndPruneVisitsCommand.php create mode 100644 src/Jobs/IncrementVisitJob.php create mode 100644 src/Models/ShortUrlDailyStats.php diff --git a/README.md b/README.md index 09df372..bdbe971 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Tests

-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.password_title') ?? 'Password Protected' }} + @vite(['resources/css/app.css', 'resources/js/app.jsx']) + + + +
+
+
+ + + +
+

+ {{ __('filament-short-url::default.password_title') ?? 'Password Protected' }} +

+

+ {{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }} +

+
+ +
+ @csrf +
+ + + @if ($errors->has('password')) +

+ {{ $errors->first('password') }} +

+ @endif +
+ + +
+
+ + diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php index dedf8a8..3f61e36 100644 --- a/resources/views/stats.blade.php +++ b/resources/views/stats.blade.php @@ -19,19 +19,15 @@ @php $dateFrom = $this->filterData['date_from'] ?? null; $dateTo = $this->filterData['date_to'] ?? null; + $isCustom = ($this->filterData['preset'] ?? '') === 'custom'; @endphp
-
-
-

- {{ __('filament-short-url::default.stats_filter_date_range') }} -

-
-
- {{ $this->form }} -
-
+
+
+
+ {{ $this->form }} +
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 }} +

+
+ +
+ + {{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }} + + + +
+
+ + 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 --}}
@@ -146,6 +152,32 @@
+ {{-- 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'], ]); } }