1 Commits

Author SHA1 Message Date
Bartłomiej Janczak
719901d4c6 feat: expand developer REST API, modularize logo controller, and update README for v3.2.0 2026-06-04 14:29:07 +02:00
10 changed files with 996 additions and 302 deletions

239
README.md
View File

@@ -68,7 +68,7 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
- 🔌 **Developer REST API** — Full programmatical control with secure API Key authentication to create, list, and delete short links externally.
- 🔌 **Developer REST API (updated in v3.2.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
@@ -611,6 +611,8 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
### Endpoints
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
#### `GET /api/short-url/links`
List all short URLs (paginated, 30 per page).
@@ -639,7 +641,16 @@ curl https://yourdomain.com/api/short-url/links \
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [],
"notes": null,
@@ -655,6 +666,51 @@ curl https://yourdomain.com/api/short-url/links \
}
```
#### `GET /api/short-url/links/{idOrKey}`
Retrieve details for a single short URL using either its database `id` or short `url_key`.
```bash
curl https://yourdomain.com/api/short-url/links/abc123 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{
"data": {
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"is_enabled": true,
"redirect_status_code": 302,
"total_visits": 47,
"unique_visits": 31,
"max_visits": null,
"activated_at": null,
"expires_at": null,
"webhook_url": null,
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [],
"notes": null,
"created_at": "2026-06-01T12:00:00+00:00"
}
}
```
#### `POST /api/short-url/links`
Create a new short URL.
@@ -673,36 +729,59 @@ curl -X POST https://yourdomain.com/api/short-url/links \
}'
```
**Accepted fields:**
#### `PUT/PATCH /api/short-url/links/{idOrKey}`
Update an existing short URL (resolved dynamically by either database `id` or short `url_key`).
| Field | Type | Required | Description |
```bash
curl -X PATCH https://yourdomain.com/api/short-url/links/promo26 \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://example.com/new-product-page",
"notes": "Updated summer campaign description",
"is_enabled": true
}'
```
**Accepted Fields (for POST & PUT/PATCH requests):**
| Field | Type | Required (POST) | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL |
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
| `notes` | string | ❌ | Internal notes |
| `destination_url` | string (URL) | ✅ | Target URL (max 2048 chars) |
| `url_key` | string | ❌ | Custom unique slug/key (max 32 chars, alpha-dash; auto-generated if omitted) |
| `notes` | string | ❌ | Internal admin notes (max 1000 chars) |
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
| `single_use` | boolean | ❌ | Expire after first click |
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
| `max_visits` | integer | ❌ | Click limit before expiry |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
| `activated_at` | datetime | ❌ | Activation timestamp |
| `expires_at` | datetime | ❌ | Expiration timestamp |
| `pixels` | array of integers | ❌ | List of pixel registry IDs to associate with the link |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
| `password` | string | ❌ | Access protection password |
| `show_warning_page` | boolean | ❌ | Show safety warning page before redirect |
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect status code |
| `single_use` | boolean | ❌ | Expire the short link immediately after the first visit |
| `forward_query_params` | boolean | ❌ | Forward visitor query parameters to target destination |
| `max_visits` | integer | ❌ | Maximum click threshold limit |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL to redirect to upon link expiration |
| `activated_at` | datetime | ❌ | Activation timestamp (must be after or equal to today) |
| `expires_at` | datetime | ❌ | Expiration timestamp (must be after or equal to `activated_at`) |
| `pixels` | array of integers | ❌ | List of registered retargeting pixel IDs to associate with the link |
| `webhook_url` | string (URL) | ❌ | Per-link webhook URL for immediate event notifications |
| `targeting_rules` | array | ❌ | Targeting rules JSON schema (device, geo, rotation, language) |
| `password` | string | ❌ | Password to protect the short URL |
| `show_warning_page` | boolean | ❌ | Toggle redirect warning interstitial page |
| `auto_open_app_mobile` | boolean | ❌ | Auto open deep link in native application on mobile devices |
| `ga_tracking_id` | string | ❌ | Custom Google Analytics 4 Measurement ID for this link (`G-XXXXXXXXXX`) |
| `track_visits` | boolean | ❌ | Toggle tracking/analytics logging for this link (default: `true`) |
| `track_ip_address` | boolean | ❌ | Track client IP address (default: `true`) |
| `track_browser` | boolean | ❌ | Track client browser name (default: `true`) |
| `track_browser_version` | boolean | ❌ | Track client browser version (default: `true`) |
| `track_operating_system` | boolean | ❌ | Track client OS (default: `true`) |
| `track_operating_system_version` | boolean | ❌ | Track client OS version (default: `true`) |
| `track_device_type` | boolean | ❌ | Track client device type (default: `true`) |
| `track_referer_url` | boolean | ❌ | Track visitor referrer URL (default: `true`) |
| `track_browser_language` | boolean | ❌ | Track visitor preferred browser language (default: `true`) |
**Response:** `201 Created` with a wrapper message and the created link data:
**Response (POST & PUT/PATCH success):** `200 OK` (or `201 Created` for POST) containing:
```json
{
"message": "Short URL created successfully.",
"message": "Short URL updated successfully.",
"data": {
"id": 2,
"destination_url": "https://example.com/product",
"destination_url": "https://example.com/new-product-page",
"url_key": "promo26",
"short_url": "https://yourdomain.com/s/promo26",
"is_enabled": true,
@@ -716,41 +795,105 @@ curl -X POST https://yourdomain.com/api/short-url/links \
"targeting_rules": null,
"password": null,
"show_warning_page": false,
"auto_open_app_mobile": false,
"ga_tracking_id": null,
"track_visits": true,
"track_ip_address": true,
"track_browser": true,
"track_browser_version": true,
"track_operating_system": true,
"track_operating_system_version": true,
"track_device_type": true,
"track_referer_url": true,
"track_browser_language": true,
"pixels": [
{
"id": 1,
"name": "Meta Pixel (1234567890)",
"type": "meta",
"pixel_id": "1234567890",
"is_active": true
},
{
"id": 2,
"name": "Google Tag (G-XXXXXXXXXX)",
"type": "google",
"pixel_id": "G-XXXXXXXXXX",
"is_active": true
}
],
"notes": "Summer campaign",
"pixels": [],
"notes": "Updated summer campaign description",
"created_at": "2026-06-04T12:00:00+00:00"
}
}
```
#### `DELETE /api/short-url/links/{id}`
Permanently delete a short URL by its ID.
#### `GET /api/short-url/links/{idOrKey}/stats`
Retrieve visit analytics statistics for a single short URL.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
curl https://yourdomain.com/api/short-url/links/promo26/stats \
-H "X-Api-Key: sh_key_your_key_here"
```
**Optional Query Parameters:**
* `date_from` (string): Filter stats starting from date (e.g. `YYYY-MM-DD`).
* `date_to` (string): Filter stats up to date (e.g. `YYYY-MM-DD`).
**Response:** `200 OK`
```json
{
"data": {
"totalVisits": 156,
"uniqueVisits": 98,
"visitsToday": 14,
"visitsThisWeek": 114,
"visitsThisMonth": 156,
"visitsByDay": {
"2026-06-01": 42,
"2026-06-02": 50
},
"visitsByCountry": {
"Poland": 100,
"United States": 50
},
"visitsByCity": {
"Warsaw (PL)": 80,
"Krakow (PL)": 20
},
"visitsByDevice": {
"desktop": 90,
"mobile": 66
},
"visitsByBrowser": {
"Chrome": 110,
"Safari": 46
},
"visitsByOs": {
"Windows": 70,
"macOS": 60
},
"visitsByReferer": {
"linkedin.com": 80,
"twitter.com": 40
},
"utmSources": {
"linkedin": 80,
"twitter": 40
},
"utmMediums": {
"social": 120
},
"utmCampaigns": {
"spring_sale": 120
},
"qrScans": 12,
"visitsByLanguage": {
"pl": 100,
"en": 56
}
}
}
```
#### `DELETE /api/short-url/links/{idOrKey}`
Permanently delete a short URL by its database `id` or short `url_key`.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/promo26 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{ "message": "Short URL deleted successfully." }
{
"message": "Short URL deleted successfully."
}
```
### Error Responses
@@ -758,6 +901,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 |
@@ -1015,6 +1159,13 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### 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.

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',
@@ -402,6 +412,8 @@ return [
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
'webhook_show_payload' => 'Show example JSON payload',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',

View File

@@ -299,6 +299,16 @@ return [
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'confirm_password' => 'Potwierdź hasło',
'new_password' => 'Nowe hasło',
'change_password' => 'Zmień hasło',
'remove_password' => 'Usuń hasło',
'password_status_active' => 'Ochrona hasłem jest włączona.',
'set_password' => 'Ustaw hasło',
'cancel' => 'Anuluj',
'confirm' => 'Potwierdź',
'password_required_error' => 'Hasło jest wymagane.',
'password_mismatch_error' => 'Hasła nie są identyczne.',
'show_warning_page' => 'Pokaż stronę ostrzegającą przed przekierowaniem',
'show_warning_page_helper' => 'Pokaż ekran pośredni ostrzegający o przekierowaniu na zewnętrzny adres (ochrona przed phishingiem/bezpieczeństwo NSFW).',
'targeting_type' => 'Strategia targetowania',
@@ -399,6 +409,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

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

View File

@@ -10,8 +10,11 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
@@ -19,6 +22,8 @@ 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;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
class ShortUrlForm
{
@@ -410,13 +415,151 @@ 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)) {
\Filament\Notifications\Notification::make()
->title(__('filament-short-url::default.password_required_error') ?? 'Password is required.')
->danger()
->send();
return;
}
if ($password !== $confirm) {
\Filament\Notifications\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'))
@@ -560,6 +703,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 +728,9 @@ class ShortUrlForm
->maxLength(2048)
->nullable()
->columnSpanFull(),
ViewField::make('webhook_payload_example')
->view('filament-short-url::webhook-payload-example')
->columnSpanFull(),
]),
]);
}

