2 Commits

20 changed files with 1980 additions and 578 deletions

380
README.md
View File

@@ -68,7 +68,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
- 🔌 **Developer REST API** — Full programmatical control with secure API Key authentication to create, list, and delete short links externally.
- 🔌 **Developer REST API (updated in v3.2.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
@@ -322,6 +322,26 @@ This feature is useful for NSFW links, external partner links, or any URL that l
---
## Security & Anti-Fraud v2.0 (new in v1.6.0)
Protect your application redirection routes and visitor data from malicious activities and automated scrapers.
### 1. VPN & Proxy Detection
Filter out anonymous proxy, VPN, or Tor connections to ensure clean analytics and prevent abuse.
* **Driver Selection**: Choose between the free **IP-API** service (default) or the premium **VPNAPI.io** service (requires setting an API key).
* **Configurable Action**:
* **Flag Only**: Flags VPN/Proxy visits in database statistics for inspection but allows the redirection to continue.
* **Block Traffic**: Actively blocks the request, serving a `403 Forbidden` response to the client.
* **Verify Key**: An interactive "Verify connection" action is available in settings to check your API credentials.
### 2. Google Safe Browsing URL Verification
Scan and verify all user-provided target URLs against Google's safe browsing lookup API on creation and edit.
* **Protection**: Blocks malware, phishing, and social engineering domains.
* **Filament UI**: Displays a clean status badge and blocks form saving if the target URL is flagged as unsafe.
* **Verify Key**: Includes a "Test API Connection" action on the settings dashboard to validate your Safe Browsing API credentials.
---
## Custom Branded Expiry Pages (new in v3.0.0)
When a short URL is expired, deactivated, or has reached its maximum visit limit, it needs to handle the redirect gracefully:
@@ -334,83 +354,69 @@ When a short URL is expired, deactivated, or has reached its maximum visit limit
---
## Smart Link Targeting (new in v1.2.0)
## Smart Link Targeting (updated in v3.3.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
You can configure multiple rules evaluated sequentially from top to bottom. Each rule contains:
- A **Target URL** (the redirect destination if rule matches).
- A **Match Strategy**: `AND` (all filters must match) or `OR` (any filter can match).
- A list of **Filters**:
- **Device**: Filter by `desktop`, `mobile`, `tablet`.
- **Platform**: Filter by `windows`, `mac`, `linux`, `ios`, `android`, `fire_os`.
- **Country**: Filter by country codes (e.g. `PL`, `US`, `DE`) with flags display.
- **Language**: Filter by preferred browser language codes (e.g. `pl`, `en`, `de`).
#### 1. Device-Based Redirects
Route visitors to different URLs based on their device type (detected from User-Agent):
### Multi-Filter JSON Schema (v3.3.0+)
| 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.
For programmatic or REST API updates, pass an array of rules to `targeting_rules`:
```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'],
[
'match' => 'and',
'url' => 'https://ios-pl.example.com',
'filters' => [
[
'type' => 'platform',
'data' => ['platforms' => ['ios']]
],
[
'type' => 'language',
'data' => ['languages' => ['pl']]
]
]
],
],
[
'match' => 'or',
'url' => 'https://fallback-mobile.example.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile', 'tablet']]
]
]
]
]
]);
```
#### 3. Browser Language-Based Redirects (new in v2.0.5)
Route visitors to language-specific URLs based on their browser's language preferences (detected from the `Accept-Language` header). Fully supports matching exact regional locales (e.g., `en-US`, `en-GB`) with automatic fallback to base language codes (e.g., `en`, `pl`).
### Supported Filter Options & Formats
```php
$shortUrl->update([
'targeting_rules' => [
'type' => 'language',
'language' => [
['language_code' => 'pl', 'url' => 'https://pl.example.com'],
['language_code' => 'en-US', 'url' => 'https://us.example.com'],
['language_code' => 'de', 'url' => 'https://de.example.com'],
],
],
]);
```
| Filter Type | Data Key | Allowed Values |
|-------------|----------|----------------|
| `device` | `devices` | `desktop`, `mobile`, `tablet` |
| `platform` | `platforms` | `windows`, `mac`, `linux`, `ios`, `android`, `fire_os` |
| `country` | `countries` | ISO-3166 2-letter country codes (e.g. `PL`, `US`, `DE`), case-insensitive |
| `language` | `languages` | ISO-639-1 language codes (e.g. `pl`, `en`, `de`), case-insensitive |
#### 4. A/B Split Rotation
Distribute traffic across multiple URLs using weighted random selection. Weights are proportional — they do not need to sum to 100.
### Legacy Strategies (v1.2.0 - v2.x)
```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.
If your database contains legacy single-strategy rules (e.g. `'type' => 'device'` or `'type' => 'geo'`), the plugin handles them automatically:
* **Redirection Engine**: The redirect system detects the legacy structure and processes it on-the-fly using the legacy strategy.
* **Filament UI**: When loading a link with legacy rules, the Filament Form automatically upgrades and hydrates them to equivalent new multi-filter rules.
* **A/B Split Rotation**: The legacy rotation strategy is still supported backward-compatibly in redirect logic, but cannot be newly configured through the Filament v3.3.0 form.
---
@@ -611,6 +617,8 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
### Endpoints
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
#### `GET /api/short-url/links`
List all short URLs (paginated, 30 per page).
@@ -639,7 +647,16 @@ curl https://yourdomain.com/api/short-url/links \
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [],
"notes": null,
@@ -655,6 +672,51 @@ curl https://yourdomain.com/api/short-url/links \
}
```
#### `GET /api/short-url/links/{idOrKey}`
Retrieve details for a single short URL using either its database `id` or short `url_key`.
```bash
curl https://yourdomain.com/api/short-url/links/abc123 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{
"data": {
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"is_enabled": true,
"redirect_status_code": 302,
"total_visits": 47,
"unique_visits": 31,
"max_visits": null,
"activated_at": null,
"expires_at": null,
"webhook_url": null,
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [],
"notes": null,
"created_at": "2026-06-01T12:00:00+00:00"
}
}
```
#### `POST /api/short-url/links`
Create a new short URL.
@@ -673,36 +735,59 @@ curl -X POST https://yourdomain.com/api/short-url/links \
}'
```
**Accepted fields:**
#### `PUT/PATCH /api/short-url/links/{idOrKey}`
Update an existing short URL (resolved dynamically by either database `id` or short `url_key`).
| Field | Type | Required | Description |
```bash
curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://example.com/new-product-page",
"notes": "Updated summer campaign description",
"is_enabled": true
}'
```
**Accepted Fields (for POST & PUT/PATCH requests):**
| Field | Type | Required (POST) | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL |
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
| `notes` | string | ❌ | Internal notes |
| `destination_url` | string (URL) | ✅ | Target URL (max 2048 chars) |
| `url_key` | string | ❌ | Custom unique slug/key (max 32 chars, alpha-dash; auto-generated if omitted) |
| `notes` | string | ❌ | Internal admin notes (max 1000 chars) |
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
| `single_use` | boolean | ❌ | Expire after first click |
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
| `max_visits` | integer | ❌ | Click limit before expiry |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
| `activated_at` | datetime | ❌ | Activation timestamp |
| `expires_at` | datetime | ❌ | Expiration timestamp |
| `pixels` | array of integers | ❌ | List of pixel registry IDs to associate with the link |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
| `password` | string | ❌ | Access protection password |
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect status code |
| `single_use` | boolean | ❌ | Expire the short link immediately after the first visit |
| `forward_query_params` | boolean | ❌ | Forward visitor query parameters to target destination |
| `max_visits` | integer | ❌ | Maximum click threshold limit |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration |
| `activated_at` | datetime | ❌ | Activation timestamp (must be after or equal to today) |
| `expires_at` | datetime | ❌ | Expiration timestamp (must be after or equal to `activated_at`) |
| `pixels` | array of integers | ❌ | List of registered retargeting pixel IDs to associate with the link |
| `webhook_url` | string (URL) | ❌ | Per-link webhook URL for immediate event notifications |
| `targeting_rules` | array | ❌ | Advanced Multi-Filter Targeting Rules JSON schema (see [Smart Link Targeting](#smart-link-targeting-updated-in-v330) for schema details) |
| `password` | string | ❌ | Password to protect the short URL |
| `show_warning_page` | boolean | ❌ | Toggle redirect warning interstitial page |
| `auto_open_app_mobile` | boolean | ❌ | Auto open deep link in native application on mobile devices |
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`) |
| `track_visits` | boolean | ❌ | Toggle tracking/analytics logging for this link (default: `true`) |
| `track_ip_address` | boolean | ❌ | Track client IP address (default: `true`) |
| `track_browser` | boolean | ❌ | Track client browser name (default: `true`) |
| `track_browser_version` | boolean | ❌ | Track client browser version (default: `true`) |
| `track_operating_system` | boolean | ❌ | Track client OS (default: `true`) |
| `track_operating_system_version` | boolean | ❌ | Track client OS version (default: `true`) |
| `track_device_type` | boolean | ❌ | Track client device type (default: `true`) |
| `track_referer_url` | boolean | ❌ | Track visitor referrer URL (default: `true`) |
| `track_browser_language` | boolean | ❌ | Track visitor preferred browser language (default: `true`) |
**Response:** `201 Created` with a wrapper message and the created link data:
**Response (POST & PUT/PATCH success):** `200 OK` (or `201 Created` for POST) containing:
```json
{
"message": "Short URL created successfully.",
"message": "Short URL updated successfully.",
"data": {
"id": 2,
"destination_url": "https://example.com/product",
"destination_url": "https://example.com/new-product-page",
"url_key": "promo26",
"short_url": "https://yourdomain.com/s/promo26",
"is_enabled": true,
@@ -716,41 +801,105 @@ curl -X POST https://yourdomain.com/api/short-url/links \
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [
{
"id": 1,
"name": "Meta Pixel (1234567890)",
"type": "meta",
"pixel_id": "1234567890",
"is_active": true
},
{
"id": 2,
"name": "Google Tag (G-XXXXXXXXXX)",
"type": "google",
"pixel_id": "G-XXXXXXXXXX",
"is_active": true
}
],
"notes": "Summer campaign",
"pixels": [],
"notes": "Updated summer campaign description",
"created_at": "2026-06-04T12:00:00+00:00"
}
}
```
#### `DELETE /api/short-url/links/{id}`
Permanently delete a short URL by its ID.
#### `GET /api/short-url/links/{idOrKey}/stats`
Retrieve visit analytics statistics for a single short URL.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
curl https://yourdomain.com/api/short-url/links/promo26/stats \
-H "X-Api-Key: sh_key_your_key_here"
```
**Optional Query Parameters:**
* `date_from` (string): Filter stats starting from date (e.g. `YYYY-MM-DD`).
* `date_to` (string): Filter stats up to date (e.g. `YYYY-MM-DD`).
**Response:** `200 OK`
```json
{
"data": {
"totalVisits": 156,
"uniqueVisits": 98,
"visitsToday": 14,
"visitsThisWeek": 114,
"visitsThisMonth": 156,
"visitsByDay": {
"2026-06-01": 42,
"2026-06-02": 50
},
"visitsByCountry": {
"Poland": 100,
"United States": 50
},
"visitsByCity": {
"Warsaw (PL)": 80,
"Krakow (PL)": 20
},
"visitsByDevice": {
"desktop": 90,
"mobile": 66
},
"visitsByBrowser": {
"Chrome": 110,
"Safari": 46
},
"visitsByOs": {
"Windows": 70,
"macOS": 60
},
"visitsByReferer": {
"linkedin.com": 80,
"twitter.com": 40
},
"utmSources": {
"linkedin": 80,
"twitter": 40
},
"utmMediums": {
"social": 120
},
"utmCampaigns": {
"spring_sale": 120
},
"qrScans": 12,
"visitsByLanguage": {
"pl": 100,
"en": 56
}
}
}
```
#### `DELETE /api/short-url/links/{idOrKey}`
Permanently delete a short URL by its database `id` or short `url_key`.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/promo26 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{ "message": "Short URL deleted successfully." }
{
"message": "Short URL deleted successfully."
}
```
### Error Responses
@@ -758,6 +907,7 @@ curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
| HTTP Code | Reason |
|---|---|
| `401 Unauthorized` | Missing or invalid API key |
| `404 Not Found` | Short URL not found |
| `422 Unprocessable Entity` | Validation error (see `errors` field in response) |
| `503 Service Unavailable` | REST API is disabled in Settings |
@@ -856,11 +1006,13 @@ You can also pre-configure all parameters via your `.env` file:
| Environment Variable | Config Path | Default | Description |
|---|---|---|---|
| `SHORT_URL_PREFIX` | `route_prefix` | `'s'` | URL prefix for short URL redirects. |
| `SHORT_URL_SITE_NAME` | `site_name` | `null` | Brand/Site name override for warning/interstitial pages. |
| `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` | `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_QUEUE_NAME` | `queue_name` | `'default'` | Queue name to which visit tracking jobs are dispatched. |
| `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). |
@@ -871,6 +1023,10 @@ You can also pre-configure all parameters via your `.env` file:
| `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. |
| `SHORT_URL_DEEP_LINKING_ENABLED` | `deep_linking.enabled` | `false` | Enable serving domain association files for deep linking. |
> [!TIP]
> **Database Configuration Preferred**: Avoid configuring large, multi-line JSON blocks (such as `apple-app-site-association` and `assetlinks.json`) via `.env` file environment variables as it is error-prone and can cause parsing issues. The recommended approach is to configure them dynamically via the **Filament Settings Panel**, which stores them securely in the database with real-time JSON validation.
---
@@ -1015,6 +1171,20 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### v3.3.0
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
### v3.2.0
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
- **REST API Throttling & Rate Limiting** — Configured built-in route middleware to rate limit the Developer REST API to 60 requests per minute (`throttle:60,1`) to protect from abuse.
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
### v3.1.0
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.

