8 Commits

39 changed files with 3113 additions and 97 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/art export-ignore
/tests export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore

332
README.md
View File

@@ -15,18 +15,36 @@
A professional, high-performance **Short URL Manager** plugin for [Filament v5](https://filamentphp.com). Built from scratch with cutting-edge practices, proxy resistance, offline Geo-IP engines, enterprise-grade smart targeting, and zero external shortening API dependencies.
## Screenshots
<p align="center">
<table align="center" style="border-collapse: collapse; border: none;">
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
</tr>
<tr style="border: none;">
<td width="50%" style="border: none; padding: 5px;"><img src="art/3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
<td width="50%" style="border: none; padding: 5px;"><img src="art/4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
</tr>
</table>
</p>
---
## Features
- 🔗 **Short URL Generation** — Create custom links or let the system auto-generate collision-free Base62 keys.
- 🌍 **Multiple Geo-IP Drivers** — High-speed offline detection using local MaxMind databases, edge-provided CDN headers (Cloudflare, CloudFront, generic), or fallback API integration.
- 🗺️ **Visitor World Map Widget** — Interactive SVG world map on the link statistics dashboard showcasing the geographical distribution of visitor clicks with hover details.
- 📈 **Real-Time Statistics Dashboard** — Track visits in real-time with cached aggregate metrics (countries, devices, browsers, operating systems, referrers, traffic charts, and maps).
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude VPNs, proxies, Tor exit nodes, and automated bot clicks from your analytics to keep visitor statistics accurate.
- 🔍 **Google Safe Browsing Integration** — Automatically verify target URLs on creation/edit to block malicious, phishing, malware, or social engineering links.
- 🎨 **SVG QR Code Designer** — Built-in interactive design canvas in Filament to customize dot styles, margins, gradient coloring, and background transparency with instant SVG download.
-**Ultra-Fast Redirects** — Redirections resolve in milliseconds. Analytical tasks, event dispatching, and GA4 payloads are processed asynchronously via Laravel Queue jobs.
- 🎯 **Google Analytics 4 server-side tracking** — Native integration with the GA4 Measurement Protocol to bypass client-side AdBlockers completely.
- ⚙️ **Dual-way UTM Campaign Builder** — Built-in form builder synchronizes UTM parameters with your destination URLs in real-time (two-way binding).
- 🔒 **Link Rules & Expiry**Disable links manually, set expiration dates, or activate single-use links that deactivate automatically after the first click.
- 🔒 **Link Validity Ranges & Expiry**Set activation date ranges (From - To), custom visit limit counters (e.g. active for 3 clicks then expires), single-use restrictions, and custom fallback redirect URLs on expiration instead of static 410 Gone errors.
- ➡️ **Query Parameter Forwarding** — Dynamically forward client query parameters (e.g. ad tokens, discount codes) to the destination URL.
- 🛠️ **Dedicated Settings GUI** — Manage global configuration (routing, Geo-IP, GA4, cache, rate limiting, aggregation) directly inside the Filament panel without modifying files or `.env` files.
- 💻 **Fluent Developer Builder** — Native model query builder pattern and robust programmatic generation APIs.
@@ -35,6 +53,9 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5](
- 🎯 **Smart Link Targeting** — Route visitors to different destination URLs based on their device type, country, or via weighted A/B split rotation.
- 🛡️ **Rate Limiting / Bot Protection** — Configurable per-IP rate limits on redirects with automatic `429 Too Many Requests` responses.
- 📊 **Daily Stats Aggregation & Pruning** — Automatic daily summarization of raw visit logs into compact daily stats tables. Configurable retention window prevents unbounded database growth at scale.
- 🎯 **Social Retargeting Pixels** *(new in v1.5)* — Inject Meta Pixel, Google Tag, and LinkedIn Insight tracking scripts client-side via a premium glassmorphic interstitial page. Build remarketing audiences even when redirecting to external domains.
- 🔌 **Developer REST API** *(new in v1.5)* — Full REST API (`GET`, `POST`, `DELETE`) for external integrations. Secured with API Key authentication managed via the Settings panel.
- 📡 **Webhooks** *(new in v1.5)* — Real-time HTTP POST event notifications on every click, link creation, or expiration. Configure per-link or globally, dispatched asynchronously via the queue.
---
@@ -341,12 +362,280 @@ The `short_url_daily_stats` table stores pre-aggregated daily summaries per shor
The `getCachedStats()` model method **automatically merges** data from both tables: historical days come from `short_url_daily_stats`, while today's data comes directly from `short_url_visits` — completely transparent to the dashboard.
### Queue Worker vs. Synchronous Execution
The plugin is designed to be highly reliable and performant regardless of whether your hosting environment runs a background queue worker.
#### 1. With a Queue Worker (Recommended)
If your application runs a queue worker (e.g. via supervisor running `php artisan queue:work`), you should configure the **Queue Connection** in the **Settings** panel (or via `SHORT_URL_QUEUE` env variable) to your primary queue driver (like `redis` or `database`).
- **Performance**: High-speed redirects are prioritized. Visitor clicks, Geo-IP lookups, GA4 hits, and Webhook dispatches are immediately deferred to background queue jobs (`TrackShortUrlVisitJob` and `SendWebhookJob`), keeping redirect response times under 20ms.
- **Worker Configuration**: Ensure your queue listener is running:
```bash
php artisan queue:work --queue=default
```
*(If you customized the queue name in Settings, replace `default` with your custom queue name).*
#### 2. Without a Queue Worker (Sync Driver Fallback)
If you do not run background queue workers, set the **Queue Connection** to `sync`.
- **Automatic Fallback**: The plugin dynamically executes all jobs synchronously on the request thread. The visitor is redirected as soon as the synchronous processes (log recording, webhook calls, etc.) complete.
- **Fault Tolerance (Safety Nets)**: Webhook failures (timeouts, 500 errors) or database tracking glitches can happen. The plugin wraps all synchronous execution points in strict error boundaries (`try/catch`). **A failed tracking job or a slow/offline webhook will NEVER crash the visitor's redirect or programmatic REST API responses.** They are gracefully logged in the background.
### Queue-Based Counter Fallback
When Redis is not available and counter buffering is enabled, the `IncrementVisitJob` is dispatched to the configured queue connection. This guarantees visit counts are not lost during cache evictions or restarts.
---
## Social Retargeting Pixels (new in v1.5.0)
The **Marketing & API** tab in the short URL form lets you attach client-side tracking pixels to any link. When a visitor clicks a link that has pixels configured, instead of an instant 302 redirect the plugin serves a lightweight, premium HTML interstitial page.
### The Interstitial Experience
- **Premium Design**: Built using the exact same modern glassmorphic look as the password protection and warning interstitial pages. Supports dark mode automatically.
- **Brand Customization**: Automatically renders the application logo and the **Site Name Override** (configured globally in Settings, falling back to `config('app.name')`) to ensure the transition page looks professional and trust-instilling.
- **Micro-Animations & Smooth Redirect**: Displays a sleek animated loading spinner and a progress bar that smoothly fills from 0% to 100% in ~220-250ms. This short delay ensures browser execution time for the tracking scripts before performing a seamless `window.location.replace()` to the destination URL.
This unlocks remarketing to people who clicked your links **even when redirecting to external domains** (e.g. booking.com, amazon.com) where you cannot install your own tracking code.
### Supported Pixel Providers
| Field | Provider | Script loaded |
|---|---|---|
| **Meta Pixel ID** | Meta / Facebook Ads | `fbevents.js` via `fbq('init', ...)` |
| **Google Tag / GA4 ID** | Google Ads, GA4 | `gtag.js` via Google Tag Manager |
| **LinkedIn Partner ID** | LinkedIn Insight Tag | `insight.min.js` via LinkedIn |
> **Note:** These pixels fire **client-side** in the visitor's browser — completely separate from the server-side GA4 Measurement Protocol integration. Both systems work in parallel and do not interfere with each other.
### How to use
1. Open any short URL for editing.
2. Navigate to the **Marketing & API** tab.
3. Enter your pixel IDs in the **Retargeting Pixels** section.
4. Save. Done — every click will now trigger the configured tracking scripts.
```php
// Programmatically via model attributes
$shortUrl->update([
'pixel_meta_id' => '1234567890',
'pixel_google_id' => 'G-XXXXXXXXXX',
'pixel_linkedin_id' => '1234567',
]);
```
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
---
## Developer REST API (new in v1.5.0)
The plugin exposes a REST API that allows external systems (CRMs, Zapier, Make, custom integrations) to manage short URLs programmatically.
### Enabling the API
The API is **disabled by default**. Enable it in **Settings → API & Webhooks → REST API Access → Enable Developer REST API**.
### Authentication
All API endpoints are protected. Include your API key in every request using one of these headers:
```
X-Api-Key: sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
or
```
Authorization: Bearer sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
### Endpoints
#### `GET /api/short-url/links`
List all short URLs (paginated, 30 per page).
```bash
curl https://yourdomain.com/api/short-url/links \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:**
```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,
"pixel_meta_id": null,
"pixel_google_id": null,
"pixel_linkedin_id": null,
"webhook_url": null,
"notes": null,
"created_at": "2026-06-01T12:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 3,
"per_page": 30,
"total": 72
}
}
```
#### `POST /api/short-url/links`
Create a new short URL.
```bash
curl -X POST https://yourdomain.com/api/short-url/links \
-H "X-Api-Key: sh_key_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://example.com/product",
"url_key": "promo26",
"notes": "Summer campaign",
"single_use": false,
"max_visits": 1000,
"pixel_meta_id": "1234567890",
"webhook_url": "https://api.mycrm.com/clicks"
}'
```
**Accepted fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `destination_url` | string (URL) | ✅ | Target URL |
| `url_key` | string | ❌ | Custom slug (auto-generated if omitted) |
| `notes` | string | ❌ | Internal notes |
| `is_enabled` | boolean | ❌ | Active status (default: `true`) |
| `redirect_status_code` | integer (301/302) | ❌ | HTTP redirect code |
| `single_use` | boolean | ❌ | Expire after first click |
| `forward_query_params` | boolean | ❌ | Forward query string to destination |
| `max_visits` | integer | ❌ | Click limit before expiry |
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
| `activated_at` | datetime | ❌ | Activation timestamp |
| `expires_at` | datetime | ❌ | Expiration timestamp |
| `pixel_meta_id` | string | ❌ | Meta Pixel ID |
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID |
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID |
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
**Response:** `201 Created` with the created link object.
#### `DELETE /api/short-url/links/{id}`
Permanently delete a short URL by its ID.
```bash
curl -X DELETE https://yourdomain.com/api/short-url/links/42 \
-H "X-Api-Key: sh_key_your_key_here"
```
**Response:** `200 OK`
```json
{ "message": "Short URL deleted successfully." }
```
### Error Responses
| HTTP Code | Reason |
|---|---|
| `401 Unauthorized` | Missing or invalid API key |
| `422 Unprocessable Entity` | Validation error (see `errors` field in response) |
| `503 Service Unavailable` | REST API is disabled in Settings |
---
## Webhooks (new in v1.5.0)
Webhooks allow external systems to receive real-time HTTP POST notifications when events occur on your short URLs. Payloads are dispatched **asynchronously** via the Laravel Queue — redirects are never blocked.
### Configuration
Webhooks can be configured at two levels:
**1. Per-link Webhook (Dedicated Webhook URL)**:
* **Where to configure**: Set the `Dedicated Webhook URL` in the **Marketing & API** tab of a specific short URL (or pass it via the `webhook_url` parameter in the REST API).
* **How it works**: When a visitor clicks the short URL, or when the link is programmatically created via the REST API, a webhook notification is sent directly to this URL.
* **Bypassing Global Filters**: Dedicated webhooks **always fire** for these events and are completely independent of the global *Monitored Webhook Events* settings. This is useful for third-party landing pages or integrations that need to track a specific link's traffic without routing all package events.
**2. Global Webhook**:
* **Where to configure**: Set a **Global Webhook URL** in **Settings → API & Webhooks → Global Webhook Configuration**.
* **How it works**: Fires for all links that *do not* have their own Dedicated Webhook URL configured. It will only fire for the specific event types selected in the **Monitored Webhook Events** setting.
### Monitored Events
| Event key | When fired |
|---|---|
| `visited` | A visitor clicks the short URL |
| `created` | A new short URL is created via the REST API |
| `expired` | A link reaches its expiration date |
| `limit_reached` | A link reaches its `max_visits` click limit |
Select which events to monitor in **Settings → API & Webhooks → Monitored Webhook Events**.
### Payload Format
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
```json
{
"event": "visited",
"timestamp": "2026-06-02T10:00:00+00:00",
"short_url": {
"id": 1,
"destination_url": "https://example.com",
"url_key": "abc123",
"short_url": "https://yourdomain.com/s/abc123",
"total_visits": 48,
"unique_visits": 32
},
"visit": {
"id": 101,
"visited_at": "2026-06-02T10:00:00+00:00",
"device_type": "desktop",
"browser": "Chrome",
"browser_version": "124.0",
"operating_system": "Windows",
"operating_system_version": "10",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://linkedin.com",
"referer_host": "linkedin.com",
"utm_source": "linkedin",
"utm_medium": "social",
"utm_campaign": "spring_sale",
"utm_term": null,
"utm_content": null
}
}
```
### Retry Policy
If the webhook endpoint returns a non-2xx response or is unreachable, the `SendWebhookJob` will automatically retry up to **3 times** with a **10-second backoff** between attempts. Failed jobs land in your queue's failed jobs table after exhausting retries.
### Webhook Priority (per-link vs global)
The resolution order is:
1. If the short URL has its own `webhook_url` → use it (always fires, regardless of global event selection).
2. If no per-link URL is set, and a **Global Webhook URL** is configured, and the event type is in the selected **Monitored Events** list → use the global URL.
3. Otherwise no webhook is fired.
---
## Configuration Reference (.env)
You can also pre-configure all parameters via your `.env` file:
@@ -386,6 +675,14 @@ As of version `1.3.0`, **you no longer need to manually copy scheduled commands
- **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON).
- **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON).
> [!IMPORTANT]
> **Cron Setup Required**:
> For the scheduler to fire these tasks automatically, your server must run the standard Laravel Scheduler cron job. Add this single cron entry to your server:
> ```bash
> * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
> ```
> *Note: These commands execute synchronously within the scheduling process. You do **not** need a queue worker (`php artisan queue:work`) to run them.*
If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions):
```php
@@ -436,10 +733,14 @@ echo $shortUrl->getShortUrl(); // https://yourdomain.com/s/promo2026
| `notes()` | `string` | Appends internal notes for administrators. |
| `singleUse()` | `bool` (default `true`) | Makes the short URL expire immediately after the first successful click. |
| `forwardQueryParams()` | `bool` (default `true`) | Forwards client-side incoming parameters (e.g. `?gclid=xxx`) to the final target. |
| `expiresAt()` | `DateTimeInterface\|Carbon\|null` | Automatically sets a timestamp after which the URL returns a `410 Gone` error. |
| `expiresAt()` | `DateTimeInterface\|Carbon\|null` | Automatically sets an expiration timestamp after which the URL is inactive. |
| `activatedAt()` | `DateTimeInterface\|Carbon\|null` | Sets the timestamp from which the URL is active. |
| `deactivatedAt()` | `DateTimeInterface\|Carbon\|null` | Sets the timestamp after which the URL is inactive. |
| `maxVisits()` | `int\|null` | Sets a custom limit of total visits allowed. |
| `expirationRedirectUrl()` | `string\|null` | Sets a custom URL to redirect to when expired/inactive. |
| `trackVisits()` | `bool` (default `true`) | Toggles statistical logging for this link. |
| `withTracing()` | `array` | Appends non-empty tracking parameters (like UTM codes) directly to the target URL. |
| `create()` | `void` | Persists the model to the database and returns the `ShortUrl` instance. |
| `create()` | `ShortUrl` | Persists the model to the database and returns the `ShortUrl` instance. |
---
@@ -501,7 +802,30 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
## Changelog
### v1.3.0 (Latest)
### v1.6.0
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
- **Enhanced Caching & Chart Formatting** — Improved analytics caching for high volumes, and clean `d.m` date formatting (removed year) on visitor stats charts.
### v1.5.1
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing.
### v1.5.0
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
- **Developer REST API** — Full `GET /api/short-url/links`, `POST /api/short-url/links`, and `DELETE /api/short-url/links/{id}` endpoints with API Key authentication (managed via Settings UI). Supports creating links with all available attributes including pixels and webhooks.
- **Webhook System** — Real-time HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events. Configurable per-link or globally. Dispatched asynchronously via `SendWebhookJob` with 3-attempt retry policy and 10-second backoff.
- **Settings: API & Webhooks Tab** — New settings tab to manage global webhook URL, monitored event types, and developer API key management with name labels and per-key activation toggles.
### v1.4.0
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.
- **Reactive Validity Controls** — Master switch to toggle date limits, including bidirectional datetime picker constraints (Active From cannot exceed Expires At and vice versa) and custom UI field visibility.
- **Smart Model Observers** — Automatic cleanup of unused parameters (like clearing `max_visits` if `single_use` is enabled, and clearing expiration fallbacks if date limits are off) to guarantee database consistency.
- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder.
### v1.3.0
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.

