Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719901d4c6 |
239
README.md
239
README.md
@@ -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.
|
||||
|
||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.css
vendored
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
108
resources/views/webhook-payload-example.blade.php
Normal file
108
resources/views/webhook-payload-example.blade.php
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
240
src/Http/Controllers/ShortUrlLogoController.php
Normal file
240
src/Http/Controllers/ShortUrlLogoController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user