View File

@@ -226,37 +226,6 @@ return [
*/
'deep_linking' => [
'enabled' => env('SHORT_URL_DEEP_LINKING_ENABLED', false),
'aasa_json' => env('SHORT_URL_AASA_JSON', <<<'JSON'
{
"applinks": {
"apps": [],
"details": [
{
"appID": "YOUR_TEAM_ID.com.yourcompany.app",
"paths": [
"/s/*"
]
}
]
}
}
JSON),
'assetlinks_json' => env('SHORT_URL_ASSETLINKS_JSON', <<<'JSON'
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.app",
"sha256_cert_fingerprints": [
"14:6D:E9:..."
]
}
}
]
JSON),
],
];

File diff suppressed because one or more lines are too long

View File

@@ -302,6 +302,16 @@ return [
'security_section_title' => 'Security Controls',
'password' => 'Access Password',
'password_helper' => 'Require visitors to enter a password before being redirected.',
'confirm_password' => 'Confirm Password',
'new_password' => 'New Password',
'change_password' => 'Change Password',
'remove_password' => 'Remove Password',
'password_status_active' => 'Password protection is enabled.',
'set_password' => 'Set Password',
'cancel' => 'Cancel',
'confirm' => 'Confirm',
'password_required_error' => 'Password is required.',
'password_mismatch_error' => 'Passwords do not match.',
'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',
@@ -323,6 +333,25 @@ return [
'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 Advanced Targeting Builder
'targeting_rules' => 'Targeting Rules',
'add_filter' => 'Add a filter',
'match' => 'Match',
'match_or' => 'Any of the following filters (OR)',
'match_and' => 'All the following filters (AND)',
'filter_device' => 'Device',
'filter_platform' => 'Platform',
'filter_country' => 'Country',
'filter_language' => 'Browser Language',
'direct_to_url' => 'Direct to URL',
'select_devices' => 'Select devices',
'select_platforms' => 'Select operating systems',
'select_countries' => 'Select countries',
'select_languages' => 'Select browser languages',
'device_desktop_label' => 'Desktop',
'device_mobile_label' => 'Smartphone',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Performance & Security',
'settings_section_aggregation' => 'High-Traffic Log Management',
@@ -402,6 +431,8 @@ return [
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
'webhook_show_payload' => 'Show example JSON payload',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',

View File

@@ -299,6 +299,16 @@ return [
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'confirm_password' => 'Potwierdź hasło',
'new_password' => 'Nowe hasło',
'change_password' => 'Zmień hasło',
'remove_password' => 'Usuń hasło',
'password_status_active' => 'Ochrona hasłem jest włączona.',
'set_password' => 'Ustaw hasło',
'cancel' => 'Anuluj',
'confirm' => 'Potwierdź',
'password_required_error' => 'Hasło jest wymagane.',
'password_mismatch_error' => 'Hasła nie są identyczne.',
'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',
@@ -320,6 +330,25 @@ return [
'rotation_weight' => 'Waga ruchu',
'rotation_weight_helper' => 'Procent ruchu kierowany na ten URL (np. 50 dla 50%). Wagi są bilansowane proporcjonalnie.',
// New Advanced Targeting Builder
'targeting_rules' => 'Reguły targetowania',
'add_filter' => 'Dodaj filtr',
'match' => 'Warunek dopasowania',
'match_or' => 'Dowolny z poniższych filtrów (OR)',
'match_and' => 'Wszystkie poniższe filtry (AND)',
'filter_device' => 'Urządzenie',
'filter_platform' => 'Platforma (System operacyjny)',
'filter_country' => 'Kraj',
'filter_language' => 'Język przeglądarki',
'direct_to_url' => 'Przekieruj na URL',
'select_devices' => 'Wybierz urządzenia',
'select_platforms' => 'Wybierz systemy operacyjne',
'select_countries' => 'Wybierz kraje',
'select_languages' => 'Wybierz języki przeglądarki',
'device_desktop_label' => 'Komputer stacjonarny',
'device_mobile_label' => 'Smartfon',
'device_tablet_label' => 'Tablet',
// New Settings Page Fields
'settings_tab_advanced' => 'Wydajność i bezpieczeństwo',
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
@@ -399,6 +428,8 @@ return [
'marketing_webhooks_title' => 'Integracja Webhook linku',
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
'webhook_url' => 'Dedykowany Webhook URL',
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',

View File

@@ -0,0 +1,108 @@
@php
$rawJson = '{
"event": "visited",
"timestamp": "2026-06-04T12:00:00+02:00",
"short_url": {
"id": 12,
"destination_url": "https://example.com/some-page",
"url_key": "promo26",
"short_url": "https://yoursite.com/s/promo26",
"total_visits": 150,
"unique_visits": 120
},
"visit": {
"id": 345,
"visited_at": "2026-06-04T12:00:00+02:00",
"device_type": "mobile",
"browser": "Chrome",
"browser_version": "120.0",
"operating_system": "Android",
"operating_system_version": "14",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://t.co/",
"referer_host": "t.co",
"utm_source": "twitter",
"utm_medium": "social",
"utm_campaign": "summer_sale",
"utm_term": null,
"utm_content": "banner_ad",
"is_qr_scan": false,
"browser_language": "pl"
}
}';
@endphp
<div class="space-y-2 mt-4">
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.webhook_show_payload') ?? 'Show example JSON payload' }}
</label>
<div
x-data="{
copied: false,
rawJson: @js($rawJson),
copy() {
navigator.clipboard.writeText(this.rawJson);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
}
}"
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
>
<!-- Copy Button in Top Right -->
<button
type="button"
x-on:click="copy"
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
title="Copy payload to clipboard"
>
<!-- Copy Icon -->
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<!-- Check Icon -->
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Syntax Highlighted Payload -->
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"short_url"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
},
<span style="color: #f43f5e;">"visit"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
}
}</code></pre>
</div>
</div>

View File

@@ -14,10 +14,16 @@
@php
$pct = $totalVisits > 0 ? round($count / $totalVisits * 100) : 0;
$translatedCountry = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryTranslation($country);
$code = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsRightBreakdown::getCountryCode($country);
@endphp
<div>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $translatedCountry }}</span>
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if ($code)
<img src="https://flagcdn.com/h20/{{ strtolower($code) }}.webp" class="w-5 h-auto rounded-sm inline-block" alt="{{ $translatedCountry }}" />
@endif
<span>{{ $translatedCountry }}</span>
</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">