BIN
art/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
art/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

BIN
art/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

BIN
art/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@@ -14,6 +14,16 @@ return [
*/
'route_prefix' => env('SHORT_URL_PREFIX', 's'),
/*
|--------------------------------------------------------------------------
| Site Name Override
|--------------------------------------------------------------------------
| The brand or site name displayed on the password prompt, redirect warnings,
| and pixel loading screens. Falls back to config('app.name') if empty.
|
*/
'site_name' => env('SHORT_URL_SITE_NAME'),
/*
|--------------------------------------------------------------------------
| Default Redirect Status Code
@@ -93,6 +103,15 @@ return [
*/
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
/*
|--------------------------------------------------------------------------
| Queue Name
|--------------------------------------------------------------------------
| The queue name to which visit tracking and counter jobs are dispatched.
|
*/
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
/*
|--------------------------------------------------------------------------
| Redirect Cache TTL
@@ -127,8 +146,8 @@ return [
'foreground_color' => '#000000',
'background_color' => '#ffffff',
'gradient_enabled' => false,
'gradient_from' => '#000000',
'gradient_to' => '#ffffff',
'gradient_from' => '#4f46e5',
'gradient_to' => '#06b6d4',
'gradient_type' => 'linear',
],

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->unsignedInteger('max_visits')->nullable();
$table->string('expiration_redirect_url', 2048)->nullable();
});
}
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table): void {
$table->dropColumn(['max_visits', 'expiration_redirect_url']);
});
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('pixel_meta_id', 100)->nullable();
$table->string('pixel_google_id', 100)->nullable();
$table->string('pixel_linkedin_id', 100)->nullable();
$table->string('webhook_url', 2048)->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
]);
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->boolean('is_bot')->default(false)->index();
$table->boolean('is_proxy')->default(false)->index();
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table): void {
$table->dropColumn(['is_bot', 'is_proxy']);
});
}
};

View File

@@ -0,0 +1,253 @@
<?php
return [
'AF' => 'Afghanistan',
'AX' => 'Åland Islands',
'AL' => 'Albania',
'DZ' => 'Algeria',
'AS' => 'American Samoa',
'AD' => 'Andorra',
'AO' => 'Angola',
'AI' => 'Anguilla',
'AQ' => 'Antarctica',
'AG' => 'Antigua and Barbuda',
'AR' => 'Argentina',
'AM' => 'Armenia',
'AW' => 'Aruba',
'AU' => 'Australia',
'AT' => 'Austria',
'AZ' => 'Azerbaijan',
'BS' => 'Bahamas',
'BH' => 'Bahrain',
'BD' => 'Bangladesh',
'BB' => 'Barbados',
'BY' => 'Belarus',
'BE' => 'Belgium',
'BZ' => 'Belize',
'BJ' => 'Benin',
'BM' => 'Bermuda',
'BT' => 'Bhutan',
'BO' => 'Bolivia',
'BQ' => 'Bonaire, Sint Eustatius and Saba',
'BA' => 'Bosnia and Herzegovina',
'BW' => 'Botswana',
'BV' => 'Bouvet Island',
'BR' => 'Brazil',
'IO' => 'British Indian Ocean Territory',
'BN' => 'Brunei Darussalam',
'BG' => 'Bulgaria',
'BF' => 'Burkina Faso',
'BI' => 'Burundi',
'CV' => 'Cabo Verde',
'KH' => 'Cambodia',
'CM' => 'Cameroon',
'CA' => 'Canada',
'KY' => 'Cayman Islands',
'CF' => 'Central African Republic',
'TD' => 'Chad',
'CL' => 'Chile',
'CN' => 'China',
'CX' => 'Christmas Island',
'CC' => 'Cocos (Keeling) Islands',
'CO' => 'Colombia',
'KM' => 'Comoros',
'CD' => 'Congo (Democratic Republic)',
'CG' => 'Congo (Republic)',
'CK' => 'Cook Islands',
'CR' => 'Costa Rica',
'CI' => 'Côte d\'Ivoire',
'HR' => 'Croatia',
'CU' => 'Cuba',
'CW' => 'Curaçao',
'CY' => 'Cyprus',
'CZ' => 'Czech Republic',
'DK' => 'Denmark',
'DJ' => 'Djibouti',
'DM' => 'Dominica',
'DO' => 'Dominican Republic',
'EC' => 'Ecuador',
'EG' => 'Egypt',
'SV' => 'El Salvador',
'GQ' => 'Equatorial Guinea',
'ER' => 'Eritrea',
'EE' => 'Estonia',
'SZ' => 'Eswatini',
'ET' => 'Ethiopia',
'FK' => 'Falkland Islands',
'FO' => 'Faroe Islands',
'FJ' => 'Fiji',
'FI' => 'Finland',
'FR' => 'France',
'GF' => 'French Guiana',
'PF' => 'French Polynesia',
'TF' => 'French Southern Territories',
'GA' => 'Gabon',
'GM' => 'Gambia',
'GE' => 'Georgia',
'DE' => 'Germany',
'GH' => 'Ghana',
'GI' => 'Gibraltar',
'GR' => 'Greece',
'GL' => 'Greenland',
'GD' => 'Grenada',
'GP' => 'Guadeloupe',
'GU' => 'Guam',
'GT' => 'Guatemala',
'GG' => 'Guernsey',
'GN' => 'Guinea',
'GW' => 'Guinea-Bissau',
'GY' => 'Guyana',
'HT' => 'Haiti',
'HM' => 'Heard Island and McDonald Islands',
'VA' => 'Holy See',
'HN' => 'Honduras',
'HK' => 'Hong Kong',
'HU' => 'Hungary',
'IS' => 'Iceland',
'IN' => 'India',
'ID' => 'Indonesia',
'IR' => 'Iran',
'IQ' => 'Iraq',
'IE' => 'Ireland',
'IM' => 'Isle of Man',
'IL' => 'Israel',
'IT' => 'Italy',
'JM' => 'Jamaica',
'JP' => 'Japan',
'JE' => 'Jersey',
'JO' => 'Jordan',
'KZ' => 'Kazakhstan',
'KE' => 'Kenya',
'KI' => 'Kiribati',
'KP' => 'North Korea',
'KR' => 'South Korea',
'KW' => 'Kuwait',
'KG' => 'Kyrgyzstan',
'LA' => 'Lao People\'s Democratic Republic',
'LV' => 'Latvia',
'LB' => 'Lebanon',
'LS' => 'Lesotho',
'LR' => 'Liberia',
'LY' => 'Libya',
'LI' => 'Liechtenstein',
'LT' => 'Lithuania',
'LU' => 'Luxembourg',
'MO' => 'Macao',
'MG' => 'Madagascar',
'MW' => 'Malawi',
'MY' => 'Malaysia',
'MV' => 'Maldives',
'ML' => 'Mali',
'MT' => 'Malta',
'MH' => 'Marshall Islands',
'MQ' => 'Martinique',
'MR' => 'Mauritania',
'MU' => 'Mauritius',
'YT' => 'Mayotte',
'MX' => 'Mexico',
'FM' => 'Micronesia',
'MD' => 'Moldova',
'MC' => 'Monaco',
'MN' => 'Mongolia',
'ME' => 'Montenegro',
'MS' => 'Montserrat',
'MA' => 'Morocco',
'MZ' => 'Mozambique',
'MM' => 'Myanmar',
'NA' => 'Namibia',
'NR' => 'Nauru',
'NP' => 'Nepal',
'NL' => 'Netherlands',
'NC' => 'New Caledonia',
'NZ' => 'New Zealand',
'NI' => 'Nicaragua',
'NE' => 'Niger',
'NG' => 'Nigeria',
'NU' => 'Niue',
'NF' => 'Norfolk Island',
'MK' => 'North Macedonia',
'MP' => 'Northern Mariana Islands',
'NO' => 'Norway',
'OM' => 'Oman',
'PK' => 'Pakistan',
'PW' => 'Palau',
'PS' => 'Palestine',
'PA' => 'Panama',
'PG' => 'Papua New Guinea',
'PY' => 'Paraguay',
'PE' => 'Peru',
'PH' => 'Philippines',
'PN' => 'Pitcairn',
'PL' => 'Poland',
'PT' => 'Portugal',
'PR' => 'Puerto Rico',
'QA' => 'Qatar',
'RE' => 'Réunion',
'RO' => 'Romania',
'RU' => 'Russian Federation',
'RW' => 'Rwanda',
'BL' => 'Saint Barthélemy',
'SH' => 'Saint Helena, Ascension and Tristan da Cunha',
'KN' => 'Saint Kitts and Nevis',
'LC' => 'Saint Lucia',
'MF' => 'Saint Martin (French part)',
'PM' => 'Saint Pierre and Miquelon',
'VC' => 'Saint Vincent and the Grenadines',
'WS' => 'Samoa',
'SM' => 'San Marino',
'ST' => 'Sao Tome and Principe',
'SA' => 'Saudi Arabia',
'SN' => 'Senegal',
'RS' => 'Serbia',
'SC' => 'Seychelles',
'SL' => 'Sierra Leone',
'SG' => 'Singapore',
'SX' => 'Sint Maarten (Dutch part)',
'SK' => 'Slovakia',
'SI' => 'Slovenia',
'SB' => 'Solomon Islands',
'SO' => 'Somalia',
'ZA' => 'South Africa',
'GS' => 'South Georgia and the South Sandwich Islands',
'SS' => 'South Sudan',
'ES' => 'Spain',
'LK' => 'Sri Lanka',
'SD' => 'Sudan',
'SR' => 'Suriname',
'SJ' => 'Svalbard and Jan Mayen',
'SE' => 'Sweden',
'CH' => 'Switzerland',
'SY' => 'Syrian Arab Republic',
'TW' => 'Taiwan',
'TJ' => 'Tajikistan',
'TZ' => 'Tanzania',
'TH' => 'Thailand',
'TL' => 'Timor-Leste',
'TG' => 'Togo',
'TK' => 'Tokelau',
'TO' => 'Tonga',
'TT' => 'Trinidad and Tobago',
'TN' => 'Tunisia',
'TR' => 'Turkey',
'TM' => 'Turkmenistan',
'TC' => 'Turks and Caicos Islands',
'TV' => 'Tuvalu',
'UG' => 'Uganda',
'UA' => 'Ukraine',
'AE' => 'United Arab Emirates',
'GB' => 'United Kingdom',
'UM' => 'United States Minor Outlying Islands',
'US' => 'United States',
'UY' => 'Uruguay',
'UZ' => 'Uzbekistan',
'VU' => 'Vanuatu',
'VE' => 'Venezuela',
'VN' => 'Viet Nam',
'VG' => 'Virgin Islands (British)',
'VI' => 'Virgin Islands (U.S.)',
'WF' => 'Wallis and Futuna',
'EH' => 'Western Sahara',
'YE' => 'Yemen',
'ZM' => 'Zambia',
'ZW' => 'Zimbabwe',
];

View File

@@ -24,6 +24,13 @@ return [
'forward_query_params' => 'Forward Query Parameters',
'forward_query_params_helper' => 'Append incoming query parameters (like UTM tags) to the destination URL.',
'expires_at' => 'Expires At',
'activated_at' => 'Active From',
'max_visits' => 'Max Visits Limit',
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
'expiration_redirect_url' => 'Expiration Redirect URL',
'expiration_redirect_url_helper' => 'Fallback URL to redirect to when the link is expired or inactive (leaves blank for 410 Gone error page).',
'form_section_validity' => 'Validity & Limits',
'use_date_validity' => 'Set Date Range Limits',
'notes' => 'Internal Notes',
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
@@ -160,6 +167,8 @@ return [
'settings_section_ga4' => 'GA4 Measurement Protocol',
'settings_ga4_description' => 'Configure server-side Google Analytics 4 event tracking via the Measurement Protocol. Events are sent in the background without blocking the redirect.',
'settings_site_name' => 'Site Name Override',
'settings_site_name_helper' => 'Custom brand/site name displayed on redirect prompt screens. If empty, falls back to config("app.name").',
'settings_route_prefix' => 'Route Prefix',
'settings_route_prefix_helper' => 'URL segment before the short key, e.g. "/s/abc123". Change requires a config:clear.',
'settings_key_length' => 'Auto-generated Key Length',
@@ -288,6 +297,39 @@ return [
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
'settings_rate_limiting_decay_seconds_helper' => 'Time period for the rate limiter.',
// Queue Settings Additions
'settings_queue_name' => 'Queue Name',
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
'settings_geoip_stats_cache_ttl_helper' => 'Number of seconds to cache query metrics on the dashboard statistics page (prevents database load). Default: 300 (5 minutes).',
// Default Tracking Tab
'settings_tab_tracking_defaults' => 'Default Tracking',
'settings_section_tracking_defaults' => 'Default Visitor Tracking Settings',
'settings_section_tracking_defaults_helper' => 'These settings govern the default tracking status for newly created short URLs.',
'settings_track_visits_default' => 'Enable Tracking by Default',
'settings_track_ip_default' => 'Default IP Address Tracking',
'settings_track_browser_default' => 'Default Browser Tracking',
'settings_track_browser_version_default' => 'Default Browser Version Tracking',
'settings_track_os_default' => 'Default OS Tracking',
'settings_track_os_version_default' => 'Default OS Version Tracking',
'settings_track_referer_default' => 'Default Referer URL Tracking',
'settings_track_device_type_default' => 'Default Device Type Tracking',
// QR Defaults Tab
'settings_tab_qr' => 'QR Defaults',
'settings_section_qr_defaults' => 'Default QR Code Design Options',
'settings_section_qr_defaults_helper' => 'Configure standard design styling automatically loaded for newly generated QR codes.',
'settings_qr_size' => 'Default Size (px)',
'settings_qr_margin' => 'Default Margin',
'settings_qr_dot_style' => 'Default Dot Style',
'settings_qr_foreground_color' => 'Default Foreground Color',
'settings_qr_background_color' => 'Default Background Color',
'settings_qr_gradient_enabled' => 'Enable Gradient by Default',
'settings_qr_gradient_from' => 'Gradient Color From',
'settings_qr_gradient_to' => 'Gradient Color To',
'settings_qr_gradient_type' => 'Default Gradient Type',
// Views
'password_title' => 'Password Required',
'password_description' => 'This link is password-protected. Please enter the correct password to continue.',
@@ -298,4 +340,48 @@ return [
'warning_description' => 'You are leaving this secure portal and being redirected to an external target link. Please ensure you trust the address below:',
'warning_btn_continue' => 'Continue to Destination',
'warning_btn_back' => 'Go Back',
'pixel_loading_title' => 'Connecting...',
'pixel_loading_description' => 'Preparing your connection and forwarding you now.',
// Marketing & API Tab
'tab_marketing' => 'Marketing & API',
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'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',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',
'settings_section_rest_api' => 'REST API Access',
'settings_api_enabled' => 'Enable Developer REST API',
'settings_api_enabled_helper' => 'Allow external systems to create, list, and delete short URLs via the REST API. Disable to block all /api/short-url/* endpoints with a 503 response.',
'settings_section_global_webhook' => 'Global Webhook Configuration',
'settings_global_webhook_url' => 'Global Webhook URL',
'settings_global_webhook_url_helper' => 'Destination URL to dispatch event payloads asynchronously in the background for all links.',
'settings_webhook_events' => 'Monitored Webhook Events',
'settings_webhook_events_helper' => 'Select which events should trigger a POST notification.',
'webhook_event_visited' => 'Link Visited (Click)',
'webhook_event_created' => 'Link Created',
'webhook_event_expired' => 'Link Expired (Date)',
'webhook_event_limit_reached' => 'Link Click Limit Reached',
'settings_section_api_keys' => 'Developer API Keys',
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
'settings_api_keys' => 'Active API Keys',
'api_key_name' => 'Key Name (Description)',
'api_key' => 'API Key',
'active' => 'Active',
// World Map Widget
'world_map_title' => 'Visitor World Map',
'world_map_total_clicks' => 'total clicks',
'world_map_countries' => 'countries',
'world_map_fewer' => 'Fewer',
'world_map_more' => 'More',
'world_map_top_countries' => 'Top Countries',
'world_map_no_data' => 'No geographic data yet.',
'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.',
];

View File

@@ -0,0 +1,253 @@
<?php
return [
'AF' => 'Afganistan',
'AX' => 'Wyspy Alandzkie',
'AL' => 'Albania',
'DZ' => 'Algieria',
'AS' => 'Samoa Amerykańskie',
'AD' => 'Andora',
'AO' => 'Angola',
'AI' => 'Anguilla',
'AQ' => 'Antarktyda',
'AG' => 'Antigua i Barbuda',
'AR' => 'Argentyna',
'AM' => 'Armenia',
'AW' => 'Aruba',
'AU' => 'Australia',
'AT' => 'Austria',
'AZ' => 'Azerbejdżan',
'BS' => 'Bahamy',
'BH' => 'Bahrajn',
'BD' => 'Bangladesz',
'BB' => 'Barbados',
'BY' => 'Białoruś',
'BE' => 'Belgia',
'BZ' => 'Belize',
'BJ' => 'Benin',
'BM' => 'Bermudy',
'BT' => 'Bhutan',
'BO' => 'Boliwia',
'BQ' => 'Bonaire, Sint Eustatius i Saba',
'BA' => 'Bośnia i Hercegowina',
'BW' => 'Botswana',
'BV' => 'Wyspa Bouveta',
'BR' => 'Brazylia',
'IO' => 'Brytyjskie Terytorium Oceanu Indyjskiego',
'BN' => 'Brunei',
'BG' => 'Bułgaria',
'BF' => 'Burkina Faso',
'BI' => 'Burundi',
'CV' => 'Cabo Verde',
'KH' => 'Kambodża',
'CM' => 'Kamerun',
'CA' => 'Kanada',
'KY' => 'Kajmany',
'CF' => 'Republika Środkowoafrykańska',
'TD' => 'Czad',
'CL' => 'Chile',
'CN' => 'Chiny',
'CX' => 'Wyspa Bożego Narodzenia',
'CC' => 'Wyspy Kokosowe',
'CO' => 'Kolumbia',
'KM' => 'Komory',
'CD' => 'Demokratyczna Republika Konga',
'CG' => 'Kongo',
'CK' => 'Wyspy Cooka',
'CR' => 'Kostaryka',
'CI' => 'Wybrzeże Kości Słoniowej',
'HR' => 'Chorwacja',
'CU' => 'Kuba',
'CW' => 'Curaçao',
'CY' => 'Cypr',
'CZ' => 'Czechy',
'DK' => 'Dania',
'DJ' => 'Dżibuti',
'DM' => 'Dominika',
'DO' => 'Dominikana',
'EC' => 'Ekwador',
'EG' => 'Egipt',
'SV' => 'Salwador',
'GQ' => 'Gwinea Równikowa',
'ER' => 'Erytrea',
'EE' => 'Estonia',
'SZ' => 'Eswatini',
'ET' => 'Etiopia',
'FK' => 'Falklandy',
'FO' => 'Wyspy Owcze',
'FJ' => 'Fidżi',
'FI' => 'Finlandia',
'FR' => 'Francja',
'GF' => 'Gujana Francuska',
'PF' => 'Polinezja Francuska',
'TF' => 'Francuskie Terytoria Południowe',
'GA' => 'Gabon',
'GM' => 'Gambia',
'GE' => 'Gruzja',
'DE' => 'Niemcy',
'GH' => 'Ghana',
'GI' => 'Gibraltar',
'GR' => 'Grecja',
'GL' => 'Grenlandia',
'GD' => 'Grenada',
'GP' => 'Gwadelupa',
'GU' => 'Guam',
'GT' => 'Gwatemala',
'GG' => 'Guernsey',
'GN' => 'Gwinea',
'GW' => 'Gwinea Bissau',
'GY' => 'Gujana',
'HT' => 'Haiti',
'HM' => 'Wyspy Heard i McDonalda',
'VA' => 'Watykan',
'HN' => 'Honduras',
'HK' => 'Hongkong',
'HU' => 'Węgry',
'IS' => 'Islandia',
'IN' => 'Indie',
'ID' => 'Indonezja',
'IR' => 'Iran',
'IQ' => 'Irak',
'IE' => 'Irlandia',
'IM' => 'Wyspa Man',
'IL' => 'Izrael',
'IT' => 'Włochy',
'JM' => 'Jamajka',
'JP' => 'Japonia',
'JE' => 'Jersey',
'JO' => 'Jordania',
'KZ' => 'Kazachstan',
'KE' => 'Kenia',
'KI' => 'Kiribati',
'KP' => 'Korea Północna',
'KR' => 'Korea Południowa',
'KW' => 'Kuwejt',
'KG' => 'Kirgistan',
'LA' => 'Laos',
'LV' => 'Łotwa',
'LB' => 'Liban',
'LS' => 'Lesotho',
'LR' => 'Liberia',
'LY' => 'Libia',
'LI' => 'Liechtenstein',
'LT' => 'Litwa',
'LU' => 'Luksemburg',
'MO' => 'Makau',
'MG' => 'Madagaskar',
'MW' => 'Malawi',
'MY' => 'Malezja',
'MV' => 'Malediwy',
'ML' => 'Mali',
'MT' => 'Malta',
'MH' => 'Wyspy Marshalla',
'MQ' => 'Martynika',
'MR' => 'Mauretania',
'MU' => 'Mauritius',
'YT' => 'Majotta',
'MX' => 'Meksyk',
'FM' => 'Mikronezja',
'MD' => 'Mołdawia',
'MC' => 'Monako',
'MN' => 'Mongolia',
'ME' => 'Czarnogóra',
'MS' => 'Montserrat',
'MA' => 'Maroko',
'MZ' => 'Mozambik',
'MM' => 'Mjanma',
'NA' => 'Namibia',
'NR' => 'Nauru',
'NP' => 'Nepal',
'NL' => 'Holandia',
'NC' => 'Nowa Kaledonia',
'NZ' => 'Nowa Zelandia',
'NI' => 'Nikaragua',
'NE' => 'Niger',
'NG' => 'Nigeria',
'NU' => 'Niue',
'NF' => 'Norfolk',
'MK' => 'Macedonia Północna',
'MP' => 'Mariany Północne',
'NO' => 'Norwegia',
'OM' => 'Oman',
'PK' => 'Pakistan',
'PW' => 'Palau',
'PS' => 'Palestyna',
'PA' => 'Panama',
'PG' => 'Papua-Nowa Gwinea',
'PY' => 'Paragwaj',
'PE' => 'Peru',
'PH' => 'Filipiny',
'PN' => 'Pitcairn',
'PL' => 'Polska',
'PT' => 'Portugalia',
'PR' => 'Portoryko',
'QA' => 'Katar',
'RE' => 'Reunion',
'RO' => 'Rumunia',
'RU' => 'Rosja',
'RW' => 'Rwanda',
'BL' => 'Saint-Barthélemy',
'SH' => 'Święta Helena, Wyspa Wniebowstąpienia i Tristan da Cunha',
'KN' => 'Saint Kitts i Nevis',
'LC' => 'Saint Lucia',
'MF' => 'Saint-Martin',
'PM' => 'Saint-Pierre i Miquelon',
'VC' => 'Saint Vincent i Grenadyny',
'WS' => 'Samoa',
'SM' => 'San Marino',
'ST' => 'Wyspy Świętego Tomasza i Książęca',
'SA' => 'Arabia Saudyjska',
'SN' => 'Senegal',
'RS' => 'Serbia',
'SC' => 'Seszele',
'SL' => 'Sierra Leone',
'SG' => 'Singapur',
'SX' => 'Sint Maarten',
'SK' => 'Słowacja',
'SI' => 'Słowenia',
'SB' => 'Wyspy Salomona',
'SO' => 'Somalia',
'ZA' => 'Południowa Afryka',
'GS' => 'Georgia Południowa i Sandwich Południowy',
'SS' => 'Sudan Południowy',
'ES' => 'Hiszpania',
'LK' => 'Sri Lanka',
'SD' => 'Sudan',
'SR' => 'Surinam',
'SJ' => 'Svalbard i Jan Mayen',
'SE' => 'Szwecja',
'CH' => 'Szwajcaria',
'SY' => 'Syria',
'TW' => 'Tajwan',
'TJ' => 'Tadżykistan',
'TZ' => 'Tanzania',
'TH' => 'Tajlandia',
'TL' => 'Timor Wschodni',
'TG' => 'Togo',
'TK' => 'Tokelau',
'TO' => 'Tonga',
'TT' => 'Trynidad i Tobago',
'TN' => 'Tunezja',
'TR' => 'Turcja',
'TM' => 'Turkmenistan',
'TC' => 'Turks i Caicos',
'TV' => 'Tuvalu',
'UG' => 'Uganda',
'UA' => 'Ukraina',
'AE' => 'Zjednoczone Emiraty Arabskie',
'GB' => 'Wielka Brytania',
'UM' => 'Dalekie Wyspy Mniejsze Stanów Zjednoczonych',
'US' => 'Stany Zjednoczone',
'UY' => 'Urugwaj',
'UZ' => 'Uzbekistan',
'VU' => 'Vanuatu',
'VE' => 'Wenezuela',
'VN' => 'Wietnam',
'VG' => 'Brytyjskie Wyspy Dziewicze',
'VI' => 'Wyspy Dziewicze Stanów Zjednoczonych',
'WF' => 'Wallis i Futuna',
'EH' => 'Sahara Zachodnia',
'YE' => 'Jemen',
'ZM' => 'Zambia',
'ZW' => 'Zimbabwe',
];

View File

@@ -24,6 +24,13 @@ return [
'forward_query_params' => 'Przekaż parametry URL',
'forward_query_params_helper' => 'Dołącz parametry wejściowe (np. tagi UTM) do docelowego adresu URL.',
'expires_at' => 'Wygasa dnia',
'activated_at' => 'Aktywny od',
'max_visits' => 'Maksymalny limit wejść',
'max_visits_helper' => 'Opcjonalny limit liczby wejść, po osiągnięciu którego link zostanie automatycznie wyłączony.',
'expiration_redirect_url' => 'URL przekierowania po wygaśnięciu',
'expiration_redirect_url_helper' => 'Adres URL, na który nastąpi przekierowanie, gdy link wygaśnie lub będzie nieaktywny (pozostaw puste, aby wyświetlić błąd 410 Gone).',
'form_section_validity' => 'Ważność i limity',
'use_date_validity' => 'Ustaw limity datowe',
'notes' => 'Wewnętrzne notatki',
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.',
@@ -161,6 +168,8 @@ return [
'settings_section_ga4' => 'GA4 Measurement Protocol',
'settings_ga4_description' => 'Skonfiguruj serwerowe śledzenie zdarzeń GA4 przez Measurement Protocol. Zdarzenia są wysyłane w tle bez blokowania przekierowania.',
'settings_site_name' => 'Niestandardowa nazwa witryny',
'settings_site_name_helper' => 'Własna nazwa witryny/marki wyświetlana na ekranach przekierowań (monit o hasło, ostrzeżenia, piksele). W przypadku braku wartości, zostanie użyta nazwa z konfiguracji config("app.name").',
'settings_route_prefix' => 'Prefiks trasy',
'settings_route_prefix_helper' => 'Segment URL przed kluczem, np. "/s/abc123". Zmiana wymaga php artisan config:clear.',
'settings_key_length' => 'Długość auto-generowanego klucza',
@@ -289,6 +298,39 @@ return [
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
'settings_rate_limiting_decay_seconds_helper' => 'Przedział czasowy dla ograniczenia liczby żądań.',
// Queue Settings Additions
'settings_queue_name' => 'Nazwa kolejki',
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
'settings_geoip_stats_cache_ttl' => 'TTL cache statystyk panelu',
'settings_geoip_stats_cache_ttl_helper' => 'Liczba sekund buforowania wyników zapytań na stronie statystyk (zapobiega obciążeniu bazy danych). Domyślnie: 300 (5 minut).',
// Default Tracking Tab
'settings_tab_tracking_defaults' => 'Domyślne śledzenie',
'settings_section_tracking_defaults' => 'Domyślne ustawienia śledzenia wizyt',
'settings_section_tracking_defaults_helper' => 'Ustawienia te określają domyślny status śledzenia dla nowo tworzonych krótkich adresów URL.',
'settings_track_visits_default' => 'Śledź wizyty domyślnie',
'settings_track_ip_default' => 'Domyślne śledzenie adresu IP',
'settings_track_browser_default' => 'Domyślne śledzenie przeglądarki',
'settings_track_browser_version_default' => 'Domyślne śledzenie wersji przeglądarki',
'settings_track_os_default' => 'Domyślne śledzenie systemu operacyjnego',
'settings_track_os_version_default' => 'Domyślne śledzenie wersji systemu operacyjnego',
'settings_track_referer_default' => 'Domyślne śledzenie adresu referera',
'settings_track_device_type_default' => 'Domyślne śledzenie typu urządzenia',
// QR Defaults Tab
'settings_tab_qr' => 'Domyślne QR',
'settings_section_qr_defaults' => 'Domyślne opcje wyglądu kodów QR',
'settings_section_qr_defaults_helper' => 'Skonfiguruj standardowy styl wyglądu automatycznie ładowany dla nowo generowanych kodów QR.',
'settings_qr_size' => 'Domyślny rozmiar (px)',
'settings_qr_margin' => 'Domyślny margines',
'settings_qr_dot_style' => 'Domyślny styl kropek',
'settings_qr_foreground_color' => 'Domyślny kolor pierwszego planu',
'settings_qr_background_color' => 'Domyślny kolor tła',
'settings_qr_gradient_enabled' => 'Domyślnie włącz gradient',
'settings_qr_gradient_from' => 'Kolor gradientu od',
'settings_qr_gradient_to' => 'Kolor gradientu do',
'settings_qr_gradient_type' => 'Domyślny typ gradientu',
// Views
'password_title' => 'Wymagane hasło',
'password_description' => 'Ten link jest chroniony hasłem. Wprowadź poprawne hasło, aby kontynuować.',
@@ -299,4 +341,38 @@ return [
'warning_description' => 'Opuszczasz ten bezpieczny portal i zostajesz przekierowany na zewnętrzny link docelowy. Upewnij się, że ufasz poniższemu adresowi:',
'warning_btn_continue' => 'Kontynuuj do celu',
'warning_btn_back' => 'Wróć',
'pixel_loading_title' => 'Łączenie...',
'pixel_loading_description' => 'Przygotowujemy połączenie i za chwilę zostaniesz przekierowany.',
// Marketing & API Tab
'tab_marketing' => 'Marketing i API',
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'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',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',
'settings_section_rest_api' => 'Dostęp do REST API',
'settings_api_enabled' => 'Włącz Developer REST API',
'settings_api_enabled_helper' => 'Zezwól zewnętrznym systemom na tworzenie, listowanie i usuwanie krótkich linków przez REST API. Wyłącz, aby zablokować wszystkie endpointy /api/short-url/* odpowiedzią 503.',
'settings_section_global_webhook' => 'Konfiguracja globalnego Webhooka',
'settings_global_webhook_url' => 'Globalny Webhook URL',
'settings_global_webhook_url_helper' => 'Adres URL, pod który wysyłane będą w tle powiadomienia o zdarzeniach ze wszystkich linków.',
'settings_webhook_events' => 'Monitorowane zdarzenia Webhooka',
'settings_webhook_events_helper' => 'Wybierz zdarzenia, które powinny wyzwalać wysłanie powiadomienia POST.',
'webhook_event_visited' => 'Odwiedziny linku (Wizyta)',
'webhook_event_created' => 'Utworzenie linku',
'webhook_event_expired' => 'Wygaśnięcie linku (Data)',
'webhook_event_limit_reached' => 'Osiągnięcie limitu kliknięć',
'settings_section_api_keys' => 'Klucze API Dewelopera',
'settings_api_keys_description' => 'Zarządzaj kluczami API do integracji z zewnętrznymi systemami (np. CRM, Zapier, Make). Autoryzacja odbywa się za pomocą nagłówka X-Api-Key.',
'settings_api_keys' => 'Aktywne klucze API',
'api_key_name' => 'Nazwa klucza (Opis)',
'api_key' => 'Klucz API',
'active' => 'Aktywny',
];

View File

@@ -37,7 +37,7 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Sign-in Box --}}

View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}</title>
<!-- Premium Google Fonts: Bricolage Grotesque -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<!-- Self-contained Tailwind CSS CDN for maximum plug-and-play reliability -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
// Detect system dark mode preferences
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
<!-- Meta / Facebook Pixel -->
@if(!empty($pixelMetaId))
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '{{ $pixelMetaId }}');
fbq('track', 'PageView');
</script>
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id={{ $pixelMetaId }}&ev=PageView&noscript=1" /></noscript>
@endif
<!-- Google Analytics / GTM -->
@if(!empty($pixelGoogleId))
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $pixelGoogleId }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ $pixelGoogleId }}');
</script>
@endif
<!-- LinkedIn Insight -->
@if(!empty($pixelLinkedinId))
<script type="text/javascript">
_linkedin_data_partner_id = "{{ $pixelLinkedinId }}";
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
window._linkedin_data_partner_ids.push(_linkedin_data_partner_id);
(function(l) {
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
window.lintrk.q=[]}
var s = document.getElementsByTagName("script")[0];
var b = document.createElement("script");
b.type = "text/javascript";b.async = true;
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
s.parentNode.insertBefore(b, s);})(window.lintrk);
</script>
<noscript><img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid={{ $pixelLinkedinId }}&fmt=gif" /></noscript>
@endif
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen flex flex-col justify-between items-center py-10 px-6 font-sans antialiased">
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main card --}}
<div class="w-full max-w-[360px] flex flex-col items-center gap-6 my-auto">
<div class="flex flex-col items-center text-center pb-2 select-none w-full">
@if ($logoUrl)
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="h-[60px] w-auto object-contain mb-4" />
@else
<span class="text-3xl font-extrabold tracking-tight text-neutral-900 dark:text-white mb-3">{{ $siteName }}</span>
@endif
{{-- Animated spinner --}}
<div class="my-5 flex items-center justify-center gap-1.5" aria-hidden="true">
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
</div>
<p class="text-xl font-medium text-neutral-900 dark:text-white mt-2">
{{ __('filament-short-url::default.pixel_loading_title') ?? 'Connecting...' }}
</p>
<p class="text-sm text-neutral-400 dark:text-neutral-500 mt-1">
{{ __('filament-short-url::default.pixel_loading_description') ?? 'Preparing your connection and forwarding you now.' }}
</p>
</div>
{{-- Progress bar --}}
<div class="w-full h-1 rounded-full bg-neutral-200 dark:bg-neutral-800 overflow-hidden">
<div id="pixel-progress" class="h-full w-0 rounded-full bg-neutral-900 dark:bg-white transition-all ease-linear" style="transition-duration: 220ms;"></div>
</div>
</div>
{{-- Footer --}}
<div class="flex flex-col items-center gap-2 mt-auto select-none">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-600">© {{ date('Y') }} {{ $siteName }} Inc. All rights reserved.</span>
</div>
{{-- Redirect after pixels fire --}}
<script>
// Animate the progress bar to full in sync with the redirect delay
requestAnimationFrame(function() {
document.getElementById('pixel-progress').style.width = '100%';
});
setTimeout(function() {
window.location.replace("{!! addslashes($destination) !!}");
}, 250);
</script>
</body>
</html>

View File

@@ -54,6 +54,12 @@
</div>
</div>
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [
'record' => $record,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
], key('stats-world-map-' . $dateFrom . '-' . $dateTo))
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [
'record' => $record,
'dateFrom' => $dateFrom,

View File

@@ -37,7 +37,7 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('app.name', 'Laravel');
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
{{-- Main Warning Box --}}

View File

@@ -0,0 +1,403 @@
@php
/**
* World-map heat-map widget.
*
* Uses Natural Earth 110m simplified country paths (public-domain).
* Countries are coloured using their ISO-3166-1 alpha-2 code which is stored
* in the short_url_visits.country_code column.
*
* $countryData array<ISO2, int> raw click counts
* $normalized array<ISO2, 0-100> intensity percentage
* $maxCount int
* $totalClicks int
*/
/**
* Very compact "mercator" paths for ~180 countries sourced from public-domain
* Natural Earth data (110m resolution) encoded into a 950×500 viewport.
*
* Only the most visited countries are listed for performance; the rest render
* in a neutral colour. Full-resolution SVG world maps can be bundled as an
* asset if needed.
*/
$countryPaths = [
'AF' => 'M 613 175 L 617 170 L 626 172 L 630 168 L 637 173 L 638 180 L 633 186 L 624 189 L 616 184 Z',
'AL' => 'M 508 142 L 511 138 L 514 142 L 512 147 L 508 145 Z',
'DZ' => 'M 470 148 L 500 148 L 500 185 L 470 185 L 455 175 L 455 158 Z',
'AO' => 'M 500 240 L 520 240 L 520 270 L 500 270 L 490 255 Z',
'AR' => 'M 265 330 L 280 320 L 285 360 L 275 400 L 260 410 L 250 390 L 255 350 Z',
'AU' => 'M 730 310 L 790 295 L 820 310 L 830 345 L 810 370 L 760 375 L 725 355 L 720 330 Z',
'AT' => 'M 500 120 L 514 118 L 516 124 L 502 126 Z',
'AZ' => 'M 568 138 L 575 134 L 580 140 L 574 145 L 568 142 Z',
'BD' => 'M 655 180 L 663 178 L 666 185 L 660 190 L 654 187 Z',
'BE' => 'M 480 112 L 487 110 L 489 116 L 482 118 Z',
'BF' => 'M 460 198 L 475 195 L 478 205 L 462 208 Z',
'BY' => 'M 530 105 L 545 103 L 548 112 L 532 115 Z',
'BJ' => 'M 475 205 L 480 202 L 482 215 L 477 218 Z',
'BO' => 'M 265 295 L 280 288 L 285 310 L 268 315 Z',
'BA' => 'M 507 130 L 514 128 L 515 136 L 508 137 Z',
'BW' => 'M 518 278 L 530 275 L 532 290 L 520 292 Z',
'BR' => 'M 255 220 L 320 210 L 340 240 L 330 300 L 290 320 L 255 310 L 235 280 L 240 250 Z',
'BN' => 'M 724 218 L 728 215 L 730 220 L 726 223 Z',
'BG' => 'M 525 130 L 536 128 L 537 136 L 526 137 Z',
'KH' => 'M 705 210 L 715 207 L 717 218 L 707 220 Z',
'CM' => 'M 490 210 L 503 207 L 505 230 L 491 232 Z',
'CA' => 'M 55 65 L 200 55 L 220 85 L 180 100 L 100 108 L 55 95 Z',
'CF' => 'M 502 220 L 520 218 L 522 235 L 504 237 Z',
'TD' => 'M 498 188 L 515 185 L 517 220 L 499 222 Z',
'CL' => 'M 250 295 L 258 290 L 260 380 L 252 385 L 248 350 Z',
'CN' => 'M 640 130 L 740 125 L 755 160 L 745 200 L 700 215 L 660 205 L 635 185 L 630 160 Z',
'CO' => 'M 225 220 L 255 215 L 260 250 L 240 258 L 220 245 Z',
'CD' => 'M 503 238 L 535 230 L 540 270 L 520 278 L 500 270 Z',
'CG' => 'M 496 238 L 505 236 L 507 255 L 497 257 Z',
'CR' => 'M 205 215 L 212 212 L 213 220 L 206 222 Z',
'HR' => 'M 505 126 L 514 124 L 514 132 L 506 133 Z',
'CU' => 'M 198 183 L 218 180 L 220 188 L 200 191 Z',
'CY' => 'M 540 152 L 548 150 L 549 155 L 541 156 Z',
'CZ' => 'M 505 115 L 518 113 L 519 120 L 506 121 Z',
'DK' => 'M 494 98 L 500 95 L 502 105 L 495 107 Z',
'DO' => 'M 225 188 L 233 186 L 234 193 L 226 194 Z',
'EC' => 'M 220 248 L 233 244 L 235 260 L 221 263 Z',
'EG' => 'M 537 158 L 558 155 L 560 175 L 538 178 Z',
'SV' => 'M 196 210 L 203 208 L 204 214 L 197 215 Z',
'GQ' => 'M 487 228 L 493 226 L 494 233 L 488 234 Z',
'ER' => 'M 553 192 L 563 188 L 565 198 L 554 200 Z',
'EE' => 'M 525 95 L 535 93 L 536 99 L 526 101 Z',
'ET' => 'M 545 205 L 570 198 L 574 220 L 548 228 Z',
'FJ' => 'M 865 285 L 873 282 L 875 290 L 867 292 Z',
'FI' => 'M 520 75 L 540 68 L 545 92 L 522 95 Z',
'FR' => 'M 467 115 L 492 112 L 494 133 L 469 136 Z',
'GA' => 'M 490 235 L 500 233 L 501 248 L 491 250 Z',
'GM' => 'M 430 200 L 442 198 L 443 204 L 431 205 Z',
'GE' => 'M 558 130 L 572 128 L 573 136 L 559 137 Z',
'DE' => 'M 491 105 L 512 103 L 514 125 L 492 127 Z',
'GH' => 'M 458 212 L 470 210 L 471 228 L 459 230 Z',
'GR' => 'M 519 138 L 533 136 L 535 150 L 520 152 Z',
'GT' => 'M 187 207 L 197 205 L 198 215 L 188 217 Z',
'GN' => 'M 432 210 L 452 207 L 454 222 L 433 225 Z',
'GW' => 'M 428 205 L 438 203 L 439 210 L 429 211 Z',
'GY' => 'M 265 230 L 275 227 L 277 242 L 267 244 Z',
'HT' => 'M 218 188 L 226 186 L 227 194 L 219 195 Z',
'HN' => 'M 197 208 L 212 205 L 213 215 L 198 217 Z',
'HU' => 'M 510 120 L 525 118 L 526 127 L 511 128 Z',
'IN' => 'M 617 160 L 660 155 L 665 195 L 655 215 L 635 220 L 615 205 L 608 185 Z',
'ID' => 'M 710 235 L 790 225 L 800 255 L 780 265 L 720 260 L 705 250 Z',
'IR' => 'M 573 148 L 615 142 L 620 172 L 608 185 L 578 180 L 568 165 Z',
'IQ' => 'M 557 148 L 580 145 L 582 170 L 558 173 Z',
'IE' => 'M 453 105 L 463 103 L 464 115 L 454 117 Z',
'IL' => 'M 545 155 L 550 153 L 551 163 L 546 165 Z',
'IT' => 'M 492 128 L 512 125 L 518 148 L 508 158 L 494 150 Z',
'CI' => 'M 445 215 L 462 212 L 463 228 L 446 231 Z',
'JP' => 'M 770 135 L 795 128 L 800 155 L 775 162 L 765 150 Z',
'JO' => 'M 548 155 L 557 153 L 558 162 L 549 163 Z',
'KZ' => 'M 580 108 L 645 103 L 650 140 L 640 145 L 582 142 Z',
'KE' => 'M 543 232 L 563 227 L 566 252 L 544 255 Z',
'KP' => 'M 751 140 L 763 137 L 765 150 L 752 152 Z',
'KR' => 'M 758 148 L 770 145 L 772 158 L 759 160 Z',
'XK' => 'M 513 133 L 519 131 L 520 137 L 514 138 Z',
'KW' => 'M 570 160 L 576 158 L 577 164 L 571 165 Z',
'KG' => 'M 630 130 L 648 127 L 650 136 L 631 138 Z',
'LA' => 'M 700 190 L 713 185 L 716 207 L 702 210 Z',
'LV' => 'M 522 100 L 535 98 L 536 105 L 523 106 Z',
'LB' => 'M 548 150 L 553 148 L 554 155 L 549 156 Z',
'LS' => 'M 522 295 L 528 293 L 529 300 L 523 301 Z',
'LR' => 'M 435 218 L 447 215 L 448 226 L 436 228 Z',
'LY' => 'M 498 155 L 535 150 L 537 178 L 500 182 Z',
'LT' => 'M 520 105 L 535 103 L 536 112 L 521 113 Z',
'MK' => 'M 516 135 L 524 133 L 525 140 L 517 141 Z',
'MG' => 'M 565 278 L 578 272 L 582 298 L 568 302 Z',
'MW' => 'M 535 260 L 541 257 L 543 273 L 536 275 Z',
'MY' => 'M 700 218 L 735 212 L 738 228 L 702 232 Z',
'ML' => 'M 440 185 L 475 180 L 478 210 L 442 215 Z',
'MR' => 'M 428 175 L 455 172 L 457 200 L 430 203 Z',
'MX' => 'M 115 155 L 200 148 L 210 190 L 195 205 L 150 200 L 115 180 Z',
'MD' => 'M 530 118 L 540 116 L 541 124 L 531 125 Z',
'MN' => 'M 652 115 L 730 108 L 733 140 L 655 145 Z',
'ME' => 'M 511 132 L 517 130 L 518 136 L 512 137 Z',
'MA' => 'M 440 148 L 460 145 L 462 168 L 441 171 Z',
'MZ' => 'M 533 265 L 552 260 L 555 295 L 535 298 Z',
'MM' => 'M 675 178 L 695 172 L 698 208 L 677 212 Z',
'NA' => 'M 495 275 L 520 270 L 522 295 L 497 298 Z',
'NP' => 'M 635 163 L 658 160 L 659 170 L 636 172 Z',
'NL' => 'M 483 107 L 494 105 L 495 114 L 484 115 Z',
'NZ' => 'M 845 365 L 855 358 L 858 378 L 848 382 Z',
'NI' => 'M 200 212 L 215 209 L 216 220 L 201 222 Z',
'NE' => 'M 462 183 L 500 178 L 502 208 L 464 212 Z',
'NG' => 'M 468 208 L 503 203 L 505 235 L 470 238 Z',
'NO' => 'M 490 72 L 520 62 L 535 85 L 510 92 L 490 88 Z',
'OM' => 'M 592 170 L 616 163 L 618 188 L 595 192 Z',
'PK' => 'M 606 150 L 640 145 L 643 175 L 620 182 L 607 172 Z',
'PS' => 'M 545 155 L 549 153 L 550 160 L 546 161 Z',
'PA' => 'M 213 222 L 228 218 L 229 228 L 214 230 Z',
'PG' => 'M 780 255 L 815 248 L 818 268 L 782 272 Z',
'PY' => 'M 265 310 L 285 305 L 288 328 L 267 331 Z',
'PE' => 'M 225 262 L 265 255 L 268 300 L 228 308 Z',
'PH' => 'M 745 195 L 773 188 L 776 220 L 748 224 Z',
'PL' => 'M 508 105 L 530 102 L 532 120 L 510 122 Z',
'PT' => 'M 450 125 L 462 122 L 463 140 L 451 143 Z',
'PR' => 'M 234 188 L 240 186 L 241 192 L 235 193 Z',
'RO' => 'M 520 118 L 540 115 L 542 130 L 522 132 Z',
'RU' => 'M 540 60 L 820 50 L 830 120 L 770 135 L 680 118 L 590 100 L 548 88 Z',
'RW' => 'M 530 245 L 537 242 L 538 250 L 531 251 Z',
'SA' => 'M 553 163 L 607 155 L 610 195 L 580 210 L 553 205 Z',
'SN' => 'M 425 198 L 448 195 L 450 208 L 427 211 Z',
'RS' => 'M 512 126 L 524 124 L 525 135 L 513 136 Z',
'SL' => 'M 430 218 L 442 215 L 443 225 L 431 227 Z',
'SO' => 'M 555 210 L 578 200 L 582 228 L 560 238 L 555 225 Z',
'ZA' => 'M 500 288 L 536 280 L 540 310 L 510 318 L 495 308 Z',
'SS' => 'M 525 215 L 547 210 L 549 232 L 527 235 Z',
'ES' => 'M 452 130 L 485 125 L 487 148 L 453 152 Z',
'LK' => 'M 641 205 L 647 202 L 649 213 L 643 215 Z',
'SD' => 'M 520 183 L 552 177 L 555 215 L 522 220 Z',
'SR' => 'M 268 228 L 280 225 L 281 238 L 269 240 Z',
'SZ' => 'M 526 292 L 532 290 L 533 297 L 527 298 Z',
'SE' => 'M 503 72 L 520 68 L 525 100 L 505 103 Z',
'CH' => 'M 487 118 L 502 116 L 503 124 L 488 125 Z',
'SY' => 'M 545 143 L 563 140 L 565 155 L 546 157 Z',
'TW' => 'M 753 172 L 759 169 L 761 178 L 755 180 Z',
'TJ' => 'M 625 135 L 642 132 L 644 142 L 627 144 Z',
'TZ' => 'M 530 250 L 555 244 L 557 272 L 532 275 Z',
'TH' => 'M 692 193 L 712 188 L 714 218 L 694 222 Z',
'TL' => 'M 762 258 L 775 255 L 776 263 L 763 265 Z',
'TG' => 'M 470 210 L 476 208 L 477 225 L 471 226 Z',
'TN' => 'M 490 143 L 502 141 L 503 158 L 491 160 Z',
'TR' => 'M 530 132 L 575 127 L 577 148 L 532 152 Z',
'TM' => 'M 590 130 L 623 126 L 625 145 L 592 148 Z',
'UG' => 'M 530 232 L 548 228 L 550 248 L 532 251 Z',
'UA' => 'M 522 108 L 560 104 L 562 128 L 524 132 Z',
'AE' => 'M 590 170 L 605 167 L 606 178 L 591 180 Z',
'GB' => 'M 460 100 L 480 95 L 482 118 L 462 122 Z',
'US' => 'M 55 105 L 215 98 L 225 148 L 215 178 L 150 185 L 60 168 Z',
'UY' => 'M 275 330 L 292 325 L 294 342 L 277 345 Z',
'UZ' => 'M 600 118 L 635 113 L 637 135 L 602 138 Z',
'VE' => 'M 235 220 L 270 212 L 273 240 L 237 245 Z',
'VN' => 'M 708 185 L 726 178 L 730 215 L 710 218 Z',
'YE' => 'M 563 188 L 608 180 L 610 202 L 565 207 Z',
'ZM' => 'M 515 258 L 543 252 L 545 278 L 517 281 Z',
'ZW' => 'M 518 275 L 540 270 L 542 292 L 520 294 Z',
];
// Compute top 10 for the ranked sidebar
$topCountries = collect($countryData)->sortDesc()->take(10);
// Country name lookup (ISO-2 → English name)
$countryNames = [
'AF'=>'Afghanistan','AL'=>'Albania','DZ'=>'Algeria','AO'=>'Angola','AR'=>'Argentina','AU'=>'Australia',
'AT'=>'Austria','AZ'=>'Azerbaijan','BD'=>'Bangladesh','BE'=>'Belgium','BF'=>'Burkina Faso','BY'=>'Belarus',
'BJ'=>'Benin','BO'=>'Bolivia','BA'=>'Bosnia & Herz.','BW'=>'Botswana','BR'=>'Brazil','BN'=>'Brunei',
'BG'=>'Bulgaria','KH'=>'Cambodia','CM'=>'Cameroon','CA'=>'Canada','CF'=>'C. African Rep.','TD'=>'Chad',
'CL'=>'Chile','CN'=>'China','CO'=>'Colombia','CD'=>'DR Congo','CG'=>'Congo','CR'=>'Costa Rica',
'HR'=>'Croatia','CU'=>'Cuba','CY'=>'Cyprus','CZ'=>'Czech Rep.','DK'=>'Denmark','DO'=>'Dominican Rep.',
'EC'=>'Ecuador','EG'=>'Egypt','SV'=>'El Salvador','GQ'=>'Eq. Guinea','ER'=>'Eritrea','EE'=>'Estonia',
'ET'=>'Ethiopia','FI'=>'Finland','FR'=>'France','GA'=>'Gabon','GM'=>'Gambia','GE'=>'Georgia',
'DE'=>'Germany','GH'=>'Ghana','GR'=>'Greece','GT'=>'Guatemala','GN'=>'Guinea','GW'=>'Guinea-Bissau',
'GY'=>'Guyana','HT'=>'Haiti','HN'=>'Honduras','HU'=>'Hungary','IN'=>'India','ID'=>'Indonesia',
'IR'=>'Iran','IQ'=>'Iraq','IE'=>'Ireland','IL'=>'Israel','IT'=>'Italy','CI'=>'Ivory Coast',
'JP'=>'Japan','JO'=>'Jordan','KZ'=>'Kazakhstan','KE'=>'Kenya','KP'=>'North Korea','KR'=>'South Korea',
'KW'=>'Kuwait','KG'=>'Kyrgyzstan','LA'=>'Laos','LV'=>'Latvia','LB'=>'Lebanon','LS'=>'Lesotho',
'LR'=>'Liberia','LY'=>'Libya','LT'=>'Lithuania','MK'=>'N. Macedonia','MG'=>'Madagascar','MW'=>'Malawi',
'MY'=>'Malaysia','ML'=>'Mali','MR'=>'Mauritania','MX'=>'Mexico','MD'=>'Moldova','MN'=>'Mongolia',
'ME'=>'Montenegro','MA'=>'Morocco','MZ'=>'Mozambique','MM'=>'Myanmar','NA'=>'Namibia','NP'=>'Nepal',
'NL'=>'Netherlands','NZ'=>'New Zealand','NI'=>'Nicaragua','NE'=>'Niger','NG'=>'Nigeria','NO'=>'Norway',
'OM'=>'Oman','PK'=>'Pakistan','PS'=>'Palestine','PA'=>'Panama','PG'=>'Papua N. Guinea','PY'=>'Paraguay',
'PE'=>'Peru','PH'=>'Philippines','PL'=>'Poland','PT'=>'Portugal','RO'=>'Romania','RU'=>'Russia',
'RW'=>'Rwanda','SA'=>'Saudi Arabia','SN'=>'Senegal','RS'=>'Serbia','SL'=>'Sierra Leone','SO'=>'Somalia',
'ZA'=>'South Africa','SS'=>'South Sudan','ES'=>'Spain','LK'=>'Sri Lanka','SD'=>'Sudan','SR'=>'Suriname',
'SZ'=>'Eswatini','SE'=>'Sweden','CH'=>'Switzerland','SY'=>'Syria','TW'=>'Taiwan','TJ'=>'Tajikistan',
'TZ'=>'Tanzania','TH'=>'Thailand','TG'=>'Togo','TN'=>'Tunisia','TR'=>'Turkey','TM'=>'Turkmenistan',
'UG'=>'Uganda','UA'=>'Ukraine','AE'=>'UAE','GB'=>'United Kingdom','US'=>'United States','UY'=>'Uruguay',
'UZ'=>'Uzbekistan','VE'=>'Venezuela','VN'=>'Vietnam','YE'=>'Yemen','ZM'=>'Zambia','ZW'=>'Zimbabwe',
];
// Unique Alpine.js component ID to allow multiple instances
$mapId = 'world-map-' . Str::random(8);
@endphp
<x-filament-widgets::widget>
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
{{-- Header --}}
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500 dark:bg-indigo-950/50 dark:text-indigo-400">
<x-filament::icon icon="heroicon-o-globe-alt" class="h-5 w-5" />
</div>
<div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">
{{ __('filament-short-url::default.world_map_title') }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ number_format($totalClicks) }} {{ __('filament-short-url::default.world_map_total_clicks') }}
· {{ count($countryData) }} {{ __('filament-short-url::default.world_map_countries') }}
</p>
</div>
</div>
{{-- Legend --}}
<div class="hidden items-center gap-2 sm:flex">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_fewer') }}</span>
<div class="flex gap-0.5">
@foreach([10, 25, 45, 65, 85, 100] as $intensity)
<div class="h-4 w-4 rounded-sm" style="background: hsl(243 100% {{ max(30, 90 - $intensity * 0.55) }}% / {{ max(0.12, $intensity / 100) }});"></div>
@endforeach
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_more') }}</span>
</div>
</div>
@if(empty($countryData))
{{-- Empty state --}}
<div class="flex flex-col items-center justify-center py-20 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
<x-filament::icon icon="heroicon-o-globe-alt" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('filament-short-url::default.world_map_no_data') }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ __('filament-short-url::default.world_map_no_data_sub') }}</p>
</div>
@else
{{-- Map + Sidebar layout --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
{{-- SVG Map --}}
<div class="relative lg:col-span-3"
x-data="{
tooltip: { show: false, country: '', count: 0, x: 0, y: 0 },
showTooltip(country, count, event) {
this.tooltip = { show: true, country, count, x: event.offsetX, y: event.offsetY };
},
hideTooltip() { this.tooltip.show = false; }
}"
>
{{-- Tooltip --}}
<div x-show="tooltip.show"
x-cloak
:style="`left: ${tooltip.x + 12}px; top: ${tooltip.y - 8}px`"
class="pointer-events-none absolute z-20 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
style="min-width: 130px;"
>
<p class="text-xs font-semibold text-gray-800 dark:text-gray-200" x-text="tooltip.country"></p>
<p class="mt-0.5 text-xs text-indigo-600 dark:text-indigo-400">
<span x-text="tooltip.count.toLocaleString()"></span> clicks
</p>
</div>
<div class="overflow-hidden rounded-xl bg-gray-50 dark:bg-gray-800/50">
<svg viewBox="0 0 950 500" xmlns="http://www.w3.org/2000/svg"
class="h-full w-full"
style="min-height: 280px; max-height: 420px;"
>
{{-- Ocean background --}}
<rect width="950" height="500" fill="currentColor" class="text-gray-100 dark:text-gray-800" rx="8"/>
{{-- Grid lines (latitude/longitude) --}}
<g class="opacity-30" stroke="currentColor" stroke-width="0.5" class="text-gray-300 dark:text-gray-600">
@foreach([83, 167, 250, 333, 417] as $x)
<line x1="{{ $x }}" y1="0" x2="{{ $x }}" y2="500" stroke="#94a3b8" stroke-width="0.4" opacity="0.4"/>
@endforeach
@foreach([100, 200, 300, 400] as $y)
<line x1="0" y1="{{ $y }}" x2="950" y2="{{ $y }}" stroke="#94a3b8" stroke-width="0.4" opacity="0.4"/>
@endforeach
</g>
{{-- Countries --}}
@foreach($countryPaths as $code => $path)
@php
$count = $countryData[$code] ?? 0;
$intensity = $normalized[$code] ?? 0;
$name = $countryNames[$code] ?? $code;
if ($count > 0) {
// Active country: indigo hue, darkness based on intensity
$lightness = max(30, 88 - ($intensity * 0.55));
$alpha = max(0.15, $intensity / 100);
$fill = "hsl(243 100% {$lightness}% / {$alpha})";
$stroke = "hsl(243 80% 40% / 0.4)";
$strokeWidth = "0.8";
} else {
// Inactive country: neutral gray
$fill = "hsl(220 13% 91%)";
$stroke = "hsl(220 13% 80%)";
$strokeWidth = "0.4";
}
@endphp
<path
d="{{ $path }}"
fill="{{ $fill }}"
stroke="{{ $stroke }}"
stroke-width="{{ $strokeWidth }}"
class="{{ $count > 0 ? 'cursor-pointer transition-all duration-200 hover:brightness-90 hover:stroke-indigo-500' : '' }}"
@if($count > 0)
@mouseenter="showTooltip('{{ $name }}', {{ $count }}, $event)"
@mouseleave="hideTooltip()"
@endif
style="transition: fill 0.2s ease;"
/>
@endforeach
{{-- Pulse dots on top countries --}}
@php
$dotPositions = [
'US' => [135, 140], 'GB' => [471, 108], 'DE' => [502, 115], 'FR' => [480, 124],
'IN' => [638, 188], 'CN' => [692, 167], 'BR' => [287, 265], 'RU' => [685, 90],
'AU' => [775, 340], 'CA' => [127, 80], 'JP' => [782, 145], 'MX' => [162, 165],
'ES' => [468, 138], 'IT' => [504, 137], 'TR' => [553, 140], 'PL' => [520, 112],
'NL' => [488, 110], 'SA' => [580, 182], 'ZA' => [517, 299], 'AR' => [268, 360],
];
@endphp
@foreach($topCountries->take(5)->keys() as $rank => $code)
@if(isset($dotPositions[$code]))
@php [$dx, $dy] = $dotPositions[$code]; @endphp
<circle cx="{{ $dx }}" cy="{{ $dy }}" r="5" fill="hsl(243 100% 55%)" opacity="0.9">
<animate attributeName="r" values="5;9;5" dur="{{ 1.5 + $rank * 0.3 }}s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0.2;0.9" dur="{{ 1.5 + $rank * 0.3 }}s" repeatCount="indefinite"/>
</circle>
<circle cx="{{ $dx }}" cy="{{ $dy }}" r="3" fill="hsl(243 100% 65%)" opacity="1"/>
@endif
@endforeach
</svg>
</div>
</div>
{{-- Ranked Country Sidebar --}}
<div class="flex flex-col gap-1">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ __('filament-short-url::default.world_map_top_countries') }}
</p>
@forelse($topCountries as $code => $count)
@php
$pct = $totalClicks > 0 ? round($count / $totalClicks * 100, 1) : 0;
$name = $countryNames[$code] ?? $code;
$barWidth = $maxCount > 0 ? round($count / $maxCount * 100) : 0;
@endphp
<div class="group rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800">
<div class="flex items-center gap-2">
<span class="w-5 text-center text-xs font-bold text-gray-400 dark:text-gray-500">
{{ $loop->iteration }}
</span>
<span class="flex-1 truncate text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $name }}
</span>
<span class="shrink-0 font-mono text-xs font-semibold text-gray-900 dark:text-white">
{{ number_format($count) }}
</span>
<span class="w-9 shrink-0 text-right text-xs text-gray-400 dark:text-gray-500">
{{ $pct }}%
</span>
</div>
<div class="mt-1 ml-7 h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div class="h-full rounded-full bg-indigo-500 transition-all duration-700"
style="width: {{ $barWidth }}%">
</div>
</div>
</div>
@empty
<p class="py-4 text-center text-sm text-gray-400 dark:text-gray-500">
{{ __('filament-short-url::default.world_map_no_data') }}
</p>
@endforelse
</div>
</div>
@endif
</div>
</x-filament-widgets::widget>

