Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043920836d | ||
|
|
9989fb938b | ||
|
|
73b8b875a8 | ||
|
|
9493431017 | ||
|
|
822a3e6fa3 | ||
|
|
5b2eb35a0b | ||
|
|
964a7364ef | ||
|
|
ba601dcf1e | ||
|
|
e4ef194420 | ||
|
|
53a3c1138d | ||
|
|
4cef5de366 | ||
|
|
e0602e80ef | ||
|
|
3592828ab3 | ||
|
|
7082db495d | ||
|
|
f73803017f |
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# GitHub Sponsors
|
||||
github: [janczakb]
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
66
LICENSE
@@ -1,22 +1,58 @@
|
||||
Custom Source-Available License
|
||||
Custom Source-Available & Dual-Commercial License
|
||||
|
||||
Copyright (c) 2026 Bartek Janczak
|
||||
Copyright (c) 2026 Bartek Janczak. All rights reserved.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
IMPORTANT: PLEASE READ THIS LICENSE AGREEMENT CAREFULLY. BY DOWNLOADING, INSTALLING, OR USING THE SOFTWARE, YOU AGREE TO BE BOUND BY ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, OR USE 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.
|
||||
-------------------------------------------------------------------------------
|
||||
1. DEFINITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
"Software" means the "Short URL Manager" PHP/Filament package, including all source code, modules, documentation, migrations, updates, and modified versions thereof.
|
||||
"Copyright Holder" means Bartek Janczak.
|
||||
"Commercial Distribution" means selling, renting, leasing, sublicensing, or otherwise distributing the Software (in whole or in part, or embedded into a larger work, boilerplate, theme, SaaS platform, or application) to third parties for monetary gain, fees, or any commercial consideration.
|
||||
"Internal Commercial Use" means deploying and using the Software within your own business operations or building a custom website/system for a single client, where the client does not resell the underlying code.
|
||||
|
||||
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.
|
||||
-------------------------------------------------------------------------------
|
||||
2. GRANT OF FREE LICENSE (NON-DISTRIBUTION ONLY)
|
||||
-------------------------------------------------------------------------------
|
||||
Subject to compliance with Section 3 (Restrictions), the Copyright Holder hereby grants you a worldwide, royalty-free, non-exclusive, non-sublicensable license to:
|
||||
a) Download, install, and use the Software free of charge for personal, educational, internal business purposes, or Internal Commercial Use across an unlimited number of projects.
|
||||
b) Modify the Software solely for your own private, internal, or Internal Commercial Use, provided that any such modifications are kept private and are never distributed publicly.
|
||||
|
||||
3. Copyright Notice
|
||||
The above copyright notice and this permission notice must be included in all copies or substantial portions of the Software.
|
||||
-------------------------------------------------------------------------------
|
||||
3. STRICT RESTRICTIONS & PROHIBITIONS
|
||||
-------------------------------------------------------------------------------
|
||||
Unless you have obtained an explicit, written Commercial License from the Copyright Holder pursuant to Section 4, you are STRICTLY PROHIBITED from:
|
||||
a) Public Redistribution: Publishing, hosting, or sharing 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 public package registries (such as Packagist).
|
||||
b) Commercial Exploitation & Bundling: Engaging in any Commercial Distribution. You may not bundle, embed, or include the Software, in whole or in part, or any modified versions thereof, into any product, theme, extension, boilerplate, framework, or software application that is offered for sale, license, subscription, or commercial distribution to third parties.
|
||||
c) Relabeling: Publishing, distributing, or sharing the Software under a different author, vendor, or package name.
|
||||
d) Sublicensing: Granting any third party the right to resell, redistribute, or further sublicense the Software.
|
||||
|
||||
4. Disclaimer of Warranty
|
||||
-------------------------------------------------------------------------------
|
||||
4. DUAL-LICENSING & COMMERCIAL LICENSING REQUIREMENT
|
||||
-------------------------------------------------------------------------------
|
||||
Any individual, entity, or organization wishing to engage in Commercial Distribution, or use the Software in a manner not expressly permitted by Section 2, MUST secure a separate, written Commercial License from the Copyright Holder prior to such use or distribution.
|
||||
|
||||
To inquire about purchasing a Commercial License or to request written authorization, please contact the Copyright Holder via email at: barek122@gmail.com
|
||||
|
||||
Commercial Licenses may be subject to a licensing fee, revenue-sharing model, or other terms as agreed upon in writing by the Copyright Holder. Any Commercial Distribution attempted without a valid, active, written Commercial License from the Copyright Holder is null and void, constitutes a material breach of this Agreement, and is a direct infringement of international copyright laws.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
5. INTELLECTUAL PROPERTY & COPYRIGHT NOTICE
|
||||
-------------------------------------------------------------------------------
|
||||
The Software is licensed, not sold. The Copyright Holder retains all right, title, and interest in and to the Software, including all copyrights, patents, trade secrets, and other intellectual property rights. The original copyright notice, author attribution, and this entire permission notice must be included in all copies, substantial portions, or private forks of the Software.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
6. TERMINATION
|
||||
-------------------------------------------------------------------------------
|
||||
This License and your right to use the Software will terminate automatically and immediately, without notice from the Copyright Holder, if you fail to comply with any of its terms or restrictions. Upon termination, you must immediately cease all use of the Software and destroy all copies, full or partial, in your possession.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
7. GOVERNING LAW AND JURISDICTION
|
||||
-------------------------------------------------------------------------------
|
||||
This License Agreement shall be governed by, and construed in accordance with, the substantive laws of Poland, without regard to its conflict of laws principles. Any legal action, suit, or proceeding arising out of or relating to this License shall be instituted exclusively in the competent courts located in Warsaw, Poland.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
8. 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.
|
||||
|
||||
180
README.md
@@ -1,11 +1,13 @@
|
||||
<p align="center">
|
||||
<img src="https://cdn3d.iconscout.com/3d/premium/thumb/url-3d-icon-png-download-9292351.png" width="180" alt="Filament Short URL Logo">
|
||||
<img src="art/logo-fism.png" width="180" alt="Filament Short URL — Laravel link shortener plugin for Filament">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Filament Short URL</h1>
|
||||
|
||||
<p align="center"><strong>The most complete open-source link management plugin for Laravel & Filament.</strong><br>Short URLs · QR codes · Analytics · Targeting · Deep links · Webhooks · REST API</p>
|
||||
|
||||
<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://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 on Packagist"></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>
|
||||
@@ -13,32 +15,33 @@
|
||||
<a href="https://github.com/janczakb/filament-short-url/actions"><img src="https://img.shields.io/badge/tests-passing-success.svg?style=flat-square" alt="Tests"></a>
|
||||
</p>
|
||||
|
||||
A professional, high-performance **Short URL Manager, Redirect Engine & QR Code Generator** plugin for [Filament v5](https://filamentphp.com).
|
||||
A self-hosted **URL shortener, redirect engine, and QR code manager** built as a [Filament v5](https://filamentphp.com) plugin. Drop it into any Laravel 11+ application to replace Bitly, Dub.co, or Rebrandly subscriptions — with full data ownership, no click limits, and no monthly fees.
|
||||
|
||||
Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, this package provides advanced link shortening, mobile app deep linking (Universal Links & Android App Links), client-side retargeting pixels, offline Geo-IP country routing, real-time analytics, and webhooks—with zero external API dependencies.
|
||||
On top of basic link shortening it ships: multi-channel analytics with live activity feed, cross-filtering, and cross-domain retargeting pixels; advanced routing rules (device, country, language, A/B); mobile deep linking into 24+ native apps; a REST API with scoped keys; HMAC-signed webhooks; and offline GDPR-safe Geo-IP — all managed from a polished Filament admin panel.
|
||||
|
||||
### Why choose Filament Short URL?
|
||||
* **Save on SaaS Costs**: Replace expensive Bitly or Rebrandly subscriptions with a self-hosted solution that has zero click or creation limits.
|
||||
* **Custom Domain Branding**: Connect your own custom domains with dynamic DNS checks and automatic routing redirection at the root level.
|
||||
* **Server-Side GA4 Tracking**: Bypasses browser-side ad blockers completely to ensure 100% accurate traffic data.
|
||||
* **Retarget on External Sites**: Inject tracking pixels (Meta, Google Ads, LinkedIn, TikTok, Pinterest) on a beautiful glassmorphic redirect page before forwarding visitors.
|
||||
* **Seamless Mobile App Redirects**: Launch native mobile applications (Instagram, YouTube, Spotify, WhatsApp) automatically using deep links.
|
||||
* **GDPR-Friendly Geo-IP**: Resolve visitor countries offline using local MaxMind databases or trust your CDN headers (Cloudflare, CloudFront).
|
||||
## Video Walkthrough
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/edeca20a-2265-435e-a7f3-a32773c48bda" width="100%" autoplay loop muted controls style="max-width: 100%; border-radius: 8px;"></video>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 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/v2_1.png" alt="Dashboard Stats" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_2.png" alt="Short URL Creation Form" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_1.png" alt="Analytics dashboard" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_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/v2_3.png" alt="Targeting and Rules" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v2_4.png" alt="Interactive Settings Panel" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_3.png" alt="Smart targeting rules" style="border-radius: 8px;"></td>
|
||||
<td width="50%" style="border: none; padding: 5px;"><img src="art/v3_4.png" alt="Settings panel" style="border-radius: 8px;"></td>
|
||||
</tr>
|
||||
<tr style="border: none;">
|
||||
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v2_5.png" alt="Visit Logs Table" style="border-radius: 8px; width: 100%;"></td>
|
||||
<td colspan="2" style="border: none; padding: 5px;"><img src="art/v3_5.png" alt="Visit logs table" style="border-radius: 8px; width: 100%;"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</p>
|
||||
@@ -47,41 +50,66 @@ Acting as a self-hosted, enterprise-grade alternative to Bitly and Rebrandly, th
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Base62 Short Link Generation** — Create clean, custom short links or let the system auto-generate collision-free Base62 keys.
|
||||
- 🌐 **Custom Domain Branding (new in v4.0.0)** — Register custom domains, show dynamic DNS setup instructions (A/CNAME records), verify records in real-time, and route redirects directly at the root of verified custom domains.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — Route and analyze traffic with offline MaxMind detection, CDN edge headers (Cloudflare's `CF-IPCountry`, CloudFront), or fallback APIs.
|
||||
- 🗺️ **Interactive Visitor World Map** — Showcase geographic click distribution on a beautiful SVG world map widget with real-time hover details.
|
||||
- 📈 **Comprehensive Analytics Dashboard** — Monitor total/unique visits, referrers, operating systems, devices, browsers, and top browser languages in real-time with full cross-filtering (e.g., "show only mobile visits from Poland").
|
||||
- ⚡ **Live Activity Feed (new in v5.0.0)** — Dedicated real-time visit stream page at `/stats/live`. Polls only when new visits are detected (O(1) `MAX(id)` check + `skipRender()` on no-change), serving `~100-byte` responses when idle. Flags via `flagcdn.com`, precomputed in PHP, CSP-safe Alpine.js fallback.
|
||||
- 🗂️ **Folders & Tags (new in v5.0.0)** — Organize links into folders (one folder per link) and tag them with up to 5 labels. Navigate directly from a folder or tag to a filtered link list. Link counts displayed on each folder and tag card.
|
||||
- 🗄️ **Link Archiving (new in v5.0.0)** — Archive unused links instead of permanently deleting them. Archived links are hidden from the default view but can be restored at any time.
|
||||
- 🛡️ **VPN, Proxy & Bot Filtering** — Exclude scrapers, crawlers, Tor exit nodes, and automated bot clicks to keep your analytics clean and accurate.
|
||||
- 🔍 **Google Safe Browsing** — Automatically scan target URLs on creation/edit to block phishing, malware, and social engineering links.
|
||||
- 🎨 **SVG QR Code Designer** — Customize dot styles, gradients, margins, and upload custom brand logos with auto-clear backing dots and high-quality SVG/PNG exports.
|
||||
- 📊 **Dedicated QR Code Tracking** — Differentiate physical QR scans from direct web clicks with automatically appended query parameters (`?source=qr`).
|
||||
- 🌐 **Browser Language Targeting** — Route visitors dynamically based on their browser language preferences (e.g., redirect Polish speakers to a Polish landing page).
|
||||
- ⚡ **Ultra-Fast Redirections** — Redirections resolve in milliseconds. Logging, GA4 payloads, and webhooks are processed asynchronously in the background.
|
||||
- 🎯 **Server-Side GA4 Integration** — Send server-side `short_url_visit` hits using the Google Analytics 4 Measurement Protocol to bypass ad-blockers.
|
||||
- ⚙️ **Dual-way UTM Builder** — Build campaign URLs with a real-time synchronized UTM builder directly in the link creation form.
|
||||
- 🔒 **Link Expiration & Fallbacks** — Set activation date ranges, click-limit caps, single-use restrictions, and custom redirection fallbacks on expiration.
|
||||
- ➡️ **Query Parameter Forwarding** — Automatically forward client query strings (e.g., ad tokens, UTM parameters, discount codes) to the destination.
|
||||
- 🛠️ **Central Settings Panel** — Manage routes, Geo-IP, GA4, caching, rate limiting, and retention directly inside your Filament panel.
|
||||
- 🔑 **Password-Protected Links** — Secure sensitive links with a customizable, session-based password entry interstitial.
|
||||
- ⚠️ **Redirect Warning Interstitials** — Show a security warning page to verify external links (phishing and NSFW protection).
|
||||
- 🎯 **Advanced Smart Targeting** — Redirect visitors dynamically based on device type (iOS, Android, Desktop), country (Geo-IP), or browser language.
|
||||
- ⚖️ **A/B Split Testing Rotation** — Distribute traffic randomly across multiple landing pages using custom weighted rotation rules.
|
||||
- 🛡️ **Throttling & Rate Limiting** — Protect your redirection routes from flood attacks with configurable per-IP rate limits.
|
||||
- 📊 **Log Aggregation & Pruning** — Compact millions of raw visit logs into daily summaries automatically to prevent database bloat.
|
||||
- 🎯 **Central Retargeting Pixel Registry (new in v3.0.0)** — Register Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, and Pinterest Tag centrally and associate them with links via checkboxes.
|
||||
- 🔌 **Developer REST API with Scoped Keys (updated in v5.0.0)** — Full programmatical control with API Key authentication. Each key supports `links:read-only` or `links:read-write` scope, and an individual per-key rate limit (requests/minute). Create, read, update, list, delete, and inspect analytics for short links externally.
|
||||
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
|
||||
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
|
||||
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
|
||||
- 🎨 **Branded Expiry Pages (new in v3.0.0)** — Display a premium, dark-mode compatible custom expiry page when a link is deactivated or limit-reached, falling back to a clean site-name greeting instead of a generic browser error.
|
||||
- 👤 **User Attribution (new in v3.5.0)** — Automatically assign the authenticated user's ID to every new short URL. Show their avatar and hover card (name + email) directly in the list table.
|
||||
- 🕒 **Relative Time Badges (new in v3.5.0)** — Display compact relative timestamps (e.g., `2h`, `5d`, `3mo`) for link creation dates with rich Alpine.js hover popovers showing precise absolute dates and timezone offsets.
|
||||
- ⌨️ **Keyboard Shortcuts (new in v3.5.0)** — Hover over any table row to enable shortcut keys: `E` (edit), `Q` (QR code), `I` (share/copy), `S` (statistics), `X` (delete). Shortcuts are safely ignored when focus is inside modals or text inputs.
|
||||
- 🔘 **Unified 3-dot Action Dropdown (new in v3.5.0)** — All per-row actions are consolidated under a single icon button that opens a styled dropdown with shortcut key badges on the right.
|
||||
- 🔗 **Short Link Generation** — Auto-generate collision-free Base62 keys or set your own custom slugs.
|
||||
- 🌐 **Custom Domain Branding** — Register your own domains, verify DNS in real-time (A/CNAME), and serve links directly from the domain root — no `/s/` prefix needed.
|
||||
- 🌍 **Multiple Geo-IP Drivers** — Resolve visitor countries offline via MaxMind, from CDN headers (Cloudflare `CF-IPCountry`, CloudFront), or via ip-api.com fallback.
|
||||
- 🗺️ **Visitor World Map** — Visualize click distribution on an interactive SVG world map with per-country hover stats.
|
||||
- 📈 **Full Analytics Dashboard** — Track total/unique visits, referrers, devices, browsers, OSes, and browser languages. Fully cross-filterable (e.g., "only mobile visits from Poland in May").
|
||||
- ⚡ **Live Activity Feed** — Standalone real-time visit stream at `/stats/live`. Uses `MAX(id)` polling — returns a ~100-byte response when nothing changed, full render only on new visits.
|
||||
- 🗂️ **Folders & Tags** — Assign each link to a folder and up to 5 tags. Click any folder or tag to navigate to a filtered link list.
|
||||
- 🗄️ **Link Archiving** — Soft-archive links instead of deleting them. Archived links are hidden by default and fully restorable.
|
||||
- 🛡️ **VPN, Proxy & Bot Filtering** — Filter out Tor nodes, VPNs, anonymous proxies, and known bot user agents from visit stats.
|
||||
- 🔍 **Google Safe Browsing** — All target URLs are checked against Google's threat database on create and edit.
|
||||
- 🎨 **SVG QR Code Designer** — Full dot style, gradient, margin, and logo customization. Export as SVG or high-resolution PNG.
|
||||
- 📊 **QR Scan Tracking** — QR scans are recorded separately from regular web clicks via `?source=qr`. Shown as a distinct metric in analytics.
|
||||
- ✈️ **Browser Language Targeting** — Route visitors to different destinations based on their browser's `Accept-Language` header.
|
||||
- 🚀 **Ultra-Fast Redirects** — Sub-15ms redirect response time. Bypasses the heavy Laravel session/cookie middleware group for standard links, falling back to a stateful route dynamically only when password protection is active.
|
||||
- 🎯 **Server-Side GA4** — Send `short_url_visit` events via GA4 Measurement Protocol, completely bypassing browser ad-blockers.
|
||||
- ⚙️ **UTM Builder** — Build and preview campaign URLs with a real-time UTM form that syncs bidirectionally with the destination URL field.
|
||||
- 🔒 **Link Expiration & Caps** — Set `activated_at`, `expires_at`, `max_visits`, single-use mode, and a custom fallback URL for when any of these conditions are met.
|
||||
- ➡️ **Query Parameter Forwarding** — Automatically append incoming query strings (UTM parameters, ad tokens, discount codes) to the destination.
|
||||
- 🛠️ **Settings Panel** — All configuration in a dedicated Filament settings page — no `.env` editing required for day-to-day changes.
|
||||
- 🔑 **Password-Protected Links** — Visitors enter a password before being redirected. Session-persisted, so they only see the prompt once.
|
||||
- ⚠️ **Redirect Warning Pages** — Show a confirmation screen before sending visitors off to external domains.
|
||||
- 🎲 **A/B Split Testing** — Distribute traffic across 2–5 weighted variants. Works at the root level or nested inside targeting rules.
|
||||
- 📉 **Log Aggregation & Pruning** — A nightly command aggregates raw visits into daily summaries and prunes records older than the configured retention window. Keeps the database lean even at millions of visits.
|
||||
- 🏷️ **Retargeting Pixel Registry** — Define Meta Pixel, Google Tag, LinkedIn Insight, TikTok, and Pinterest pixels once, then attach them to any link via a simple checkbox list.
|
||||
- 🔌 **REST API with Scoped Keys** — Full CRUD API, SHA-256 hashed key storage, `links:read-only` / `links:read-write` scopes, and per-key rate limits.
|
||||
- 📡 **Webhooks** — HMAC-SHA256 signed HTTP POST callbacks on `visited`, `created`, `expired`, and `limit_reached` events. Dispatched asynchronously with 3-attempt exponential retry.
|
||||
- 📱 **Mobile Deep Linking** — Detect mobile visitors and launch the link directly inside 24+ native apps (Instagram, YouTube, Spotify, TikTok, WhatsApp, etc.).
|
||||
- 🍎 **Universal Links & Android App Links** — Serve `apple-app-site-association` and `assetlinks.json` directly from your domain to enable OS-level native app integration.
|
||||
- 💀 **Branded Expiry Pages** — When a link expires or hits its limit, visitors see a styled page with your site name instead of a bare 410 error.
|
||||
- 👤 **User Attribution** — Each link records its creator. Avatar and name/email hover card visible in the table.
|
||||
- 🕒 **Relative Time Badges** — Compact timestamps (`2h`, `5d`, `3mo`) with exact date on hover.
|
||||
- ⌨️ **Keyboard Shortcuts** — `E` edit, `Q` QR, `I` copy, `S` stats, `X` delete — available on any row hover.
|
||||
- ⋯ **Unified Action Menu** — All row actions in one 3-dot dropdown with shortcut badges.
|
||||
|
||||
---
|
||||
|
||||
## vs. Bitly, Dub.co & Rebrandly
|
||||
|
||||
| Feature | Filament Short URL | Bitly | Dub.co | Rebrandly |
|
||||
|---|:---:|:---:|:---:|:---:|
|
||||
| Self-hosted | ✅ | ❌ | partial¹ | ❌ |
|
||||
| Unlimited links & clicks | ✅ | ❌ | ❌ | ❌ |
|
||||
| Custom domains | ✅ | 💰 | 💰 | 💰 |
|
||||
| QR code designer | ✅ | 💰 basic | 💰 basic | 💰 |
|
||||
| A/B split testing | ✅ | ❌ | ✅ | ❌ |
|
||||
| Retargeting pixels | ✅ 5 providers | ❌ | ❌ | 💰 |
|
||||
| Mobile deep linking | ✅ 24+ apps | 💰 Enterprise | 💰 partial | 💰 |
|
||||
| Server-side GA4 (ad-block bypass) | ✅ | ❌ | ❌ | ❌ |
|
||||
| Live activity feed | ✅ | ❌ | ✅ | ❌ |
|
||||
| Cross-filtering analytics | ✅ | 💰 basic | partial | 💰 basic |
|
||||
| REST API | ✅ scoped keys | 💰 | ✅ | 💰 |
|
||||
| Webhooks | ✅ HMAC-signed | 💰 Enterprise | ✅ | 💰 |
|
||||
| Offline GDPR Geo-IP (MaxMind) | ✅ | ❌ | ❌ | ❌ |
|
||||
| VPN & bot filtering | ✅ | ❌ | ❌ | ❌ |
|
||||
| Google Safe Browsing on save | ✅ | ❌ | ❌ | ❌ |
|
||||
| Filament/Laravel admin panel | ✅ | ❌ | ❌ | ❌ |
|
||||
| Data stays on your server | ✅ | ❌ | partial¹ | ❌ |
|
||||
| Monthly cost | **$0** | $0–$199+ | $0–$190+ | $0–$49+ |
|
||||
|
||||
> 💰 = paid plans only · ¹ Dub.co is open-source (AGPLv3) but self-hosting requires external managed services (Tinybird, PlanetScale, Upstash)
|
||||
|
||||
---
|
||||
|
||||
@@ -112,48 +140,32 @@ php artisan migrate
|
||||
|
||||
## Publishing Package Assets
|
||||
|
||||
Customize and override views, translations, or configuration files by publishing the package assets:
|
||||
All assets are optional. Publish only what you need to customize:
|
||||
|
||||
### 1. Publish Config File
|
||||
Copies the default config file to `config/filament-short-url.php`:
|
||||
```bash
|
||||
# Config file → config/filament-short-url.php
|
||||
php artisan vendor:publish --tag=filament-short-url-config
|
||||
```
|
||||
|
||||
### 2. Publish Translation Files
|
||||
Copies localization files to `lang/vendor/filament-short-url/` (English and Polish included by default):
|
||||
```bash
|
||||
# Translations → lang/vendor/filament-short-url/ (EN + PL included)
|
||||
php artisan vendor:publish --tag=filament-short-url-translations
|
||||
```
|
||||
|
||||
### 3. Publish Blade Views & Templates
|
||||
Copies the dashboard components, charts, and QR designer templates to `resources/views/vendor/filament-short-url/`:
|
||||
```bash
|
||||
# Blade views (dashboard, QR designer, interstitials)
|
||||
php artisan vendor:publish --tag=filament-short-url-views
|
||||
```
|
||||
|
||||
### 4. Publish CSS Assets
|
||||
|
||||
The plugin ships with a pre-compiled stylesheet. Copy it to your application's public directory so the browser can load it:
|
||||
|
||||
```bash
|
||||
# Pre-compiled CSS → public/css/janczakb/filament-short-url/
|
||||
php artisan filament:assets
|
||||
```
|
||||
|
||||
That's all. The plugin's CSS will be served from `public/css/janczakb/filament-short-url/filament-short-url.css` and Filament registers it automatically.
|
||||
The CSS file is pre-compiled and bundled in the package — you don't need to run `npm` or Tailwind for it.
|
||||
|
||||
> **You do not need to run Tailwind, Vite, or npm for the plugin styles.** The compiled file is included in the package.
|
||||
>
|
||||
> **Using a Custom Filament Theme (Tailwind CSS v4)?**
|
||||
> If you are compiling your own Filament panel theme stylesheet, you can optionally tell Tailwind to scan the plugin's views by adding the `@source` directive to your theme's CSS file (e.g., `resources/css/filament/admin/theme.css`):
|
||||
>
|
||||
> ```css
|
||||
> @source './vendor/janczakb/filament-short-url/resources/views/**/*.blade.php';
|
||||
> ```
|
||||
**If you compile your own Filament theme** (Tailwind CSS v4), add an `@source` directive so Tailwind scans the plugin views:
|
||||
|
||||
**Tip — automate on every `composer install` / `composer update`:**
|
||||
```css
|
||||
/* resources/css/filament/admin/theme.css */
|
||||
@source './vendor/janczakb/filament-short-url/resources/views/**/*.blade.php';
|
||||
```
|
||||
|
||||
Add `filament:assets` to the `post-autoload-dump` scripts in your application's `composer.json` so the assets are always up to date without manual steps:
|
||||
**Tip:** Add `filament:assets` to `post-autoload-dump` in your `composer.json` so CSS stays up to date on every `composer install`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
@@ -318,6 +330,9 @@ $shortUrl->update(['password' => 'my-secret-pass']);
|
||||
|
||||
> **Note**: Passwords are currently stored as plain text. For sensitive use-cases, hash the password and compare with `Hash::check()` by overriding the redirect controller.
|
||||
|
||||
> [!NOTE]
|
||||
> **Stateful Route Transition (v5.1.0+)**: To achieve ultra-fast (<15ms) redirects for normal links, the main `/s/{key}` path completely bypasses Laravel's session/cookie `web` middleware group. If password protection is detected, the engine dynamically redirects the visitor to a stateful `/s-auth/{key}` route where sessions are loaded and the password prompt is served.
|
||||
|
||||
---
|
||||
|
||||
## Redirect Warning Pages (new in v1.2.0)
|
||||
@@ -1414,6 +1429,13 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**:
|
||||
|
||||
## Changelog
|
||||
|
||||
### v5.1.0
|
||||
|
||||
#### Request Lifecycle & Middleware Optimization
|
||||
- **Bypassed `web` middleware group** — Standard redirect paths (`s/{key}` and fallback routes) bypass session/cookie instantiation entirely. Under active caching, redirects resolve statelessly in `< 15ms`.
|
||||
- **Dynamic Stateful Route Transition** — Password-protected redirects are dynamically routed to a stateful `/s-auth/{key}` route wrapped under the `web` group. Handles session verification, password prompts, brute force rate-limiting, and warning interstitials.
|
||||
- **Robust Integration Testing** — Refactored the entire package test suite and main application feature tests to accommodate the dual stateful/stateless redirect routing architectures without coverage loss.
|
||||
|
||||
### v5.0.0
|
||||
|
||||
#### Analytics — Live Activity Feed
|
||||
|
||||
BIN
art/logo-fism.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
art/preview.webm
Normal file
BIN
art/v2_1.png
|
Before Width: | Height: | Size: 345 KiB |
BIN
art/v2_2.png
|
Before Width: | Height: | Size: 292 KiB |
BIN
art/v2_3.png
|
Before Width: | Height: | Size: 451 KiB |
BIN
art/v2_4.png
|
Before Width: | Height: | Size: 247 KiB |
BIN
art/v2_5.png
|
Before Width: | Height: | Size: 484 KiB |
BIN
art/v3_1.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
art/v3_2.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
art/v3_3.png
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
art/v3_4.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
art/v3_5.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
@@ -20,7 +20,16 @@ Route::match(
|
||||
)
|
||||
->name('short-url.redirect')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
||||
|
||||
Route::match(
|
||||
['GET', 'POST'],
|
||||
config('filament-short-url.route_prefix', 's').'-auth/{key}',
|
||||
[ShortUrlRedirectController::class, 'handlePasswordAuth']
|
||||
)
|
||||
->name('short-url.password-auth')
|
||||
->where('key', '[a-zA-Z0-9_-]+')
|
||||
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
|
||||
|
||||
Route::prefix('api/short-url')
|
||||
->middleware([
|
||||
@@ -44,5 +53,5 @@ Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLo
|
||||
|
||||
if (config('filament-short-url.enable_fallback_route', true)) {
|
||||
Route::fallback(ShortUrlRedirectController::class)
|
||||
->middleware(config('filament-short-url.middleware', ['web', 'throttle:120,1']));
|
||||
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -81,14 +80,14 @@ class ShortUrlFolderResource extends Resource
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->allowHtml()
|
||||
->options([
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
@@ -137,13 +136,13 @@ class ShortUrlFolderResource extends Resource
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_folder')),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
]),
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton()
|
||||
->extraAttributes([
|
||||
'class' => 'action-trigger-btn group flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border text-sm bg-transparent hover:bg-bg-muted data-[state=open]:ring-4 data-[state=open]:ring-border-subtle sm:inline-flex h-8 w-8 outline-none transition-all duration-200 border-transparent data-[state=open]:border-neutral-500 sm:group-hover/card:data-[state=closed]:border-neutral-200',
|
||||
'style' => 'width: 32px !important; height: 32px !important; padding: 0px !important; border-radius: 8px !important;',
|
||||
]),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
|
||||
@@ -9,8 +9,8 @@ use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\Page;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShortUrlsTable
|
||||
{
|
||||
@@ -159,7 +160,7 @@ class ShortUrlsTable
|
||||
Action::make('move')
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_move').'</span><span class="text-[10px] bg-neutral-100 dark:bg-neutral-800 text-neutral-400 dark:text-neutral-500 rounded px-1.5 py-0.5 ml-auto font-mono">M</span></div>'))
|
||||
->icon('heroicon-o-folder-open')
|
||||
->modalHeading(fn (ShortUrl $record) => __('filament-short-url::default.action_move') . ' ' . str_replace(['http://', 'https://'], '', $record->getShortUrl()))
|
||||
->modalHeading(fn (ShortUrl $record) => __('filament-short-url::default.action_move').' '.str_replace(['http://', 'https://'], '', $record->getShortUrl()))
|
||||
->modalWidth('md')
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_move'))
|
||||
->fillForm(fn (ShortUrl $record): array => [
|
||||
@@ -177,7 +178,7 @@ class ShortUrlsTable
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', \Illuminate\Support\Str::slug($state))),
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
@@ -198,7 +199,7 @@ class ShortUrlsTable
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
]),
|
||||
])
|
||||
->action(function (ShortUrl $record, array $data): void {
|
||||
$record->update(['folder_id' => $data['folder_id']]);
|
||||
|
||||
@@ -97,21 +97,21 @@ class ShortUrlLiveFeedWidget extends Widget
|
||||
'is_qr_scan', 'selected_variant',
|
||||
])
|
||||
->map(fn (ShortUrlVisit $visit) => [
|
||||
'id' => $visit->id,
|
||||
'time_ago' => $visit->visited_at->diffForHumans(),
|
||||
'ip_address' => $visit->ip_address,
|
||||
'flag_url' => $visit->country_code
|
||||
'id' => $visit->id,
|
||||
'time_ago' => $visit->visited_at->diffForHumans(),
|
||||
'ip_address' => $visit->ip_address,
|
||||
'flag_url' => $visit->country_code
|
||||
? 'https://flagcdn.com/h20/'.strtolower($visit->country_code).'.webp'
|
||||
: null,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'browser' => $visit->browser,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'browser' => $visit->browser,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'device_type' => $visit->device_type,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'is_qr_scan' => $visit->is_qr_scan,
|
||||
'device_type' => $visit->device_type,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'is_qr_scan' => $visit->is_qr_scan,
|
||||
'selected_variant' => $visit->selected_variant,
|
||||
])
|
||||
->all();
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages\ListShortUrlTags;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -12,7 +11,6 @@ use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\Layout\Stack;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -81,14 +79,14 @@ class ShortUrlTagResource extends Resource
|
||||
->label(__('filament-short-url::default.tag_color'))
|
||||
->allowHtml()
|
||||
->options([
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>' . __('filament-short-url::default.color_gray') . '</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>' . __('filament-short-url::default.color_red') . '</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>' . __('filament-short-url::default.color_blue') . '</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>' . __('filament-short-url::default.color_green') . '</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>' . __('filament-short-url::default.color_yellow') . '</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>' . __('filament-short-url::default.color_indigo') . '</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>' . __('filament-short-url::default.color_purple') . '</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>' . __('filament-short-url::default.color_pink') . '</span></span>',
|
||||
'gray' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #737373;"></span><span>'.__('filament-short-url::default.color_gray').'</span></span>',
|
||||
'red' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ef4444;"></span><span>'.__('filament-short-url::default.color_red').'</span></span>',
|
||||
'blue' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #3b82f6;"></span><span>'.__('filament-short-url::default.color_blue').'</span></span>',
|
||||
'green' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #10b981;"></span><span>'.__('filament-short-url::default.color_green').'</span></span>',
|
||||
'yellow' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #f59e0b;"></span><span>'.__('filament-short-url::default.color_yellow').'</span></span>',
|
||||
'indigo' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #6366f1;"></span><span>'.__('filament-short-url::default.color_indigo').'</span></span>',
|
||||
'purple' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #a855f7;"></span><span>'.__('filament-short-url::default.color_purple').'</span></span>',
|
||||
'pink' => '<span class="flex items-center gap-2"><span class="w-3 h-3 rounded-full shrink-0 border border-black/10 dark:border-white/10" style="background-color: #ec4899;"></span><span>'.__('filament-short-url::default.color_pink').'</span></span>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
@@ -125,9 +123,9 @@ class ShortUrlTagResource extends Resource
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_tag')),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton(),
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
|
||||
@@ -112,41 +112,12 @@ class ShortUrlRedirectController extends Controller
|
||||
|
||||
// 3. Password Protection Check
|
||||
if (! empty($shortUrl->password)) {
|
||||
$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;
|
||||
$queryString = $request->getQueryString();
|
||||
|
||||
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'),
|
||||
]);
|
||||
|
||||
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return response(view('filament-short-url::password-prompt'))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
return redirect()->to(
|
||||
route('short-url.password-auth', ['key' => $key]).($queryString ? '?'.$queryString : ''),
|
||||
302
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
|
||||
@@ -340,4 +311,222 @@ class ShortUrlRedirectController extends Controller
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stateful password authentication and redirection.
|
||||
*/
|
||||
public function handlePasswordAuth(Request $request, ?string $key = null): Response
|
||||
{
|
||||
if (empty($key)) {
|
||||
$key = $request->path();
|
||||
$prefix = config('filament-short-url.route_prefix', 's').'-auth/';
|
||||
if (str_starts_with($key, $prefix)) {
|
||||
$key = substr($key, strlen($prefix));
|
||||
}
|
||||
}
|
||||
|
||||
$host = $request->getHost();
|
||||
$shortUrl = ShortUrl::findByKey($key, $host);
|
||||
|
||||
if (! $shortUrl || empty($shortUrl->password)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $shortUrl->isActive()) {
|
||||
if ($shortUrl->isExpired() || ($shortUrl->deactivated_at && $shortUrl->deactivated_at->isPast())) {
|
||||
$expiredKey = "fsu:expired-webhook-sent:{$shortUrl->id}";
|
||||
if (cache()->add($expiredKey, true, 86400 * 30)) {
|
||||
$shortUrl->dispatchWebhook('expired');
|
||||
}
|
||||
}
|
||||
|
||||
if ($shortUrl->expiration_redirect_url && $shortUrl->is_enabled) {
|
||||
return redirect()->away($shortUrl->expiration_redirect_url, 302);
|
||||
}
|
||||
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// 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);
|
||||
$detection = $this->proxyDetector->detect($ipAddress);
|
||||
if ($detection['is_proxy'] || $detection['is_bot']) {
|
||||
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
|
||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
RateLimiter::hit($limiterKey, $decaySeconds);
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// Redirect to the same URL to process final redirection steps
|
||||
return redirect()->to($request->fullUrl());
|
||||
}
|
||||
|
||||
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
|
||||
|
||||
$errors = new MessageBag([
|
||||
'password' => __('filament-short-url::default.password_error'),
|
||||
]);
|
||||
|
||||
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return response(view('filament-short-url::password-prompt'))
|
||||
->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
// Resolve Destination URL
|
||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||
|
||||
// App Linking / Deep Links Auto-Open Check
|
||||
if ($shortUrl->auto_open_app_mobile) {
|
||||
$deviceType = $this->uaParser->getDeviceType($request->userAgent() ?? '');
|
||||
|
||||
if ($deviceType === 'mobile' || $deviceType === 'tablet') {
|
||||
$matchedApp = AppLinkingEngine::matchApp($destination);
|
||||
if ($matchedApp !== null) {
|
||||
$deepLink = AppLinkingEngine::convertToScheme($destination, $matchedApp);
|
||||
$activePixels = $shortUrl->pixels->where('is_active', true);
|
||||
|
||||
return response(view('filament-short-url::app-redirect', [
|
||||
'destination' => $destination,
|
||||
'deepLink' => $deepLink,
|
||||
'appId' => $matchedApp,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Track Visit
|
||||
if ($shortUrl->track_visits) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
$ipAddress = ClientIpExtractor::getIp($request);
|
||||
$countryCode = ClientIpExtractor::getCountryCode($request);
|
||||
$city = ClientIpExtractor::getCity($request);
|
||||
|
||||
$isQrScan = (bool) ($request->query('source') === 'qr' || $request->query('qr') === '1');
|
||||
$languages = $request->getLanguages();
|
||||
$browserLanguage = null;
|
||||
if (! empty($languages)) {
|
||||
$parts = explode('-', str_replace('_', '-', $languages[0]));
|
||||
$browserLanguage = strtolower(trim($parts[0]));
|
||||
if (strlen($browserLanguage) > 5) {
|
||||
$browserLanguage = substr($browserLanguage, 0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
$selectedVariant = app()->bound('resolved_ab_variant') ? app('resolved_ab_variant') : null;
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrlId: $shortUrl->id,
|
||||
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'),
|
||||
isQrScan: $isQrScan,
|
||||
browserLanguage: $browserLanguage,
|
||||
selectedVariant: $selectedVariant,
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
dispatch($job->onConnection($connection));
|
||||
} else {
|
||||
dispatch($job->onConnection('sync'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Redirect tracking failed', [
|
||||
'url_key' => $key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically disable single-use URLs
|
||||
if ($shortUrl->single_use) {
|
||||
$affected = ShortUrl::where('id', $shortUrl->id)
|
||||
->where('is_enabled', true)
|
||||
->update(['is_enabled' => false]);
|
||||
|
||||
if ($affected === 0) {
|
||||
return response(view('filament-short-url::expired', [
|
||||
'shortUrl' => $shortUrl,
|
||||
]), 410)->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
$hostsToForget = array_unique(array_filter([
|
||||
'default',
|
||||
$appHost,
|
||||
$request->getHost(),
|
||||
$shortUrl->custom_domain_id && $shortUrl->customDomain
|
||||
? $shortUrl->customDomain->domain
|
||||
: null,
|
||||
]));
|
||||
|
||||
foreach ($hostsToForget as $h) {
|
||||
cache()->forget("filament-short-url:{$shortUrl->url_key}:{$h}");
|
||||
}
|
||||
}
|
||||
|
||||
$activePixels = $shortUrl->pixels->where('is_active', true);
|
||||
|
||||
if ($activePixels->isNotEmpty()) {
|
||||
return response(view('filament-short-url::pixel-loading', [
|
||||
'destination' => $destination,
|
||||
'pixels' => $activePixels,
|
||||
]))->header('Content-Type', 'text/html');
|
||||
}
|
||||
|
||||
return redirect()->away($destination, $shortUrl->redirect_status_code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +66,17 @@ describe('complex option interactions', function () {
|
||||
'password' => 'secret123',
|
||||
]);
|
||||
|
||||
// First attempt (below rate limit) -> Shows password page (200)
|
||||
$this->get('/s/pw-rl-combo')->assertStatus(200)->assertSee('Password Required');
|
||||
// First attempt (below rate limit) -> Redirects to stateful s-auth route (302)
|
||||
$this->get('/s/pw-rl-combo')->assertStatus(302)->assertRedirect('/s-auth/pw-rl-combo');
|
||||
|
||||
// Second attempt (at rate limit) -> Shows password page (200)
|
||||
$this->get('/s/pw-rl-combo')->assertStatus(200);
|
||||
// Second attempt (at rate limit) -> Redirects to stateful s-auth route (302)
|
||||
$this->get('/s/pw-rl-combo')->assertStatus(302)->assertRedirect('/s-auth/pw-rl-combo');
|
||||
|
||||
// Third attempt (exceeds rate limit) -> Aborts with 429
|
||||
$this->get('/s/pw-rl-combo')->assertStatus(429);
|
||||
|
||||
// Submitting POST should also be blocked by the rate limiter
|
||||
$this->post('/s/pw-rl-combo', ['password' => 'secret123'])->assertStatus(429);
|
||||
// Submitting POST to s-auth should also be blocked by the rate limiter
|
||||
$this->post('/s-auth/pw-rl-combo', ['password' => 'secret123'])->assertStatus(429);
|
||||
});
|
||||
|
||||
it('password rate limiting is independent and tracks wrong attempts only', function () {
|
||||
@@ -91,19 +91,19 @@ describe('complex option interactions', function () {
|
||||
|
||||
// Submit 5 wrong attempts -> 200 with incorrect password
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/s/pw-brute', ['password' => 'wrong-password'])
|
||||
$this->post('/s-auth/pw-brute', ['password' => 'wrong-password'])
|
||||
->assertStatus(200)
|
||||
->assertSee('Incorrect password');
|
||||
}
|
||||
|
||||
// 6th attempt (even correct password) -> 429 due to brute force protection
|
||||
$this->post('/s/pw-brute', ['password' => 'open-sesame'])
|
||||
$this->post('/s-auth/pw-brute', ['password' => 'open-sesame'])
|
||||
->assertStatus(429);
|
||||
|
||||
// Check that a different IP can still log in successfully immediately
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '1.1.1.1'])
|
||||
->post('/s/pw-brute', ['password' => 'open-sesame'])
|
||||
->assertRedirect('/s/pw-brute');
|
||||
->post('/s-auth/pw-brute', ['password' => 'open-sesame'])
|
||||
->assertRedirect('/s-auth/pw-brute');
|
||||
});
|
||||
|
||||
it('targeting rules apply after password protection but before warning page', function () {
|
||||
@@ -127,6 +127,13 @@ describe('complex option interactions', function () {
|
||||
$response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->get('/s/tgt-pw-warn');
|
||||
|
||||
$response->assertStatus(302)
|
||||
->assertRedirect('/s-auth/tgt-pw-warn');
|
||||
|
||||
// Visit s-auth to see the password prompt
|
||||
$response = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->get('/s-auth/tgt-pw-warn');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertSee('Password Required')
|
||||
->assertDontSee('Security Redirect Warning')
|
||||
@@ -134,12 +141,12 @@ describe('complex option interactions', function () {
|
||||
|
||||
// 2. Submit correct password
|
||||
$this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->post('/s/tgt-pw-warn', ['password' => 'super-secret'])
|
||||
->assertRedirect('/s/tgt-pw-warn');
|
||||
->post('/s-auth/tgt-pw-warn', ['password' => 'super-secret'])
|
||||
->assertRedirect('/s-auth/tgt-pw-warn');
|
||||
|
||||
// 3. Authenticated request as mobile: targeting resolved to mobile.com, warning page shown
|
||||
$response2 = $this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->get('/s/tgt-pw-warn');
|
||||
->get('/s-auth/tgt-pw-warn');
|
||||
|
||||
$response2->assertStatus(200)
|
||||
->assertSee('Security Redirect Warning')
|
||||
@@ -148,7 +155,7 @@ describe('complex option interactions', function () {
|
||||
|
||||
// 4. Confirm redirection: goes to mobile URL
|
||||
$this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')
|
||||
->get('/s/tgt-pw-warn?confirmed=1')
|
||||
->get('/s-auth/tgt-pw-warn?confirmed=1')
|
||||
->assertRedirect('https://mobile.com');
|
||||
});
|
||||
|
||||
@@ -369,8 +376,13 @@ describe('complex option interactions', function () {
|
||||
'show_warning_page' => true,
|
||||
]);
|
||||
|
||||
// Attempting to bypass password using ?confirmed=1 should still show password prompt
|
||||
$this->get('/s/pw-bypass?confirmed=1')
|
||||
// Attempting to bypass password using ?confirmed=1 should redirect to stateful s-auth route
|
||||
$response = $this->get('/s/pw-bypass?confirmed=1');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/pw-bypass?confirmed=1');
|
||||
|
||||
// Visiting s-auth should render password prompt view, not warning page
|
||||
$this->get('/s-auth/pw-bypass?confirmed=1')
|
||||
->assertStatus(200)
|
||||
->assertSee('Password Required')
|
||||
->assertDontSee('Security Redirect Warning');
|
||||
|
||||
@@ -106,6 +106,8 @@ it('renders live activity feed widget with filtered data', function () {
|
||||
'filters' => ['country_code' => 'PL'],
|
||||
])
|
||||
->assertViewHas('visits', function ($visits) {
|
||||
return count($visits) === 1 && $visits->first()->country_code === 'PL';
|
||||
$first = collect($visits)->first();
|
||||
|
||||
return count($visits) === 1 && ($first['country_code'] ?? $first->country_code ?? null) === 'PL';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,22 +331,27 @@ it('requires password to redirect when protected', function () {
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// Unauthenticated request should render password prompt view
|
||||
// Unauthenticated request should redirect to the stateful s-auth route
|
||||
$response = $this->get('/s/password123');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/password123');
|
||||
|
||||
// GET request to s-auth should render password prompt view
|
||||
$response = $this->get('/s-auth/password123');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
|
||||
// Send incorrect password
|
||||
$response = $this->post('/s/password123', ['password' => 'wrong']);
|
||||
$response = $this->post('/s-auth/password123', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
|
||||
// Send correct password
|
||||
$response = $this->post('/s/password123', ['password' => 'secret-key']);
|
||||
$response->assertRedirect('/s/password123');
|
||||
$response = $this->post('/s-auth/password123', ['password' => 'secret-key']);
|
||||
$response->assertRedirect('/s-auth/password123');
|
||||
|
||||
// Following request should redirect to target
|
||||
$this->get('/s/password123')->assertRedirect('https://example.com');
|
||||
$this->get('/s-auth/password123')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('applies rate limiting on password attempts when protected', function () {
|
||||
@@ -358,13 +363,13 @@ it('applies rate limiting on password attempts when protected', function () {
|
||||
|
||||
// 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'])
|
||||
$this->post('/s-auth/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'])
|
||||
$this->post('/s-auth/password-ratelimit', ['password' => 'wrong-pass'])
|
||||
->assertStatus(429);
|
||||
});
|
||||
|
||||
@@ -393,31 +398,36 @@ it('requires password first, then shows warning page when both are enabled', fun
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
// 1. Visit without password -> password prompt page, not warning page
|
||||
// 1. Visit without password -> redirects to s-auth route
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/both-secure');
|
||||
|
||||
// Visit s-auth without password -> password prompt page, not warning page
|
||||
$response = $this->get('/s-auth/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Password Required');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 2. Submit wrong password -> password prompt page with error
|
||||
$response = $this->post('/s/both-secure', ['password' => 'wrong']);
|
||||
$response = $this->post('/s-auth/both-secure', ['password' => 'wrong']);
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Incorrect password');
|
||||
$response->assertDontSee('Security Redirect Warning');
|
||||
|
||||
// 3. Submit correct password -> redirects back to the short URL path
|
||||
$response = $this->post('/s/both-secure', ['password' => 'secret-combination']);
|
||||
$response->assertRedirect('/s/both-secure');
|
||||
$response = $this->post('/s-auth/both-secure', ['password' => 'secret-combination']);
|
||||
$response->assertRedirect('/s-auth/both-secure');
|
||||
|
||||
// 4. Follow redirect (GET request with authenticated session, but without confirmed parameter)
|
||||
// -> shows redirect warning page, not direct redirect
|
||||
$response = $this->get('/s/both-secure');
|
||||
$response = $this->get('/s-auth/both-secure');
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Security Redirect Warning');
|
||||
$response->assertSee('https://example.com');
|
||||
|
||||
// 5. Follow the confirmed link (confirmed=1) -> redirects to destination
|
||||
$this->get('/s/both-secure?confirmed=1')
|
||||
$this->get('/s-auth/both-secure?confirmed=1')
|
||||
->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
|
||||
@@ -407,20 +407,29 @@ describe('link option: password', function () {
|
||||
it('shows password prompt to unauthenticated visitor', function () {
|
||||
makeLink(['url_key' => 'pw1', 'password' => 'letmein']);
|
||||
|
||||
$this->get('/s/pw1')->assertStatus(200)->assertSee('Password Required');
|
||||
// GET request to /s/{key} should redirect to /s-auth/{key}
|
||||
$response = $this->get('/s/pw1');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/pw1');
|
||||
|
||||
// GET request to /s-auth/{key} should show password prompt
|
||||
$this->get('/s-auth/pw1')->assertStatus(200)->assertSee('Password Required');
|
||||
});
|
||||
|
||||
it('redirects after correct password via session', function () {
|
||||
makeLink(['url_key' => 'pw2', 'password' => 'open']);
|
||||
|
||||
$this->post('/s/pw2', ['password' => 'open'])->assertRedirect('/s/pw2');
|
||||
$this->get('/s/pw2')->assertRedirect('https://example.com');
|
||||
// POST correct password to /s-auth/{key} -> redirects back to /s-auth/{key}
|
||||
$this->post('/s-auth/pw2', ['password' => 'open'])->assertRedirect('/s-auth/pw2');
|
||||
// Following request to /s-auth/{key} redirects to final destination
|
||||
$this->get('/s-auth/pw2')->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('returns error view on wrong password', function () {
|
||||
makeLink(['url_key' => 'pw3', 'password' => 'right']);
|
||||
|
||||
$this->post('/s/pw3', ['password' => 'wrong'])
|
||||
// POST wrong password to /s-auth/{key} -> returns prompt with error
|
||||
$this->post('/s-auth/pw3', ['password' => 'wrong'])
|
||||
->assertStatus(200)
|
||||
->assertSee('Incorrect password');
|
||||
});
|
||||
@@ -429,17 +438,22 @@ describe('link option: password', function () {
|
||||
makeLink(['url_key' => 'pw-rl', 'password' => 'correct']);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(200);
|
||||
$this->post('/s-auth/pw-rl', ['password' => 'wrong'])->assertStatus(200);
|
||||
}
|
||||
|
||||
$this->post('/s/pw-rl', ['password' => 'wrong'])->assertStatus(429);
|
||||
$this->post('/s-auth/pw-rl', ['password' => 'wrong'])->assertStatus(429);
|
||||
});
|
||||
|
||||
it('password check happens before warning page', function () {
|
||||
makeLink(['url_key' => 'pw-warn', 'password' => 'abc', 'show_warning_page' => true]);
|
||||
|
||||
// Unauthenticated visitor redirects to /s-auth/pw-warn
|
||||
$response = $this->get('/s/pw-warn');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/pw-warn');
|
||||
|
||||
// Password prompt must appear first, not warning
|
||||
$this->get('/s/pw-warn')->assertSee('Password Required')->assertDontSee('Security Redirect Warning');
|
||||
$this->get('/s-auth/pw-warn')->assertSee('Password Required')->assertDontSee('Security Redirect Warning');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1125,8 +1139,13 @@ describe('option interactions and conflict scenarios', function () {
|
||||
it('single_use link with password shows password prompt on first visit', function () {
|
||||
makeLink(['url_key' => 'su-pw', 'single_use' => true, 'password' => 'abc']);
|
||||
|
||||
// Should show password prompt, NOT redirect + disable
|
||||
$this->get('/s/su-pw')->assertSee('Password Required');
|
||||
// Should redirect to stateful s-auth route
|
||||
$response = $this->get('/s/su-pw');
|
||||
$response->assertStatus(302);
|
||||
$response->assertRedirect('/s-auth/su-pw');
|
||||
|
||||
// Visiting s-auth should render password prompt, NOT redirect + disable
|
||||
$this->get('/s-auth/su-pw')->assertSee('Password Required');
|
||||
|
||||
// Link must still be enabled (not consumed by the password prompt visit)
|
||||
expect(ShortUrl::where('url_key', 'su-pw')->value('is_enabled'))->toBeTrue();
|
||||
|
||||