View File

@@ -1,6 +1,7 @@
<?php
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Http\Request;
@@ -24,14 +25,20 @@ Route::match(
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([AuthenticateShortUrlApi::class])
->middleware([
AuthenticateShortUrlApi::class,
'throttle:60,1',
])
->group(function () {
Route::get('links', [ShortUrlApiController::class, 'index']);
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
});
Route::post('admin/short-url/upload-logo', [ShortUrlApiController::class, 'uploadLogo'])
Route::post('admin/short-url/upload-logo', [ShortUrlLogoController::class, 'uploadLogo'])
->name('short-url.upload-logo')
->middleware(['web']);
@@ -41,5 +48,5 @@ Route::post('admin/short-url/log-debug', function (Request $request) {
return response()->json(['status' => 'ok']);
})->middleware(['web']);
Route::get('short-url/logo/{filename}', [ShortUrlApiController::class, 'serveLogo'])
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
->name('short-url.logo');

View File

@@ -2,16 +2,24 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
@@ -19,6 +27,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class ShortUrlForm
{
@@ -410,13 +419,153 @@ class ShortUrlForm
->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),
Hidden::make('password'),
Hidden::make('password_active_flag')
->dehydrated(false)
->afterStateHydrated(function (Hidden $component, $state, ?ShortUrl $record) {
$component->state($record && ! empty($record->password));
}),
Hidden::make('is_entering_password')
->dehydrated(false)
->default(false),
// State 1: Password is active (password_active_flag is true)
Group::make([
Placeholder::make('password_status')
->label(__('filament-short-url::default.password'))
->content(new HtmlString(
'<div class="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-semibold">'.
'<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'.
'<span>'.(__('filament-short-url::default.password_status_active') ?? 'Password protection is enabled.').'</span>'.
'</div>'
))
->columnSpan(1),
Actions::make([
Action::make('change_password')
->label(__('filament-short-url::default.change_password'))
->icon('heroicon-o-pencil')
->color('primary')
->action(function (Set $set) {
$set('password_active_flag', false);
$set('is_entering_password', true);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
}),
Action::make('remove_password')
->label(__('filament-short-url::default.remove_password'))
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(function (Set $set, ?ShortUrl $record) {
$set('password', null);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
$set('password_active_flag', false);
$set('is_entering_password', false);
if ($record) {
$record->password = null;
}
}),
])
->columnSpan(1),
])
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('password_active_flag')),
// State 2: No Password, Setup Button Group (password_active_flag is false, is_entering_password is false)
Group::make([
Actions::make([
Action::make('setup_password')
->label(__('filament-short-url::default.set_password'))
->icon('heroicon-o-key')
->color('primary')
->action(function (Set $set) {
$set('is_entering_password', true);
}),
])
->columnSpanFull(),
])
->columnSpanFull()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
// State 3: Entering Password Group (password_active_flag is false, is_entering_password is true)
Group::make([
Group::make([
TextInput::make('new_password_input')
->label(__('filament-short-url::default.new_password'))
->password()
->revealable()
->live()
->maxLength(255)
->dehydrated(false)
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
->columnSpan(1),
TextInput::make('new_password_confirmation_input')
->label(__('filament-short-url::default.confirm_password'))
->password()
->revealable()
->same('new_password_input')
->maxLength(255)
->dehydrated(false)
->required(fn (Get $get): bool => ! empty($get('new_password_input')))
->columnSpan(1),
])
->columns(2)
->columnSpanFull(),
Actions::make([
Action::make('confirm_password')
->label(__('filament-short-url::default.confirm'))
->icon('heroicon-o-check')
->color('success')
->action(function (Get $get, Set $set) {
$password = $get('new_password_input');
$confirm = $get('new_password_confirmation_input');
if (empty($password)) {
Notification::make()
->title(__('filament-short-url::default.password_required_error') ?? 'Password is required.')
->danger()
->send();
return;
}
if ($password !== $confirm) {
Notification::make()
->title(__('filament-short-url::default.password_mismatch_error') ?? 'Passwords do not match.')
->danger()
->send();
return;
}
$set('password', $password);
$set('password_active_flag', true);
$set('is_entering_password', false);
}),
Action::make('cancel_password')
->label(__('filament-short-url::default.cancel'))
->icon('heroicon-o-x-mark')
->color('gray')
->action(function (Get $get, Set $set, ?ShortUrl $record) {
if ($record && ! empty($record->password)) {
$set('password_active_flag', true);
} else {
$set('password_active_flag', false);
}
$set('is_entering_password', false);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
}),
])
->columnSpanFull(),
])
->columnSpanFull()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
Toggle::make('show_warning_page')
->label(__('filament-short-url::default.show_warning_page'))
@@ -425,117 +574,265 @@ class ShortUrlForm
->inline(false),
])->columns(2),
Section::make(__('filament-short-url::default.targeting_type'))
Section::make(__('filament-short-url::default.targeting_rules'))
->compact()
->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'),
'language' => __('filament-short-url::default.targeting_type_language'),
'rotation' => __('filament-short-url::default.targeting_type_rotation'),
])
->default('none')
->live()
->required(),
Repeater::make('targeting_rules')
->label(__('filament-short-url::default.targeting_rules'))
->hiddenLabel()
->defaultItems(0)
->reorderable(false)
->collapsible()
->collapsed()
->itemLabel(fn (array $state): ?string => filled($state['url'] ?? null) ? __('filament-short-url::default.direct_to_url').': '.$state['url'] : null)
->columns(12)
->afterStateHydrated(function (Repeater $component, $state) {
if (! is_array($state)) {
$component->state([]);
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'),
return;
}
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(function (): array {
$countries = __('filament-short-url::countries');
if (is_array($countries)) {
asort($countries, SORT_LOCALE_STRING);
// If it is legacy format (has 'type' key)
if (isset($state['type'])) {
$type = $state['type'];
$newRules = [];
return $countries;
if ($type === 'device') {
$devices = $state['device'] ?? [];
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
if ($mobileUrl) {
$newRules[] = [
'match' => 'or',
'url' => $mobileUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
],
];
}
return [];
})
->searchable()
->optionsLimit(300)
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->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.language')
->label(__('filament-short-url::default.language_targeting_rules'))
->schema([
Select::make('language_code')
->label(__('filament-short-url::default.language_code'))
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
if ($tabletUrl) {
$newRules[] = [
'match' => 'or',
'url' => $tabletUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['tablet']],
],
],
];
}
$desktopUrl = $devices['desktop'] ?? null;
if ($desktopUrl) {
$newRules[] = [
'match' => 'or',
'url' => $desktopUrl,
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['desktop']],
],
],
];
}
} elseif ($type === 'geo') {
foreach ($state['geo'] ?? [] as $geoRule) {
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
$newRules[] = [
'match' => 'or',
'url' => $geoRule['url'],
'filters' => [
[
'type' => 'country',
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
],
],
];
}
}
} elseif ($type === 'language') {
foreach ($state['language'] ?? [] as $langRule) {
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
$newRules[] = [
'match' => 'or',
'url' => $langRule['url'],
'filters' => [
[
'type' => 'language',
'data' => ['languages' => [strtolower($langRule['language_code'])]],
],
],
];
}
}
}
return [];
})
->searchable()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->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') === 'language'),
$component->state($newRules);
Repeater::make('targeting_rules.rotation')
->label(__('filament-short-url::default.rotation_targeting_rules'))
return;
}
if (! array_is_list($state)) {
$component->state([]);
return;
}
$component->state($state);
})
->schema([
Select::make('match')
->label(__('filament-short-url::default.match'))
->options([
'or' => __('filament-short-url::default.match_or'),
'and' => __('filament-short-url::default.match_and'),
])
->default('or')
->required()
->columnSpan(3),
TextInput::make('url')
->label(__('filament-short-url::default.rotation_url'))
->label(__('filament-short-url::default.direct_to_url'))
->url()
->required()
->maxLength(2048),
TextInput::make('weight')
->label(__('filament-short-url::default.rotation_weight'))
->helperText(__('filament-short-url::default.rotation_weight_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(1000)
->required()
->default(50),
->maxLength(2048)
->columnSpan(9),
Builder::make('filters')
->label(__('filament-short-url::default.add_filter'))
->hiddenLabel()
->addActionLabel(__('filament-short-url::default.add_filter'))
->reorderable(false)
->collapsible()
->blockNumbers(false)
->addBetweenAction(fn ($action) => $action->hidden())
->minItems(1)
->blocks([
Block::make('device')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_device')
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-device-phone-mobile')
->schema([
CheckboxList::make('devices')
->label(__('filament-short-url::default.select_devices'))
->hiddenLabel()
->options([
'desktop' => __('filament-short-url::default.device_desktop_label'),
'mobile' => __('filament-short-url::default.device_mobile_label'),
'tablet' => __('filament-short-url::default.device_tablet_label'),
])
->required()
->columns(3)
->columnSpanFull(),
])
->maxItems(1),
Block::make('platform')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_platform')
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-computer-desktop')
->schema([
CheckboxList::make('platforms')
->label(__('filament-short-url::default.select_platforms'))
->hiddenLabel()
->options([
'android' => 'Android',
'fire_os' => 'Fire OS',
'ios' => 'iOS / iPadOS',
'linux' => 'Linux',
'mac' => 'macOS',
'windows' => 'Windows',
])
->required()
->columns(3)
->columnSpanFull(),
])
->maxItems(1),
Block::make('country')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_country')
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-globe-alt')
->schema([
Select::make('countries')
->label(__('filament-short-url::default.select_countries'))
->hiddenLabel()
->multiple()
->searchable()
->allowHtml()
->options(function (): array {
$countries = __('filament-short-url::countries');
if (is_array($countries)) {
asort($countries, SORT_LOCALE_STRING);
$htmlOptions = [];
foreach ($countries as $code => $name) {
$lowerCode = strtolower($code);
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
}
return $htmlOptions;
}
return [];
})
->optionsLimit(300)
->required()
->columnSpanFull(),
])
->maxItems(1),
Block::make('language')
->label(fn (?array $state) => $state === null
? __('filament-short-url::default.filter_language')
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
)
->icon('heroicon-o-language')
->schema([
Select::make('languages')
->label(__('filament-short-url::default.select_languages'))
->hiddenLabel()
->multiple()
->searchable()
->options(function (): array {
$languages = __('filament-short-url::languages');
if (is_array($languages)) {
asort($languages, SORT_LOCALE_STRING);
return $languages;
}
return [];
})
->required()
->columnSpanFull(),
])
->maxItems(1),
])
->rules([
function () {
return function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
$types = collect($value)->pluck('type');
if ($types->duplicates()->isNotEmpty()) {
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
}
};
},
])
->columnSpanFull(),
])
->columns(2)
->visible(fn (Get $get): bool => $get('targeting_rules.type') === 'rotation'),
->columnSpanFull()
->default([]),
]),
]);
}
@@ -560,6 +857,24 @@ class ShortUrlForm
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))
->schema([
Placeholder::make('webhook_info')
->hiddenLabel()
->content(new HtmlString(
'<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info">'.
'<div class="mt-0.5 w-4" data-component-part="callout-icon">'.
'<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info">'.
'<path d="M8 0C3.58125 0 0 3.58125 0 8C0 12.4187 3.58125 16 8 16C12.4187 16 16 12.4187 16 8C16 3.58125 12.4187 0 8 0ZM8 14.5C4.41563 14.5 1.5 11.5841 1.5 8C1.5 4.41594 4.41563 1.5 8 1.5C11.5844 1.5 14.5 4.41594 14.5 8C14.5 11.5841 11.5844 14.5 8 14.5ZM9.25 10.5H8.75V7.75C8.75 7.3375 8.41563 7 8 7H7C6.5875 7 6.25 7.3375 6.25 7.75C6.25 8.1625 6.5875 8.5 7 8.5H7.25V10.5H6.75C6.3375 10.5 6 10.8375 6 11.25C6 11.6625 6.3375 12 6.75 12H9.25C9.66406 12 10 11.25C10 10.8359 9.66563 10.5 9.25 10.5ZM8 6C8.55219 6 9 5.55219 9 5C9 4.44781 8.55219 4 8 4C7.44781 4 7 4.44687 7 5C7 5.55313 7.44687 6 8 6Z"></path>'.
'</svg>'.
'</div>'.
'<div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content">'.
'<span data-as="p">'.
(__('filament-short-url::default.webhook_helper_alert') ?? 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).').
'</span>'.
'</div>'.
'</div>'
))
->columnSpanFull(),
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
@@ -567,6 +882,9 @@ class ShortUrlForm
->maxLength(2048)
->nullable()
->columnSpanFull(),
ViewField::make('webhook_payload_example')
->view('filament-short-url::webhook-payload-example')
->columnSpanFull(),
]),
]);
}

