Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9411808e | ||
|
|
3a9575f5b1 | ||
|
|
325a38bfe8 | ||
|
|
1cd85af0a1 |
33
LICENSE
33
LICENSE
@@ -1,21 +1,22 @@
|
||||
MIT License
|
||||
Custom Source-Available License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Grant of License
|
||||
Subject to the terms of this License, the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
|
||||
- Use the Software for personal, internal, or commercial purposes.
|
||||
- Modify the Software for your own personal, internal, or commercial use.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2. Restrictions
|
||||
You are strictly prohibited from:
|
||||
- Redistributing the Software, in whole or in part, or any modified versions thereof, in any public repository (including but not limited to public repositories on GitHub, GitLab, Bitbucket) or package registry (such as Packagist).
|
||||
- Selling, renting, leasing, or sublicensing the Software or any modified versions thereof as a standalone package or library.
|
||||
- Publishing, distributing, or sharing the Software under a different name or package name.
|
||||
|
||||
3. Copyright Notice
|
||||
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
|
||||
|
||||
4. Disclaimer of Warranty
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
87
README.md
87
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/v/janczakb/filament-short-url.svg?style=flat-square" alt="Latest Version"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/github/license/janczakb/filament-short-url.svg?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-proprietary-7c3aed.svg?style=flat-square" alt="License"></a>
|
||||
<a href="https://packagist.org/packages/janczakb/filament-short-url"><img src="https://img.shields.io/packagist/dt/janczakb/filament-short-url.svg?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/stargazers"><img src="https://img.shields.io/github/stars/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Stars"></a>
|
||||
<a href="https://github.com/janczakb/filament-short-url/issues"><img src="https://img.shields.io/github/issues/janczakb/filament-short-url.svg?style=flat-square" alt="GitHub Issues"></a>
|
||||
@@ -242,7 +242,7 @@ protected $policies = [
|
||||
|
||||
The package comes with a built-in admin settings dashboard. It is accessible directly from your sidebar menu under the same navigation group as your links.
|
||||
|
||||
Settings are stored dynamically in `storage/app/filament-short-url-settings.json` and immediately override config defaults.
|
||||
Settings are stored dynamically in the database (`short_url_settings` table), cached indefinitely, and immediately override config defaults. Legacy settings from `filament-short-url-settings.json` are automatically imported on first load.
|
||||
|
||||
The settings panel allows you to configure:
|
||||
|
||||
@@ -577,16 +577,7 @@ $shortUrl = ShortUrl::find($id);
|
||||
$shortUrl->pixels()->sync([$pixelId1, $pixelId2]);
|
||||
```
|
||||
|
||||
For backward compatibility, assigning individual attributes directly on the model still works and will automatically find or create a pixel registry entry:
|
||||
|
||||
```php
|
||||
// Programmatically via model attributes (automatically handles registration under the hood)
|
||||
$shortUrl->update([
|
||||
'pixel_meta_id' => '1234567890',
|
||||
'pixel_google_id' => 'G-XXXXXXXXXX',
|
||||
'pixel_linkedin_id' => '1234567',
|
||||
]);
|
||||
```
|
||||
|
||||
> **Privacy/GDPR Note:** You are responsible for ensuring that firing these pixels complies with applicable privacy regulations and your cookie consent mechanism.
|
||||
|
||||
@@ -614,6 +605,8 @@ Authorization: Bearer sh_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
**Managing API Keys:** Go to **Settings → API & Webhooks → Developer API Keys** and add named keys. Each key can be individually activated or deactivated without deleting it.
|
||||
|
||||
For security, new API keys are hashed using SHA-256 and stored securely in the database. The plain key is displayed only once during generation via a persistent warning notification in the Filament UI. All keys are authenticated using constant-time string comparisons (`hash_equals()`) to prevent timing attacks.
|
||||
|
||||
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
|
||||
|
||||
### Endpoints
|
||||
@@ -642,9 +635,6 @@ curl https://yourdomain.com/api/short-url/links \
|
||||
"max_visits": null,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"pixel_meta_id": null,
|
||||
"pixel_google_id": null,
|
||||
"pixel_linkedin_id": null,
|
||||
"webhook_url": null,
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
@@ -678,7 +668,7 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
"notes": "Summer campaign",
|
||||
"single_use": false,
|
||||
"max_visits": 1000,
|
||||
"pixel_meta_id": "1234567890",
|
||||
"pixels": [1, 2],
|
||||
"webhook_url": "https://api.mycrm.com/clicks"
|
||||
}'
|
||||
```
|
||||
@@ -698,9 +688,6 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
| `expiration_redirect_url` | string (URL) | ❌ | Fallback URL on expiry |
|
||||
| `activated_at` | datetime | ❌ | Activation timestamp |
|
||||
| `expires_at` | datetime | ❌ | Expiration timestamp |
|
||||
| `pixel_meta_id` | string | ❌ | Meta Pixel ID (Legacy support) |
|
||||
| `pixel_google_id` | string | ❌ | Google Tag / GA4 ID (Legacy support) |
|
||||
| `pixel_linkedin_id` | string | ❌ | LinkedIn Partner ID (Legacy support) |
|
||||
| `pixels` | array of integers | ❌ | List of pixel registry IDs to associate with the link |
|
||||
| `webhook_url` | string (URL) | ❌ | Per-link webhook endpoint |
|
||||
| `targeting_rules` | array | ❌ | JSON targeting rules (device, geo, rotation, language) |
|
||||
@@ -709,7 +696,49 @@ curl -X POST https://yourdomain.com/api/short-url/links \
|
||||
| `track_visits` | boolean | ❌ | Track visitor clicks and logs |
|
||||
| `track_browser_language` | boolean | ❌ | Track visitor browser language locale |
|
||||
|
||||
**Response:** `201 Created` with the created link object.
|
||||
**Response:** `201 Created` with a wrapper message and the created link data:
|
||||
```json
|
||||
{
|
||||
"message": "Short URL created successfully.",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"destination_url": "https://example.com/product",
|
||||
"url_key": "promo26",
|
||||
"short_url": "https://yourdomain.com/s/promo26",
|
||||
"is_enabled": true,
|
||||
"redirect_status_code": 302,
|
||||
"total_visits": 0,
|
||||
"unique_visits": 0,
|
||||
"max_visits": 1000,
|
||||
"activated_at": null,
|
||||
"expires_at": null,
|
||||
"webhook_url": "https://api.mycrm.com/clicks",
|
||||
"targeting_rules": null,
|
||||
"password": null,
|
||||
"show_warning_page": false,
|
||||
"track_visits": true,
|
||||
"track_browser_language": true,
|
||||
"pixels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Meta Pixel (1234567890)",
|
||||
"type": "meta",
|
||||
"pixel_id": "1234567890",
|
||||
"is_active": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Google Tag (G-XXXXXXXXXX)",
|
||||
"type": "google",
|
||||
"pixel_id": "G-XXXXXXXXXX",
|
||||
"is_active": true
|
||||
}
|
||||
],
|
||||
"notes": "Summer campaign",
|
||||
"created_at": "2026-06-04T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/short-url/links/{id}`
|
||||
Permanently delete a short URL by its ID.
|
||||
@@ -762,6 +791,13 @@ Webhooks can be configured at two levels:
|
||||
|
||||
Select which events to monitor in **Settings → API & Webhooks → Monitored Webhook Events**.
|
||||
|
||||
### Webhook Signature Verification
|
||||
|
||||
Outgoing webhooks can be cryptographically signed using an HMAC-SHA256 signature to verify that the request originated from your system:
|
||||
1. Configure a **Webhook Signing Secret** in your Settings panel under **API & Webhooks**.
|
||||
2. When configured, outgoing HTTP POST payloads will include the signature in the `X-ShortUrl-Signature` header, which is calculated as `hash_hmac('sha256', $payloadJson, $secret)`.
|
||||
3. The receiver can verify the signature by re-calculating the HMAC of the raw request payload using the shared secret and comparing it using `hash_equals()`.
|
||||
|
||||
### Payload Format
|
||||
|
||||
All webhook requests are HTTP POST with `Content-Type: application/json` and the following payload structure:
|
||||
@@ -979,12 +1015,21 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3.1.0
|
||||
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
|
||||
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
|
||||
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
|
||||
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
|
||||
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
|
||||
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
|
||||
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
|
||||
|
||||
### v3.0.0
|
||||
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
|
||||
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
|
||||
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
|
||||
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
|
||||
- **Backward-Compatible REST API** — The API now exposes the `pixels` relationship list, while fully retaining backward-compatible support for legacy single-pixel parameters (`pixel_meta_id`, `pixel_google_id`, `pixel_linkedin_id`).
|
||||
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
|
||||
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
|
||||
- **Full Localization & WhatsApp Favicon Fix** — Added friendly translation strings in English and Polish across the entire app-linking preview and redirect interfaces, and adjusted domains order to restore the WhatsApp favicon.
|
||||
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
|
||||
@@ -1046,4 +1091,4 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Custom Source-Available License. Please see the [LICENSE](LICENSE) file for the full text.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
"description": "A professional Short URL manager plugin for Filament v5 with QR code design, visit tracking, geo-IP detection, and analytics.",
|
||||
"keywords": ["filament", "short-url", "qr-code", "analytics", "tracking", "laravel"],
|
||||
"homepage": "https://github.com/janczakb/filament-short-url",
|
||||
"license": "MIT",
|
||||
"license": "proprietary",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Bartek Janczak",
|
||||
"email": "hello@bjanczak.dev",
|
||||
"email": "barek122@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
|
||||
12190
composer.lock
generated
Normal file
12190
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -260,4 +260,3 @@ JSON),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_settings', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->longText('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('short_url_settings');
|
||||
}
|
||||
};
|
||||
@@ -153,7 +153,7 @@ return [
|
||||
'success_modal_helper' => 'Skopiuj i udostępnij ręcznie lub wybierz platformę.',
|
||||
'open_link' => 'Otwórz link',
|
||||
'close_button' => 'Zamknij',
|
||||
'dont_show_again' => "Nie pokazuj opcji udostępniania po utworzeniu linku",
|
||||
'dont_show_again' => 'Nie pokazuj opcji udostępniania po utworzeniu linku',
|
||||
|
||||
// Stats Page
|
||||
'stats_title' => 'Statystyki',
|
||||
|
||||
@@ -23,7 +23,6 @@ Route::match(
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([AuthenticateShortUrlApi::class])
|
||||
->group(function () {
|
||||
|
||||
@@ -42,97 +42,102 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
foreach ($dates as $date) {
|
||||
// Wrap date aggregation in a database transaction for data integrity
|
||||
DB::transaction(function () use ($date): void {
|
||||
// Accumulate stats per short_url_id using chunked reads — avoids loading
|
||||
// potentially millions of rows into PHP memory at once.
|
||||
$statsByUrl = [];
|
||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||
$start = $date.' 00:00:00';
|
||||
$end = $nextDate.' 00:00:00';
|
||||
|
||||
DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $date.' 00:00:00')
|
||||
->where('visited_at', '<', $nextDate.' 00:00:00')
|
||||
// 1. Get totals and uniques per short_url_id
|
||||
$totals = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->select([
|
||||
'short_url_id',
|
||||
'is_qr_scan',
|
||||
'ip_hash',
|
||||
'device_type',
|
||||
'browser',
|
||||
'operating_system',
|
||||
'country',
|
||||
'country_code',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'browser_language',
|
||||
'city',
|
||||
'referer_host',
|
||||
DB::raw('count(*) as total'),
|
||||
DB::raw('count(distinct ip_hash) as uniques'),
|
||||
DB::raw("count(case when is_qr_scan = 1 or is_qr_scan = true or is_qr_scan = '1' then 1 end) as qr_scans"),
|
||||
])
|
||||
->orderBy('id')
|
||||
->chunk(1000, function ($chunk) use (&$statsByUrl): void {
|
||||
foreach ($chunk as $visit) {
|
||||
$urlId = $visit->short_url_id;
|
||||
->groupBy('short_url_id')
|
||||
->get();
|
||||
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
$statsByUrl[$urlId] = [
|
||||
'total' => 0,
|
||||
'ip_hashes' => [],
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'qr_scans' => 0,
|
||||
'language_stats' => [],
|
||||
];
|
||||
}
|
||||
if ($totals->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$s = &$statsByUrl[$urlId];
|
||||
$s['total']++;
|
||||
$statsByUrl = [];
|
||||
foreach ($totals as $row) {
|
||||
$statsByUrl[$row->short_url_id] = [
|
||||
'total' => (int) $row->total,
|
||||
'uniques' => (int) $row->uniques,
|
||||
'qr_scans' => (int) $row->qr_scans,
|
||||
'device_stats' => [],
|
||||
'browser_stats' => [],
|
||||
'os_stats' => [],
|
||||
'country_stats' => [],
|
||||
'city_stats' => [],
|
||||
'referer_stats' => [],
|
||||
'utm_source_stats' => [],
|
||||
'utm_medium_stats' => [],
|
||||
'utm_campaign_stats' => [],
|
||||
'language_stats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if ($visit->is_qr_scan) {
|
||||
$s['qr_scans']++;
|
||||
}
|
||||
// Helper to fetch and populate category stats
|
||||
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
|
||||
$query = DB::table('short_url_visits')
|
||||
->where('visited_at', '>=', $start)
|
||||
->where('visited_at', '<', $end)
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '');
|
||||
|
||||
if ($visit->ip_hash) {
|
||||
$s['ip_hashes'][$visit->ip_hash] = true;
|
||||
}
|
||||
if ($statsKey === 'city_stats') {
|
||||
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', 'city', 'country_code']);
|
||||
} else {
|
||||
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
|
||||
->groupBy(['short_url_id', $column]);
|
||||
}
|
||||
|
||||
$inc = function (?string $value, string $key) use (&$s): void {
|
||||
if ($value) {
|
||||
$s[$key][$value] = ($s[$key][$value] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
$rows = $query->get();
|
||||
|
||||
$inc($visit->device_type, 'device_stats');
|
||||
$inc($visit->browser, 'browser_stats');
|
||||
$inc($visit->operating_system, 'os_stats');
|
||||
$inc($visit->country, 'country_stats');
|
||||
$inc($visit->utm_source, 'utm_source_stats');
|
||||
$inc($visit->utm_medium, 'utm_medium_stats');
|
||||
$inc($visit->utm_campaign, 'utm_campaign_stats');
|
||||
$inc($visit->browser_language, 'language_stats');
|
||||
|
||||
if ($visit->city) {
|
||||
$cityKey = "{$visit->city} ({$visit->country_code})";
|
||||
$s['city_stats'][$cityKey] = ($s['city_stats'][$cityKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if ($visit->referer_host) {
|
||||
$s['referer_stats'][$visit->referer_host] = ($s['referer_stats'][$visit->referer_host] ?? 0) + 1;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$urlId = $row->short_url_id;
|
||||
if (! isset($statsByUrl[$urlId])) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
if ($statsKey === 'city_stats') {
|
||||
$cityVal = $row->city;
|
||||
$countryCode = $row->country_code;
|
||||
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
|
||||
} else {
|
||||
$val = $row->$column;
|
||||
}
|
||||
|
||||
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
|
||||
}
|
||||
};
|
||||
|
||||
// Populate all categories via 10 quick indexed database aggregations
|
||||
$populateStats('device_type', 'device_stats');
|
||||
$populateStats('browser', 'browser_stats');
|
||||
$populateStats('operating_system', 'os_stats');
|
||||
$populateStats('country', 'country_stats');
|
||||
$populateStats('city', 'city_stats');
|
||||
$populateStats('referer_host', 'referer_stats');
|
||||
$populateStats('utm_source', 'utm_source_stats');
|
||||
$populateStats('utm_medium', 'utm_medium_stats');
|
||||
$populateStats('utm_campaign', 'utm_campaign_stats');
|
||||
$populateStats('browser_language', 'language_stats');
|
||||
|
||||
// Write aggregated stats to ShortUrlDailyStats
|
||||
foreach ($statsByUrl as $urlId => $s) {
|
||||
ShortUrlDailyStats::updateOrCreate([
|
||||
'short_url_id' => $urlId,
|
||||
'date' => $date,
|
||||
], [
|
||||
'visits_count' => $s['total'],
|
||||
'unique_visits_count' => count($s['ip_hashes']),
|
||||
'unique_visits_count' => $s['uniques'],
|
||||
'device_stats' => $s['device_stats'],
|
||||
'browser_stats' => $s['browser_stats'],
|
||||
'os_stats' => $s['os_stats'],
|
||||
|
||||
@@ -4,14 +4,13 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource\Pages\ListShortUrlPixels;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
@@ -163,6 +163,8 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
||||
'aasa_json' => $aasa,
|
||||
'assetlinks_json' => $assetlinks,
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret' => $mgr->get('webhook_signing_secret'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -843,6 +845,7 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
if (! $state) {
|
||||
$set('global_webhook_url', null);
|
||||
$set('webhook_events', ['visited']);
|
||||
$set('webhook_signing_secret', null);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -854,6 +857,15 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
TextInput::make('webhook_signing_secret')
|
||||
->label('Webhook Signing Secret')
|
||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('••••••••••••••••••••')
|
||||
->columnSpanFull()
|
||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||
|
||||
Select::make('webhook_events')
|
||||
->label(__('filament-short-url::default.settings_webhook_events'))
|
||||
->helperText(__('filament-short-url::default.settings_webhook_events_helper'))
|
||||
@@ -947,6 +959,20 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
->title(__('filament-short-url::default.settings_saved'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
if ($newKeys = session()->get('fsu_new_api_keys')) {
|
||||
foreach ($newKeys as $newKey) {
|
||||
Notification::make()
|
||||
->title(__('filament-short-url::default.api_key_generated') ?? 'API Key Generated')
|
||||
->body(new HtmlString('<strong>'.$newKey['name'].'</strong>: '.(__('filament-short-url::default.api_key_warning') ?? "Please copy this key now. You won't be able to see it again!")."<br/><code style='background:#e4e4e7;color:#18181b;padding:6px;border-radius:4px;user-select:all;display:block;margin-top:6px;font-family:monospace;font-weight:bold;'>".$newKey['plain'].'</code>'))
|
||||
->warning()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
// Re-mount settings form state to reflect the new masked key names in the UI
|
||||
$this->mount();
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
|
||||
@@ -596,4 +596,3 @@ class ShortUrlForm
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
@@ -48,7 +55,7 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
ShortUrlPixelResource::class,
|
||||
])
|
||||
->pages([
|
||||
\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage::class,
|
||||
ShortUrlSettingsPage::class,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
|
||||
@@ -41,6 +47,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
'2026_06_03_120000_add_track_browser_language_to_short_urls_table',
|
||||
'2026_06_03_150000_create_short_url_pixels_table',
|
||||
'2026_06_03_160000_add_auto_open_app_mobile_to_short_urls_table',
|
||||
'2026_06_04_000000_create_short_url_settings_table',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -65,50 +66,12 @@ class ShortUrlApiController extends Controller
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
// Keep for backward compatibility
|
||||
'pixel_meta_id' => 'nullable|string|max:100',
|
||||
'pixel_google_id' => 'nullable|string|max:100',
|
||||
'pixel_linkedin_id' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
// Backward compatibility support for old pixel columns
|
||||
if (! empty($validated['pixel_meta_id'])) {
|
||||
$metaPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'meta',
|
||||
'pixel_id' => $validated['pixel_meta_id'],
|
||||
], [
|
||||
'name' => 'Meta Pixel (' . $validated['pixel_meta_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $metaPixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_google_id'])) {
|
||||
$googlePixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'google',
|
||||
'pixel_id' => $validated['pixel_google_id'],
|
||||
], [
|
||||
'name' => 'Google Tag (' . $validated['pixel_google_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $googlePixelId;
|
||||
}
|
||||
|
||||
if (! empty($validated['pixel_linkedin_id'])) {
|
||||
$linkedinPixelId = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::firstOrCreate([
|
||||
'type' => 'linkedin',
|
||||
'pixel_id' => $validated['pixel_linkedin_id'],
|
||||
], [
|
||||
'name' => 'LinkedIn Insight (' . $validated['pixel_linkedin_id'] . ')',
|
||||
'is_active' => true,
|
||||
])->id;
|
||||
$pixelIds[] = $linkedinPixelId;
|
||||
}
|
||||
|
||||
// Clean up parameters that shouldn't be mass assigned
|
||||
unset($validated['pixel_meta_id'], $validated['pixel_google_id'], $validated['pixel_linkedin_id'], $validated['pixels']);
|
||||
unset($validated['pixels']);
|
||||
|
||||
$shortUrl = $this->service->create($validated);
|
||||
|
||||
@@ -157,9 +120,6 @@ class ShortUrlApiController extends Controller
|
||||
'max_visits' => $link->max_visits ? (int) $link->max_visits : null,
|
||||
'activated_at' => $link->activated_at ? $link->activated_at->toIso8601String() : null,
|
||||
'expires_at' => $link->expires_at ? $link->expires_at->toIso8601String() : null,
|
||||
'pixel_meta_id' => $pixels->where('type', 'meta')->first()?->pixel_id,
|
||||
'pixel_google_id' => $pixels->where('type', 'google')->first()?->pixel_id,
|
||||
'pixel_linkedin_id' => $pixels->where('type', 'linkedin')->first()?->pixel_id,
|
||||
'webhook_url' => $link->webhook_url,
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
|
||||
@@ -73,13 +73,27 @@ class ShortUrlRedirectController extends Controller
|
||||
$sessionKey = "short-url-auth-{$shortUrl->id}";
|
||||
if (! session()->get($sessionKey)) {
|
||||
if ($request->isMethod('POST')) {
|
||||
// Password brute force protection
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts
|
||||
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
|
||||
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
|
||||
$submitted = $request->input('password');
|
||||
if ($submitted === $shortUrl->password) {
|
||||
session()->put($sessionKey, true);
|
||||
RateLimiter::clear($passwordLimiterKey);
|
||||
|
||||
return redirect()->to($request->fullUrl());
|
||||
}
|
||||
|
||||
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
|
||||
|
||||
$errors = new MessageBag([
|
||||
'password' => __('filament-short-url::default.password_error') ?? 'Incorrect password.',
|
||||
]);
|
||||
@@ -105,7 +119,7 @@ class ShortUrlRedirectController extends Controller
|
||||
$matchedApp = AppLinkingEngine::matchApp($destination);
|
||||
if ($matchedApp !== null) {
|
||||
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
$activePixels = $shortUrl->pixels->where('is_active', true);
|
||||
|
||||
return response(view('filament-short-url::app-redirect', [
|
||||
'destination' => $destination,
|
||||
@@ -189,7 +203,7 @@ class ShortUrlRedirectController extends Controller
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}");
|
||||
}
|
||||
|
||||
$activePixels = $shortUrl->pixels()->where('is_active', true)->get();
|
||||
$activePixels = $shortUrl->pixels->where('is_active', true);
|
||||
|
||||
if ($activePixels->isNotEmpty()) {
|
||||
return response(view('filament-short-url::pixel-loading', [
|
||||
|
||||
@@ -38,10 +38,26 @@ class AuthenticateShortUrlApi
|
||||
$keys = $mgr->get('api_keys', []);
|
||||
|
||||
$valid = false;
|
||||
$hashedInput = hash('sha256', $apiKey);
|
||||
|
||||
foreach ($keys as $keyObj) {
|
||||
if (($keyObj['key'] ?? '') === $apiKey && (bool) ($keyObj['is_active'] ?? false)) {
|
||||
$valid = true;
|
||||
break;
|
||||
if (! (bool) ($keyObj['is_active'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check hashed key (new format) or fall back to plaintext key (legacy format)
|
||||
$storedHash = $keyObj['hashed_key'] ?? null;
|
||||
if ($storedHash) {
|
||||
if (hash_equals($storedHash, $hashedInput)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$storedPlain = $keyObj['key'] ?? '';
|
||||
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
|
||||
$valid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,19 @@ class SendWebhookJob implements ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
|
||||
];
|
||||
|
||||
$secret = config('filament-short-url.webhook_signing_secret');
|
||||
if (! empty($secret)) {
|
||||
$payloadJson = json_encode($this->payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$headers['X-ShortUrl-Signature'] = hash_hmac('sha256', $payloadJson, $secret);
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'wYachts-ShortUrl-Webhook/1.5',
|
||||
])
|
||||
->withHeaders($headers)
|
||||
->post($this->url, $this->payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
|
||||
@@ -14,7 +14,6 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Queued job for recording short URL visits.
|
||||
@@ -170,8 +169,17 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
$apiSecret = config('filament-short-url.ga4.api_secret');
|
||||
$firebaseAppId = config('filament-short-url.ga4.firebase_app_id');
|
||||
|
||||
// GA4 Measurement Protocol requires a client_id
|
||||
$clientId = Str::uuid()->toString();
|
||||
// GA4 Measurement Protocol requires a client_id. We generate a deterministic UUID
|
||||
// based on the visitor IP and User Agent to allow sessions/retention tracking
|
||||
// without violating privacy (the IP and UA are hashed and cannot be reversed).
|
||||
$hash = md5($this->ipAddress.'|'.$this->userAgent);
|
||||
$clientId = sprintf('%08s-%04s-%04s-%04s-%12s',
|
||||
substr($hash, 0, 8),
|
||||
substr($hash, 8, 4),
|
||||
substr($hash, 12, 4),
|
||||
substr($hash, 16, 4),
|
||||
substr($hash, 20, 12)
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'client_id' => $clientId,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
@@ -176,13 +182,13 @@ class ShortUrl extends Model
|
||||
$ttl = config('filament-short-url.cache_ttl', 3600);
|
||||
|
||||
if ($ttl <= 0) {
|
||||
return static::where('url_key', $key)->first();
|
||||
return static::where('url_key', $key)->with('pixels')->first();
|
||||
}
|
||||
|
||||
return cache()->remember(
|
||||
"filament-short-url:{$key}",
|
||||
$ttl,
|
||||
fn () => static::where('url_key', $key)->first()
|
||||
fn () => static::where('url_key', $key)->with('pixels')->first()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,9 +200,11 @@ class ShortUrl extends Model
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $m) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of single-use URLs
|
||||
if ($m->single_use || $m->max_visits !== null || $m->expires_at !== null) {
|
||||
if ($m->single_use) {
|
||||
$m->max_visits = null;
|
||||
}
|
||||
$m->redirect_status_code = 302; // Force temporary redirect to prevent browser caching of limited/expiring URLs
|
||||
}
|
||||
|
||||
if ($m->activated_at === null && $m->expires_at === null) {
|
||||
@@ -233,6 +241,7 @@ class ShortUrl extends Model
|
||||
|
||||
static::saved(function (self $m) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
cache()->forget("filament-short-url:visits:{$m->id}");
|
||||
|
||||
if ($m->wasChanged('url_key')) {
|
||||
$oldKey = $m->getOriginal('url_key');
|
||||
@@ -243,6 +252,7 @@ class ShortUrl extends Model
|
||||
});
|
||||
static::deleted(function (self $m) {
|
||||
cache()->forget("filament-short-url:{$m->url_key}");
|
||||
cache()->forget("filament-short-url:visits:{$m->id}");
|
||||
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
|
||||
|
||||
if (! empty($m->qr_logo)) {
|
||||
@@ -272,21 +282,17 @@ class ShortUrl extends Model
|
||||
|
||||
public function getRealTimeTotalVisits(): int
|
||||
{
|
||||
$buffered = 0;
|
||||
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
|
||||
$buffered = (int) cache()->get("{$prefix}total:{$this->id}", 0);
|
||||
|
||||
return $this->total_visits + $buffered;
|
||||
}
|
||||
|
||||
if ($this->max_visits !== null) {
|
||||
$dbVal = (int) DB::table($this->table)
|
||||
->where('id', $this->id)
|
||||
->value('total_visits');
|
||||
// Use real-time visit count in cache to keep the cached model instance updated
|
||||
$cacheKey = "filament-short-url:visits:{$this->id}";
|
||||
|
||||
return $dbVal + $buffered;
|
||||
}
|
||||
|
||||
return $this->total_visits + $buffered;
|
||||
return (int) cache()->remember($cacheKey, 3600, fn () => $this->total_visits);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
@@ -554,6 +560,18 @@ class ShortUrl extends Model
|
||||
$this->newQuery()
|
||||
->where('id', $this->id)
|
||||
->increment('total_visits', 1, $updates);
|
||||
|
||||
// Keep the real-time cache count incremented
|
||||
$cacheKey = "filament-short-url:visits:{$this->id}";
|
||||
try {
|
||||
if (cache()->has($cacheKey)) {
|
||||
cache()->increment($cacheKey);
|
||||
} else {
|
||||
cache()->put($cacheKey, $this->total_visits + 1, 3600);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore cache errors in increment to never disrupt redirection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
class AppLinkingEngine
|
||||
@@ -237,6 +243,6 @@ class AppLinkingEngine
|
||||
}
|
||||
}
|
||||
|
||||
return $scheme . $urlWithoutProtocol;
|
||||
return $scheme.$urlWithoutProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package janczakb/filament-short-url
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -25,25 +32,35 @@ class ProxyDetectionService
|
||||
}
|
||||
|
||||
$cacheKey = "short-url:proxy-check:{$ip}";
|
||||
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
|
||||
|
||||
return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) {
|
||||
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
|
||||
$timeout = (int) config('filament-short-url.vpn_detection.timeout', 2);
|
||||
if (Cache::has($cacheKey)) {
|
||||
return Cache::get($cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($driver === 'vpnapi') {
|
||||
return $this->queryVpnApi($ip, $timeout);
|
||||
}
|
||||
$driver = config('filament-short-url.vpn_detection.driver', 'ip-api');
|
||||
// Aggressive 800ms timeout to avoid hanging the redirect thread on API lag
|
||||
$timeout = 0.8;
|
||||
|
||||
// Default to ip-api
|
||||
return $this->queryIpApi($ip, $timeout);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("Proxy detection failed for IP: {$ip}. Error: ".$e->getMessage());
|
||||
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
try {
|
||||
if ($driver === 'vpnapi') {
|
||||
$result = $this->queryVpnApi($ip, $timeout);
|
||||
} else {
|
||||
$result = $this->queryIpApi($ip, $timeout);
|
||||
}
|
||||
});
|
||||
|
||||
$cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400);
|
||||
Cache::put($cacheKey, $result, $cacheTtl);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("Proxy detection failed or timed out for IP: {$ip}. Error: ".$e->getMessage());
|
||||
|
||||
// Temporary cache for failure (60 seconds) to allow retry and prevent whitelisting IPs long-term
|
||||
$failResult = ['is_proxy' => false, 'is_bot' => false];
|
||||
Cache::put($cacheKey, $failResult, 60);
|
||||
|
||||
return $failResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,14 +68,18 @@ class ProxyDetectionService
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryIpApi(string $ip, int $timeout): array
|
||||
private function queryIpApi(string $ip, float|int $timeout): array
|
||||
{
|
||||
$url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting";
|
||||
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed() || $response->json('status') === 'fail') {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
if ($response->json('status') === 'fail') {
|
||||
throw new \RuntimeException('API returned failure: '.$response->json('message'));
|
||||
}
|
||||
|
||||
$isProxy = (bool) $response->json('proxy', false);
|
||||
@@ -75,12 +96,12 @@ class ProxyDetectionService
|
||||
*
|
||||
* @return array{is_proxy: bool, is_bot: bool}
|
||||
*/
|
||||
private function queryVpnApi(string $ip, int $timeout): array
|
||||
private function queryVpnApi(string $ip, float|int $timeout): array
|
||||
{
|
||||
$key = config('filament-short-url.vpn_detection.vpnapi_key');
|
||||
|
||||
if (empty($key)) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
throw new \RuntimeException('vpnapi.io API key is empty.');
|
||||
}
|
||||
|
||||
$url = "https://vpnapi.io/api/{$ip}?key={$key}";
|
||||
@@ -88,7 +109,7 @@ class ProxyDetectionService
|
||||
$response = Http::timeout($timeout)->get($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
return ['is_proxy' => false, 'is_bot' => false];
|
||||
throw new \RuntimeException('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
$security = $response->json('security', []);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ShortUrlSettingsManager
|
||||
{
|
||||
@@ -25,22 +33,63 @@ class ShortUrlSettingsManager
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
$readFromFile = function () {
|
||||
$path = $this->getSettingsPath();
|
||||
if (File::exists($path)) {
|
||||
try {
|
||||
return json_decode(File::get($path), true) ?: [];
|
||||
} catch (\Throwable) {
|
||||
// Fallback to empty if json is corrupt
|
||||
$readFromDb = function () {
|
||||
try {
|
||||
// Ensure table exists before querying to avoid exception on unmigrated db
|
||||
if (! Schema::hasTable('short_url_settings')) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
$settings = DB::table('short_url_settings')
|
||||
->pluck('value', 'key')
|
||||
->toArray();
|
||||
|
||||
$decoded = [];
|
||||
foreach ($settings as $key => $val) {
|
||||
$decoded[$key] = json_decode($val, true);
|
||||
}
|
||||
|
||||
// If DB is empty, check if we can import legacy settings from JSON file
|
||||
if (empty($decoded)) {
|
||||
$legacyPath = $this->getSettingsPath();
|
||||
if (File::exists($legacyPath)) {
|
||||
try {
|
||||
$legacyContent = File::get($legacyPath);
|
||||
$legacySettings = json_decode($legacyContent, true) ?: [];
|
||||
|
||||
// Import each legacy setting to database
|
||||
foreach ($legacySettings as $k => $v) {
|
||||
DB::table('short_url_settings')->updateOrInsert(
|
||||
['key' => $k],
|
||||
['value' => json_encode($v), 'updated_at' => now(), 'created_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
// Rename the legacy file to avoid re-importing
|
||||
File::move($legacyPath, $legacyPath.'.bak');
|
||||
|
||||
return $legacySettings;
|
||||
} catch (\Throwable $e) {
|
||||
// Suppress import failures to prevent boot crash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback to empty if DB query fails during boot/installation
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the settings indefinitely (31536000 seconds = 1 year) in multi-server environments
|
||||
$stored = app()->bound('cache')
|
||||
? cache()->remember('filament-short-url:settings', 86400, $readFromFile)
|
||||
: $readFromFile();
|
||||
? cache()->remember('filament-short-url:settings', 31536000, $readFromDb)
|
||||
: $readFromDb();
|
||||
|
||||
if (! is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
|
||||
// Merge stored settings with default values from config()
|
||||
$this->cache = array_merge([
|
||||
@@ -100,6 +149,8 @@ class ShortUrlSettingsManager
|
||||
'deep_linking_enabled' => config('filament-short-url.deep_linking.enabled', false),
|
||||
'aasa_json' => config('filament-short-url.deep_linking.aasa_json'),
|
||||
'assetlinks_json' => config('filament-short-url.deep_linking.assetlinks_json'),
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret' => null,
|
||||
], $stored);
|
||||
|
||||
return $this->cache;
|
||||
@@ -111,19 +162,12 @@ class ShortUrlSettingsManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist settings to JSON file.
|
||||
* Persist settings to the database.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function set(array $data): void
|
||||
{
|
||||
$path = $this->getSettingsPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! File::isDirectory($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
$oldPrefix = $this->get('route_prefix');
|
||||
|
||||
// Keep only supported settings keys to prevent bloat
|
||||
@@ -184,6 +228,8 @@ class ShortUrlSettingsManager
|
||||
'deep_linking_enabled',
|
||||
'aasa_json',
|
||||
'assetlinks_json',
|
||||
// Webhook signing secret
|
||||
'webhook_signing_secret',
|
||||
];
|
||||
|
||||
$filtered = array_intersect_key($data, array_flip($keys));
|
||||
@@ -287,8 +333,53 @@ class ShortUrlSettingsManager
|
||||
$filtered['deep_linking_enabled'] = (bool) $filtered['deep_linking_enabled'];
|
||||
}
|
||||
|
||||
File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
cache()->forget('filament-short-url:settings');
|
||||
$newKeys = [];
|
||||
if (isset($filtered['api_keys']) && is_array($filtered['api_keys'])) {
|
||||
foreach ($filtered['api_keys'] as &$keyObj) {
|
||||
$key = $keyObj['key'] ?? '';
|
||||
if (str_starts_with($key, 'sh_key_')) {
|
||||
$plainKey = $key;
|
||||
$hashed = hash('sha256', $plainKey);
|
||||
$masked = substr($plainKey, 0, 11).'••••'.substr($plainKey, -4);
|
||||
|
||||
$keyObj['hashed_key'] = $hashed;
|
||||
$keyObj['key'] = $masked;
|
||||
|
||||
$newKeys[] = [
|
||||
'name' => $keyObj['name'] ?? 'API Key',
|
||||
'plain' => $plainKey,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (! empty($newKeys)) {
|
||||
session()->flash('fsu_new_api_keys', $newKeys);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (Schema::hasTable('short_url_settings')) {
|
||||
DB::transaction(function () use ($filtered) {
|
||||
// Update or insert each key
|
||||
foreach ($filtered as $key => $val) {
|
||||
DB::table('short_url_settings')->updateOrInsert(
|
||||
['key' => $key],
|
||||
['value' => json_encode($val), 'updated_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
// Delete settings from the database that are no longer in keys (e.g. removed features)
|
||||
DB::table('short_url_settings')
|
||||
->whereNotIn('key', array_keys($filtered))
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore / log database persist errors
|
||||
}
|
||||
|
||||
if (app()->bound('cache')) {
|
||||
cache()->forget('filament-short-url:settings');
|
||||
}
|
||||
$this->cache = null;
|
||||
|
||||
// Apply immediately to current request config
|
||||
@@ -371,6 +462,8 @@ class ShortUrlSettingsManager
|
||||
'filament-short-url.deep_linking.enabled' => (bool) ($settings['deep_linking_enabled'] ?? false),
|
||||
'filament-short-url.deep_linking.aasa_json' => $settings['aasa_json'] ?? null,
|
||||
'filament-short-url.deep_linking.assetlinks_json' => $settings['assetlinks_json'] ?? null,
|
||||
// Webhook signing secret
|
||||
'filament-short-url.webhook_signing_secret' => $settings['webhook_signing_secret'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Services;
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,12 +74,19 @@ it('allows API requests with a valid key', function () {
|
||||
it('allows creating a short link programmatically via POST', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
|
||||
$pixel = \Bjanczak\FilamentShortUrl\Models\ShortUrlPixel::create([
|
||||
'name' => 'Meta Pixel Test',
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
'notes' => 'API Generated link',
|
||||
'single_use' => true,
|
||||
'pixel_meta_id' => '12345',
|
||||
'pixels' => [$pixel->id],
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_active_token',
|
||||
@@ -95,9 +102,10 @@ it('allows creating a short link programmatically via POST', function () {
|
||||
'webhook_url' => 'https://webhook.site/test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('short_url_pixels', [
|
||||
'type' => 'meta',
|
||||
'pixel_id' => '12345',
|
||||
$shortUrl = ShortUrl::where('url_key', 'googleapi')->first();
|
||||
$this->assertDatabaseHas('short_url_pixel', [
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'pixel_id' => $pixel->id,
|
||||
]);
|
||||
|
||||
// SendWebhookJob should be dispatched for 'created' event since custom webhook_url is set
|
||||
|
||||
225
tests/Feature/ShortUrlCoreOptimizationTest.php
Normal file
225
tests/Feature/ShortUrlCoreOptimizationTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up any test configurations
|
||||
cache()->forget('filament-short-url:settings');
|
||||
app(ShortUrlSettingsManager::class)->set(['route_prefix' => 's']);
|
||||
});
|
||||
|
||||
it('migrates settings to database and caches them', function () {
|
||||
// 1. Verify schema has table
|
||||
expect(Schema::hasTable('short_url_settings'))->toBeTrue();
|
||||
|
||||
// 2. Write setting and verify it exists in db
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
$mgr->set(['route_prefix' => 'custom-prefix']);
|
||||
|
||||
$this->assertDatabaseHas('short_url_settings', [
|
||||
'key' => 'route_prefix',
|
||||
'value' => json_encode('custom-prefix'),
|
||||
]);
|
||||
|
||||
// 3. Clear cache manually and verify it still reads correctly
|
||||
cache()->forget('filament-short-url:settings');
|
||||
expect($mgr->get('route_prefix'))->toBe('custom-prefix');
|
||||
});
|
||||
|
||||
it('forces 302 redirect status code for limited/expiring links', function () {
|
||||
// Case A: Link with expires_at
|
||||
$url1 = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/expiring',
|
||||
'url_key' => 'expiringlink',
|
||||
'expires_at' => now()->addDays(5),
|
||||
'redirect_status_code' => 301, // User requests 301
|
||||
]);
|
||||
expect($url1->redirect_status_code)->toBe(302); // Enforced to 302
|
||||
|
||||
// Case B: Link with max_visits
|
||||
$url2 = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/limited',
|
||||
'url_key' => 'limitedlink',
|
||||
'max_visits' => 100,
|
||||
'redirect_status_code' => 301,
|
||||
]);
|
||||
expect($url2->redirect_status_code)->toBe(302); // Enforced to 302
|
||||
});
|
||||
|
||||
it('generates a deterministic privacy-safe GA4 client ID', function () {
|
||||
$job1 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job2 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||
);
|
||||
|
||||
$job3 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
ipAddress: '8.8.8.8',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'
|
||||
);
|
||||
|
||||
// Use reflection to access the private sendGa4Hit method or verify GA4 client ID determination
|
||||
$ref = new ReflectionClass(TrackShortUrlVisitJob::class);
|
||||
$method = $ref->getMethod('sendGa4Hit');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/ga4',
|
||||
'url_key' => 'ga4test',
|
||||
'ga_tracking_id' => 'G-12345',
|
||||
]);
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'ga4_api_secret' => 'secret_secret_123',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$visit = new ShortUrlVisit([
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'browser' => 'Chrome',
|
||||
]);
|
||||
|
||||
// Send first hit
|
||||
$method->invoke($job1, $shortUrl, $visit);
|
||||
// Send second hit (same IP/UA)
|
||||
$method->invoke($job2, $shortUrl, $visit);
|
||||
// Send third hit (different IP/UA)
|
||||
$method->invoke($job3, $shortUrl, $visit);
|
||||
|
||||
$requests = Http::recorded();
|
||||
expect($requests)->toHaveCount(3);
|
||||
|
||||
$body1 = json_decode($requests[0][0]->body(), true);
|
||||
$body2 = json_decode($requests[1][0]->body(), true);
|
||||
$body3 = json_decode($requests[2][0]->body(), true);
|
||||
|
||||
// Client ID 1 and 2 must match exactly since they originate from the same user (deterministic hash)
|
||||
expect($body1['client_id'])->toBe($body2['client_id']);
|
||||
|
||||
// Client ID 3 must be different
|
||||
expect($body1['client_id'])->not->toBe($body3['client_id']);
|
||||
});
|
||||
|
||||
it('signs outgoing webhooks with X-ShortUrl-Signature when secret is configured', function () {
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'webhook_signing_secret' => 'my-signing-secret-key-123',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
url: 'https://webhook.site/my-endpoint',
|
||||
event: 'visited',
|
||||
payload: ['click_id' => 99, 'short_url_id' => 1]
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
expect($request->hasHeader('X-ShortUrl-Signature'))->toBeTrue();
|
||||
|
||||
$secret = 'my-signing-secret-key-123';
|
||||
$payloadJson = json_encode(['click_id' => 99, 'short_url_id' => 1], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$expectedSignature = hash_hmac('sha256', $payloadJson, $secret);
|
||||
|
||||
expect($request->header('X-ShortUrl-Signature')[0])->toBe($expectedSignature);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('runs database-level daily stats aggregation successfully', function () {
|
||||
// 1. Seed raw visits for a previous day
|
||||
$yesterday = Carbon::yesterday()->toDateString();
|
||||
|
||||
$link = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/aggregate',
|
||||
'url_key' => 'aggkey',
|
||||
]);
|
||||
|
||||
DB::table('short_url_visits')->insert([
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'),
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'macOS',
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Warsaw',
|
||||
'referer_host' => 'google.com',
|
||||
'visited_at' => $yesterday.' 10:00:00',
|
||||
'is_qr_scan' => false,
|
||||
'browser_language' => 'pl',
|
||||
],
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '1.1.1.1'), // same user
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'macOS',
|
||||
'device_type' => 'desktop',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Warsaw',
|
||||
'referer_host' => 'google.com',
|
||||
'visited_at' => $yesterday.' 11:00:00',
|
||||
'is_qr_scan' => false,
|
||||
'browser_language' => 'pl',
|
||||
],
|
||||
[
|
||||
'short_url_id' => $link->id,
|
||||
'ip_hash' => hash('sha256', '2.2.2.2'), // unique user, QR scan
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'iOS',
|
||||
'device_type' => 'mobile',
|
||||
'country' => 'Poland',
|
||||
'country_code' => 'PL',
|
||||
'city' => 'Krakow',
|
||||
'referer_host' => 'facebook.com',
|
||||
'visited_at' => $yesterday.' 12:00:00',
|
||||
'is_qr_scan' => true,
|
||||
'browser_language' => 'en',
|
||||
],
|
||||
]);
|
||||
|
||||
// Run the aggregation command
|
||||
$this->artisan('short-url:aggregate-and-prune')
|
||||
->assertExitCode(0);
|
||||
|
||||
// Verify statistics entry in database
|
||||
$this->assertDatabaseHas('short_url_daily_stats', [
|
||||
'short_url_id' => $link->id,
|
||||
'date' => $yesterday,
|
||||
'visits_count' => 3,
|
||||
'unique_visits_count' => 2,
|
||||
'qr_visits_count' => 1,
|
||||
]);
|
||||
|
||||
$stats = ShortUrlDailyStats::where('short_url_id', $link->id)
|
||||
->where('date', $yesterday)
|
||||
->first();
|
||||
|
||||
expect($stats->device_stats)->toBe(['desktop' => 2, 'mobile' => 1])
|
||||
->and($stats->browser_stats)->toBe(['Chrome' => 2, 'Safari' => 1])
|
||||
->and($stats->os_stats)->toBe(['macOS' => 2, 'iOS' => 1])
|
||||
->and($stats->country_stats)->toBe(['Poland' => 3])
|
||||
->and($stats->city_stats)->toBe(['Warsaw (PL)' => 2, 'Krakow (PL)' => 1])
|
||||
->and($stats->language_stats)->toBe(['pl' => 2, 'en' => 1]);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -129,7 +130,7 @@ it('clears deep linking cache when settings page save is triggered', function ()
|
||||
});
|
||||
|
||||
it('redirects to the intermediate app-redirect page when auto_open_app_mobile is enabled and visited from mobile', function () {
|
||||
$shortUrl = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlService::class)->create([
|
||||
$shortUrl = app(ShortUrlService::class)->create([
|
||||
'destination_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'url_key' => 'ytmobile',
|
||||
'auto_open_app_mobile' => true,
|
||||
|
||||
@@ -348,6 +348,25 @@ it('requires password to redirect when protected', function () {
|
||||
$this->get('/s/password123')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('applies rate limiting on password attempts when protected', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'password-ratelimit',
|
||||
'password' => 'secret-combination',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// First 5 incorrect attempts should return 200 (renders password prompt again)
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
|
||||
->assertStatus(200)
|
||||
->assertSee('Incorrect password');
|
||||
}
|
||||
|
||||
// 6th incorrect attempt should be rate limited (429)
|
||||
$this->post('/s/password-ratelimit', ['password' => 'wrong-pass'])
|
||||
->assertStatus(429);
|
||||
});
|
||||
|
||||
it('shows warning page before redirecting when enabled', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'warn1',
|
||||
@@ -684,4 +703,3 @@ it('renders custom branded expired view on deactivated URL', function () {
|
||||
$response->assertSee('Link Inactive or Expired');
|
||||
$response->assertSee('Go to Homepage');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user