6 Commits

43 changed files with 14947 additions and 790 deletions

33
LICENSE
View File

@@ -1,21 +1,22 @@
MIT License
Custom Source-Available License
Copyright (c) 2026 Bartek Janczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Grant of License
Subject to the terms of this License, the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
- Use the Software for personal, internal, or commercial purposes.
- Modify the Software for your own personal, internal, or commercial use.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2. Restrictions
You are strictly prohibited from:
- Redistributing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or package registry (such as Packagist).
- Selling, renting, leasing, or sublicensing the Software or any modified versions thereof as a standalone package or library.
- Publishing, distributing, or sharing the Software under a different name or package name.
3. Copyright Notice
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
4. Disclaimer of Warranty
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

437
README.md
View File

@@ -6,7 +6,7 @@
<p align="center">
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-proprietary-7c3aed.svg?style=flat-square" alt="License"></a>
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
@@ -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.
@@ -242,7 +242,7 @@ protected $policies = [
The package comes with a built-in admin settings dashboard. It is accessible directly from your sidebar menu under the same navigation group as your links.
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
Settings are stored dynamically in the database (`short_url_settings` table), cached indefinitely, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
The settings panel allows you to configure:
@@ -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.
---
@@ -577,16 +583,7 @@ $shortUrl = ShortUrl::find($id);
$shortUrl->pixels()->sync([$pixelId1, $pixelId2]);
```
For backward compatibility, assigning individual attributes directly on the model still works and will automatically find or create a pixel registry entry:
```php
// Programmatically via model attributes (automatically handles registration under the hood)
$shortUrl->update([
'pixel_meta_id' => '1234567890',
'pixel_google_id' => 'G-XXXXXXXXXX',
'pixel_linkedin_id' => '1234567',
]);
```
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
@@ -614,10 +611,14 @@ Authorization: Bearer sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
For security, new API keys are hashed using SHA-256 and stored securely in the database. The plain key is displayed only once during generation via a persistent warning notification in the Filament UI. All keys are authenticated using constant-time string comparisons (`hash_equals()`) to prevent timing attacks.
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
### 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).
@@ -642,14 +643,20 @@ curl https://yourdomain.com/api/short-url/links \
"max_visits": null,
"activated_at": null,
"expires_at": null,
"pixel_meta_id": null,
"pixel_google_id": null,
"pixel_linkedin_id": 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,
@@ -665,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.
@@ -678,50 +730,176 @@ curl -X POST https://yourdomain.com/api/short-url/links \
"notes": "Summer campaign",
"single_use": false,
"max_visits": 1000,
"pixel_meta_id": "1234567890",
"pixels": [1, 2],
"webhook_url": "https://api.mycrm.com/clicks"
}'
```
**Accepted fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL |
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
| `notes` | string | ❌ | Internal notes |
| `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 |
| `pixel_meta_id` | string | ❌ | Meta Pixel ID (Legacy support) |
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID (Legacy support) |
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID (Legacy support) |
| `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 |
**Response:** `201 Created` with the created link object.
#### `DELETE /api/short-url/links/{id}`
Permanently delete a short URL by its ID.
#### `PUT/PATCH /api/short-url/links/{idOrKey}`
Update an existing short URL (resolved dynamically by either database `id` or short `url_key`).
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
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 (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 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 (POST & PUT/PATCH success):** `200 OK` (or `201 Created` for POST) containing:
```json
{
"message": "Short URL updated successfully.",
"data": {
"id": 2,
"destination_url": "https://example.com/new-product-page",
"url_key": "promo26",
"short_url": "https://yourdomain.com/s/promo26",
"is_enabled": true,
"redirect_status_code": 302,
"total_visits": 0,
"unique_visits": 0,
"max_visits": 1000,
"activated_at": null,
"expires_at": null,
"webhook_url": "https://api.mycrm.com/clicks",
"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": "Updated summer campaign description",
"created_at": "2026-06-04T12:00:00+00:00"
}
}
```
#### `GET /api/short-url/links/{idOrKey}/stats`
Retrieve visit analytics statistics for a single short URL.
```bash
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
@@ -729,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 |
@@ -762,6 +941,13 @@ Webhooks can be configured at two levels:
Select which events to monitor in **Settings → API & Webhooks → Monitored Webhook Events**.
### Webhook Signature Verification
Outgoing webhooks can be cryptographically signed using an HMAC-SHA256 signature to verify that the request originated from your system:
1. Configure a **Webhook Signing Secret** in your Settings panel under **API & Webhooks**.
2. When configured, outgoing HTTP POST payloads will include the signature in the `X-ShortUrl-Signature` header, which is calculated as `hash_hmac('sha256', $payloadJson, $secret)`.
3. The receiver can verify the signature by re-calculating the HMAC of the raw request payload using the shared secret and comparing it using `hash_equals()`.
### Payload Format
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
@@ -820,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). |
@@ -835,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.
---
@@ -979,12 +1171,35 @@ 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.
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
### v3.0.0
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
- **Backward-Compatible REST API** — The API now exposes the `pixels` relationship list, while fully retaining backward-compatible support for legacy single-pixel parameters (`pixel_meta_id`, `pixel_google_id`, `pixel_linkedin_id`).
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
- **Full Localization & WhatsApp Favicon Fix** — Added friendly translation strings in English and Polish across the entire app-linking preview and redirect interfaces, and adjusted domains order to restore the WhatsApp favicon.
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
@@ -1046,4 +1261,4 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## License
MIT
Custom Source-Available License. Please see the [LICENSE](LICENSE) file for the full text.

View File

@@ -4,11 +4,11 @@
"description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.",
"keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"],
"homepage": "https://github.com/janczakb/filament-short-url",
"license": "MIT",
"license": "proprietary",
"authors": [
{
"name": "Bartek Janczak",
"email": "hello@bjanczak.dev",
"email": "barek122@gmail.com",
"role": "Developer"
}
],

12190
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -226,38 +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),
],
];

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('short_url_settings', function (Blueprint $table): void {
$table->string('key')->primary();
$table->longText('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('short_url_settings');
}
};

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

@@ -153,7 +153,7 @@ return [
'success_modal_helper' => 'Skopiuj i udostępnij ręcznie lub wybierz platformę.',
'open_link' => 'Otwórz link',
'close_button' => 'Zamknij',
'dont_show_again' => "Nie pokazuj opcji udostępniania po utworzeniu linku",
'dont_show_again' => 'Nie pokazuj opcji udostępniania po utworzeniu linku',
// Stats Page
'stats_title' => 'Statystyki',
@@ -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;
@@ -23,16 +24,21 @@ Route::match(
->where('key', '[a-zA-Z0-9_-]+')
->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']);
@@ -42,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

@@ -42,97 +42,102 @@ class AggregateAndPruneVisitsCommand extends Command
foreach ($dates as $date) {
// Wrap date aggregation in a database transaction for data integrity
DB::transaction(function () use ($date): void {
// Accumulate stats per short_url_id using chunked reads — avoids loading
// potentially millions of rows into PHP memory at once.
$statsByUrl = [];
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
DB::table('short_url_visits')
->where('visited_at', '>=', $date.' 00:00:00')
->where('visited_at', '<', $nextDate.' 00:00:00')
// 1. Get totals and uniques per short_url_id
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->select([
'short_url_id',
'is_qr_scan',
'ip_hash',
'device_type',
'browser',
'operating_system',
'country',
'country_code',
'utm_source',
'utm_medium',
'utm_campaign',
'browser_language',
'city',
'referer_host',
DB::raw('count(*) as total'),
DB::raw('count(distinct ip_hash) as uniques'),
DB::raw("count(case when is_qr_scan = 1 or is_qr_scan = true or is_qr_scan = '1' then 1 end) as qr_scans"),
])
->orderBy('id')
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;
->groupBy('short_url_id')
->get();
if (! isset($statsByUrl[$urlId])) {
$statsByUrl[$urlId] = [
'total' => 0,
'ip_hashes' => [],
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'qr_scans' => 0,
'language_stats' => [],
];
}
if ($totals->isEmpty()) {
return;
}
$s = &$statsByUrl[$urlId];
$s['total']++;
$statsByUrl = [];
foreach ($totals as $row) {
$statsByUrl[$row->short_url_id] = [
'total' => (int) $row->total,
'uniques' => (int) $row->uniques,
'qr_scans' => (int) $row->qr_scans,
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'language_stats' => [],
];
}
if ($visit->is_qr_scan) {
$s['qr_scans']++;
}
// Helper to fetch and populate category stats
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereNotNull($column)
->where($column, '<>', '');
if ($visit->ip_hash) {
$s['ip_hashes'][$visit->ip_hash] = true;
}
if ($statsKey === 'city_stats') {
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
->groupBy(['short_url_id', 'city', 'country_code']);
} else {
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
->groupBy(['short_url_id', $column]);
}
$inc = function (?string $value, string $key) use (&$s): void {
if ($value) {
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
}
};
$rows = $query->get();
$inc($visit->device_type, 'device_stats');
$inc($visit->browser, 'browser_stats');
$inc($visit->operating_system, 'os_stats');
$inc($visit->country, 'country_stats');
$inc($visit->utm_source, 'utm_source_stats');
$inc($visit->utm_medium, 'utm_medium_stats');
$inc($visit->utm_campaign, 'utm_campaign_stats');
$inc($visit->browser_language, 'language_stats');
if ($visit->city) {
$cityKey = "{$visit->city} ({$visit->country_code})";
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
}
if ($visit->referer_host) {
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
}
foreach ($rows as $row) {
$urlId = $row->short_url_id;
if (! isset($statsByUrl[$urlId])) {
continue;
}
});
if ($statsKey === 'city_stats') {
$cityVal = $row->city;
$countryCode = $row->country_code;
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
} else {
$val = $row->$column;
}
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
}
};
// Populate all categories via 10 quick indexed database aggregations
$populateStats('device_type', 'device_stats');
$populateStats('browser', 'browser_stats');
$populateStats('operating_system', 'os_stats');
$populateStats('country', 'country_stats');
$populateStats('city', 'city_stats');
$populateStats('referer_host', 'referer_stats');
$populateStats('utm_source', 'utm_source_stats');
$populateStats('utm_medium', 'utm_medium_stats');
$populateStats('utm_campaign', 'utm_campaign_stats');
$populateStats('browser_language', 'language_stats');
// Write aggregated stats to ShortUrlDailyStats
foreach ($statsByUrl as $urlId => $s) {
ShortUrlDailyStats::updateOrCreate([
'short_url_id' => $urlId,
'date' => $date,
], [
'visits_count' => $s['total'],
'unique_visits_count' => count($s['ip_hashes']),
'unique_visits_count' => $s['uniques'],
'device_stats' => $s['device_stats'],
'browser_stats' => $s['browser_stats'],
'os_stats' => $s['os_stats'],

View File

@@ -4,14 +4,13 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;

View File

@@ -163,6 +163,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
'aasa_json' => $aasa,
'assetlinks_json' => $assetlinks,
// Webhook signing secret
'webhook_signing_secret' => $mgr->get('webhook_signing_secret'),
]);
}
@@ -843,6 +845,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
if (! $state) {
$set('global_webhook_url', null);
$set('webhook_events', ['visited']);
$set('webhook_signing_secret', null);
}
}),
@@ -854,6 +857,15 @@ class ShortUrlSettingsPage extends Page implements HasForms
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
TextInput::make('webhook_signing_secret')
->label('Webhook Signing Secret')
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
@@ -947,6 +959,20 @@ class ShortUrlSettingsPage extends Page implements HasForms
->title(__('filament-short-url::default.settings_saved'))
->success()
->send();
if ($newKeys = session()->get('fsu_new_api_keys')) {
foreach ($newKeys as $newKey) {
Notification::make()
->title(__('filament-short-url::default.api_key_generated') ?? 'API Key Generated')
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
->warning()
->persistent()
->send();
}
// Re-mount settings form state to reflect the new masked key names in the UI
$this->mount();
}
}
protected function getHeaderActions(): array

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(),
]),
]);
}
@@ -596,4 +914,3 @@ class ShortUrlForm
]);
}
}

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

@@ -1,9 +1,16 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
use Filament\Contracts\Plugin;
use Filament\Panel;
@@ -48,7 +55,7 @@ class FilamentShortUrlPlugin implements Plugin
ShortUrlPixelResource::class,
])
->pages([
\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage::class,
ShortUrlSettingsPage::class,
]);
}

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
@@ -41,6 +47,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_03_120000_add_track_browser_language_to_short_urls_table',
'2026_06_03_150000_create_short_url_pixels_table',
'2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table',
'2026_06_04_000000_create_short_url_settings_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,

View File

@@ -9,10 +9,6 @@ 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
{
@@ -45,70 +41,12 @@ 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',
// Keep for backward compatibility
'pixel_meta_id' => 'nullable|string|max:100',
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
]);
$validated = $request->validate($this->getValidationRules($request));
$pixelIds = $validated['pixels'] ?? [];
// Backward compatibility support for old pixel columns
if (! empty($validated['pixel_meta_id'])) {
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'meta',
'pixel_id' => $validated['pixel_meta_id'],
], [
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $metaPixelId;
}
if (! empty($validated['pixel_google_id'])) {
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'google',
'pixel_id' => $validated['pixel_google_id'],
], [
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $googlePixelId;
}
if (! empty($validated['pixel_linkedin_id'])) {
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
'type' => 'linkedin',
'pixel_id' => $validated['pixel_linkedin_id'],
], [
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
'is_active' => true,
])->id;
$pixelIds[] = $linkedinPixelId;
}
// Clean up parameters that shouldn't be mass assigned
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
unset($validated['pixels']);
$shortUrl = $this->service->create($validated);
@@ -125,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([
@@ -138,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.
*/
@@ -157,14 +371,20 @@ class ShortUrlApiController extends Controller
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_id,
'webhook_url' => $link->webhook_url,
'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,
@@ -211,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

@@ -73,13 +73,27 @@ class ShortUrlRedirectController extends Controller
$sessionKey = "short-url-auth-{$shortUrl->id}";
if (! session()->get($sessionKey)) {
if ($request->isMethod('POST')) {
// Password brute force protection
$ipAddress = ClientIpExtractor::getIp($request);
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
'Retry-After' => $retryAfter,
]);
}
$submitted = $request->input('password');
if ($submitted === $shortUrl->password) {
session()->put($sessionKey, true);
RateLimiter::clear($passwordLimiterKey);
return redirect()->to($request->fullUrl());
}
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
$errors = new MessageBag([
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
]);
@@ -99,13 +113,13 @@ 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);
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
$activePixels = $shortUrl->pixels->where('is_active', true);
return response(view('filament-short-url::app-redirect', [
'destination' => $destination,
@@ -189,7 +203,7 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
$activePixels = $shortUrl->pixels->where('is_active', true);
if ($activePixels->isNotEmpty()) {
return response(view('filament-short-url::pixel-loading', [

View File

@@ -38,10 +38,26 @@ class AuthenticateShortUrlApi
$keys = $mgr->get('api_keys', []);
$valid = false;
$hashedInput = hash('sha256', $apiKey);
foreach ($keys as $keyObj) {
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
$valid = true;
break;
if (! (bool) ($keyObj['is_active'] ?? false)) {
continue;
}
// Check hashed key (new format) or fall back to plaintext key (legacy format)
$storedHash = $keyObj['hashed_key'] ?? null;
if ($storedHash) {
if (hash_equals($storedHash, $hashedInput)) {
$valid = true;
break;
}
} else {
$storedPlain = $keyObj['key'] ?? '';
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
$valid = true;
break;
}
}
}

View File

@@ -40,11 +40,19 @@ class SendWebhookJob implements ShouldQueue
public function handle(): void
{
try {
$headers = [
'Content-Type' => 'application/json',
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
];
$secret = config('filament-short-url.webhook_signing_secret');
if (! empty($secret)) {
$payloadJson = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$headers['X-ShortUrl-Signature'] = hash_hmac('sha256', $payloadJson, $secret);
}
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
])
->withHeaders($headers)
->post($this->url, $this->payload);
if ($response->failed()) {

View File

@@ -14,7 +14,6 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Queued job for recording short URL visits.
@@ -39,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,
@@ -170,8 +169,17 @@ class TrackShortUrlVisitJob implements ShouldQueue
$apiSecret = config('filament-short-url.ga4.api_secret');
$firebaseAppId = config('filament-short-url.ga4.firebase_app_id');
// GA4 Measurement Protocol requires a client_id
$clientId = Str::uuid()->toString();
// GA4 Measurement Protocol requires a client_id. We generate a deterministic UUID
// based on the visitor IP and User Agent to allow sessions/retention tracking
// without violating privacy (the IP and UA are hashed and cannot be reversed).
$hash = md5($this->ipAddress.'|'.$this->userAgent);
$clientId = sprintf('%08s-%04s-%04s-%04s-%12s',
substr($hash, 0, 8),
substr($hash, 8, 4),
substr($hash, 12, 4),
substr($hash, 16, 4),
substr($hash, 20, 12)
);
$payload = [
'client_id' => $clientId,

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Models;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
@@ -176,13 +182,13 @@ class ShortUrl extends Model
$ttl = config('filament-short-url.cache_ttl', 3600);
if ($ttl <= 0) {
return static::where('url_key', $key)->first();
return static::where('url_key', $key)->with('pixels')->first();
}
return cache()->remember(
"filament-short-url:{$key}",
$ttl,
fn () => static::where('url_key', $key)->first()
fn () => static::where('url_key', $key)->with('pixels')->first()
);
}
@@ -194,9 +200,11 @@ class ShortUrl extends Model
protected static function booted(): void
{
static::saving(function (self $m) {
if ($m->single_use) {
$m->max_visits = null;
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of single-use URLs
if ($m->single_use || $m->max_visits !== null || $m->expires_at !== null) {
if ($m->single_use) {
$m->max_visits = null;
}
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of limited/expiring URLs
}
if ($m->activated_at === null && $m->expires_at === null) {
@@ -233,6 +241,7 @@ class ShortUrl extends Model
static::saved(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
cache()->forget("filament-short-url:visits:{$m->id}");
if ($m->wasChanged('url_key')) {
$oldKey = $m->getOriginal('url_key');
@@ -243,6 +252,7 @@ class ShortUrl extends Model
});
static::deleted(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
cache()->forget("filament-short-url:visits:{$m->id}");
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
if (! empty($m->qr_logo)) {
@@ -272,21 +282,17 @@ class ShortUrl extends Model
public function getRealTimeTotalVisits(): int
{
$buffered = 0;
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
return $this->total_visits + $buffered;
}
if ($this->max_visits !== null) {
$dbVal = (int) DB::table($this->table)
->where('id', $this->id)
->value('total_visits');
// Use real-time visit count in cache to keep the cached model instance updated
$cacheKey = "filament-short-url:visits:{$this->id}";
return $dbVal + $buffered;
}
return $this->total_visits + $buffered;
return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits);
}
public function isActive(): bool
@@ -401,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;
@@ -554,6 +665,18 @@ class ShortUrl extends Model
$this->newQuery()
->where('id', $this->id)
->increment('total_visits', 1, $updates);
// Keep the real-time cache count incremented
$cacheKey = "filament-short-url:visits:{$this->id}";
try {
if (cache()->has($cacheKey)) {
cache()->increment($cacheKey);
} else {
cache()->put($cacheKey, $this->total_visits + 1, 3600);
}
} catch (\Throwable $e) {
// Ignore cache errors in increment to never disrupt redirection
}
}
/**

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Models;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
class AppLinkingEngine
@@ -237,6 +243,6 @@ class AppLinkingEngine
}
}
return $scheme . $urlWithoutProtocol;
return $scheme.$urlWithoutProtocol;
}
}

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Http\Request;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use GeoIp2\Database\Reader;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Cache;
@@ -25,25 +31,35 @@ class ProxyDetectionService
}
$cacheKey = "short-url:proxy-check:{$ip}";
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) {
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
$timeout = (int) config('filament-short-url.vpn_detection.timeout', 2);
if (Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
try {
if ($driver === 'vpnapi') {
return $this->queryVpnApi($ip, $timeout);
}
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
// Aggressive 800ms timeout to avoid hanging the redirect thread on API lag
$timeout = 0.8;
// Default to ip-api
return $this->queryIpApi($ip, $timeout);
} catch (\Throwable $e) {
Log::warning("Proxy detection failed for IP: {$ip}. Error: ".$e->getMessage());
return ['is_proxy' => false, 'is_bot' => false];
try {
if ($driver === 'vpnapi') {
$result = $this->queryVpnApi($ip, $timeout);
} else {
$result = $this->queryIpApi($ip, $timeout);
}
});
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
Cache::put($cacheKey, $result, $cacheTtl);
return $result;
} catch (\Throwable $e) {
Log::warning("Proxy detection failed or timed out for IP: {$ip}. Error: ".$e->getMessage());
// Temporary cache for failure (60 seconds) to allow retry and prevent whitelisting IPs long-term
$failResult = ['is_proxy' => false, 'is_bot' => false];
Cache::put($cacheKey, $failResult, 60);
return $failResult;
}
}
/**
@@ -51,14 +67,18 @@ class ProxyDetectionService
*
* @return array{is_proxy: bool, is_bot: bool}
*/
private function queryIpApi(string $ip, int $timeout): array
private function queryIpApi(string $ip, float|int $timeout): array
{
$url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting";
$response = Http::timeout($timeout)->get($url);
if ($response->failed() || $response->json('status') === 'fail') {
return ['is_proxy' => false, 'is_bot' => false];
if ($response->failed()) {
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
}
if ($response->json('status') === 'fail') {
throw new \RuntimeException('API returned failure: '.$response->json('message'));
}
$isProxy = (bool) $response->json('proxy', false);
@@ -75,12 +95,12 @@ class ProxyDetectionService
*
* @return array{is_proxy: bool, is_bot: bool}
*/
private function queryVpnApi(string $ip, int $timeout): array
private function queryVpnApi(string $ip, float|int $timeout): array
{
$key = config('filament-short-url.vpn_detection.vpnapi_key');
if (empty($key)) {
return ['is_proxy' => false, 'is_bot' => false];
throw new \RuntimeException('vpnapi.io API key is empty.');
}
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
@@ -88,7 +108,7 @@ class ProxyDetectionService
$response = Http::timeout($timeout)->get($url);
if ($response->failed()) {
return ['is_proxy' => false, 'is_bot' => false];
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
}
$security = $response->json('security', []);

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Http;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;

View File

@@ -1,9 +1,17 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
class ShortUrlSettingsManager
{
@@ -25,22 +33,63 @@ class ShortUrlSettingsManager
return $this->cache;
}
$readFromFile = function () {
$path = $this->getSettingsPath();
if (File::exists($path)) {
try {
return json_decode(File::get($path), true) ?: [];
} catch (\Throwable) {
// Fallback to empty if json is corrupt
$readFromDb = function () {
try {
// Ensure table exists before querying to avoid exception on unmigrated db
if (! Schema::hasTable('short_url_settings')) {
return [];
}
}
return [];
$settings = DB::table('short_url_settings')
->pluck('value', 'key')
->toArray();
$decoded = [];
foreach ($settings as $key => $val) {
$decoded[$key] = json_decode($val, true);
}
// If DB is empty, check if we can import legacy settings from JSON file
if (empty($decoded)) {
$legacyPath = $this->getSettingsPath();
if (File::exists($legacyPath)) {
try {
$legacyContent = File::get($legacyPath);
$legacySettings = json_decode($legacyContent, true) ?: [];
// Import each legacy setting to database
foreach ($legacySettings as $k => $v) {
DB::table('short_url_settings')->updateOrInsert(
['key' => $k],
['value' => json_encode($v), 'updated_at' => now(), 'created_at' => now()]
);
}
// Rename the legacy file to avoid re-importing
File::move($legacyPath, $legacyPath.'.bak');
return $legacySettings;
} catch (\Throwable $e) {
// Suppress import failures to prevent boot crash
}
}
}
return $decoded;
} catch (\Throwable $e) {
// Fallback to empty if DB query fails during boot/installation
return [];
}
};
// Cache the settings indefinitely (31536000 seconds = 1 year) in multi-server environments
$stored = app()->bound('cache')
? cache()->remember('filament-short-url:settings', 86400, $readFromFile)
: $readFromFile();
? cache()->remember('filament-short-url:settings', 31536000, $readFromDb)
: $readFromDb();
if (! is_array($stored)) {
$stored = [];
}
// Merge stored settings with default values from config()
$this->cache = array_merge([
@@ -100,6 +149,8 @@ class ShortUrlSettingsManager
'deep_linking_enabled' => config('filament-short-url.deep_linking.enabled', false),
'aasa_json' => config('filament-short-url.deep_linking.aasa_json'),
'assetlinks_json' => config('filament-short-url.deep_linking.assetlinks_json'),
// Webhook signing secret
'webhook_signing_secret' => null,
], $stored);
return $this->cache;
@@ -111,19 +162,12 @@ class ShortUrlSettingsManager
}
/**
* Persist settings to JSON file.
* Persist settings to the database.
*
* @param array<string, mixed> $data
*/
public function set(array $data): void
{
$path = $this->getSettingsPath();
$dir = dirname($path);
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
$oldPrefix = $this->get('route_prefix');
// Keep only supported settings keys to prevent bloat
@@ -184,6 +228,8 @@ class ShortUrlSettingsManager
'deep_linking_enabled',
'aasa_json',
'assetlinks_json',
// Webhook signing secret
'webhook_signing_secret',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -287,8 +333,53 @@ class ShortUrlSettingsManager
$filtered['deep_linking_enabled'] = (bool) $filtered['deep_linking_enabled'];
}
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
cache()->forget('filament-short-url:settings');
$newKeys = [];
if (isset($filtered['api_keys']) && is_array($filtered['api_keys'])) {
foreach ($filtered['api_keys'] as &$keyObj) {
$key = $keyObj['key'] ?? '';
if (str_starts_with($key, 'sh_key_')) {
$plainKey = $key;
$hashed = hash('sha256', $plainKey);
$masked = substr($plainKey, 0, 11).'••••'.substr($plainKey, -4);
$keyObj['hashed_key'] = $hashed;
$keyObj['key'] = $masked;
$newKeys[] = [
'name' => $keyObj['name'] ?? 'API Key',
'plain' => $plainKey,
];
}
}
if (! empty($newKeys)) {
session()->flash('fsu_new_api_keys', $newKeys);
}
}
try {
if (Schema::hasTable('short_url_settings')) {
DB::transaction(function () use ($filtered) {
// Update or insert each key
foreach ($filtered as $key => $val) {
DB::table('short_url_settings')->updateOrInsert(
['key' => $key],
['value' => json_encode($val), 'updated_at' => now()]
);
}
// Delete settings from the database that are no longer in keys (e.g. removed features)
DB::table('short_url_settings')
->whereNotIn('key', array_keys($filtered))
->delete();
});
}
} catch (\Throwable $e) {
// Ignore / log database persist errors
}
if (app()->bound('cache')) {
cache()->forget('filament-short-url:settings');
}
$this->cache = null;
// Apply immediately to current request config
@@ -371,6 +462,8 @@ class ShortUrlSettingsManager
'filament-short-url.deep_linking.enabled' => (bool) ($settings['deep_linking_enabled'] ?? false),
'filament-short-url.deep_linking.aasa_json' => $settings['aasa_json'] ?? null,
'filament-short-url.deep_linking.assetlinks_json' => $settings['assetlinks_json'] ?? null,
// Webhook signing secret
'filament-short-url.webhook_signing_secret' => $settings['webhook_signing_secret'] ?? null,
]);
}
}

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;

View File

@@ -1,5 +1,11 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Services;
/**
@@ -32,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
@@ -97,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,12 +75,19 @@ it('allows API requests with a valid key', function () {
it('allows creating a short link programmatically via POST', function () {
Queue::fake([SendWebhookJob::class]);
$pixel = ShortUrlPixel::create([
'name' => 'Meta Pixel Test',
'type' => 'meta',
'pixel_id' => '12345',
'is_active' => true,
]);
$response = $this->postJson('/api/short-url/links', [
'destination_url' => 'https://google.com',
'url_key' => 'googleapi',
'notes' => 'API Generated link',
'single_use' => true,
'pixel_meta_id' => '12345',
'pixels' => [$pixel->id],
'webhook_url' => 'https://webhook.site/test',
], [
'X-Api-Key' => 'sh_key_active_token',
@@ -95,9 +103,10 @@ it('allows creating a short link programmatically via POST', function () {
'webhook_url' => 'https://webhook.site/test',
]);
$this->assertDatabaseHas('short_url_pixels', [
'type' => 'meta',
'pixel_id' => '12345',
$shortUrl = ShortUrl::where('url_key', 'googleapi')->first();
$this->assertDatabaseHas('short_url_pixel', [
'short_url_id' => $shortUrl->id,
'pixel_id' => $pixel->id,
]);
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
@@ -123,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

@@ -0,0 +1,225 @@
<?php
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
// Clean up any test configurations
cache()->forget('filament-short-url:settings');
app(ShortUrlSettingsManager::class)->set(['route_prefix' => 's']);
});
it('migrates settings to database and caches them', function () {
// 1. Verify schema has table
expect(Schema::hasTable('short_url_settings'))->toBeTrue();
// 2. Write setting and verify it exists in db
$mgr = app(ShortUrlSettingsManager::class);
$mgr->set(['route_prefix' => 'custom-prefix']);
$this->assertDatabaseHas('short_url_settings', [
'key' => 'route_prefix',
'value' => json_encode('custom-prefix'),
]);
// 3. Clear cache manually and verify it still reads correctly
cache()->forget('filament-short-url:settings');
expect($mgr->get('route_prefix'))->toBe('custom-prefix');
});
it('forces 302 redirect status code for limited/expiring links', function () {
// Case A: Link with expires_at
$url1 = ShortUrl::create([
'destination_url' => 'https://example.com/expiring',
'url_key' => 'expiringlink',
'expires_at' => now()->addDays(5),
'redirect_status_code' => 301, // User requests 301
]);
expect($url1->redirect_status_code)->toBe(302); // Enforced to 302
// Case B: Link with max_visits
$url2 = ShortUrl::create([
'destination_url' => 'https://example.com/limited',
'url_key' => 'limitedlink',
'max_visits' => 100,
'redirect_status_code' => 301,
]);
expect($url2->redirect_status_code)->toBe(302); // Enforced to 302
});
it('generates a deterministic privacy-safe GA4 client ID', function () {
$job1 = new TrackShortUrlVisitJob(
shortUrl: new ShortUrl(['id' => 1]),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
);
$job2 = new TrackShortUrlVisitJob(
shortUrl: new ShortUrl(['id' => 1]),
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
);
$job3 = new TrackShortUrlVisitJob(
shortUrl: new ShortUrl(['id' => 1]),
ipAddress: '8.8.8.8',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'
);
// Use reflection to access the private sendGa4Hit method or verify GA4 client ID determination
$ref = new ReflectionClass(TrackShortUrlVisitJob::class);
$method = $ref->getMethod('sendGa4Hit');
$method->setAccessible(true);
$shortUrl = ShortUrl::create([
'destination_url' => 'https://example.com/ga4',
'url_key' => 'ga4test',
'ga_tracking_id' => 'G-12345',
]);
app(ShortUrlSettingsManager::class)->set([
'ga4_api_secret' => 'secret_secret_123',
]);
Http::fake();
$visit = new ShortUrlVisit([
'device_type' => 'desktop',
'country' => 'Poland',
'browser' => 'Chrome',
]);
// Send first hit
$method->invoke($job1, $shortUrl, $visit);
// Send second hit (same IP/UA)
$method->invoke($job2, $shortUrl, $visit);
// Send third hit (different IP/UA)
$method->invoke($job3, $shortUrl, $visit);
$requests = Http::recorded();
expect($requests)->toHaveCount(3);
$body1 = json_decode($requests[0][0]->body(), true);
$body2 = json_decode($requests[1][0]->body(), true);
$body3 = json_decode($requests[2][0]->body(), true);
// Client ID 1 and 2 must match exactly since they originate from the same user (deterministic hash)
expect($body1['client_id'])->toBe($body2['client_id']);
// Client ID 3 must be different
expect($body1['client_id'])->not->toBe($body3['client_id']);
});
it('signs outgoing webhooks with X-ShortUrl-Signature when secret is configured', function () {
app(ShortUrlSettingsManager::class)->set([
'webhook_signing_secret' => 'my-signing-secret-key-123',
]);
Http::fake();
$job = new SendWebhookJob(
url: 'https://webhook.site/my-endpoint',
event: 'visited',
payload: ['click_id' => 99, 'short_url_id' => 1]
);
$job->handle();
Http::assertSent(function ($request) {
expect($request->hasHeader('X-ShortUrl-Signature'))->toBeTrue();
$secret = 'my-signing-secret-key-123';
$payloadJson = json_encode(['click_id' => 99, 'short_url_id' => 1], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$expectedSignature = hash_hmac('sha256', $payloadJson, $secret);
expect($request->header('X-ShortUrl-Signature')[0])->toBe($expectedSignature);
return true;
});
});
it('runs database-level daily stats aggregation successfully', function () {
// 1. Seed raw visits for a previous day
$yesterday = Carbon::yesterday()->toDateString();
$link = ShortUrl::create([
'destination_url' => 'https://example.com/aggregate',
'url_key' => 'aggkey',
]);
DB::table('short_url_visits')->insert([
[
'short_url_id' => $link->id,
'ip_hash' => hash('sha256', '1.1.1.1'),
'browser' => 'Chrome',
'operating_system' => 'macOS',
'device_type' => 'desktop',
'country' => 'Poland',
'country_code' => 'PL',
'city' => 'Warsaw',
'referer_host' => 'google.com',
'visited_at' => $yesterday.' 10:00:00',
'is_qr_scan' => false,
'browser_language' => 'pl',
],
[
'short_url_id' => $link->id,
'ip_hash' => hash('sha256', '1.1.1.1'), // same user
'browser' => 'Chrome',
'operating_system' => 'macOS',
'device_type' => 'desktop',
'country' => 'Poland',
'country_code' => 'PL',
'city' => 'Warsaw',
'referer_host' => 'google.com',
'visited_at' => $yesterday.' 11:00:00',
'is_qr_scan' => false,
'browser_language' => 'pl',
],
[
'short_url_id' => $link->id,
'ip_hash' => hash('sha256', '2.2.2.2'), // unique user, QR scan
'browser' => 'Safari',
'operating_system' => 'iOS',
'device_type' => 'mobile',
'country' => 'Poland',
'country_code' => 'PL',
'city' => 'Krakow',
'referer_host' => 'facebook.com',
'visited_at' => $yesterday.' 12:00:00',
'is_qr_scan' => true,
'browser_language' => 'en',
],
]);
// Run the aggregation command
$this->artisan('short-url:aggregate-and-prune')
->assertExitCode(0);
// Verify statistics entry in database
$this->assertDatabaseHas('short_url_daily_stats', [
'short_url_id' => $link->id,
'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.' 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(['iOS' => 1, 'macOS' => 2])
->and($stats->country_stats)->toBe(['Poland' => 3])
->and($stats->city_stats)->toBe(['Krakow (PL)' => 1, 'Warsaw (PL)' => 2])
->and($stats->language_stats)->toBe(['en' => 1, 'pl' => 2]);
});

View File

@@ -1,6 +1,7 @@
<?php
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@@ -129,7 +130,7 @@ it('clears deep linking cache when settings page save is triggered', function ()
});
it('redirects to the intermediate app-redirect page when auto_open_app_mobile is enabled and visited from mobile', function () {
$shortUrl = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlService::class)->create([
$shortUrl = app(ShortUrlService::class)->create([
'destination_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'url_key' => 'ytmobile',
'auto_open_app_mobile' => true,

View File

@@ -348,6 +348,25 @@ it('requires password to redirect when protected', function () {
$this->get('/s/password123')->assertRedirect('https://example.com');
});
it('applies rate limiting on password attempts when protected', function () {
$shortUrl = createShortUrl([
'url_key' => 'password-ratelimit',
'password' => 'secret-combination',
'track_visits' => false,
]);
// First 5 incorrect attempts should return 200 (renders password prompt again)
for ($i = 0; $i < 5; $i++) {
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
->assertStatus(200)
->assertSee('Incorrect password');
}
// 6th incorrect attempt should be rate limited (429)
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
->assertStatus(429);
});
it('shows warning page before redirecting when enabled', function () {
$shortUrl = createShortUrl([
'url_key' => 'warn1',
@@ -685,3 +704,133 @@ it('renders custom branded expired view on deactivated URL', function () {
$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');
});