View File

@@ -1,6 +1,8 @@
<?php
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Support\Facades\Route;
Route::match(
@@ -11,3 +13,11 @@ Route::match(
->name('short-url.redirect')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
Route::prefix('api/short-url')
->middleware([AuthenticateShortUrlApi::class])
->group(function () {
Route::get('links', [ShortUrlApiController::class, 'index']);
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::delete('links/{id}', [ShortUrlApiController::class, 'destroy']);
});

View File

@@ -42,8 +42,10 @@ class AggregateAndPruneVisitsCommand extends Command
// Accumulate stats per short_url_id using chunked reads — avoids loading
// potentially millions of rows into PHP memory at once.
$statsByUrl = [];
$nextDate = Carbon::parse($date)->addDay()->toDateString();
ShortUrlVisit::whereBetween('visited_at', [$date.' 00:00:00', $date.' 23:59:59'])
ShortUrlVisit::where('visited_at', '>=', $date.' 00:00:00')
->where('visited_at', '<', $nextDate.' 00:00:00')
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
foreach ($chunk as $visit) {
$urlId = $visit->short_url_id;

View File

@@ -3,8 +3,11 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
@@ -44,7 +47,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
'geo_ip_cache_ttl' => $mgr->get('geo_ip_cache_ttl', 86400),
'geo_ip_timeout' => $mgr->get('geo_ip_timeout', 3),
'maxmind_database_path' => $mgr->get('maxmind_database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
'queue_connection' => $mgr->get('queue_connection', 'sync'),
'queue_name' => $mgr->get('queue_name', 'default'),
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
@@ -54,6 +59,35 @@ class ShortUrlSettingsPage extends Page implements HasForms
'rate_limiting_enabled' => $mgr->get('rate_limiting_enabled', false),
'rate_limiting_max_attempts' => $mgr->get('rate_limiting_max_attempts', 60),
'rate_limiting_decay_seconds' => $mgr->get('rate_limiting_decay_seconds', 60),
'tracking_enabled' => $mgr->get('tracking_enabled', true),
'tracking_fields_ip_address' => $mgr->get('tracking_fields_ip_address', true),
'tracking_fields_browser' => $mgr->get('tracking_fields_browser', true),
'tracking_fields_browser_version' => $mgr->get('tracking_fields_browser_version', true),
'tracking_fields_operating_system' => $mgr->get('tracking_fields_operating_system', true),
'tracking_fields_operating_system_version' => $mgr->get('tracking_fields_operating_system_version', true),
'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true),
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
'qr_size' => $mgr->get('qr_size', 300),
'qr_margin' => $mgr->get('qr_margin', 1),
'qr_dot_style' => $mgr->get('qr_dot_style', 'square'),
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'),
'qr_gradient_enabled' => $mgr->get('qr_gradient_enabled', false),
'qr_gradient_from' => $mgr->get('qr_gradient_from', '#4f46e5'),
'qr_gradient_to' => $mgr->get('qr_gradient_to', '#06b6d4'),
'qr_gradient_type' => $mgr->get('qr_gradient_type', 'linear'),
'global_webhook_url' => $mgr->get('global_webhook_url'),
'webhook_events' => $mgr->get('webhook_events', ['visited']),
'api_keys' => $mgr->get('api_keys', []),
'api_enabled' => $mgr->get('api_enabled', false),
'site_name' => $mgr->get('site_name'),
// Security v2.0
'vpn_detection_enabled' => $mgr->get('vpn_detection_enabled', false),
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
'vpnapi_key' => $mgr->get('vpnapi_key'),
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
]);
}
@@ -72,6 +106,13 @@ class ShortUrlSettingsPage extends Page implements HasForms
Section::make(__('filament-short-url::default.settings_section_routing'))
->columns(2)
->schema([
TextInput::make('site_name')
->label(__('filament-short-url::default.settings_site_name'))
->helperText(__('filament-short-url::default.settings_site_name_helper'))
->nullable()
->maxLength(100)
->columnSpanFull(),
TextInput::make('route_prefix')
->label(__('filament-short-url::default.settings_route_prefix'))
->helperText(__('filament-short-url::default.settings_route_prefix_helper'))
@@ -91,6 +132,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_key_length'))
->helperText(__('filament-short-url::default.settings_key_length_helper'))
->numeric()
->integer()
->minValue(4)
->maxValue(20)
->required(),
@@ -99,6 +141,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_cache_ttl'))
->helperText(__('filament-short-url::default.settings_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->suffix('s')
->required(),
@@ -111,6 +154,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
]),
Section::make(__('filament-short-url::default.settings_section_queue'))
->columns(2)
->schema([
Select::make('queue_connection')
->label(__('filament-short-url::default.settings_queue_connection'))
@@ -125,6 +169,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
];
})
->required(),
TextInput::make('queue_name')
->label(__('filament-short-url::default.settings_queue_name'))
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
->required(),
]),
Section::make(__('filament-short-url::default.settings_section_buffering'))
@@ -168,6 +217,18 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_geoip_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
// ── Stats Cache TTL (only when geo-ip is on) ──
TextInput::make('geo_ip_stats_cache_ttl')
->label(__('filament-short-url::default.settings_geoip_stats_cache_ttl'))
->helperText(__('filament-short-url::default.settings_geoip_stats_cache_ttl_helper'))
->numeric()
->integer()
->minValue(0)
->suffix('s')
->required()
@@ -178,6 +239,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_geoip_timeout'))
->helperText(__('filament-short-url::default.settings_geoip_timeout_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(30)
->suffix('s')
@@ -354,6 +416,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
->numeric()
->integer()
->minValue(1)
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
@@ -362,11 +425,292 @@ class ShortUrlSettingsPage extends Page implements HasForms
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
->numeric()
->integer()
->minValue(1)
->suffix('s')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
]),
Section::make(__('filament-short-url::default.settings_section_security_v2'))
->description(__('filament-short-url::default.settings_section_security_v2_desc'))
->columns(2)
->schema([
Toggle::make('vpn_detection_enabled')
->label(__('filament-short-url::default.settings_vpn_detection_enabled'))
->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
Select::make('vpn_detection_driver')
->label(__('filament-short-url::default.settings_vpn_driver'))
->helperText(__('filament-short-url::default.settings_vpn_driver_helper'))
->options([
'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'),
'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'),
])
->default('ip-api')
->live()
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
TextInput::make('vpnapi_key')
->label(__('filament-short-url::default.settings_vpnapi_key'))
->helperText(__('filament-short-url::default.settings_vpnapi_key_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'),
Select::make('vpn_block_action')
->label(__('filament-short-url::default.settings_vpn_block_action'))
->helperText(__('filament-short-url::default.settings_vpn_block_action_helper'))
->options([
'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'),
'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'),
])
->default('flag_only')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Toggle::make('safe_browsing_enabled')
->label(__('filament-short-url::default.settings_safe_browsing_enabled'))
->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
TextInput::make('google_safe_browsing_api_key')
->label(__('filament-short-url::default.settings_safe_browsing_api_key'))
->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper'))
->password()
->revealable()
->placeholder('AIza••••••••••••••••••')
->columnSpanFull()
->suffixAction(
Action::make('testSafeBrowsing')
->label(__('filament-short-url::default.settings_safe_browsing_test'))
->icon('heroicon-o-signal')
->color('gray')
->action(function (Get $get): void {
$key = trim($get('google_safe_browsing_api_key') ?? '');
if (empty($key)) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_empty'))
->warning()->send();
return;
}
try {
$svc = app(SafeBrowsingService::class);
$safe = $svc->isSafeWithKey('https://google.com', $key);
Notification::make()
->title($safe
? __('filament-short-url::default.settings_safe_browsing_test_ok')
: __('filament-short-url::default.settings_safe_browsing_test_fail'))
->color($safe ? 'success' : 'danger')
->send();
} catch (\Throwable $e) {
Notification::make()
->title(__('filament-short-url::default.settings_safe_browsing_test_error'))
->body($e->getMessage())
->danger()->send();
}
})
)
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
]),
]),
// ── Tracking Defaults ─────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_tracking_defaults'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.settings_section_tracking_defaults'))
->description(__('filament-short-url::default.settings_section_tracking_defaults_helper'))
->schema([
Toggle::make('tracking_enabled')
->label(__('filament-short-url::default.settings_track_visits_default'))
->live()
->inline(false)
->columnSpanFull(),
Toggle::make('tracking_fields_ip_address')
->label(__('filament-short-url::default.settings_track_ip_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser')
->label(__('filament-short-url::default.settings_track_browser_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_browser_version')
->label(__('filament-short-url::default.settings_track_browser_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system')
->label(__('filament-short-url::default.settings_track_os_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_operating_system_version')
->label(__('filament-short-url::default.settings_track_os_version_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_referer_url')
->label(__('filament-short-url::default.settings_track_referer_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
Toggle::make('tracking_fields_device_type')
->label(__('filament-short-url::default.settings_track_device_type_default'))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('tracking_enabled')),
])
->columns(4),
]),
// ── QR Defaults ──────────────────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_qr'))
->icon('heroicon-o-qr-code')
->schema([
Section::make(__('filament-short-url::default.settings_section_qr_defaults'))
->description(__('filament-short-url::default.settings_section_qr_defaults_helper'))
->columns(2)
->schema([
TextInput::make('qr_size')
->label(__('filament-short-url::default.settings_qr_size'))
->numeric()
->integer()
->minValue(100)
->maxValue(2000)
->required(),
Select::make('qr_margin')
->label(__('filament-short-url::default.settings_qr_margin'))
->options(array_combine(range(0, 10), range(0, 10)))
->required(),
Select::make('qr_dot_style')
->label(__('filament-short-url::default.settings_qr_dot_style'))
->options([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->required(),
ColorPicker::make('qr_foreground_color')
->label(__('filament-short-url::default.settings_qr_foreground_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#000000')
->required(),
ColorPicker::make('qr_background_color')
->label(__('filament-short-url::default.settings_qr_background_color'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#ffffff')
->required(),
Toggle::make('qr_gradient_enabled')
->label(__('filament-short-url::default.settings_qr_gradient_enabled'))
->live()
->inline(false),
ColorPicker::make('qr_gradient_from')
->label(__('filament-short-url::default.settings_qr_gradient_from'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#4f46e5')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
ColorPicker::make('qr_gradient_to')
->label(__('filament-short-url::default.settings_qr_gradient_to'))
->regex('/^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\b$/')
->placeholder('#06b6d4')
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
Select::make('qr_gradient_type')
->label(__('filament-short-url::default.settings_qr_gradient_type'))
->options([
'linear' => __('filament-short-url::default.qr_gradient_linear'),
'radial' => __('filament-short-url::default.qr_gradient_radial'),
])
->required(fn (Get $get): bool => (bool) $get('qr_gradient_enabled'))
->visible(fn (Get $get): bool => (bool) $get('qr_gradient_enabled')),
]),
]),
// ── Developer API & Webhooks ────────────────────────
Tab::make(__('filament-short-url::default.settings_tab_developer'))
->icon('heroicon-o-cpu-chip')
->schema([
Section::make(__('filament-short-url::default.settings_section_rest_api'))
->schema([
Toggle::make('api_enabled')
->label(__('filament-short-url::default.settings_api_enabled'))
->helperText(__('filament-short-url::default.settings_api_enabled_helper'))
->default(false)
->inline(false)
->columnSpanFull()
->live(),
]),
Section::make(__('filament-short-url::default.settings_section_api_keys'))
->description(__('filament-short-url::default.settings_api_keys_description'))
->visible(fn (Get $get): bool => (bool) $get('api_enabled'))
->schema([
Repeater::make('api_keys')
->label(__('filament-short-url::default.settings_api_keys'))
->schema([
TextInput::make('name')
->label(__('filament-short-url::default.api_key_name'))
->required(),
TextInput::make('key')
->label(__('filament-short-url::default.api_key'))
->disabled()
->dehydrated()
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
])
->columns(3)
->default([]),
]),
Section::make(__('filament-short-url::default.settings_section_global_webhook'))
->schema([
TextInput::make('global_webhook_url')
->label(__('filament-short-url::default.settings_global_webhook_url'))
->helperText(__('filament-short-url::default.settings_global_webhook_url_helper'))
->url()
->nullable()
->columnSpanFull(),
Select::make('webhook_events')
->label(__('filament-short-url::default.settings_webhook_events'))
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
->multiple()
->options([
'visited' => __('filament-short-url::default.webhook_event_visited'),
'created' => __('filament-short-url::default.webhook_event_created'),
'expired' => __('filament-short-url::default.webhook_event_expired'),
'limit_reached' => __('filament-short-url::default.webhook_event_limit_reached'),
])
->default(['visited'])
->columnSpanFull(),
]),
]),
]),
])

View File

@@ -27,6 +27,7 @@ class ShortUrlForm
static::linkTab(),
static::targetingTab(),
static::trackingTab(),
static::marketingTab(),
static::qrDesignTab(),
])->columnSpanFull(),
]);
@@ -44,6 +45,14 @@ class ShortUrlForm
->required()
->url()
->maxLength(2048)
->rules([
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
$safeBrowsing = app(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class);
if (! $safeBrowsing->isSafe($value)) {
$fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.');
}
},
])
->live(onBlur: true)
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
if (! $state) {
@@ -103,7 +112,7 @@ class ShortUrlForm
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->default(302)
->default(fn () => config('filament-short-url.redirect_status_code', 302))
->required(),
])->columns(2),
@@ -113,24 +122,79 @@ class ShortUrlForm
->default(true)
->inline(false),
Toggle::make('single_use')
->label(__('filament-short-url::default.single_use'))
->helperText(__('filament-short-url::default.single_use_helper'))
->default(false)
->inline(false),
Toggle::make('forward_query_params')
->label(__('filament-short-url::default.forward_query_params'))
->helperText(__('filament-short-url::default.forward_query_params_helper'))
->default(false)
->inline(false),
DateTimePicker::make('expires_at')
->label(__('filament-short-url::default.expires_at'))
->nullable()
->native(false),
])->columns(2),
Section::make(__('filament-short-url::default.form_section_validity'))
->schema([
Toggle::make('use_date_validity')
->label(__('filament-short-url::default.use_date_validity'))
->dehydrated(false)
->live()
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
})
->afterStateUpdated(function ($state, Set $set) {
if ($state) {
$set('activated_at', now()->startOfMinute());
} else {
$set('activated_at', null);
$set('expires_at', null);
$set('expiration_redirect_url', null);
}
})
->columnSpanFull(),
DateTimePicker::make('activated_at')
->label(__('filament-short-url::default.activated_at'))
->nullable()
->native(false)
->withoutSeconds()
->live(onBlur: true)
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
->minDate(now()->startOfDay())
->maxDate(fn (Get $get) => $get('expires_at')),
DateTimePicker::make('expires_at')
->label(__('filament-short-url::default.expires_at'))
->nullable()
->native(false)
->withoutSeconds()
->live(onBlur: true)
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
TextInput::make('expiration_redirect_url')
->label(__('filament-short-url::default.expiration_redirect_url'))
->helperText(__('filament-short-url::default.expiration_redirect_url_helper'))
->url()
->maxLength(2048)
->nullable()
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
->columnSpanFull(),
Toggle::make('single_use')
->label(__('filament-short-url::default.single_use'))
->helperText(__('filament-short-url::default.single_use_helper'))
->default(false)
->inline(false)
->live(),
TextInput::make('max_visits')
->label(__('filament-short-url::default.max_visits'))
->helperText(__('filament-short-url::default.max_visits_helper'))
->numeric()
->integer()
->minValue(1)
->nullable()
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
])->columns(2),
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
@@ -183,7 +247,7 @@ class ShortUrlForm
->schema([
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
->default(true)
->default(fn () => config('filament-short-url.tracking.enabled', true))
->live()
->inline(false)
->columnSpanFull(),
@@ -193,43 +257,43 @@ class ShortUrlForm
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_browser')
->label(__('filament-short-url::default.track_browser'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.browser', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_browser_version')
->label(__('filament-short-url::default.track_browser_version'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.browser_version', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_operating_system')
->label(__('filament-short-url::default.track_os'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.operating_system', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_operating_system_version')
->label(__('filament-short-url::default.track_os_version'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_device_type')
->label(__('filament-short-url::default.track_device_type'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.device_type', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_referer_url')
->label(__('filament-short-url::default.track_referer'))
->default(true)
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
])
@@ -378,39 +442,19 @@ class ShortUrlForm
->schema([
Select::make('country_code')
->label(__('filament-short-url::default.country_code'))
->options([
'PL' => 'Poland',
'US' => 'United States',
'GB' => 'United Kingdom',
'DE' => 'Germany',
'FR' => 'France',
'ES' => 'Spain',
'IT' => 'Italy',
'CA' => 'Canada',
'AU' => 'Australia',
'NL' => 'Netherlands',
'UA' => 'Ukraine',
'IE' => 'Ireland',
'BE' => 'Belgium',
'AT' => 'Austria',
'CH' => 'Switzerland',
'SE' => 'Sweden',
'NO' => 'Norway',
'DK' => 'Denmark',
'FI' => 'Finland',
'CZ' => 'Czech Republic',
'SK' => 'Slovakia',
'HU' => 'Hungary',
'RO' => 'Romania',
'GR' => 'Greece',
'PT' => 'Portugal',
'BR' => 'Brazil',
'MX' => 'Mexico',
'CN' => 'China',
'JP' => 'Japan',
'IN' => 'India',
])
->options(function (): array {
$countries = __('filament-short-url::countries');
if (is_array($countries)) {
asort($countries, SORT_LOCALE_STRING);
return $countries;
}
return [];
})
->searchable()
->optionsLimit(300)
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->required(),
TextInput::make('url')
->label(__('filament-short-url::default.destination_url'))
@@ -433,6 +477,9 @@ class ShortUrlForm
->label(__('filament-short-url::default.rotation_weight'))
->helperText(__('filament-short-url::default.rotation_weight_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(1000)
->required()
->default(50),
])
@@ -441,4 +488,43 @@ class ShortUrlForm
]),
]);
}
private static function marketingTab(): Tab
{
return Tab::make(__('filament-short-url::default.tab_marketing'))
->icon('heroicon-o-megaphone')
->schema([
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
->schema([
TextInput::make('pixel_meta_id')
->label(__('filament-short-url::default.pixel_meta'))
->placeholder('e.g., 1234567890')
->maxLength(100)
->nullable(),
TextInput::make('pixel_google_id')
->label(__('filament-short-url::default.pixel_google'))
->placeholder('e.g., G-XXXXXXXXXX or AW-XXXXXXXXXX')
->maxLength(100)
->nullable(),
TextInput::make('pixel_linkedin_id')
->label(__('filament-short-url::default.pixel_linkedin'))
->placeholder('e.g., 1234567')
->maxLength(100)
->nullable(),
])->columns(3),
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))
->schema([
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
]),
]);
}
}

