8 Commits

Author SHA1 Message Date
Bartłomiej Janczak
e5437dd0a6 Audit fixes: device targeting, query index optimizations, translation gaps, rate-limiting, and path consistency 2026-06-02 07:51:45 +02:00
Bartłomiej Janczak
65a1b126df feat: global overview widget, optimized cache, CSS asset docs
- Add ShortUrlGlobalOverview StatsOverviewWidget with split cache strategy:
  link counts cached forever (invalidated on create/delete), click stats
  with short TTL (geo_ip.stats_cache_ttl config)
- Fix visited_at column in ShortUrlVisit queries (was created_at)
- Disable Livewire polling (pollingInterval = null)
- Add cache invalidation for global overview in ShortUrl::booted()
- Rewrite README asset section: user-facing install instructions clarified,
  post-autoload-dump tip added, developer recompile notes separated
2026-06-01 18:59:17 +02:00
Bartłomiej Janczak
f0d76ff03b fix: memory-safe aggregation via chunking + allow 0 retention days
- Replace ->get() with ->chunk(1000) in AggregateAndPruneVisitsCommand
  to avoid loading entire day's visits into PHP RAM (critical at scale)
- Stats are now accumulated incrementally via a hash-keyed array,
  unique visits counted via ip_hash deduplication key
- Fix pruning_retention_days minValue(1) → minValue(0) in Settings form;
  0 was documented to disable pruning but form validation blocked it
2026-06-01 14:20:43 +02:00
Bartłomiej Janczak
eda4794cac feat: v1.2.0 — password protection, warning pages, smart targeting, rate limiting, daily stats aggregation
- Add password-protected links with session-based unlock flow
- Add redirect warning (interstitial) pages before external URLs
- Add smart targeting rules: device-based, country/geo, A/B weighted rotation
- Add configurable per-IP rate limiting with 429 + Retry-After headers
- Add daily stats aggregation & log pruning (short-url:aggregate-and-prune)
- Add IncrementVisitJob as queue-based counter buffering fallback
- Add ShortUrlDailyStats model with JSON stat columns per day
- Add two new migrations: targeting/security fields, daily_stats table
- Add password-prompt.blade.php and warning.blade.php views
- Extend Settings GUI with Performance & Security tab (aggregation + rate limiting)
- Extend ShortUrlForm with Targeting & Security section
- Add POST route for password form submission (was GET-only → 405)
- Replace enum(device_type) with string(20) for cross-DB compatibility
- Remove ->after() MySQL-only hints from ALTER TABLE migrations
- Fix aggregation test: use whereDate() instead of assertDatabaseHas for date column
- Extend en/pl translations for all new features
- Expand README.md with full v1.2.0 documentation (476 lines)
2026-06-01 13:54:13 +02:00
Bartłomiej Janczak
69f6cef31b feat: Add UTM parameters, interactive date range filter, cleaned referrer domains, and visitor city tracking to analytics 2026-06-01 13:24:02 +02:00
Bartłomiej Janczak
2ab85dd264 fix: Import ShortUrlResource in ShortUrlsTable to resolve class not found error 2026-06-01 13:10:01 +02:00
Bartłomiej Janczak
f518d4dae9 chore: Update license badge URL in README 2026-06-01 13:07:55 +02:00
Bartłomiej Janczak
bdbe9b2ffd v1.2.0: Added counter buffering (write-back caching), CDN trust headers toggle, full UI localization, dynamic Filament routes, and high-concurrency fixes 2026-06-01 13:05:48 +02:00
40 changed files with 3042 additions and 373 deletions

290
README.md
View File