View File

@@ -107,4 +107,23 @@ class ShortUrlVisitsRightBreakdown extends Widget
return $englishName;
}
public static function getCountryCode(string $englishName): ?string
{
$englishName = trim($englishName);
try {
$enCountries = trans('filament-short-url::countries', [], 'en');
if (is_array($enCountries)) {
$flipped = array_change_key_case(array_flip($enCountries), CASE_LOWER);
$lookupKey = strtolower($englishName);
return $flipped[$lookupKey] ?? null;
}
} catch (\Throwable) {
// Ignore
}
return null;
}
}

View File

@@ -4,16 +4,11 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlApiController extends Controller
{
@@ -46,27 +41,7 @@ class ShortUrlApiController extends Controller
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'destination_url' => 'required|url|max:2048',
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
'notes' => 'nullable|string|max:1000',
'is_enabled' => 'nullable|boolean',
'redirect_status_code' => 'nullable|integer|in:301,302',
'single_use' => 'nullable|boolean',
'forward_query_params' => 'nullable|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'webhook_url' => 'nullable|url|max:2048',
'targeting_rules' => 'nullable|array',
'password' => 'nullable|string|max:255',
'show_warning_page' => 'nullable|boolean',
'track_visits' => 'nullable|boolean',
'track_browser_language' => 'nullable|boolean',
'pixels' => 'nullable|array',
'pixels.*' => 'integer|exists:short_url_pixels,id',
]);
$validated = $request->validate($this->getValidationRules($request));
$pixelIds = $validated['pixels'] ?? [];
@@ -88,12 +63,41 @@ class ShortUrlApiController extends Controller
], 201);
}
/**
* Display the specified short URL.
*/
public function show(string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
return response()->json([
'data' => $this->transformLink($shortUrl),
]);
}
/**
* Display statistics/analytics for the specified short URL.
*/
public function stats(Request $request, string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
$stats = $shortUrl->getCachedStats(
dateFrom: $request->query('date_from'),
dateTo: $request->query('date_to')
);
return response()->json([
'data' => $stats,
]);
}
/**
* Remove the specified short URL.
*/
public function destroy(int $id): JsonResponse
public function destroy(string|int $idOrKey): JsonResponse
{
$shortUrl = ShortUrl::findOrFail($id);
$shortUrl = $this->findLink($idOrKey);
$shortUrl->delete();
return response()->json([
@@ -101,6 +105,253 @@ class ShortUrlApiController extends Controller
]);
}
/**
* Update the specified short URL.
*/
public function update(Request $request, string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
// Merge existing dates for validation if only one of them is sent
if ($request->has('expires_at') && ! $request->has('activated_at')) {
$request->merge(['activated_at' => $shortUrl->activated_at?->toIso8601String()]);
}
if ($request->has('activated_at') && ! $request->has('expires_at')) {
$request->merge(['expires_at' => $shortUrl->expires_at?->toIso8601String()]);
}
$validated = $request->validate($this->getValidationRules($request, $shortUrl));
$pixelIds = $validated['pixels'] ?? null;
unset($validated['pixels']);
$shortUrl->update($validated);
if ($pixelIds !== null) {
$shortUrl->pixels()->sync($pixelIds);
}
return response()->json([
'message' => 'Short URL updated successfully.',
'data' => $this->transformLink($shortUrl->fresh()),
]);
}
/**
* Find a ShortUrl by database ID or URL key.
*/
private function findLink(string|int $idOrKey): ShortUrl
{
if (is_numeric($idOrKey)) {
return ShortUrl::findOrFail((int) $idOrKey);
}
$link = ShortUrl::where('url_key', $idOrKey)->first();
if (! $link) {
abort(404, 'Short URL not found.');
}
return $link;
}
/**
* Get the validation rules for creating or updating a short URL.
*
* @return array<string, mixed>
*/
private function getValidationRules(Request $request, ?ShortUrl $model = null): array
{
$countries = __('filament-short-url::countries');
$countryRule = is_array($countries) && ! empty($countries)
? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries))))
: 'string|max:10';
$languages = __('filament-short-url::languages');
$languageRule = is_array($languages) && ! empty($languages)
? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages))))
: 'string|max:10';
$isUpdate = $model !== null;
// Apply after_or_equal:today only if the activated_at date is actually being changed
$activatedAtRule = 'nullable|date';
if ($isUpdate) {
if ($request->has('activated_at') && $request->input('activated_at') !== $model->activated_at?->toIso8601String() && $request->input('activated_at') !== $model->activated_at?->toDateTimeString()) {
$activatedAtRule .= '|after_or_equal:today';
}
} else {
$activatedAtRule .= '|after_or_equal:today';
}
$uniqueKeyRule = 'unique:short_urls,url_key';
if ($isUpdate) {
$uniqueKeyRule .= ','.$model->id;
}
$isLegacyRules = is_array($request->input('targeting_rules')) && isset($request->input('targeting_rules')['type']);
$targetingRules = [];
if ($isLegacyRules) {
$targetingRules = [
'targeting_rules' => 'nullable|array',
'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation',
'targeting_rules.device' => 'nullable|array',
'targeting_rules.device.mobile' => 'nullable|url|max:2048',
'targeting_rules.device.tablet' => 'nullable|url|max:2048',
'targeting_rules.device.desktop' => 'nullable|url|max:2048',
'targeting_rules.device.ios' => 'nullable|url|max:2048',
'targeting_rules.device.android' => 'nullable|url|max:2048',
'targeting_rules.geo' => 'nullable|array',
'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule,
'targeting_rules.geo.*.url' => 'required_with:targeting_rules.geo|url|max:2048',
'targeting_rules.language' => 'nullable|array',
'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule,
'targeting_rules.language.*.url' => 'required_with:targeting_rules.language|url|max:2048',
'targeting_rules.rotation' => 'nullable|array',
'targeting_rules.rotation.*.url' => 'required_with:targeting_rules.rotation|url|max:2048',
'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000',
];
} else {
$targetingRules = [
'targeting_rules' => [
'nullable',
'array',
function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
foreach ($value as $index => $rule) {
if (! is_array($rule)) {
$fail("Targeting rule at index {$index} must be an array.");
continue;
}
$allowedKeys = ['match', 'url', 'filters'];
$invalidKeys = array_diff(array_keys($rule), $allowedKeys);
if (! empty($invalidKeys)) {
$fail("Invalid keys in targeting rule at index {$index}: ".implode(', ', $invalidKeys));
}
}
},
],
'targeting_rules.*.match' => 'required_with:targeting_rules|string|in:or,and',
'targeting_rules.*.url' => 'required_with:targeting_rules|url|max:2048',
'targeting_rules.*.filters' => [
'required_with:targeting_rules',
'array',
'min:1',
function (string $attribute, $value, \Closure $fail) {
if (! is_array($value)) {
return;
}
$types = collect($value)->pluck('type');
if ($types->duplicates()->isNotEmpty()) {
$fail('Each filter type (device, platform, country, language) can only be added once.');
}
foreach ($value as $index => $filter) {
if (! is_array($filter)) {
$fail("Filter at index {$index} must be an array.");
continue;
}
$allowedFilterKeys = ['type', 'data'];
$invalidFilterKeys = array_diff(array_keys($filter), $allowedFilterKeys);
if (! empty($invalidFilterKeys)) {
$fail("Invalid keys in filter at index {$index}: ".implode(', ', $invalidFilterKeys));
continue;
}
$type = $filter['type'] ?? null;
$data = $filter['data'] ?? null;
if (! in_array($type, ['device', 'platform', 'country', 'language'])) {
continue;
}
if (! is_array($data)) {
$fail("Filter data for type '{$type}' must be an array.");
continue;
}
$allowedKeys = match ($type) {
'device' => ['devices'],
'platform' => ['platforms'],
'country' => ['countries'],
'language' => ['languages'],
};
$invalidKeys = array_diff(array_keys($data), $allowedKeys);
if (! empty($invalidKeys)) {
$fail("Invalid keys in data for filter '{$type}': ".implode(', ', $invalidKeys));
continue;
}
$mainKey = $allowedKeys[0];
if (! isset($data[$mainKey]) || ! is_array($data[$mainKey]) || empty($data[$mainKey])) {
$fail("Filter '{$type}' requires a non-empty array named '{$mainKey}'.");
continue;
}
}
},
],
'targeting_rules.*.filters.*.type' => 'required_with:targeting_rules|string|in:device,platform,country,language',
'targeting_rules.*.filters.*.data' => 'required_with:targeting_rules|array',
'targeting_rules.*.filters.*.data.devices' => 'nullable|array',
'targeting_rules.*.filters.*.data.devices.*' => 'string|in:desktop,mobile,tablet',
'targeting_rules.*.filters.*.data.platforms' => 'nullable|array',
'targeting_rules.*.filters.*.data.platforms.*' => 'string|in:android,fire_os,ios,linux,mac,windows',
'targeting_rules.*.filters.*.data.countries' => 'nullable|array',
'targeting_rules.*.filters.*.data.countries.*' => 'string|'.$countryRule,
'targeting_rules.*.filters.*.data.languages' => 'nullable|array',
'targeting_rules.*.filters.*.data.languages.*' => 'string|'.$languageRule,
];
}
$rules = [
'destination_url' => ($isUpdate ? 'sometimes|' : '').'required|url|max:2048',
'url_key' => ($isUpdate ? 'sometimes|' : '').'nullable|string|alpha_dash|max:32|'.$uniqueKeyRule,
'notes' => 'nullable|string|max:1000',
'is_enabled' => 'nullable|boolean',
'redirect_status_code' => 'nullable|integer|in:301,302',
'single_use' => 'nullable|boolean',
'forward_query_params' => 'nullable|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => $activatedAtRule,
'expires_at' => 'nullable|date|after_or_equal:activated_at',
'webhook_url' => 'nullable|url|max:2048',
];
$rules = array_merge($rules, $targetingRules);
$rules = array_merge($rules, [
'password' => 'nullable|string|max:255',
'show_warning_page' => 'nullable|boolean',
'auto_open_app_mobile' => 'nullable|boolean',
'ga_tracking_id' => 'nullable|string|regex:/^G-[A-Z0-9]+$/',
'track_visits' => 'nullable|boolean',
'track_ip_address' => 'nullable|boolean',
'track_browser' => 'nullable|boolean',
'track_browser_version' => 'nullable|boolean',
'track_operating_system' => 'nullable|boolean',
'track_operating_system_version' => 'nullable|boolean',
'track_device_type' => 'nullable|boolean',
'track_referer_url' => 'nullable|boolean',
'track_browser_language' => 'nullable|boolean',
'pixels' => 'nullable|array',
'pixels.*' => 'integer|exists:short_url_pixels,id',
]);
return $rules;
}
/**
* Transform a ShortUrl model to API response array.
*/
@@ -124,7 +375,16 @@ class ShortUrlApiController extends Controller
'targeting_rules' => $link->targeting_rules,
'password' => $link->password,
'show_warning_page' => (bool) $link->show_warning_page,
'auto_open_app_mobile' => (bool) $link->auto_open_app_mobile,
'ga_tracking_id' => $link->ga_tracking_id,
'track_visits' => (bool) $link->track_visits,
'track_ip_address' => (bool) $link->track_ip_address,
'track_browser' => (bool) $link->track_browser,
'track_browser_version' => (bool) $link->track_browser_version,
'track_operating_system' => (bool) $link->track_operating_system,
'track_operating_system_version' => (bool) $link->track_operating_system_version,
'track_device_type' => (bool) $link->track_device_type,
'track_referer_url' => (bool) $link->track_referer_url,
'track_browser_language' => (bool) $link->track_browser_language,
'pixels' => $pixels->map(fn ($p) => [
'id' => $p->id,
@@ -171,223 +431,4 @@ class ShortUrlApiController extends Controller
}
}
}
/**
* Upload QR logo file from admin panel.
*/
public function uploadLogo(Request $request): JsonResponse
{
Log::info('ShortUrlApiController::uploadLogo called', [
'auth_check' => auth()->check(),
'user_id' => auth()->id(),
'has_file' => $request->hasFile('logo'),
'all_files' => array_keys($request->allFiles()),
]);
if (! auth()->check()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'logo' => 'required|image|max:10240',
]);
if ($request->hasFile('logo')) {
$file = $request->file('logo');
$tempPath = $file->getRealPath();
$filename = Str::random(40).'.webp';
$targetPath = 'short-urls/tmp/'.$filename;
$processed = $this->processLogo($tempPath, $targetPath);
if ($processed) {
$path = $targetPath;
} else {
// Fallback: store raw file if image processing fails
$path = $file->store('short-urls/tmp', 'public');
}
$url = route('short-url.logo', ['filename' => basename($path)]);
return response()->json([
'path' => $path,
'url' => $url,
]);
}
return response()->json(['error' => 'No file uploaded'], 400);
}
/**
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
*/
private function processLogo(string $filePath, string $targetPath): bool
{
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
return $this->processLogoWithImagick($filePath, $targetPath);
}
if (extension_loaded('gd') && function_exists('gd_info')) {
return $this->processLogoWithGd($filePath, $targetPath);
}
return false;
}
/**
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
*/
private function processLogoWithImagick(string $filePath, string $targetPath): bool
{
try {
$imagick = new \Imagick($filePath);
// Get original dimensions
$width = $imagick->getImageWidth();
$height = $imagick->getImageHeight();
// Calculate new dimensions
$maxDim = 800;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
$imagick->scaleImage($newWidth, $newHeight);
}
// Convert to WebP format
$imagick->setImageFormat('webp');
$imagick->setImageCompressionQuality(85);
// Get image data as blob
$webpData = $imagick->getImageBlob();
// Clear resources
$imagick->clear();
$imagick->destroy();
if ($webpData) {
return Storage::disk('public')->put($targetPath, $webpData);
}
} catch (\Throwable $e) {
// Fall back to GD or raw store
}
return false;
}
/**
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
*/
private function processLogoWithGd(string $filePath, string $targetPath): bool
{
// 1. Get original dimensions and type
$info = @getimagesize($filePath);
if (! $info) {
return false;
}
[$width, $height, $type] = $info;
// 2. Load image based on type
switch ($type) {
case IMAGETYPE_JPEG:
$src = @imagecreatefromjpeg($filePath);
break;
case IMAGETYPE_PNG:
$src = @imagecreatefrompng($filePath);
break;
case IMAGETYPE_WEBP:
$src = @imagecreatefromwebp($filePath);
break;
case IMAGETYPE_GIF:
$src = @imagecreatefromgif($filePath);
break;
default:
return false;
}
if (! $src) {
return false;
}
// 3. Calculate new dimensions
$maxDim = 800;
$newWidth = $width;
$newHeight = $height;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
}
// 4. Create new truecolor image
$dst = imagecreatetruecolor($newWidth, $newHeight);
if (! $dst) {
imagedestroy($src);
return false;
}
// Preserve transparency for PNG and WebP
imagealphablending($dst, false);
imagesavealpha($dst, true);
// Resize
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// 5. Save as WebP
$disk = Storage::disk('public');
ob_start();
$saved = imagewebp($dst, null, 85);
$webpData = ob_get_clean();
// Free memory
imagedestroy($src);
imagedestroy($dst);
if ($saved && $webpData !== false) {
return $disk->put($targetPath, $webpData);
}
return false;
}
/**
* Serve the uploaded QR logo.
*/
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
{
// Prevent directory traversal attacks
$filename = basename($filename);
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
abort(400, 'Invalid filename');
}
$disk = Storage::disk('public');
$path = 'short-urls/logos/'.$filename;
if (! $disk->exists($path)) {
$path = 'short-urls/tmp/'.$filename;
if (! $disk->exists($path)) {
abort(404);
}
}
return $disk->response($path, null, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
]);
}
}