View File

@@ -50,7 +50,17 @@ class ShortUrlVisitsChart extends ChartWidget
'fill' => true,
],
],
'labels' => array_keys($visitsByDay),
'labels' => array_map(function (string $date) {
try {
$carbon = \Illuminate\Support\Carbon::parse($date);
if (strlen($date) === 7) { // Y-m
return $carbon->format('m.Y');
}
return $carbon->format('d.m');
} catch (\Throwable $e) {
return $date;
}
}, array_keys($visitsByDay)),
];
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ShortUrlWorldMapWidget extends Widget
{
public ?ShortUrl $record = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
protected string $view = 'filament-short-url::widgets.world-map';
protected int|string|array $columnSpan = 'full';
public function mount(?ShortUrl $record = null): void
{
$this->record = $record;
}
/**
* Build a country_code → visit count map, merging daily stats and today's raw visits.
*
* @return array<string, int>
*/
public function getCountryData(): array
{
if (! $this->record) {
return [];
}
$dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null;
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
$today = Carbon::today()->toDateString();
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all');
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$counts = [];
$query = ShortUrlVisit::query()
->select('country_code', DB::raw('COUNT(*) as cnt'))
->where('short_url_id', $this->record->id)
->whereNotNull('country_code')
->where('country_code', '!=', '')
->where('is_bot', false)
->where('is_proxy', false);
if ($dateFromClean) {
$query->whereDate('visited_at', '>=', $dateFromClean);
}
if ($dateToClean) {
$query->whereDate('visited_at', '<=', $dateToClean);
}
foreach ($query->groupBy('country_code')->get() as $row) {
$code = strtoupper(trim($row->country_code));
if ($code) {
$counts[$code] = (int) $row->cnt;
}
}
arsort($counts);
return $counts;
});
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$countryData = $this->getCountryData();
$max = max(1, ...array_values($countryData) ?: [1]);
$total = array_sum($countryData);
// Normalise to 0100 for CSS opacity/intensity
$normalized = [];
foreach ($countryData as $code => $count) {
$normalized[$code] = round(($count / $max) * 100);
}
return [
'countryData' => $countryData,
'maxCount' => $max,
'totalClicks' => $total,
'normalized' => $normalized,
];
}
}

