Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5437dd0a6 | ||
|
|
65a1b126df | ||
|
|
f0d76ff03b | ||
|
|
eda4794cac |
275
README.md
275
README.md
@@ -13,7 +13,7 @@
|
||||
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/badge/tests-passing-success.svg?style=flat-square" alt="Tests"></a>
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -80,11 +85,47 @@ Copies the dashboard components, charts, and QR designer templates to `resources
|
||||
php artisan vendor:publish --tag=filament-short-url-views
|
||||
```
|
||||
|
||||
### 4. Publish All Assets
|
||||
### 4. Publish CSS Assets
|
||||
|
||||
The plugin ships with a pre-compiled stylesheet. Copy it to your application's public directory so the browser can load it:
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --provider="Bjanczak\FilamentShortUrl\FilamentShortUrlServiceProvider"
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
That's all. The plugin's CSS will be served from `public/css/janczakb/filament-short-url/filament-short-url.css` and Filament registers it automatically.
|
||||
|
||||
> **You do not need to run Tailwind, Vite, or npm for the plugin styles.** The compiled file is included in the package.
|
||||
|
||||
**Tip — automate on every `composer install` / `composer update`:**
|
||||
|
||||
Add `filament:assets` to the `post-autoload-dump` scripts in your application's `composer.json` so the assets are always up to date without manual steps:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
"@php artisan filament:assets"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> #### 🛠 For Plugin Developers Only
|
||||
>
|
||||
> If you are modifying the plugin source and need to recompile its stylesheet after changing Blade/PHP files:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. Recompile the plugin CSS with Tailwind v4
|
||||
> npx @tailwindcss/cli -i ./packages/filament-short-url/resources/css/plugin.css \
|
||||
> -o ./packages/filament-short-url/resources/dist/filament-short-url.css --minify
|
||||
>
|
||||
> # 2. Re-publish the compiled asset to public/
|
||||
> php artisan filament:assets
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
@@ -121,7 +162,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 +170,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 +198,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)
|
||||
@@ -168,13 +356,49 @@ You can also pre-configure all parameters via your `.env` file:
|
||||
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
|
||||
| `SHORT_URL_GEO_IP` | `geo_ip.enabled` | `true` | Globally enable/disable Geo-IP tracking. |
|
||||
| `SHORT_URL_GEO_IP_DRIVER` | `geo_ip.driver` | `'headers'` | Geo-IP resolver driver (`headers`, `maxmind`, `ip-api`). |
|
||||
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `database_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
|
||||
| `SHORT_URL_MAXMIND_DB` | `geo_ip.maxmind.database_path` | `storage_path('geoip/GeoLite2-Country.mmdb')` | Path to local MaxMind db. |
|
||||
| `SHORT_URL_STATS_CACHE_TTL` | `geo_ip.stats_cache_ttl` | `300` | Caching TTL in seconds for dashboard charts. |
|
||||
| `SHORT_URL_QUEUE` | `queue_connection` | `'sync'` | Queue connection for recording visits. |
|
||||
| `SHORT_URL_CACHE_TTL` | `cache_ttl` | `3600` | Redirection model caching TTL (set to `0` to disable). |
|
||||
| `GA4_API_SECRET` | `ga4.api_secret` | `null` | Google Analytics 4 Measurement Protocol API Secret. |
|
||||
| `FIREBASE_APP_ID` | `ga4.firebase_app_id` | `null` | Google Analytics 4 Firebase App ID (or uses Measurement ID). |
|
||||
| `SHORT_URL_COUNTER_BUFFERING` | `counter_buffering.enabled` | `false` | Buffer click counts in cache (flushed via console command). |
|
||||
| `SHORT_URL_TRUST_CDN_HEADERS` | `trust_cdn_headers` | `false` | Trust proxy/CDN headers to extract real client IP and country. |
|
||||
| `SHORT_URL_PRUNING_ENABLED` | `pruning.enabled` | `true` | Enable daily aggregation and log pruning. |
|
||||
| `SHORT_URL_PRUNING_DAYS` | `pruning.retention_days` | `90` | Number of days to retain raw visit logs. |
|
||||
| `SHORT_URL_RATE_LIMITING` | `rate_limiting.enabled` | `false` | Enable per-IP redirect rate limiting. |
|
||||
| `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`). |
|
||||
|
||||
### Automatic Scheduler Registration (v1.3.0)
|
||||
|
||||
As of version `1.3.0`, **you no longer need to manually copy scheduled commands into your host application code!** The plugin automatically registers the required tasks inside its ServiceProvider booted phase, dynamically respecting your Settings GUI toggles:
|
||||
|
||||
- **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON).
|
||||
- **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON).
|
||||
|
||||
If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions):
|
||||
|
||||
```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 +449,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 +489,38 @@ 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.3.0 (Latest)
|
||||
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
|
||||
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
|
||||
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.
|
||||
- **Filament v5 Notification API** — Seamless integration of the new `FilamentNotification` class API inside client-side JS.
|
||||
- **Asset Compilation Guide** — Explains Tailwind CLI CSS compilation and Filament asset publishing workflows for package extensions.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -46,7 +46,7 @@ return [
|
||||
'driver' => env('SHORT_URL_GEO_IP_DRIVER', 'headers'), // 'headers', 'maxmind', 'ip-api'
|
||||
'timeout' => 3, // seconds to wait for geo-ip response
|
||||
'maxmind' => [
|
||||
'database_path' => env('SHORT_URL_MAXMIND_DB', database_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'database_path' => env('SHORT_URL_MAXMIND_DB', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
],
|
||||
'stats_cache_ttl' => env('SHORT_URL_STATS_CACHE_TTL', 300), // 5 minutes caching for stats page calculations
|
||||
],
|
||||
@@ -157,6 +157,33 @@ return [
|
||||
*/
|
||||
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Data Pruning & Aggregation
|
||||
|--------------------------------------------------------------------------
|
||||
| To keep the database clean and fast, raw visit logs can be aggregated
|
||||
| into daily statistics and pruned after a retention period.
|
||||
|
|
||||
*/
|
||||
'pruning' => [
|
||||
'enabled' => env('SHORT_URL_PRUNING_ENABLED', true),
|
||||
'retention_days' => env('SHORT_URL_PRUNING_DAYS', 90),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
| Protect redirect routes from bot abuse and brute force by enabling
|
||||
| rate limiting.
|
||||
|
|
||||
*/
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('SHORT_URL_RATE_LIMITING', false),
|
||||
'max_attempts' => env('SHORT_URL_RATE_LIMIT_MAX', 60),
|
||||
'decay_seconds' => env('SHORT_URL_RATE_LIMIT_DECAY', 60),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Route Middleware
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_daily_stats', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
17
resources/css/plugin.css
Normal file
17
resources/css/plugin.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.short-url-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1.25rem !important;
|
||||
right: 1.25rem !important;
|
||||
z-index: 10 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
Normal file
2
resources/dist/filament-short-url.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Short URLs',
|
||||
'navigation_label' => 'Short Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'resource_title' => 'Short URL',
|
||||
|
||||
@@ -106,6 +106,13 @@ return [
|
||||
'action_stats' => 'Statistics',
|
||||
'action_copy' => 'Copy URL',
|
||||
'action_qr' => 'QR Code',
|
||||
'action_edit' => 'Edit URL',
|
||||
'action_delete' => 'Delete URL',
|
||||
'action_share' => 'Share Link',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
'share_copied' => 'Short link copied to clipboard!',
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statistics',
|
||||
@@ -175,11 +182,20 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maximum seconds to wait for an external Geo-IP API response before giving up.',
|
||||
'settings_maxmind_path' => 'MaxMind Database Path',
|
||||
'settings_maxmind_path_helper' => 'Absolute path to your GeoLite2-Country.mmdb or GeoIP2-Country.mmdb file. Required when driver is set to MaxMind.',
|
||||
'settings_maxmind_verify' => 'Verify file',
|
||||
'settings_maxmind_verify_ok' => '✅ File found & readable',
|
||||
'settings_maxmind_verify_fail' => '❌ File not found or not readable',
|
||||
'settings_maxmind_verify_empty' => 'Please enter a path first.',
|
||||
|
||||
'settings_ga4_api_secret' => 'Measurement Protocol API Secret',
|
||||
'settings_ga4_api_secret_helper' => 'Generate this in GA4 → Admin → Data Streams → your stream → Measurement Protocol API secrets.',
|
||||
'settings_ga4_firebase_app_id' => 'Firebase App ID (optional)',
|
||||
'settings_ga4_firebase_app_id_helper' => 'Required only if tracking a Firebase / app stream. Leave empty for standard web GA4 streams.',
|
||||
'settings_ga4_verify' => 'Test connection',
|
||||
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
|
||||
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
|
||||
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
|
||||
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
|
||||
|
||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
||||
@@ -227,4 +243,59 @@ 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' => 'Raw Log Retention Period',
|
||||
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
|
||||
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
|
||||
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
|
||||
'retention_30_days' => '30 Days',
|
||||
'retention_60_days' => '60 Days',
|
||||
'retention_90_days' => '90 Days',
|
||||
'retention_180_days' => '180 Days (Half Year)',
|
||||
'retention_365_days' => '365 Days (1 Year)',
|
||||
'retention_730_days' => '2 Years',
|
||||
'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',
|
||||
];
|
||||
|
||||
@@ -106,6 +106,13 @@ return [
|
||||
'action_stats' => 'Statystyki',
|
||||
'action_copy' => 'Kopiuj link',
|
||||
'action_qr' => 'Kod QR',
|
||||
'action_edit' => 'Edytuj URL',
|
||||
'action_delete' => 'Usuń URL',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
'share_copied' => 'Krótki link skopiowany do schowka!',
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statystyki',
|
||||
@@ -176,11 +183,20 @@ return [
|
||||
'settings_geoip_timeout_helper' => 'Maksymalna liczba sekund oczekiwania na odpowiedź zewnętrznego API Geo-IP.',
|
||||
'settings_maxmind_path' => 'Ścieżka bazy MaxMind',
|
||||
'settings_maxmind_path_helper' => 'Bezwzględna ścieżka do pliku GeoLite2-Country.mmdb lub GeoIP2-Country.mmdb. Wymagana gdy sterownik to MaxMind.',
|
||||
'settings_maxmind_verify' => 'Weryfikuj plik',
|
||||
'settings_maxmind_verify_ok' => '✅ Plik znaleziony i dostępny',
|
||||
'settings_maxmind_verify_fail' => '❌ Plik nie istnieje lub jest niedostępny',
|
||||
'settings_maxmind_verify_empty' => 'Najpierw podaj ścieżkę do pliku.',
|
||||
|
||||
'settings_ga4_api_secret' => 'Klucz API Measurement Protocol',
|
||||
'settings_ga4_api_secret_helper' => 'Wygeneruj w GA4 → Admin → Strumienie danych → twój strumień → Klucze API Measurement Protocol.',
|
||||
'settings_ga4_firebase_app_id' => 'Firebase App ID (opcjonalne)',
|
||||
'settings_ga4_firebase_app_id_helper' => 'Wymagane tylko przy śledzeniu strumienia Firebase/aplikacji. Zostaw puste dla standardowych strumieni GA4.',
|
||||
'settings_ga4_verify' => 'Testuj połączenie',
|
||||
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
|
||||
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
|
||||
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
|
||||
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
|
||||
|
||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
||||
@@ -228,4 +244,59 @@ 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' => 'Czas retencji surowych logów',
|
||||
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
|
||||
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
|
||||
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
|
||||
'retention_30_days' => '30 dni',
|
||||
'retention_60_days' => '60 dni',
|
||||
'retention_90_days' => '90 dni',
|
||||
'retention_180_days' => '180 dni (pół roku)',
|
||||
'retention_365_days' => '365 dni (rok)',
|
||||
'retention_730_days' => '2 lata',
|
||||
'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óć',
|
||||
];
|
||||
|
||||
101
resources/views/password-prompt.blade.php
Normal file
101
resources/views/password-prompt.blade.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Sign-in Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.password_title') ?? 'Password Protected' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('filament-short-url::default.password_description') ?? 'This link is password-protected. Please enter the correct password to continue.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="w-full flex flex-col gap-4">
|
||||
@csrf
|
||||
|
||||
{{-- Password input styled exactly like Custom Login --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex flex-col justify-center px-3 py-2 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 focus-within:ring-2 focus-within:ring-neutral-900 dark:focus-within:ring-white transition duration-200">
|
||||
<label for="password" class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
{{ __('filament-short-url::default.password_placeholder') ?? 'Password' }}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autofocus
|
||||
class="w-full bg-transparent text-sm text-neutral-900 dark:text-white placeholder-neutral-300 dark:placeholder-neutral-700 focus:outline-none py-0.5 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
@if ($errors->has('password'))
|
||||
<span class="text-xs text-red-500 px-1 mt-0.5">{{ $errors->first('password') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Submit button --}}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full mt-2 py-3.5 rounded-xl bg-neutral-900 hover:bg-neutral-800 dark:bg-white dark:hover:bg-neutral-200 dark:text-neutral-900 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2"
|
||||
>
|
||||
<span>{{ __('filament-short-url::default.password_btn_unlock') ?? 'Unlock & Redirect' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,19 +19,15 @@
|
||||
@php
|
||||
$dateFrom = $this->filterData['date_from'] ?? null;
|
||||
$dateTo = $this->filterData['date_to'] ?? null;
|
||||
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="p-6 bg-white rounded-xl border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_filter_date_range') }}
|
||||
</h3>
|
||||
<div class="flex-1 max-w-lg">
|
||||
<form class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex justify-end items-center">
|
||||
<div class="w-full {{ $isCustom ? 'max-w-xl' : 'max-w-[200px]' }}">
|
||||
<form>
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
113
resources/views/warning.blade.php
Normal file
113
resources/views/warning.blade.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('filament-short-url::default.warning_title') ?? 'Redirect Warning' }}</title>
|
||||
|
||||
<!-- Premium Google Fonts: Bricolage Grotesque -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Bricolage Grotesque', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect system dark mode preferences
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
|
||||
@php
|
||||
$logoPath = function_exists('setting') ? setting('logo_path') : null;
|
||||
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
|
||||
$siteName = config('app.name', 'Laravel');
|
||||
@endphp
|
||||
|
||||
{{-- Main Warning Box --}}
|
||||
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
|
||||
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
|
||||
@if ($logoUrl)
|
||||
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
|
||||
@else
|
||||
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
|
||||
@endif
|
||||
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
|
||||
{{ __('filament-short-url::default.warning_title') ?? 'Security Redirect Warning' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
|
||||
{{ __('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:' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
{{-- Destination URL Container styled exactly like Custom Login input box --}}
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="relative flex items-center justify-between px-3 py-3 w-full rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 transition duration-200">
|
||||
<div class="flex-grow flex flex-col justify-center min-w-0 pr-4">
|
||||
<span class="text-[10px] font-bold text-neutral-400 dark:text-neutral-500 uppercase tracking-wide select-none">
|
||||
Destination URL
|
||||
</span>
|
||||
<p id="dest-url-text" class="break-all text-xs font-mono font-semibold text-rose-600 dark:text-rose-400 mt-1 select-all">
|
||||
{{ $destinationUrl }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Copy Button -->
|
||||
<button onclick="
|
||||
const text = document.getElementById('dest-url-text').innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = this;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = `<svg class='h-4 w-4 text-emerald-500' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='3.5'><path stroke-linecap='round' stroke-linejoin='round' d='M5 13l4 4L19 7' /></svg>`;
|
||||
setTimeout(() => { btn.innerHTML = origHtml; }, 2000);
|
||||
" class="flex-shrink-0 p-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors focus:outline-none" title="Copy URL">
|
||||
<svg class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="flex flex-col gap-3 w-full mt-2">
|
||||
<!-- Continue Button (Primary Accent) -->
|
||||
<a href="{{ request()->fullUrlWithQuery(['confirmed' => 1]) }}"
|
||||
class="w-full py-3.5 rounded-xl bg-rose-600 hover:bg-rose-500 dark:bg-rose-500 dark:hover:bg-rose-400 text-white font-semibold text-sm transition duration-200 shadow-sm flex justify-center items-center gap-2 text-center">
|
||||
<span>{{ __('filament-short-url::default.warning_btn_continue') ?? 'Continue to Destination' }}</span>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Go Back Button (Secondary) -->
|
||||
<button onclick="window.history.back()"
|
||||
class="w-full py-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white font-semibold text-sm transition duration-200 flex justify-center items-center gap-2 shadow-sm">
|
||||
{{ __('filament-short-url::default.warning_btn_back') ?? 'Go Back' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer section --}}
|
||||
<div class="flex flex-col items-center gap-2 mt-auto select-none">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,7 +28,8 @@
|
||||
|
||||
<x-filament-widgets::widget>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{{-- Row 1: Devices, Browsers, Operating Systems --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
|
||||
{{-- Devices --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
@@ -113,6 +114,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Row 2: Referrers, Cities --}}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
|
||||
{{-- Referers --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
@@ -146,6 +152,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cities --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- UTM Campaigns Section --}}
|
||||
|
||||
@@ -27,31 +27,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cities --}}
|
||||
<div class="fi-wi-stats-breakdown-card rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900/50">
|
||||
<div class="mb-4 flex items-center gap-3 border-b border-gray-100 pb-3 dark:border-gray-800">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-500 dark:bg-emerald-950/50 dark:text-emerald-400">
|
||||
<x-filament::icon icon="heroicon-o-map-pin" class="h-5 w-5" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_cities') }}</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@forelse ($visitsByCity as $city => $count)
|
||||
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
|
||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div class="h-full rounded-full bg-emerald-500 transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_city_data') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
134
src/Console/Commands/AggregateAndPruneVisitsCommand.php
Normal file
134
src/Console/Commands/AggregateAndPruneVisitsCommand.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AggregateAndPruneVisitsCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'short-url:aggregate-and-prune';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Aggregate past days visits into daily stats and prune old raw visit logs';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$today = Carbon::today()->toDateString();
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$dateExpression = match ($driver) {
|
||||
'pgsql' => 'visited_at::date',
|
||||
'sqlsrv' => 'CAST(visited_at AS DATE)',
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
|
||||
$dates = ShortUrlVisit::where('visited_at', '<', $today)
|
||||
->selectRaw("{$dateExpression} 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) {
|
||||
// Accumulate stats per short_url_id using chunked reads — avoids loading
|
||||
// potentially millions of rows into PHP memory at once.
|
||||
$statsByUrl = [];
|
||||
|
||||
ShortUrlVisit::whereBetween('visited_at', [$date.' 00:00:00', $date.' 23:59:59'])
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
|
||||
$inc = function (?string $value, string $key) use (&$s): void {
|
||||
if ($value) {
|
||||
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
$inc($visit->device_type, 'device_stats');
|
||||
$inc($visit->browser, 'browser_stats');
|
||||
$inc($visit->operating_system, 'os_stats');
|
||||
$inc($visit->country, 'country_stats');
|
||||
$inc($visit->utm_source, 'utm_source_stats');
|
||||
$inc($visit->utm_medium, 'utm_medium_stats');
|
||||
$inc($visit->utm_campaign, 'utm_campaign_stats');
|
||||
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($visit->referer_host) {
|
||||
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => count($s['ip_hashes']),
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
'country_stats' => $s['country_stats'],
|
||||
'city_stats' => $s['city_stats'],
|
||||
'referer_stats' => $s['referer_stats'],
|
||||
'utm_source_stats' => $s['utm_source_stats'],
|
||||
'utm_medium_stats' => $s['utm_medium_stats'],
|
||||
'utm_campaign_stats' => $s['utm_campaign_stats'],
|
||||
]);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
@@ -40,6 +41,8 @@ class ListShortUrls extends ManageRecords
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class ShortUrlSettingsPage extends Page implements HasForms
|
||||
{
|
||||
@@ -41,12 +43,17 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'geo_ip_driver' => $mgr->get('geo_ip_driver', 'headers'),
|
||||
'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400),
|
||||
'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3),
|
||||
'maxmind_database_path' => $mgr->get('maxmind_database_path', database_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
||||
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
|
||||
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -182,7 +189,39 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->label(__('filament-short-url::default.settings_maxmind_path'))
|
||||
->helperText(__('filament-short-url::default.settings_maxmind_path_helper'))
|
||||
->columnSpanFull()
|
||||
->placeholder(database_path('geoip/GeoLite2-Country.mmdb'))
|
||||
->placeholder('/var/www/html/database/geoip/GeoLite2-Country.mmdb')
|
||||
->suffixAction(
|
||||
Action::make('verifyMaxmindPath')
|
||||
->label(__('filament-short-url::default.settings_maxmind_verify'))
|
||||
->icon('heroicon-o-server')
|
||||
->action(function (?string $state): void {
|
||||
$path = trim($state ?? '');
|
||||
|
||||
if (empty($path)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($path) && is_readable($path) && str_ends_with($path, '.mmdb')) {
|
||||
$sizeKb = round(filesize($path) / 1024);
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_ok'))
|
||||
->body("{$path} ({$sizeKb} KB)")
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_maxmind_verify_fail'))
|
||||
->body($path)
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
)
|
||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') && $get('geo_ip_driver') === 'maxmind'),
|
||||
]),
|
||||
]),
|
||||
@@ -199,15 +238,134 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->helperText(__('filament-short-url::default.settings_ga4_api_secret_helper'))
|
||||
->password()
|
||||
->revealable()
|
||||
->live()
|
||||
->placeholder('••••••••••••••••••••'),
|
||||
|
||||
// Firebase App ID only when API Secret is set
|
||||
// Firebase App ID — always visible
|
||||
TextInput::make('ga4_firebase_app_id')
|
||||
->label(__('filament-short-url::default.settings_ga4_firebase_app_id'))
|
||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||
->placeholder('1:1234567890:android:abcdef123456')
|
||||
->visible(fn (Get $get): bool => filled($get('ga4_api_secret'))),
|
||||
->placeholder('1:1234567890:android:abcdef123456'),
|
||||
|
||||
// GA4 connection verify button
|
||||
Actions::make([
|
||||
Action::make('verifyGa4ApiSecret')
|
||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||
->icon('heroicon-o-signal')
|
||||
->color('gray')
|
||||
->action(function (Get $get): void {
|
||||
$secret = trim($get('ga4_api_secret') ?? '');
|
||||
|
||||
if (empty($secret)) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// GA4 debug endpoint — a valid api_secret returns event-level
|
||||
// validation messages; an invalid one returns auth errors.
|
||||
$response = Http::timeout(5)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post(
|
||||
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
|
||||
[
|
||||
'client_id' => 'short-url-plugin-verify',
|
||||
'events' => [
|
||||
['name' => 'page_view', 'params' => []],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$body = $response->json();
|
||||
$messages = $body['validationMessages'] ?? [];
|
||||
|
||||
// A valid secret returns event-level messages, not auth errors
|
||||
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
|
||||
strtolower($m['description'] ?? ''),
|
||||
'api_secret'
|
||||
));
|
||||
|
||||
if ($hasAuthError || $response->status() === 401) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||
->danger()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ── 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),
|
||||
|
||||
Select::make('pruning_retention_days')
|
||||
->label(__('filament-short-url::default.settings_retention_days'))
|
||||
->helperText(__('filament-short-url::default.settings_retention_days_helper'))
|
||||
->options([
|
||||
30 => __('filament-short-url::default.retention_30_days'),
|
||||
60 => __('filament-short-url::default.retention_60_days'),
|
||||
90 => __('filament-short-url::default.retention_90_days'),
|
||||
180 => __('filament-short-url::default.retention_180_days'),
|
||||
365 => __('filament-short-url::default.retention_365_days'),
|
||||
730 => __('filament-short-url::default.retention_730_days'),
|
||||
])
|
||||
->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')),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -118,8 +118,8 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
return $query
|
||||
->when($data['visited_from'], fn ($q, $date) => $q->whereDate('visited_at', '>=', $date))
|
||||
->when($data['visited_until'], fn ($q, $date) => $q->whereDate('visited_at', '<=', $date));
|
||||
->when($data['visited_from'], fn ($q, $date) => $q->where('visited_at', '>=', $date.' 00:00:00'))
|
||||
->when($data['visited_until'], fn ($q, $date) => $q->where('visited_at', '<=', $date.' 23:59:59'));
|
||||
}),
|
||||
])
|
||||
->headerActions([
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.mobile')
|
||||
->label(__('filament-short-url::default.device_mobile'))
|
||||
->url()
|
||||
->nullable()
|
||||
->maxLength(2048),
|
||||
TextInput::make('targeting_rules.device.tablet')
|
||||
->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'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Forms;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ShortUrlsTable
|
||||
{
|
||||
@@ -23,60 +24,147 @@ class ShortUrlsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(fn (ShortUrl $record): string => $record->getShortUrl())
|
||||
->copyable()
|
||||
->copyMessage(__('filament-short-url::default.qr_copied'))
|
||||
->fontFamily('mono')
|
||||
->weight(FontWeight::SemiBold)
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
Stack::make([
|
||||
// ROW 1: Favicon + Short URL with copy capability on click of text or icon (vertically centered!)
|
||||
TextColumn::make('url_key')
|
||||
->label(__('filament-short-url::default.col_short_url'))
|
||||
->getStateUsing(static function (ShortUrl $record): HtmlString {
|
||||
$shortUrl = e($record->getShortUrl());
|
||||
$copiedMsg = e(__('filament-short-url::default.qr_copied'));
|
||||
$tooltipCopy = e(__('filament-short-url::default.action_copy'));
|
||||
$destHost = e(parse_url($record->destination_url, PHP_URL_HOST));
|
||||
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->limit(45)
|
||||
->tooltip(fn (ShortUrl $record): string => $record->destination_url)
|
||||
->url(fn (ShortUrl $record): string => $record->destination_url, shouldOpenInNewTab: true)
|
||||
->searchable(),
|
||||
return new HtmlString(<<<HTML
|
||||
<div onclick="
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = '{$shortUrl}';
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textCopyArea = document.createElement('textarea');
|
||||
textCopyArea.value = text;
|
||||
textCopyArea.style.position = 'fixed';
|
||||
textCopyArea.style.left = '-999999px';
|
||||
textCopyArea.style.top = '-999999px';
|
||||
document.body.appendChild(textCopyArea);
|
||||
textCopyArea.focus();
|
||||
textCopyArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (err) {}
|
||||
textCopyArea.remove();
|
||||
}
|
||||
if (typeof FilamentNotification !== 'undefined') {
|
||||
new FilamentNotification()
|
||||
.title('{$copiedMsg}')
|
||||
.success()
|
||||
.send();
|
||||
} else if (window.Alpine) {
|
||||
window.Alpine.store('filament-notifications')?.send({
|
||||
status: 'success',
|
||||
title: '{$copiedMsg}'
|
||||
});
|
||||
}
|
||||
" class="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<div class="flex items-center justify-center rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-1.5 flex-shrink-0 w-8 h-8">
|
||||
<img src="https://icons.duckduckgo.com/ip2/{$destHost}.ico"
|
||||
class="w-full h-full object-contain"
|
||||
onerror="this.src='https://heroicons.com/24/outline/link.svg'" />
|
||||
</div>
|
||||
<span class="text-[#2a5bd7] text-[16px] font-bold leading-6 break-all line-clamp-1">
|
||||
{$shortUrl}
|
||||
</span>
|
||||
<span title="{$tooltipCopy}" class="w-8 h-8 rounded-full flex items-center justify-center bg-[#f4f4f5] hover:bg-[#e4e4e7] dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors flex-shrink-0 focus:outline-none">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
HTML
|
||||
);
|
||||
})
|
||||
->searchable(query: fn ($query, string $search) => $query->where('url_key', 'like', "%{$search}%")),
|
||||
|
||||
TextColumn::make('total_visits')
|
||||
->label(__('filament-short-url::default.col_total_visits'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
// ROW 2: Sub-arrow + Destination URL
|
||||
TextColumn::make('destination_url')
|
||||
->label(__('filament-short-url::default.col_destination_url'))
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 text-xs mt-2">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
style="transform: scaleY(-1);"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 00-6 6v3" />
|
||||
</svg>
|
||||
<a href="'.e($record->destination_url).'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate max-w-[50ch] hover:underline text-[#273144] text-[15px] leading-[16px] font-medium"
|
||||
title="'.e($record->destination_url).'">
|
||||
'.e($record->destination_url).'
|
||||
</a>
|
||||
</div>
|
||||
'))
|
||||
->searchable(),
|
||||
|
||||
TextColumn::make('unique_visits')
|
||||
->label(__('filament-short-url::default.stats_card_unique'))
|
||||
->numeric()
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable(),
|
||||
// ROW 3: Bottom Metadata (Total clicks / Unique clicks / Date added / Expiry / Redirect code)
|
||||
TextColumn::make('metadata_badges')
|
||||
->getStateUsing(fn ($record): HtmlString => new HtmlString('
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
<!-- Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
'.$record->total_visits.' clicks
|
||||
</span>
|
||||
|
||||
ToggleColumn::make('is_enabled')
|
||||
->label(__('filament-short-url::default.col_status'))
|
||||
->sortable(),
|
||||
<!-- Unique Clicks Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
'.$record->unique_visits.' unique
|
||||
</span>
|
||||
|
||||
IconColumn::make('track_visits')
|
||||
->label(__('filament-short-url::default.track_visits'))
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-signal')
|
||||
->falseIcon('heroicon-o-signal-slash')
|
||||
->trueColor('success')
|
||||
->falseColor('gray'),
|
||||
<!-- Date Added Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
'.$record->created_at->format('M d, Y').'
|
||||
</span>
|
||||
|
||||
TextColumn::make('expires_at')
|
||||
->label(__('filament-short-url::default.col_expires_at'))
|
||||
->dateTime('d M Y')
|
||||
->placeholder('Never')
|
||||
->color(fn (ShortUrl $record): string => $record->isExpired() ? 'danger' : 'gray')
|
||||
->sortable(),
|
||||
<!-- Expiry / Single Use Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
'.($record->expires_at ? '
|
||||
<svg class="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Expires: '.$record->expires_at->format('M d, Y').'
|
||||
' : '
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
No expiry
|
||||
').'
|
||||
</span>
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('filament-short-url::default.col_created_at'))
|
||||
->since()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
<!-- Redirect Type Badge -->
|
||||
<span class="inline-flex items-center gap-1 bg-[#f4f4f5] dark:bg-gray-800 px-2 py-1 rounded text-[11px] font-medium text-gray-600 dark:text-gray-300">
|
||||
<svg class="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
'.$record->redirect_status_code.' redirect
|
||||
</span>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_enabled')
|
||||
@@ -96,18 +184,139 @@ class ShortUrlsTable
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
->recordClasses(fn (): string => 'short-url-card')
|
||||
->actions([
|
||||
Action::make('share')
|
||||
->label(__('filament-short-url::default.action_share'))
|
||||
->icon('heroicon-o-share')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_share'))
|
||||
->modalHeading(__('filament-short-url::default.share_title'))
|
||||
->modalWidth('md')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelAction(false)
|
||||
->form([
|
||||
Forms\Components\Placeholder::make('platforms')
|
||||
->label(__('filament-short-url::default.share_description'))
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-6 overflow-x-auto pb-4 pt-1 scroll-smooth" style="scrollbar-width: thin; -ms-overflow-style: none;">
|
||||
<!-- Messenger -->
|
||||
<a href="fb-messenger://share/?link='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-tr from-[#006aff] via-[#00b2ff] to-[#00d6ff] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.14 2 11.25c0 2.9 1.45 5.48 3.73 7.08v3.67c0 .24.23.4.43.27l4.07-2.3c.57.16 1.17.25 1.77.25 5.52 0 10-4.14 10-9.25S17.52 2 12 2zm1.09 11.95l-2.43-2.6-4.73 2.6 5.19-5.52 2.47 2.63 4.7-2.63-5.2 5.52z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Messenger</span>
|
||||
</a>
|
||||
<!-- Facebook -->
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#1877f2] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-550 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Facebook</span>
|
||||
</a>
|
||||
<!-- WhatsApp -->
|
||||
<a href="https://api.whatsapp.com/send?text='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#25d366] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L0 24l6.335-1.662c1.746.953 3.71 1.458 5.704 1.459h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">WhatsApp</span>
|
||||
</a>
|
||||
<!-- Twitter/X -->
|
||||
<a href="https://twitter.com/intent/tweet?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0f1419] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Twitter (X)</span>
|
||||
</a>
|
||||
<!-- LinkedIn -->
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#0077b5] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.779-1.75-1.75s.784-1.75 1.75-1.75 1.75.779 1.75 1.75-.784 1.75-1.75 1.75zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">LinkedIn</span>
|
||||
</a>
|
||||
<!-- Email -->
|
||||
<a href="mailto:?body='.urlencode($record->getShortUrl()).'" target="_blank" rel="noopener noreferrer" class="flex flex-col items-center gap-2 group flex-shrink-0 cursor-pointer">
|
||||
<div class="w-11 h-11 rounded-full bg-[#6b7280] text-white flex items-center justify-center shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||
<svg class="w-5.5 h-5.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[11px] font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">Email</span>
|
||||
</a>
|
||||
</div>
|
||||
')),
|
||||
|
||||
Forms\Components\Placeholder::make('copy_field')
|
||||
->label('')
|
||||
->content(fn (ShortUrl $record): HtmlString => new HtmlString('
|
||||
<div class="flex items-center gap-2 relative mt-2">
|
||||
<input type="text"
|
||||
readonly
|
||||
value="'.e($record->getShortUrl()).'"
|
||||
id="share_link_input_'.$record->id.'"
|
||||
class="flex-1 min-w-0 block w-full px-3.5 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-900 dark:text-gray-100 text-sm focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 focus:outline-none">
|
||||
|
||||
<button onclick="
|
||||
const input = document.getElementById(\'share_link_input_'.$record->id.'\');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value);
|
||||
if (typeof FilamentNotification !== \'undefined\') {
|
||||
new FilamentNotification()
|
||||
.title(\''.e(__('filament-short-url::default.share_copied')).'\')
|
||||
.success()
|
||||
.send();
|
||||
} else if (typeof Alpine !== \'undefined\') {
|
||||
Alpine.store(\'filament-notifications\')?.send({
|
||||
status: \'success\',
|
||||
title: \''.e(__('filament-short-url::default.share_copied')).'\'
|
||||
});
|
||||
}
|
||||
"
|
||||
class="flex-shrink-0 inline-flex items-center justify-center gap-1.5 px-4 py-2.5 bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-white text-white dark:text-gray-950 font-semibold text-sm rounded-lg shadow-sm hover:shadow transition duration-200">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>'.e(__('filament-short-url::default.share_copy')).'</span>
|
||||
</button>
|
||||
</div>
|
||||
')),
|
||||
]),
|
||||
|
||||
Action::make('stats')
|
||||
->label(__('filament-short-url::default.action_stats'))
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->color('gray')
|
||||
->size('sm')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_stats'))
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->size('sm')
|
||||
->color('gray'),
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_edit')),
|
||||
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->tooltip(__('filament-short-url::default.action_delete')),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 1,
|
||||
'xl' => 1,
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShortUrlGlobalOverview extends BaseWidget
|
||||
{
|
||||
/** Disable automatic Livewire polling — cache is invalidated via model events instead. */
|
||||
protected ?string $pollingInterval = null;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/** Cache key for link counts (forever, busted on create/delete). */
|
||||
public const LINKS_CACHE_KEY = 'filament-short-url:global-links';
|
||||
|
||||
/** Cache key for click trends (short TTL, time-based data). */
|
||||
public const CLICKS_CACHE_KEY = 'filament-short-url:global-clicks';
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$linkData = $this->getLinkStats();
|
||||
$clickData = $this->getClickStats();
|
||||
|
||||
$clicksTrend = $clickData['prev7Clicks'] > 0
|
||||
? round((($clickData['last7Clicks'] - $clickData['prev7Clicks']) / $clickData['prev7Clicks']) * 100, 1)
|
||||
: ($clickData['last7Clicks'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
$uniquesTrend = $clickData['prev7Uniques'] > 0
|
||||
? round((($clickData['last7Uniques'] - $clickData['prev7Uniques']) / $clickData['prev7Uniques']) * 100, 1)
|
||||
: ($clickData['last7Uniques'] > 0 ? 100.0 : 0.0);
|
||||
|
||||
return [
|
||||
Stat::make(__('filament-short-url::default.navigation_label'), number_format($linkData['totalLinks']))
|
||||
->description($linkData['activeLinks'].' active · '.$linkData['activeRatio'].'%')
|
||||
->descriptionIcon('heroicon-m-link')
|
||||
->icon('heroicon-o-link')
|
||||
->color('gray'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($clickData['totalVisits']))
|
||||
->description(abs($clicksTrend).'% '.($clicksTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($clicksTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-cursor-arrow-rays')
|
||||
->color($clicksTrend >= 0 ? 'success' : 'danger'),
|
||||
|
||||
Stat::make(__('filament-short-url::default.stats_card_unique'), number_format($clickData['uniqueVisits']))
|
||||
->description(abs($uniquesTrend).'% '.($uniquesTrend >= 0 ? 'up' : 'down').' vs last 7 days')
|
||||
->descriptionIcon($uniquesTrend >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
|
||||
->icon('heroicon-o-user-group')
|
||||
->color($uniquesTrend >= 0 ? 'success' : 'danger'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Link counts cached forever — invalidated only when a ShortUrl is created or deleted.
|
||||
*
|
||||
* @return array{totalLinks: int, activeLinks: int, activeRatio: int}
|
||||
*/
|
||||
protected function getLinkStats(): array
|
||||
{
|
||||
return Cache::rememberForever(self::LINKS_CACHE_KEY, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('COUNT(*) as total'),
|
||||
DB::raw('SUM(CASE WHEN is_enabled = 1 THEN 1 ELSE 0 END) as active'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$total = (int) ($agg->total ?? 0);
|
||||
$active = (int) ($agg->active ?? 0);
|
||||
|
||||
return [
|
||||
'totalLinks' => $total,
|
||||
'activeLinks' => $active,
|
||||
'activeRatio' => $total > 0 ? round(($active / $total) * 100) : 0,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click/visit stats cached with a short TTL (time-based data changes frequently).
|
||||
*
|
||||
* @return array{totalVisits: int, uniqueVisits: int, last7Clicks: int, prev7Clicks: int, last7Uniques: int, prev7Uniques: int}
|
||||
*/
|
||||
protected function getClickStats(): array
|
||||
{
|
||||
$ttl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
|
||||
|
||||
return Cache::remember(self::CLICKS_CACHE_KEY, $ttl, function () {
|
||||
$agg = ShortUrl::query()
|
||||
->select([
|
||||
DB::raw('SUM(total_visits) as total_visits'),
|
||||
DB::raw('SUM(unique_visits) as unique_visits'),
|
||||
])
|
||||
->first();
|
||||
|
||||
$last7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))->count();
|
||||
$prev7Clicks = ShortUrlVisit::where('visited_at', '>=', now()->subDays(14))
|
||||
->where('visited_at', '<', now()->subDays(7))
|
||||
->count();
|
||||
|
||||
$last7Uniques = ShortUrlVisit::where('visited_at', '>=', now()->subDays(7))
|
||||
->distinct('ip_hash')
|
||||
->count('ip_hash');
|
||||
|
||||
$prev7Uniques = ShortUrlVisit::whereBetween('visited_at', [now()->subDays(14), now()->subDays(7)])
|
||||
->distinct('ip_hash')
|
||||
->count('ip_hash');
|
||||
|
||||
return [
|
||||
'totalVisits' => (int) ($agg->total_visits ?? 0),
|
||||
'uniqueVisits' => (int) ($agg->unique_visits ?? 0),
|
||||
'last7Clicks' => $last7Clicks,
|
||||
'prev7Clicks' => $prev7Clicks,
|
||||
'last7Uniques' => $last7Uniques,
|
||||
'prev7Uniques' => $prev7Uniques,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? [],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Filament\Support\Assets\Css;
|
||||
use Filament\Support\Facades\FilamentAsset;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
|
||||
@@ -26,8 +29,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']);
|
||||
}
|
||||
|
||||
@@ -42,4 +50,24 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
$this->app->singleton(ShortUrlService::class);
|
||||
$this->app->singleton(ShortUrlTracker::class);
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
{
|
||||
FilamentAsset::register([
|
||||
Css::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
||||
], package: 'janczakb/filament-short-url');
|
||||
|
||||
// Automatically register scheduled tasks in the application scheduler
|
||||
$this->app->booted(function (): void {
|
||||
$schedule = $this->app->make(Schedule::class);
|
||||
|
||||
if (config('filament-short-url.pruning.enabled', true)) {
|
||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
||||
}
|
||||
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ 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 Illuminate\Support\MessageBag;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ShortUrlRedirectController extends Controller
|
||||
{
|
||||
@@ -16,7 +18,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 +32,69 @@ 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);
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
|
||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
|
||||
'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 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 +137,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);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/Jobs/IncrementVisitJob.php
Normal file
43
src/Jobs/IncrementVisitJob.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Jobs;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncrementVisitJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $shortUrlId,
|
||||
public readonly bool $isUnique = false,
|
||||
) {
|
||||
$this->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')] : []
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\IncrementVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
||||
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
@@ -67,6 +73,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 +94,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 +108,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
|
||||
@@ -141,12 +157,18 @@ class ShortUrl extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Bust the cache when the model is saved or deleted.
|
||||
* Bust the redirect cache when the model is saved or deleted.
|
||||
* Also invalidate the global overview link-count cache (forever cache)
|
||||
* on creation or deletion — the count changes only in those cases.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
|
||||
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
|
||||
|
||||
// Bust the forever-cached link counts displayed in the global overview widget.
|
||||
static::created(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
static::deleted(fn () => cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY));
|
||||
}
|
||||
|
||||
/** @return Collection<int, static> */
|
||||
@@ -237,6 +259,72 @@ 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(Request $request): string
|
||||
{
|
||||
$rules = $this->targeting_rules;
|
||||
|
||||
if (empty($rules)) {
|
||||
return $this->destination_url;
|
||||
}
|
||||
|
||||
$type = $rules['type'] ?? 'none';
|
||||
|
||||
if ($type === 'device') {
|
||||
$parser = app(UserAgentParser::class);
|
||||
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
|
||||
|
||||
if ($deviceType === 'mobile') {
|
||||
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
|
||||
}
|
||||
if ($deviceType === 'tablet') {
|
||||
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
return $rules['device']['desktop'] ?? $this->destination_url;
|
||||
}
|
||||
|
||||
if ($type === 'geo') {
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
if (! $countryCode) {
|
||||
// Try resolving via GeoIpService
|
||||
$ip = ClientIpExtractor::getIp($request);
|
||||
$geo = app(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 +332,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 IncrementVisitJob($this->id, $isUnique)->onConnection($connection ?: 'sync'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newQuery()
|
||||
@@ -292,156 +375,295 @@ 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()->where('visited_at', '>=', $today.' 00:00:00')->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;
|
||||
}
|
||||
|
||||
$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();
|
||||
return $base;
|
||||
};
|
||||
|
||||
$daysDiff = (int) $chartFrom->diffInDays($chartTo);
|
||||
// Initialize metrics
|
||||
$totalVisits = 0;
|
||||
$uniqueVisitsCount = 0;
|
||||
$visitsToday = count($rawVisits);
|
||||
$visitsThisWeek = 0;
|
||||
$visitsThisMonth = 0;
|
||||
|
||||
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();
|
||||
$visitsByCountry = [];
|
||||
$visitsByCity = [];
|
||||
$visitsByDevice = [];
|
||||
$visitsByBrowser = [];
|
||||
$visitsByOs = [];
|
||||
$visitsByReferer = [];
|
||||
$utmSources = [];
|
||||
$utmMediums = [];
|
||||
$utmCampaigns = [];
|
||||
|
||||
$visitsByDay = [];
|
||||
for ($i = $daysDiff; $i >= 0; $i--) {
|
||||
$date = (clone $chartTo)->subDays($i)->format('Y-m-d');
|
||||
$visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
|
||||
// Sum up daily stats
|
||||
$startOfWeek = now()->startOfWeek()->toDateString();
|
||||
$startOfMonth = now()->startOfMonth()->toDateString();
|
||||
|
||||
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),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache properties to hold preloaded buffered visits for the current request.
|
||||
*/
|
||||
protected static ?array $bufferedTotalVisits = null;
|
||||
|
||||
protected static ?array $bufferedUniqueVisits = null;
|
||||
|
||||
/**
|
||||
* Preload all buffered clicks in a single batch query for the entire request.
|
||||
* Prevents N+1 database queries even if database cache driver is used.
|
||||
*/
|
||||
protected static function loadAllBufferedVisits(): void
|
||||
{
|
||||
if (static::$bufferedTotalVisits !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::$bufferedTotalVisits = [];
|
||||
static::$bufferedUniqueVisits = [];
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$dirtyKey = "{$prefix}dirty_ids";
|
||||
|
||||
// 1. Fetch the list of dirty IDs (URLs with pending buffered clicks) in one query
|
||||
$dirtyIds = [];
|
||||
try {
|
||||
if (cache()->getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
|
||||
$dirtyIds = Redis::smembers($dirtyKey);
|
||||
} else {
|
||||
$dirtyIds = cache()->get($dirtyKey, []);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
|
||||
if (empty($dirtyIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dirtyIds = array_unique(array_filter((array) $dirtyIds));
|
||||
|
||||
// 2. Build array of keys to fetch in a single cache store read
|
||||
$totalKeys = [];
|
||||
$uniqueKeys = [];
|
||||
foreach ($dirtyIds as $id) {
|
||||
$totalKeys[$id] = "{$prefix}total:{$id}";
|
||||
$uniqueKeys[$id] = "{$prefix}unique:{$id}";
|
||||
}
|
||||
|
||||
try {
|
||||
// Cache::many() is highly optimized (e.g. 1 database query for database store, or 1 MGET for Redis)
|
||||
$totals = cache()->many(array_values($totalKeys));
|
||||
$uniques = cache()->many(array_values($uniqueKeys));
|
||||
|
||||
foreach ($totalKeys as $id => $key) {
|
||||
static::$bufferedTotalVisits[$id] = (int) ($totals[$key] ?? 0);
|
||||
}
|
||||
foreach ($uniqueKeys as $id => $key) {
|
||||
static::$bufferedUniqueVisits[$id] = (int) ($uniques[$key] ?? 0);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getTotalVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['total_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedTotalVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unique visits count, merging the database value with any buffered clicks in cache.
|
||||
* Prevents database N+1 queries.
|
||||
*/
|
||||
public function getUniqueVisitsAttribute(): int
|
||||
{
|
||||
$dbValue = $this->attributes['unique_visits'] ?? 0;
|
||||
|
||||
if (! config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
return $dbValue;
|
||||
}
|
||||
|
||||
static::loadAllBufferedVisits();
|
||||
|
||||
$buffered = static::$bufferedUniqueVisits[$this->id] ?? 0;
|
||||
|
||||
return $dbValue + $buffered;
|
||||
}
|
||||
}
|
||||
|
||||
67
src/Models/ShortUrlDailyStats.php
Normal file
67
src/Models/ShortUrlDailyStats.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $short_url_id
|
||||
* @property Carbon $date
|
||||
* @property int $visits_count
|
||||
* @property int $unique_visits_count
|
||||
* @property array|null $device_stats
|
||||
* @property array|null $browser_stats
|
||||
* @property array|null $os_stats
|
||||
* @property array|null $country_stats
|
||||
* @property array|null $city_stats
|
||||
* @property array|null $referer_stats
|
||||
* @property array|null $utm_source_stats
|
||||
* @property array|null $utm_medium_stats
|
||||
* @property array|null $utm_campaign_stats
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class ShortUrlDailyStats extends Model
|
||||
{
|
||||
protected $table = 'short_url_daily_stats';
|
||||
|
||||
protected $fillable = [
|
||||
'short_url_id',
|
||||
'date',
|
||||
'visits_count',
|
||||
'unique_visits_count',
|
||||
'device_stats',
|
||||
'browser_stats',
|
||||
'os_stats',
|
||||
'country_stats',
|
||||
'city_stats',
|
||||
'referer_stats',
|
||||
'utm_source_stats',
|
||||
'utm_medium_stats',
|
||||
'utm_campaign_stats',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,15 @@ class ShortUrlVisit extends Model
|
||||
'operating_system_version',
|
||||
'device_type',
|
||||
'referer_url',
|
||||
'referer_host',
|
||||
'country',
|
||||
'country_code',
|
||||
'city',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'visited_at',
|
||||
];
|
||||
|
||||
|
||||
@@ -44,13 +44,18 @@ class ShortUrlSettingsManager
|
||||
'geo_ip_driver' => config('filament-short-url.geo_ip.driver', 'headers'),
|
||||
'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400),
|
||||
'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3),
|
||||
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', database_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
|
||||
'ga4_api_secret' => config('filament-short-url.ga4.api_secret'),
|
||||
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
|
||||
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
|
||||
'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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user