View File

@@ -0,0 +1,240 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlLogoController extends Controller
{
/**
* Upload QR logo file from admin panel.
*/
public function uploadLogo(Request $request): JsonResponse
{
Log::info('ShortUrlLogoController::uploadLogo called', [
'auth_check' => auth()->check(),
'user_id' => auth()->id(),
'has_file' => $request->hasFile('logo'),
'all_files' => array_keys($request->allFiles()),
]);
if (! auth()->check()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$request->validate([
'logo' => 'required|image|max:10240',
]);
if ($request->hasFile('logo')) {
$file = $request->file('logo');
$tempPath = $file->getRealPath();
$filename = Str::random(40).'.webp';
$targetPath = 'short-urls/tmp/'.$filename;
$processed = $this->processLogo($tempPath, $targetPath);
if ($processed) {
$path = $targetPath;
} else {
// Fallback: store raw file if image processing fails
$path = $file->store('short-urls/tmp', 'public');
}
$url = route('short-url.logo', ['filename' => basename($path)]);
return response()->json([
'path' => $path,
'url' => $url,
]);
}
return response()->json(['error' => 'No file uploaded'], 400);
}
/**
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
*/
private function processLogo(string $filePath, string $targetPath): bool
{
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
return $this->processLogoWithImagick($filePath, $targetPath);
}
if (extension_loaded('gd') && function_exists('gd_info')) {
return $this->processLogoWithGd($filePath, $targetPath);
}
return false;
}
/**
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
*/
private function processLogoWithImagick(string $filePath, string $targetPath): bool
{
try {
$imagick = new \Imagick($filePath);
// Get original dimensions
$width = $imagick->getImageWidth();
$height = $imagick->getImageHeight();
// Calculate new dimensions
$maxDim = 800;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
$imagick->scaleImage($newWidth, $newHeight);
}
// Convert to WebP format
$imagick->setImageFormat('webp');
$imagick->setImageCompressionQuality(85);
// Get image data as blob
$webpData = $imagick->getImageBlob();
// Clear resources
$imagick->clear();
$imagick->destroy();
if ($webpData) {
return Storage::disk('public')->put($targetPath, $webpData);
}
} catch (\Throwable $e) {
// Fall back to GD or raw store
}
return false;
}
/**
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
*/
private function processLogoWithGd(string $filePath, string $targetPath): bool
{
// 1. Get original dimensions and type
$info = @getimagesize($filePath);
if (! $info) {
return false;
}
[$width, $height, $type] = $info;
// 2. Load image based on type
switch ($type) {
case IMAGETYPE_JPEG:
$src = @imagecreatefromjpeg($filePath);
break;
case IMAGETYPE_PNG:
$src = @imagecreatefrompng($filePath);
break;
case IMAGETYPE_WEBP:
$src = @imagecreatefromwebp($filePath);
break;
case IMAGETYPE_GIF:
$src = @imagecreatefromgif($filePath);
break;
default:
return false;
}
if (! $src) {
return false;
}
// 3. Calculate new dimensions
$maxDim = 800;
$newWidth = $width;
$newHeight = $height;
if ($width > $maxDim || $height > $maxDim) {
if ($width > $height) {
$newWidth = $maxDim;
$newHeight = (int) round(($height * $maxDim) / $width);
} else {
$newHeight = $maxDim;
$newWidth = (int) round(($width * $maxDim) / $height);
}
}
// 4. Create new truecolor image
$dst = imagecreatetruecolor($newWidth, $newHeight);
if (! $dst) {
imagedestroy($src);
return false;
}
// Preserve transparency for PNG and WebP
imagealphablending($dst, false);
imagesavealpha($dst, true);
// Resize
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
// 5. Save as WebP
$disk = Storage::disk('public');
ob_start();
$saved = imagewebp($dst, null, 85);
$webpData = ob_get_clean();
// Free memory
imagedestroy($src);
imagedestroy($dst);
if ($saved && $webpData !== false) {
return $disk->put($targetPath, $webpData);
}
return false;
}
/**
* Serve the uploaded QR logo.
*/
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
{
// Prevent directory traversal attacks
$filename = basename($filename);
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
abort(400, 'Invalid filename');
}
$disk = Storage::disk('public');
$path = 'short-urls/logos/'.$filename;
if (! $disk->exists($path)) {
$path = 'short-urls/tmp/'.$filename;
if (! $disk->exists($path)) {
abort(404);
}
}
return $disk->response($path, null, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
]);
}
}