View File

@@ -31,6 +31,9 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_01_000003_add_utm_city_referer_to_short_url_visits_table',
'2026_06_01_000004_add_targeting_and_security_to_short_urls_table',
'2026_06_01_000005_create_short_url_daily_stats_table',
'2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table',
'2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table',
'2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table',
])
->hasCommands([
SyncBufferedCountersCommand::class,
@@ -49,6 +52,8 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
$this->app->singleton(GeoIpService::class);
$this->app->singleton(ShortUrlService::class);
$this->app->singleton(ShortUrlTracker::class);
$this->app->singleton(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class);
$this->app->singleton(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class);
}
public function packageBooted(): void

View File

@@ -0,0 +1,145 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
class ShortUrlApiController extends Controller
{
public function __construct(
private readonly ShortUrlService $service
) {}
/**
* Display a listing of short URLs.
*/
public function index(): JsonResponse
{
$links = ShortUrl::orderBy('id', 'desc')->paginate(30);
$transformed = $links->getCollection()->map(fn ($link) => $this->transformLink($link));
return response()->json([
'data' => $transformed,
'meta' => [
'current_page' => $links->currentPage(),
'last_page' => $links->lastPage(),
'per_page' => $links->perPage(),
'total' => $links->total(),
],
]);
}
/**
* Store a newly created short URL.
*/
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',
'pixel_meta_id' => 'nullable|string|max:100',
'pixel_google_id' => 'nullable|string|max:100',
'pixel_linkedin_id' => 'nullable|string|max:100',
'webhook_url' => 'nullable|url|max:2048',
]);
$shortUrl = $this->service->create($validated);
// Fire 'created' webhook if active
$this->dispatchCreatedWebhook($shortUrl);
return response()->json([
'message' => 'Short URL created successfully.',
'data' => $this->transformLink($shortUrl),
], 201);
}
/**
* Remove the specified short URL.
*/
public function destroy(int $id): JsonResponse
{
$shortUrl = ShortUrl::findOrFail($id);
$shortUrl->delete();
return response()->json([
'message' => 'Short URL deleted successfully.',
]);
}
/**
* Transform a ShortUrl model to API response array.
*/
private function transformLink(ShortUrl $link): array
{
return [
'id' => $link->id,
'destination_url' => $link->destination_url,
'url_key' => $link->url_key,
'short_url' => $link->getShortUrl(),
'is_enabled' => (bool) $link->is_enabled,
'redirect_status_code' => (int) $link->redirect_status_code,
'total_visits' => (int) $link->total_visits,
'unique_visits' => (int) $link->unique_visits,
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
'pixel_meta_id' => $link->pixel_meta_id,
'pixel_google_id' => $link->pixel_google_id,
'pixel_linkedin_id' => $link->pixel_linkedin_id,
'webhook_url' => $link->webhook_url,
'notes' => $link->notes,
'created_at' => $link->created_at->toIso8601String(),
];
}
/**
* Dispatch webhook if global or per-link webhook is active for 'created' event.
*/
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
{
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && ! empty($globalUrl) && in_array('created', $events)) {
$targetUrl = $globalUrl;
}
if (! empty($targetUrl)) {
try {
$connection = config('filament-short-url.queue_connection', 'sync');
dispatch(new SendWebhookJob(
url: $targetUrl,
event: 'created',
payload: [
'event' => 'created',
'timestamp' => now()->toIso8601String(),
'short_url' => $this->transformLink($shortUrl),
]
)->onConnection($connection ?: 'sync'));
} catch (\Throwable $e) {
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
'url_key' => $shortUrl->url_key,
'error' => $e->getMessage(),
]);
}
}
}
}