View File

@@ -4,16 +4,11 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlApiController extends Controller
{
@@ -46,27 +41,7 @@ class ShortUrlApiController extends Controller
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'destination_url' => 'required|url|max:2048',
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
'notes' => 'nullable|string|max:1000',
'is_enabled' => 'nullable|boolean',
'redirect_status_code' => 'nullable|integer|in:301,302',
'single_use' => 'nullable|boolean',
'forward_query_params' => 'nullable|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:2048',
'activated_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'webhook_url' => 'nullable|url|max:2048',
'targeting_rules' => 'nullable|array',
'password' => 'nullable|string|max:255',
'show_warning_page' => 'nullable|boolean',
'track_visits' => 'nullable|boolean',
'track_browser_language' => 'nullable|boolean',
'pixels' => 'nullable|array',
'pixels.*' => 'integer|exists:short_url_pixels,id',
]);
$validated = $request->validate($this->getValidationRules($request));
$pixelIds = $validated['pixels'] ?? [];
@@ -88,12 +63,41 @@ class ShortUrlApiController extends Controller
], 201);
}
/**
* Display the specified short URL.
*/
public function show(string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
return response()->json([
'data' => $this->transformLink($shortUrl),
]);
}
/**
* Display statistics/analytics for the specified short URL.
*/
public function stats(Request $request, string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
$stats = $shortUrl->getCachedStats(
dateFrom: $request->query('date_from'),
dateTo: $request->query('date_to')
);
return response()->json([
'data' => $stats,
]);
}
/**
* Remove the specified short URL.
*/
public function destroy(int $id): JsonResponse
public function destroy(string|int $idOrKey): JsonResponse
{
$shortUrl = ShortUrl::findOrFail($id);
$shortUrl = $this->findLink($idOrKey);
$shortUrl->delete();
return response()->json([
@@ -101,6 +105,138 @@ 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;
}
return [
'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',
'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',
'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',
];
}
/**
* Transform a ShortUrl model to API response array.
*/
@@ -124,7 +260,16 @@ class ShortUrlApiController extends Controller
'targeting_rules' => $link->targeting_rules,
'password' => $link->password,
'show_warning_page' => (bool) $link->show_warning_page,
'auto_open_app_mobile' => (bool) $link->auto_open_app_mobile,
'ga_tracking_id' => $link->ga_tracking_id,
'track_visits' => (bool) $link->track_visits,
'track_ip_address' => (bool) $link->track_ip_address,
'track_browser' => (bool) $link->track_browser,
'track_browser_version' => (bool) $link->track_browser_version,
'track_operating_system' => (bool) $link->track_operating_system,
'track_operating_system_version' => (bool) $link->track_operating_system_version,
'track_device_type' => (bool) $link->track_device_type,
'track_referer_url' => (bool) $link->track_referer_url,
'track_browser_language' => (bool) $link->track_browser_language,
'pixels' => $pixels->map(fn ($p) => [
'id' => $p->id,
@@ -172,222 +317,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

@@ -131,3 +131,76 @@ 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',
],
]);
});