View File

@@ -113,9 +113,9 @@ class ShortUrlRedirectController extends Controller
// App Linking / Deep Links Auto-Open Check
if ($shortUrl->auto_open_app_mobile) {
$uaParser = app(UserAgentParser::class);
$parsedUa = $uaParser->parse($request->userAgent() ?? '');
$deviceType = $uaParser->getDeviceType($request->userAgent() ?? '');
if ($parsedUa['device_type'] === 'mobile' || $parsedUa['device_type'] === 'tablet') {
if ($deviceType === 'mobile' || $deviceType === 'tablet') {
$matchedApp = AppLinkingEngine::matchApp($destination);
if ($matchedApp !== null) {
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);

View File

@@ -38,7 +38,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
public readonly ShortUrl $shortUrl,
public readonly string $ipAddress,
public readonly string $userAgent,
public readonly ?string $refererUrl,
public readonly ?string $refererUrl = null,
public readonly ?string $countryCode = null,
public readonly ?string $city = null,
public readonly ?string $utmSource = null,

View File

@@ -407,93 +407,198 @@ class ShortUrl extends Model
return $this->destination_url;
}
$type = $rules['type'] ?? 'none';
// Backward compatibility check: legacy type-based strategy
if (is_array($rules) && isset($rules['type'])) {
$type = $rules['type'] ?? 'none';
if ($type === 'device') {
$parser = app(UserAgentParser::class);
$deviceType = $parser->parse($request->userAgent() ?? '')['device_type'];
if ($type === 'device') {
$parser = app(UserAgentParser::class);
$deviceType = $parser->getDeviceType($request->userAgent() ?? '');
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 ($deviceType === 'mobile') {
return $rules['device']['mobile'] ?? $rules['device']['ios'] ?? $this->destination_url;
}
}
}
if ($type === 'language') {
$acceptedLanguages = $request->getLanguages();
// Pass 1: Exact match (e.g. "en-us" matches "en-us" rule, or "pl" matches "pl" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
if ($deviceType === 'tablet') {
return $rules['device']['tablet'] ?? $rules['device']['android'] ?? $this->destination_url;
}
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $acceptedLanguage) {
return $rule['url'] ?? $this->destination_url;
}
}
return $rules['device']['desktop'] ?? $this->destination_url;
}
// Pass 2: Primary language fallback match (e.g. "en-us" matches general "en" rule)
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
if ($type === 'geo') {
$countryCode = ClientIpExtractor::getCountryCode($request);
if (! $countryCode) {
$ip = ClientIpExtractor::getIp($request);
$geo = app(GeoIpService::class)->resolve($ip);
$countryCode = $geo['country_code'] ?? null;
}
$parts = explode('-', $acceptedLanguage);
$primaryLang = strtolower(trim($parts[0]));
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $primaryLang) {
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;
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 === 'language') {
$acceptedLanguages = $request->getLanguages();
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $acceptedLanguage) {
return $rule['url'] ?? $this->destination_url;
}
}
}
foreach ($acceptedLanguages as $acceptedLanguage) {
$acceptedLanguage = strtolower(trim(str_replace('_', '-', $acceptedLanguage)));
if (empty($acceptedLanguage)) {
continue;
}
$parts = explode('-', $acceptedLanguage);
$primaryLang = strtolower(trim($parts[0]));
foreach ($rules['language'] ?? [] as $rule) {
$ruleLang = strtolower(trim(str_replace('_', '-', $rule['language_code'] ?? '')));
if ($ruleLang === $primaryLang) {
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;
}
// Evaluate new multi-filter rule engine
if (! is_array($rules)) {
return $this->destination_url;
}
// Lazy-loaded request properties for maximum performance
$parsedUserAgent = null;
$deviceType = null;
$platformOs = null;
$countryCode = null;
$browserLanguages = null;
foreach ($rules as $rule) {
$filters = $rule['filters'] ?? [];
if (empty($filters)) {
continue;
}
$matchType = $rule['match'] ?? 'or';
$ruleMatches = $matchType === 'and'; // 'and' defaults to true (requires all matching), 'or' defaults to false
foreach ($filters as $filter) {
$filterType = $filter['type'] ?? '';
$filterData = $filter['data'] ?? [];
$filterMatches = false;
if ($filterType === 'device') {
if ($deviceType === null) {
$deviceType = app(UserAgentParser::class)->getDeviceType($request->userAgent() ?? '');
}
$filterMatches = in_array($deviceType, $filterData['devices'] ?? []);
} elseif ($filterType === 'platform') {
if ($platformOs === null) {
$rawOs = app(UserAgentParser::class)->getOs($request->userAgent() ?? '') ?? '';
$platformOs = match (true) {
stripos($rawOs, 'Windows') !== false => 'windows',
stripos($rawOs, 'Fire OS') !== false => 'fire_os',
stripos($rawOs, 'iOS') !== false || stripos($rawOs, 'iPad') !== false => 'ios',
stripos($rawOs, 'Mac') !== false => 'mac',
stripos($rawOs, 'Android') !== false => 'android',
stripos($rawOs, 'Linux') !== false => 'linux',
default => strtolower($rawOs),
};
}
$filterMatches = in_array($platformOs, $filterData['platforms'] ?? []);
} elseif ($filterType === 'country') {
if ($countryCode === null) {
$countryCode = ClientIpExtractor::getCountryCode($request);
if (! $countryCode) {
$ip = ClientIpExtractor::getIp($request);
$geo = app(GeoIpService::class)->resolve($ip);
$countryCode = $geo['country_code'] ?? '';
}
$countryCode = strtoupper(trim($countryCode));
}
$filterMatches = in_array($countryCode, array_map('strtoupper', $filterData['countries'] ?? []));
} elseif ($filterType === 'language') {
if ($browserLanguages === null) {
$browserLanguages = array_map(function ($lang) {
return strtolower(trim(str_replace('_', '-', $lang)));
}, $request->getLanguages());
}
$filterLangs = array_map('strtolower', $filterData['languages'] ?? []);
// Pass 1: Exact match
foreach ($browserLanguages as $browserLang) {
if (in_array($browserLang, $filterLangs)) {
$filterMatches = true;
break;
}
}
// Pass 2: Fallback prefix match
if (! $filterMatches) {
foreach ($browserLanguages as $browserLang) {
$primaryLang = explode('-', $browserLang)[0];
if (in_array($primaryLang, $filterLangs)) {
$filterMatches = true;
break;
}
}
}
}
if ($matchType === 'and') {
if (! $filterMatches) {
$ruleMatches = false;
break; // fail fast
}
} else { // 'or'
if ($filterMatches) {
$ruleMatches = true;
break; // succeed fast
}
}
}
if ($ruleMatches && ! empty($rule['url'])) {
return $rule['url'];
}
}
return $this->destination_url;

View File

@@ -1,7 +1,6 @@
<?php
/**
* @package janczakb/filament-short-url
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)

View File

@@ -38,6 +38,16 @@ class UserAgentParser
];
}
public function getDeviceType(string $userAgent): string
{
return $this->parseDeviceType($userAgent);
}
public function getOs(string $userAgent): ?string
{
return $this->parseOs($userAgent);
}
private function parseBrowser(string $ua): ?string
{
// Order matters — check specific first, generic last
@@ -103,6 +113,7 @@ class UserAgentParser
private function parseOs(string $ua): ?string
{
return match (true) {
(stripos($ua, 'Silk/') !== false || stripos($ua, 'Kindle') !== false) => 'Fire OS',
stripos($ua, 'Windows') !== false => 'Windows',
stripos($ua, 'iPad') !== false => 'iPadOS',
stripos($ua, 'iPhone') !== false => 'iOS',

View File

@@ -2,6 +2,7 @@
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Support\Facades\Queue;
@@ -74,7 +75,7 @@ it('allows API requests with a valid key', function () {
it('allows creating a short link programmatically via POST', function () {
Queue::fake([SendWebhookJob::class]);
$pixel = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::create([
$pixel = ShortUrlPixel::create([
'name' => 'Meta Pixel Test',
'type' => 'meta',
'pixel_id' => '12345',
@@ -131,3 +132,218 @@ it('allows deleting a short link via DELETE', function () {
'id' => $link->id,
]);
});
it('allows updating a short link via PUT', function () {
$link = ShortUrl::create([
'destination_url' => 'https://initial-destination.com',
'url_key' => 'initkey',
'notes' => 'Initial notes',
]);
$response = $this->putJson("/api/short-url/links/{$link->id}", [
'destination_url' => 'https://updated-destination.com',
'notes' => 'Updated notes',
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(200)
->assertJsonFragment(['destination_url' => 'https://updated-destination.com'])
->assertJsonFragment(['notes' => 'Updated notes'])
->assertJsonFragment(['url_key' => 'initkey']);
$this->assertDatabaseHas('short_urls', [
'id' => $link->id,
'destination_url' => 'https://updated-destination.com',
'notes' => 'Updated notes',
'url_key' => 'initkey',
]);
});
it('allows showing a single short link via GET by ID or key', function () {
$link = ShortUrl::create([
'destination_url' => 'https://single-show.com',
'url_key' => 'showkey',
]);
// Show by ID
$response = $this->getJson("/api/short-url/links/{$link->id}", [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(200)
->assertJsonFragment(['url_key' => 'showkey']);
// Show by Key
$responseKey = $this->getJson('/api/short-url/links/showkey', [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseKey->assertStatus(200)
->assertJsonFragment(['id' => $link->id]);
});
it('allows fetching link statistics via GET', function () {
$link = ShortUrl::create([
'destination_url' => 'https://stats-test.com',
'url_key' => 'statskey',
]);
$response = $this->getJson("/api/short-url/links/{$link->id}/stats", [
'X-Api-Key' => 'sh_key_active_token',
]);
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'totalVisits',
'uniqueVisits',
'visitsToday',
'visitsByDay',
],
]);
});
it('validates new targeting rules in API payload', function () {
// 1. Empty filters array validation
$responseEmpty = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'rule_empty',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'filters' => [], // Empty filters array, should fail
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseEmpty->assertStatus(422)
->assertJsonValidationErrors(['targeting_rules.0.filters']);
// 2. Duplicate filter type validation
$responseDuplicate = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'rule_dup',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
[
'type' => 'device', // Duplicate filter type, should fail
'data' => ['devices' => ['desktop']],
],
],
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseDuplicate->assertStatus(422)
->assertJsonValidationErrors(['targeting_rules.0.filters']);
// 3. Valid rules should succeed
$responseValid = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'rule_valid',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
[
'type' => 'platform',
'data' => ['platforms' => ['ios']],
],
],
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseValid->assertStatus(201);
// 4. Extraneous key in targeting rules validation
$responseExtraRuleKey = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'rule_extra_key',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'random_junk_key' => 'garbage', // extraneous key, should fail
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
],
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseExtraRuleKey->assertStatus(422)
->assertJsonValidationErrors(['targeting_rules']);
// 5. Extraneous key in filters validation
$responseExtraFilterKey = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'filter_extra_key',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
'random_junk_key' => 'garbage', // extraneous key, should fail
],
],
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseExtraFilterKey->assertStatus(422)
->assertJsonValidationErrors(['targeting_rules.0.filters']);
// 6. Invalid values in targeting rules (e.g. invalid country code and invalid device)
$responseInvalidValues = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'rule_invalid_values',
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://mobile.com',
'filters' => [
[
'type' => 'device',
'data' => ['devices' => ['microwave']], // invalid device type
],
],
],
],
], [
'X-Api-Key' => 'sh_key_active_token',
]);
$responseInvalidValues->assertStatus(422)
->assertJsonValidationErrors(['targeting_rules.0.filters.0.data.devices.0']);
});

View File

@@ -206,20 +206,20 @@ it('runs database-level daily stats aggregation successfully', function () {
// Verify statistics entry in database
$this->assertDatabaseHas('short_url_daily_stats', [
'short_url_id' => $link->id,
'date' => $yesterday,
'date' => $yesterday.' 00:00:00',
'visits_count' => 3,
'unique_visits_count' => 2,
'qr_visits_count' => 1,
]);
$stats = ShortUrlDailyStats::where('short_url_id', $link->id)
->where('date', $yesterday)
->where('date', $yesterday.' 00:00:00')
->first();
expect($stats->device_stats)->toBe(['desktop' => 2, 'mobile' => 1])
->and($stats->browser_stats)->toBe(['Chrome' => 2, 'Safari' => 1])
->and($stats->os_stats)->toBe(['macOS' => 2, 'iOS' => 1])
->and($stats->os_stats)->toBe(['iOS' => 1, 'macOS' => 2])
->and($stats->country_stats)->toBe(['Poland' => 3])
->and($stats->city_stats)->toBe(['Warsaw (PL)' => 2, 'Krakow (PL)' => 1])
->and($stats->language_stats)->toBe(['pl' => 2, 'en' => 1]);
->and($stats->city_stats)->toBe(['Krakow (PL)' => 1, 'Warsaw (PL)' => 2])
->and($stats->language_stats)->toBe(['en' => 1, 'pl' => 2]);
});

View File

@@ -703,3 +703,134 @@ it('renders custom branded expired view on deactivated URL', function () {
$response->assertSee('Link Inactive or Expired');
$response->assertSee('Go to Homepage');
});
it('evaluates new multi-filter targeting rules with OR match logic', function () {
config(['filament-short-url.trust_cdn_headers' => true]);
createShortUrl([
'url_key' => 'or-multi',
'track_visits' => false,
'targeting_rules' => [
[
'match' => 'or',
'url' => 'https://matched-or.example.com',
'filters' => [
[
'type' => 'country',
'data' => ['countries' => ['PL', 'DE']],
],
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
],
],
],
]);
// Matches Country (PL) -> redirects
$this->get('/s/or-multi', ['CF-IPCountry' => 'PL'])
->assertRedirect('https://matched-or.example.com');
// Matches Device (mobile) but different Country -> redirects
$this->get('/s/or-multi', [
'CF-IPCountry' => 'US',
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
])->assertRedirect('https://matched-or.example.com');
// Matches neither -> falls back to default destination_url
$this->get('/s/or-multi', [
'CF-IPCountry' => 'US',
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
])->assertRedirect('https://example.com');
});
it('evaluates new multi-filter targeting rules with AND match logic', function () {
config(['filament-short-url.trust_cdn_headers' => true]);
createShortUrl([
'url_key' => 'and-multi',
'track_visits' => false,
'targeting_rules' => [
[
'match' => 'and',
'url' => 'https://matched-and.example.com',
'filters' => [
[
'type' => 'country',
'data' => ['countries' => ['PL']],
],
[
'type' => 'device',
'data' => ['devices' => ['mobile']],
],
],
],
],
]);
// Matches both -> redirects
$this->get('/s/and-multi', [
'CF-IPCountry' => 'PL',
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
])->assertRedirect('https://matched-and.example.com');
// Matches only Country -> falls back
$this->get('/s/and-multi', [
'CF-IPCountry' => 'PL',
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
])->assertRedirect('https://example.com');
// Matches only Device -> falls back
$this->get('/s/and-multi', [
'CF-IPCountry' => 'US',
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
])->assertRedirect('https://example.com');
});
it('evaluates platform and browser language filters in new rule engine', function () {
createShortUrl([
'url_key' => 'platform-lang',
'track_visits' => false,
'targeting_rules' => [
[
'match' => 'and',
'url' => 'https://matched-platform-lang.example.com',
'filters' => [
[
'type' => 'platform',
'data' => ['platforms' => ['ios', 'android']],
],
[
'type' => 'language',
'data' => ['languages' => ['pl', 'de']],
],
],
],
],
]);
// iOS + pl -> redirects
$this->get('/s/platform-lang', [
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
'Accept-Language' => 'pl-PL,pl;q=0.9',
])->assertRedirect('https://matched-platform-lang.example.com');
// Android + de -> redirects
$this->get('/s/platform-lang', [
'User-Agent' => 'Mozilla/5.0 (Linux; Android 13)',
'Accept-Language' => 'de-DE,de;q=0.9',
])->assertRedirect('https://matched-platform-lang.example.com');
// iOS + en -> falls back
$this->get('/s/platform-lang', [
'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
'Accept-Language' => 'en-US,en;q=0.9',
])->assertRedirect('https://example.com');
// Windows + pl -> falls back
$this->get('/s/platform-lang', [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Accept-Language' => 'pl-PL,pl;q=0.9',
])->assertRedirect('https://example.com');
});