View File

@@ -8,6 +8,7 @@ use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\MessageBag;
use Symfony\Component\HttpFoundation\Response;
@@ -27,12 +28,26 @@ class ShortUrlRedirectController extends Controller
abort(404);
}
// 410 Gone if disabled or expired
// Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired
if (! $shortUrl->isActive()) {
if ($shortUrl->expiration_redirect_url) {
return redirect()->away($shortUrl->expiration_redirect_url, 302);
}
abort(410);
}
// 1. Rate Limiting Check
// 1. VPN/Proxy & Bot Blocking Check
if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') {
$ipAddress = ClientIpExtractor::getIp($request);
$proxyDetector = app(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class);
$detection = $proxyDetector->detect($ipAddress);
if ($detection['is_proxy'] || $detection['is_bot']) {
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
}
}
// 2. Rate Limiting Check
if (config('filament-short-url.rate_limiting.enabled', false)) {
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
@@ -48,7 +63,7 @@ class ShortUrlRedirectController extends Controller
RateLimiter::hit($limiterKey, $decaySeconds);
}
// 2. Password Protection Check
// 3. Password Protection Check
if (! empty($shortUrl->password)) {
$sessionKey = "short-url-auth-{$shortUrl->id}";
if (! session()->get($sessionKey)) {
@@ -73,7 +88,7 @@ class ShortUrlRedirectController extends Controller
}
}
// 3. Resolve Destination URL (evaluating targeting rules)
// 4. Resolve Destination URL (evaluating targeting rules)
$destination = $shortUrl->resolveDestinationUrl($request);
// Forward query parameters if configured
@@ -88,37 +103,45 @@ class ShortUrlRedirectController extends Controller
}
}
// 4. Warning / Intermediate Page Check
// 5. Warning / Intermediate Page Check
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
return response(view('filament-short-url::warning', ['destinationUrl' => $destination]))
->header('Content-Type', 'text/html');
}
// 5. Track Visit
// 6. Track Visit
if ($shortUrl->track_visits) {
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
$countryCode = ClientIpExtractor::getCountryCode($request);
$city = ClientIpExtractor::getCity($request);
try {
$connection = config('filament-short-url.queue_connection', 'sync');
$ipAddress = ClientIpExtractor::getIp($request);
$countryCode = ClientIpExtractor::getCountryCode($request);
$city = ClientIpExtractor::getCity($request);
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
ipAddress: $ipAddress,
userAgent: $request->userAgent() ?? '',
refererUrl: $request->header('Referer'),
countryCode: $countryCode,
city: $city,
utmSource: $request->query('utm_source'),
utmMedium: $request->query('utm_medium'),
utmCampaign: $request->query('utm_campaign'),
utmTerm: $request->query('utm_term'),
utmContent: $request->query('utm_content'),
);
$job = new TrackShortUrlVisitJob(
shortUrl: $shortUrl,
ipAddress: $ipAddress,
userAgent: $request->userAgent() ?? '',
refererUrl: $request->header('Referer'),
countryCode: $countryCode,
city: $city,
utmSource: $request->query('utm_source'),
utmMedium: $request->query('utm_medium'),
utmCampaign: $request->query('utm_campaign'),
utmTerm: $request->query('utm_term'),
utmContent: $request->query('utm_content'),
);
if ($connection) {
dispatch($job->onConnection($connection));
} else {
dispatch($job->onConnection('sync'));
if ($connection) {
dispatch($job->onConnection($connection));
} else {
dispatch($job->onConnection('sync'));
}
} catch (\Throwable $e) {
// Never let tracking failures block the redirection of the user!
Log::error('[FilamentShortUrl] Redirect tracking failed', [
'url_key' => $key,
'error' => $e->getMessage(),
]);
}
}
@@ -137,6 +160,15 @@ class ShortUrlRedirectController extends Controller
cache()->forget("filament-short-url:{$shortUrl->url_key}");
}
if (! empty($shortUrl->pixel_meta_id) || ! empty($shortUrl->pixel_google_id) || ! empty($shortUrl->pixel_linkedin_id)) {
return response(view('filament-short-url::pixel-loading', [
'destination' => $destination,
'pixelMetaId' => $shortUrl->pixel_meta_id,
'pixelGoogleId' => $shortUrl->pixel_google_id,
'pixelLinkedinId' => $shortUrl->pixel_linkedin_id,
]))->header('Content-Type', 'text/html');
}
return redirect()->away($destination, $shortUrl->redirect_status_code);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Bjanczak\FilamentShortUrl\Http\Middleware;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateShortUrlApi
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (! config('filament-short-url.api_enabled', false)) {
return response()->json([
'error' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
], 503);
}
$apiKey = $request->header('X-Api-Key');
if (! $apiKey && $auth = $request->header('Authorization')) {
if (str_starts_with(strtolower($auth), 'bearer ')) {
$apiKey = substr($auth, 7);
}
}
if (empty($apiKey)) {
return response()->json([
'error' => 'Unauthorized. API Key is missing.',
], 401);
}
$mgr = app(ShortUrlSettingsManager::class);
$keys = $mgr->get('api_keys', []);
$valid = false;
foreach ($keys as $keyObj) {
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
$valid = true;
break;
}
}
if (! $valid) {
return response()->json([
'error' => 'Unauthorized. Invalid or inactive API Key.',
], 401);
}
return $next($request);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Bjanczak\FilamentShortUrl\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SendWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int Max retry attempts if the webhook fails */
public int $tries = 3;
/** @var int Delay between retries (seconds) */
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public readonly string $url,
public readonly string $event,
public readonly array $payload
) {
$this->onQueue(config('filament-short-url.queue_name', 'default'));
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
])
->post($this->url, $this->payload);
if ($response->failed()) {
Log::warning('[FilamentShortUrl] Webhook delivery returned client/server error', [
'url' => $this->url,
'event' => $this->event,
'status' => $response->status(),
]);
// Throw exception to trigger queue retry
throw new \RuntimeException('Webhook failed with status '.$response->status());
}
} catch (\Throwable $e) {
Log::warning('[FilamentShortUrl] Webhook delivery failed', [
'url' => $this->url,
'event' => $this->event,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -90,6 +90,60 @@ class TrackShortUrlVisitJob implements ShouldQueue
// Fire event for user listeners
ShortUrlVisited::dispatch($shortUrl, $visit);
// Trigger Webhook if active
$targetUrl = $shortUrl->webhook_url;
$globalUrl = config('filament-short-url.global_webhook_url');
$events = config('filament-short-url.webhook_events', []);
if (empty($targetUrl) && ! empty($globalUrl) && in_array('visited', $events)) {
$targetUrl = $globalUrl;
}
if (! empty($targetUrl)) {
try {
dispatch(new SendWebhookJob(
url: $targetUrl,
event: 'visited',
payload: [
'event' => 'visited',
'timestamp' => now()->toIso8601String(),
'short_url' => [
'id' => $shortUrl->id,
'destination_url' => $shortUrl->destination_url,
'url_key' => $shortUrl->url_key,
'short_url' => $shortUrl->getShortUrl(),
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
'unique_visits' => (int) $shortUrl->unique_visits,
],
'visit' => [
'id' => $visit->id,
'visited_at' => $visit->visited_at->toIso8601String(),
'device_type' => $visit->device_type,
'browser' => $visit->browser,
'browser_version' => $visit->browser_version,
'operating_system' => $visit->operating_system,
'operating_system_version' => $visit->operating_system_version,
'country' => $visit->country,
'country_code' => $visit->country_code,
'city' => $visit->city,
'referer_url' => $visit->referer_url,
'referer_host' => $visit->referer_host,
'utm_source' => $visit->utm_source,
'utm_medium' => $visit->utm_medium,
'utm_campaign' => $visit->utm_campaign,
'utm_term' => $visit->utm_term,
'utm_content' => $visit->utm_content,
],
]
)->onConnection($this->connection ?: 'sync'));
} catch (\Throwable $e) {
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
'url_key' => $shortUrl->url_key,
'error' => $e->getMessage(),
]);
}
}
// Optional GA4 Measurement Protocol integration
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
$this->sendGa4Hit($shortUrl, $visit);

View File

@@ -78,6 +78,12 @@ class ShortUrl extends Model
'targeting_rules',
'total_visits',
'unique_visits',
'max_visits',
'expiration_redirect_url',
'pixel_meta_id',
'pixel_google_id',
'pixel_linkedin_id',
'webhook_url',
];
/** @var array<string, string> */
@@ -96,6 +102,7 @@ class ShortUrl extends Model
'qr_options' => 'array',
'show_warning_page' => 'boolean',
'targeting_rules' => 'array',
'max_visits' => 'integer',
'activated_at' => 'datetime',
'deactivated_at' => 'datetime',
'expires_at' => 'datetime',
@@ -124,9 +131,21 @@ class ShortUrl extends Model
{
return $query
->enabled()
->where(fn (Builder $q) => $q
->whereNull('activated_at')
->orWhere('activated_at', '<=', now())
)
->where(fn (Builder $q) => $q
->whereNull('deactivated_at')
->orWhere('deactivated_at', '>', now())
)
->where(fn (Builder $q) => $q
->whereNull('expires_at')
->orWhere('expires_at', '>', now())
)
->where(fn (Builder $q) => $q
->whereNull('max_visits')
->orWhereRaw('total_visits < max_visits')
);
}
@@ -163,7 +182,39 @@ class ShortUrl extends Model
*/
protected static function booted(): void
{
static::saved(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
static::saving(function (self $m) {
if ($m->single_use) {
$m->max_visits = null;
}
if ($m->activated_at === null && $m->expires_at === null) {
$m->expiration_redirect_url = null;
}
if (empty($m->pixel_meta_id)) {
$m->pixel_meta_id = null;
}
if (empty($m->pixel_google_id)) {
$m->pixel_google_id = null;
}
if (empty($m->pixel_linkedin_id)) {
$m->pixel_linkedin_id = null;
}
if (empty($m->webhook_url)) {
$m->webhook_url = null;
}
});
static::saved(function (self $m) {
cache()->forget("filament-short-url:{$m->url_key}");
if ($m->wasChanged('url_key')) {
$oldKey = $m->getOriginal('url_key');
if ($oldKey) {
cache()->forget("filament-short-url:{$oldKey}");
}
}
});
static::deleted(fn (self $m) => cache()->forget("filament-short-url:{$m->url_key}"));
// Bust the forever-cached link counts displayed in the global overview widget.
@@ -187,7 +238,17 @@ class ShortUrl extends Model
return app(ShortUrlService::class)->destination($url);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
public function getRealTimeTotalVisits(): int
{
$total = $this->total_visits;
if (config('filament-short-url.counter_buffering.enabled', false)) {
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$total += (int) cache()->get("{$prefix}total:{$this->id}", 0);
}
return $total;
}
public function isActive(): bool
{
@@ -210,12 +271,25 @@ class ShortUrl extends Model
return false;
}
// Visit limit reached
if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) {
return false;
}
return true;
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return true;
}
if (! $this->single_use && $this->max_visits !== null && $this->getRealTimeTotalVisits() >= $this->max_visits) {
return true;
}
return false;
}
public function trackingEnabled(): bool