@@ -6,14 +6,14 @@
<p align="center">
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/l/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
<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.
@@ -145,6 +186,165 @@ Sends server-side `short_url_visit` hits using the **GA4 Measurement Protocol AP
* **GA4 API Secret**: Create this secret in Google Analytics under `Admin -> Data Streams -> Measurement Protocol API secrets`.
* **Firebase App ID / Measurement ID**: The target analytics stream identifier.
### 4. Counter Buffering (Write-back Caching)
For extremely high-traffic applications, direct database writes for click counts can cause row-locking bottlenecks.
* **Buffer Click Counts**: Toggling this option buffers total and unique visit count increments in the application cache.
* **Cron Synchronization**: When enabled, you must schedule the synchronization command to run periodically (e.g., every minute) to flush counts to the database:
```bash
php artisan short-url:sync-counters
```
In your scheduler (`routes/console.php` or `app/Console/Kernel.php`):
```php
$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)
@@ -156,12 +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');
```
---
@@ -212,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`:
@@ -251,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

View File

@@ -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
],
@@ -132,4 +132,69 @@ return [
'gradient_type' => 'linear',
],
/*
|--------------------------------------------------------------------------
| Counter Buffering (Write-back Caching)
|--------------------------------------------------------------------------
| For high-traffic applications, direct database writes on every visit
| can cause locks. Enable this to buffer total/unique visit counts in
| cache, then sync them to the database periodically via a scheduled task.
*/
'counter_buffering' => [
'enabled' => (bool) env('SHORT_URL_COUNTER_BUFFERING', false),
'cache_key_prefix' => 'filament-short-url:buffer:',
],
/*
|--------------------------------------------------------------------------
| Trust CDN/Proxy Headers
|--------------------------------------------------------------------------
| If your application sits behind a CDN (like Cloudflare, AWS CloudFront)
| or a reverse proxy, set this to true to parse real visitor IP addresses
| and country codes from proxy headers. Only enable this if you are
| actually behind a proxy to prevent client IP spoofing!
|
*/
'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
|--------------------------------------------------------------------------
| The middleware list applied to the short URL redirect route.
| By default, standard web middleware and rate limiting are applied.
|
*/
'middleware' => [
'web',
'throttle:120,1',
],
];

View File

@@ -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();

View File

@@ -0,0 +1,36 @@
<?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_url_visits', function (Blueprint $table): void {
$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();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn([
'city',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
]);
});
}
};

View File

@@ -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',
]);
});
}
};

View File

@@ -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
View 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

File diff suppressed because one or more lines are too long

View File

@@ -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,9 +182,120 @@ 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',
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Trust CDN & Proxy Headers',
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Visited From',
'stats_filter_visited_until' => 'Visited Until',
'stats_action_export' => 'Export CSV',
'stats_csv_time' => 'Time',
'stats_csv_ip' => 'IP Address',
'stats_csv_country' => 'Country',
'stats_csv_device' => 'Device',
'stats_csv_browser' => 'Browser',
'stats_csv_os' => 'OS',
'stats_csv_referer' => 'Referer',
// Bulk Actions Localization
'action_enable_selected' => 'Enable selected',
'action_disable_selected' => 'Disable selected',
// Form Sections Localization
'form_section_options' => 'Options',
'form_section_notes' => 'Internal Notes',
'form_section_tracking' => 'Visit Tracking',
'form_section_tracked_fields' => 'Tracked Fields',
'form_section_analytics' => 'Third-Party Analytics',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Top UTM Source',
'stats_card_top_country' => 'Top Country',
'stats_filter_date_range' => 'Date Range',
'stats_preset_24_hours' => 'Last 24 Hours',
'stats_preset_7_days' => 'Last 7 Days',
'stats_preset_30_days' => 'Last 30 Days',
'stats_preset_90_days' => 'Last 90 Days',
'stats_preset_custom' => 'Custom Range',
'stats_breakdown_cities' => 'Top Cities',
'stats_no_city_data' => 'No city data.',
'stats_breakdown_utm_source' => 'Campaign Sources',
'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',
];

View File

@@ -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,9 +183,120 @@ 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)',
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
// CDN Trust Settings
'settings_trust_cdn_headers' => 'Ufaj nagłówkom CDN i proxy',
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Odwiedzono od',
'stats_filter_visited_until' => 'Odwiedzono do',
'stats_action_export' => 'Eksportuj CSV',
'stats_csv_time' => 'Czas',
'stats_csv_ip' => 'Adres IP',
'stats_csv_country' => 'Kraj',
'stats_csv_device' => 'Urządzenie',
'stats_csv_browser' => 'Przeglądarka',
'stats_csv_os' => 'System operacyjny',
'stats_csv_referer' => 'Źródło (Referer)',
// Bulk Actions Localization
'action_enable_selected' => 'Włącz zaznaczone',
'action_disable_selected' => 'Wyłącz zaznaczone',
// Form Sections Localization
'form_section_options' => 'Opcje',
'form_section_notes' => 'Wewnętrzne notatki',
'form_section_tracking' => 'Śledzenie wizyt',
'form_section_tracked_fields' => 'Śledzone pola',
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Główne źródło UTM',
'stats_card_top_country' => 'Główny kraj',
'stats_filter_date_range' => 'Zakres dat',
'stats_preset_24_hours' => 'Ostatnie 24 godziny',
'stats_preset_7_days' => 'Ostatnie 7 dni',
'stats_preset_30_days' => 'Ostatnie 30 dni',
'stats_preset_90_days' => 'Ostatnie 90 dni',
'stats_preset_custom' => 'Własny zakres',
'stats_breakdown_cities' => 'Najpopularniejsze miasta',
'stats_no_city_data' => 'Brak danych o miastach.',
'stats_breakdown_utm_source' => 'Źródła kampanii',
'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óć',
];

View 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>

View File

@@ -254,7 +254,7 @@
{{-- Size & Margin --}}
<div class="qr-section">
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:12px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_size') }}</span>
<input type="number" x-model.number="size" min="100" max="1000" step="10" class="qr-input-num" />
@@ -288,7 +288,7 @@
<span class="qr-label">{{ __('filament-short-url::default.qr_label_foreground_color') }}</span>
{{-- Mode radio --}}
<div class="flex gap-2">
<div class="flex gap-2" style="display:flex;gap:8px">
<button type="button"
:class="colorMode === 'solid' ? 'active' : ''"
class="qr-radio-option flex-1 justify-center text-center"
@@ -308,7 +308,7 @@
{{-- Single color picker --}}
<div x-show="colorMode === 'solid'" x-transition style="display:block" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+fgColor">
<input type="color" x-model="fgColor" />
</div>
@@ -321,10 +321,10 @@
{{-- Gradient pickers --}}
<div x-show="colorMode === 'gradient'" x-transition style="display:none" class="mt-3 space-y-3 qr-space-y qr-margin-top">
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2" style="display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px">
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_from') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientFrom">
<input type="color" x-model="gradientFrom" />
</div>
@@ -336,7 +336,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_to') }}</span>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5" style="display:flex;align-items:center;gap:6px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+gradientTo">
<input type="color" x-model="gradientTo" />
</div>
@@ -360,7 +360,7 @@
{{-- Background --}}
<div class="qr-section">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_background') }}</span>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:500;color:#374151" class="dark:text-gray-300">{{ __('filament-short-url::default.qr_label_transparent') }}</span>
<button type="button"
:class="bgTransparent ? 'on' : 'off'"
@@ -369,9 +369,9 @@
<span class="qr-toggle-thumb"></span>
</button>
</div>
<div x-show="!bgTransparent" x-transition style="display:block" class="mt-3">
<div x-show="!bgTransparent" x-transition style="display:block;margin-top:12px" class="mt-3 qr-margin-top">
<span class="qr-label">{{ __('filament-short-url::default.qr_label_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+bgColor">
<input type="color" x-model="bgColor" />
</div>
@@ -381,8 +381,8 @@
maxlength="7" placeholder="#ffffff" />
</div>
</div>
<div x-show="bgTransparent" x-transition style="display:none" class="mt-3">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="font-size:11px;color:#9ca3af;font-weight:600">
<div x-show="bgTransparent" x-transition style="display:none;margin-top:12px" class="mt-3 qr-margin-top">
<div class="qr-checker flex h-9 w-full items-center justify-center rounded-lg border border-dashed border-gray-300" style="display:flex;align-items:center;justify-content:center;font-size:11px;color:#9ca3af;font-weight:600">
TRANSPARENT
</div>
</div>
@@ -390,7 +390,7 @@
{{-- Eye Config --}}
<div class="qr-section">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between">
<span class="qr-label" style="margin-bottom:0">{{ __('filament-short-url::default.qr_label_eye_config') }}</span>
<button type="button"
:class="eyeConfigEnabled ? 'on' : 'off'"
@@ -417,7 +417,7 @@
</div>
<div>
<span class="qr-label">{{ __('filament-short-url::default.qr_label_eye_color') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<div class="qr-color-swatch qr-color-picker" :style="'background:'+eyeColor">
<input type="color" x-model="eyeColor" />
</div>
@@ -436,9 +436,9 @@
<div style="display:flex;flex-direction:column;min-width:0;height:100%">
{{-- Top bar --}}
<div class="mb-4 flex items-center justify-between">
<div class="mb-4 flex items-center justify-between" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<span style="font-size:13px;font-weight:600;color:#9ca3af">{{ __('filament-short-url::default.qr_label_preview') }}</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" style="display:flex;align-items:center;gap:8px">
<button type="button" x-on:click="download('png')" class="qr-dl-btn">
<svg style="width:13px;height:13px" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>

View File

@@ -16,18 +16,48 @@
</x-filament::tabs.item>
</x-filament::tabs>
<div class="space-y-6">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, ['record' => $record])
@php
$dateFrom = $this->filterData['date_from'] ?? null;
$dateTo = $this->filterData['date_to'] ?? null;
$isCustom = ($this->filterData['preset'] ?? '') === 'custom';
@endphp
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, ['record' => $record])
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, ['record' => $record])
<div class="space-y-6">
<div class="flex justify-end items-center">
<div class="w-full {{ $isCustom ? 'max-w-xl' : 'max-w-[200px]' }}">
<form>
{{ $this->form }}
</form>
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, ['record' => $record])
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlStatsOverview::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-overview-' . $dateFrom . '-' . $dateTo))
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsChart::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-chart-' . $dateFrom . '-' . $dateTo))
</div>
<div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-right-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-bottom-breakdown-' . $dateFrom . '-' . $dateTo))
</div>
</x-filament-panels::page>

View 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>

View File

@@ -27,118 +27,231 @@
@endphp
<x-filament-widgets::widget>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="space-y-6">
{{-- 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">
<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-device-phone-mobile" class="h-5 w-5" />
{{-- 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">
<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-device-phone-mobile" 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_devices') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_devices') }}</h3>
</div>
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
<div class="space-y-4">
@forelse ($visitsByDevice as $device => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<x-filament::icon :icon="$deviceIcons[$device] ?? 'heroicon-m-question-mark-circle'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</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>
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between text-sm">
<span class="capitalize font-medium text-gray-700 dark:text-gray-300 truncate">{{ $device }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ $pct }}%</span>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<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-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" 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_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<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-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" 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_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</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">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" 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_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@if ($referer === 'Direct')
<span class="font-medium text-gray-500 dark:text-gray-400 truncate">{{ $referer }}</span>
@else
<a href="https://{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
@endif
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</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>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_device_data') }}</p>
@endforelse
</div>
</div>
{{-- Browsers --}}
<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-amber-50 text-amber-500 dark:bg-amber-950/50 dark:text-amber-400">
<x-filament::icon icon="heroicon-o-globe-asia-australia" class="h-5 w-5" />
@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>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_browsers') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $browser }}</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_browser_data') }}</p>
@endforelse
</div>
</div>
{{-- Operating Systems --}}
<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-blue-50 text-blue-500 dark:bg-blue-950/50 dark:text-blue-400">
<x-filament::icon icon="heroicon-o-computer-desktop" class="h-5 w-5" />
{{-- UTM Campaigns Section --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{{-- UTM Sources --}}
<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-rose-50 text-rose-500 dark:bg-rose-950/50 dark:text-rose-400">
<x-filament::icon icon="heroicon-o-megaphone" 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_utm_source') }}</h3>
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_os') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-computer-desktop'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ $os }}</span>
<div class="space-y-3.5">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0 ml-2">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_os_data') }}</p>
@endforelse
</div>
</div>
{{-- 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">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-purple-50 text-purple-500 dark:bg-purple-950/50 dark:text-purple-400">
<x-filament::icon icon="heroicon-o-link" class="h-5 w-5" />
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ __('filament-short-url::default.stats_breakdown_referers') }}</h3>
</div>
<div class="space-y-3">
@forelse ($visitsByReferer as $referer => $count)
<div class="flex items-center justify-between text-sm gap-4">
<div class="flex items-center gap-2 min-w-0 flex-1">
<x-filament::icon icon="heroicon-m-link" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<a href="{{ $referer }}" target="_blank" rel="noopener"
class="truncate text-indigo-600 hover:underline dark:text-indigo-400 font-medium">
{{ $referer }}
</a>
</div>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }}
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_referer_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Mediums --}}
<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-teal-50 text-teal-500 dark:bg-teal-950/50 dark:text-teal-400">
<x-filament::icon icon="heroicon-o-tag" 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_utm_medium') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
{{-- UTM Campaigns --}}
<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-cyan-50 text-cyan-500 dark:bg-cyan-950/50 dark:text-cyan-400">
<x-filament::icon icon="heroicon-o-flag" 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_utm_campaign') }}</h3>
</div>
<div class="space-y-3.5">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0; @endphp
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
</span>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.stats_no_utm_data') }}</p>
@endforelse
</div>
</div>
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -3,10 +3,11 @@
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
)
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware('throttle:120,1');
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));

View 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;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class SyncBufferedCountersCommand extends Command
{
/** @var string */
protected $signature = 'short-url:sync-counters';
/** @var string */
protected $description = 'Sync buffered short URL visit counters from cache to the database';
public function handle(): int
{
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$dirtyKey = "{$prefix}dirty_ids";
// Pull the list atomically to avoid race conditions with incoming clicks
if (Cache::getDefaultDriver() === 'redis' && class_exists(Redis::class)) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
Redis::rename($dirtyKey, $tempKey);
$dirtyIds = Redis::smembers($tempKey);
Redis::del($tempKey);
} catch (\Throwable) {
// If key does not exist or rename fails, fallback
$dirtyIds = [];
}
} else {
$dirtyIds = Cache::pull($dirtyKey, []);
}
if (empty($dirtyIds)) {
$this->info('No buffered counters to synchronize.');
return 0;
}
$dirtyIds = array_unique(array_filter($dirtyIds));
$processed = 0;
foreach ($dirtyIds as $id) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$totalDelta = (int) Cache::pull($totalKey, 0);
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
if ($totalDelta > 0 || $uniqueDelta > 0) {
// Perform a single atomic update query for this URL
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$totalDelta}"),
'unique_visits' => DB::raw("unique_visits + {$uniqueDelta}"),
]);
$processed++;
}
}
$this->info("Successfully synchronized counters for {$processed} short URLs.");
return 0;
}
}

View File

@@ -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,
];
}
}

View File

@@ -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,10 +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),
]);
}
@@ -93,6 +102,12 @@ class ShortUrlSettingsPage extends Page implements HasForms
->minValue(0)
->suffix('s')
->required(),
Toggle::make('trust_cdn_headers')
->label(__('filament-short-url::default.settings_trust_cdn_headers'))
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
->columnSpanFull()
->inline(false),
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
@@ -111,6 +126,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
})
->required(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->inline(false),
]),
]),
// ── Geo-IP ───────────────────────────────────────────
@@ -166,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'),
]),
]),
@@ -183,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')),
]),
]),
]),

View File

@@ -112,31 +112,38 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
Tables\Filters\Filter::make('visited_at')
->form([
DatePicker::make('visited_from')
->label('Visited From'),
->label(__('filament-short-url::default.stats_filter_visited_from')),
DatePicker::make('visited_until')
->label('Visited Until'),
->label(__('filament-short-url::default.stats_filter_visited_until')),
])
->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([
Action::make('export_csv')
->label('Export CSV')
->label(__('filament-short-url::default.stats_action_export'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->action(function () {
return response()->streamDownload(function () {
->action(function (HasTable $livewire) {
return response()->streamDownload(function () use ($livewire) {
$handle = fopen('php://output', 'w');
// Add UTF-8 BOM for Microsoft Excel
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($handle, ['Time', 'IP Address', 'Country', 'Device', 'Browser', 'OS', 'Referer']);
fputcsv($handle, [
__('filament-short-url::default.stats_csv_time'),
__('filament-short-url::default.stats_csv_ip'),
__('filament-short-url::default.stats_csv_country'),
__('filament-short-url::default.stats_csv_device'),
__('filament-short-url::default.stats_csv_browser'),
__('filament-short-url::default.stats_csv_os'),
__('filament-short-url::default.stats_csv_referer'),
]);
ShortUrlVisit::query()
->where('short_url_id', $this->record->id)
$livewire->getFilteredTableQuery()
->orderBy('visited_at', 'desc')
->chunk(200, function ($visits) use ($handle) {
foreach ($visits as $visit) {

View File

@@ -5,9 +5,12 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
class ViewShortUrlStats extends Page implements HasForms
{
@@ -23,10 +26,80 @@ class ViewShortUrlStats extends Page implements HasForms
public int $totalVisits = 0;
public ?array $filterData = [];
public function mount(ShortUrl $record): void
{
$this->record = $record;
$this->totalVisits = $record->getCachedStats()['totalVisits'] ?? 0;
$this->form->fill([
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
]);
if (empty($this->filterData)) {
$this->filterData = [
'preset' => '30_days',
'date_from' => now()->subDays(29)->format('Y-m-d'),
'date_to' => now()->format('Y-m-d'),
];
}
$this->totalVisits = $record->getCachedStats($this->filterData['date_from'], $this->filterData['date_to'])['totalVisits'] ?? 0;
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('preset')
->hiddenLabel()
->options([
'24_hours' => __('filament-short-url::default.stats_preset_24_hours'),
'7_days' => __('filament-short-url::default.stats_preset_7_days'),
'30_days' => __('filament-short-url::default.stats_preset_30_days'),
'90_days' => __('filament-short-url::default.stats_preset_90_days'),
'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) {
'24_hours' => now()->subDay()->format('Y-m-d'),
'7_days' => now()->subDays(6)->format('Y-m-d'),
'30_days' => now()->subDays(29)->format('Y-m-d'),
'90_days' => now()->subDays(89)->format('Y-m-d'),
'custom' => now()->subDays(29)->format('Y-m-d'),
default => null,
};
if ($from) {
$set('date_from', $from);
$set('date_to', $to);
}
}),
DatePicker::make('date_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')
->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');
}
protected function getHeaderWidgets(): array

View File

@@ -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(),
@@ -105,7 +107,7 @@ class ShortUrlForm
->required(),
])->columns(2),
Section::make('Options')->schema([
Section::make(__('filament-short-url::default.form_section_options'))->schema([
Toggle::make('is_enabled')
->label(__('filament-short-url::default.status'))
->default(true)
@@ -129,7 +131,7 @@ class ShortUrlForm
->native(false),
])->columns(2),
Section::make('Internal Notes')->schema([
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
->rows(3)
@@ -177,7 +179,7 @@ class ShortUrlForm
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make('Visit Tracking')
Section::make(__('filament-short-url::default.form_section_tracking'))
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
@@ -187,7 +189,7 @@ class ShortUrlForm
->columnSpanFull(),
])->columns(1),
Section::make('Tracked Fields')
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
@@ -277,7 +279,7 @@ class ShortUrlForm
])
->columns(2),
Section::make('Third-Party Analytics')
Section::make(__('filament-short-url::default.form_section_analytics'))
->schema([
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))
@@ -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'),
]),
]);
}
}

View File

@@ -2,19 +2,21 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
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
{
@@ -22,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')
@@ -95,43 +184,150 @@ 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')
->url(fn (ShortUrl $record): string => route(
'filament.admin.resources.short-urls.stats',
['record' => $record->id]
)),
Action::make('copy')
->label(__('filament-short-url::default.action_copy'))
->icon('heroicon-o-clipboard')
->color('gray')
->size('sm')
->action(fn () => null) // Copy happens client-side
->extraAttributes(fn (ShortUrl $record): array => [
'x-on:click' => 'navigator.clipboard.writeText("'.$record->getShortUrl().'")',
'title' => 'Copy short URL',
]),
->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([
BulkAction::make('enable')
->label('Enable selected')
->label(__('filament-short-url::default.action_enable_selected'))
->icon('heroicon-o-check-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => true]))
->deselectRecordsAfterCompletion(),
BulkAction::make('disable')
->label('Disable selected')
->label(__('filament-short-url::default.action_disable_selected'))
->icon('heroicon-o-x-circle')
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
->deselectRecordsAfterCompletion(),

View File

@@ -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,
];
});
}
}

View File

@@ -10,13 +10,27 @@ class ShortUrlStatsOverview extends BaseWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected function getStats(): array
{
if (! $this->record) {
return [];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$topSource = 'Direct';
if (! empty($stats['utmSources'])) {
$topSource = array_key_first($stats['utmSources']);
}
$topCountry = '—';
if (! empty($stats['visitsByCountry'])) {
$topCountry = array_key_first($stats['visitsByCountry']);
}
return [
Stat::make(__('filament-short-url::default.stats_card_total'), number_format($stats['totalVisits'] ?? 0))
@@ -27,17 +41,13 @@ class ShortUrlStatsOverview extends BaseWidget
->icon('heroicon-o-user-group')
->color('info'),
Stat::make(__('filament-short-url::default.stats_card_today'), number_format($stats['visitsToday'] ?? 0))
->icon('heroicon-o-sun')
Stat::make(__('filament-short-url::default.stats_card_top_source'), $topSource)
->icon('heroicon-o-megaphone')
->color('warning'),
Stat::make(__('filament-short-url::default.stats_card_week'), number_format($stats['visitsThisWeek'] ?? 0))
->icon('heroicon-o-calendar-days')
Stat::make(__('filament-short-url::default.stats_card_top_country'), $topCountry)
->icon('heroicon-o-globe-alt')
->color('success'),
Stat::make(__('filament-short-url::default.stats_card_month'), number_format($stats['visitsThisMonth'] ?? 0))
->icon('heroicon-o-calendar')
->color('gray'),
];
}
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsBottomBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-bottom-breakdown';
protected int|string|array $columnSpan = 'full';
@@ -29,17 +33,25 @@ class ShortUrlVisitsBottomBreakdown extends Widget
'visitsByBrowser' => [],
'visitsByOs' => [],
'visitsByReferer' => [],
'visitsByCity' => [],
'utmSources' => [],
'utmMediums' => [],
'utmCampaigns' => [],
'totalVisits' => 0,
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByDevice' => $stats['visitsByDevice'] ?? [],
'visitsByBrowser' => $stats['visitsByBrowser'] ?? [],
'visitsByOs' => $stats['visitsByOs'] ?? [],
'visitsByReferer' => $stats['visitsByReferer'] ?? [],
'visitsByCity' => $stats['visitsByCity'] ?? [],
'utmSources' => $stats['utmSources'] ?? [],
'utmMediums' => $stats['utmMediums'] ?? [],
'utmCampaigns' => $stats['utmCampaigns'] ?? [],
'totalVisits' => $stats['totalVisits'] ?? 0,
];
}

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsChart extends ChartWidget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected ?string $maxHeight = '200px';
protected int|string|array $columnSpan = [
@@ -29,7 +33,7 @@ class ShortUrlVisitsChart extends ChartWidget
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
$visitsByDay = $stats['visitsByDay'] ?? [];
return [

View File

@@ -9,6 +9,10 @@ class ShortUrlVisitsRightBreakdown extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.visits-right-breakdown';
protected int|string|array $columnSpan = [
@@ -32,7 +36,7 @@ class ShortUrlVisitsRightBreakdown extends Widget
];
}
$stats = $this->record->getCachedStats();
$stats = $this->record->getCachedStats($this->dateFrom, $this->dateTo);
return [
'visitsByCountry' => $stats['visitsByCountry'] ?? [],

View File

@@ -2,11 +2,15 @@
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
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;
@@ -24,6 +28,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
->hasMigrations([
'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,
])
->hasRoutes(['web']);
}
@@ -39,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();
}
});
}
}

View File

@@ -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,10 +32,74 @@ 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');
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
$countryCode = ClientIpExtractor::getCountryCode($request);
$city = ClientIpExtractor::getCity($request);
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
@@ -41,12 +107,18 @@ class ShortUrlRedirectController extends Controller
userAgent: $request->userAgent() ?? '',
refererUrl: $request->header('Referer'),
countryCode: $countryCode,
city: $city,
utmSource: $request->query('utm_source'),
utmMedium: $request->query('utm_medium'),
utmCampaign: $request->query('utm_campaign'),
utmTerm: $request->query('utm_term'),
utmContent: $request->query('utm_content'),
);
if ($connection && $connection !== 'default') {
if ($connection) {
dispatch($job->onConnection($connection));
} else {
dispatch($job);
dispatch($job->onConnection('sync'));
}
}
@@ -60,10 +132,11 @@ class ShortUrlRedirectController extends Controller
if ($affected === 0) {
abort(410);
}
// Manually forget cache since DB-level update does not trigger Eloquent events
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);
}
}

View 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')] : []
);
}
}

View File

@@ -41,6 +41,12 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $countryCode = null,
public readonly ?string $city = null,
public readonly ?string $utmSource = null,
public readonly ?string $utmMedium = null,
public readonly ?string $utmCampaign = null,
public readonly ?string $utmTerm = null,
public readonly ?string $utmContent = null,
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
@@ -62,7 +68,19 @@ class TrackShortUrlVisitJob implements ShouldQueue
]);
$countryCode = isset($this->countryCode) ? $this->countryCode : null;
$visit = $tracker->record($shortUrl, $request, $countryCode);
$city = isset($this->city) ? $this->city : null;
$visit = $tracker->record(
shortUrl: $shortUrl,
request: $request,
preResolvedCountryCode: $countryCode,
preResolvedCity: $city,
utmSource: $this->utmSource,
utmMedium: $this->utmMedium,
utmCampaign: $this->utmCampaign,
utmTerm: $this->utmTerm,
utmContent: $this->utmContent,
);
// Null means bot/crawler — nothing to dispatch or report
if ($visit === null) {

View File

@@ -2,15 +2,22 @@
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;
/**
* @property int $id
@@ -66,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',
];
@@ -84,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',
@@ -96,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
@@ -140,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> */
@@ -236,12 +259,103 @@ 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.
* to avoid race conditions and two round-trips. Supports write-back caching.
*/
public function incrementVisits(bool $isUnique = false): void
{
if (config('filament-short-url.counter_buffering.enabled', false)) {
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}");
}
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()
->where('id', $this->id)
->increment('total_visits', 1, $isUnique ? ['unique_visits' => DB::raw('unique_visits + 1')] : []);
@@ -252,98 +366,304 @@ class ShortUrl extends Model
*
* @return array<string, mixed>
*/
public function getCachedStats(): array
public function getCachedStats(?string $dateFrom = null, ?string $dateTo = null): array
{
$dateFromClean = $dateFrom ? Carbon::parse($dateFrom)->toDateString() : null;
$dateToClean = $dateTo ? Carbon::parse($dateTo)->toDateString() : null;
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_stats_{$this->id}";
$cacheKey = "short_url_stats_{$this->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
return cache()->remember($cacheKey, $cacheTtl, function () {
$visits = $this->visits();
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$today = Carbon::today()->toDateString();
$totalVisits = (int) ($this->total_visits ?? 0);
$uniqueVisits = (int) ($this->unique_visits ?? 0);
$visitsToday = (clone $visits)->where('visited_at', '>=', today()->startOfDay())->count();
$visitsThisWeek = (clone $visits)->where('visited_at', '>=', now()->startOfWeek())->count();
$visitsThisMonth = (clone $visits)->where('visited_at', '>=', now()->startOfMonth())->count();
// 1. Fetch daily stats (aggregated historical data)
$dailyQuery = $this->dailyStats()->where('date', '<', $today);
if ($dateFromClean) {
$dailyQuery->where('date', '>=', $dateFromClean);
}
if ($dateToClean && $dateToClean < $today) {
$dailyQuery->where('date', '<=', $dateToClean);
}
$dailyStatsRows = $dailyQuery->get();
// Visits by day — last 30 days
$visitsByDayRaw = (clone $visits)
->where('visited_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(visited_at) as date, COUNT(*) as count')
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
$visitsByDay = [];
for ($i = 29; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$visitsByDay[$date] = $visitsByDayRaw[$date] ?? 0;
// 2. Fetch raw visits for today (if within date range)
$includeToday = ($dateToClean === null || $dateToClean >= $today);
if ($dateFromClean && $dateFromClean > $today) {
$includeToday = false;
}
// 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();
$rawVisits = [];
if ($includeToday) {
$rawVisits = $this->visits()->where('visited_at', '>=', $today.' 00:00:00')->get();
}
// Device types
$visitsByDevice = (clone $visits)
->whereNotNull('device_type')
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->pluck('count', 'device_type')
->toArray();
// 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;
}
// Browsers
$visitsByBrowser = (clone $visits)
->whereNotNull('browser')
->selectRaw('browser, COUNT(*) as count')
->groupBy('browser')
->orderByDesc('count')
->limit(8)
->pluck('count', 'browser')
->toArray();
return $base;
};
// 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();
// Initialize metrics
$totalVisits = 0;
$uniqueVisitsCount = 0;
$visitsToday = count($rawVisits);
$visitsThisWeek = 0;
$visitsThisMonth = 0;
// Top referers
$visitsByReferer = (clone $visits)
->whereNotNull('referer_url')
->selectRaw('referer_url, COUNT(*) as count')
->groupBy('referer_url')
->orderByDesc('count')
->limit(10)
->pluck('count', 'referer_url')
->toArray();
$visitsByCountry = [];
$visitsByCity = [];
$visitsByDevice = [];
$visitsByBrowser = [];
$visitsByOs = [];
$visitsByReferer = [];
$utmSources = [];
$utmMediums = [];
$utmCampaigns = [];
// 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;
}
}
}
// Build visitsByDay timeline
$chartFrom = $dateFromClean ? Carbon::parse($dateFromClean) : now()->subDays(29)->startOfDay();
$chartTo = $dateToClean ? Carbon::parse($dateToClean) : now()->endOfDay();
$daysDiff = (int) $chartFrom->diffInDays($chartTo);
$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);
}
}
// 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,
'visitsByCountry' => array_slice($visitsByCountry, 0, 10, true),
'visitsByCity' => array_slice($visitsByCity, 0, 10, true),
'visitsByDevice' => $visitsByDevice,
'visitsByBrowser' => $visitsByBrowser,
'visitsByOs' => $visitsByOs,
'visitsByReferer' => $visitsByReferer,
'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;
}
}

View 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');
}
}

View File

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

View File

@@ -11,29 +11,31 @@ class ClientIpExtractor
*/
public static function getIp(Request $request): string
{
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare connecting IP header
if ($cfIp = $request->header('CF-Connecting-IP')) {
return trim($cfIp);
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
}
// 2. Akamai or other CDNs True-Client-IP header
if ($trueIp = $request->header('True-Client-IP')) {
return trim($trueIp);
}
// 3. General reverse proxy / Nginx X-Real-IP header
if ($realIp = $request->header('X-Real-IP')) {
return trim($realIp);
}
// 4. Standard X-Forwarded-For header chain (first IP is the client)
if ($forwardedFor = $request->header('X-Forwarded-For')) {
$ips = explode(',', $forwardedFor);
return trim($ips[0]);
}
// 5. Fallback to standard Laravel/Symfony IP resolver
// 5. Fallback to standard Laravel/Symfony IP resolver (which respects Trusted Proxies)
return $request->ip() ?? '0.0.0.0';
}
@@ -42,19 +44,39 @@ class ClientIpExtractor
*/
public static function getCountryCode(Request $request): ?string
{
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
if (config('filament-short-url.trust_cdn_headers', false)) {
// 1. Cloudflare IP Country header
if ($cfCountry = $request->header('CF-IPCountry')) {
return strtoupper(trim($cfCountry));
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
}
}
// 2. AWS CloudFront country header
if ($cfViewerCountry = $request->header('CloudFront-Viewer-Country')) {
return strtoupper(trim($cfViewerCountry));
}
return null;
}
// 3. Generic CDN / Proxy country header
if ($xCountry = $request->header('X-Country-Code')) {
return strtoupper(trim($xCountry));
/**
* Extract the edge-provided city name from CDN/proxy headers.
*/
public static function getCity(Request $request): ?string
{
if (config('filament-short-url.trust_cdn_headers', false)) {
if ($cfCity = $request->header('CF-IPCity')) {
return trim($cfCity);
}
if ($cfViewerCity = $request->header('CloudFront-Viewer-City')) {
return trim($cfViewerCity);
}
}
return null;

View File

@@ -17,31 +17,32 @@ use Illuminate\Support\Facades\Log;
*/
class GeoIpService
{
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode';
private const API_URL = 'http://ip-api.com/json/%s?fields=status,country,countryCode,city';
/** @var array<string, array{country: string|null, country_code: string|null}> */
/** @var array<string, array{country: string|null, country_code: string|null, city: string|null}> */
private static array $runtimeCache = [];
/**
* Resolve country data for the given IP address.
* Resolve country and city data for the given IP address.
*
* @return array{country: string|null, country_code: string|null}
* @return array{country: string|null, country_code: string|null, city: string|null}
*/
public function resolve(string $ip, ?string $preResolvedCountryCode = null): array
public function resolve(string $ip, ?string $preResolvedCountryCode = null, ?string $preResolvedCity = null): array
{
$empty = ['country' => null, 'country_code' => null];
$empty = ['country' => null, 'country_code' => null, 'city' => null];
if (! config('filament-short-url.geo_ip.enabled', true)) {
return $empty;
}
// 1. Prioritise edge-provided CDN country code (extremely fast, zero latency)
// 1. Prioritise edge-provided CDN country code & city (extremely fast, zero latency)
if ($preResolvedCountryCode) {
$code = strtoupper(trim($preResolvedCountryCode));
return [
'country' => $this->getCountryName($code),
'country_code' => $code,
'city' => $preResolvedCity,
];
}
@@ -129,9 +130,9 @@ class GeoIpService
}
/**
* Resolve country using local MaxMind GeoIP2 DB.
* Resolve country and city using local MaxMind GeoIP2 DB.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function lookupMaxMind(string $ip): ?array
{
@@ -151,11 +152,24 @@ class GeoIpService
try {
$reader = new Reader($dbPath);
$dbType = $reader->metadata()->databaseType;
if (str_contains($dbType, 'City')) {
$record = $reader->city($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => $record->city->name ?? null,
];
}
$record = $reader->country($ip);
return [
'country' => $record->country->name ?? null,
'country_code' => $record->country->isoCode ?? null,
'city' => null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] MaxMind GeoIP lookup failed', [
@@ -168,9 +182,9 @@ class GeoIpService
}
/**
* Resolve country using free ip-api.com.
* Resolve country and city using free ip-api.com.
*
* @return array{country: string, country_code: string}|null
* @return array{country: string|null, country_code: string|null, city: string|null}|null
*/
private function fetchFromApi(string $ip): ?array
{
@@ -193,6 +207,7 @@ class GeoIpService
return [
'country' => $data['country'] ?? null,
'country_code' => $data['countryCode'] ?? null,
'city' => $data['city'] ?? null,
];
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] GeoIP lookup failed', [

View File

@@ -44,11 +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;
@@ -87,6 +94,13 @@ class ShortUrlSettingsManager
'ga4_firebase_app_id',
'queue_connection',
'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));
@@ -110,6 +124,27 @@ class ShortUrlSettingsManager
if (isset($filtered['cache_ttl'])) {
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
}
if (isset($filtered['counter_buffering_enabled'])) {
$filtered['counter_buffering_enabled'] = (bool) $filtered['counter_buffering_enabled'];
}
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;
@@ -138,6 +173,13 @@ class ShortUrlSettingsManager
'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'],
'filament-short-url.queue_connection' => $settings['queue_connection'],
'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'],
]);
}
}

View File

@@ -23,8 +23,17 @@ class ShortUrlTracker
*
* Returns null if the visit was from a bot/crawler (we don't track those).
*/
public function record(ShortUrl $shortUrl, Request $request, ?string $preResolvedCountryCode = null): ?ShortUrlVisit
{
public function record(
ShortUrl $shortUrl,
Request $request,
?string $preResolvedCountryCode = null,
?string $preResolvedCity = null,
?string $utmSource = null,
?string $utmMedium = null,
?string $utmCampaign = null,
?string $utmTerm = null,
?string $utmContent = null,
): ?ShortUrlVisit {
$ip = ClientIpExtractor::getIp($request);
$ipHash = hash('sha256', $ip);
$ua = $request->userAgent() ?? '';
@@ -36,8 +45,8 @@ class ShortUrlTracker
}
$geo = config('filament-short-url.geo_ip.enabled', true)
? $this->geoIp->resolve($ip, $preResolvedCountryCode)
: ['country' => null, 'country_code' => null];
? $this->geoIp->resolve($ip, $preResolvedCountryCode, $preResolvedCity)
: ['country' => null, 'country_code' => null, 'city' => null];
// Determine uniqueness: first time this IP hash visits this URL.
// We check BEFORE insert to avoid a self-referential race.
@@ -56,9 +65,42 @@ class ShortUrlTracker
$visit->operating_system_version = $shortUrl->track_operating_system_version
? $parsed['operating_system_version'] : null;
$visit->device_type = $shortUrl->track_device_type ? $parsed['device_type'] : null;
$visit->referer_url = $shortUrl->track_referer_url ? $request->header('Referer') : null;
$visit->country = $geo['country'];
$visit->country_code = $geo['country_code'];
$visit->city = $geo['city'] ?? null;
$visit->utm_source = $utmSource;
$visit->utm_medium = $utmMedium;
$visit->utm_campaign = $utmCampaign;
$visit->utm_term = $utmTerm;
$visit->utm_content = $utmContent;
// Clean & normalize referer URL to host domain
if ($shortUrl->track_referer_url && $referer = $request->header('Referer')) {
$visit->referer_url = $referer;
$refererHost = parse_url($referer, PHP_URL_HOST);
if ($refererHost) {
$refererHost = preg_replace('/^www\./', '', strtolower($refererHost));
if (str_contains($refererHost, 'facebook.com')) {
$refererHost = 'facebook.com';
} elseif (str_contains($refererHost, 'linkedin.com')) {
$refererHost = 'linkedin.com';
} elseif (str_contains($refererHost, 'twitter.com') || str_contains($refererHost, 't.co')) {
$refererHost = 'twitter.com';
} elseif (str_contains($refererHost, 'google.')) {
$refererHost = 'google.com';
} elseif (str_contains($refererHost, 'instagram.com')) {
$refererHost = 'instagram.com';
} elseif (str_contains($refererHost, 'youtube.com')) {
$refererHost = 'youtube.com';
}
$visit->referer_host = $refererHost;
} else {
$visit->referer_host = 'Direct';
}
} else {
$visit->referer_host = 'Direct';
}
$visit->save();