Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043920836d | ||
|
|
9989fb938b | ||
|
|
73b8b875a8 | ||
|
|
9493431017 | ||
|
|
822a3e6fa3 | ||
|
|
5b2eb35a0b | ||
|
|
964a7364ef | ||
|
|
ba601dcf1e | ||
|
|
e4ef194420 | ||
|
|
53a3c1138d | ||
|
|
4cef5de366 | ||
|
|
e0602e80ef | ||
|
|
3592828ab3 | ||
|
|
7082db495d | ||
|
|
f73803017f | ||
|
|
2107a3e6c8 |
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.
|
||||
|
||||
218
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,38 +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.
|
||||
- 🛡️ **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 (updated in v3.5.0)** — Full programmatical control with secure API Key authentication to create, read, update, list, delete, and inspect analytics for short links externally.
|
||||
- 📡 **Real-Time Webhooks** — Asynchronous HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events with a built-in retry policy.
|
||||
- 📱 **Mobile App Deep Linking (new in v3.0.0)** — Detect mobile visitors and open links directly in 24+ native apps (Instagram, YouTube, Spotify, TikTok, etc.) using custom URI schemes.
|
||||
- 🔗 **Universal Links & App Links (new in v3.0.0)** — Host iOS `apple-app-site-association` and Android `assetlinks.json` domain configuration files directly from your root domain.
|
||||
- 🎨 **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)
|
||||
|
||||
---
|
||||
|
||||
@@ -109,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": {
|
||||
@@ -315,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)
|
||||
@@ -807,9 +825,26 @@ For security, new API keys are hashed using SHA-256 and stored securely in the d
|
||||
|
||||
> If the API is disabled globally, all endpoints return `503 Service Unavailable` regardless of the key provided.
|
||||
|
||||
### API Key Scopes
|
||||
|
||||
Each API key can be assigned a **scope** that restricts its access level:
|
||||
|
||||
| Scope | Allowed Methods | Use Case |
|
||||
|---|---|---|
|
||||
| `links:read-write` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Full control (default) |
|
||||
| `links:read-only` | `GET` only | Integrations that only need to read link data (e.g., dashboards, reporting tools) |
|
||||
|
||||
A `links:read-only` key attempting a write operation (`POST`, `PUT`, `PATCH`, `DELETE`) will receive a `403 Forbidden` response.
|
||||
|
||||
### Per-Key Rate Limiting
|
||||
|
||||
Each API key can have its own **individual rate limit** (requests per minute), independent of the global route throttle. This allows high-trust integrations to use a higher limit while restricting untrusted keys more aggressively.
|
||||
|
||||
Configure the rate limit for each key in **Settings → API & Webhooks → Developer API Keys**. The default is **60 requests per minute**.
|
||||
|
||||
### Endpoints
|
||||
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware and rate-limited to **60 requests per minute** (`throttle:60,1`).
|
||||
All endpoints are prefix-grouped under `/api/short-url/` and are protected by the API Key middleware. The effective rate limit is the lower of the global route throttle and the per-key rate limit.
|
||||
|
||||
#### `GET /api/short-url/links`
|
||||
List all short URLs (paginated, 30 per page).
|
||||
@@ -1394,6 +1429,35 @@ 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
|
||||
- **Dedicated `/stats/live` page** — Live Feed is now a full standalone page at `/{record}/stats/live`, on par with Visit Logs at `/{record}/stats/logs`. Tab navigation is consistent across all three stat sub-pages (Statistics / Live Feed / Visit Logs).
|
||||
- **Intelligent no-change skip** — Each poll tick now calls `checkForUpdates()`, which performs a single O(1) `MAX(id)` query against the composite index. If the highest visit ID hasn't changed since the last render, `skipRender()` is called and Livewire returns a ~100-byte "nothing changed" response — zero PHP rendering, zero DOM operations.
|
||||
- **Correct cursor initialization** — `$latestVisitId` is initialized to `-1` (sentinel). After the first `getViewData()` call it is set to the actual `MAX(id)`, so the very first poll does not trigger an unnecessary re-render.
|
||||
- **Cache key versioned by `latestVisitId`** — Fixed a bug where two concurrent users detecting the same new visit could share a thundering-herd cache entry that didn't include the visit that triggered their render, causing them to permanently miss that visit until the next new visit arrived.
|
||||
- **3-second thundering-herd cache** — Collapses N concurrent admin users watching the same URL into one DB query per 3-second window. Cache key includes URL id, date range, active filters, and `latestVisitId` version.
|
||||
- **Country flags via `flagcdn.com`** — Flag images are rendered as `<img src="https://flagcdn.com/h20/{cc}.webp">` instead of emoji. The URL is precomputed once in PHP (`getViewData()`), not per-row in Blade. Alpine.js `x-on:error` handles broken images without CSP-unsafe inline `onerror` handlers.
|
||||
- **Blade performance** — `time_ago` is precomputed once in PHP using `$visit->visited_at->diffForHumans()` (no `Carbon::parse()` double-instantiation). All per-row presentation values are plain PHP arrays, not Eloquent model calls in Blade.
|
||||
- **Optimized DB query** — Selects only 12 needed columns (`id`, `visited_at`, `country`, `country_code`, `city`, `browser`, `operating_system`, `device_type`, `referer_host`, `referer_url`, `ip_address`, `is_qr_scan`, `selected_variant`) instead of `SELECT *`. Limit reduced to 25 rows.
|
||||
- **Polling stops when hidden** — `wire:poll.5s.visible` pauses polling completely when the widget is scrolled out of viewport.
|
||||
|
||||
#### Link Organization
|
||||
- **Folders** — Each short URL can be assigned to one folder. Clicking a folder in the sidebar navigates to a filtered link list showing only that folder's links. Link count displayed per folder.
|
||||
- **Tags** — Each short URL can have up to 5 tags. Clicking a tag shows a filtered link list. Link counts displayed per tag.
|
||||
- **Archiving** — Short URLs can be archived instead of permanently deleted. Archived links are hidden from the default list view and can be restored at any time via the Filament panel.
|
||||
|
||||
#### REST API
|
||||
- **Per-key API scopes** — Each API key now supports either `links:read-only` (GET only) or `links:read-write` (full CRUD) scope. Read-only keys return `403 Forbidden` on any write attempt.
|
||||
- **Per-key rate limiting** — Each API key can have its own individual rate limit (requests per minute), independent of the global route throttle. Configure in **Settings → API & Webhooks → Developer API Keys**.
|
||||
|
||||
### v4.0.0
|
||||
- **Custom Domains Branding** — Let users connect branded custom domains with real-time CNAME/A record DNS verification and automatic prefix-free root routing.
|
||||
- **Cache Leak Fix** — Solved a critical caching bug where single-use links could be visited multiple times during the cache TTL by passing the host-specific key suffix to cache invalidations.
|
||||
|
||||
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 |
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
|
||||
@@ -265,11 +266,10 @@ return [
|
||||
|
|
||||
*/
|
||||
'user' => [
|
||||
'model' => \App\Models\User::class,
|
||||
'model' => User::class,
|
||||
'name_column' => 'name',
|
||||
'email_column' => 'email',
|
||||
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('short_url_folders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tags', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('color')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('short_url_tag', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('short_url_id')->index();
|
||||
$table->unsignedBigInteger('tag_id')->index();
|
||||
$table->primary(['short_url_id', 'tag_id']);
|
||||
});
|
||||
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('folder_id')->nullable()->after('custom_domain_id')->index();
|
||||
$table->boolean('is_archived')->default(false)->after('is_enabled')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('short_urls', function (Blueprint $table) {
|
||||
$table->dropColumn(['folder_id', 'is_archived']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('short_url_tag');
|
||||
Schema::dropIfExists('short_url_tags');
|
||||
Schema::dropIfExists('short_url_folders');
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss/theme";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
@source "../../src/**/*.php";
|
||||
@source "../../resources/views/**/*.blade.php";
|
||||
@source "../views/**/*.blade.php";
|
||||
@source "packages/filament-short-url/src/**/*.php";
|
||||
@source "packages/filament-short-url/resources/views/**/*.blade.php";
|
||||
|
||||
.short-url-card {
|
||||
position: relative !important;
|
||||
@@ -85,4 +88,38 @@ select.fi-select-input:disabled,
|
||||
.fi-select-input .fi-select-input-btn.fi-disabled {
|
||||
background-image: none !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Folder Card overrides to reset default Filament record-content padding and force height/width stretch */
|
||||
.folder-card .fi-ta-record-content,
|
||||
.folder-card .fi-ta-record-content-ctn {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.folder-card .fi-ta-record-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Position action buttons absolute on the card wrapper to keep dropdown aligned */
|
||||
.folder-card .fi-ta-actions {
|
||||
position: absolute !important;
|
||||
top: 1rem !important;
|
||||
right: 1rem !important;
|
||||
z-index: 20 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Force height down the Filament table layout tree so justify-between works */
|
||||
.folder-card .fi-ta-stack,
|
||||
.folder-card .fi-ta-stack > div,
|
||||
.folder-card .fi-ta-text,
|
||||
.folder-card .fi-ta-text > div {
|
||||
height: 100% !important;
|
||||
}
|
||||
2
resources/dist/filament-short-url.css
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Links',
|
||||
'navigation_group' => 'Tools',
|
||||
'navigation_group' => 'URL Shortener',
|
||||
'resource_title' => 'Short URL',
|
||||
'empty_state_heading' => 'No links yet',
|
||||
'empty_state_description' => 'Start creating short links for your marketing campaigns, referral programs, and more.',
|
||||
@@ -156,6 +156,7 @@ return [
|
||||
'url_key_change_confirmation_heading' => 'Replace link?',
|
||||
'url_key_change_confirmation' => "You've modified the back-half of this link's URL. Saving these changes will update the key. Any references to the original link will break.",
|
||||
'action_share' => 'Share Link',
|
||||
'action_move' => 'Move',
|
||||
'share_title' => 'Share Link',
|
||||
'share_description' => 'Share this short link via:',
|
||||
'share_copy' => 'Copy',
|
||||
@@ -674,5 +675,76 @@ return [
|
||||
'action_delete_domain' => 'Delete Domain',
|
||||
'delete_domain_confirmation_desc' => 'Are you sure you want to delete this custom domain? This action cannot be undone – proceed with caution.',
|
||||
'short_link_label' => 'Short Link',
|
||||
];
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'API Scope',
|
||||
'api_key_scope_helper' => 'Choose the permission level for this API key.',
|
||||
'api_key_scope_read_write' => 'Read & Write (Full Access)',
|
||||
'api_key_scope_read_only' => 'Read Only',
|
||||
'api_key_rate_limit' => 'Rate Limit',
|
||||
'api_key_rate_limit_helper' => 'Define the maximum requests per minute allowed for this key.',
|
||||
'api_key_rate_limit_unlimited' => 'Unlimited',
|
||||
'api_key_rate_limit_rpm' => ':count requests / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Folders',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Folder Name',
|
||||
'folder_slug' => 'Slug',
|
||||
'folder_color' => 'Color',
|
||||
'empty_state_folder_action' => 'Create Folder',
|
||||
'empty_state_folders_heading' => 'No folders yet',
|
||||
'empty_state_folders_description' => 'Organize your short links into folders.',
|
||||
'action_edit_folder' => 'Edit Folder',
|
||||
'action_delete_folder' => 'Delete Folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count links',
|
||||
|
||||
// Tags
|
||||
'tags_navigation_label' => 'Tags',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Tag Name',
|
||||
'tag_slug' => 'Slug',
|
||||
'tag_color' => 'Color',
|
||||
'empty_state_tag_action' => 'Create Tag',
|
||||
'empty_state_tags_heading' => 'No tags yet',
|
||||
'empty_state_tags_description' => 'Tag your short links to filter them easily.',
|
||||
'action_edit_tag' => 'Edit Tag',
|
||||
'action_delete_tag' => 'Delete Tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Archived',
|
||||
'is_archived_helper' => 'Archived links are hidden from the active list but continue to redirect.',
|
||||
'action_archive' => 'Archive',
|
||||
'action_restore' => 'Restore',
|
||||
'action_archive_selected' => 'Archive Selected',
|
||||
'action_restore_selected' => 'Restore Selected',
|
||||
'tab_active_links' => 'Active',
|
||||
'tab_archived_links' => 'Archived',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Live Feed',
|
||||
'stats_live_feed_empty' => 'No visits recorded yet. Visits will appear here in real-time.',
|
||||
'stats_live_feed_poll_interval' => 'Updates every 5s',
|
||||
'stats_col_visitor' => 'Visitor / Location',
|
||||
|
||||
// Empty state mock values
|
||||
'empty_state_tags_mock_redirecting' => 'Redirecting...',
|
||||
'empty_state_tags_mock_promo' => 'promo',
|
||||
'empty_state_tags_mock_social' => 'social',
|
||||
'empty_state_tags_mock_dev' => 'dev',
|
||||
|
||||
// Folder links count
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count links',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Gray',
|
||||
'color_red' => 'Red',
|
||||
'color_blue' => 'Blue',
|
||||
'color_green' => 'Green',
|
||||
'color_yellow' => 'Yellow',
|
||||
'color_indigo' => 'Indigo',
|
||||
'color_purple' => 'Purple',
|
||||
'color_pink' => 'Pink',
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'navigation_label' => 'Linki',
|
||||
'navigation_group' => 'Narzędzia',
|
||||
'navigation_group' => 'Skracacz linków',
|
||||
'resource_title' => 'Krótki URL',
|
||||
'empty_state_heading' => 'Brak linków',
|
||||
'empty_state_description' => 'Zacznij tworzyć krótkie linki dla swoich kampanii marketingowych, programów partnerskich i nie tylko.',
|
||||
@@ -152,6 +152,7 @@ return [
|
||||
'url_key_change_confirmation_heading' => 'Zastąpić link?',
|
||||
'url_key_change_confirmation' => 'Zmieniłeś krótki klucz (Short Key) tego linku. Zapisanie tych zmian spowoduje, że dotychczasowy krótki URL przestanie działać, a wszelkie udostępnione wcześniej linki z poprzednim kluczem wygasną.',
|
||||
'action_share' => 'Udostępnij link',
|
||||
'action_move' => 'Przenieś',
|
||||
'share_title' => 'Udostępnij Link',
|
||||
'share_description' => 'Udostępnij ten krótki link przez:',
|
||||
'share_copy' => 'Skopiuj',
|
||||
@@ -672,5 +673,76 @@ return [
|
||||
'action_delete_domain' => 'Usuń domenę',
|
||||
'delete_domain_confirmation_desc' => 'Czy na pewno chcesz usunąć tę własną domenę? Ta akcja nie może zostać cofnięta – postępuj ostrożnie.',
|
||||
'short_link_label' => 'Krótki link',
|
||||
];
|
||||
|
||||
// API Scopes & Rate Limits
|
||||
'api_key_scope' => 'Uprawnienia (Scope)',
|
||||
'api_key_scope_helper' => 'Wybierz poziom dostępu dla tego klucza API.',
|
||||
'api_key_scope_read_write' => 'Odczyt i Zapis (Pełny dostęp)',
|
||||
'api_key_scope_read_only' => 'Tylko Odczyt (Read-Only)',
|
||||
'api_key_rate_limit' => 'Limit zapytań',
|
||||
'api_key_rate_limit_helper' => 'Zdefiniuj maksymalną liczbę zapytań na minutę dopuszczalną dla tego klucza.',
|
||||
'api_key_rate_limit_unlimited' => 'Bez limitu',
|
||||
'api_key_rate_limit_rpm' => ':count zapytań / min',
|
||||
|
||||
// Folders
|
||||
'folders_navigation_label' => 'Foldery',
|
||||
'folder_resource_title' => 'Folder',
|
||||
'folder_name' => 'Nazwa folderu',
|
||||
'folder_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'folder_color' => 'Kolor',
|
||||
'empty_state_folder_action' => 'Utwórz folder',
|
||||
'empty_state_folders_heading' => 'Brak folderów',
|
||||
'empty_state_folders_description' => 'Uporządkuj swoje krótkie linki w folderach.',
|
||||
'action_edit_folder' => 'Edytuj folder',
|
||||
'action_delete_folder' => 'Usuń folder',
|
||||
'link_count_one' => '1 link',
|
||||
'link_count_many' => ':count linków',
|
||||
|
||||
// Tagi
|
||||
'tags_navigation_label' => 'Tagi',
|
||||
'tag_resource_title' => 'Tag',
|
||||
'tag_name' => 'Nazwa tagu',
|
||||
'tag_slug' => 'Uproszczona nazwa (Slug)',
|
||||
'tag_color' => 'Kolor',
|
||||
'empty_state_tag_action' => 'Utwórz tag',
|
||||
'empty_state_tags_heading' => 'Brak tagów',
|
||||
'empty_state_tags_description' => 'Oznaczaj swoje krótkie linki tagami, by łatwo je filtrować.',
|
||||
'action_edit_tag' => 'Edytuj tag',
|
||||
'action_delete_tag' => 'Usuń tag',
|
||||
|
||||
// Archiving
|
||||
'is_archived' => 'Zarchiwizowany',
|
||||
'is_archived_helper' => 'Zarchiwizowane linki są ukrywane z głównej listy, ale nadal przekierowują użytkowników.',
|
||||
'action_archive' => 'Archiwizuj',
|
||||
'action_restore' => 'Przywróć',
|
||||
'action_archive_selected' => 'Zarchiwizuj zaznaczone',
|
||||
'action_restore_selected' => 'Przywróć zaznaczone',
|
||||
'tab_active_links' => 'Aktywne',
|
||||
'tab_archived_links' => 'Zarchiwizowane',
|
||||
|
||||
// Live Feed
|
||||
'stats_tab_live_feed' => 'Strumień aktywności',
|
||||
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
|
||||
'stats_live_feed_poll_interval' => 'Aktualizacja co 5s',
|
||||
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
|
||||
|
||||
// Puste stany - makiety
|
||||
'empty_state_tags_mock_redirecting' => 'Przekierowywanie...',
|
||||
'empty_state_tags_mock_promo' => 'promocja',
|
||||
'empty_state_tags_mock_social' => 'social-media',
|
||||
'empty_state_tags_mock_dev' => 'programisci',
|
||||
|
||||
// Liczba linków w folderze
|
||||
'folder_one_link' => '1 link',
|
||||
'folder_many_links' => ':count linków',
|
||||
|
||||
// Colors
|
||||
'color_gray' => 'Szary',
|
||||
'color_red' => 'Czerwony',
|
||||
'color_blue' => 'Niebieski',
|
||||
'color_green' => 'Zielony',
|
||||
'color_yellow' => 'Żółty',
|
||||
'color_indigo' => 'Indygo',
|
||||
'color_purple' => 'Fioletowy',
|
||||
'color_pink' => 'Różowy',
|
||||
];
|
||||
|
||||
35
resources/views/live.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats', ['record' => $record]) }}"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
icon="heroicon-m-list-bullet"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_visit_logs') }}
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
<div class="mt-2">
|
||||
@livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget::class, [
|
||||
'record' => $record,
|
||||
'dateFrom' => null,
|
||||
'dateTo' => null,
|
||||
'filters' => [],
|
||||
], key('live-feed-page-' . $record->id))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -8,6 +8,14 @@
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
icon="heroicon-m-list-bullet"
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::tabs class="mb-6">
|
||||
<x-filament::tabs.item
|
||||
:active="true"
|
||||
wire:click="$set('activeTab', 'statistics')"
|
||||
:active="$activeTab === 'statistics'"
|
||||
icon="heroicon-m-presentation-chart-line"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_statistics') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.live', ['record' => $record]) }}"
|
||||
icon="heroicon-m-bolt"
|
||||
>
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</x-filament::tabs.item>
|
||||
|
||||
<x-filament::tabs.item
|
||||
tag="a"
|
||||
href="{{ \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource::getUrl('stats.logs', ['record' => $record]) }}"
|
||||
@@ -127,3 +136,4 @@
|
||||
], key('stats-variants-' . $dateFrom . '-' . $dateTo . '-' . $filtersHash))
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
|
||||
133
resources/views/table/empty-state-folders.blade.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Folders Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
|
||||
|
||||
<!-- 3D Folder Container -->
|
||||
<div class="relative w-48 h-32 flex items-center justify-center z-10" style="perspective: 1000px;">
|
||||
<!-- Folder Back -->
|
||||
<div class="absolute bottom-2 w-28 h-18 bg-neutral-300 dark:bg-neutral-800 rounded-lg shadow-sm border border-neutral-400/30 dark:border-neutral-700/50" style="transform: translateZ(-30px);">
|
||||
<!-- Folder Tab -->
|
||||
<div class="absolute -top-2 left-3 w-10 h-3 bg-neutral-300 dark:bg-neutral-800 rounded-t-md border-t border-x border-neutral-400/30 dark:border-neutral-700/50"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Document/Link Cards (Middle Layer) -->
|
||||
<!-- Card 1 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-blue-500/90 to-indigo-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-1 4s infinite cubic-bezier(0.25, 1, 0.5, 1); transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<div class="absolute w-24 h-12 rounded-lg bg-gradient-to-br from-violet-500/90 to-purple-600/90 text-white shadow-md border border-white/20 flex flex-col justify-between p-2 select-none"
|
||||
style="animation: fsu-folder-card-2 4s infinite cubic-bezier(0.25, 1, 0.5, 1); animation-delay: 2s; transform-style: preserve-3d;">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3.5 h-3.5 rounded-full bg-white/25 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="h-1.5 w-8 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
<div class="h-1 w-12 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Front (Glassmorphic & Tilted) -->
|
||||
<div class="absolute bottom-2 w-28 h-16 bg-neutral-100/50 dark:bg-neutral-900/50 border border-white/30 dark:border-neutral-800/80 rounded-lg shadow-lg origin-bottom transition-all"
|
||||
style="backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); transform: rotateX(-16deg); transform-style: preserve-3d; animation: fsu-folder-front 4s infinite ease-in-out; box-shadow: 0 12px 20px -8px rgba(0,0,0,0.15), 0 4px 6px -2px rgba(0,0,0,0.05);">
|
||||
<!-- Folder lock / branding line -->
|
||||
<div class="absolute top-2 left-3 w-6 h-1 bg-neutral-300 dark:bg-neutral-700 rounded-full"></div>
|
||||
<div class="absolute bottom-2 right-3 w-3 h-3 rounded-full border border-neutral-300 dark:border-neutral-750 flex items-center justify-center">
|
||||
<div class="w-1 h-1 rounded-full bg-neutral-400 dark:bg-neutral-650"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-folder-front {
|
||||
0%, 100% {
|
||||
transform: rotateX(-15deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotateX(-22deg);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-1 {
|
||||
0% {
|
||||
transform: translate3d(35px, -60px, 10px) rotate(12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(20px, -45px, 20px) rotate(6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(-2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(-4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fsu-folder-card-2 {
|
||||
0% {
|
||||
transform: translate3d(-35px, -60px, 10px) rotate(-12deg) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
transform: translate3d(-20px, -45px, 20px) rotate(-6deg) scale(0.95);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: translate3d(0px, -20px, 0px) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate3d(0px, 10px, -15px) rotate(2deg) scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
75%, 100% {
|
||||
transform: translate3d(0px, 25px, -25px) rotate(4deg) scale(0.75);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-md text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.empty_state_folders_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_folders_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
@if(!isset($hideCreateButton) || !$hideCreateButton)
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_folder_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
128
resources/views/table/empty-state-tags.blade.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<div class="flex flex-col items-center justify-center gap-6 px-4 py-10 md:min-h-[550px]">
|
||||
<!-- Tags Visual Grid -->
|
||||
<div class="relative flex items-center justify-center h-40 w-full max-w-xs md:max-w-md overflow-hidden rounded-2xl border border-neutral-200/60 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/30 p-6 shadow-inner">
|
||||
<div class="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
|
||||
|
||||
<!-- Orbiting Tags Container -->
|
||||
<div class="relative w-64 h-32 flex items-center justify-center z-10">
|
||||
<!-- Orbit Path Visual -->
|
||||
<svg class="absolute w-56 h-20 opacity-20 dark:opacity-30 text-neutral-300 dark:text-neutral-700" viewBox="0 0 220 80" fill="none">
|
||||
<ellipse cx="110" cy="40" rx="100" ry="32" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 4" />
|
||||
</svg>
|
||||
|
||||
<!-- Central URL Card -->
|
||||
<div class="relative w-36 bg-white dark:bg-neutral-900 border border-neutral-200/80 dark:border-neutral-800 rounded-xl px-2.5 py-2 shadow-md flex items-center gap-2 select-none"
|
||||
style="animation: fsu-tag-center-card-float 4s infinite ease-in-out; z-index: 10;">
|
||||
<div class="w-5 h-5 rounded-lg bg-purple-500/10 dark:bg-purple-500/20 flex items-center justify-center text-purple-600 dark:text-purple-400 flex-shrink-0">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<span class="text-[10px] font-bold text-neutral-800 dark:text-neutral-200 font-mono leading-none truncate max-w-[85px] block" title="{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}">
|
||||
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? request()->getHost() }}/{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}
|
||||
</span>
|
||||
<span class="text-[7.5px] text-neutral-400 dark:text-neutral-500 font-sans mt-0.5">{{ __('filament-short-url::default.empty_state_tags_mock_redirecting') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orbiting Badges -->
|
||||
<!-- Tag 1: promo (Purple) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-purple-100 dark:bg-purple-950/60 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: 0s;">
|
||||
<span class="text-purple-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_promo') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 2: social (Teal) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-teal-100 dark:bg-teal-950/60 text-teal-700 dark:text-teal-300 border border-teal-200 dark:border-teal-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -2.66s;">
|
||||
<span class="text-teal-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_social') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tag 3: dev (Amber) -->
|
||||
<div class="absolute px-2 py-0.5 rounded-full text-[9px] font-semibold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/50 shadow-sm flex items-center gap-1 select-none"
|
||||
style="animation: fsu-tag-orbit 8s infinite linear; animation-delay: -5.33s;">
|
||||
<span class="text-amber-400 font-bold">#</span>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tags_mock_dev') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fsu-tag-center-card-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(-1deg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) rotate(1deg);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
@keyframes fsu-tag-orbit {
|
||||
0% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
4% {
|
||||
z-index: 20;
|
||||
}
|
||||
25% {
|
||||
transform: translate3d(0px, 32px, 0px) scale(1.05);
|
||||
z-index: 20;
|
||||
opacity: 1;
|
||||
}
|
||||
46% {
|
||||
z-index: 20;
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
54% {
|
||||
z-index: 2;
|
||||
}
|
||||
75% {
|
||||
transform: translate3d(0px, -32px, 0px) scale(0.8);
|
||||
z-index: 2;
|
||||
opacity: 0.5;
|
||||
}
|
||||
96% {
|
||||
z-index: 2;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-100px, 0px, 0px) scale(0.9);
|
||||
z-index: 2;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Title and Description -->
|
||||
<div class="max-w-md text-pretty text-center px-4">
|
||||
<span class="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ __('filament-short-url::default.empty_state_tags_heading') }}
|
||||
</span>
|
||||
<div class="mt-2 text-pretty text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{ __('filament-short-url::default.empty_state_tags_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
@if(!isset($hideCreateButton) || !$hideCreateButton)
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button"
|
||||
x-on:click="$wire.mountAction('create')"
|
||||
class="group flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-black bg-black text-white hover:bg-neutral-800 dark:border-white dark:bg-white dark:text-black dark:hover:bg-neutral-100 hover:ring-4 hover:ring-neutral-200 dark:hover:ring-neutral-800/50 px-4 text-sm font-semibold transition-all cursor-pointer">
|
||||
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>{{ __('filament-short-url::default.empty_state_tag_action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
107
resources/views/table/folder-card-column.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map folder color to valid Tailwind classes matching the premium demo design
|
||||
$colorClasses = match ($color) {
|
||||
'gray' => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
'red' => [
|
||||
'border_outer' => 'border-red-200 dark:border-red-900/30',
|
||||
'bg_inner' => 'bg-red-100 dark:bg-red-950/40',
|
||||
'text' => 'text-red-800 dark:text-red-400',
|
||||
'badge_bg' => 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-350',
|
||||
],
|
||||
'blue' => [
|
||||
'border_outer' => 'border-blue-200 dark:border-blue-900/30',
|
||||
'bg_inner' => 'bg-blue-100 dark:bg-blue-950/40',
|
||||
'text' => 'text-blue-800 dark:text-blue-400',
|
||||
'badge_bg' => 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-350',
|
||||
],
|
||||
'green' => [
|
||||
'border_outer' => 'border-emerald-200 dark:border-emerald-900/30',
|
||||
'bg_inner' => 'bg-emerald-100 dark:bg-emerald-950/40',
|
||||
'text' => 'text-emerald-800 dark:text-emerald-400',
|
||||
'badge_bg' => 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-350',
|
||||
],
|
||||
'yellow' => [
|
||||
'border_outer' => 'border-amber-200 dark:border-amber-900/30',
|
||||
'bg_inner' => 'bg-amber-100 dark:bg-amber-950/40',
|
||||
'text' => 'text-amber-800 dark:text-amber-400',
|
||||
'badge_bg' => 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-350',
|
||||
],
|
||||
'indigo' => [
|
||||
'border_outer' => 'border-indigo-200 dark:border-indigo-900/30',
|
||||
'bg_inner' => 'bg-indigo-100 dark:bg-indigo-950/40',
|
||||
'text' => 'text-indigo-800 dark:text-indigo-400',
|
||||
'badge_bg' => 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-350',
|
||||
],
|
||||
'purple' => [
|
||||
'border_outer' => 'border-purple-200 dark:border-purple-900/30',
|
||||
'bg_inner' => 'bg-purple-100 dark:bg-purple-950/40',
|
||||
'text' => 'text-purple-800 dark:text-purple-400',
|
||||
'badge_bg' => 'bg-purple-100 dark:bg-purple-900/30 text-purple-750 dark:text-purple-350',
|
||||
],
|
||||
'pink' => [
|
||||
'border_outer' => 'border-pink-200 dark:border-pink-900/30',
|
||||
'bg_inner' => 'bg-pink-100 dark:bg-pink-950/40',
|
||||
'text' => 'text-pink-800 dark:text-pink-400',
|
||||
'badge_bg' => 'bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-350',
|
||||
],
|
||||
default => [
|
||||
'border_outer' => 'border-neutral-200 dark:border-neutral-700',
|
||||
'bg_inner' => 'bg-neutral-100 dark:bg-neutral-800',
|
||||
'text' => 'text-neutral-600 dark:text-neutral-400',
|
||||
'badge_bg' => 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-350',
|
||||
],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col justify-between h-full w-full select-none">
|
||||
|
||||
<!-- Top Row: Icon Container -->
|
||||
<div class="flex items-center justify-between z-10 pointer-events-none">
|
||||
<div class="border rounded-full bg-white dark:bg-neutral-850 p-0.5 {{ $colorClasses['border_outer'] }}">
|
||||
<div class="rounded-full p-2 {{ $colorClasses['bg_inner'] }} {{ $colorClasses['text'] }}">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row: Title, Badges, Link count -->
|
||||
<div class="z-10 pointer-events-none">
|
||||
<span class="flex items-center justify-start gap-1.5 truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
<span class="truncate">{{ $record->name }}</span>
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded {{ $colorClasses['badge_bg'] }} font-mono">
|
||||
/{{ $record->slug }}
|
||||
</span>
|
||||
@if($record->slug === 'default' || $record->slug === 'links')
|
||||
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
Default
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="mt-1.5 flex items-center gap-1 text-neutral-500 dark:text-neutral-400">
|
||||
<svg height="18" width="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-sm font-normal">{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
53
resources/views/table/tag-row-column.blade.php
Normal file
@@ -0,0 +1,53 @@
|
||||
@php
|
||||
$record = $getRecord();
|
||||
$color = $record->color ?? 'gray';
|
||||
|
||||
// Map tag color to icon + badge colors — same hex values as the Select options
|
||||
$colorMap = match ($color) {
|
||||
'red' => ['icon_bg' => '#fee2e2', 'icon_color' => '#ef4444'],
|
||||
'blue' => ['icon_bg' => '#dbeafe', 'icon_color' => '#3b82f6'],
|
||||
'green' => ['icon_bg' => '#d1fae5', 'icon_color' => '#10b981'],
|
||||
'yellow' => ['icon_bg' => '#fef3c7', 'icon_color' => '#f59e0b'],
|
||||
'indigo' => ['icon_bg' => '#e0e7ff', 'icon_color' => '#6366f1'],
|
||||
'purple' => ['icon_bg' => '#f3e8ff', 'icon_color' => '#a855f7'],
|
||||
'pink' => ['icon_bg' => '#fce7f3', 'icon_color' => '#ec4899'],
|
||||
default => ['icon_bg' => '#f3f4f6', 'icon_color' => '#737373'],
|
||||
};
|
||||
|
||||
$linkCount = $record->short_urls_count ?? 0;
|
||||
$linkString = $linkCount === 1 ? __('filament-short-url::default.link_count_one') : __('filament-short-url::default.link_count_many', ['count' => $linkCount]);
|
||||
if (str_contains($linkString, 'filament-short-url::')) {
|
||||
$linkString = $linkCount === 1 ? '1 link' : "{$linkCount} links";
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3 select-none py-0.5">
|
||||
{{-- Left: icon + name --}}
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{{-- Colored tag icon --}}
|
||||
<div class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full"
|
||||
style="background-color: {{ $colorMap['icon_bg'] }};">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="{{ $colorMap['icon_color'] }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2H7a2 2 0 0 0-2 2v5.586a1 1 0 0 0 .293.707l9.414 9.414a2 2 0 0 0 2.828 0l4.172-4.172a2 2 0 0 0 0-2.828L12.707 2.293A1 1 0 0 0 12 2z"/>
|
||||
<circle cx="7.5" cy="7.5" r="1" fill="{{ $colorMap['icon_color'] }}" stroke="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{{-- Name --}}
|
||||
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100 truncate">
|
||||
{{ $record->name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Right: link count badge --}}
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 mr-2 border border-neutral-200 dark:border-neutral-800 rounded-full px-2.5 py-1 text-neutral-500 dark:text-neutral-400 text-xs font-medium bg-white dark:bg-neutral-900">
|
||||
<svg height="14" width="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 flex-shrink-0">
|
||||
<g fill="currentColor">
|
||||
<ellipse cx="9" cy="9" fill="none" rx="7.25" ry="3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<ellipse cx="9" cy="9" fill="none" rx="3" ry="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></ellipse>
|
||||
<circle cx="9" cy="9" fill="none" r="7.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<span>{{ $linkString }}</span>
|
||||
</div>
|
||||
</div>
|
||||
153
resources/views/widgets/live-feed.blade.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<x-filament-widgets::widget>
|
||||
{{--
|
||||
wire:poll calls checkForUpdates() instead of blindly re-rendering.
|
||||
checkForUpdates() does a single MAX(id) query; if nothing changed it
|
||||
calls skipRender() and Livewire returns a ~100-byte "no diff" response.
|
||||
Only when a new visit is detected does the full render happen.
|
||||
.visible stops polling entirely when the widget is scrolled out of view.
|
||||
--}}
|
||||
<div wire:poll.5s.visible="checkForUpdates"
|
||||
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ __('filament-short-url::default.stats_tab_live_feed') }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-6 relative min-h-[300px]">
|
||||
<!-- Spinner shown only during re-renders that actually happen -->
|
||||
<div wire:loading class="absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-[1px] z-10 transition-opacity">
|
||||
<div class="flex items-center justify-center h-full w-full">
|
||||
<x-filament::loading-indicator class="h-7 w-7 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4" wire:loading.class="opacity-40 pointer-events-none transition-opacity">
|
||||
@forelse ($visits as $visit)
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 border border-gray-100 dark:border-white/5 rounded-xl hover:bg-gray-50/50 dark:hover:bg-white/5 transition-all">
|
||||
|
||||
<!-- Visitor & Location Info -->
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
{{-- Flag from flagcdn.com. Alpine handles broken image gracefully without
|
||||
inline onerror JS (which can violate CSP nonce policies). --}}
|
||||
<div class="mt-0.5 shrink-0 w-6 flex items-center" title="{{ $visit['country'] }}">
|
||||
@if ($visit['flag_url'])
|
||||
<span x-data="{ error: false }">
|
||||
<img x-show="!error"
|
||||
x-on:error="error = true"
|
||||
src="{{ $visit['flag_url'] }}"
|
||||
width="20"
|
||||
height="15"
|
||||
alt="{{ $visit['country_code'] }}"
|
||||
class="rounded-[2px] object-cover shadow-sm">
|
||||
<span x-show="error" class="text-base leading-none">🌐</span>
|
||||
</span>
|
||||
@else
|
||||
<span class="text-base leading-none">🌐</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ $visit['country_code'] }}' })"
|
||||
class="text-sm font-semibold text-gray-950 dark:text-white hover:underline truncate">
|
||||
{{ $visit['city'] ?: 'Unknown City' }}, {{ $visit['country'] ?: 'Unknown Country' }}
|
||||
</button>
|
||||
|
||||
@if ($visit['is_qr_scan'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-50 text-purple-700 dark:bg-purple-950/40 dark:text-purple-300 border border-purple-100 dark:border-purple-900/40">
|
||||
<x-filament::icon icon="heroicon-m-qr-code" class="w-3.5 h-3.5" />
|
||||
QR Scan
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($visit['selected_variant'])
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 border border-amber-100 dark:border-amber-900/40">
|
||||
Variant: {{ $visit['selected_variant'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 flex items-center gap-2">
|
||||
{{-- time_ago precomputed in PHP — no Carbon::parse() per-row in Blade --}}
|
||||
<span>{{ $visit['time_ago'] }}</span>
|
||||
<span>•</span>
|
||||
<span class="font-mono text-[11px]">{{ $visit['ip_address'] ?: '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrer -->
|
||||
<div class="flex items-center gap-2 min-w-[150px] max-w-[220px]">
|
||||
<x-filament::icon icon="heroicon-m-link" class="w-4 h-4 text-gray-400 dark:text-gray-500 shrink-0" />
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 truncate"
|
||||
title="{{ $visit['referer_url'] }}">
|
||||
{{ $visit['referer_host'] ?: __('filament-short-url::default.stats_referer_direct') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Device / Browser / OS filters -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Device -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ $visit['device_type'] }}' })"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by device: {{ $visit['device_type'] }}">
|
||||
@if (strtolower($visit['device_type'] ?? '') === 'desktop')
|
||||
<x-filament::icon icon="heroicon-m-computer-desktop" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'mobile')
|
||||
<x-filament::icon icon="heroicon-m-device-phone-mobile" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@elseif (strtolower($visit['device_type'] ?? '') === 'tablet')
|
||||
<x-filament::icon icon="heroicon-m-device-tablet" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@else
|
||||
<x-filament::icon icon="heroicon-m-question-mark-circle" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
@endif
|
||||
<span class="capitalize">{{ $visit['device_type'] ?: 'Unknown' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Browser -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ $visit['browser'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by browser: {{ $visit['browser'] }}">
|
||||
<x-filament::icon icon="heroicon-m-globe-alt" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['browser'] ?: 'Browser' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- OS -->
|
||||
<button type="button"
|
||||
x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ $visit['operating_system'] }}' })"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
title="Filter by OS: {{ $visit['operating_system'] }}">
|
||||
<x-filament::icon icon="heroicon-m-cpu-chip" class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ $visit['operating_system'] ?: 'OS' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-50 text-gray-400 dark:bg-gray-800/30 dark:text-gray-500">
|
||||
<x-filament::icon icon="heroicon-o-chat-bubble-left-right" class="h-6 w-6" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('filament-short-url::default.stats_live_feed_empty') }}
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
@@ -20,12 +20,20 @@ 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([
|
||||
AuthenticateShortUrlApi::class,
|
||||
'throttle:60,1',
|
||||
])
|
||||
->group(function () {
|
||||
Route::get('links', [ShortUrlApiController::class, 'index']);
|
||||
@@ -45,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']));
|
||||
}
|
||||
|
||||
@@ -27,12 +27,22 @@ class AggregateAndPruneVisitsCommand extends Command
|
||||
default => 'DATE(visited_at)',
|
||||
};
|
||||
|
||||
// 1. Find all unique dates before today that have visits (optimized range and compatible DATE extract)
|
||||
$dates = ShortUrlVisit::where('visited_at', '<', $today)
|
||||
->selectRaw("{$dateExpression} as visit_date")
|
||||
->distinct()
|
||||
->pluck('visit_date')
|
||||
->toArray();
|
||||
// 1. Find the oldest and newest visit dates before today using highly optimized min/max index scanning
|
||||
$oldestVisit = ShortUrlVisit::where('visited_at', '<', $today)->min('visited_at');
|
||||
if (! $oldestVisit) {
|
||||
$dates = [];
|
||||
} else {
|
||||
$latestVisit = ShortUrlVisit::where('visited_at', '<', $today)->max('visited_at');
|
||||
$startCarbon = Carbon::parse($oldestVisit)->startOfDay();
|
||||
$endCarbon = Carbon::parse($latestVisit)->startOfDay();
|
||||
|
||||
$dates = [];
|
||||
$current = $startCarbon->copy();
|
||||
while ($current->lte($endCarbon)) {
|
||||
$dates[] = $current->toDateString();
|
||||
$current->addDay();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($dates)) {
|
||||
$this->info('No historical visits to aggregate.');
|
||||
|
||||
158
src/Filament/Resources/ShortUrlFolderResource.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages\ListShortUrlFolders;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
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;
|
||||
|
||||
class ShortUrlFolderResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlFolder::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static ?int $navigationSort = 53;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folder_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.folders_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 3;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug', ignoreRecord: true),
|
||||
|
||||
Select::make('color')
|
||||
->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>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Stack::make([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->view('filament-short-url::table.folder-card-column')
|
||||
->counts('shortUrls')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])->extraAttributes([
|
||||
'class' => 'h-full',
|
||||
]),
|
||||
])
|
||||
->contentGrid([
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->recordUrl(
|
||||
fn (ShortUrlFolder $record): string => ShortUrlResource::getUrl('index', [
|
||||
'filters' => ['folder' => ['values' => [$record->id]]],
|
||||
])
|
||||
)
|
||||
->recordClasses(fn (): string => 'folder-card relative flex flex-col justify-between rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-5 py-4 transition-all duration-200 h-[120px] sm:h-[132px] group/card hover:shadow-md hover:border-neutral-350 dark:hover:border-neutral-700')
|
||||
->filters([])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->label(__('filament-short-url::default.action_edit_folder'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->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;',
|
||||
]),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-folders'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlFolders::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlFolders extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlFolderResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_folder_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->createAnother(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ListShortUrls;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLiveFeed;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlLogs;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortUrlForm;
|
||||
@@ -111,6 +112,7 @@ class ShortUrlResource extends Resource
|
||||
return [
|
||||
'index' => ListShortUrls::route('/'),
|
||||
'stats' => ViewShortUrlStats::route('/{record}/stats'),
|
||||
'stats.live' => ViewShortUrlLiveFeed::route('/{record}/stats/live'),
|
||||
'stats.logs' => ViewShortUrlLogs::route('/{record}/stats/logs'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class ListShortUrls extends ManageRecords
|
||||
@@ -395,4 +396,14 @@ HTML;
|
||||
ShortUrlGlobalOverview::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'active' => Tab::make(__('filament-short-url::default.tab_active_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', false)),
|
||||
'archived' => Tab::make(__('filament-short-url::default.tab_archived_links'))
|
||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,29 @@ class DeveloperTab
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->default(fn () => 'sh_key_'.bin2hex(random_bytes(16))),
|
||||
Select::make('scope')
|
||||
->label(__('filament-short-url::default.api_key_scope'))
|
||||
->options([
|
||||
'links:read-write' => __('filament-short-url::default.api_key_scope_read_write'),
|
||||
'links:read-only' => __('filament-short-url::default.api_key_scope_read_only'),
|
||||
])
|
||||
->default('links:read-write')
|
||||
->required(),
|
||||
Select::make('rate_limit')
|
||||
->label(__('filament-short-url::default.api_key_rate_limit'))
|
||||
->options([
|
||||
'60' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 60]),
|
||||
'120' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 120]),
|
||||
'300' => __('filament-short-url::default.api_key_rate_limit_rpm', ['count' => 300]),
|
||||
'0' => __('filament-short-url::default.api_key_rate_limit_unlimited'),
|
||||
])
|
||||
->default('60')
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->label(__('filament-short-url::default.active'))
|
||||
->default(true),
|
||||
])
|
||||
->columns(3)
|
||||
->columns(5)
|
||||
->default([]),
|
||||
]),
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 3;
|
||||
return ShortUrlResource::getNavigationSort() + 99;
|
||||
} catch (\Throwable) {
|
||||
return 53;
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\Page;
|
||||
|
||||
class ViewShortUrlLiveFeed extends Page
|
||||
{
|
||||
protected static string $resource = ShortUrlResource::class;
|
||||
|
||||
protected string $view = 'filament-short-url::live';
|
||||
|
||||
protected static ?string $title = 'Live Feed';
|
||||
|
||||
public ShortUrl $record;
|
||||
|
||||
public function mount(ShortUrl $record): void
|
||||
{
|
||||
$this->record = $record;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'record' => $this->record,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('back')
|
||||
->label(__('filament-short-url::default.stats_btn_back'))
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(ShortUrlResource::getUrl()),
|
||||
|
||||
Action::make('copy_url')
|
||||
->label(__('filament-short-url::default.stats_btn_copy'))
|
||||
->icon('heroicon-o-clipboard')
|
||||
->color('gray')
|
||||
->extraAttributes([
|
||||
'x-on:click' => 'navigator.clipboard.writeText("'.$this->record->getShortUrl().'")',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return __('filament-short-url::default.stats_tab_live_feed').' — '.$this->record->url_key;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumm::make('referer_host')
|
||||
Tables\Columns\TextColumn::make('referer_host')
|
||||
->label(__('filament-short-url::default.stats_col_referrer'))
|
||||
->icon('heroicon-o-link')
|
||||
->formatStateUsing(fn ($state) => empty($state) ? __('filament-short-url::default.stats_referer_direct') : $state)
|
||||
|
||||
@@ -32,6 +32,8 @@ class ViewShortUrlStats extends Page implements HasForms
|
||||
|
||||
public array $activeFilters = [];
|
||||
|
||||
public string $activeTab = 'statistics';
|
||||
|
||||
#[On('set-stats-filter')]
|
||||
public function setStatsFilter(string $key, $value): void
|
||||
{
|
||||
|
||||
@@ -341,6 +341,88 @@ class LinkTab
|
||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
||||
])->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.folders_navigation_label').' & '.__('filament-short-url::default.tags_navigation_label'))
|
||||
->schema([
|
||||
Select::make('folder_id')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->nullable()
|
||||
->searchable()
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug'),
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
]),
|
||||
|
||||
Select::make('tags')
|
||||
->label(__('filament-short-url::default.tags_navigation_label'))
|
||||
->multiple()
|
||||
->maxItems(5)
|
||||
->relationship('tags', 'name')
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.tag_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_tags', 'slug'),
|
||||
Select::make('color')
|
||||
->label(__('filament-short-url::default.tag_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
]),
|
||||
|
||||
Toggle::make('is_archived')
|
||||
->label(__('filament-short-url::default.is_archived'))
|
||||
->helperText(__('filament-short-url::default.is_archived_helper'))
|
||||
->default(false)
|
||||
->inline(false)
|
||||
->hiddenOn('create')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make(__('filament-short-url::default.form_section_notes'))->schema([
|
||||
Textarea::make('notes')
|
||||
->label(__('filament-short-url::default.notes'))
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -60,6 +61,18 @@ class ShortUrlsTable
|
||||
'0' => 'Multi-use',
|
||||
'1' => 'Single-use',
|
||||
]),
|
||||
|
||||
SelectFilter::make('folder')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->preload()
|
||||
->multiple(),
|
||||
|
||||
SelectFilter::make('tags')
|
||||
->label(__('filament-short-url::default.tags_navigation_label'))
|
||||
->relationship('tags', 'name')
|
||||
->preload()
|
||||
->multiple(),
|
||||
])
|
||||
->recordClasses(fn (): string => 'short-url-card group/card')
|
||||
->actions([
|
||||
@@ -143,6 +156,64 @@ class ShortUrlsTable
|
||||
->icon('heroicon-o-chart-bar')
|
||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
||||
|
||||
ActionGroup::make([
|
||||
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()))
|
||||
->modalWidth('md')
|
||||
->modalSubmitActionLabel(__('filament-short-url::default.action_move'))
|
||||
->fillForm(fn (ShortUrl $record): array => [
|
||||
'folder_id' => $record->folder_id,
|
||||
])
|
||||
->form(fn (ShortUrl $record): array => [
|
||||
Forms\Components\Select::make('folder_id')
|
||||
->label(__('filament-short-url::default.folder_resource_title'))
|
||||
->relationship('folder', 'name')
|
||||
->preload()
|
||||
->searchable()
|
||||
->createOptionForm([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label(__('filament-short-url::default.folder_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.folder_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_folders', 'slug'),
|
||||
Forms\Components\Select::make('color')
|
||||
->label(__('filament-short-url::default.folder_color'))
|
||||
->options([
|
||||
'gray' => 'Gray',
|
||||
'red' => 'Red',
|
||||
'blue' => 'Blue',
|
||||
'green' => 'Green',
|
||||
'yellow' => 'Yellow',
|
||||
'indigo' => 'Indigo',
|
||||
'purple' => 'Purple',
|
||||
'pink' => 'Pink',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
]),
|
||||
])
|
||||
->action(function (ShortUrl $record, array $data): void {
|
||||
$record->update(['folder_id' => $data['folder_id']]);
|
||||
}),
|
||||
|
||||
Action::make('archive')
|
||||
->label(fn (ShortUrl $record) => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.($record->is_archived ? __('filament-short-url::default.action_restore') : __('filament-short-url::default.action_archive')).'</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">'.($record->is_archived ? 'R' : 'A').'</span></div>'))
|
||||
->icon(fn (ShortUrl $record) => $record->is_archived
|
||||
? 'heroicon-o-arrow-path-rounded-square'
|
||||
: 'heroicon-o-archive-box')
|
||||
->color('warning')
|
||||
->action(fn (ShortUrl $record) => $record->update(['is_archived' => ! $record->is_archived])),
|
||||
])->dropdown(false),
|
||||
|
||||
ActionGroup::make([
|
||||
DeleteAction::make()
|
||||
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left text-red-600 dark:text-red-400 font-semibold"><span>'.__('filament-short-url::default.action_delete').'</span><span class="text-[10px] bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 rounded px-1.5 py-0.5 ml-auto font-mono font-normal">X</span></div>'))
|
||||
@@ -247,11 +318,25 @@ class ShortUrlsTable
|
||||
->action(fn ($records) => $records->each->update(['is_enabled' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('archive')
|
||||
->label(__('filament-short-url::default.action_archive_selected'))
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('warning')
|
||||
->action(fn ($records) => $records->each->update(['is_archived' => true]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
BulkAction::make('restore')
|
||||
->label(__('filament-short-url::default.action_restore_selected'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('success')
|
||||
->action(fn ($records) => $records->each->update(['is_archived' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain']))
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['user', 'customDomain', 'folder', 'tags']))
|
||||
->recordUrl(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record]))
|
||||
->emptyState(view('filament-short-url::table.empty-state'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ShortUrlLiveFeedWidget extends Widget
|
||||
{
|
||||
use HasStatsFilters;
|
||||
|
||||
public ?ShortUrl $record = null;
|
||||
|
||||
public ?string $dateFrom = null;
|
||||
|
||||
public ?string $dateTo = null;
|
||||
|
||||
/**
|
||||
* ID of the most recently rendered visit.
|
||||
* Stored in the Livewire snapshot so each browser tab tracks independently.
|
||||
* Initialized to -1 so mount can detect "first load" vs "subsequent polls".
|
||||
*/
|
||||
public int $latestVisitId = -1;
|
||||
|
||||
protected string $view = 'filament-short-url::widgets.live-feed';
|
||||
|
||||
/**
|
||||
* Called on every wire:poll tick.
|
||||
*
|
||||
* Does a single O(1) MAX(id) query against the composite index.
|
||||
* If nothing changed since the last render → skipRender() → ~100-byte response.
|
||||
* Only when the cursor advances does a full re-render occur.
|
||||
*/
|
||||
public function checkForUpdates(): void
|
||||
{
|
||||
if (! $this->record) {
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latest = (int) ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->max('id');
|
||||
|
||||
if ($latest === $this->latestVisitId) {
|
||||
// Nothing new — skip rendering entirely.
|
||||
$this->skipRender();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// New visit detected — update the cursor; render is triggered automatically.
|
||||
$this->latestVisitId = $latest;
|
||||
}
|
||||
|
||||
protected function getViewData(): array
|
||||
{
|
||||
if (! $this->record) {
|
||||
return ['visits' => []];
|
||||
}
|
||||
|
||||
// BUG FIX #1: Include latestVisitId in the cache key.
|
||||
// Without this, user B could get a 3-second-old cache response that doesn't
|
||||
// include the visit that triggered their re-render, then skipRender() on every
|
||||
// subsequent poll because their latestVisitId is already up-to-date — causing
|
||||
// them to permanently miss that visit until the next new visit arrives.
|
||||
$filtersHash = md5(json_encode($this->filters ?? []));
|
||||
$cacheKey = "live_feed_{$this->record->id}_{$this->dateFrom}_{$this->dateTo}_{$filtersHash}_{$this->latestVisitId}";
|
||||
|
||||
$visits = cache()->remember($cacheKey, 3, function () {
|
||||
$query = ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($this->dateFrom) {
|
||||
$query->where('visited_at', '>=', $this->dateFrom.' 00:00:00');
|
||||
}
|
||||
if ($this->dateTo) {
|
||||
$query->where('visited_at', '<=', $this->dateTo.' 23:59:59');
|
||||
}
|
||||
|
||||
$this->record->applyStatsFilters($query, $this->filters ?? []);
|
||||
|
||||
return $query
|
||||
->latest('visited_at')
|
||||
->limit(25)
|
||||
->get([
|
||||
'id', 'visited_at', 'country', 'country_code', 'city',
|
||||
'browser', 'operating_system', 'device_type',
|
||||
'referer_host', 'referer_url', 'ip_address',
|
||||
'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
|
||||
? 'https://flagcdn.com/h20/'.strtolower($visit->country_code).'.webp'
|
||||
: null,
|
||||
'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,
|
||||
'selected_variant' => $visit->selected_variant,
|
||||
])
|
||||
->all();
|
||||
});
|
||||
|
||||
// BUG FIX #2: Initialize the cursor on first load so the very first poll
|
||||
// does not trigger an unnecessary re-render.
|
||||
// $latestVisitId starts at -1 (sentinel). After the initial getViewData()
|
||||
// we set it to the actual MAX id so checkForUpdates() correctly skips
|
||||
// re-renders when there are no new visits.
|
||||
if ($this->latestVisitId === -1) {
|
||||
$this->latestVisitId = ! empty($visits)
|
||||
? (int) $visits[0]['id']
|
||||
: (int) ShortUrlVisit::query()
|
||||
->where('short_url_id', $this->record->id)
|
||||
->where('is_bot', false)
|
||||
->where('is_proxy', false)
|
||||
->max('id');
|
||||
}
|
||||
|
||||
return ['visits' => $visits];
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,10 @@ class ShortUrlWorldMapWidget extends Widget
|
||||
->where('is_proxy', false);
|
||||
|
||||
if ($dateFromClean) {
|
||||
$query->whereDate('visited_at', '>=', $dateFromClean);
|
||||
$query->where('visited_at', '>=', $dateFromClean.' 00:00:00');
|
||||
}
|
||||
if ($dateToClean) {
|
||||
$query->whereDate('visited_at', '<=', $dateToClean);
|
||||
$query->where('visited_at', '<=', $dateToClean.' 23:59:59');
|
||||
}
|
||||
|
||||
$this->record->applyStatsFilters($query, $this->filters);
|
||||
|
||||
141
src/Filament/Resources/ShortUrlTagResource.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages\ListShortUrlTags;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShortUrlTagResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ShortUrlTag::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
|
||||
|
||||
protected static ?int $navigationSort = 54;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tags_navigation_label');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tag_resource_title');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('filament-short-url::default.tags_navigation_label');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|\UnitEnum|null
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationGroup();
|
||||
} catch (\Throwable) {
|
||||
return __('filament-short-url::default.navigation_group');
|
||||
}
|
||||
}
|
||||
|
||||
public static function getNavigationSort(): ?int
|
||||
{
|
||||
try {
|
||||
return ShortUrlResource::getNavigationSort() + 4;
|
||||
} catch (\Throwable) {
|
||||
return static::$navigationSort;
|
||||
}
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('slug', Str::slug($state))),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label(__('filament-short-url::default.tag_slug'))
|
||||
->required()
|
||||
->maxLength(100)
|
||||
->unique('short_url_tags', 'slug', ignoreRecord: true),
|
||||
|
||||
Select::make('color')
|
||||
->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>',
|
||||
])
|
||||
->default('gray')
|
||||
->required()
|
||||
->native(false),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('filament-short-url::default.tag_name'))
|
||||
->view('filament-short-url::table.tag-row-column')
|
||||
->counts('shortUrls')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
])
|
||||
->recordUrl(
|
||||
fn (ShortUrlTag $record): string => ShortUrlResource::getUrl('index', [
|
||||
'filters' => ['tags' => ['values' => [$record->id]]],
|
||||
])
|
||||
)
|
||||
->filters([])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('gray')
|
||||
->label(__('filament-short-url::default.action_edit_tag'))
|
||||
->modalWidth('md'),
|
||||
DeleteAction::make()
|
||||
->icon('heroicon-o-trash')
|
||||
->label(__('filament-short-url::default.action_delete_tag')),
|
||||
])
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->iconButton(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->emptyState(view('filament-short-url::table.empty-state-tags'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListShortUrlTags::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource\Pages;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ManageRecords;
|
||||
|
||||
class ListShortUrlTags extends ManageRecords
|
||||
{
|
||||
protected static string $resource = ShortUrlTagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make()
|
||||
->label(__('filament-short-url::default.empty_state_tag_action'))
|
||||
->modalHeading(__('filament-short-url::default.empty_state_tag_action'))
|
||||
->icon('heroicon-o-plus')
|
||||
->size('sm')
|
||||
->color('primary')
|
||||
->modalWidth('md')
|
||||
->modalAutofocus(false)
|
||||
->createAnother(false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
namespace Bjanczak\FilamentShortUrl;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlCustomDomainResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlFolderResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ShortUrlSettingsPage;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlTagResource;
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
@@ -55,6 +57,8 @@ class FilamentShortUrlPlugin implements Plugin
|
||||
ShortUrlResource::class,
|
||||
ShortUrlPixelResource::class,
|
||||
ShortUrlCustomDomainResource::class,
|
||||
ShortUrlFolderResource::class,
|
||||
ShortUrlTagResource::class,
|
||||
])
|
||||
->pages([
|
||||
ShortUrlSettingsPage::class,
|
||||
|
||||
@@ -42,6 +42,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
||||
'2026_06_05_000001_add_user_id_to_short_urls_table',
|
||||
'2026_06_05_000002_create_short_url_custom_domains_table',
|
||||
'2026_06_05_000003_add_custom_domain_id_to_short_urls_table',
|
||||
'2026_06_06_000001_create_short_url_folders_and_tags_tables',
|
||||
])
|
||||
->hasCommands([
|
||||
SyncBufferedCountersCommand::class,
|
||||
|
||||
@@ -11,14 +11,12 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
|
||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
@@ -55,9 +53,6 @@ class ShortUrlApiController extends Controller
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
// Fire 'created' webhook if active
|
||||
$this->dispatchCreatedWebhook($shortUrl);
|
||||
|
||||
return (new ShortUrlResource($shortUrl))
|
||||
->additional(['message' => 'Short URL created successfully.'])
|
||||
->response()
|
||||
@@ -149,47 +144,4 @@ class ShortUrlApiController extends Controller
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook if global or per-link webhook is active for 'created' event.
|
||||
*/
|
||||
private function dispatchCreatedWebhook(ShortUrl $shortUrl): void
|
||||
{
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
if (empty($targetUrl) && ! empty($globalUrl) && in_array('created', $events)) {
|
||||
$targetUrl = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($targetUrl)) {
|
||||
try {
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
|
||||
$job = new SendWebhookJob(
|
||||
url: $targetUrl,
|
||||
event: 'created',
|
||||
payload: [
|
||||
'event' => 'created',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => (new ShortUrlResource($shortUrl))->resolve(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
$job->onConnection($connection);
|
||||
} else {
|
||||
$job->onConnection('sync');
|
||||
}
|
||||
|
||||
dispatch($job);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Created webhook dispatch failed', [
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ class ShortUrlRedirectController extends Controller
|
||||
|
||||
// Redirect to custom expiration URL if defined, otherwise 410 Gone if disabled or expired
|
||||
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);
|
||||
}
|
||||
@@ -105,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)
|
||||
@@ -193,7 +171,7 @@ class ShortUrlRedirectController extends Controller
|
||||
$selectedVariant = app()->bound('resolved_ab_variant') ? app('resolved_ab_variant') : null;
|
||||
|
||||
$job = new TrackShortUrlVisitJob(
|
||||
shortUrl: $shortUrl,
|
||||
shortUrlId: $shortUrl->id,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $request->userAgent() ?? '',
|
||||
refererUrl: $request->header('Referer'),
|
||||
@@ -333,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Http\Middleware;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateShortUrlApi
|
||||
@@ -41,6 +42,7 @@ class AuthenticateShortUrlApi
|
||||
$keys = $this->mgr->get('api_keys', []);
|
||||
|
||||
$valid = false;
|
||||
$matchedKey = null;
|
||||
$hashedInput = hash('sha256', $apiKey);
|
||||
|
||||
foreach ($keys as $keyObj) {
|
||||
@@ -53,23 +55,52 @@ class AuthenticateShortUrlApi
|
||||
if ($storedHash) {
|
||||
if (hash_equals($storedHash, $hashedInput)) {
|
||||
$valid = true;
|
||||
$matchedKey = $keyObj;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$storedPlain = $keyObj['key'] ?? '';
|
||||
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
|
||||
$valid = true;
|
||||
$matchedKey = $keyObj;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $valid) {
|
||||
if (! $valid || ! $matchedKey) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized. Invalid or inactive API Key.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
// 1. API Scope Authorization (backward compatible with read-write)
|
||||
$scope = $matchedKey['scope'] ?? 'links:read-write';
|
||||
if ($scope === 'links:read-only' && ! $request->isMethod('GET')) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden. This API key has read-only permissions.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. Per-Key Rate Limiting (default to 60 rpm if unspecified, 0 is Unlimited)
|
||||
$rateLimit = isset($matchedKey['rate_limit']) ? (int) $matchedKey['rate_limit'] : 60;
|
||||
if ($rateLimit > 0) {
|
||||
$keyIdentifier = $matchedKey['hashed_key'] ?? hash('sha256', $apiKey);
|
||||
$limiterKey = "fsu_api_key_limit:{$keyIdentifier}";
|
||||
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, $rateLimit)) {
|
||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Too many requests. API key rate limit exceeded.',
|
||||
], 429, [
|
||||
'Retry-After' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($limiterKey, 60);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public int $backoff = 5;
|
||||
|
||||
public function __construct(
|
||||
public readonly ShortUrl $shortUrl,
|
||||
public readonly int $shortUrlId,
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $userAgent,
|
||||
public readonly ?string $refererUrl = null,
|
||||
@@ -56,7 +56,7 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
public function handle(ShortUrlTracker $tracker): void
|
||||
{
|
||||
// Re-fetch fresh model to avoid acting on stale state
|
||||
$shortUrl = ShortUrl::find($this->shortUrl->id);
|
||||
$shortUrl = ShortUrl::find($this->shortUrlId);
|
||||
|
||||
if (! $shortUrl || ! $shortUrl->track_visits) {
|
||||
return;
|
||||
@@ -102,71 +102,39 @@ class TrackShortUrlVisitJob implements ShouldQueue
|
||||
// Fire event for user listeners
|
||||
ShortUrlVisited::dispatch($shortUrl, $visit);
|
||||
|
||||
// Trigger Webhook if active
|
||||
$targetUrl = $shortUrl->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array('visited', $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (! empty($webhooksToDispatch)) {
|
||||
$payload = [
|
||||
'event' => 'visited',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => [
|
||||
'id' => $shortUrl->id,
|
||||
'destination_url' => $shortUrl->destination_url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'short_url' => $shortUrl->getShortUrl(),
|
||||
'total_visits' => (int) $shortUrl->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $shortUrl->unique_visits,
|
||||
],
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
||||
'browser_language' => $visit->browser_language,
|
||||
],
|
||||
];
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
dispatch(new SendWebhookJob(
|
||||
url: $url,
|
||||
event: 'visited',
|
||||
payload: $payload
|
||||
)->onConnection($this->connection ?: 'sync'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[FilamentShortUrl] Visited webhook dispatch failed', [
|
||||
'url' => $url,
|
||||
'url_key' => $shortUrl->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
// Check if limit is reached now
|
||||
if ($shortUrl->max_visits !== null && $shortUrl->getRealTimeTotalVisits() >= $shortUrl->max_visits) {
|
||||
$limitReachedKey = "fsu:limit-reached-webhook-sent:{$shortUrl->id}";
|
||||
if (cache()->add($limitReachedKey, true, 86400 * 30)) {
|
||||
$shortUrl->dispatchWebhook('limit_reached');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger Webhook if active
|
||||
$shortUrl->dispatchWebhook('visited', [
|
||||
'visit' => [
|
||||
'id' => $visit->id,
|
||||
'visited_at' => $visit->visited_at->toIso8601String(),
|
||||
'device_type' => $visit->device_type,
|
||||
'browser' => $visit->browser,
|
||||
'browser_version' => $visit->browser_version,
|
||||
'operating_system' => $visit->operating_system,
|
||||
'operating_system_version' => $visit->operating_system_version,
|
||||
'country' => $visit->country,
|
||||
'country_code' => $visit->country_code,
|
||||
'city' => $visit->city,
|
||||
'referer_url' => $visit->referer_url,
|
||||
'referer_host' => $visit->referer_host,
|
||||
'utm_source' => $visit->utm_source,
|
||||
'utm_medium' => $visit->utm_medium,
|
||||
'utm_campaign' => $visit->utm_campaign,
|
||||
'utm_term' => $visit->utm_term,
|
||||
'utm_content' => $visit->utm_content,
|
||||
'is_qr_scan' => (bool) $visit->is_qr_scan,
|
||||
'browser_language' => $visit->browser_language,
|
||||
],
|
||||
]);
|
||||
|
||||
// Optional GA4 Measurement Protocol integration
|
||||
if ($shortUrl->ga_tracking_id && config('filament-short-url.ga4.api_secret')) {
|
||||
$this->sendGa4Hit($shortUrl, $visit);
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlBuilder;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -21,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -95,13 +98,17 @@ class ShortUrl extends Model
|
||||
'destination_type',
|
||||
'rotation_variants',
|
||||
'custom_domain_id',
|
||||
'folder_id',
|
||||
'is_archived',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'custom_domain_id' => 'integer',
|
||||
'folder_id' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'single_use' => 'boolean',
|
||||
'forward_query_params' => 'boolean',
|
||||
'auto_open_app_mobile' => 'boolean',
|
||||
@@ -127,6 +134,16 @@ class ShortUrl extends Model
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────────
|
||||
|
||||
public function folder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShortUrlFolder::class, 'folder_id');
|
||||
}
|
||||
|
||||
public function tags(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(ShortUrlTag::class, 'short_url_tag', 'short_url_id', 'tag_id');
|
||||
}
|
||||
|
||||
public function customDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShortUrlCustomDomain::class, 'custom_domain_id');
|
||||
@@ -300,8 +317,11 @@ class ShortUrl extends Model
|
||||
}
|
||||
|
||||
// Current domain
|
||||
if ($m->custom_domain_id && $m->customDomain) {
|
||||
$hosts[] = $m->customDomain->domain;
|
||||
if ($m->custom_domain_id) {
|
||||
$domain = ShortUrlCustomDomain::find($m->custom_domain_id);
|
||||
if ($domain) {
|
||||
$hosts[] = $domain->domain;
|
||||
}
|
||||
}
|
||||
|
||||
// If custom domain changed, clear old domain cache too
|
||||
@@ -333,6 +353,7 @@ class ShortUrl extends Model
|
||||
// Using wasRecentlyCreated avoids the double-forget that the separate created() event caused.
|
||||
if ($m->wasRecentlyCreated) {
|
||||
cache()->forget(ShortUrlGlobalOverview::LINKS_CACHE_KEY);
|
||||
$m->dispatchWebhook('created');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -346,8 +367,11 @@ class ShortUrl extends Model
|
||||
$hosts[] = $appHost;
|
||||
}
|
||||
|
||||
if ($m->custom_domain_id && $m->customDomain) {
|
||||
$hosts[] = $m->customDomain->domain;
|
||||
if ($m->custom_domain_id) {
|
||||
$domain = ShortUrlCustomDomain::find($m->custom_domain_id);
|
||||
if ($domain) {
|
||||
$hosts[] = $domain->domain;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
@@ -484,4 +508,69 @@ class ShortUrl extends Model
|
||||
|
||||
return rtrim($baseUrl, '/').'/'.$this->url_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook if global or per-link webhook is active for the specified event.
|
||||
*/
|
||||
public function dispatchWebhook(string $event, array $extraPayload = []): void
|
||||
{
|
||||
$targetUrl = $this->webhook_url;
|
||||
$globalUrl = config('filament-short-url.global_webhook_url');
|
||||
$events = config('filament-short-url.webhook_events', []);
|
||||
|
||||
$webhooksToDispatch = [];
|
||||
if (! empty($targetUrl)) {
|
||||
$webhooksToDispatch[] = $targetUrl;
|
||||
}
|
||||
if (! empty($globalUrl) && in_array($event, $events)) {
|
||||
$webhooksToDispatch[] = $globalUrl;
|
||||
}
|
||||
|
||||
if (empty($webhooksToDispatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shortUrlData = ($event === 'created')
|
||||
? (new ShortUrlResource($this))->resolve()
|
||||
: [
|
||||
'id' => $this->id,
|
||||
'destination_url' => $this->destination_url,
|
||||
'url_key' => $this->url_key,
|
||||
'short_url' => $this->getShortUrl(),
|
||||
'total_visits' => (int) $this->getRealTimeTotalVisits(),
|
||||
'unique_visits' => (int) $this->unique_visits,
|
||||
];
|
||||
|
||||
$payload = array_merge([
|
||||
'event' => $event,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'short_url' => $shortUrlData,
|
||||
], $extraPayload);
|
||||
|
||||
$connection = config('filament-short-url.queue_connection', 'sync');
|
||||
|
||||
foreach (array_unique($webhooksToDispatch) as $url) {
|
||||
try {
|
||||
$job = new SendWebhookJob(
|
||||
url: $url,
|
||||
event: $event,
|
||||
payload: $payload
|
||||
);
|
||||
|
||||
if ($connection) {
|
||||
$job->onConnection($connection);
|
||||
} else {
|
||||
$job->onConnection('sync');
|
||||
}
|
||||
|
||||
dispatch($job);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("[FilamentShortUrl] {$event} webhook dispatch failed", [
|
||||
'url' => $url,
|
||||
'url_key' => $this->url_key,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,43 @@ class ShortUrlCustomDomain extends Model
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
$bust = static function (self $m): void {
|
||||
static::saved(function (self $m): void {
|
||||
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
|
||||
};
|
||||
|
||||
static::saved($bust);
|
||||
static::deleted($bust);
|
||||
$domainChanged = $m->wasChanged('domain');
|
||||
if ($domainChanged) {
|
||||
$oldDomain = $m->getOriginal('domain');
|
||||
if ($oldDomain) {
|
||||
cache()->forget("filament-short-url:custom-domain:{$oldDomain}");
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate the redirect cache keys for all short URLs mapped to this domain
|
||||
// if the domain name changes or its active status is toggled.
|
||||
if ($domainChanged || $m->wasChanged('is_active')) {
|
||||
$hosts = [$m->domain];
|
||||
if ($domainChanged && isset($oldDomain)) {
|
||||
$hosts[] = $oldDomain;
|
||||
}
|
||||
|
||||
$shortUrls = $m->shortUrls()->get(['url_key']);
|
||||
foreach ($shortUrls as $url) {
|
||||
foreach ($hosts as $host) {
|
||||
cache()->forget("filament-short-url:{$url->url_key}:{$host}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function (self $m): void {
|
||||
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
|
||||
|
||||
// Clear redirect caches for all URLs mapped to this deleted domain
|
||||
$shortUrls = $m->shortUrls()->get(['url_key']);
|
||||
foreach ($shortUrls as $url) {
|
||||
cache()->forget("filament-short-url:{$url->url_key}:{$m->domain}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
src/Models/ShortUrlFolder.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ShortUrlFolder extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'color',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $m) {
|
||||
if (auth()->check() && empty($m->user_id)) {
|
||||
$m->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function shortUrls(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShortUrl::class, 'folder_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(
|
||||
config('filament-short-url.user.model', User::class),
|
||||
'user_id'
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Models/ShortUrlTag.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @author Bartek Janczak <barek122@gmail.com>
|
||||
* @copyright 2026 Bartek Janczak
|
||||
* @license Custom Source-Available License (see LICENSE file)
|
||||
*/
|
||||
|
||||
namespace Bjanczak\FilamentShortUrl\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class ShortUrlTag extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'color',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $m) {
|
||||
if (auth()->check() && empty($m->user_id)) {
|
||||
$m->user_id = auth()->id();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function shortUrls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
ShortUrl::class,
|
||||
'short_url_tag',
|
||||
'tag_id',
|
||||
'short_url_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(
|
||||
config('filament-short-url.user.model', User::class),
|
||||
'user_id'
|
||||
);
|
||||
}
|
||||
}
|
||||
140
tests/Feature/ShortUrlApiKeyScopingTest.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
beforeEach(function () {
|
||||
// Enable REST API and inject mock keys with different scopes and rate limits
|
||||
app(ShortUrlSettingsManager::class)->set([
|
||||
'api_enabled' => true,
|
||||
'api_keys' => [
|
||||
[
|
||||
'name' => 'Read Only Token',
|
||||
'key' => 'sh_key_read_only',
|
||||
'scope' => 'links:read-only',
|
||||
'rate_limit' => '60',
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Read Write Token',
|
||||
'key' => 'sh_key_read_write',
|
||||
'scope' => 'links:read-write',
|
||||
'rate_limit' => '0', // Unlimited
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Rate Limited Token',
|
||||
'key' => 'sh_key_rate_limited',
|
||||
'scope' => 'links:read-write',
|
||||
'rate_limit' => '2', // 2 per minute
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Legacy Token',
|
||||
'key' => 'sh_key_legacy',
|
||||
// No scope, no rate_limit (should default to links:read-write and 60 rpm)
|
||||
'is_active' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Clear rate limiter cache before each test
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_rate_limited'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_only'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_read_write'));
|
||||
RateLimiter::clear('fsu_api_key_limit:'.hash('sha256', 'sh_key_legacy'));
|
||||
});
|
||||
|
||||
it('allows GET requests but blocks modifying requests with read-only scope', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// GET should work
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(200);
|
||||
|
||||
// POST should be blocked
|
||||
$this->postJson('/api/short-url/links', [
|
||||
'destination_url' => 'https://google.com',
|
||||
'url_key' => 'googleapi',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
|
||||
// PUT should be blocked
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
|
||||
// DELETE should be blocked
|
||||
$this->deleteJson("/api/short-url/links/{$shortUrl->id}", [], [
|
||||
'X-Api-Key' => 'sh_key_read_only',
|
||||
])->assertStatus(403)
|
||||
->assertJsonFragment(['error' => 'Forbidden. This API key has read-only permissions.']);
|
||||
});
|
||||
|
||||
it('allows modifying requests with read-write scope', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// PUT should work
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_read_write',
|
||||
])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('defaults legacy tokens to read-write and 60 rpm', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/scope-test',
|
||||
'url_key' => 'scopetest',
|
||||
]);
|
||||
|
||||
// Legacy token has no scope, so should default to read-write and succeed
|
||||
$this->putJson("/api/short-url/links/{$shortUrl->id}", [
|
||||
'destination_url' => 'https://updated-destination.com',
|
||||
], [
|
||||
'X-Api-Key' => 'sh_key_legacy',
|
||||
])->assertStatus(200);
|
||||
});
|
||||
|
||||
it('enforces dynamic rate limiting per API key', function () {
|
||||
// Request 1: Allowed
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Request 2: Allowed
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Request 3: Blocked
|
||||
$response = $this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_rate_limited',
|
||||
]);
|
||||
|
||||
$response->assertStatus(429)
|
||||
->assertJsonFragment(['error' => 'Too many requests. API key rate limit exceeded.'])
|
||||
->assertHeader('Retry-After');
|
||||
});
|
||||
|
||||
it('does not limit unlimited keys (rate_limit = 0)', function () {
|
||||
// Run multiple requests to exceed standard limit
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->getJson('/api/short-url/links', [
|
||||
'X-Api-Key' => 'sh_key_read_write',
|
||||
])->assertStatus(200);
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -67,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 () {
|
||||
@@ -92,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,7 +126,14 @@ describe('complex option interactions', function () {
|
||||
// 1. Unauthenticated request as mobile: password prompt must show first, not warning, not redirect
|
||||
$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')
|
||||
@@ -135,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')
|
||||
@@ -149,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');
|
||||
});
|
||||
|
||||
@@ -190,7 +196,7 @@ describe('complex option interactions', function () {
|
||||
// Verification:
|
||||
// 1. The old visit should be pruned from database
|
||||
$this->assertDatabaseMissing('short_url_visits', ['id' => $oldVisit->id]);
|
||||
|
||||
|
||||
// 2. The recent visit should remain in database
|
||||
$this->assertDatabaseHas('short_url_visits', ['id' => $recentVisit->id]);
|
||||
|
||||
@@ -243,7 +249,7 @@ describe('complex option interactions', function () {
|
||||
'destination_type' => 'single',
|
||||
'url' => 'https://tablet-single.com',
|
||||
'filters' => [['type' => 'device', 'data' => ['devices' => ['tablet']]]],
|
||||
]
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -370,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');
|
||||
|
||||
@@ -6,6 +6,8 @@ use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -57,19 +59,19 @@ it('forces 302 redirect status code for limited/expiring links', function () {
|
||||
|
||||
it('generates a deterministic privacy-safe GA4 client ID', function () {
|
||||
$job1 = new TrackShortUrlVisitJob(
|
||||
shortUrl: new ShortUrl(['id' => 1]),
|
||||
shortUrlId: 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]),
|
||||
shortUrlId: 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]),
|
||||
shortUrlId: 1,
|
||||
ipAddress: '8.8.8.8',
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)'
|
||||
);
|
||||
@@ -228,12 +230,12 @@ it('anonymizes IP address correctly for IPv4 and IPv6', function () {
|
||||
$ipv4 = '192.168.1.123';
|
||||
$ipv6 = '2001:db8:85a3:8d3:1319:8a2e:370:7334';
|
||||
|
||||
expect(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv4))->toBe('192.168.1.0')
|
||||
->and(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::anonymizeIp($ipv6))->toBe('2001:db8:85a3::');
|
||||
expect(ShortUrlTracker::anonymizeIp($ipv4))->toBe('192.168.1.0')
|
||||
->and(ShortUrlTracker::anonymizeIp($ipv6))->toBe('2001:db8:85a3::');
|
||||
});
|
||||
|
||||
it('anonymizes saved IP address when anonymize_ips is enabled but computes hash on raw IP', function () {
|
||||
$mgr = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager::class);
|
||||
$mgr = app(ShortUrlSettingsManager::class);
|
||||
$mgr->set(['tracking_anonymize_ips' => true]);
|
||||
|
||||
$link = ShortUrl::create([
|
||||
@@ -242,11 +244,11 @@ it('anonymizes saved IP address when anonymize_ips is enabled but computes hash
|
||||
'track_ip_address' => true,
|
||||
]);
|
||||
|
||||
$request = \Illuminate\Http\Request::create('/anonkey', 'GET', [], [], [], [
|
||||
$request = Request::create('/anonkey', 'GET', [], [], [], [
|
||||
'REMOTE_ADDR' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
$tracker = app(\Bjanczak\FilamentShortUrl\Services\ShortUrlTracker::class);
|
||||
$tracker = app(ShortUrlTracker::class);
|
||||
$visit = $tracker->record($link, $request);
|
||||
|
||||
expect($visit->ip_address)->toBe('1.2.3.0')
|
||||
|
||||
@@ -93,3 +93,46 @@ it('returns correct short URL link string based on custom domain association', f
|
||||
expect($shortUrl->getShortUrl())->toBe('https://links.acme.com/s/promo');
|
||||
expect($standardUrl->getShortUrl())->toBe('https://maindomain.com/s/stdkey');
|
||||
});
|
||||
|
||||
it('invalidates both old and new custom domain cache keys when domain name is updated', function () {
|
||||
$domain = ShortUrlCustomDomain::create([
|
||||
'domain' => 'old-domain.com',
|
||||
'is_verified' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:custom-domain:old-domain.com', 300, fn () => $domain);
|
||||
|
||||
expect(cache()->has('filament-short-url:custom-domain:old-domain.com'))->toBeTrue();
|
||||
|
||||
// Now update domain name
|
||||
$domain->update(['domain' => 'new-domain.com']);
|
||||
|
||||
// Both old and new domain cache keys must be cleared
|
||||
expect(cache()->has('filament-short-url:custom-domain:old-domain.com'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:custom-domain:new-domain.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('invalidates short URL redirect caches when its custom domain is updated or toggled', function () {
|
||||
$domain = ShortUrlCustomDomain::create([
|
||||
'domain' => 'old-domain.com',
|
||||
'is_verified' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/target',
|
||||
'url_key' => 'promo',
|
||||
'custom_domain_id' => $domain->id,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:promo:old-domain.com', 3600, fn () => $shortUrl);
|
||||
expect(cache()->has('filament-short-url:promo:old-domain.com'))->toBeTrue();
|
||||
|
||||
// Update the domain name
|
||||
$domain->update(['domain' => 'new-domain.com']);
|
||||
|
||||
// Redirect cache for the old domain should be invalidated
|
||||
expect(cache()->has('filament-short-url:promo:old-domain.com'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:promo:new-domain.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
113
tests/Feature/ShortUrlFoldersAndTagsTest.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLiveFeedWidget;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can assign a short URL to a folder and tags', function () {
|
||||
$folder = ShortUrlFolder::create([
|
||||
'name' => 'Marketing Campaigns',
|
||||
'slug' => 'marketing-campaigns',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
|
||||
$tag1 = ShortUrlTag::create([
|
||||
'name' => 'Summer 2026',
|
||||
'slug' => 'summer-2026',
|
||||
'color' => 'red',
|
||||
]);
|
||||
|
||||
$tag2 = ShortUrlTag::create([
|
||||
'name' => 'Newsletter',
|
||||
'slug' => 'newsletter',
|
||||
'color' => 'green',
|
||||
]);
|
||||
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/promo',
|
||||
'url_key' => 'summer',
|
||||
'folder_id' => $folder->id,
|
||||
]);
|
||||
|
||||
$shortUrl->tags()->attach([$tag1->id, $tag2->id]);
|
||||
|
||||
// Assertions
|
||||
expect($shortUrl->folder->name)->toBe('Marketing Campaigns')
|
||||
->and($shortUrl->tags)->toHaveCount(2)
|
||||
->and($shortUrl->tags->pluck('name'))->toContain('Summer 2026', 'Newsletter');
|
||||
|
||||
// Assert reverse relations
|
||||
expect($folder->shortUrls)->toHaveCount(1)
|
||||
->and($tag1->shortUrls)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('can archive and restore a short URL', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/archive-test',
|
||||
'url_key' => 'archivekey',
|
||||
'is_archived' => false,
|
||||
]);
|
||||
|
||||
expect($shortUrl->is_archived)->toBeFalse();
|
||||
|
||||
$shortUrl->update(['is_archived' => true]);
|
||||
expect($shortUrl->fresh()->is_archived)->toBeTrue();
|
||||
|
||||
$shortUrl->update(['is_archived' => false]);
|
||||
expect($shortUrl->fresh()->is_archived)->toBeFalse();
|
||||
});
|
||||
|
||||
it('renders live activity feed widget with filtered data', function () {
|
||||
$shortUrl = ShortUrl::create([
|
||||
'destination_url' => 'https://example.com/live-test',
|
||||
'url_key' => 'livekey',
|
||||
]);
|
||||
|
||||
// Seed some visits
|
||||
$visitPl = ShortUrlVisit::create([
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'country_code' => 'PL',
|
||||
'country' => 'Poland',
|
||||
'device_type' => 'mobile',
|
||||
'browser' => 'Chrome',
|
||||
'operating_system' => 'Android',
|
||||
'visited_at' => now(),
|
||||
'is_bot' => false,
|
||||
'is_proxy' => false,
|
||||
]);
|
||||
|
||||
$visitUs = ShortUrlVisit::create([
|
||||
'short_url_id' => $shortUrl->id,
|
||||
'country_code' => 'US',
|
||||
'country' => 'United States',
|
||||
'device_type' => 'desktop',
|
||||
'browser' => 'Safari',
|
||||
'operating_system' => 'OS X',
|
||||
'visited_at' => now()->subMinute(),
|
||||
'is_bot' => false,
|
||||
'is_proxy' => false,
|
||||
]);
|
||||
|
||||
// Test without filters
|
||||
Livewire::test(ShortUrlLiveFeedWidget::class, ['record' => $shortUrl])
|
||||
->assertViewHas('visits', function ($visits) {
|
||||
return count($visits) === 2;
|
||||
});
|
||||
|
||||
// Test with cross-filtering (PL only)
|
||||
Livewire::test(ShortUrlLiveFeedWidget::class, [
|
||||
'record' => $shortUrl,
|
||||
'filters' => ['country_code' => 'PL'],
|
||||
])
|
||||
->assertViewHas('visits', function ($visits) {
|
||||
$first = collect($visits)->first();
|
||||
|
||||
return count($visits) === 1 && ($first['country_code'] ?? $first->country_code ?? null) === 'PL';
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\ViewShortUrlStats;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||
@@ -330,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 () {
|
||||
@@ -357,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);
|
||||
});
|
||||
|
||||
@@ -392,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');
|
||||
});
|
||||
|
||||
@@ -834,3 +845,61 @@ it('evaluates platform and browser language filters in new rule engine', functio
|
||||
'Accept-Language' => 'pl-PL,pl;q=0.9',
|
||||
])->assertRedirect('https://example.com');
|
||||
});
|
||||
|
||||
it('dispatches limit_reached webhook when link reaches its click limit', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
cache()->forget('filament-short-url:settings');
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'limit-reach-webhook',
|
||||
'max_visits' => 1,
|
||||
'track_visits' => true,
|
||||
'webhook_url' => 'https://webhook.site/limit',
|
||||
]);
|
||||
|
||||
// Force queue connection to sync for the job so it runs inline
|
||||
config(['filament-short-url.queue_connection' => 'sync']);
|
||||
|
||||
// Perform visit (will trigger TrackShortUrlVisitJob which will increment total_visits to 1 >= max_visits)
|
||||
$this->get('/s/limit-reach-webhook');
|
||||
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/limit' && $job->event === 'limit_reached';
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches expired webhook when user attempts to visit an expired link', function () {
|
||||
Queue::fake([SendWebhookJob::class]);
|
||||
cache()->forget('filament-short-url:settings');
|
||||
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'expired-webhook-test',
|
||||
'expires_at' => now()->subDay(),
|
||||
'track_visits' => true,
|
||||
'webhook_url' => 'https://webhook.site/expired',
|
||||
]);
|
||||
|
||||
// Perform visit to expired link
|
||||
$this->get('/s/expired-webhook-test');
|
||||
|
||||
Queue::assertPushed(SendWebhookJob::class, function ($job) {
|
||||
return $job->url === 'https://webhook.site/expired' && $job->event === 'expired';
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates redirect cache keys when the url_key is updated', function () {
|
||||
$shortUrl = createShortUrl([
|
||||
'url_key' => 'old-key',
|
||||
'track_visits' => false,
|
||||
]);
|
||||
|
||||
cache()->remember('filament-short-url:old-key:default', 3600, fn () => $shortUrl);
|
||||
expect(cache()->has('filament-short-url:old-key:default'))->toBeTrue();
|
||||
|
||||
// Now update url_key
|
||||
$shortUrl->update(['url_key' => 'new-key']);
|
||||
|
||||
// Cache for both keys should be cleared
|
||||
expect(cache()->has('filament-short-url:old-key:default'))->toBeFalse();
|
||||
expect(cache()->has('filament-short-url:new-key:default'))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||