View File

@@ -47,11 +47,15 @@ class ShortUrlVisit extends Model
'utm_term',
'utm_content',
'visited_at',
'is_bot',
'is_proxy',
];
/** @var array<string, string> */
protected $casts = [
'visited_at' => 'datetime',
'is_bot' => 'boolean',
'is_proxy' => 'boolean',
];
public function shortUrl(): BelongsTo

View File

@@ -0,0 +1,108 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ProxyDetectionService
{
/**
* Detect if an IP address belongs to a VPN, proxy, Tor node, or data center hosting.
*
* @return array{is_proxy: bool, is_bot: bool}
*/
public function detect(string $ip): array
{
if (empty($ip) || $ip === '127.0.0.1' || $ip === '::1') {
return ['is_proxy' => false, 'is_bot' => false];
}
$enabled = config('filament-short-url.vpn_detection.enabled', false);
if (! $enabled) {
return ['is_proxy' => false, 'is_bot' => false];
}
$cacheKey = "short-url:proxy-check:{$ip}";
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) {
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
$timeout = (int) config('filament-short-url.vpn_detection.timeout', 2);
try {
if ($driver === 'vpnapi') {
return $this->queryVpnApi($ip, $timeout);
}
// Default to ip-api
return $this->queryIpApi($ip, $timeout);
} catch (\Throwable $e) {
Log::warning("Proxy detection failed for IP: {$ip}. Error: " . $e->getMessage());
return ['is_proxy' => false, 'is_bot' => false];
}
});
}
/**
* Query ip-api.com endpoint.
*
* @return array{is_proxy: bool, is_bot: bool}
*/
private function queryIpApi(string $ip, int $timeout): array
{
$url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting";
$response = Http::timeout($timeout)->get($url);
if ($response->failed() || $response->json('status') === 'fail') {
return ['is_proxy' => false, 'is_bot' => false];
}
$isProxy = (bool) $response->json('proxy', false);
$isBot = (bool) $response->json('hosting', false); // hosting indicates data centers (bots/scrapers)
return [
'is_proxy' => $isProxy,
'is_bot' => $isBot,
];
}
/**
* Query vpnapi.io endpoint.
*
* @return array{is_proxy: bool, is_bot: bool}
*/
private function queryVpnApi(string $ip, int $timeout): array
{
$key = config('filament-short-url.vpn_detection.vpnapi_key');
if (empty($key)) {
return ['is_proxy' => false, 'is_bot' => false];
}
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
$response = Http::timeout($timeout)->get($url);
if ($response->failed()) {
return ['is_proxy' => false, 'is_bot' => false];
}
$security = $response->json('security', []);
$isVpn = (bool) ($security['vpn'] ?? false);
$isProxy = (bool) ($security['proxy'] ?? false);
$isTor = (bool) ($security['tor'] ?? false);
$isRelay = (bool) ($security['relay'] ?? false);
// Treat hosting/data center as bot traffic
$isHosting = (bool) ($response->json('security.hosting') ?? false);
return [
'is_proxy' => $isVpn || $isProxy || $isTor || $isRelay,
'is_bot' => $isHosting,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SafeBrowsingService
{
/**
* Check if a URL is safe using Google Safe Browsing API.
*
* Returns true if safe (or if API is disabled/failed), false if a threat is detected.
*/
public function isSafe(string $url): bool
{
$enabled = config('filament-short-url.safe_browsing.enabled', false);
$apiKey = config('filament-short-url.safe_browsing.api_key');
if (! $enabled || empty($apiKey)) {
return true;
}
return $this->isSafeWithKey($url, $apiKey);
}
/**
* Check if a URL is safe using Google Safe Browsing API with a specific key.
*/
public function isSafeWithKey(string $url, string $apiKey): bool
{
if (empty($apiKey)) {
return true;
}
try {
$endpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key={$apiKey}";
$payload = [
'client' => [
'clientId' => 'filament-short-url-plugin',
'clientVersion' => '2.0.0',
],
'threatInfo' => [
'threatTypes' => [
'MALWARE',
'SOCIAL_ENGINEERING',
'UNWANTED_SOFTWARE',
'POTENTIALLY_HARMFUL_APPLICATION',
],
'platformTypes' => ['ANY_PLATFORM'],
'threatEntryTypes' => ['URL'],
'threatEntries' => [
['url' => $url],
],
],
];
$response = Http::timeout(3)
->withHeaders(['Content-Type' => 'application/json'])
->post($endpoint, $payload);
if ($response->failed()) {
Log::warning("Google Safe Browsing API request failed with status: " . $response->status());
return true; // Default to safe if API is down
}
$matches = $response->json('matches', []);
// If there are matches, the URL is flagged as unsafe
return empty($matches);
} catch (\Throwable $e) {
Log::warning("Google Safe Browsing check failed: " . $e->getMessage());
return true; // Default to safe on exception
}
}
}

View File

@@ -51,6 +51,34 @@ class ShortUrlBuilder
return $this;
}
public function activatedAt(\DateTimeInterface|Carbon|null $date): static
{
$this->data['activated_at'] = $date ? Carbon::instance($date) : null;
return $this;
}
public function deactivatedAt(\DateTimeInterface|Carbon|null $date): static
{
$this->data['deactivated_at'] = $date ? Carbon::instance($date) : null;
return $this;
}
public function maxVisits(?int $maxVisits): static
{
$this->data['max_visits'] = $maxVisits;
return $this;
}
public function expirationRedirectUrl(?string $url): static
{
$this->data['expiration_redirect_url'] = $url;
return $this;
}
public function trackVisits(bool $track = true): static
{
$this->data['track_visits'] = $track;

View File

@@ -107,13 +107,15 @@ class ShortUrlService
*/
public function resolveRedirectUrl(ShortUrl $shortUrl, Request $request): string
{
$destination = $shortUrl->destination_url;
$destination = $shortUrl->resolveDestinationUrl($request);
if (! $shortUrl->forward_query_params) {
return $destination;
}
$queryParams = $request->query();
// Remove routing/auth parameters so they don't leak to destination
unset($queryParams['confirmed'], $queryParams['password']);
if (empty($queryParams)) {
return $destination;

View File

@@ -2,6 +2,7 @@
namespace Bjanczak\FilamentShortUrl\Services;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
class ShortUrlSettingsManager
@@ -45,9 +46,11 @@ class ShortUrlSettingsManager
'geo_ip_cache_ttl' => config('filament-short-url.geo_ip.cache_ttl', 86400),
'geo_ip_timeout' => config('filament-short-url.geo_ip.timeout', 3),
'maxmind_database_path' => config('filament-short-url.geo_ip.maxmind.database_path', storage_path('geoip/GeoLite2-Country.mmdb')),
'geo_ip_stats_cache_ttl' => config('filament-short-url.geo_ip.stats_cache_ttl', 300),
'ga4_api_secret' => config('filament-short-url.ga4.api_secret'),
'ga4_firebase_app_id' => config('filament-short-url.ga4.firebase_app_id'),
'queue_connection' => config('filament-short-url.queue_connection', 'sync'),
'queue_name' => config('filament-short-url.queue_name', 'default'),
'cache_ttl' => config('filament-short-url.cache_ttl', 3600),
'counter_buffering_enabled' => config('filament-short-url.counter_buffering.enabled', false),
'trust_cdn_headers' => config('filament-short-url.trust_cdn_headers', false),
@@ -56,6 +59,35 @@ class ShortUrlSettingsManager
'rate_limiting_enabled' => config('filament-short-url.rate_limiting.enabled', false),
'rate_limiting_max_attempts' => config('filament-short-url.rate_limiting.max_attempts', 60),
'rate_limiting_decay_seconds' => config('filament-short-url.rate_limiting.decay_seconds', 60),
'tracking_enabled' => config('filament-short-url.tracking.enabled', true),
'tracking_fields_ip_address' => config('filament-short-url.tracking.fields.ip_address', true),
'tracking_fields_browser' => config('filament-short-url.tracking.fields.browser', true),
'tracking_fields_browser_version' => config('filament-short-url.tracking.fields.browser_version', true),
'tracking_fields_operating_system' => config('filament-short-url.tracking.fields.operating_system', true),
'tracking_fields_operating_system_version' => config('filament-short-url.tracking.fields.operating_system_version', true),
'tracking_fields_referer_url' => config('filament-short-url.tracking.fields.referer_url', true),
'tracking_fields_device_type' => config('filament-short-url.tracking.fields.device_type', true),
'qr_size' => config('filament-short-url.qr_defaults.size', 300),
'qr_margin' => config('filament-short-url.qr_defaults.margin', 1),
'qr_dot_style' => config('filament-short-url.qr_defaults.dot_style', 'square'),
'qr_foreground_color' => config('filament-short-url.qr_defaults.foreground_color', '#000000'),
'qr_background_color' => config('filament-short-url.qr_defaults.background_color', '#ffffff'),
'qr_gradient_enabled' => config('filament-short-url.qr_defaults.gradient_enabled', false),
'qr_gradient_from' => config('filament-short-url.qr_defaults.gradient_from', '#4f46e5'),
'qr_gradient_to' => config('filament-short-url.qr_defaults.gradient_to', '#06b6d4'),
'qr_gradient_type' => config('filament-short-url.qr_defaults.gradient_type', 'linear'),
'global_webhook_url' => null,
'webhook_events' => ['visited'],
'api_keys' => [],
'api_enabled' => false,
'site_name' => config('filament-short-url.site_name'),
// Security v2.0
'vpn_detection_enabled' => config('filament-short-url.vpn_detection.enabled', false),
'vpn_detection_driver' => config('filament-short-url.vpn_detection.driver', 'ip-api'),
'vpnapi_key' => config('filament-short-url.vpn_detection.vpnapi_key'),
'vpn_block_action' => config('filament-short-url.vpn_detection.block_action', 'flag_only'),
'safe_browsing_enabled' => config('filament-short-url.safe_browsing.enabled', false),
'google_safe_browsing_api_key' => config('filament-short-url.safe_browsing.api_key'),
], $stored);
return $this->cache;
@@ -80,6 +112,8 @@ class ShortUrlSettingsManager
File::makeDirectory($dir, 0755, true);
}
$oldPrefix = $this->get('route_prefix');
// Keep only supported settings keys to prevent bloat
$keys = [
'route_prefix',
@@ -90,9 +124,11 @@ class ShortUrlSettingsManager
'geo_ip_cache_ttl',
'geo_ip_timeout',
'maxmind_database_path',
'geo_ip_stats_cache_ttl',
'ga4_api_secret',
'ga4_firebase_app_id',
'queue_connection',
'queue_name',
'cache_ttl',
'counter_buffering_enabled',
'trust_cdn_headers',
@@ -101,6 +137,35 @@ class ShortUrlSettingsManager
'rate_limiting_enabled',
'rate_limiting_max_attempts',
'rate_limiting_decay_seconds',
'tracking_enabled',
'tracking_fields_ip_address',
'tracking_fields_browser',
'tracking_fields_browser_version',
'tracking_fields_operating_system',
'tracking_fields_operating_system_version',
'tracking_fields_referer_url',
'tracking_fields_device_type',
'qr_size',
'qr_margin',
'qr_dot_style',
'qr_foreground_color',
'qr_background_color',
'qr_gradient_enabled',
'qr_gradient_from',
'qr_gradient_to',
'qr_gradient_type',
'global_webhook_url',
'webhook_events',
'api_keys',
'api_enabled',
'site_name',
// Security v2.0
'vpn_detection_enabled',
'vpn_detection_driver',
'vpnapi_key',
'vpn_block_action',
'safe_browsing_enabled',
'google_safe_browsing_api_key',
];
$filtered = array_intersect_key($data, array_flip($keys));
@@ -121,6 +186,9 @@ class ShortUrlSettingsManager
if (isset($filtered['geo_ip_timeout'])) {
$filtered['geo_ip_timeout'] = (int) $filtered['geo_ip_timeout'];
}
if (isset($filtered['geo_ip_stats_cache_ttl'])) {
$filtered['geo_ip_stats_cache_ttl'] = (int) $filtered['geo_ip_stats_cache_ttl'];
}
if (isset($filtered['cache_ttl'])) {
$filtered['cache_ttl'] = (int) $filtered['cache_ttl'];
}
@@ -146,11 +214,68 @@ class ShortUrlSettingsManager
$filtered['rate_limiting_decay_seconds'] = (int) $filtered['rate_limiting_decay_seconds'];
}
// Tracking defaults
if (isset($filtered['tracking_enabled'])) {
$filtered['tracking_enabled'] = (bool) $filtered['tracking_enabled'];
}
if (isset($filtered['tracking_fields_ip_address'])) {
$filtered['tracking_fields_ip_address'] = (bool) $filtered['tracking_fields_ip_address'];
}
if (isset($filtered['tracking_fields_browser'])) {
$filtered['tracking_fields_browser'] = (bool) $filtered['tracking_fields_browser'];
}
if (isset($filtered['tracking_fields_browser_version'])) {
$filtered['tracking_fields_browser_version'] = (bool) $filtered['tracking_fields_browser_version'];
}
if (isset($filtered['tracking_fields_operating_system'])) {
$filtered['tracking_fields_operating_system'] = (bool) $filtered['tracking_fields_operating_system'];
}
if (isset($filtered['tracking_fields_operating_system_version'])) {
$filtered['tracking_fields_operating_system_version'] = (bool) $filtered['tracking_fields_operating_system_version'];
}
if (isset($filtered['tracking_fields_referer_url'])) {
$filtered['tracking_fields_referer_url'] = (bool) $filtered['tracking_fields_referer_url'];
}
if (isset($filtered['tracking_fields_device_type'])) {
$filtered['tracking_fields_device_type'] = (bool) $filtered['tracking_fields_device_type'];
}
// QR defaults
if (isset($filtered['qr_size'])) {
$filtered['qr_size'] = (int) $filtered['qr_size'];
}
if (isset($filtered['qr_margin'])) {
$filtered['qr_margin'] = (int) $filtered['qr_margin'];
}
if (isset($filtered['qr_gradient_enabled'])) {
$filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled'];
}
// Security v2.0 casts
if (isset($filtered['vpn_detection_enabled'])) {
$filtered['vpn_detection_enabled'] = (bool) $filtered['vpn_detection_enabled'];
}
if (isset($filtered['safe_browsing_enabled'])) {
$filtered['safe_browsing_enabled'] = (bool) $filtered['safe_browsing_enabled'];
}
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->cache = null;
// Apply immediately to current request config
$this->applyConfigOverrides();
// Clear route cache if route prefix has changed
$newPrefix = $filtered['route_prefix'] ?? null;
if ($oldPrefix !== null && $newPrefix !== null && $oldPrefix !== $newPrefix) {
try {
if (app()->routesAreCached()) {
Artisan::call('route:clear');
}
} catch (\Throwable) {
// Ignore route clear errors during boot/test
}
}
}
/**
@@ -169,9 +294,11 @@ class ShortUrlSettingsManager
'filament-short-url.geo_ip.cache_ttl' => $settings['geo_ip_cache_ttl'],
'filament-short-url.geo_ip.timeout' => $settings['geo_ip_timeout'],
'filament-short-url.geo_ip.maxmind.database_path' => $settings['maxmind_database_path'],
'filament-short-url.geo_ip.stats_cache_ttl' => $settings['geo_ip_stats_cache_ttl'],
'filament-short-url.ga4.api_secret' => $settings['ga4_api_secret'],
'filament-short-url.ga4.firebase_app_id' => $settings['ga4_firebase_app_id'],
'filament-short-url.queue_connection' => $settings['queue_connection'],
'filament-short-url.queue_name' => $settings['queue_name'],
'filament-short-url.cache_ttl' => $settings['cache_ttl'],
'filament-short-url.counter_buffering.enabled' => $settings['counter_buffering_enabled'],
'filament-short-url.trust_cdn_headers' => $settings['trust_cdn_headers'],
@@ -180,6 +307,36 @@ class ShortUrlSettingsManager
'filament-short-url.rate_limiting.enabled' => $settings['rate_limiting_enabled'],
'filament-short-url.rate_limiting.max_attempts' => $settings['rate_limiting_max_attempts'],
'filament-short-url.rate_limiting.decay_seconds' => $settings['rate_limiting_decay_seconds'],
'filament-short-url.tracking.enabled' => $settings['tracking_enabled'],
'filament-short-url.tracking.fields.ip_address' => $settings['tracking_fields_ip_address'],
'filament-short-url.tracking.fields.browser' => $settings['tracking_fields_browser'],
'filament-short-url.tracking.fields.browser_version' => $settings['tracking_fields_browser_version'],
'filament-short-url.tracking.fields.operating_system' => $settings['tracking_fields_operating_system'],
'filament-short-url.tracking.fields.operating_system_version' => $settings['tracking_fields_operating_system_version'],
'filament-short-url.tracking.fields.referer_url' => $settings['tracking_fields_referer_url'],
'filament-short-url.tracking.fields.device_type' => $settings['tracking_fields_device_type'],
'filament-short-url.qr_defaults.size' => $settings['qr_size'],
'filament-short-url.qr_defaults.margin' => $settings['qr_margin'],
'filament-short-url.qr_defaults.dot_style' => $settings['qr_dot_style'],
'filament-short-url.qr_defaults.foreground_color' => $settings['qr_foreground_color'],
'filament-short-url.qr_defaults.background_color' => $settings['qr_background_color'],
'filament-short-url.qr_defaults.gradient_enabled' => $settings['qr_gradient_enabled'],
'filament-short-url.qr_defaults.gradient_from' => $settings['qr_gradient_from'],
'filament-short-url.qr_defaults.gradient_to' => $settings['qr_gradient_to'],
'filament-short-url.qr_defaults.gradient_type' => $settings['qr_gradient_type'],
'filament-short-url.global_webhook_url' => $settings['global_webhook_url'] ?? null,
'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'],
'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false),
'filament-short-url.site_name' => $settings['site_name'] ?? null,
// Security v2.0
'filament-short-url.vpn_detection.enabled' => (bool) ($settings['vpn_detection_enabled'] ?? false),
'filament-short-url.vpn_detection.driver' => $settings['vpn_detection_driver'] ?? 'ip-api',
'filament-short-url.vpn_detection.vpnapi_key' => $settings['vpnapi_key'] ?? null,
'filament-short-url.vpn_detection.block_action' => $settings['vpn_block_action'] ?? 'flag_only',
'filament-short-url.vpn_detection.cache_ttl' => 86400,
'filament-short-url.vpn_detection.timeout' => 2,
'filament-short-url.safe_browsing.enabled' => (bool) ($settings['safe_browsing_enabled'] ?? false),
'filament-short-url.safe_browsing.api_key' => $settings['google_safe_browsing_api_key'] ?? null,
]);
}
}

View File

@@ -16,6 +16,7 @@ class ShortUrlTracker
public function __construct(
private readonly UserAgentParser $uaParser,
private readonly GeoIpService $geoIp,
private readonly ProxyDetectionService $proxyDetector,
) {}
/**
@@ -39,9 +40,14 @@ class ShortUrlTracker
$ua = $request->userAgent() ?? '';
$parsed = $this->uaParser->parse($ua);
// Don't track bots — they inflate stats and waste API calls
if ($parsed['device_type'] === 'robot') {
return null;
// Run bot & proxy/VPN detection
$isBot = $parsed['device_type'] === 'robot';
$isProxy = false;
if (! $isBot) {
$detection = $this->proxyDetector->detect($ip);
$isBot = (bool) $detection['is_bot'];
$isProxy = (bool) $detection['is_proxy'];
}
$geo = config('filament-short-url.geo_ip.enabled', true)
@@ -68,6 +74,8 @@ class ShortUrlTracker
$visit->country = $geo['country'];
$visit->country_code = $geo['country_code'];
$visit->city = $geo['city'] ?? null;
$visit->is_bot = $isBot;
$visit->is_proxy = $isProxy;
$visit->utm_source = $utmSource;
$visit->utm_medium = $utmMedium;
@@ -104,7 +112,10 @@ class ShortUrlTracker
$visit->save();
$shortUrl->incrementVisits($isUnique);
// Increment stats only if it's NOT a bot or proxy/VPN to keep analytics clean
if (! $isBot && ! $isProxy) {
$shortUrl->incrementVisits($isUnique);
}
return $visit;
}