v3.3.0: Advanced Multi-Filter Targeting, Strict API Validation, Clean Configs, UX Improvements
This commit is contained in:
143
README.md
143
README.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -760,7 +766,7 @@ curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
|
||||
| `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 | ❌ | Targeting rules JSON schema (device, geo, rotation, language) |
|
||||
| `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 |
|
||||
@@ -1000,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). |
|
||||
@@ -1015,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1159,6 +1171,13 @@ 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.
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.css
vendored
File diff suppressed because one or more lines are too long
@@ -333,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',
|
||||
|
||||
@@ -330,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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
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\Hidden;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
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;
|
||||
@@ -23,7 +28,6 @@ use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
class ShortUrlForm
|
||||
{
|
||||
@@ -432,9 +436,9 @@ class ShortUrlForm
|
||||
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 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),
|
||||
@@ -466,11 +470,11 @@ class ShortUrlForm
|
||||
}
|
||||
}),
|
||||
])
|
||||
->columnSpan(1),
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('password_active_flag')),
|
||||
->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([
|
||||
@@ -483,10 +487,10 @@ class ShortUrlForm
|
||||
$set('is_entering_password', true);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
|
||||
->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([
|
||||
@@ -511,8 +515,8 @@ class ShortUrlForm
|
||||
->required(fn (Get $get): bool => ! empty($get('new_password_input')))
|
||||
->columnSpan(1),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Actions::make([
|
||||
Action::make('confirm_password')
|
||||
@@ -523,17 +527,19 @@ class ShortUrlForm
|
||||
$password = $get('new_password_input');
|
||||
$confirm = $get('new_password_confirmation_input');
|
||||
if (empty($password)) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_required_error') ?? 'Password is required.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
if ($password !== $confirm) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.password_mismatch_error') ?? 'Passwords do not match.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
$set('password', $password);
|
||||
@@ -556,10 +562,10 @@ class ShortUrlForm
|
||||
$set('new_password_confirmation_input', null);
|
||||
}),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
|
||||
->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'))
|
||||
@@ -568,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([]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -706,17 +860,17 @@ class ShortUrlForm
|
||||
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 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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,132 @@ class ShortUrlApiController extends Controller
|
||||
$uniqueKeyRule .= ','.$model->id;
|
||||
}
|
||||
|
||||
return [
|
||||
$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',
|
||||
@@ -202,23 +327,11 @@ class ShortUrlApiController extends Controller
|
||||
'activated_at' => $activatedAtRule,
|
||||
'expires_at' => 'nullable|date|after_or_equal:activated_at',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'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',
|
||||
];
|
||||
|
||||
$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',
|
||||
@@ -234,7 +347,9 @@ class ShortUrlApiController extends Controller
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
];
|
||||
]);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,5 +431,4 @@ class ShortUrlApiController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
@@ -174,7 +175,7 @@ it('allows showing a single short link via GET by ID or key', function () {
|
||||
->assertJsonFragment(['url_key' => 'showkey']);
|
||||
|
||||
// Show by Key
|
||||
$responseKey = $this->getJson("/api/short-url/links/showkey", [
|
||||
$responseKey = $this->getJson('/api/short-url/links/showkey', [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
]);
|
||||
|
||||
@@ -203,4 +204,146 @@ it('allows fetching link statistics via GET', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user