release: v5.2.3 - Redis optimizations, stats scaling, DNS and CSP fixes

This commit is contained in:
Bartłomiej Janczak
2026-06-08 16:11:47 +02:00
parent 06e3934438
commit 9c4d693b25
195 changed files with 21717 additions and 4098 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@
*.log
.DS_Store
Thumbs.db
# Internal maintainer notes (not shipped)
/AUDIT_IMPLEMENTATION.md

363
CHANGELOG.md Normal file
View File

@@ -0,0 +1,363 @@
# Changelog
All notable changes to **janczakb/filament-short-url** are documented here.
Recent releases (v5.x) are summarized in [README.md](README.md#changelog). This file contains the full history for older versions and the complete **v5.2.0** release notes (including enterprise hardening follow-up, signed 2026-06-08).
---
## v5.2.3
> **Production Redis mode, stats scaling for high traffic, and Settings diagnostics.**
### Settings → Redis & queue diagnostics
- **Redis connection in Settings** — When **Queue Connection = `redis`**, configure host, port, password, database, and key prefix in **Settings → General**. Values are stored in `short_url_settings` and **override** `database.redis` + `queue.connections.redis` at runtime (independent of `CACHE_STORE`).
- **Test Redis connection** — PING + pipelined `INCR`/`SADD` probe (same Redis primitives as visit counters). Uses unsaved form values via preview overrides.
- **Test queue worker** — Dispatches `VerifyQueueWorkerJob` on the selected connection/queue and waits up to 12 s for a worker to process it; reports the exact `php artisan queue:work {connection} --queue={name}` command on failure.
- **Unified queue callout** — Single info box per async driver with a **dynamic** worker command from current Settings (no duplicate boxes; hidden entirely for `sync`).
- **`withPreviewSettings()`** — Settings test buttons apply form state temporarily without saving.
### Stats scaling (`queue_connection = redis`)
- **`StatsScalingProfile`** — Central profile: auto counter buffering, dedicated Redis counters, Redis today buffer, SQL micro-cache for sync mode.
- **`PluginRedisConnection`** — Resolves Redis from `queue.connections.redis` (phpredis/Predis), not from `CACHE_STORE`.
- **`VisitCounterBuffer`** — Unified buffered totals/uniques: dedicated Redis path when Settings queue is `redis`, Laravel cache path for sync + manual toggle.
- **`TodayStatsBuffer`** — Pipelined Redis counters for today's summary and hourly charts; merged live in `HasStatsCache` without busting historical stats cache on every click.
- **`StatsVisitRecorder`** — Hot-path stats side effects extracted from the visit job; **`TrackShortUrlVisitJob`** no longer clears full stats cache per visit.
- **`SyncBufferedCountersCommand`** — Refactored to flush via `VisitCounterBuffer` (Redis dirty-set + cache path).
- **Live feed** — `LiveFeedBroadcaster` prefers plugin queue Redis over cache-store Redis (unchanged priority, now shares infra with counters/stats when queue is `redis`).
### Config & migrations
- **`config/filament-short-url.php`** — New `redis.*` defaults (`REDIS_HOST`, `REDIS_PORT`, …) used as fallbacks before Settings overrides.
- **Service provider** — All package migrations registered in `hasMigrations()` including `2026_06_10_000001` and `2026_06_11_000001`.
### Tests
- **`tests/Feature/StatsScalingTest.php`** — Redis queue profile, today buffer, counter buffering.
- **`tests/Feature/RedisSettingsTest.php`** — Config overrides, masked password preservation, sync worker test.
- **`tests/Unit/PluginRedisConnectionTesterTest.php`** — Redis health-check service.
---
## v5.2.2
> **Enterprise hardening, airtight visit counters, trait refactor, and i18n audit.**
### Security
- **XSS** — `@json()` in pixel interstitial and Alpine stats widgets (replaces `addslashes()`).
- **Atomic `max_visits`** — `VisitSlotReservation::reserveAtomicSlot()` with fast-path SQL and pessimistic lock near cap.
- **Single-use bots** — BotDetector gate before consuming single-use links.
- **Buffer double-count** — `revertBufferedIncrements()` before DB fallback in `HasVisitCounters`.
- **API tenancy** — `owner_user_id` required on API keys when `scope_links_to_user` is enabled.
- **IDOR** — `ResourceOwnershipValidator` for folder/tag/pixel on API writes.
- **Redirect pipeline** — Limits before app interstitial; caps enforced when `track_visits=false`.
### Performance
- **`max_visits_pessimistic_remaining`** config (default 5).
- **`LiveFeedBroadcaster`** — Redis source priority: plugin **Settings → Queue Connection = redis** first, then `CACHE_STORE=redis`; pub/sub push with PhpRedis, SSE poll fallback otherwise.
- **`StatsCacheHelper`** — `Cache::lock` on stats miss.
- **`preloadBufferedCountersForIds()`** — Batch buffer reads in list tables.
### Quality
- **Public stats** — Uniform 404; public-safe response subset.
- **Octane** — A/B variant on request attributes.
- **Filtered uniques** — Rollup merge in `FilteredStatsCollector`.
- **`findByKey()`** — `domain_scope_id` index path.
### Maintainability
- **`HasStats` facade** — `HasVisitCounters`, `HasStatsCache`, `HasStatsQueries`, `HasSecurityStats`.
- **i18n** — API, redirect, and visitor-facing strings use translation keys (EN + PL).
### Tests
- **`tests/Feature/WorldClassHardeningTest.php`** — Comprehensive audit regression suite.
---
## v5.2.1
> **Audit roadmap follow-up** — Authorization policy, API stats parity, SSE live feed, load baselines, and hardening tests.
### Authorization
- **`ShortUrlPolicy`** — `view`, `update`, `delete`, `restore`, `forceDelete`, and `manageSettings` aligned with `scope_links_to_user` / `user_id`. Auto-registered in `FilamentShortUrlServiceProvider` when the host has no custom policy.
- **Stats page** — `ViewShortUrlStats` calls `authorize('view', $record)`.
### REST API
- **Filtered stats** — `GET /links/{idOrKey}/stats` passes query filters to `FilteredStatsCollector` (same dimensions as the Filament stats dashboard).
- **`ApiStatsFilterParser`** — Parses `CrossDimensionalStatsEngine::FILTER_KEYS` plus aliases `country``country_code`, `device``device_type`.
- **Response meta** — Returns `meta.date_from`, `meta.date_to`, and `meta.filters`.
### Live feed (SSE)
- **`ShortUrlLiveFeedStreamController`** — Authenticated SSE endpoint at `GET /short-url/live-feed/{shortUrl}/stream`.
- **Widget** — Alpine `EventSource` + `ShortUrlLiveFeedWidget::onStreamUpdate()` replaces Livewire polling.
- **Config** — `live_feed.sse_interval_seconds` (default 3), `live_feed.sse_max_duration_seconds` (default 120).
### Load & stress baselines
- **`php artisan short-url:stress-redirect {key}`** — In-process redirect benchmark (avg / min / p95 / max ms).
- **`scripts/k6/redirect-baseline.js`** — k6 HTTP load script with setup probe, configurable VUs/duration, and p95 thresholds.
### Tests
- **`tests/Feature/RoadmapHardeningTest.php`** — Policy scoping, API stats filters, webhook HMAC E2E via `Http::fake()`, `max_visits` burst guard, SSE stream authorization, stress command smoke test.
---
## v5.2.0
> **Main release theme: SEO & Social + integration hardening** — Open Graph metadata, link cloaking, search indexing control, expanded REST API, production security fixes, and **enterprise hardening** (GA4 MP, Geo-IP, custom domain DNS gate).
### Stats & security hardening (audit follow-up)
- **Filtered stats optimizer** — `FilteredStatsCollector` replaces 15+ raw GROUP BY queries with daily JSON rollups + single summary + cursor pass.
- **Cross-dimensional daily rollups** — `CrossDimensionalStatsEngine` stores single- and two-filter slices in `short_url_daily_stats` (`cross_dimensional_stats`, `cross_filter_pairs`, `filter_qr_counts`); aggregation uses one cursor pass instead of 11 GROUP BY queries; re-aggregation backfills NULL cross columns.
- **Stats cache helper** — `StatsCacheHelper` wraps remember/forget with driver-agnostic fallbacks (file, database, Redis, Memcached, array).
- **Security widget cache** — `getSecurityBreakdownStats()` with daily `all_visits_count` / `bot_visits_count` / `proxy_visits_count` rollups.
- **World map** — Always routes through `getCachedStats()`.
- **VPN fail-closed** — When `block_with_403`, detection timeout/error blocks access.
- **Public stats password** — Accepted only via POST body or `Authorization` header (not query string).
- **Custom domain legacy URLs** — `/s/{key}` → 301 → `/{key}` on verified custom domains.
### SEO, Open Graph & Link Cloaking
- **New SEO & Social tab** — `og_title`, `og_description`, `og_image`, `do_index`, `is_cloaked`, live social preview sidebar.
- **Custom Open Graph meta** — Per-link OG/Twitter tags for Facebook, LinkedIn, X, Slack, WhatsApp, and other preview bots.
- **Search Engine Indexing (`do_index`)** — Per-link toggle; when off, all responses emit `noindex, nofollow`.
- **Link Cloaking (`is_cloaked`)** — Iframe embedding on your short-link domain for humans; standalone OG page for crawlers.
- **OG meta scraper** — SSRF-safe streaming fetch (`GET /short-url/scrape-meta`).
- **OG image pipeline** — WebP conversion, temp storage, promotion to permanent disk on save.
- **Iframeable pre-check** — `POST /short-url/check-iframeable` with redirect-chain validation.
### REST API
- **Bulk create** — `POST /api/short-url/links/bulk` (max 100).
- **Upsert** — `PUT /api/short-url/links/upsert` by `external_id` or `url_key` + `destination_url`.
- **Helpers** — `GET /links/exists`, `/links/random`, `/links/info`.
- **CSV export** — `GET /links/{idOrKey}/visits/export`.
- **Tags & Folders CRUD** — `/api/short-url/tags`, `/api/short-url/folders`.
- **Per-key owner scoping** — Optional `owner_user_id` on API keys.
- **Link-level UTM** — `utm_*` and `ref` stored on the link and merged on redirect.
- **Expanded serializer** — `external_id`, UTM fields, tags, folder, archive, public stats flags.
- **Visits, bulk delete/update** — Paginated visits log, bulk delete by IDs/keys, bulk patch.
### Public Stats
- **Shareable endpoint** — `GET /short-url/public-stats/{url_key}` with optional password (`public_stats_enabled`, `public_stats_password`).
### Security & Reliability
- **Password hashing** — Link passwords hashed at rest; legacy plain-text re-hashed on save.
- **DNS-aware SSRF guard** — Webhooks, OG scraper, iframe checker block hostnames resolving to private IPs.
- **`OutboundUrl` rule** — Shared validation for outbound URLs.
- **Safe Browsing fail-closed** — Unreachable API rejects URLs when checking is enabled.
- **Global webhook toggle** — `global_webhook_enabled` must be on for global webhooks to fire.
- **`log-error` hardened** — Auth + throttle + validation.
- **Bot hardening** — Bot visit exclusion, optional click dedup, Googlebot IP verify, `?bot=1` secret.
- **Single-use fix** — No visit recorded after single-use consumption.
- **Redis counter fix** — Buffered stats sync with Redis cache driver.
### Settings GUI
- **Analytics & Bot Detection** — Click deduplication, Googlebot verify, bot debug secret in **Settings → Advanced**.
### Database (safe upgrade)
- **Migration `2026_06_08_000001`** — Adds nullable `external_id`, `utm_*`, `ref`, `public_stats_*`, and index on `short_url_visits (short_url_id, ip_hash)`. No existing data modified.
- **Migration `2026_06_09_000001`** — Adds `domain_scope_id`, composite unique `(url_key, domain_scope_id)`, FK on `custom_domain_id` (`ON DELETE SET NULL`), admin/aggregation indexes, and JSON stats columns on `short_url_daily_stats`. Existing rows backfilled (`domain_scope_id = custom_domain_id` where set, else `0`).
- **Cross-database** — No `->after()` hints, no DB-specific `ENUM`; runs on SQLite, MySQL, and PostgreSQL.
### Performance & reliability audit
- **`domain_scope_id` + composite unique** — Same slug on default domain and custom domains without collision.
- **Custom domain routing** — Root-level URLs on verified domains; reserved segments blocked (`api`, `admin`, `auth`, …).
- **`getRealTimeTotalVisits()`** — Merges buffered counters for accurate `max_visits` / single-use checks with cached redirect models.
- **HasStats / aggregation** — Incremental daily aggregation, bot/proxy filters, chunked prune, Redis dirty-set fix, scoped buffer reads, unique metric via `COUNT(DISTINCT ip_hash)`.
- **`SyncBufferedCountersCommand`** — Atomic batch flush; temp keys cleaned after commit.
- **`VerifyCustomDomainsCommand`** — Scheduled DNS re-verification for custom domains.
- **API** — Transactions on bulk/upsert, `ShortUrlCacheInvalidator`, scoped `exists`, hashed-only API keys, public stats throttle.
- **Security** — `AuthenticateShortUrlApi` drops plaintext legacy keys; public stats password from query/body/Authorization.
- **Settings** — VPN cache TTL + timeout in GUI; settings cache 3600s; queue default remains **`sync`** (opt-in `database`/`redis`).
- **Support classes** — `HostNormalizer`, `ShortUrlCacheInvalidator`, `LinkUtmMerger`, `ApiLinkScope`, `VisitCsvExporter`.
- **Verification doc** — Full itemized checklist in **[AUDIT_IMPLEMENTATION.md](AUDIT_IMPLEMENTATION.md)**.
### Refactoring
- **`ShortUrlRedirectHandler`**, **`RedirectUrlResolver`**, **`OgMetaPresenter`**, **`StatsSqlHelper`**, **`SafeUrl` rule**.
### Enterprise hardening (follow-up — 2026-06-08)
> **Signed:** Bartek Janczak — post-audit production hardening before v5.2.0 tag.
#### GA4 Measurement Protocol (enterprise)
- **`Ga4MeasurementProtocolService`** — Extracted from `TrackShortUrlVisitJob`; singleton registered in service provider.
- **Full MP payload** — `timestamp_micros`, `session_id`, `engagement_time_msec`, standard events `page_view` + `click`, custom `short_url_visit`.
- **Measurement ID validation** — Rejects invalid `ga_tracking_id` format before HTTP; logs and skips send.
- **Settings Test connection** — Requires real `G-XXXXXXXXXX` (or Firebase App ID); validates via GA4 debug collector with the configured secret (not placeholder `G-XXXXXXXXXX`).
- **Privacy-safe `client_id`** — Deterministic SHA-256 hash of IP + User Agent (unchanged semantics, now in dedicated service).
#### Geo-IP foolproofing
- **Headers driver auto-trust** — Saving `geo_ip_driver = headers` forces `trust_cdn_headers = true` in DB and runtime config.
- **Offline fallback chain** — When CDN headers are missing, Headers driver falls back to MaxMind, then ip-api (instead of returning empty geo).
- **ip-api rate limit** — Laravel `RateLimiter` guard: max 40 lookups/minute (`fsu_geo_ip_api:{YmdHi}`).
- **Settings UI** — GeoIP tab info callout explains auto-trust and fallback behaviour.
#### Custom domains (DNS gate)
- **Multi-record verification** — All A records resolved; multiple CNAME targets; IP match via `array_intersect`; CNAME chain to `app.url` host.
- **Activation enforcement** — `custom_domains.enforce_dns_on_activate` (default `true`, env `SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS`); saving with `is_active=true` without DNS → auto-deactivate.
- **Removed `SERVER_ADDR` fallback** — Verification relies on DNS resolution against `app.url` only.
#### API & settings
- **Upsert + `lock_url_key`** — `UpsertShortUrlRequest` blocks changing `url_key` and `custom_domain_id` when lock is enabled (closes panel/API parity gap).
- **`ShortUrlSettingsManager`** — `last_aggregation_date` in system keys; secret preservation on masked save; `trust_cdn_headers` derived when driver is headers.
#### Stats & reliability audit (P0P2)
- **Redis dirty-set prefix** — `SyncBufferedCountersCommand` uses store prefix for dirty ID set.
- **Webhook SSRF** — `SendWebhookJob`: `allow_redirects => false`, rejects 3xx responses.
- **Buffer fallback** — `HasStats::incrementVisits()` DB fallback when dirty-ID tracking fails.
- **Unique visits** — Human clicks from daily rollups; no DISTINCT override inflation.
- **Cross-filter stats** — Two-filter pairs in aggregation; gap fill in `FilteredStatsCollector`.
- **Per-link webhooks** — Respects `webhook_events` on link model.
- **Octane** — `GeoIpService::flush()`, `ShortUrl::flushBufferedCounterMemory()` on request terminate.
- **Security** — Redirect HTML XSS fix; public stats rate limit; visit logs default to stats-counted scope.
- **Tests** — `EnterpriseHardeningTest.php`, `StatsAuditHardeningTest.php`; run `php artisan test packages/filament-short-url/tests/ --compact` in the host app.
#### Critical audit hardening (follow-up — 2026-06-08)
- **Stateless redirect default** — Main `/s/{key}` route uses `throttle:120,1` only (no `web` middleware). Password flow stays on `/s-auth/{key}` with explicit `web`.
- **Filament `lock_url_key` server-side** — `LockedUrlKeyGuard` sanitizes Filament save data and blocks model persistence when key/domain change is attempted (parity with API).
- **Multi-tenant Filament scope** — `LinkUserScope` filters `ShortUrlResource` by `user_id` when `scope_links_to_user` is enabled (default).
- **Custom domain ownership in API** — `CustomDomainValidator` ensures API keys/users can only assign domains they own.
- **Scoped `exists` API** — `custom_domain_id` + `domain_scope_id` in response; checks per domain scope.
- **Safe Browsing on redirect** — `SafeBrowsingService::isSafeCached()` re-checks destination at redirect time (configurable TTL).
- **Webhook signing required** — Global webhooks require `webhook_signing_secret` when `webhook_signing_required` is true (default); jobs skip unsigned dispatch.
- **Hot path optimizations** — Single domain resolution passed to `findByKey()`; pixels lazy-loaded; `max_visits` enforced under row lock before tracking.
- **Docs** — README claims corrected (no sub-15ms; dub.co parity table scoped; production checklist; sync queue default documented).
- **Tests** — `SecurityHardeningFixesTest.php`, `LockedUrlKeyGuardTest.php`.
---
## 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.
- **Aggregator Performance Tuning** — Restructured daily aggregation SQL queries to filter only active URL IDs, leveraging composite indexes and running native DB aggregations.
- **Logo Upload Authorization** — Secured the logo upload API with resource-level permission and policy checks.
- **DB-Free Registration Phase** — Deferred settings overrides to the service provider boot phase to prevent database errors during offline configuration caching.
- **Conditional Redirection Fallback** — Added configurations to optionally disable global fallback route registration.
## v3.5.0
- **User Attribution** — Short URLs now record the creator. Their avatar and hover card (name + email) are displayed in the list table.
- **Relative Date Badges** — Creation dates show compact relative strings (`2h`, `5d`, `3mo`). Hover to see the precise date and timezone.
- **Keyboard Shortcuts** — Hover any table row and press `E` (edit), `Q` (QR code), `I` (share), `S` (statistics), or `X` (delete).
- **Unified Action Dropdown** — All per-row actions consolidated under a single 3-dot button with keyboard shortcut badges.
- **Row Click → Statistics** — Clicking a table row navigates directly to the link's Statistics page.
- **A/B Split Testing** — Weighted traffic rotation in root-level links and nested targeting rules, with drag-based sliders and a one-click "Balance weights" action.
- **Variant Analytics** — Tracks which A/B variant was served and shows a "Variant Clicks Distribution" chart in the analytics dashboard.
- **Query Optimization** — Composite DB index on the visits table; queries filter by indexed `country_code`; Eloquent hydration bypassed with `toBase()->get()` for large sets.
- **GDPR IP Anonymization** — Opt-in IP masking (IPv4/IPv6) with SHA-256 hashing for unique visitor identification, managed via Settings.
- **Google Safe Browsing in REST API** — Safety checks now apply to all incoming URLs in the API (destination, A/B variants, targeting rules).
- **Settings URL Cleanup** — Tab query parameters are now human-readable (e.g. `?tab=qr` instead of `?tab=qr-defaults::data::tab`).
- **Analytics Enhancements** — QR Scan Conversion Rate card with period-over-period delta, browser/OS version subtexts, A/B click-share vs. weight comparison.
## v3.3.0
- **Advanced Multi-Filter Targeting Engine** — Replaced the legacy single-strategy selection panel with a highly flexible rule engine supporting custom `AND` / `OR` logic matching.
- **Granular Filter Categories** — Added support for filtering by devices (Desktop, Mobile, Tablet), platform operating systems (Windows, macOS, Linux, iOS, Android, Fire OS), countries (multiselect with search), and browser languages (multiselect with search).
- **Client-Side Flag Icons** — Integrated high-quality country flag icons dynamically loaded from a trusted CDN (`flagcdn.com`) inside both the Filament form targeting dropdown and the analytics country statistics widget.
- **Redirection Performance Boost** — Bypassed full user agent parsing (which involves regular expression scanning for versions) during redirections by introducing specialized fast-path `getDeviceType` and `getOs` helper functions.
- **Dynamic Legacy Data Adapter** — Added automatic on-the-fly hydration and upgrade of legacy database records to the new rule format when loaded in the Filament form.
## v3.2.0
- **Expanded Developer REST API** — Added new endpoints to inspect single short URLs (`GET /api/short-url/links/{idOrKey}`), fetch real-time click metrics (`GET /api/short-url/links/{idOrKey}/stats`), and fully update links programmatically (`PUT/PATCH /api/short-url/links/{idOrKey}`). Enabled flexible lookup dynamically by ID or URL key.
- **REST API Throttling & Rate Limiting** — Added route middleware throttling for the Developer REST API. Current default: `throttle:120,1` (configurable via `filament-short-url.middleware`); per-key limits default to 60 req/min.
- **Unified Validation System** — Cleaned up and unified API and admin panel form validation, supporting advanced fields like granular logging parameters (`track_visits`, `track_browser_language`, etc.), custom GA4 tracking IDs (`ga_tracking_id`), and auto-open deep linking options (`auto_open_app_mobile`).
- **Media Controller Separation** — Refactored internal logo uploads and serving routes out of the public REST API controller into a dedicated `ShortUrlLogoController` (Single Responsibility compliance).
- **Alpine.js Webhook Payload Preview** — Replaced the Filament package CodeEditor component with a custom, high-reliability dark-mode HTML/CSS component featuring live code highlighting and copy-to-clipboard functionality powered by Alpine.js.
## v3.1.0
- **Database-Backed & Cached Settings** — Relocated user configuration from local JSON files to the database (`short_url_settings` table) with automatic caching and zero-downtime migration of legacy settings.
- **High-Performance Aggregations** — Completely refactored the statistics aggregator command to run optimized GROUP BY queries directly in the database, reducing memory usage (OOM protection) to near zero.
- **Secure Hashed API Keys** — API keys are now stored securely as SHA-256 hashes, verified using constant-time comparisons (`hash_equals()`), and displayed only once upon generation.
- **HMAC Signed Webhooks** — Webhook payloads are now optionally signed with a configured secret key and verified via the `X-ShortUrl-Signature` header.
- **Privacy-Safe GA4 Client IDs** — Replaced random UUIDs with deterministic client IDs based on hashed visitor IP and User Agent, ensuring session integrity in Google Analytics without storing raw visitor data.
- **Browser Cache Prevention for Limited Links** — Force temporary `302` redirects automatically for single-use, max-visit, or expiring links to prevent browsers from caching redirects and bypassing tracking logic.
- **Proxy Detection Optimization** — Implemented an aggressive 800ms timeout for external proxy checkers and reduced cache time for transient rate-limit failures to 60 seconds.
## v3.0.0
- **Native App Linking (Mobile Auto-Open)** — Automatically match and redirect mobile visitors directly inside 24+ native mobile apps (such as WhatsApp, YouTube, TikTok, Instagram, Spotify, etc.) using custom schemes, complete with a glassmorphic redirect page and a live interactive matching preview widget.
- **Global Deep Linking (Universal Links & App Links)** — Easily serve iOS `apple-app-site-association` and Android `.well-known/assetlinks.json` configuration files directly from your root domain to support OS-level native integration (disabled by default, managed via Settings).
- **Central Retargeting Pixel Registry** — Introduced a premium Many-to-Many pixel management registry. Define pixels centrally (Meta Pixel, Google Tag, LinkedIn Insight, TikTok Pixel, Pinterest Tag) and easily associate them with short links via the Filament panel or the REST API.
- **Standalone Settings Page** — Relocated the Settings interface from a resource header sub-action to a standalone sidebar navigation page under the default plugin group.
- **Retargeting Pixel API** — The REST API now fully exposes the `pixels` relationship array parameter for registering and linking retargeting pixels programmatically.
- **Enhanced Browser Language Redirection** — Robust double-pass language targeting logic matching exact locales first (e.g. `en-US`, `zh-CN`) and falling back to base language codes (e.g. `en`, `zh`).
- **Custom Branded Expiry Pages** — Replaced raw 410 HTTP errors with a beautiful, fully localized, dark-mode compatible HTML expiry page displaying the Site Name, expired link details, and a homepage button.
## v2.0.0
- **Interactive QR Code Designer Branding Logo** — Upload custom brand logos inside the QR designer canvas in Filament. Configure logo sizing, margins, shapes (square/circle), and toggle dot backing removal to prevent dots overlapping with the logo.
- **Dedicated QR Code Scan Tracking** — Differentiates visitor clicks from physical QR code scans by dynamically appending source tags (`?source=qr`). Added a new database tracking column (`is_qr_scan` on visits, `qr_scans` on short URLs, and `qr_visits_count` on daily stats). Displays a dedicated scans counter badge in the Filament list table.
- **Browser Language Detection & Statistics** — Captures visitor browser preferred language headers (`browser_language` field) and aggregates them into the daily stats table. Displays a new "Top Languages" widget breakdown in the link statistics dashboard.
- **High-Traffic Performance Safeguards & Robust Rollbacks** — Atomic database transactions for buffered counter updates with fail-safe rollback that restores cache values in case of DB connection failures. Prevents N+1 queries by preloading request-wide counters in a single batch cache lookup.
- **Support for Empty Route Prefix (Root-Level URLs)** — Enhanced `getShortUrl()` to support empty route prefixes without generating double slashes (e.g. `domain.com/abc123` instead of `domain.com//abc123`).
## v1.7.0
- **Role-based Settings Access Control** — New `authorizeSettingsUsing(Closure)` method on the plugin to restrict who can access the Settings page. Supports any callable returning a `bool`. Also auto-detects a `manageSettings` method on a registered `ShortUrl` policy. The Settings button in the table header is hidden automatically when access is denied.
## v1.6.0
- **Google Safe Browsing Integration** — Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges.
- **VPN / Proxy / Bot Filtering** — Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean.
- **Visitor World Map Widget** — Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details.
## v1.5.1
- **REST API On/Off Toggle** — Enable or disable the entire developer REST API from Settings → API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled.
## v1.5.0
- **Social Retargeting Pixels** — Attach Meta Pixel, Google Tag (GA4/Ads), and LinkedIn Insight Tag to any short URL. A premium glassmorphic interstitial page executes pixel scripts in the visitor's browser before forwarding them to the destination. Enables building remarketing audiences even on external domains.
- **Developer REST API** — Full `GET /api/short-url/links`, `POST /api/short-url/links`, and `DELETE /api/short-url/links/{id}` endpoints with API Key authentication (managed via Settings UI). Supports creating links with all available attributes including pixels and webhooks.
- **Webhook System** — Real-time HTTP POST notifications on `visited`, `created`, `expired`, and `limit_reached` events. Configurable per-link or globally. Dispatched asynchronously via `SendWebhookJob` with 3-attempt retry policy and 10-second backoff.
- **Settings: API & Webhooks Tab** — New settings tab to manage global webhook URL, monitored event types, and developer API key management with name labels and per-key activation toggles.
## v1.4.0
- **Validity Date Ranges (From-To)** — Set activation dates (`activated_at` and `expires_at`) to control exactly when a short link is active.
- **Custom Visit Limit Counters** — Define a custom maximum visit limit (`max_visits`) after which a link automatically expires (e.g., active for 3 hits).
- **Custom Expiration Fallbacks** — Redirect expired/inactive visitors to a custom `expiration_redirect_url` rather than showing a static 410 Gone error page.
- **Fluent Builder APIs** — Fluent method additions (`activatedAt()`, `deactivatedAt()`, `maxVisits()`, `expirationRedirectUrl()`) in the developer query builder.
## v1.3.0
- **Automatic Scheduler Registration** — Zero-configuration task registration within the ServiceProvider booted phase (dynamically honors Settings toggles).
- **Interactive Settings Validators** — Adds real-time "Test connection" verify action for GA4 Measurement Protocol and "Verify file" action for MaxMind database paths.
- **Robust Table Row Copy Action** — High-reliability, conflict-free click-to-copy in table rows with built-in fallback helper for non-HTTPS (secure context) browser environments.
## v1.2.0
- **Password-protected links** — Session-based unlock flow with a styled prompt page.
- **Redirect warning pages** — Interstitial security screen before external redirects.
- **Smart targeting** — Device-based, Country/Geo-based, and A/B weighted rotation rules per link.
- **Rate limiting** — Configurable per-IP redirect throttling with 429 responses.
- **Daily stats aggregation** — Nightly `short-url:aggregate-and-prune` command for scalable log management.
- **Extended Settings GUI** — New "Performance & Security" tab for aggregation and rate limiting configuration.
- **Polish translations** — Full `pl` locale support for all new features.

298
LICENSE
View File

@@ -1,5 +1,5 @@
================================================================================
SOURCE-AVAILABLE & DUAL-COMMERCIAL LICENSE — VERSION 1.1
SOURCE-AVAILABLE & DUAL-COMMERCIAL LICENSE — VERSION 1.3
janczakb/filament-short-url (the "Software")
================================================================================
@@ -11,6 +11,11 @@ SOFTWARE. BY TAKING ANY OF THOSE ACTIONS, YOU UNCONDITIONALLY ACCEPT EVERY
TERM AND CONDITION OF THIS AGREEMENT. IF YOU DO NOT ACCEPT, DO NOT DOWNLOAD,
INSTALL, OR USE THE SOFTWARE IN ANY WAY.
This License is intended for professional and business use (B2B). You represent
that You are acquiring the Software for professional purposes and not as a
consumer acting outside Your trade, business, craft, or profession, to the
extent such qualification is relevant under applicable law.
================================================================================
SECTION 1 — DEFINITIONS
================================================================================
@@ -21,27 +26,73 @@ SECTION 1 — DEFINITIONS
documentation, and any future updates, patches, or versions thereof.
1.2 "Copyright Holder" means Bartłomiej Janczak, sole and exclusive author
and owner of all intellectual property rights in and to the Software.
and owner of all intellectual property rights in and to the Software,
except for third-party components governed by Section 6.4.
1.3 "You" (or "Your") means the individual or legal entity exercising rights
under this License.
1.4 "Permitted Free Use" means:
1.4 "Official Distribution Channels" means distribution points operated or
expressly authorized in writing by the Copyright Holder for distributing
the Software to licensees, including without limitation:
(a) the official GitHub repository maintained by the Copyright Holder;
(b) the janczakb/filament-short-url package listing on Packagist.org;
(c) any mirror, release artifact, or update channel explicitly linked
from the official repository or Copyright Holder website.
Receiving a copy through an Official Distribution Channel at the Copyright
Holder's direction is not "redistribution" by You.
1.5 "Software Transfer" means, with respect to the Software or any portion
thereof (including unmodified vendor copies, source trees, archives,
Composer packages, Docker images, or deployment artifacts from which the
Software can be extracted):
(a) delivering, disclosing, publishing, or making accessible such copies
to any third party;
(b) granting any third party the right or practical ability to copy,
redistribute, sublicense, republish, or further transfer the Software;
(c) placing the Software in a repository, package index, shared drive,
client portal, or artifact store where the client or any third party
may obtain standalone copies independent of Your controlled runtime;
(d) licensing or selling access to the Software itself, rather than to
services or outcomes produced using the Software under this License.
For the avoidance of doubt, Software Transfer does NOT include:
(i) operating the Software on infrastructure controlled by You or Your
client where the client receives no standalone copy and no rights
to extract, republish, or sublicense the Software;
(ii) delivering an integrated application to a client under Section
1.7(b) when the client receives only runtime access and no rights
described in subsections (a) through (d) above.
1.6 "Distinct Client Engagement" means one separately contracted scope of work
for one end client identified in writing before deployment begins. Multiple
projects, environments, brands, or business units of the same client
constitute separate engagements unless expressly covered by one written
statement of work.
1.7 "Permitted Free Use" means use that satisfies ALL of Sections 1.7(a) or
(b), Section 1.12 (High-Volume Use limit), and every restriction in
Section 3:
(a) Personal, educational, research, or internal business use by a single
natural person or a single legal entity solely for that entity's own
internal operations;
(b) Deployment of the Software for a single, uniquely identified client as
a work-for-hire or freelance project, provided that:
(i) the client does not receive, resell, or otherwise transfer the
Software or any portion thereof to any third party;
(ii) the deployment is limited to one (1) production environment per
distinct client engagement; and
(iii) the monthly tracked-click/redirect volume of the resulting
system does not exceed 1,000,000 (one million) events per
calendar month across all deployed instances attributable to
that engagement.
internal operations, including staging and non-production environments
controlled by that same entity;
(b) Deployment of the Software for one Distinct Client Engagement as a
work-for-hire or freelance project, provided that:
(i) You remain the sole licensee and operator responsible for
compliance with this License;
(ii) the engagement uses no more than one (1) production environment
for that client unless a Commercial License expressly authorizes
more;
(iii) You do not perform any Software Transfer to the client or any
third party, except that the client may receive compiled or
integrated application access without standalone Software copies
or redistribution rights as described in Section 1.5;
(iv) the client does not resell, license, distribute, or operate the
Software as a product for third parties;
(v) Qualified Events attributable to that engagement do not exceed
the High-Volume Use threshold in Section 1.12.
1.5 "Commercial Distribution" means any act of selling, renting, leasing,
1.8 "Commercial Distribution" means any act of selling, renting, leasing,
sublicensing, or otherwise transferring — for monetary gain, in-kind
consideration, or any other commercial benefit — the Software, or any
Derivative Work, or any Bundled Product, to one or more third parties.
@@ -55,30 +106,41 @@ SECTION 1 — DEFINITIONS
which the Software's functionality is a material component;
(c) providing the Software to a client under a contract where the client
subsequently resells, licenses, or distributes the resulting product
to end users.
to end users;
(d) performing any Software Transfer for consideration or as part of a
commercial deliverable.
1.6 "Derivative Work" means any work that is based upon, incorporates,
1.9 "Derivative Work" means any work that is based upon, incorporates,
modifies, adapts, translates, or is otherwise derived from the Software
or any substantial part thereof, regardless of the programming language,
file format, or packaging used.
1.7 "Bundled Product" means any software application, plugin, extension,
1.10 "Bundled Product" means any software application, plugin, extension,
theme, template, framework, boilerplate, starter kit, digital product, or
service offering in which the Software — in whole or in any part, modified
or unmodified — is included, embedded, required as a dependency, or
integrated in any manner, irrespective of whether the Software constitutes
the primary component or a minor ancillary element.
1.8 "High-Volume Use" means any deployment — whether by a single entity or
across multiple instances — that generates more than 1,000,000 (one
million) combined tracked clicks, redirects, or link events per calendar
month attributable to the Software's functionality.
1.11 "Qualified Event" means each HTTP redirect response served through the
Software's redirect routes and each visit or click recorded by the
Software's built-in tracking, counters, analytics, or daily-stats
subsystems, whether or not visit logging is enabled for a given link.
Automated health checks, load tests, and crawler traffic that the Software
explicitly excludes from counters in its own code are excluded.
1.9 "Repository" means any version-control hosting service or platform,
1.12 "High-Volume Use" means any deployment — whether internal, client,
single-instance, or multi-instance — that generates more than 1,000,000
(one million) Qualified Events in any calendar month attributable to
deployments operated under Your license. Attribution includes all
environments (production, staging, preview, and replicas) that You
control or cause to be operated for the same entity or engagement.
1.13 "Repository" means any version-control hosting service or platform,
including without limitation GitHub, GitLab, Bitbucket, Codeberg, or any
self-hosted equivalent, whether public, private, or restricted.
1.10 "Package Registry" means any software distribution index, including
1.14 "Package Registry" means any software distribution index, including
without limitation Packagist, npm, PyPI, or any equivalent service.
================================================================================
@@ -89,16 +151,23 @@ Subject strictly to the conditions and restrictions in Sections 3 and 4, the
Copyright Holder grants You a worldwide, royalty-free, non-exclusive,
non-sublicensable, non-transferable license to:
2.1 Download, install, and use the Software solely for Permitted Free Use as
defined in Section 1.4.
2.1 Obtain copies of the Software only through Official Distribution Channels
or other channels expressly authorized in writing by the Copyright Holder,
and install and use the Software solely for Permitted Free Use as defined
in Section 1.7. Merely downloading or installing a copy from an Official
Distribution Channel does not constitute redistribution by You.
2.2 Modify the Software solely for Your own private or internal use under
Section 1.4(a) or (b), provided that all such modifications:
Section 1.7(a) or (b), provided that all such modifications:
(a) are kept strictly private and are never distributed, published,
shared, disclosed, or made accessible to any third party in any form;
(b) retain this entire License and all copyright notices without
alteration.
2.3 Make a reasonable number of backup copies and deployment artifacts
strictly necessary to operate Permitted Free Use, provided such copies
remain under Your control and are not subject to Software Transfer.
No rights are granted except those expressly set out in this Section 2.
All other rights are reserved exclusively by the Copyright Holder.
@@ -107,31 +176,38 @@ SECTION 3 — STRICT RESTRICTIONS AND PROHIBITIONS
================================================================================
The following acts are CATEGORICALLY AND UNCONDITIONALLY PROHIBITED unless
You hold a valid, current, written Commercial License issued by the Copyright
Holder under Section 5:
You hold a valid, current Commercial License issued by the Copyright Holder
under Section 5:
3.1 REDISTRIBUTION IN ANY FORM. You may not publish, upload, transmit,
broadcast, mirror, host, or otherwise make available the Software — in
whole or in any part, modified or unmodified — through any means,
including but not limited to:
(a) any public or private Repository, regardless of access controls;
(b) any Package Registry;
(a) any public or private Repository, regardless of access controls,
except as expressly permitted in Section 3.1(A);
(b) any Package Registry, except Official Distribution Channels operated
by the Copyright Holder;
(c) file-sharing platforms, cloud storage, or peer-to-peer networks;
(d) email, instant messaging, or any other communication channel.
SOLE EXCEPTION — CONTRIBUTION FORKS: A single public fork of the
official repository on GitHub is permitted exclusively to submit a Pull
Request to the original repository. Such a fork:
(i) must be created solely for the purpose of the specific contribution;
(ii) must not be used for any other purpose, including personal hosting
or showcase;
(A) CONTRIBUTION FORKS — SOLE EXCEPTION. Public fork(s) of the official
repository on GitHub are permitted exclusively to prepare and submit Pull
Request(s) to the original repository. Each such fork:
(i) must be created and maintained solely for contribution work;
(ii) must not be used for hosting, demonstration, resale, or any
independent product;
(iii) must reproduce this License in full without modification;
(iv) must be deleted or converted to private within thirty (30) calendar
days of the earlier of: (A) the Pull Request being closed, merged,
or rejected; or (B) ninety (90) calendar days of inactivity. Failure
to do so constitutes a material breach of this License.
days of the earlier of: (A) all related Pull Request(s) being
closed, merged, or rejected; or (B) ninety (90) calendar days of
inactivity on that fork. Failure to comply constitutes a material
breach of this License.
3.2 BUNDLING AND COMMERCIAL EXPLOITATION. You may not:
3.2 SOFTWARE TRANSFER. You may not perform any Software Transfer except as
expressly allowed without transfer in Section 1.5(i)(ii) and Section
1.7(b)(iii).
3.3 BUNDLING AND COMMERCIAL EXPLOITATION. You may not:
(a) include, embed, bundle, or integrate the Software, in whole or in any
part, into any Bundled Product that is or will be subject to
Commercial Distribution;
@@ -144,30 +220,38 @@ Holder under Section 5:
end users, whether individually priced or as part of a bundle or
subscription.
3.3 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
Commercial Distribution as defined in Section 1.5.
3.4 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
Commercial Distribution as defined in Section 1.8.
3.4 HIGH-VOLUME USE without a Commercial License. You may not operate any
3.5 HIGH-VOLUME USE without a Commercial License. You may not operate any
system or service that constitutes High-Volume Use as defined in
Section 1.8.
Section 1.12, including internal business use under Section 1.7(a).
3.5 RELABELING AND MISATTRIBUTION. You may not:
3.6 RELABELING AND MISATTRIBUTION. You may not:
(a) publish, distribute, or otherwise make available the Software under a
different package name, author name, or vendor identity;
(b) remove, obscure, alter, or misrepresent the copyright notice or author
attribution in any copy or portion of the Software;
(b) remove, obscure, alter, or misrepresent the copyright notice, license
text, or author attribution in any copy or portion of the Software;
(c) represent, imply, or permit others to believe that the Software was
created by anyone other than Bartłomiej Janczak.
3.6 SUBLICENSING AND TRANSFER. You may not grant any sublicense, assign any
rights, or otherwise transfer this License or any rights granted hereunder
to any third party.
3.7 SUBLICENSING AND TRANSFER OF LICENSE RIGHTS. You may not grant any
sublicense, assign any rights, or otherwise transfer this License or any
rights granted hereunder to any third party. Hosting the Software for a
client under Section 1.7(b) does not sublicense rights to the client.
3.7 CIRCUMVENTION. You may not structure any transaction, arrangement, or
3.8 CIRCUMVENTION. You may not structure any transaction, arrangement, or
technical implementation with the intent or effect of circumventing any
restriction in this Section 3, including without limitation by splitting a
Bundled Product into artificially separate components or by using an
intermediary entity.
Bundled Product into artificially separate components, using an
intermediary entity, masking the Software as a different dependency,
or separating repositories solely to evade transfer or redistribution
prohibitions.
3.9 COMPETING PRODUCTS. You may not use the Software, or any Derivative Work,
as the core of a substantially similar link-management, URL-shortening,
QR-code, or redirect-analytics product offered to third parties, whether
open or closed source, except under a Commercial License.
================================================================================
SECTION 4 — CONTRIBUTOR LICENSE AGREEMENT (CLA)
@@ -182,12 +266,14 @@ SECTION 4 — CONTRIBUTOR LICENSE AGREEMENT (CLA)
terms the Copyright Holder deems appropriate, including proprietary terms.
4.2 You represent and warrant that:
(a) You are the sole author of the contribution and have the full legal
right to grant the license in Section 4.1;
(a) You are the sole author of the contribution or have obtained all
rights necessary to grant the license in Section 4.1;
(b) the contribution does not infringe any third-party intellectual
property rights;
(c) the contribution is not subject to any license, agreement, or
obligation that would conflict with the grant in Section 4.1.
obligation that would conflict with the grant in Section 4.1;
(d) if the contribution includes work created for an employer or client,
You have authority to submit it under Section 4.1.
4.3 The Copyright Holder retains the right to reject any contribution without
giving reasons and without incurring any obligation to the contributor.
@@ -197,10 +283,11 @@ SECTION 5 — COMMERCIAL LICENSING
================================================================================
5.1 Any use not expressly permitted by Section 2 — including Commercial
Distribution, Bundled Product distribution, High-Volume Use, or any
other commercial exploitation of the Software — requires a separate,
written Commercial License issued by the Copyright Holder prior to such
use.
Distribution, Software Transfer beyond Section 1.5(i)(ii), Bundled
Product distribution, High-Volume Use, competing products under
Section 3.9, or any other commercial exploitation of the Software —
requires a separate Commercial License issued by the Copyright Holder
prior to such use.
5.2 To inquire about or obtain a Commercial License, contact:
@@ -209,11 +296,15 @@ SECTION 5 — COMMERCIAL LICENSING
5.3 Commercial Licenses are issued at the Copyright Holder's sole discretion
and may be subject to a one-time fee, recurring royalty, revenue-sharing
arrangement, or other terms as agreed in writing. No Commercial License
is effective until signed in writing by the Copyright Holder.
arrangement, or other terms as agreed in a written instrument signed by
the Copyright Holder, including a qualified electronic signature or other
form of document with legal effect equivalent to writing under applicable
law. No Commercial License is effective until so signed or otherwise
authenticated by the Copyright Holder.
5.4 Any act of Commercial Distribution, Bundled Product distribution, or
High-Volume Use without a valid Commercial License:
5.4 Any act of Commercial Distribution, prohibited Software Transfer,
Bundled Product distribution, High-Volume Use, or competing use under
Section 3.9 without a valid Commercial License:
(a) is null and void ab initio;
(b) constitutes a material breach of this License;
(c) constitutes direct infringement of the Copyright Holder's exclusive
@@ -232,7 +323,7 @@ SECTION 6 — INTELLECTUAL PROPERTY
6.1 The Software is licensed, not sold. Bartłomiej Janczak retains all right,
title, and interest in and to the Software, including all copyrights,
moral rights, trade secrets, and any other intellectual property rights,
worldwide.
worldwide, except as expressly granted in this License.
6.2 This License does not transfer to You any ownership interest in or to the
Software, any trademarks, trade names, or service marks of the Copyright
@@ -250,6 +341,11 @@ SECTION 6 — INTELLECTUAL PROPERTY
janczakb/filament-short-url package and does not modify or supersede the
terms of any third-party license.
6.5 PATENTS. No patent rights are granted except to the minimum extent
necessary for You to exercise the copyright permissions expressly granted
in Section 2 during Permitted Free Use or under a valid Commercial
License. All other patent rights are reserved.
================================================================================
SECTION 7 — TERMINATION
================================================================================
@@ -262,7 +358,8 @@ SECTION 7 — TERMINATION
(a) immediately cease all use, reproduction, and distribution of the
Software;
(b) permanently delete all copies, full or partial, of the Software in
Your possession or control, including backups and archived copies;
Your possession or control, including backups, deployment artifacts,
and archived copies;
(c) upon request, certify in writing to the Copyright Holder that You have
complied with Section 7.2(b).
@@ -277,20 +374,58 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. THE COPYRIGHT
HOLDER DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, OPERATE
WITHOUT INTERRUPTION, OR BE FREE FROM ERRORS OR SECURITY VULNERABILITIES.
YOU ASSUME ALL RISKS ASSOCIATED WITH YOUR USE OF THE SOFTWARE.
YOU ASSUME THE ENTIRE RISK AS TO THE QUALITY, PERFORMANCE, SECURITY, AND
RESULTS OF YOUR USE OF THE SOFTWARE. YOU ARE SOLELY RESPONSIBLE FOR ANY DAMAGE
TO YOUR SYSTEMS, DATA, BUSINESS, OR THIRD PARTIES ARISING FROM YOUR USE OR
INABILITY TO USE THE SOFTWARE.
================================================================================
SECTION 9 — LIMITATION OF LIABILITY
SECTION 9 — LIMITATION OF LIABILITY (LICENSEE CLAIMS AGAINST COPYRIGHT HOLDER)
================================================================================
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
CONSEQUENTIAL, OR PUNITIVE DAMAGES (INCLUDING BUT NOT LIMITED TO LOSS OF
PROFITS, LOSS OF DATA, BUSINESS INTERRUPTION, OR COST OF SUBSTITUTE GOODS OR
SERVICES) ARISING OUT OF OR IN CONNECTION WITH THIS LICENSE OR THE USE OR
INABILITY TO USE THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES. THE COPYRIGHT HOLDER'S TOTAL LIABILITY FOR ANY CLAIM ARISING UNDER
THIS LICENSE SHALL NOT EXCEED EUR 100 (ONE HUNDRED EURO).
This Section limits claims that a Licensee (or any user acting under this
License) may bring AGAINST the Copyright Holder. It does NOT limit, reduce,
or cap any remedy, claim, or enforcement action that the Copyright Holder may
bring against You for breach of this License, copyright infringement, or
unauthorized use, including without limitation Sections 5.4, 7, and 10.8.
9.1 EXCLUSION OF DAMAGES. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW,
THE COPYRIGHT HOLDER SHALL NOT BE LIABLE TO YOU FOR ANY DAMAGES OF ANY
KIND OR NATURE, WHETHER DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
CONSEQUENTIAL, OR PUNITIVE, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS,
REVENUE, GOODWILL, DATA, BUSINESS, OR OPPORTUNITY, ARISING OUT OF OR IN
CONNECTION WITH THIS LICENSE OR THE SOFTWARE, EVEN IF THE COPYRIGHT
HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
9.2 ZERO LIABILITY FOR PROFESSIONAL USE. WHERE PERMITTED FOR PROFESSIONAL OR
BUSINESS USE (B2B), THE COPYRIGHT HOLDER SHALL HAVE NO LIABILITY WHATSOEVER
TO YOU UNDER OR IN CONNECTION WITH THIS LICENSE.
9.3 MANDATORY-LAW FLOOR. IF A COMPETENT COURT FINALLY DETERMINES THAT ANY
PART OF SECTIONS 9.1 OR 9.2 CANNOT BE ENFORCED FOR A PARTICULAR CLAIM,
THE COPYRIGHT HOLDER'S TOTAL AGGREGATE LIABILITY FOR THAT CLAIM AND ALL
RELATED CLAIMS ARISING UNDER THIS LICENSE SHALL NOT EXCEED THE GREATER OF:
(a) EUR 0 (ZERO EURO); OR
(b) THE FEES ACTUALLY PAID BY YOU TO THE COPYRIGHT HOLDER FOR A VALID
COMMERCIAL LICENSE FOR THE SOFTWARE IN THE TWELVE (12) MONTHS
IMMEDIATELY PRECEDING THE EVENT GIVING RISE TO THE CLAIM;
AND IN ALL CASES ONLY TO THE MINIMUM EXTENT REQUIRED BY MANDATORY
APPLICABLE LAW. NOTHING IN THIS SECTION 9.3 EXPANDS LIABILITY BEYOND
WHAT APPLICABLE LAW REQUIRES.
9.4 EXCLUSIVE REMEDY. YOUR SOLE AND EXCLUSIVE REMEDY FOR DISSATISFACTION WITH
THE SOFTWARE OR ANY BREACH BY THE COPYRIGHT HOLDER OF THIS LICENSE IS TO
DISCONTINUE USE OF THE SOFTWARE AND DELETE ALL COPIES IN YOUR POSSESSION
OR CONTROL.
9.5 NO LIMITATION ON COPYRIGHT HOLDER ENFORCEMENT. For clarity: if You breach
this License, the Copyright Holder may pursue all remedies available under
this License and applicable law without regard to this Section 9. A person
who infringes or misuses the Software is not a beneficiary of this
limitation and may owe the Copyright Holder damages, injunctive relief,
disgorgement of profits, and other remedies far exceeding any amount
referenced in Section 9.3.
================================================================================
SECTION 10 — GENERAL PROVISIONS
@@ -324,11 +459,18 @@ SECTION 10 — GENERAL PROVISIONS
publish revised versions of this License. Each version will be identified
by a version number. The version under which You obtained the Software
governs Your use unless the Copyright Holder expressly states otherwise.
This is version 1.1, effective 1 January 2026.
This is version 1.3, effective 8 June 2026. Version 1.3 supersedes
version 1.2 for copies obtained on or after its effective date.
10.7 LANGUAGE. This License is written in English. Any translation is provided
for convenience only; in the event of a conflict the English text prevails.
10.8 AUDIT. Upon reasonable prior written notice, the Copyright Holder may
request written confirmation that Your use remains within Permitted Free
Use, including monthly Qualified Event volumes. You must respond within
thirty (30) calendar days with good-faith information. Refusal to respond
or material misrepresentation constitutes a breach of this License.
================================================================================
END OF LICENSE
================================================================================

1071
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" role="img" aria-label="System overview">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="600" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="520" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">System overview</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Filament panel, public routes, core services, persistence</text>
<g filter="url(#shadow)">
<rect x="48" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="100" width="272" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="72" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Filament admin</text>
<text x="72" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Panel layer</text>
<text x="72" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">ShortUrlResource</text>
<text x="72" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Settings, domains, QR</text>
<text x="72" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Analytics and live feed</text>
<rect x="72" y="258" width="170" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="157.0" y="274.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope_links_to_user</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="100" width="272" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="368" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Public HTTP</text>
<text x="368" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Edge of Laravel app</text>
<text x="368" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s/[key]</text>
<text x="368" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /s-auth/[key]</text>
<text x="368" y="232" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">GET /api/short-url/*</text>
<text x="368" y="254" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bae6fd">/[key] on custom domain</text>
<rect x="368" y="278" width="92" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#38bdf8" stroke-opacity="0.35"/><text x="414.0" y="293.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="100" width="272" height="220" rx="26" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="100" width="272" height="220" rx="26" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="100" width="272" height="3" rx="1.5" fill="#2dd4bf" fill-opacity="0.85"/>
<text x="664" y="136" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Package core</text>
<text x="664" y="158" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Runtime services</text>
<text x="664" y="188" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">RedirectHandler</text>
<text x="664" y="210" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">TrackShortUrlVisitJob</text>
<text x="664" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">SendWebhookJob (HMAC)</text>
<text x="664" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">HasStats and rollups</text>
</g><line x1="184" y1="320" x2="184" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="480" y1="320" x2="480" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="776" y1="320" x2="776" y2="352" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="120" y="360" width="720" height="130" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="360" width="720" height="130" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="120" y="360" width="720" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="396" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Persistence layer</text>
<text x="480" y="422" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#94a3b8">short_urls, visits, daily_stats, cache, optional Redis buffer</text>
<rect x="200" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="265.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SQL DB</text>
<rect x="350" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="405.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Redis</text>
<rect x="480" y="448" width="130" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="545.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Safe Browsing</text>
<rect x="630" y="448" width="110" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="685.0" y="464.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Link cache</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="512" width="864" height="56" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="512" width="864" height="56" rx="20" fill="url(#shine)" pointer-events="none"/>
<text x="480" y="546" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff">Self-hosted Laravel plugin, not edge SaaS</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 680" role="img" aria-label="Redirect lifecycle">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="680" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="600" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Redirect lifecycle</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">GET /s/[key] from click to destination</text>
<g filter="url(#shadow)">
<rect x="280" y="96" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="96" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="96" width="400" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="120" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Visitor click</text>
<text x="480" y="138" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Start of request</text>
</g><line x1="480" y1="150" x2="480" y2="168" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="168" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="168" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="168" width="400" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="480" y="192" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Throttle only</text>
<text x="480" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">No web middleware, no session cookie</text>
</g><line x1="480" y1="222" x2="480" y2="240" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="240" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="240" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="240" width="400" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
<text x="480" y="264" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">findByKey(host)</text>
<text x="480" y="282" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Cache hit or single DB resolve</text>
</g><line x1="480" y1="294" x2="480" y2="312" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="312" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="312" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="312" width="400" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="480" y="336" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Safe Browsing cache</text>
<text x="480" y="354" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">isSafeCached(destination) when enabled</text>
</g><line x1="480" y1="366" x2="480" y2="384" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="384" width="400" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="384" width="400" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="384" width="400" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="408" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">max_visits lock</text>
<text x="480" y="426" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">lockForUpdate plus isActive()</text>
</g><line x1="480" y1="456" x2="480" y2="476" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,476 410,514 550,514" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="504" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">Response path</text>
<g filter="url(#shadow)">
<rect x="48" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="528" width="272" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="184" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Password</text>
<text x="184" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">/s-auth/[key] + web</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="528" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
<text x="480" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Interstitial</text>
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">warning, pixels, cloak, OG</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="528" width="272" height="88" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="528" width="272" height="88" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="528" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="776" y="556" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="700">Simple 302</text>
<text x="776" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">TrackJob then redirect</text>
</g>
<g filter="url(#shadow)">
<rect x="120" y="636" width="720" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="636" width="720" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<text x="480" y="664" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#cbd5e1">TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Stateless vs stateful">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="460" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stateless vs stateful routes</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Lean clicks vs session-backed password unlock</text>
<g filter="url(#shadow)">
<rect x="48" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="96" width="416" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="76" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Every redirect click</text>
<rect x="76" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="120.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">STATELESS</text>
<text x="76" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s/[key]</text>
<text x="76" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /[key] on custom domain</text>
<text x="76" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: throttle:120,1</text>
<text x="76" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">No cookies, no CSRF</text>
<rect x="128" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="228.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed in v5.2</text>
</g>
<g filter="url(#shadow)">
<rect x="496" y="96" width="416" height="280" rx="28" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="496" y="96" width="416" height="280" rx="28" fill="url(#shine)" pointer-events="none"/>
<rect x="496" y="96" width="416" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
<text x="524" y="132" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="17" fill="#e2e8f0" font-weight="700">Password unlock only</text>
<rect x="524" y="148" width="88" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="568.0" y="163.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">SESSION</text>
<text x="524" y="188" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /s-auth/[key]</text>
<text x="524" y="210" text-anchor="start" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#e2e8f0">GET /auth/[key] on custom domain</text>
<text x="524" y="232" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Middleware: web + throttle</text>
<text x="524" y="254" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Unlock remembered per session</text>
<rect x="576" y="284" width="200" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="676.0" y="300.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">By design</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="396" width="864" height="44" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="396" width="864" height="44" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="396" width="864" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="480" y="424" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fecaca">Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Custom domain routing">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Custom domains and domain_scope_id</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Same slug on different domains without collision</text>
<g filter="url(#shadow)">
<rect x="64" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="64" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="64" y="96" width="380" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="254" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Default app domain</text>
<text x="254" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#ddd6fe">app.test/s/promo</text>
<rect x="154" y="178" width="200" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="254.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">domain_scope_id = 0</text>
</g>
<g filter="url(#shadow)">
<rect x="516" y="96" width="380" height="150" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="516" y="96" width="380" height="150" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="516" y="96" width="380" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="706" y="132" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#e2e8f0" font-weight="700">Verified custom domain</text>
<text x="706" y="160" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#bae6fd">links.brand.com/promo</text>
<rect x="586" y="178" width="240" height="26" rx="13" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="706.0" y="195.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">scope = custom_domain_id</text>
</g><line x1="254" y1="246" x2="254" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="706" y1="246" x2="706" y2="276" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="180" y="276" width="600" height="62" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="180" y="276" width="600" height="62" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="180" y="276" width="600" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="304" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#a7f3d0" font-weight="700">Composite unique: (url_key, domain_scope_id)</text>
<text x="480" y="324" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#94a3b8">GET /links/exists?url_key=promo&amp;custom_domain_id=3</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="360" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="360" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="360" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="392" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="16" fill="#fed7aa" font-weight="700">Ownership enforcement</text>
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Filament domains scoped by user_id</text>
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">API: CustomDomainValidator blocks foreign domain IDs</text>
<rect x="320" y="458" width="320" height="24" rx="12" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="480.0" y="474.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Fixed: exists scope + ownership (v5.2)</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 460" role="img" aria-label="Targeting and A/B">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="460" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="400" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Smart targeting and A/B split</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">RedirectUrlResolver evaluates rules top to bottom</text>
<g filter="url(#shadow)">
<rect x="360" y="92" width="240" height="46" rx="23" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="360" y="92" width="240" height="46" rx="23" fill="url(#shine)" pointer-events="none"/>
<rect x="360" y="92" width="240" height="3" rx="1.5" fill="#a78bfa" fill-opacity="0.85"/>
<text x="480" y="122" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="13" fill="#e2e8f0" font-weight="600">GET /s/[key]</text>
</g><line x1="480" y1="138" x2="480" y2="158" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="300" y="158" width="360" height="56" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="300" y="158" width="360" height="56" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="300" y="158" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="480" y="184" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">RedirectUrlResolver</text>
<text x="480" y="204" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UA, Geo, language, rules</text>
</g><line x1="480" y1="214" x2="480" y2="238" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/><text x="480" y="262" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="600">First matching rule?</text><line x1="400" y1="272" x2="160" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/><line x1="560" y1="272" x2="800" y2="310" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="48" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="310" width="272" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="184" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Rule matched</text>
<text x="184" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Use rule URL</text>
<text x="184" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Nested A/B in rule</text>
</g>
<g filter="url(#shadow)">
<rect x="344" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="344" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="344" y="310" width="272" height="3" rx="1.5" fill="#facc15" fill-opacity="0.85"/>
<text x="480" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Root A/B</text>
<text x="480" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Weighted 2-5 URLs</text>
<text x="480" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">Variant on visit row</text>
</g>
<g filter="url(#shadow)">
<rect x="640" y="310" width="272" height="100" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="640" y="310" width="272" height="100" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="640" y="310" width="272" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="776" y="338" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">No match</text>
<text x="776" y="358" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">destination_url</text>
<text x="776" y="376" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#94a3b8">UTM merge + query forward</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 500" role="img" aria-label="Webhooks and HMAC">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="500" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="440" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Webhooks and HMAC signing</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Global events, per-link URL override, SSRF-safe dispatch</text>
<g filter="url(#shadow)">
<rect x="60" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="60" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="60" y="96" width="360" height="3" rx="1.5" fill="#c084fc" fill-opacity="0.85"/>
<text x="240" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Global webhook</text>
<text x="240" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Settings: URL, enabled, events</text>
<text x="240" y="174" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">visited | created | expired | limit</text>
<text x="240" y="196" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#ddd6fe">Signing secret required (v5.2)</text>
<rect x="120" y="208" width="240" height="22" rx="11" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.35"/><text x="240.0" y="223.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">Events are global, not per-link</text>
</g>
<g filter="url(#shadow)">
<rect x="540" y="96" width="360" height="140" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="540" y="96" width="360" height="140" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="540" y="96" width="360" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="720" y="128" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Per-link webhook URL</text>
<text x="720" y="152" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">short_urls.webhook_url override</text>
<text x="720" y="174" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">SSRF re-check in SendWebhookJob</text>
<text x="720" y="196" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#e2e8f0">allow_redirects=false</text>
</g><line x1="480" y1="236" x2="480" y2="262" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="200" y="262" width="560" height="54" rx="18" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="200" y="262" width="560" height="54" rx="18" fill="url(#shine)" pointer-events="none"/>
<rect x="200" y="262" width="560" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="480" y="286" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">SendWebhookJob</text>
<text x="480" y="304" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="10" fill="#94a3b8">Header: X-Short-Url-Signature sha256=... (HMAC body)</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="336" width="800" height="120" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="336" width="800" height="120" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="336" width="800" height="3" rx="1.5" fill="#fb923c" fill-opacity="0.85"/>
<text x="480" y="368" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#fed7aa" font-weight="700">Audit fixes (v5.2)</text>
<text x="480" y="394" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">Before: optional secret allowed unsigned webhooks</text>
<text x="480" y="418" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0">After: Settings rejects enable without secret</text>
<text x="480" y="442" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Safe Browsing on save + redirect | lock_url_key server-side</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" role="img" aria-label="Stats and queue">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="540" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="480" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">Stats pipeline and queue modes</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Visits, aggregation cron, panel vs API stats gap</text>
<text x="480" y="98" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#e2e8f0" font-weight="700">Visit tracking</text>
<g filter="url(#shadow)">
<rect x="60" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="60" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="60" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="140" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Redirect</text>
</g><line x1="220" y1="136" x2="280" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="280" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="280" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="280" y="112" width="160" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="360" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">TrackJob</text>
</g><line x1="440" y1="136" x2="540" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="540" y="112" width="160" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="540" y="112" width="160" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="540" y="112" width="160" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="620" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">visits table</text>
</g><line x1="700" y1="136" x2="740" y2="136" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="740" y="112" width="120" height="48" rx="16" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="740" y="112" width="120" height="48" rx="16" fill="url(#shine)" pointer-events="none"/>
<rect x="740" y="112" width="120" height="3" rx="1.5" fill="#38bdf8" fill-opacity="0.85"/>
<text x="800" y="142" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0" font-weight="600">Buffer</text>
</g>
<g filter="url(#shadow)">
<rect x="120" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="120" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="120" y="180" width="340" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="290" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#6ee7b7" font-weight="700">sync (default)</text>
<text x="290" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Job runs inside redirect</text>
<text x="290" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Works without queue worker</text>
<text x="290" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">GeoIP, webhook, GA4 add latency</text>
</g>
<g filter="url(#shadow)">
<rect x="500" y="180" width="340" height="110" rx="22" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="500" y="180" width="340" height="110" rx="22" fill="url(#shine)" pointer-events="none"/>
<rect x="500" y="180" width="340" height="3" rx="1.5" fill="#60a5fa" fill-opacity="0.85"/>
<text x="670" y="210" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="15" fill="#93c5fd" font-weight="700">redis / database</text>
<text x="670" y="234" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">302 sent first</text>
<text x="670" y="256" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Requires queue:work</text>
<text x="670" y="278" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Settings - General</text>
</g><text x="480" y="318" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#e2e8f0" font-weight="700">Aggregation (schedule:run)</text>
<g filter="url(#shadow)">
<rect x="140" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="140" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="140" y="332" width="200" height="3" rx="1.5" fill="#f87171" fill-opacity="0.85"/>
<text x="240" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Raw visits 90d</text>
</g><line x1="340" y1="354" x2="400" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="400" y="332" width="240" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="400" y="332" width="240" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="400" y="332" width="240" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="520" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">AggregateAndPrune</text>
</g><line x1="640" y1="354" x2="700" y2="354" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="700" y="332" width="200" height="44" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="700" y="332" width="200" height="44" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="700" y="332" width="200" height="3" rx="1.5" fill="#4ade80" fill-opacity="0.85"/>
<text x="800" y="360" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">daily_stats</text>
</g>
<g filter="url(#shadow)">
<rect x="80" y="400" width="800" height="90" rx="24" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="80" y="400" width="800" height="90" rx="24" fill="url(#shine)" pointer-events="none"/>
<rect x="80" y="400" width="800" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="430" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e2e8f0" font-weight="600">Filament: FilteredStatsCollector + cross-dimensional rollups</text>
<text x="480" y="454" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">API stats: date_from / date_to only (cross-filters = open gap)</text>
<text x="480" y="476" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="10" fill="#64748b">Long-range reports use rollups after raw prune</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 560" role="img" aria-label="Audit trust summary">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="560" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="500" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">v5.2 audit trust summary</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">Promise vs old reality vs current status</text>
<g filter="url(#shadow)">
<rect x="48" y="92" width="864" height="36" rx="14" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="92" width="864" height="36" rx="14" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="92" width="864" height="3" rx="1.5" fill="#ffffff" fill-opacity="0.85"/>
<text x="180" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Audit promise</text>
<text x="440" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">Old reality</text>
<text x="730" y="116" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="12" fill="#e2e8f0" font-weight="700">v5.2 status</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="140" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="140" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="140" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Sub-15ms / stateless</text>
<text x="330" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">web on /s/[key]</text>
<text x="600" y="162" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: throttle only</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="180" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="180" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="180" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Multi-tenant panel</text>
<text x="330" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">All admins see all links</text>
<text x="600" y="202" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LinkUserScope</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="220" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="220" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="220" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">API exists</text>
<text x="330" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Ignored domain scope</text>
<text x="600" y="242" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: domain_scope_id</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="260" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="260" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="260" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Domain ownership</text>
<text x="330" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Foreign domain ID</text>
<text x="600" y="282" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: CustomDomainValidator</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="300" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="300" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="300" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Safe Browsing</text>
<text x="330" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Save only</text>
<text x="600" y="322" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: redirect cache</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="340" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="340" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="340" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Webhook HMAC</text>
<text x="330" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Optional secret</text>
<text x="600" y="362" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: required when on</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="380" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="380" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="380" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">lock_url_key</text>
<text x="330" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">UI disabled only</text>
<text x="600" y="402" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: LockedUrlKeyGuard</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="420" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="420" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="420" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="64" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Per-link webhook events</text>
<text x="330" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Documented per-link</text>
<text x="600" y="442" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">OPEN: global list</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="460" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="460" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="460" width="864" height="3" rx="1.5" fill="#fbbf24" fill-opacity="0.85"/>
<text x="64" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Default queue async</text>
<text x="330" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">README vs sync</text>
<text x="600" y="482" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#fbbf24" font-weight="600">BY DESIGN: sync default</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="500" width="864" height="34" rx="10" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="500" width="864" height="34" rx="10" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="500" width="864" height="3" rx="1.5" fill="#34d399" fill-opacity="0.85"/>
<text x="64" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#e2e8f0">Dub parity table</text>
<text x="330" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Overstated</text>
<text x="600" y="522" text-anchor="start" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#34d399" font-weight="600">FIXED: scoped compare</text>
</g>
<g filter="url(#shadow)">
<rect x="48" y="548" width="864" height="72" rx="20" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="48" y="548" width="864" height="72" rx="20" fill="url(#shine)" pointer-events="none"/>
<rect x="48" y="548" width="864" height="3" rx="1.5" fill="#818cf8" fill-opacity="0.85"/>
<text x="480" y="576" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="13" fill="#e9d5ff" font-weight="600">Product boundary: Laravel Filament plugin, not Dub clone</text>
<text x="480" y="598" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="11" fill="#94a3b8">Open: OpenAPI, API stats filters, workspaces, conversion, load tests</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Generate liquid-glass architecture SVG diagrams for README."""
from pathlib import Path
OUT = Path(__file__).parent
GLASS_HEAD = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 {height}" role="img" aria-label="{label}">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1e1b4b"/>
<stop offset="45%" stop-color="#0f172a"/>
<stop offset="100%" stop-color="#164e63"/>
</linearGradient>
<radialGradient id="orb1" cx="20%" cy="15%" r="55%">
<stop offset="0%" stop-color="#a855f7" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#a855f7" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb2" cx="85%" cy="25%" r="50%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
</radialGradient>
<radialGradient id="orb3" cx="50%" cy="90%" r="45%">
<stop offset="0%" stop-color="#2dd4bf" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>
<stop offset="45%" stop-color="#ffffff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur80" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="80"/>
</filter>
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="16" stdDeviation="20" flood-color="#020617" flood-opacity="0.55"/>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#e2e8f0" fill-opacity="0.9"/>
</marker>
</defs>
<rect width="960" height="{height}" fill="url(#bg)"/>
<ellipse cx="180" cy="80" rx="220" ry="160" fill="url(#orb1)" filter="url(#blur80)"/>
<ellipse cx="820" cy="120" rx="200" ry="150" fill="url(#orb2)" filter="url(#blur80)"/>
<ellipse cx="480" cy="{orb_y}" rx="260" ry="180" fill="url(#orb3)" filter="url(#blur80)"/>
<text x="480" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="28" font-weight="700" fill="#f8fafc" letter-spacing="-0.6">{title}</text>
<text x="480" y="72" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="14" fill="#cbd5e1" fill-opacity="0.85">{subtitle}</text>
"""
GLASS_CARD = """
<g filter="url(#shadow)">
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="#ffffff" fill-opacity="0.11" stroke="#ffffff" stroke-opacity="0.38" stroke-width="1.2"/>
<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{rx}" fill="url(#shine)" pointer-events="none"/>
{accent}{content}
</g>"""
ACCENT_TOP = '<rect x="{x}" y="{y}" width="{w}" height="3" rx="1.5" fill="{color}" fill-opacity="0.85"/>\n '
FOOT = """
</svg>
"""
def card(x, y, w, h, content, rx=26, accent=None):
accent_html = ""
if accent:
accent_html = ACCENT_TOP.format(x=x, y=y, w=w, color=accent)
return GLASS_CARD.format(x=x, y=y, w=w, h=h, rx=rx, accent=accent_html, content=content)
def t(x, y, text, size=12, weight="400", fill="#e2e8f0", anchor="start", mono=False):
fam = "ui-monospace,Menlo,monospace" if mono else "system-ui,-apple-system,Segoe UI,sans-serif"
w = f' font-weight="{weight}"' if weight != "400" else ""
return f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-family="{fam}" font-size="{size}" fill="{fill}"{w}>{text}</text>'
def line(x1, y1, x2, y2):
return f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#e2e8f0" stroke-opacity="0.35" stroke-width="2" marker-end="url(#arrow)"/>'
def pill(x, y, w, h, text, stroke="#ffffff"):
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{h//2}" fill="#ffffff" fill-opacity="0.08" stroke="{stroke}" stroke-opacity="0.35"/><text x="{x+w/2}" y="{y+h/2+4}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="#f1f5f9">{text}</text>'
def diagram_01():
h, oy = 600, 520
body = GLASS_HEAD.format(height=h, label="System overview", title="System overview", subtitle="Filament panel, public routes, core services, persistence", orb_y=oy)
body += card(48, 100, 272, 220, "\n ".join([
t(72, 136, "Filament admin", 16, "700"),
t(72, 158, "Panel layer", 11, fill="#94a3b8"),
t(72, 188, "ShortUrlResource", 12),
t(72, 210, "Settings, domains, QR", 12),
t(72, 232, "Analytics and live feed", 12),
pill(72, 258, 170, 24, "scope_links_to_user"),
]), accent="#c084fc")
body += card(344, 100, 272, 220, "\n ".join([
t(368, 136, "Public HTTP", 16, "700"),
t(368, 158, "Edge of Laravel app", 11, fill="#94a3b8"),
t(368, 188, "GET /s/[key]", 11, mono=True, fill="#bae6fd"),
t(368, 210, "GET /s-auth/[key]", 11, mono=True, fill="#bae6fd"),
t(368, 232, "GET /api/short-url/*", 11, mono=True, fill="#bae6fd"),
t(368, 254, "/[key] on custom domain", 11, mono=True, fill="#bae6fd"),
pill(368, 278, 92, 22, "STATELESS", stroke="#38bdf8"),
]), accent="#38bdf8")
body += card(640, 100, 272, 220, "\n ".join([
t(664, 136, "Package core", 16, "700"),
t(664, 158, "Runtime services", 11, fill="#94a3b8"),
t(664, 188, "RedirectHandler", 12),
t(664, 210, "TrackShortUrlVisitJob", 12),
t(664, 232, "SendWebhookJob (HMAC)", 12),
t(664, 254, "HasStats and rollups", 12),
]), accent="#2dd4bf")
body += line(184, 320, 184, 352) + line(480, 320, 480, 352) + line(776, 320, 776, 352)
body += card(120, 360, 720, 130, "\n ".join([
t(480, 396, "Persistence layer", 17, "700", anchor="middle"),
t(480, 422, "short_urls, visits, daily_stats, cache, optional Redis buffer", 12, anchor="middle", fill="#94a3b8"),
pill(200, 448, 130, 24, "SQL DB"),
pill(350, 448, 110, 24, "Redis"),
pill(480, 448, 130, 24, "Safe Browsing"),
pill(630, 448, 110, 24, "Link cache"),
]), accent="#fb923c", rx=28)
body += card(48, 512, 864, 56, t(480, 546, "Self-hosted Laravel plugin, not edge SaaS", 13, anchor="middle", fill="#e9d5ff"), rx=20)
return body + FOOT
def diagram_02():
h, oy = 680, 600
body = GLASS_HEAD.format(height=h, label="Redirect lifecycle", title="Redirect lifecycle", subtitle="GET /s/[key] from click to destination", orb_y=oy)
steps = [
("Visitor click", "Start of request", "#818cf8"),
("Throttle only", "No web middleware, no session cookie", "#fbbf24"),
("findByKey(host)", "Cache hit or single DB resolve", "#a78bfa"),
("Safe Browsing cache", "isSafeCached(destination) when enabled", "#f87171"),
("max_visits lock", "lockForUpdate plus isActive()", "#34d399"),
]
y = 96
for i, (title, sub, color) in enumerate(steps):
body += card(280, y, 400, 54, "\n ".join([
t(480, y + 24, title, 13, "600", anchor="middle"),
t(480, y + 42, sub, 11, anchor="middle", fill="#94a3b8"),
]), accent=color, rx=18)
if i < len(steps) - 1:
body += line(480, y + 54, 480, y + 72)
y += 72
body += line(480, y, 480, y + 20)
body += '<polygon points="480,{0} 410,{1} 550,{1}" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'.format(y + 20, y + 58)
body += t(480, y + 48, "Response path", 12, "600", anchor="middle")
y2 = y + 72
branches = [
(48, "Password", "/s-auth/[key] + web", "#fb923c"),
(344, "Interstitial", "warning, pixels, cloak, OG", "#facc15"),
(640, "Simple 302", "TrackJob then redirect", "#4ade80"),
]
for x, title, sub, color in branches:
body += card(x, y2, 272, 88, "\n ".join([
t(x + 136, y2 + 28, title, 13, "700", anchor="middle"),
t(x + 136, y2 + 48, sub, 10, anchor="middle", fill="#94a3b8"),
]), accent=color, rx=20)
body += card(120, y2 + 108, 720, 48, t(480, y2 + 136, "TrackShortUrlVisitJob: GeoIP, VPN, visit, webhook, GA4 (sync default or async queue)", 11, anchor="middle", fill="#cbd5e1"), rx=16)
return body + FOOT
def diagram_03():
h, oy = 460, 400
body = GLASS_HEAD.format(height=h, label="Stateless vs stateful", title="Stateless vs stateful routes", subtitle="Lean clicks vs session-backed password unlock", orb_y=oy)
for x, title, badge, lines, accent in [
(48, "Every redirect click", "STATELESS", ["GET /s/[key]", "GET /[key] on custom domain", "Middleware: throttle:120,1", "No cookies, no CSRF"], "#34d399"),
(496, "Password unlock only", "SESSION", ["GET /s-auth/[key]", "GET /auth/[key] on custom domain", "Middleware: web + throttle", "Unlock remembered per session"], "#60a5fa"),
]:
content = [t(x + 28, 132, title, 17, "700"), pill(x + 28, 148, 88, 22, badge)]
cy = 188
for ln in lines:
content.append(t(x + 28, cy, ln, 12, mono=("/" in ln)))
cy += 22
content.append(pill(x + 80, cy + 8, 200, 24, "Fixed in v5.2" if x == 48 else "By design"))
body += card(x, 96, 416, 280, "\n ".join(content), accent=accent, rx=28)
body += card(48, 396, 864, 44, t(480, 424, "Old audit: web on /s/[key] sent cookies on every click. Republish config if middleware still includes web.", 11, anchor="middle", fill="#fecaca"), rx=16, accent="#f87171")
return body + FOOT
def diagram_04():
h, oy = 540, 480
body = GLASS_HEAD.format(height=h, label="Custom domain routing", title="Custom domains and domain_scope_id", subtitle="Same slug on different domains without collision", orb_y=oy)
body += card(64, 96, 380, 150, "\n ".join([
t(254, 132, "Default app domain", 16, "700", anchor="middle"),
t(254, 160, "app.test/s/promo", 12, anchor="middle", mono=True, fill="#ddd6fe"),
pill(154, 178, 200, 26, "domain_scope_id = 0"),
]), accent="#c084fc", rx=24)
body += card(516, 96, 380, 150, "\n ".join([
t(706, 132, "Verified custom domain", 16, "700", anchor="middle"),
t(706, 160, "links.brand.com/promo", 12, anchor="middle", mono=True, fill="#bae6fd"),
pill(586, 178, 240, 26, "scope = custom_domain_id"),
]), accent="#38bdf8", rx=24)
body += line(254, 246, 254, 276) + line(706, 246, 706, 276)
body += card(180, 276, 600, 62, "\n ".join([
t(480, 304, "Composite unique: (url_key, domain_scope_id)", 13, "700", anchor="middle", fill="#a7f3d0"),
t(480, 324, "GET /links/exists?url_key=promo&amp;custom_domain_id=3", 11, anchor="middle", mono=True, fill="#94a3b8"),
]), accent="#34d399", rx=20)
body += card(80, 360, 800, 120, "\n ".join([
t(480, 392, "Ownership enforcement", 16, "700", anchor="middle", fill="#fed7aa"),
t(480, 418, "Filament domains scoped by user_id", 12, anchor="middle"),
t(480, 442, "API: CustomDomainValidator blocks foreign domain IDs", 12, anchor="middle"),
pill(320, 458, 320, 24, "Fixed: exists scope + ownership (v5.2)"),
]), accent="#fb923c", rx=24)
return body + FOOT
def diagram_05():
h, oy = 460, 400
body = GLASS_HEAD.format(height=h, label="Targeting and A/B", title="Smart targeting and A/B split", subtitle="RedirectUrlResolver evaluates rules top to bottom", orb_y=oy)
body += card(360, 92, 240, 46, t(480, 122, "GET /s/[key]", 13, "600", anchor="middle", mono=True), rx=23, accent="#a78bfa")
body += line(480, 138, 480, 158)
body += card(300, 158, 360, 56, "\n ".join([
t(480, 184, "RedirectUrlResolver", 13, "600", anchor="middle"),
t(480, 204, "UA, Geo, language, rules", 11, anchor="middle", fill="#94a3b8"),
]), rx=18, accent="#38bdf8")
body += line(480, 214, 480, 238)
body += '<polygon points="480,238 400,272 560,272" fill="#ffffff" fill-opacity="0.12" stroke="#ffffff" stroke-opacity="0.35"/>'
body += t(480, 262, "First matching rule?", 12, "600", anchor="middle")
body += line(400, 272, 160, 310) + line(560, 272, 800, 310)
for x, title, lines, color in [
(48, "Rule matched", ["Use rule URL", "Nested A/B in rule"], "#4ade80"),
(344, "Root A/B", ["Weighted 2-5 URLs", "Variant on visit row"], "#facc15"),
(640, "No match", ["destination_url", "UTM merge + query forward"], "#f87171"),
]:
body += card(x, 310, 272, 100, "\n ".join([t(x + 136, 338, title, 12, "700", anchor="middle")] + [t(x + 136, 358 + i * 18, ln, 10, anchor="middle", fill="#94a3b8") for i, ln in enumerate(lines)]), accent=color, rx=20)
return body + FOOT
def diagram_06():
h, oy = 500, 440
body = GLASS_HEAD.format(height=h, label="Webhooks and HMAC", title="Webhooks and HMAC signing", subtitle="Global events, per-link URL override, SSRF-safe dispatch", orb_y=oy)
body += card(60, 96, 360, 140, "\n ".join([
t(240, 128, "Global webhook", 15, "700", anchor="middle"),
t(240, 152, "Settings: URL, enabled, events", 11, anchor="middle", fill="#94a3b8"),
t(240, 174, "visited | created | expired | limit", 10, anchor="middle", mono=True),
t(240, 196, "Signing secret required (v5.2)", 11, anchor="middle", fill="#ddd6fe"),
pill(120, 208, 240, 22, "Events are global, not per-link"),
]), accent="#c084fc", rx=22)
body += card(540, 96, 360, 140, "\n ".join([
t(720, 128, "Per-link webhook URL", 15, "700", anchor="middle"),
t(720, 152, "short_urls.webhook_url override", 11, anchor="middle", fill="#94a3b8"),
t(720, 174, "SSRF re-check in SendWebhookJob", 11, anchor="middle"),
t(720, 196, "allow_redirects=false", 10, anchor="middle", mono=True),
]), accent="#38bdf8", rx=22)
body += line(480, 236, 480, 262)
body += card(200, 262, 560, 54, "\n ".join([
t(480, 286, "SendWebhookJob", 14, "700", anchor="middle"),
t(480, 304, "Header: X-Short-Url-Signature sha256=... (HMAC body)", 10, anchor="middle", mono=True, fill="#94a3b8"),
]), accent="#34d399", rx=18)
body += card(80, 336, 800, 120, "\n ".join([
t(480, 368, "Audit fixes (v5.2)", 15, "700", anchor="middle", fill="#fed7aa"),
t(480, 394, "Before: optional secret allowed unsigned webhooks", 12, anchor="middle"),
t(480, 418, "After: Settings rejects enable without secret", 12, anchor="middle"),
t(480, 442, "Safe Browsing on save + redirect | lock_url_key server-side", 11, anchor="middle", fill="#94a3b8"),
]), accent="#fb923c", rx=24)
return body + FOOT
def diagram_07():
h, oy = 540, 480
body = GLASS_HEAD.format(height=h, label="Stats and queue", title="Stats pipeline and queue modes", subtitle="Visits, aggregation cron, panel vs API stats gap", orb_y=oy)
body += t(480, 98, "Visit tracking", 15, "700", anchor="middle")
boxes = [(60, "Redirect"), (280, "TrackJob"), (540, "visits table"), (740, "Buffer")]
for i, (x, label) in enumerate(boxes):
body += card(x, 112, 160 if x < 740 else 120, 48, t(x + (80 if x < 740 else 60), 142, label, 11, "600", anchor="middle"), rx=16, accent="#818cf8" if i == 1 else "#38bdf8")
if i < len(boxes) - 1:
nx = boxes[i + 1][0]
body += line(x + (160 if x < 740 else 120), 136, nx, 136)
body += card(120, 180, 340, 110, "\n ".join([
t(290, 210, "sync (default)", 15, "700", anchor="middle", fill="#6ee7b7"),
t(290, 234, "Job runs inside redirect", 11, anchor="middle", fill="#94a3b8"),
t(290, 256, "Works without queue worker", 11, anchor="middle", fill="#94a3b8"),
t(290, 278, "GeoIP, webhook, GA4 add latency", 10, anchor="middle", fill="#64748b"),
]), accent="#34d399", rx=22)
body += card(500, 180, 340, 110, "\n ".join([
t(670, 210, "redis / database", 15, "700", anchor="middle", fill="#93c5fd"),
t(670, 234, "302 sent first", 11, anchor="middle", fill="#94a3b8"),
t(670, 256, "Requires queue:work", 11, anchor="middle", fill="#94a3b8"),
t(670, 278, "Settings - General", 10, anchor="middle", fill="#64748b"),
]), accent="#60a5fa", rx=22)
body += t(480, 318, "Aggregation (schedule:run)", 14, "700", anchor="middle")
body += card(140, 332, 200, 44, t(240, 360, "Raw visits 90d", 11, anchor="middle"), rx=14, accent="#f87171")
body += line(340, 354, 400, 354)
body += card(400, 332, 240, 44, t(520, 360, "AggregateAndPrune", 11, anchor="middle"), rx=14, accent="#fbbf24")
body += line(640, 354, 700, 354)
body += card(700, 332, 200, 44, t(800, 360, "daily_stats", 11, anchor="middle"), rx=14, accent="#4ade80")
body += card(80, 400, 800, 90, "\n ".join([
t(480, 430, "Filament: FilteredStatsCollector + cross-dimensional rollups", 13, "600", anchor="middle"),
t(480, 454, "API stats: date_from / date_to only (cross-filters = open gap)", 11, anchor="middle", fill="#94a3b8"),
t(480, 476, "Long-range reports use rollups after raw prune", 10, anchor="middle", fill="#64748b"),
]), accent="#818cf8", rx=24)
return body + FOOT
def diagram_08():
h, oy = 560, 500
body = GLASS_HEAD.format(height=h, label="Audit trust summary", title="v5.2 audit trust summary", subtitle="Promise vs old reality vs current status", orb_y=oy)
rows = [
("Sub-15ms / stateless", "web on /s/[key]", "FIXED: throttle only", "#34d399"),
("Multi-tenant panel", "All admins see all links", "FIXED: LinkUserScope", "#34d399"),
("API exists", "Ignored domain scope", "FIXED: domain_scope_id", "#34d399"),
("Domain ownership", "Foreign domain ID", "FIXED: CustomDomainValidator", "#34d399"),
("Safe Browsing", "Save only", "FIXED: redirect cache", "#34d399"),
("Webhook HMAC", "Optional secret", "FIXED: required when on", "#34d399"),
("lock_url_key", "UI disabled only", "FIXED: LockedUrlKeyGuard", "#34d399"),
("Per-link webhook events", "Documented per-link", "OPEN: global list", "#fbbf24"),
("Default queue async", "README vs sync", "BY DESIGN: sync default", "#fbbf24"),
("Dub parity table", "Overstated", "FIXED: scoped compare", "#34d399"),
]
y = 92
body += card(48, y, 864, 36, "\n ".join([
t(180, y + 24, "Audit promise", 12, "700", anchor="middle"),
t(440, y + 24, "Old reality", 12, "700", anchor="middle"),
t(730, y + 24, "v5.2 status", 12, "700", anchor="middle"),
]), rx=14, accent="#ffffff")
y += 48
for promise, old, status, color in rows:
body += card(48, y, 864, 34, "\n ".join([
t(64, y + 22, promise, 11),
t(330, y + 22, old, 11, fill="#94a3b8"),
t(600, y + 22, status, 11, "600", fill=color),
]), rx=10, accent=color if "FIXED" in status else "#fbbf24")
y += 40
body += card(48, y + 8, 864, 72, "\n ".join([
t(480, y + 36, "Product boundary: Laravel Filament plugin, not Dub clone", 13, "600", anchor="middle", fill="#e9d5ff"),
t(480, y + 58, "Open: OpenAPI, API stats filters, workspaces, conversion, load tests", 11, anchor="middle", fill="#94a3b8"),
]), rx=20, accent="#818cf8")
return body + FOOT
DIAGRAMS = [
("01-system-overview.svg", diagram_01),
("02-redirect-lifecycle.svg", diagram_02),
("03-stateless-vs-stateful.svg", diagram_03),
("04-custom-domain-routing.svg", diagram_04),
("05-targeting-and-ab.svg", diagram_05),
("06-webhooks-and-security.svg", diagram_06),
("07-stats-and-queue.svg", diagram_07),
("08-audit-trust-summary.svg", diagram_08),
]
if __name__ == "__main__":
for name, fn in DIAGRAMS:
path = OUT / name
path.write_text(fn(), encoding="utf-8")
print(f"Wrote {name}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

24
composer.lock generated
View File

@@ -1676,16 +1676,16 @@
},
{
"name": "guzzlehttp/guzzle",
"version": "7.11.0",
"version": "7.11.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b"
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b",
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
"shasum": ""
},
"require": {
@@ -1704,7 +1704,7 @@
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
"guzzlehttp/test-server": "^0.4",
"guzzlehttp/test-server": "^0.5",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
@@ -1784,7 +1784,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.11.0"
"source": "https://github.com/guzzle/guzzle/tree/7.11.1"
},
"funding": [
{
@@ -1800,7 +1800,7 @@
"type": "tidelift"
}
],
"time": "2026-06-02T12:40:51+00:00"
"time": "2026-06-07T22:54:06+00:00"
},
{
"name": "guzzlehttp/promises",
@@ -2156,16 +2156,16 @@
},
{
"name": "laravel/framework",
"version": "v12.61.0",
"version": "v12.61.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf"
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/1124062a1ca92d290c8bcb9b7f649920fa6816bf",
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf",
"url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d",
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d",
"shasum": ""
},
"require": {
@@ -2374,7 +2374,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-05-26T23:41:33+00:00"
"time": "2026-06-04T14:22:52+00:00"
},
{
"name": "laravel/prompts",

View File

@@ -61,6 +61,17 @@ return [
*/
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
/*
|--------------------------------------------------------------------------
| Custom Domains
|--------------------------------------------------------------------------
| enforce_dns_on_activate: when true, activating a domain (or changing its
| hostname while active) requires a passing DNS check against app.url.
*/
'custom_domains' => [
'enforce_dns_on_activate' => env('SHORT_URL_CUSTOM_DOMAIN_ENFORCE_DNS', true),
],
/*
|--------------------------------------------------------------------------
| Geo-IP Settings
@@ -120,7 +131,8 @@ return [
| Queue Connection
|--------------------------------------------------------------------------
| Visit tracking is dispatched to a queue for ultra-fast redirects.
| Set to null to run synchronously (not recommended in production).
| Default is `sync` (no background worker required). Switch to `database` or
| `redis` in Settings or via SHORT_URL_QUEUE when you run a queue worker.
*/
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
@@ -133,6 +145,22 @@ return [
*/
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
/*
|--------------------------------------------------------------------------
| Redis (Settings override when Queue Connection = redis)
|--------------------------------------------------------------------------
| Defaults from .env; overridden at runtime from Settings → General when
| queue_connection is redis. Used for queue driver, visit counters, stats,
| and live feed — independent of CACHE_STORE.
*/
'redis' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => (int) env('REDIS_PORT', 6379),
'password' => env('REDIS_PASSWORD'),
'database' => (int) env('REDIS_DB', 0),
'prefix' => env('REDIS_PREFIX', ''),
],
/*
|--------------------------------------------------------------------------
| Redirect Cache TTL
@@ -195,6 +223,58 @@ return [
*/
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
/*
|--------------------------------------------------------------------------
| Developer REST API
|--------------------------------------------------------------------------
| Enable programmatic access to short URLs via /api/short-url/* endpoints.
| API keys are managed in the Filament settings panel.
|
*/
'api_enabled' => env('SHORT_URL_API_ENABLED', false),
/*
|--------------------------------------------------------------------------
| VPN & Proxy Detection
|--------------------------------------------------------------------------
| Detect VPN, proxy, and Tor connections on incoming visits.
|
*/
'vpn_detection' => [
'enabled' => env('SHORT_URL_VPN_DETECTION', false),
'driver' => env('SHORT_URL_VPN_DRIVER', 'ip-api'),
'vpnapi_key' => env('SHORT_URL_VPNAPI_KEY'),
'block_action' => env('SHORT_URL_VPN_BLOCK_ACTION', 'flag_only'),
'cache_ttl' => 86400,
'timeout' => 2,
],
/*
|--------------------------------------------------------------------------
| Google Safe Browsing
|--------------------------------------------------------------------------
| Block phishing and malware URLs at creation time.
|
*/
'safe_browsing' => [
'enabled' => env('SHORT_URL_SAFE_BROWSING', false),
'api_key' => env('SHORT_URL_SAFE_BROWSING_KEY'),
'check_on_redirect' => env('SHORT_URL_SAFE_BROWSING_ON_REDIRECT', true),
'redirect_cache_ttl' => (int) env('SHORT_URL_SAFE_BROWSING_REDIRECT_CACHE_TTL', 3600),
],
/*
|--------------------------------------------------------------------------
| Click Deduplication
|--------------------------------------------------------------------------
| Ignore repeated clicks from the same IP within the configured window.
|
*/
'click_deduplication' => [
'enabled' => env('SHORT_URL_CLICK_DEDUP', false),
'hours' => (int) env('SHORT_URL_CLICK_DEDUP_HOURS', 1),
],
/*
|--------------------------------------------------------------------------
| Data Pruning & Aggregation
@@ -235,15 +315,56 @@ return [
|--------------------------------------------------------------------------
| Redirect Route Middleware
|--------------------------------------------------------------------------
| The middleware list applied to the short URL redirect route.
| By default, standard web middleware and rate limiting are applied.
| Middleware applied to the main short URL redirect route (/s/{key}).
| Intentionally excludes the "web" group (sessions/cookies) for a lean
| hot path. Password routes always load "web" separately (/s-auth/{key}).
| Add "web" here only if your app requires session on every redirect.
|
*/
'middleware' => [
'web',
'throttle:120,1',
],
/*
|--------------------------------------------------------------------------
| Link ownership scoping
|--------------------------------------------------------------------------
| When true, authenticated Filament users only see links they created
| (short_urls.user_id). Disable for shared admin panels.
|
*/
'scope_links_to_user' => env('SHORT_URL_SCOPE_TO_USER', true),
/*
|--------------------------------------------------------------------------
| Max visits pessimistic locking
|--------------------------------------------------------------------------
| When remaining slots are above this threshold, use a lock-free UPDATE path.
| Near the cap, a row lock is used to stay exact under concurrent traffic.
*/
'max_visits_pessimistic_remaining' => (int) env('SHORT_URL_MAX_VISITS_PESSIMISTIC_REMAINING', 5),
/*
|--------------------------------------------------------------------------
| Live feed (SSE)
|--------------------------------------------------------------------------
*/
'live_feed' => [
'sse_interval_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_INTERVAL', 3),
'sse_max_duration_seconds' => (int) env('SHORT_URL_LIVE_FEED_SSE_MAX_DURATION', 120),
// When the cache store is Redis, SSE blocks on pub/sub instead of sleep-polling.
'use_redis_push' => env('SHORT_URL_LIVE_FEED_REDIS_PUSH', true),
],
/*
|--------------------------------------------------------------------------
| Webhook signing
|--------------------------------------------------------------------------
| Require a signing secret before dispatching global webhooks.
|
*/
'webhook_signing_required' => env('SHORT_URL_WEBHOOK_SIGNING_REQUIRED', true),
/*
|--------------------------------------------------------------------------
| Deep Linking v2.1
@@ -270,4 +391,111 @@ return [
'avatar_column' => 'avatar_url', // can be attribute/method on model or null to auto-detect HasAvatar
],
/*
|--------------------------------------------------------------------------
| Bot Detection
|--------------------------------------------------------------------------
| Patterns used to identify crawlers and link-preview bots. Specific tokens
| avoid false positives from real browsers (e.g. Chrome never matches "googlebot").
|
*/
'bot_detection' => [
'user_agent_contains' => [
'facebookexternalhit',
'meta-externalagent',
'meta-externalfetcher',
'meta-externalads',
'meta-webindexer',
'Googlebot',
'Google-InspectionTool',
'GoogleOther',
'Storebot-Google',
'bingbot',
'BingPreview',
'DuckDuckBot',
'Slurp',
'Baiduspider',
'YandexBot',
'Applebot',
'Applebot-Extended',
'Twitterbot',
'LinkedInBot',
'linkedinbot',
'Slackbot',
'Slack-ImgProxy',
'Discordbot',
'TelegramBot',
'WhatsApp',
'Pinterestbot',
'Embedly',
'Iframely',
'SkypeUriPreview',
'facebookcatalog',
'MetaInspector',
'vkShare',
'Tumblr',
'redditbot',
'Snap URL Preview',
'Snapchat',
'ChatGPT-User',
'Claude-Web',
'anthropic-ai',
'PerplexityBot',
'Bytespider',
'GPTBot',
'OAI-SearchBot',
'HeadlessChrome',
'Go-http-client',
'python-requests',
'axios/',
'GuzzleHttp',
'PostmanRuntime',
'Insomnia',
'Scrapy',
'FeedBurner',
'W3C_Validator',
'Validator.nu',
'PocketParser',
'BitlyBot',
'rogerbot',
'SemrushBot',
'AhrefsBot',
'MJ12bot',
'DotBot',
'PetalBot',
'Sogou',
'ia_archiver',
'archive.org_bot',
'Wayback',
'UptimeRobot',
'StatusCake',
'Pingdom',
'GTmetrix',
'Bluesky',
'thirdLandingPageFeInfra',
'ShortLinkTranslate',
],
'user_agent_regex' => [
'/bot[\s\/;\)]/i',
'/crawler[\s\/;\)]/i',
'/spider[\s\/;\)]/i',
'/\bscraper\b/i',
'/\bslurp\b/i',
'/\bpreview\b/i',
'/^curl\//i',
'/^wget\//i',
'/\bPHP\/[\d.]+/i',
],
'referer_contains' => [
'url.emailprotection.link',
'urlsand.com',
'statics.teams.cdn.office.net',
'security-za.m.mimecastprotect.com',
'deref-mail.com',
'deref-gmx.com',
],
'verify_google_bot_ip' => env('SHORT_URL_VERIFY_GOOGLEBOT_IP', false),
'debug_secret' => env('SHORT_URL_BOT_DEBUG_SECRET'),
],
];

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->string('external_id')->nullable()->unique();
$table->string('utm_source')->nullable();
$table->string('utm_medium')->nullable();
$table->string('utm_campaign')->nullable();
$table->string('utm_term')->nullable();
$table->string('utm_content')->nullable();
$table->string('ref')->nullable();
$table->boolean('public_stats_enabled')->default(false);
$table->string('public_stats_password')->nullable();
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->index(['short_url_id', 'ip_hash'], 'short_url_visits_url_ip_hash_index');
});
}
public function down(): void
{
Schema::table('short_url_visits', function (Blueprint $table) {
$table->dropIndex('short_url_visits_url_ip_hash_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->dropColumn([
'external_id',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'ref',
'public_stats_enabled',
'public_stats_password',
]);
});
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_urls', function (Blueprint $table) {
$table->unsignedBigInteger('domain_scope_id')->default(0);
});
DB::table('short_urls')->whereNotNull('custom_domain_id')->update([
'domain_scope_id' => DB::raw('custom_domain_id'),
]);
Schema::table('short_urls', function (Blueprint $table) {
$table->dropUnique(['url_key']);
$table->unique(['url_key', 'domain_scope_id'], 'short_urls_url_key_domain_scope_unique');
$table->index(['user_id', 'is_archived', 'id'], 'short_urls_user_archived_id_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->foreign('custom_domain_id')
->references('id')
->on('short_url_custom_domains')
->nullOnDelete();
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->index(['short_url_id', 'id'], 'short_url_visits_url_id_index');
});
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->json('utm_terms')->nullable();
$table->json('utm_contents')->nullable();
$table->json('browser_versions')->nullable();
$table->json('os_versions')->nullable();
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['utm_terms', 'utm_contents', 'browser_versions', 'os_versions']);
});
Schema::table('short_url_visits', function (Blueprint $table) {
$table->dropIndex('short_url_visits_url_id_index');
});
Schema::table('short_urls', function (Blueprint $table) {
$table->dropForeign(['custom_domain_id']);
$table->dropIndex('short_urls_user_archived_id_index');
$table->dropUnique('short_urls_url_key_domain_scope_unique');
$table->unique('url_key');
$table->dropColumn('domain_scope_id');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->unsignedInteger('all_visits_count')->default(0)->after('visits_count');
$table->unsignedInteger('bot_visits_count')->default(0)->after('all_visits_count');
$table->unsignedInteger('proxy_visits_count')->default(0)->after('bot_visits_count');
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['all_visits_count', 'bot_visits_count', 'proxy_visits_count']);
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->json('cross_dimensional_stats')->nullable();
$table->json('cross_filter_pairs')->nullable();
$table->json('filter_qr_counts')->nullable();
});
}
public function down(): void
{
Schema::table('short_url_daily_stats', function (Blueprint $table) {
$table->dropColumn(['cross_dimensional_stats', 'cross_filter_pairs', 'filter_qr_counts']);
});
}
};

9
phpstan.neon.dist Normal file
View File

@@ -0,0 +1,9 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- src
level: 5
tmpDir: build/phpstan
checkMissingIterableValueType: false

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -488,7 +488,7 @@ document.addEventListener('alpine:init', () => {
domain = this.domains[domainId];
}
if (this.routePrefix) {
if (this.routePrefix && !domainId) {
return `${this.protocol}://${domain}/${this.routePrefix}/${key}`;
}

View File

@@ -52,12 +52,145 @@ window.fsuResolveFormApi = function ($get, $set, $el) {
return null;
};
window.fsuScrape = function (val, $get, $set, $el) {
if (!val) {
if ($el) {
$el.dataset.scraped = 'false';
window.fsuIsPasswordProtected = function (api) {
if (!api || typeof api.get !== 'function') {
return false;
}
return !!(api.get('password_active_flag') || api.get('password'));
};
window.fsuHasManualOgImage = function (get) {
if (typeof get !== 'function') {
return false;
}
var image = get('og_image');
if (!image) {
return false;
}
if (typeof image === 'string') {
return image.trim() !== '';
}
if (Array.isArray(image)) {
return image.length > 0;
}
if (typeof image === 'object') {
return Object.keys(image).length > 0;
}
return false;
};
window.fsuLockScrape = function (inputEl) {
if (inputEl) {
inputEl.dataset.fsuScrapeLocked = 'true';
return;
}
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
el.dataset.fsuScrapeLocked = 'true';
});
};
window.fsuUnlockScrape = function (inputEl) {
if (inputEl) {
delete inputEl.dataset.fsuScrapeLocked;
return;
}
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
delete el.dataset.fsuScrapeLocked;
});
};
window.fsuIsScrapeLocked = function (inputEl, get) {
if (inputEl && inputEl.dataset.fsuScrapeLocked === 'true') {
return true;
}
return window.fsuHasManualOgImage(get);
};
window.fsuGetScrapedUrl = function (inputEl) {
return inputEl && inputEl.dataset.scrapedUrl ? inputEl.dataset.scrapedUrl : null;
};
window.fsuMarkDestinationScraped = function (inputEl, url) {
if (inputEl && url) {
inputEl.dataset.scrapedUrl = url;
}
};
window.fsuClearDestinationScrape = function (inputEl) {
if (inputEl) {
delete inputEl.dataset.scrapedUrl;
}
};
window.fsuShouldSkipScrape = function (inputEl, url, get) {
if (window.fsuIsScrapeLocked(inputEl, get)) {
return true;
}
return window.fsuGetScrapedUrl(inputEl) === url;
};
window.fsuStopScraping = function (api) {
window.fsuDispatchScraping(false);
window.dispatchEvent(new CustomEvent('fsu-og-image-updated'));
if (api && typeof api.set === 'function') {
api.set('is_scraping', false);
}
};
window.fsuOnManualOgImage = function ($get, $set) {
var api = window.fsuResolveFormApi($get, $set);
if (api) {
api.set('og_image_scraped', null);
}
window.fsuStopScraping(api);
window.fsuLockScrape();
};
window.fsuClearOgMetadata = function (api) {
if (!api || typeof api.set !== 'function') {
return;
}
api.set('og_title', null);
api.set('og_description', null);
api.set('og_image_scraped', null);
};
window.fsuRetryScrapeAfterImageRemoved = function ($get, $set) {
var api = window.fsuResolveFormApi($get, $set);
var dest = api ? api.get('destination_url') : null;
if (!dest || window.fsuHasManualOgImage(api ? api.get : $get)) {
return;
}
window.fsuUnlockScrape();
window.fsuClearOgMetadata(api);
document.querySelectorAll('[data-fsu-destination-url]').forEach(function (el) {
window.fsuClearDestinationScrape(el);
window.fsuScrape(dest, $get, $set, el);
});
};
window.fsuScrape = function (val, $get, $set, $el) {
if (!val) {
return;
}
@@ -70,16 +203,24 @@ window.fsuScrape = function (val, $get, $set, $el) {
return;
}
if ($el && $el.dataset.scraped === 'true') {
return;
}
var api = window.fsuResolveFormApi($get, $set, $el);
if (!api) {
return;
}
if (window.fsuIsPasswordProtected(api)) {
window.fsuStopScraping(api);
return;
}
if (window.fsuShouldSkipScrape($el, val, api.get)) {
window.fsuStopScraping(api);
return;
}
window.fsuDispatchScraping(true);
api.set('is_scraping', true);
@@ -88,6 +229,18 @@ window.fsuScrape = function (val, $get, $set, $el) {
return r.ok ? r.json() : null;
})
.then(function (data) {
if (window.fsuIsPasswordProtected(api)) {
window.fsuStopScraping(api);
return;
}
if (window.fsuIsScrapeLocked($el, api.get)) {
window.fsuStopScraping(api);
return;
}
if (data && (data.title || data.description || data.image)) {
if (data.title && !api.get('og_title')) {
api.set('og_title', data.title);
@@ -95,36 +248,47 @@ window.fsuScrape = function (val, $get, $set, $el) {
if (data.description && !api.get('og_description')) {
api.set('og_description', data.description);
}
if (data.image && !api.get('og_image_scraped') && !api.get('og_image')) {
if (data.image && !window.fsuHasManualOgImage(api.get)) {
api.set('og_image_scraped', data.image);
window.fsuLockScrape($el);
} else {
api.set('is_scraping', false);
window.fsuDispatchScraping(false);
}
if ($el) {
$el.dataset.scraped = 'true';
window.fsuStopScraping(api);
}
window.fsuMarkDestinationScraped($el, val);
} else {
api.set('is_scraping', false);
window.fsuDispatchScraping(false);
window.fsuStopScraping(api);
}
})
.catch(function () {
api.set('is_scraping', false);
window.fsuDispatchScraping(false);
window.fsuStopScraping(api);
});
};
window.fsuInitScrape = function ($get, $el) {
var api = window.fsuResolveFormApi($get, null, $el);
if ($el && api) {
try {
$el.dataset.scraped = (api.get('og_title') || api.get('og_description') || api.get('og_image_scraped')) ? 'true' : 'false';
} catch (_) {
$el.dataset.scraped = 'false';
if (!$el || !api) {
return;
}
} else if ($el) {
$el.dataset.scraped = 'false';
var dest = api.get('destination_url');
if (!dest) {
return;
}
if (window.fsuHasManualOgImage(api.get)) {
window.fsuLockScrape($el);
window.fsuMarkDestinationScraped($el, dest);
return;
}
if (
!window.fsuGetScrapedUrl($el)
&& (api.get('og_title') || api.get('og_description') || api.get('og_image_scraped'))
) {
window.fsuMarkDestinationScraped($el, dest);
}
};

View File

@@ -46,10 +46,68 @@ return [
'activated_at' => 'Active From',
'max_visits' => 'Max Visits Limit',
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
'max_visits_no_limit' => 'No limit',
'max_visits_suffix' => 'visits',
'number_stepper_decrease' => 'Decrease value',
'number_stepper_increase' => 'Increase value',
'expiration_redirect_url' => 'Expiration Redirect URL',
'expiration_redirect_url_helper' => 'Fallback URL to redirect to when the link is expired or inactive (leaves blank for 410 Gone error page).',
'form_section_validity' => 'Validity & Limits',
'use_date_validity' => 'Set Date Range Limits',
'use_date_validity_helper' => 'Schedule when the link becomes active and when it should stop working.',
'validity_tab_intro' => 'Control when your link is available and how many times it can be clicked. Leave everything off for a link that stays open indefinitely.',
'validity_schedule_card_title' => 'Schedule availability',
'validity_schedule_card_subtitle' => 'Optional start and end dates for campaigns, launches, or limited-time offers.',
'validity_schedule_empty_title' => 'Always available',
'validity_schedule_empty_desc' => 'Turn on the switch above to set an activation date, expiration date, or both.',
'validity_schedule_timeline_label' => 'Active window',
'validity_activated_at_helper' => 'Leave empty to activate immediately when saved.',
'validity_expires_at_helper' => 'Optional. After this moment the link stops redirecting.',
'validity_single_use_toggle' => 'Enable single-use mode',
'validity_single_use_active_note' => 'Single-use is on — the link deactivates after the first successful visit. Max visits limit is ignored.',
'validity_max_visits_locked' => 'Unavailable while single-use is enabled',
'tracking_visit_card_title' => 'Visit analytics',
'tracking_visit_card_subtitle' => 'Collect click statistics for this link — disable for privacy-first or redirect-only links.',
'tracking_visit_empty_title' => 'Tracking disabled',
'tracking_visit_empty_desc' => 'Turn on the switch above to record visits and configure which data points are stored.',
'tracking_fields_identity_title' => 'Identity & source',
'tracking_fields_device_title' => 'Device & browser',
'track_ip_desc' => 'IP address for geo insights and abuse signals.',
'track_referer_desc' => 'Referring page URL when the visitor arrives from another site.',
'track_browser_desc' => 'Browser family such as Chrome, Safari, or Firefox.',
'track_browser_version_desc' => 'Exact browser version string from the user agent.',
'track_os_desc' => 'Operating system name such as iOS, Windows, or Android.',
'track_os_version_desc' => 'OS version reported by the visitor device.',
'track_device_type_desc' => 'Device category — desktop, mobile, tablet, or bot.',
'track_browser_language_desc' => 'Preferred language from the Accept-Language header.',
'tracking_utm_card_title' => 'Campaign parameters',
'tracking_utm_card_subtitle' => 'Append UTM tags to the destination URL. Changes sync instantly with the link target.',
'tracking_ga_card_title' => 'Google Analytics 4',
'tracking_ga_card_subtitle' => 'Optional Measurement ID for server-side redirect events via GA4 Measurement Protocol.',
'link_destination_card_title' => 'Destination',
'link_destination_card_subtitle' => 'Send visitors to one URL or split traffic across A/B test variants.',
'link_short_url_card_title' => 'Short link',
'link_short_url_card_subtitle' => 'Choose a domain and customize the back-half of your short URL.',
'link_behavior_card_title' => 'Behavior',
'link_behavior_card_subtitle' => 'Control whether the link is active and how incoming query parameters are handled.',
'link_status_desc' => 'When disabled, visitors see an error page instead of being redirected.',
'link_tags_card_title' => 'Tags',
'link_tags_card_subtitle' => 'Add up to five labels to organize, filter, and report on links.',
'link_notes_card_title' => 'Internal notes',
'link_notes_card_subtitle' => 'Private admin memo — never shown to visitors.',
'link_notes_placeholder' => 'Add a reminder for your team…',
'targeting_rules_card_title' => 'Conditional redirects',
'targeting_rules_card_subtitle' => 'Send visitors to different URLs based on device, country, language, or platform.',
'targeting_rules_empty_title' => 'No targeting rules yet',
'targeting_rules_empty_desc' => 'Add a rule below to override the default destination when specific conditions match.',
'add_targeting_rule' => 'Add rule',
'targeting_rule_conditions_title' => 'When to apply',
'targeting_rule_destination_title' => 'Redirect to',
'filter_duplicate_error' => 'Each filter type (Device, Platform, Country, Language) can only be added once.',
'app_linking_card_title' => 'App linking',
'app_linking_card_subtitle' => 'Open the native mobile app instead of the browser when a matching app is detected.',
'app_linking_empty_title' => 'App auto-open disabled',
'app_linking_empty_desc' => 'Turn on the switch above to preview and enable deep-link behavior on mobile devices.',
'notes' => 'Internal Notes',
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
@@ -237,7 +295,39 @@ return [
'settings_cache_ttl' => 'Redirect Cache TTL',
'settings_cache_ttl_helper' => 'Seconds to cache resolved short URL records. Set to 0 to disable (not recommended in production).',
'settings_queue_connection' => 'Queue Connection',
'settings_queue_connection_helper' => 'Laravel queue connection used for async visit tracking. Use "sync" for synchronous (slower redirects), or "redis"/"sqs" for production.',
'settings_queue_connection_helper' => 'Laravel queue connection for async visit tracking. Each driver has different requirements — see the info box below when you change this value.',
'settings_redis_test' => 'Test Redis connection',
'settings_redis_test_ok' => 'Redis connection OK',
'settings_redis_test_fail' => 'Redis connection failed',
'settings_queue_worker_test' => 'Test queue worker',
'settings_queue_worker_test_ok' => 'Queue worker OK',
'settings_queue_worker_test_fail' => 'Queue worker not responding',
'settings_section_redis' => 'Redis Connection',
'settings_section_redis_description' => 'These values override database.redis and queue.connections.redis at runtime when Queue Connection is redis. Save settings after changing them.',
'settings_redis_host' => 'Redis Host',
'settings_redis_host_helper' => 'Hostname or IP of the Redis server used for queues, counters, stats, and live feed.',
'settings_redis_port' => 'Redis Port',
'settings_redis_port_helper' => 'Default: 6379.',
'settings_redis_password' => 'Redis Password',
'settings_redis_password_helper' => 'Leave empty if Redis has no password. Stored securely in settings.',
'settings_redis_database' => 'Redis Database',
'settings_redis_database_helper' => 'Logical Redis DB index (015). Default: 0.',
'settings_redis_key_prefix' => 'Key Prefix',
'settings_redis_key_prefix_helper' => 'Optional prefix for all plugin Redis keys. Leave empty to use config default.',
'settings_queue_worker_command' => '<strong>Queue Worker Required:</strong> Run a worker on the same connection and queue name as above (independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — production mode: async jobs, dedicated Redis counters/stats/live feed (phpredis or Predis), auto counter buffering. Configure Redis below (overrides .env at runtime). Use the test buttons before saving.',
'settings_queue_mode_desc_database' => '<strong>database</strong> — async visit jobs stored in the <code>jobs</code> table. No dedicated Redis counters unless you enable buffering manually.',
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — async jobs via AWS SQS. Requires valid AWS credentials in <code>config/queue.php</code>.',
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — async jobs via Beanstalkd. Requires a running beanstalkd server.',
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — jobs run after the HTTP response in the same PHP process. No separate worker, but adds post-response latency under load.',
'settings_queue_mode_desc_background' => '<strong>background</strong> — Laravel background driver: jobs run after the response without a queue worker (similar to deferred).',
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — uses your Laravel failover chain in <code>config/queue.php</code>. Ensure at least one backend in the chain is reachable.',
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async visit tracking via this Laravel queue connection. Ensure it is configured in <code>config/queue.php</code>.',
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — visits are recorded during the redirect (no queue worker). Best for local dev and low traffic. Optional manual counter buffering via the toggle below.</div></div>',
'settings_geoip_enabled' => 'Enable Geo-IP Detection',
'settings_geoip_enabled_helper' => 'Detect and record the visitor\'s country on each visit.',
@@ -266,11 +356,15 @@ return [
'settings_ga4_verify_ok' => '✅ API Secret is valid — GA4 connection successful',
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
'settings_ga4_verify_measurement_id' => 'Measurement ID for connection test',
'settings_ga4_verify_measurement_id_helper' => 'Use the exact G-XXXXXXXXXX from the same GA4 data stream as your API secret. Required unless you use Firebase App ID.',
'settings_ga4_verify_measurement_required' => 'Enter a Measurement ID (G-XXXXXXXXXX) or Firebase App ID to test the connection.',
'settings_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
'settings_section_buffering' => 'Visit Counters Buffering',
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
'settings_buffering_helper' => 'When enabled, visit count increments are temporarily buffered in the application cache and must be flushed periodically to the database via "php artisan short-url:sync-counters". This prevents row-locking issues and performance degradation under high-traffic spikes.',
'settings_buffering_redis_auto' => 'Automatically enabled with Redis queue. Visit counters, today stats, and live feed use the dedicated queue Redis connection (phpredis or Predis) — independent of CACHE_STORE. Schedule short-url:sync-counters every minute.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Required Cron Task (Scheduler):</strong> You have enabled click counter buffering. You must add the following task to your server\'s crontab:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1</code><br>Command run automatically in the background by the scheduler:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
@@ -278,10 +372,12 @@ return [
'settings_trust_cdn_headers_helper' => 'Enable this if your app sits behind a CDN (like Cloudflare, AWS CloudFront) or a reverse proxy. This allows extracting the real client IP and country code from CDN headers. Warning: only enable this if you are actually behind a proxy, otherwise client IP addresses can be spoofed!',
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Warning (IP Address Security):</strong> You have enabled trust for proxy headers. Enable this option <u>only</u> if your website is behind:<br>• <strong>Cloudflare</strong> (reads from CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> or another CDN system<br>• <strong>Nginx/Apache reverse proxy</strong> forwarding X-Forwarded-For.<br><br><strong>Turn this option OFF</strong> if your server connects directly to users. If left ON without a proxy, malicious users can easily spoof their IP address by sending a custom header.</span></div></div>',
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Proxy Header Trust Disabled:</strong> You selected <em>CDN Headers</em> as the Geo-IP driver, but the option <strong>"Trust CDN & Proxy Headers"</strong> in the <em>General</em> tab is disabled. Location detection will not function until you enable it.</span></div></div>',
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>CDN Headers driver:</strong> Saving settings with this driver automatically enables <strong>Trust CDN &amp; Proxy Headers</strong>. When CDN country headers are missing, the plugin falls back to MaxMind and then ip-api.com (rate-limited) instead of returning empty geo data.</span></div></div>',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Visited From',
'stats_filter_visited_until' => 'Visited Until',
'stats_filter_counted_in_stats' => 'Stats-counted visits only',
'stats_action_export' => 'Export CSV',
'stats_csv_time' => 'Time',
'stats_csv_ip' => 'IP Address',
@@ -308,8 +404,11 @@ return [
'form_section_options' => 'Options',
'form_section_notes' => 'Internal Notes',
'form_section_tracking' => 'Visit Tracking',
'form_section_tracking_desc' => 'Enable or disable visit tracking and statistics for this short link.',
'form_section_tracked_fields' => 'Tracked Fields',
'form_section_tracked_fields_desc' => 'Choose which metrics are collected for this link — useful for analytics depth and privacy.',
'form_section_analytics' => 'Third-Party Analytics',
'form_section_analytics_desc' => 'Integrate this link with Google Analytics by providing a data stream identifier.',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Top UTM Source',
@@ -338,11 +437,27 @@ return [
'security_section_title' => 'Security Controls',
'password' => 'Access Password',
'password_helper' => 'Require visitors to enter a password before being redirected.',
'password_card_title' => 'Password protection',
'password_card_subtitle' => 'Require visitors to enter a password before being redirected.',
'warning_page_card_title' => 'Redirect warning page',
'warning_page_card_subtitle' => 'Show an intermediate screen before redirecting visitors to the destination.',
'warning_page_empty_title' => 'Warning page disabled',
'warning_page_empty_desc' => 'Turn on the switch above to show a safety screen before external redirects.',
'warning_page_active_title' => 'Warning page enabled',
'confirm_password' => 'Confirm Password',
'new_password' => 'New Password',
'change_password' => 'Change Password',
'remove_password' => 'Remove Password',
'password_status_active' => 'Password protection is enabled.',
'password_status_active_desc' => 'This link is protected. Visitors must enter the correct password to continue.',
'password_empty_state_title' => 'No protection',
'password_empty_state_desc' => 'Add a password to restrict access to this short link to selected people only.',
'password_settings_section' => 'Password settings',
'password_change_short' => 'Change',
'password_remove_short' => 'Remove',
'save_password' => 'Save password',
'expiration_dates_section_desc' => 'Control when the link is available. Schedule a campaign start or automatic end.',
'visit_limits_section_desc' => 'Cap total clicks. Useful for one-time tickets or limited offers.',
'set_password' => 'Set Password',
'cancel' => 'Cancel',
'confirm' => 'Confirm',
@@ -374,6 +489,7 @@ return [
'variant_url' => 'Variant URL',
'variant_weight' => 'Traffic Share (%)',
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
'outbound_url_blocked' => 'This outbound URL is not allowed.',
// New Advanced Targeting Builder
'targeting_rules' => 'Targeting Rules',
@@ -399,8 +515,8 @@ return [
'settings_section_aggregation' => 'High-Traffic Log Management',
'settings_retention_days' => 'Raw Log Retention Period',
'settings_retention_days_helper' => 'Select the duration for which you want to keep detailed raw click logs before they are deleted.',
'settings_aggregation_enabled' => 'Enable Automatic Daily Pruning & Aggregation',
'settings_aggregation_enabled_helper' => 'When enabled, the plugin automatically registers a daily task in the scheduler (at 02:00) to aggregate visits into daily statistics and delete raw logs older than the chosen retention period.',
'settings_aggregation_enabled' => 'Enable raw visit log pruning',
'settings_aggregation_enabled_helper' => 'The daily 02:00 job always aggregates visits into daily statistics. This toggle controls only whether raw visit rows older than the retention period are deleted.',
'retention_30_days' => '30 Days',
'retention_60_days' => '60 Days',
'retention_90_days' => '90 Days',
@@ -410,6 +526,7 @@ return [
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
'settings_rate_limiting_enabled' => 'Enable Limit Protection',
'settings_rate_limiting_enabled_helper' => 'Limit the rate of redirects per client IP address.',
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Two layers:</strong> redirect routes also use the package middleware throttle (default <code>120/min</code> per IP). When you enable the limiter below, <em>both</em> can return HTTP 429 — the stricter one wins first.</div>',
'settings_rate_limiting_max_attempts' => 'Max Redirects Allowed',
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
@@ -418,7 +535,7 @@ return [
// Queue Settings Additions
'settings_queue_name' => 'Queue Name',
'settings_queue_name_helper' => 'The target queue to which tracking and buffering sync jobs are dispatched. Default: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. You must run a background worker to process these jobs:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Queue Worker Required:</strong> The selected connection runs asynchronously. Run a worker that listens on the same connection and queue name as above (this is independent of <code>QUEUE_CONNECTION</code> in <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Stats Dashboard Cache TTL',
'settings_geoip_stats_cache_ttl_helper' => 'Time (in seconds) to cache stats calculation on the dashboard. When viewing charts and logs, the system loads calculated numbers from cache instead of querying millions of rows from SQL on every refresh. Default: 300 (5 minutes). Max: 86400 (24 hours).',
@@ -470,14 +587,22 @@ return [
'tab_marketing' => 'Marketing & API',
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
'marketing_pixels_empty_title' => 'No pixels attached',
'marketing_pixels_empty_desc' => 'Select one or more retargeting pixels to fire client-side tracking scripts when visitors click this link.',
'marketing_pixels_select_placeholder' => 'Choose retargeting pixels…',
'marketing_webhook_empty_title' => 'No webhook configured',
'marketing_webhook_empty_desc' => 'Add a destination URL to receive real-time HTTP POST notifications on each click.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Link Webhook Integration',
'marketing_webhooks_desc' => 'Configure a custom destination URL to send a real-time HTTP POST notification on each click.',
'webhook_url' => 'Dedicated Webhook URL',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (including URL key, IP address, country, browser, operating system, referrer, and UTM parameters).',
'webhook_helper_alert' => 'Once configured, each visit to this short link triggers a real-time HTTP POST request to this URL. The payload contains detailed click metadata in JSON format (URL key, destination, device, browser, operating system, geo, referrer, UTM parameters, and QR scan flag). IP addresses are not included in webhook payloads.',
'webhook_show_payload' => 'Show example JSON payload',
'webhook_payload_modal_title' => 'Example webhook payload',
'webhook_payload_modal_desc' => 'Sample JSON body for the visited event. Actual values depend on each click.',
'webhook_payload_copy' => 'Copy payload to clipboard',
// Settings tab additions
'settings_tab_developer' => 'API & Webhooks',
@@ -499,6 +624,8 @@ return [
'settings_api_keys_description' => 'Manage API keys to integrate with external systems (e.g. CRM, Zapier, Make). Authenticate requests using the X-Api-Key header.',
'settings_api_keys' => 'Active API Keys',
'api_key_name' => 'Key Name (Description)',
'api_key_owner_user_id' => 'Owner User ID',
'api_key_owner_user_id_helper' => 'Required when link scoping is enabled (default). Limits this key to links owned by that user.',
'api_key' => 'API Key',
'active' => 'Active',
'api_key_generated' => 'API Key Generated',
@@ -507,6 +634,16 @@ return [
// Security v2.0
'settings_section_security_v2' => 'Security & Anti-Fraud v2.0',
'settings_section_security_v2_desc' => 'Configure VPN/proxy detection and Google Safe Browsing URL validation.',
'settings_section_analytics_security' => 'Analytics & Bot Detection',
'settings_section_analytics_security_desc' => 'Optional click deduplication and advanced bot detection settings.',
'settings_click_dedup_enabled' => 'Enable Click Deduplication',
'settings_click_dedup_enabled_helper' => 'Ignore repeat clicks from the same IP within the configured time window.',
'settings_click_dedup_hours' => 'Deduplication Window (hours)',
'settings_click_dedup_hours_helper' => 'How long to treat repeat clicks from the same IP as duplicates.',
'settings_bot_verify_googlebot' => 'Verify Googlebot IP',
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP check for Googlebot user agents.',
'settings_bot_debug_secret' => 'Bot Debug Secret',
'settings_bot_debug_secret_helper' => 'Required value for ?bot=1 outside local/testing environments.',
'settings_vpn_detection_enabled' => 'Enable VPN & Proxy Detection',
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
'settings_vpn_driver' => 'Detection Driver',
@@ -517,6 +654,12 @@ return [
'settings_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
'settings_vpn_block_action' => 'VPN Block Action',
'settings_vpn_block_action_helper' => 'Choose whether to only flag VPN traffic in statistics or actively block them with a 403 Forbidden page.',
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Link previews:</strong> blocking mode returns HTTP 403 for VPN/proxy/datacenter IPs. Known social crawlers (Facebook, LinkedIn, Google preview bots) are <em>still allowed</em> so OG meta and cloaked pages keep working. Human visitors on VPN may be blocked.</div>',
'settings_vpn_detection_timeout' => 'VPN detection timeout',
'settings_vpn_detection_timeout_helper' => 'Maximum seconds to wait for the VPN/proxy API response on each redirect.',
'settings_vpn_detection_cache_ttl' => 'VPN detection cache TTL',
'settings_vpn_detection_cache_ttl_helper' => 'How long (in seconds) to cache VPN/proxy lookup results per IP address.',
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">The <strong>CDN Headers</strong> Geo-IP driver (Geo-IP tab) requires trusting proxy headers. This option is <strong>automatically enabled</strong> when that driver is selected and on save.</div>',
'settings_vpn_block_flag_only' => 'Flag in stats only (allow redirect)',
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
@@ -575,6 +718,7 @@ return [
'app_linking_auto_open' => 'Auto-Open',
'app_linking_matched_description' => 'When clicked on a mobile device, this link will open immediately inside the native <strong>:app</strong> application instead of the web browser.',
'app_linking_deep_link_scheme' => 'Mobile app path (Deep Link): :scheme',
'app_linking_deep_link_label' => 'Deep link URL',
'app_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
// App Redirect Page
@@ -595,6 +739,11 @@ return [
'balance_weights' => 'Adjust to 100%',
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
'custom_domain_locked_error' => 'The custom domain is locked and cannot be changed.',
'custom_domain_not_owned_error' => 'You do not have permission to use this custom domain.',
'resource_not_owned_error' => 'You do not have permission to use this resource.',
'webhook_signing_secret_required' => 'A webhook signing secret is required when the global webhook is enabled.',
'webhook_signing_secret' => 'Webhook Signing Secret',
'webhook_signing_secret_helper' => 'Required when the global webhook is enabled. Outgoing requests include HMAC-SHA256 in X-ShortUrl-Signature.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'prev period',
@@ -732,7 +881,9 @@ return [
// 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_live_feed_poll_interval' => 'Live via SSE',
'stats_live_feed_mode_redis' => 'Live · Redis push',
'stats_live_feed_mode_poll' => 'Live · SSE poll',
'stats_col_visitor' => 'Visitor / Location',
// Empty state mock values
@@ -759,6 +910,7 @@ return [
'tab_seo_social' => 'SEO & Social',
'is_cloaked' => 'Link Cloaking',
'is_cloaked_helper' => 'Mask the destination URL in the address bar using an iframe on your short link domain.',
'is_cloaked_iframeable_hint' => 'Some destinations block iframe embedding (X-Frame-Options). Use the iframeable pre-check before enabling cloaking.',
'do_index' => 'Search Engine Indexing',
'do_index_helper' => 'Allow search engines (like Google) to index this short URL. Otherwise, it is served with a noindex tag.',
'og_title' => 'Custom Title',
@@ -778,4 +930,60 @@ return [
'og_empty_state_hint' => 'Paste a link to auto-generate preview',
'password_prompt_preview_desc' => 'Visitors will see a password prompt before being redirected.',
'fetching_metadata' => 'Fetching preview...',
// HTTP / API messages (visitor-facing & REST)
'api_disabled' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
'api_key_missing' => 'Unauthorized. API Key is missing.',
'api_key_invalid' => 'Unauthorized. Invalid or inactive API Key.',
'api_key_owner_required' => 'Forbidden. This API key must have an owner_user_id when link scoping is enabled.',
'api_key_read_only' => 'Forbidden. This API key has read-only permissions.',
'api_rate_limit_exceeded' => 'Too many requests. API key rate limit exceeded.',
'short_url_not_found' => 'Short URL not found.',
'redirect_vpn_blocked' => 'Access denied. VPN, Proxy, or automated scraping connection detected.',
'redirect_rate_limited' => 'Too many requests. Please try again in :seconds seconds.',
'redirect_destination_blocked' => 'This link destination has been blocked for security reasons.',
'public_stats_rate_limited' => 'Too many requests.',
'public_stats_password_invalid' => 'Password required or invalid.',
'public_stats_password_rate_limited' => 'Too many incorrect password attempts. Please try again later.',
'public_stats_retry_in' => 'Please try again in :seconds seconds.',
'action_public_stats' => 'Public stats',
'public_stats_modal_title' => 'Public statistics',
'public_stats_modal_description' => 'Share a read-only stats page without giving access to the admin panel.',
'public_stats_save' => 'Save',
'public_stats_enabled_label' => 'Enable public statistics page',
'public_stats_password_label' => 'Optional password',
'public_stats_password_helper' => 'Leave blank to keep the current password. Clear by disabling public stats.',
'public_stats_disabled_hint' => 'Public stats are off. Enable the toggle and save to generate a shareable link.',
'public_stats_link_label' => 'Public stats link',
'public_stats_save_to_activate' => 'Save to activate this link.',
'public_stats_enabled_status' => 'Public stats are enabled.',
'public_stats_copied' => 'Public stats link copied.',
'public_stats_open' => 'Open',
'public_stats_saved_enabled' => 'Public statistics enabled.',
'public_stats_saved_disabled' => 'Public statistics disabled.',
'public_stats_page_title' => 'Link statistics',
'public_stats_page_subtitle' => 'Stats for /:key',
'public_stats_date_from' => 'From',
'public_stats_date_to' => 'To',
'public_stats_apply_filter' => 'Apply',
'public_stats_total_visits' => 'Total visits',
'public_stats_unique_visits' => 'Unique visits',
'public_stats_today' => 'Today',
'public_stats_this_week' => 'This week',
'public_stats_this_month' => 'This month',
'public_stats_qr_scans' => 'QR scans',
'public_stats_visits_by_day' => 'Visits by day',
'public_stats_no_data' => 'No visits recorded for this period.',
'public_stats_day' => 'Day',
'public_stats_visits' => 'Visits',
'public_stats_password_title' => 'Protected statistics',
'public_stats_password_description' => 'Enter the password to view statistics for this link.',
'public_stats_password_submit' => 'View statistics',
'redirect_html_redirecting' => 'Redirecting you to the destination website...',
'api_created' => 'Short URL created successfully.',
'api_updated' => 'Short URL updated successfully.',
'api_bulk_created' => 'Short URLs created successfully.',
'api_deleted' => 'Short URL deleted successfully.',
'api_bulk_deleted' => 'Short URLs deleted successfully.',
'api_bulk_updated' => 'Short URLs updated successfully.',
];

View File

@@ -42,10 +42,68 @@ return [
'activated_at' => 'Aktywny od',
'max_visits' => 'Maksymalny limit wejść',
'max_visits_helper' => 'Opcjonalny limit liczby wejść, po osiągnięciu którego link zostanie automatycznie wyłączony.',
'max_visits_no_limit' => 'Bez limitu',
'max_visits_suffix' => 'wejść',
'number_stepper_decrease' => 'Zmniejsz wartość',
'number_stepper_increase' => 'Zwiększ wartość',
'expiration_redirect_url' => 'URL przekierowania po wygaśnięciu',
'expiration_redirect_url_helper' => 'Adres URL, na który nastąpi przekierowanie, gdy link wygaśnie lub będzie nieaktywny (pozostaw puste, aby wyświetlić błąd 410 Gone).',
'form_section_validity' => 'Ważność i limity',
'use_date_validity' => 'Ustaw limity datowe',
'use_date_validity_helper' => 'Zaplanuj, kiedy link ma się włączyć i kiedy przestać działać.',
'validity_tab_intro' => 'Kontroluj dostępność linku w czasie oraz liczbę dozwolonych kliknięć. Wyłączone opcje oznaczają link bez ograniczeń czasowych i ilościowych.',
'validity_schedule_card_title' => 'Harmonogram dostępności',
'validity_schedule_card_subtitle' => 'Opcjonalna data startu i końca — idealne na kampanie, premiery i oferty limitowane.',
'validity_schedule_empty_title' => 'Dostępny bezterminowo',
'validity_schedule_empty_desc' => 'Włącz przełącznik powyżej, aby ustawić datę aktywacji, wygaśnięcia lub obie.',
'validity_schedule_timeline_label' => 'Okno aktywności',
'validity_activated_at_helper' => 'Pozostaw puste, aby link był aktywny od razu po zapisaniu.',
'validity_expires_at_helper' => 'Opcjonalnie. Po tym momencie link przestaje przekierowywać.',
'validity_single_use_toggle' => 'Włącz tryb jednorazowy',
'validity_single_use_active_note' => 'Tryb jednorazowy jest włączony — link wyłączy się po pierwszej udanej wizycie. Limit wejść nie ma zastosowania.',
'validity_max_visits_locked' => 'Niedostępne przy włączonym trybie jednorazowym',
'tracking_visit_card_title' => 'Analityka wizyt',
'tracking_visit_card_subtitle' => 'Zbieraj statystyki kliknięć dla tego linku — wyłącz dla linków prywatnościowych lub samych przekierowań.',
'tracking_visit_empty_title' => 'Śledzenie wyłączone',
'tracking_visit_empty_desc' => 'Włącz przełącznik powyżej, aby rejestrować wizyty i wybrać zapisywane dane.',
'tracking_fields_identity_title' => 'Tożsamość i źródło',
'tracking_fields_device_title' => 'Urządzenie i przeglądarka',
'track_ip_desc' => 'Adres IP do geolokalizacji i wykrywania nadużyć.',
'track_referer_desc' => 'Adres strony odsyłającej, gdy użytkownik przychodzi z innej witryny.',
'track_browser_desc' => 'Rodzina przeglądarki, np. Chrome, Safari lub Firefox.',
'track_browser_version_desc' => 'Dokładna wersja przeglądarki z user agenta.',
'track_os_desc' => 'System operacyjny, np. iOS, Windows lub Android.',
'track_os_version_desc' => 'Wersja systemu operacyjnego urządzenia.',
'track_device_type_desc' => 'Typ urządzenia — desktop, mobile, tablet lub bot.',
'track_browser_language_desc' => 'Preferowany język z nagłówka Accept-Language.',
'tracking_utm_card_title' => 'Parametry kampanii',
'tracking_utm_card_subtitle' => 'Dołącz tagi UTM do docelowego URL. Zmiany synchronizują się od razu z adresem linku.',
'tracking_ga_card_title' => 'Google Analytics 4',
'tracking_ga_card_subtitle' => 'Opcjonalny identyfikator pomiaru do zdarzeń przekierowań po stronie serwera (GA4 Measurement Protocol).',
'link_destination_card_title' => 'Docelowy adres',
'link_destination_card_subtitle' => 'Kieruj odwiedzających na jeden URL lub dziel ruch między warianty testu A/B.',
'link_short_url_card_title' => 'Krótki link',
'link_short_url_card_subtitle' => 'Wybierz domenę i dostosuj końcówkę adresu skróconego linku.',
'link_behavior_card_title' => 'Zachowanie',
'link_behavior_card_subtitle' => 'Steruj aktywnością linku i sposobem przekazywania parametrów URL.',
'link_status_desc' => 'Po wyłączeniu odwiedzający zobaczą stronę błędu zamiast przekierowania.',
'link_tags_card_title' => 'Tagi',
'link_tags_card_subtitle' => 'Dodaj do pięciu etykiet, aby organizować, filtrować i raportować linki.',
'link_notes_card_title' => 'Notatki wewnętrzne',
'link_notes_card_subtitle' => 'Prywatna notatka w panelu — nigdy niewidoczna dla odwiedzających.',
'link_notes_placeholder' => 'Dodaj przypomnienie dla zespołu…',
'targeting_rules_card_title' => 'Warunkowe przekierowania',
'targeting_rules_card_subtitle' => 'Kieruj odwiedzających na różne URL-e w zależności od urządzenia, kraju, języka lub platformy.',
'targeting_rules_empty_title' => 'Brak reguł targetowania',
'targeting_rules_empty_desc' => 'Dodaj regułę poniżej, aby nadpisać domyślny cel linku, gdy spełnione są określone warunki.',
'add_targeting_rule' => 'Dodaj regułę',
'targeting_rule_conditions_title' => 'Kiedy zastosować',
'targeting_rule_destination_title' => 'Przekieruj na',
'filter_duplicate_error' => 'Każdy typ filtra (Urządzenie, Platforma, Kraj, Język) można dodać tylko raz.',
'app_linking_card_title' => 'Linkowanie aplikacji',
'app_linking_card_subtitle' => 'Otwieraj natywną aplikację mobilną zamiast przeglądarki, gdy wykryto pasującą aplikację.',
'app_linking_empty_title' => 'Autootwieranie aplikacji wyłączone',
'app_linking_empty_desc' => 'Włącz przełącznik powyżej, aby podejrzeć i włączyć zachowanie deep link na urządzeniach mobilnych.',
'notes' => 'Wewnętrzne notatki',
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.',
@@ -234,7 +292,39 @@ return [
'settings_cache_ttl' => 'TTL cache przekierowania',
'settings_cache_ttl_helper' => 'Sekundy cachowania rekordów krótkich URL. Ustaw 0 aby wyłączyć (niezalecane na produkcji).',
'settings_queue_connection' => 'Połączenie kolejki',
'settings_queue_connection_helper' => 'Kolejka Laravel do asynchronicznego śledzenia wizyt. Użyj "sync" (synchroniczne), lub "redis"/"sqs" na produkcji.',
'settings_queue_connection_helper' => 'Połączenie kolejki Laravel do asynchronicznego śledzenia wizyt. Każdy sterownik ma inne wymagania — zobacz infobox poniżej po zmianie wartości.',
'settings_redis_test' => 'Testuj połączenie Redis',
'settings_redis_test_ok' => 'Połączenie Redis OK',
'settings_redis_test_fail' => 'Połączenie Redis nieudane',
'settings_queue_worker_test' => 'Testuj workera kolejki',
'settings_queue_worker_test_ok' => 'Worker kolejki OK',
'settings_queue_worker_test_fail' => 'Worker kolejki nie odpowiada',
'settings_section_redis' => 'Połączenie Redis',
'settings_section_redis_description' => 'Te wartości nadpisują database.redis i queue.connections.redis w runtime, gdy Queue Connection = redis. Zapisz ustawienia po zmianie.',
'settings_redis_host' => 'Host Redis',
'settings_redis_host_helper' => 'Host lub IP serwera Redis dla kolejek, liczników, statystyk i live feed.',
'settings_redis_port' => 'Port Redis',
'settings_redis_port_helper' => 'Domyślnie: 6379.',
'settings_redis_password' => 'Hasło Redis',
'settings_redis_password_helper' => 'Pozostaw puste, jeśli Redis nie wymaga hasła. Przechowywane bezpiecznie w ustawieniach.',
'settings_redis_database' => 'Baza Redis (DB index)',
'settings_redis_database_helper' => 'Indeks logicznej bazy Redis (015). Domyślnie: 0.',
'settings_redis_key_prefix' => 'Prefix kluczy',
'settings_redis_key_prefix_helper' => 'Opcjonalny prefix dla kluczy pluginu w Redis. Puste = domyślny z config.',
'settings_queue_worker_command' => '<strong>Wymagany proces w tle (Queue Worker):</strong> Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>): <code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code>',
'settings_queue_mode_desc_redis' => '<strong>redis</strong> — tryb produkcyjny: async joby, dedykowane liczniki/statystyki/live feed w Redis (phpredis lub Predis), auto buforowanie. Skonfiguruj Redis poniżej (nadpisuje .env w runtime). Użyj przycisków testu przed zapisem.',
'settings_queue_mode_desc_database' => '<strong>database</strong> — joby w tabeli <code>jobs</code>. Bez dedykowanego Redis — chyba że włączysz buforowanie ręcznie.',
'settings_queue_mode_desc_sqs' => '<strong>sqs</strong> — joby przez AWS SQS. Wymaga poprawnych credentiali AWS w <code>config/queue.php</code>.',
'settings_queue_mode_desc_beanstalkd' => '<strong>beanstalkd</strong> — joby przez Beanstalkd. Wymaga działającego serwera beanstalkd.',
'settings_queue_mode_desc_deferred' => '<strong>deferred</strong> — joby po odpowiedzi HTTP w tym samym procesie PHP. Bez workera, ale większe obciążenie po response przy ruchu.',
'settings_queue_mode_desc_background' => '<strong>background</strong> — sterownik background Laravel: joby po response bez workera (podobnie jak deferred).',
'settings_queue_mode_desc_failover' => '<strong>failover</strong> — łańcuch failover z <code>config/queue.php</code>. Upewnij się, że co najmniej jeden backend działa.',
'settings_queue_mode_desc_default' => '<strong>:connection</strong> — async tracking przez to połączenie Laravel. Skonfiguruj je w <code>config/queue.php</code>.',
'settings_queue_mode_info_sync' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="text-sm text-neutral-800 dark:text-neutral-300"><strong>sync</strong> — wizyty zapisywane podczas przekierowania (bez workera). Dev i niski ruch. Opcjonalne ręczne buforowanie liczników poniżej.</div></div>',
'settings_geoip_enabled' => 'Włącz wykrywanie Geo-IP',
'settings_geoip_enabled_helper' => 'Wykrywaj i zapisuj kraj odwiedzającego przy każdej wizycie.',
@@ -263,11 +353,15 @@ return [
'settings_ga4_verify_ok' => '✅ Klucz API jest prawidłowy — połączenie z GA4 udane',
'settings_ga4_verify_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
'settings_ga4_verify_measurement_id' => 'Measurement ID do testu połączenia',
'settings_ga4_verify_measurement_id_helper' => 'Podaj dokładny G-XXXXXXXXXX ze strumienia GA4 powiązanego z kluczem API. Wymagane, chyba że używasz Firebase App ID.',
'settings_ga4_verify_measurement_required' => 'Podaj Measurement ID (G-XXXXXXXXXX) lub Firebase App ID, aby przetestować połączenie.',
'settings_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
'settings_section_buffering' => 'Buforowanie liczników wizyt',
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
'settings_buffering_helper' => 'Po włączeniu, zliczenia kliknięć będą buforowane w pamięci podręcznej aplikacji i muszą być okresowo synchronizowane z bazą danych za pomocą komendy "php artisan short-url:sync-counters". Zapobiega to blokowaniu wierszy bazy danych i spadkom wydajności przy masowym ruchu.',
'settings_buffering_redis_auto' => 'Włączane automatycznie przy kolejce Redis. Liczniki linków, statystyki „dziś” i live feed korzystaj z dedykowanego połączenia Redis kolejki (phpredis lub Predis) — niezależnie od CACHE_STORE. Zaplanuj short-url:sync-counters co minutę.',
'settings_buffering_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagane zadanie Cron (Scheduler):</strong> Włączyłeś buforowanie kliknięć. Musisz dodać do harmonogramu zadań (cron) komendę synchronizującą liczniki z bazą danych:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">* * * * * cd /sciezka-do-twojego-projektu && php artisan schedule:run >> /dev/null 2>&1</code><br>Komenda uruchamiana automatycznie w tle przez harmonogram:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan short-url:sync-counters</code></span></div></div>',
// CDN Trust Settings
@@ -275,10 +369,12 @@ return [
'settings_trust_cdn_headers_helper' => 'Włącz tę opcję, jeśli Twoja aplikacja działa za CDN (np. Cloudflare, AWS CloudFront) lub reverse proxy. Pozwala to na pobieranie prawdziwego adresu IP klienta i kodu kraju z nagłówków CDN. Uwaga: włączaj tylko wtedy, gdy faktycznie korzystasz z proxy, w przeciwnym razie adresy IP mogą być sfałszowane!',
'settings_trust_cdn_headers_info_callout' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Uwaga (Bezpieczeństwo adresów IP):</strong> Włączyłeś ufanie nagłówkom proxy. Włączaj tę opcję <u>wyłącznie</u> wtedy, gdy Twoja strona jest podpięta pod:<br>• <strong>Cloudflare</strong> (odczyt z nagłówka CF-Connecting-IP)<br>• <strong>AWS CloudFront</strong> lub inny system CDN<br>• <strong>Nginx/Apache reverse proxy</strong> przekazujący nagłówki X-Forwarded-For.<br><br><strong>Wyłącz tę opcję</strong>, jeśli Twój serwer łączy się z użytkownikami bezpośrednio. Jeśli pozostawisz ją włączoną bez CDN, złośliwi użytkownicy mogą łatwo sfałszować swój adres IP, wysyłając własny nagłówek.</span></div></div>',
'settings_geoip_headers_warning' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/20" data-callout-type="warning"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-amber-800 dark:text-amber-300" aria-label="Warning"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-amber-900 dark:text-amber-200" data-component-part="callout-content"><span><strong>Brak zaufania do nagłówków proxy:</strong> Wybrałeś <em>Nagłówki CDN</em> jako sterownik wykrywania lokalizacji, ale opcja <strong>„Ufaj nagłówkom CDN i proxy”</strong> w zakładce <em>Ogólne</em> jest wyłączona. Wykrywanie kraju nie będzie działać, dopóki jej nie włączysz.</span></div></div>',
'settings_geoip_headers_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-950/20" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-blue-800 dark:text-blue-300" aria-label="Info"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" /></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full text-blue-900 dark:text-blue-200" data-component-part="callout-content"><span><strong>Sterownik Nagłówki CDN:</strong> Zapis ustawień z tym sterownikiem automatycznie włącza opcję <strong>Ufaj nagłówkom CDN i proxy</strong>. Gdy brakuje nagłówków kraju z CDN, wtyczka korzysta z MaxMind, a następnie ip-api.com (z limitem zapytań) zamiast zwracać puste dane geo.</span></div></div>',
// Stats View & Export Localization
'stats_filter_visited_from' => 'Odwiedzono od',
'stats_filter_visited_until' => 'Odwiedzono do',
'stats_filter_counted_in_stats' => 'Tylko wizyty w statystykach',
'stats_action_export' => 'Eksportuj CSV',
'stats_csv_time' => 'Czas',
'stats_csv_ip' => 'Adres IP',
@@ -305,8 +401,11 @@ return [
'form_section_options' => 'Opcje',
'form_section_notes' => 'Wewnętrzne notatki',
'form_section_tracking' => 'Śledzenie wizyt',
'form_section_tracking_desc' => 'Włącz lub wyłącz śledzenie wizyt i statystyki dla tego skróconego linku.',
'form_section_tracked_fields' => 'Śledzone pola',
'form_section_tracked_fields_desc' => 'Wybierz, które metryki są zbierane dla tego linku — przydatne pod kątem analityki i prywatności.',
'form_section_analytics' => 'Zewnętrzne systemy analityczne',
'form_section_analytics_desc' => 'Zintegruj ten link z Google Analytics, podając identyfikator strumienia danych.',
// New Dashboard Analytics Keys
'stats_card_top_source' => 'Główne źródło UTM',
@@ -335,11 +434,27 @@ return [
'security_section_title' => 'Kontrola bezpieczeństwa',
'password' => 'Hasło dostępu',
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'password_card_title' => 'Ochrona hasłem',
'password_card_subtitle' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
'warning_page_card_title' => 'Strona ostrzegająca przed przekierowaniem',
'warning_page_card_subtitle' => 'Pokaż ekran pośredni przed przekierowaniem odwiedzających na docelowy adres.',
'warning_page_empty_title' => 'Strona ostrzegająca wyłączona',
'warning_page_empty_desc' => 'Włącz przełącznik powyżej, aby pokazać ekran bezpieczeństwa przed zewnętrznym przekierowaniem.',
'warning_page_active_title' => 'Strona ostrzegająca włączona',
'confirm_password' => 'Potwierdź hasło',
'new_password' => 'Nowe hasło',
'change_password' => 'Zmień hasło',
'remove_password' => 'Usuń hasło',
'password_status_active' => 'Ochrona hasłem jest włączona.',
'password_status_active_desc' => 'Link jest zabezpieczony. Dostęp wymaga podania prawidłowego hasła.',
'password_empty_state_title' => 'Brak zabezpieczeń',
'password_empty_state_desc' => 'Dodaj hasło, aby ograniczyć dostęp do tego skróconego linku tylko dla wybranych osób.',
'password_settings_section' => 'Ustawienia hasła',
'password_change_short' => 'Zmień',
'password_remove_short' => 'Usuń',
'save_password' => 'Zapisz hasło',
'expiration_dates_section_desc' => 'Zarządzaj dostępnością linku w czasie. Możesz zaplanować start kampanii lub jej automatyczne zakończenie.',
'visit_limits_section_desc' => 'Ogranicz maksymalną liczbę kliknięć. Idealne do biletów jednorazowych lub ofert limitowanych.',
'set_password' => 'Ustaw hasło',
'cancel' => 'Anuluj',
'confirm' => 'Potwierdź',
@@ -396,8 +511,8 @@ return [
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
'settings_retention_days' => 'Czas retencji surowych logów',
'settings_retention_days_helper' => 'Wybierz, przez jaki okres czasu chcesz przechowywać szczegółowe logi pojedynczych wizyt przed ich usunięciem.',
'settings_aggregation_enabled' => 'Włącz automatyczną agregację i czyszczenie logów',
'settings_aggregation_enabled_helper' => 'Gdy włączone, wtyczka automatycznie rejestruje zadanie w harmonogramie (codziennie o 02:00), aby zsumować wizyty do statystyk dziennych i usunąć surowe logi starsze niż wybrany okres retencji.',
'settings_aggregation_enabled' => 'Włącz automatyczne usuwanie surowych logów',
'settings_aggregation_enabled_helper' => 'Codzienne zadanie o 02:00 zawsze agreguje wizyty do statystyk dziennych. Ten przełącznik kontroluje wyłącznie usuwanie surowych logów starszych niż wybrany okres retencji.',
'retention_30_days' => '30 dni',
'retention_60_days' => '60 dni',
'retention_90_days' => '90 dni',
@@ -407,6 +522,7 @@ return [
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
'settings_rate_limiting_enabled' => 'Włącz ochronę limitów',
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
'settings_rate_limiting_route_info' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200"><strong>Dwa poziomy:</strong> trasy przekierowań mają też domyślny limit middleware pakietu (<code>120/min</code> na IP). Po włączeniu limitera poniżej <em>oba</em> mogą zwrócić HTTP 429 — pierwszy osiągnięty limit wygrywa.</div>',
'settings_rate_limiting_max_attempts' => 'Maksymalna dopuszczalna liczba przekierowań',
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
@@ -415,7 +531,7 @@ return [
// Queue Settings Additions
'settings_queue_name' => 'Nazwa kolejki',
'settings_queue_name_helper' => 'Docelowa nazwa kolejki, do której wysyłane są zadania śledzenia i synchronizacji liczników. Domyślnie: "default".',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Musisz uruchomić proces w tle (workera), aby przetwarzać te zadania:<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work --queue=:queue</code></span></div></div>',
'settings_queue_worker_info' => '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info"><div class="mt-0.5 w-4" data-component-part="callout-icon"><svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info"><path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path></svg></div><div class="text-sm prose dark:prose-invert min-w-0 w-full [&_kbd]:bg-background-light dark:[&_kbd]:bg-background-dark [_code]:!text-current [_kbd]:!text-current [_a]:!text-current [_a]:border-current [_strong]:!text-current text-neutral-800 dark:text-neutral-300" data-component-part="callout-content"><span><strong>Wymagany proces w tle (Queue Worker):</strong> Wybrane połączenie działa asynchronicznie. Uruchom workera na tym samym połączeniu i nazwie kolejki co powyżej (niezależnie od <code>QUEUE_CONNECTION</code> w <code>.env</code>):<br><code class="px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-800 text-xs">php artisan queue:work :connection --queue=:queue</code></span></div></div>',
'settings_geoip_stats_cache_ttl' => 'Czas życia (TTL) pamięci podręcznej statystyk',
'settings_geoip_stats_cache_ttl_helper' => 'Czas (w sekundach) buforowania obliczeń statystyk linków na pulpicie. Kiedy przeglądasz statystyki, system pobiera dane z cache zamiast każdorazowo przeliczać miliony rekordów w bazie danych. Domyślnie: 300 (5 minut). Maksymalnie: 86400 (24 godziny).',
@@ -467,14 +583,22 @@ return [
'tab_marketing' => 'Marketing i API',
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
'marketing_pixels_desc' => 'Dodaj skrypty śledzące, aby dołączać ciasteczka odwiedzających i budować grupy remarketingowe reklam.',
'marketing_pixels_empty_title' => 'Brak przypisanych pikseli',
'marketing_pixels_empty_desc' => 'Wybierz piksele retargetingowe, które mają uruchamiać skrypty śledzące po kliknięciu w ten link.',
'marketing_pixels_select_placeholder' => 'Wybierz piksele retargetingowe…',
'marketing_webhook_empty_title' => 'Brak skonfigurowanego webhooka',
'marketing_webhook_empty_desc' => 'Dodaj docelowy adres URL, aby otrzymywać powiadomienia HTTP POST w czasie rzeczywistym po każdym kliknięciu.',
'pixel_meta' => 'Meta Pixel ID',
'pixel_google' => 'Google Tag / GA4 ID',
'pixel_linkedin' => 'LinkedIn Partner ID',
'marketing_webhooks_title' => 'Integracja Webhook linku',
'marketing_webhooks_desc' => 'Skonfiguruj niestandardowy adres URL, pod który wysłane zostanie natychmiastowe powiadomienie HTTP POST po kliknięciu.',
'webhook_url' => 'Dedykowany Webhook URL',
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Przekazuje ono szczegółowe metadane wizyty w formacie JSON (klucz linku, adres IP, kraj, przeglądarka, system operacyjny, referer i parametry UTM).',
'webhook_helper_alert' => 'Po skonfigurowaniu adresu URL webhooka, przy każdym kliknięciu w ten skrócony link wyślemy żądanie HTTP POST w tle w czasie rzeczywistym. Payload zawiera szczegółowe metadane wizyty w JSON (klucz linku, docelowy URL, urządzenie, przeglądarka, system operacyjny, geolokalizacja, referer, parametry UTM i flagę skanowania QR). Adresy IP nie są uwzględniane w payloadzie webhooka.',
'webhook_show_payload' => 'Pokaż przykładowy payload JSON',
'webhook_payload_modal_title' => 'Przykładowy payload webhooka',
'webhook_payload_modal_desc' => 'Przykładowe body JSON dla zdarzenia visited. Rzeczywiste wartości zależą od każdego kliknięcia.',
'webhook_payload_copy' => 'Kopiuj payload do schowka',
// Settings tab additions
'settings_tab_developer' => 'API i Webhooki',
@@ -504,6 +628,19 @@ return [
// Ochrona v2.0
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami v2.0',
'settings_section_security_v2_desc' => 'Skonfiguruj wykrywanie sieci VPN/Proxy oraz walidację adresów przez Google Safe Browsing.',
'settings_section_analytics_security' => 'Analityka i wykrywanie botów',
'settings_section_analytics_security_desc' => 'Opcjonalna deduplikacja kliknięć i zaawansowane ustawienia botów.',
'settings_click_dedup_enabled' => 'Włącz deduplikację kliknięć',
'settings_click_dedup_enabled_helper' => 'Ignoruj powtarzające się kliknięcia z tego samego IP w określonym oknie czasu.',
'settings_click_dedup_hours' => 'Okno deduplikacji (godziny)',
'settings_click_dedup_hours_helper' => 'Jak długo traktować powtarzające się kliknięcia z tego samego IP jako duplikaty.',
'settings_bot_verify_googlebot' => 'Weryfikuj IP Googlebota',
'settings_bot_verify_googlebot_helper' => 'Reverse-DNS + forward IP dla user agentów Googlebota.',
'settings_bot_debug_secret' => 'Sekret debugowania botów',
'settings_bot_debug_secret_helper' => 'Wymagana wartość dla ?bot=1 poza local/testing.',
'outbound_url_blocked' => 'Ten adres URL wychodzący jest niedozwolony.',
'api_key_owner_user_id' => 'ID właściciela',
'api_key_owner_user_id_helper' => 'Wymagane, gdy włączone jest scope linków do użytkownika (domyślnie). Ogranicza klucz do linków tego użytkownika.',
'settings_vpn_detection_enabled' => 'Włącz wykrywanie VPN i Proxy',
'settings_vpn_detection_enabled_helper' => 'Po włączeniu każda wizyta będzie sprawdzana pod kątem korzystania z połączeń VPN, proxy lub sieci Tor.',
'settings_vpn_driver' => 'Sterownik wykrywania',
@@ -514,6 +651,12 @@ return [
'settings_vpnapi_key_helper' => 'Twój klucz API pobrany z vpnapi.io (wymagany dla sterownika vpnapi).',
'settings_vpn_block_action' => 'Akcja blokowania VPN',
'settings_vpn_block_action_helper' => 'Wybierz, czy ruch z sieci VPN ma być jedynie flagowany w statystykach, czy aktywnie blokowany stroną 403 Forbidden.',
'settings_vpn_block_social_warning' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 text-sm text-amber-900 dark:text-amber-200"><strong>Podglądy linków:</strong> tryb blokowania zwraca HTTP 403 dla IP VPN/proxy/hostingu. Znane boty social (Facebook, LinkedIn, Google preview) są <em>nadal wpuszczane</em>, aby OG i cloaking działały. Ludzie na VPN mogą dostać 403.</div>',
'settings_vpn_detection_timeout' => 'Timeout detekcji VPN',
'settings_vpn_detection_timeout_helper' => 'Maksymalny czas oczekiwania (w sekundach) na odpowiedź API VPN/proxy przy przekierowaniu.',
'settings_vpn_detection_cache_ttl' => 'TTL cache detekcji VPN',
'settings_vpn_detection_cache_ttl_helper' => 'Jak długo (w sekundach) cacheować wynik lookupu VPN/proxy dla danego IP.',
'settings_trust_cdn_headers_auto_enabled' => '<div class="callout my-2 px-4 py-3 rounded-xl border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 text-sm text-blue-900 dark:text-blue-200">Sterownik <strong>Nagłówki CDN</strong> (zakładka Geo-IP) wymaga ufania nagłówkom proxy. Opcja jest <strong>automatycznie włączana</strong> przy tym sterowniku i przy zapisie ustawień.</div>',
'settings_vpn_block_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
@@ -572,6 +715,7 @@ return [
'app_linking_auto_open' => 'Autootwieranie',
'app_linking_matched_description' => 'Po kliknięciu na telefonie lub tablecie, ten link otworzy się od razu w dedykowanej aplikacji <strong>:app</strong> zamiast w zwykłej przeglądarce.',
'app_linking_deep_link_scheme' => 'Używany schemat (Deep Link): :scheme',
'app_linking_deep_link_label' => 'Adres deep link',
'app_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
// App Redirect Page
@@ -592,6 +736,11 @@ return [
'balance_weights' => 'Wyreguluj do 100%',
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
'custom_domain_locked_error' => 'Domena niestandardowa jest zablokowana i nie może być zmieniona.',
'custom_domain_not_owned_error' => 'Nie masz uprawnień do użycia tej domeny niestandardowej.',
'resource_not_owned_error' => 'Nie masz uprawnień do użycia tego zasobu.',
'webhook_signing_secret_required' => 'Włączenie globalnego webhooka wymaga ustawienia sekretu podpisu HMAC.',
'webhook_signing_secret' => 'Sekret podpisu webhooka',
'webhook_signing_secret_helper' => 'Wymagany, gdy włączony jest globalny webhook. Żądania wychodzące zawierają HMAC-SHA256 w nagłówku X-ShortUrl-Signature.',
// Stats View & Granularity & PoP Trends
'stats_prev_period' => 'poprzedni okres',
@@ -730,7 +879,9 @@ return [
// 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_live_feed_poll_interval' => 'Na żywo (SSE)',
'stats_live_feed_mode_redis' => 'Na żywo · Redis push',
'stats_live_feed_mode_poll' => 'Na żywo · SSE poll',
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
// Puste stany - makiety
@@ -757,6 +908,7 @@ return [
'tab_seo_social' => 'SEO i Social',
'is_cloaked' => 'Maskowanie linku (Cloaking)',
'is_cloaked_helper' => 'Maskuj adres docelowy w pasku adresu przeglądarki za pomocą ramki iframe na domenie krótkiego linku.',
'is_cloaked_iframeable_hint' => 'Niektóre strony docelowe blokują osadzanie w iframe (X-Frame-Options). Przed włączeniem cloakingu użyj testu iframeable.',
'do_index' => 'Indeksowanie w wyszukiwarkach',
'do_index_helper' => 'Zezwól wyszukiwarkom (np. Google) na indeksowanie tego krótkiego linku. W przeciwnym razie będzie on serwowany z tagiem noindex.',
'og_title' => 'Niestandardowy tytuł',
@@ -770,4 +922,60 @@ return [
'live_qr_preview' => 'Podgląd kodu QR',
'advanced_options_section' => 'Zaawansowane opcje przekierowań i stanu',
'fetching_metadata' => 'Pobieranie podglądu...',
// HTTP / API messages (visitor-facing & REST)
'api_disabled' => 'API deweloperskie jest wyłączone. Włącz je w Ustawieniach skrótów → API i webhooki.',
'api_key_missing' => 'Brak autoryzacji. Nie podano klucza API.',
'api_key_invalid' => 'Brak autoryzacji. Nieprawidłowy lub nieaktywny klucz API.',
'api_key_owner_required' => 'Brak dostępu. Ten klucz API musi mieć owner_user_id, gdy włączone jest scope linków do użytkownika.',
'api_key_read_only' => 'Brak dostępu. Ten klucz API ma uprawnienia tylko do odczytu.',
'api_rate_limit_exceeded' => 'Zbyt wiele żądań. Przekroczono limit klucza API.',
'short_url_not_found' => 'Nie znaleziono skrótu URL.',
'redirect_vpn_blocked' => 'Odmowa dostępu. Wykryto VPN, proxy lub automatyczne pobieranie.',
'redirect_rate_limited' => 'Zbyt wiele żądań. Spróbuj ponownie za :seconds sek.',
'redirect_destination_blocked' => 'Docelowy adres tego linku został zablokowany ze względów bezpieczeństwa.',
'public_stats_rate_limited' => 'Zbyt wiele żądań.',
'public_stats_password_invalid' => 'Wymagane lub nieprawidłowe hasło.',
'public_stats_password_rate_limited' => 'Zbyt wiele nieudanych prób hasła. Spróbuj ponownie później.',
'public_stats_retry_in' => 'Spróbuj ponownie za :seconds s.',
'action_public_stats' => 'Statystyki publiczne',
'public_stats_modal_title' => 'Publiczne statystyki',
'public_stats_modal_description' => 'Udostępnij stronę ze statystykami bez dostępu do panelu administracyjnego.',
'public_stats_save' => 'Zapisz',
'public_stats_enabled_label' => 'Włącz publiczną stronę statystyk',
'public_stats_password_label' => 'Opcjonalne hasło',
'public_stats_password_helper' => 'Pozostaw puste, aby zachować obecne hasło. Wyłącz statystyki, aby je usunąć.',
'public_stats_disabled_hint' => 'Statystyki publiczne są wyłączone. Włącz przełącznik i zapisz, aby wygenerować link.',
'public_stats_link_label' => 'Link do statystyk',
'public_stats_save_to_activate' => 'Zapisz, aby aktywować ten link.',
'public_stats_enabled_status' => 'Statystyki publiczne są włączone.',
'public_stats_copied' => 'Skopiowano link do statystyk.',
'public_stats_open' => 'Otwórz',
'public_stats_saved_enabled' => 'Włączono publiczne statystyki.',
'public_stats_saved_disabled' => 'Wyłączono publiczne statystyki.',
'public_stats_page_title' => 'Statystyki linku',
'public_stats_page_subtitle' => 'Statystyki dla /:key',
'public_stats_date_from' => 'Od',
'public_stats_date_to' => 'Do',
'public_stats_apply_filter' => 'Filtruj',
'public_stats_total_visits' => 'Wszystkie wizyty',
'public_stats_unique_visits' => 'Unikalne wizyty',
'public_stats_today' => 'Dziś',
'public_stats_this_week' => 'Ten tydzień',
'public_stats_this_month' => 'Ten miesiąc',
'public_stats_qr_scans' => 'Skany QR',
'public_stats_visits_by_day' => 'Wizyty wg dnia',
'public_stats_no_data' => 'Brak wizyt w tym okresie.',
'public_stats_day' => 'Dzień',
'public_stats_visits' => 'Wizyty',
'public_stats_password_title' => 'Chronione statystyki',
'public_stats_password_description' => 'Podaj hasło, aby zobaczyć statystyki tego linku.',
'public_stats_password_submit' => 'Pokaż statystyki',
'redirect_html_redirecting' => 'Trwa przekierowanie na stronę docelową...',
'api_created' => 'Skrót URL został utworzony.',
'api_updated' => 'Skrót URL został zaktualizowany.',
'api_bulk_created' => 'Skróty URL zostały utworzone.',
'api_deleted' => 'Skrót URL został usunięty.',
'api_bulk_deleted' => 'Skróty URL zostały usunięte.',
'api_bulk_updated' => 'Skróty URL zostały zaktualizowane.',
];

View File

@@ -1,11 +1,10 @@
@php
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
$appCount = count($apps);
@endphp
<div class="mt-4 space-y-3">
{{-- Matched App Banner --}}
<div class="app-linking-preview">
@if ($matchedAppId && isset($apps[$matchedAppId]))
@php
$matchedApp = $apps[$matchedAppId];
@@ -13,106 +12,100 @@
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
@endphp
<div class="p-3.5 rounded-xl bg-emerald-500/8 border border-emerald-500/40 dark:border-emerald-500/30 dark:bg-emerald-500/5">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 rounded-[10px] bg-white dark:bg-neutral-800 border border-emerald-100 dark:border-emerald-900/50 flex items-center justify-center shadow-sm flex-shrink-0">
<img src="{{ $matchedFavicon }}" alt="{{ $matchedApp['name'] }}" class="w-5 h-5 object-contain" onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'">
<div class="app-linking-status app-linking-status--matched">
<div class="app-linking-status-main">
<div class="app-linking-status-icon app-linking-status-icon--matched">
<img
src="{{ $matchedFavicon }}"
alt="{{ $matchedApp['name'] }}"
loading="lazy"
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-[11px] font-bold text-emerald-700 dark:text-emerald-400 truncate">{{ $matchedApp['name'] }}</span>
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wider shrink-0">
<svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<div class="app-linking-status-copy">
<div class="app-linking-status-title-row">
<p class="app-linking-status-title">{{ $matchedApp['name'] }}</p>
<span class="app-linking-status-badge app-linking-status-badge--success">
{{ __('filament-short-url::default.app_linking_auto_open') }}
</span>
</div>
<p class="text-[10px] text-emerald-600/80 dark:text-emerald-500 mt-0.5 leading-snug">
<p class="app-linking-status-desc">
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
</p>
</div>
</div>
<div class="font-mono text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2.5 py-1.5 break-all select-all border border-emerald-100 dark:border-emerald-900/30">
{{ $deepLink }}
<div class="app-linking-deep-link">
<span class="app-linking-deep-link-label">{{ __('filament-short-url::default.app_linking_deep_link_label') }}</span>
<code class="app-linking-deep-link-value">{{ $deepLink }}</code>
</div>
</div>
@else
{{-- Standard Web Redirect notice --}}
<div class="flex items-center gap-2.5 px-3 py-2.5 rounded-xl bg-neutral-100/80 dark:bg-neutral-800/30 border border-neutral-200 dark:border-neutral-700/50">
<div class="w-7 h-7 rounded-lg bg-white dark:bg-neutral-800 flex items-center justify-center flex-shrink-0 border border-neutral-200 dark:border-neutral-700">
<svg class="w-3.5 h-3.5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
<div class="app-linking-status app-linking-status--browser">
<div class="app-linking-status-icon app-linking-status-icon--browser">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.732-3.553" />
</svg>
</div>
<span class="text-[11px] text-neutral-500 dark:text-neutral-400 leading-snug">
{{ __('filament-short-url::default.app_linking_standard_redirect') }}
</span>
<div class="app-linking-status-copy">
<p class="app-linking-status-title">{{ __('filament-short-url::default.app_linking_standard_redirect') }}</p>
</div>
</div>
@endif
{{-- Supported Apps Section --}}
<div class="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900/50 overflow-hidden">
<div class="app-linking-catalog">
<div class="app-linking-catalog-header">
<div class="app-linking-catalog-heading">
<p class="app-linking-catalog-title">{{ __('filament-short-url::default.app_linking_supported_apps') }}</p>
<p class="app-linking-catalog-subtitle">
{{ __('filament-short-url::default.app_linking_preconfigured_count', ['count' => $appCount]) }}
</p>
</div>
{{-- Header --}}
<div class="flex items-center justify-between px-3 py-2.5 border-b border-neutral-100 dark:border-neutral-800">
<span class="text-[10px] font-bold uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
{{ __('filament-short-url::default.app_linking_supported_apps') }}
</span>
<div class="flex items-center gap-1">
<span class="px-1.5 py-0.5 rounded-md text-[9px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 tracking-wide">iOS</span>
<span class="px-1.5 py-0.5 rounded-md text-[9px] font-bold bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 tracking-wide">Android</span>
<div class="app-linking-os-badges" aria-label="{{ __('filament-short-url::default.app_linking_supported_os') }}">
<span class="app-linking-os-badge">iOS</span>
<span class="app-linking-os-badge">Android</span>
</div>
</div>
{{-- iOS-style App Grid --}}
<div class="p-2 grid grid-cols-4 gap-1">
<div class="app-linking-grid">
@foreach ($apps as $appId => $app)
@php
$isMatched = ($appId === $matchedAppId);
$appDomain = explode('/', $app['domains'][0])[0];
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
@endphp
<div class="flex flex-col items-center gap-1 py-2 px-1 rounded-xl transition-colors relative
{{ $isMatched
? 'bg-emerald-50 dark:bg-emerald-500/10'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50' }}">
{{-- App Icon --}}
<div class="w-10 h-10 rounded-[10px] flex items-center justify-center
{{ $isMatched
? 'bg-white dark:bg-neutral-800 shadow-sm ring-1 ring-emerald-400/30'
: 'bg-neutral-100 dark:bg-neutral-800' }}">
<div @class([
'app-linking-app',
'app-linking-app--matched' => $isMatched,
])>
<div class="app-linking-app-icon-wrap">
<img
src="{{ $appFavicon }}"
alt="{{ $app['name'] }}"
class="w-6 h-6 object-contain"
alt=""
class="app-linking-app-icon"
loading="lazy"
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
>
</div>
{{-- App Name --}}
<span class="text-[9px] font-medium text-center leading-tight
{{ $isMatched
? 'text-emerald-700 dark:text-emerald-400 font-semibold'
: 'text-neutral-500 dark:text-neutral-400' }}"
style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word;">
{{ $app['name'] }}
</span>
{{-- Matched checkmark badge --}}
@if ($isMatched)
<span class="absolute top-1.5 right-1.5 w-3.5 h-3.5 rounded-full bg-emerald-500 flex items-center justify-center">
<svg class="w-2 h-2 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
<span class="app-linking-app-check" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clip-rule="evenodd" />
</svg>
</span>
@endif
</div>
<span class="app-linking-app-name">{{ $app['name'] }}</span>
</div>
@endforeach
</div>
{{-- Footer hint --}}
<div class="px-3 py-2 border-t border-neutral-100 dark:border-neutral-800">
<p class="text-[10px] text-neutral-400 dark:text-neutral-500 leading-relaxed">
<p class="app-linking-footnote">
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,216 @@
@php
$statePath = $getStatePath();
$minValue = $getMinValue();
$maxValue = $getMaxValue();
$step = $getStep();
$isInteger = $isInteger();
$isNullable = $isNullable();
$isDisabled = $isDisabled();
$variant = $getVariant();
$size = $getSize();
$displaySuffix = $getDisplaySuffix();
$nullLabel = $getNullLabel() ?? '—';
$containerClasses = match ($size) {
'sm' => 'h-8 gap-0.5 p-0.5',
'lg' => 'h-12 gap-1.5 p-1.5',
default => 'h-10 gap-1 p-1',
};
$buttonSizeClasses = match ($size) {
'sm' => 'size-7',
'lg' => 'size-10',
default => 'size-8',
};
$valueClasses = match ($size) {
'sm' => 'min-w-14 px-1 text-xs',
'lg' => 'min-w-20 px-2 text-base',
default => 'min-w-16 px-1.5 text-sm',
};
$iconClasses = match ($size) {
'sm' => 'size-3',
'lg' => 'size-5',
default => 'size-4',
};
$buttonVariantClasses = match ($variant) {
'secondary' => 'bg-primary-50 text-primary-600 shadow-sm hover:bg-primary-100 disabled:opacity-40 dark:bg-primary-500/10 dark:text-primary-400 dark:hover:bg-primary-500/20',
'tertiary' => 'bg-gray-200/80 text-gray-700 shadow-sm hover:bg-gray-300/80 disabled:opacity-40 dark:bg-white/10 dark:text-gray-200 dark:hover:bg-white/15',
'outline' => 'border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-40 dark:border-white/20 dark:bg-transparent dark:text-gray-200 dark:hover:bg-white/5',
default => 'bg-primary-600 text-white shadow-sm hover:bg-primary-500 disabled:opacity-40 dark:bg-primary-500 dark:hover:bg-primary-400',
};
@endphp
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
min: @js($minValue),
max: @js($maxValue),
step: @js($step),
integer: @js($isInteger),
nullable: @js($isNullable),
disabled: @js($isDisabled),
nullLabel: @js($nullLabel),
suffix: @js($displaySuffix),
normalize(value) {
if (value === null || value === '') {
return null;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return null;
}
return this.integer ? Math.round(numeric) : numeric;
},
get numericState() {
return this.normalize(this.state);
},
get hasValue() {
return this.numericState !== null;
},
get displayValue() {
if (! this.hasValue) {
return this.nullLabel;
}
return String(this.numericState);
},
get canDecrement() {
if (this.disabled) {
return false;
}
if (! this.hasValue) {
return false;
}
if (this.min === null) {
return this.nullable || this.numericState > 0;
}
if (this.nullable && this.numericState <= this.min) {
return true;
}
return this.numericState > this.min;
},
get canIncrement() {
if (this.disabled) {
return false;
}
if (! this.hasValue) {
return true;
}
if (this.max === null) {
return true;
}
return this.numericState < this.max;
},
decrement() {
if (! this.canDecrement) {
return;
}
if (! this.hasValue) {
return;
}
if (this.nullable && this.min !== null && this.numericState <= this.min) {
this.state = null;
return;
}
let next = this.numericState - this.step;
if (this.min !== null) {
next = Math.max(next, this.min);
}
this.state = this.integer ? Math.round(next) : next;
},
increment() {
if (! this.canIncrement) {
return;
}
let next = this.hasValue
? this.numericState + this.step
: (this.min ?? this.step);
if (this.max !== null) {
next = Math.min(next, this.max);
}
this.state = this.integer ? Math.round(next) : next;
},
}"
@class([
'fsu-number-stepper inline-flex w-fit max-w-max shrink-0 justify-self-start items-center rounded-full bg-gray-100 dark:bg-white/10',
$containerClasses,
'opacity-60' => $isDisabled,
])
role="group"
aria-label="{{ $getLabel() }}"
>
<button
type="button"
@class([
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
$buttonSizeClasses,
$buttonVariantClasses,
])
x-on:click="decrement()"
x-bind:disabled="! canDecrement"
x-bind:aria-disabled="! canDecrement"
aria-label="{{ __('filament-short-url::default.number_stepper_decrease') }}"
>
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z" clip-rule="evenodd" />
</svg>
</button>
<div
@class([
'flex items-center justify-center gap-1 font-semibold tabular-nums text-gray-950 dark:text-white',
$valueClasses,
])
>
<span x-text="displayValue"></span>
<span
x-show="hasValue && suffix"
x-text="suffix"
class="font-medium text-gray-500 dark:text-gray-400"
></span>
</div>
<button
type="button"
@class([
'inline-flex shrink-0 items-center justify-center rounded-full transition duration-75 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed',
$buttonSizeClasses,
$buttonVariantClasses,
])
x-on:click="increment()"
x-bind:disabled="! canIncrement"
x-bind:aria-disabled="! canIncrement"
aria-label="{{ __('filament-short-url::default.number_stepper_increase') }}"
>
<svg @class([$iconClasses]) viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
</svg>
</button>
</div>
</x-dynamic-component>

View File

@@ -0,0 +1,169 @@
@php
use Illuminate\Support\Js;
$statePath = $getStatePath();
$options = $getNormalizedOptions();
$optionKeys = array_keys($options);
$size = $getSize();
$variant = $getVariant();
$hasSeparators = $hasSeparators();
$isFullWidth = $isFullWidth();
$isIconOnly = $isIconOnly();
$expandSelectedLabel = $shouldExpandSelectedLabel();
$isDisabled = $isDisabled();
@endphp
<x-dynamic-component
:component="$getFieldWrapperView()"
:field="$field"
>
<div
x-data="{
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
optionKeys: {{ Js::from(array_values($optionKeys)) }},
disabledOptions: {{ Js::from(collect($options)->mapWithKeys(fn (array $option, string | int $key): array => [(string) $key => $option['disabled']])->all()) }},
separators: @js($hasSeparators),
disabled: @js($isDisabled),
indicatorStyle: '',
resizeObserver: null,
normalize(value) {
return value === null || value === undefined ? null : String(value);
},
isSelected(value) {
return this.normalize(this.state) === this.normalize(value);
},
isOptionDisabled(value) {
return this.disabledOptions[this.normalize(value)] ?? false;
},
canSelect(value) {
return ! this.disabled && ! this.isOptionDisabled(value);
},
select(value) {
if (! this.canSelect(value)) {
return;
}
this.state = value;
this.$nextTick(() => this.updateIndicator());
},
selectedIndex() {
const current = this.normalize(this.state);
return this.optionKeys.findIndex((key) => this.normalize(key) === current);
},
showSeparator(separatorIndex) {
if (! this.separators) {
return false;
}
const selectedIndex = this.selectedIndex();
if (selectedIndex === -1) {
return true;
}
return separatorIndex !== selectedIndex - 1 && separatorIndex !== selectedIndex;
},
updateIndicator() {
const track = this.$refs.track;
if (! track) {
return;
}
const selected = track.querySelector('[data-segment-selected=true]');
if (! selected) {
this.indicatorStyle = 'opacity: 0;';
return;
}
this.indicatorStyle =
'width: ' + selected.offsetWidth + 'px;' +
'height: ' + selected.offsetHeight + 'px;' +
'transform: translate3d(' + selected.offsetLeft + 'px, ' + selected.offsetTop + 'px, 0);' +
'opacity: 1;';
},
init() {
this.$watch('state', () => this.$nextTick(() => this.updateIndicator()));
this.$nextTick(() => this.updateIndicator());
if (typeof ResizeObserver === 'undefined') {
return;
}
this.resizeObserver = new ResizeObserver(() => this.updateIndicator());
this.resizeObserver.observe(this.$refs.track);
},
}"
x-init="init()"
@class([
'fsu-segment-control',
'w-full' => $isFullWidth,
'opacity-60 pointer-events-none' => $isDisabled,
])
role="radiogroup"
aria-label="{{ $getLabel() }}"
>
<div
x-ref="track"
@class([
'fsu-segment-track',
'fsu-segment-track--'.$size,
'fsu-segment-track--ghost' => $variant === 'ghost',
])
>
<div
x-ref="indicator"
aria-hidden="true"
@class([
'fsu-segment-indicator',
'fsu-segment-indicator--ghost' => $variant === 'ghost',
])
:style="indicatorStyle"
></div>
@foreach ($options as $value => $option)
@if (! $loop->first && $hasSeparators)
<span
x-show="showSeparator({{ $loop->index - 1 }})"
x-cloak
class="fsu-segment-separator"
aria-hidden="true"
></span>
@endif
<button
type="button"
role="radio"
@class([
'fsu-segment-item',
'fsu-segment-item--'.$size,
])
data-segment-value="{{ $value }}"
x-bind:data-segment-selected="isSelected(@js($value)) ? 'true' : 'false'"
x-bind:aria-checked="isSelected(@js($value)) ? 'true' : 'false'"
x-bind:disabled="disabled || isOptionDisabled(@js($value))"
x-on:click="select(@js($value))"
@if (filled($option['tooltip'] ?? null))
x-tooltip="{ content: @js($option['tooltip']), theme: $store.theme }"
@endif
>
@if ($option['icon'])
<x-filament::icon :icon="$option['icon']" />
@endif
@if ($isIconOnly)
<span class="sr-only">{{ $option['label'] }}</span>
@elseif ($expandSelectedLabel)
<span
x-show="isSelected(@js($value))"
x-cloak
>{{ $option['label'] }}</span>
@else
<span>{{ $option['label'] }}</span>
@endif
</button>
@endforeach
</div>
</div>
</x-dynamic-component>

View File

@@ -134,7 +134,7 @@
<div class="w-1.5" x-show="index > 0"></div>
<!-- Segment Box -->
<div class="flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs shadow-sm select-none">
<div class="traffic-splitter-segment flex h-full grow items-center justify-center gap-2 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-xs select-none">
<span class="text-xs font-semibold text-neutral-900 dark:text-neutral-100" x-text="index + 1"></span>
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
</div>

View File

@@ -6,7 +6,7 @@
- $destHost string Host of destination URL (for favicon)
- $urlKey string URL key (used for QR download filenames)
- $eid string Unique element ID prefix
- $escapedQrOptions string JSON-encoded & HTML-escaped QR options
- $qrOptions array QR styling options for QRCodeStyling
- $successTitle string Modal heading text
- $successSubtitle string Modal subtitle text
- $successHelper string Modal helper text
@@ -65,7 +65,7 @@
</button>
{{-- QR toggle button --}}
<button id="{{ $eid }}_qr_btn" type="button" data-qr-options="{{ $escapedQrOptions }}"
<button id="{{ $eid }}_qr_btn" type="button" data-qr-options='@json($qrOptions)'
onclick="
event.preventDefault();
event.stopPropagation();

View File

@@ -174,7 +174,7 @@
});
setTimeout(function() {
window.location.replace("{!! addslashes($destination) !!}");
window.location.replace(@json($destination));
}, 250);
</script>
</body>

View File

@@ -0,0 +1,48 @@
@php
$logoPath = function_exists('setting') ? setting('logo_path') : null;
$logoUrl = $logoPath ? \Illuminate\Support\Facades\Storage::disk('public')->url($logoPath) : null;
$siteName = config('filament-short-url.site_name') ?: config('app.name', 'Laravel');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', $siteName)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Bricolage Grotesque', 'sans-serif'],
},
}
}
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body class="bg-[#FCFCFC] dark:bg-[#0C0C0C] min-h-screen font-sans antialiased text-neutral-900 dark:text-white">
<div class="mx-auto w-full max-w-3xl px-6 py-10">
<header class="mb-8 flex flex-col items-center text-center">
@if ($logoUrl)
<img src="{{ $logoUrl }}" alt="{{ $siteName }}" class="mb-4 h-[52px] w-auto object-contain" />
@else
<span class="mb-2 text-2xl font-extrabold tracking-tight">{{ $siteName }}</span>
@endif
@hasSection('subtitle')
<p class="text-sm text-neutral-500 dark:text-neutral-400">@yield('subtitle')</p>
@endif
</header>
@yield('content')
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_password_title'))
@section('content')
<div class="mx-auto flex w-full max-w-[360px] flex-col gap-6">
<div class="text-center">
<p class="text-xl font-medium">{{ __('filament-short-url::default.public_stats_password_title') }}</p>
<p class="mt-1 text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_password_description') }}</p>
</div>
<form method="POST" class="flex flex-col gap-4">
@csrf
<div>
<label for="password" class="mb-1 block text-sm font-medium">{{ __('filament-short-url::default.password_placeholder') }}</label>
<input type="password" name="password" id="password" required autofocus
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3.5 py-2.5 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900">
@if(isset($errors) && $errors->has('password'))
<p class="mt-1 text-sm text-red-600">{{ $errors->first('password') }}</p>
@endif
</div>
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950">
{{ __('filament-short-url::default.public_stats_password_submit') }}
</button>
</form>
</div>
@endsection

View File

@@ -0,0 +1,12 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_rate_limited'))
@section('content')
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center dark:border-amber-900 dark:bg-amber-950/40">
<p class="text-lg font-semibold">{{ __('filament-short-url::default.public_stats_rate_limited') }}</p>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{{ __('filament-short-url::default.public_stats_retry_in', ['seconds' => $retryAfter]) }}
</p>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('filament-short-url::public-stats-layout')
@section('title', __('filament-short-url::default.public_stats_page_title'))
@section('subtitle')
{{ __('filament-short-url::default.public_stats_page_subtitle', ['key' => $shortUrl->url_key]) }}
@endsection
@section('content')
<form method="GET" class="mb-6 flex flex-wrap items-end gap-3 rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<div class="min-w-[140px] flex-1">
<label for="date_from" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_from') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ $dateFrom }}"
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
</div>
<div class="min-w-[140px] flex-1">
<label for="date_to" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_date_to') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ $dateTo }}"
class="w-full rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900">
</div>
<button type="submit" class="rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200">
{{ __('filament-short-url::default.public_stats_apply_filter') }}
</button>
</form>
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
@foreach ([
'totalVisits' => __('filament-short-url::default.public_stats_total_visits'),
'uniqueVisits' => __('filament-short-url::default.public_stats_unique_visits'),
'visitsToday' => __('filament-short-url::default.public_stats_today'),
'visitsThisWeek' => __('filament-short-url::default.public_stats_this_week'),
'visitsThisMonth' => __('filament-short-url::default.public_stats_this_month'),
'qrScans' => __('filament-short-url::default.public_stats_qr_scans'),
] as $metric => $label)
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-500">{{ $label }}</p>
<p class="mt-2 text-2xl font-bold tabular-nums">{{ number_format((int) ($stats[$metric] ?? 0)) }}</p>
</div>
@endforeach
</div>
<div class="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-950">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-neutral-500">{{ __('filament-short-url::default.public_stats_visits_by_day') }}</h2>
@php($visitsByDay = $stats['visitsByDay'] ?? [])
@if (empty($visitsByDay))
<p class="text-sm text-neutral-500">{{ __('filament-short-url::default.public_stats_no_data') }}</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-neutral-200 text-left text-neutral-500 dark:border-neutral-800">
<th class="py-2 pr-4 font-semibold">{{ __('filament-short-url::default.public_stats_day') }}</th>
<th class="py-2 font-semibold">{{ __('filament-short-url::default.public_stats_visits') }}</th>
</tr>
</thead>
<tbody>
@foreach ($visitsByDay as $day => $count)
<tr class="border-b border-neutral-100 dark:border-neutral-900">
<td class="py-2 pr-4">{{ $day }}</td>
<td class="py-2 tabular-nums">{{ number_format((int) $count) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endsection

View File

@@ -3,48 +3,48 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $shortUrl->og_title ?: ($shortUrl->title ?: $shortUrl->url_key) }}</title>
<title>{{ $ogMeta['title'] }}</title>
{{-- Robots Search Indexing configuration --}}
@if($shortUrl->do_index)
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ $ogMeta['canonical_url'] }}">
@else
<meta name="robots" content="noindex, nofollow">
@endif
{{-- Open Graph / Facebook Metadata --}}
<meta property="og:type" content="website">
<meta property="og:url" content="{{ $shortUrl->getShortUrl() }}">
<meta property="og:title" content="{{ $shortUrl->og_title ?: ($shortUrl->title ?: $shortUrl->url_key) }}">
@if($shortUrl->og_description)
<meta property="og:description" content="{{ $shortUrl->og_description }}">
<meta property="og:url" content="{{ $ogMeta['short_url'] }}">
<meta property="og:site_name" content="{{ $ogMeta['site_name'] }}">
<meta property="og:title" content="{{ $ogMeta['title'] }}">
@if($ogMeta['description'])
<meta property="og:description" content="{{ $ogMeta['description'] }}">
@endif
@if($ogMeta['image_url'])
<meta property="og:image" content="{{ $ogMeta['image_url'] }}">
@if($ogMeta['image_width'] && $ogMeta['image_height'])
<meta property="og:image:width" content="{{ $ogMeta['image_width'] }}">
<meta property="og:image:height" content="{{ $ogMeta['image_height'] }}">
@endif
@if($shortUrl->og_image)
@php
$ogImageUrl = $shortUrl->og_image;
if (!str_starts_with($ogImageUrl, 'http')) {
$ogImageUrl = \Illuminate\Support\Facades\Storage::disk('public')->url($ogImageUrl);
}
@endphp
<meta property="og:image" content="{{ $ogImageUrl }}">
@endif
{{-- Twitter Metadata --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="{{ $shortUrl->getShortUrl() }}">
<meta name="twitter:title" content="{{ $shortUrl->og_title ?: ($shortUrl->title ?: $shortUrl->url_key) }}">
@if($shortUrl->og_description)
<meta name="twitter:description" content="{{ $shortUrl->og_description }}">
<meta name="twitter:url" content="{{ $ogMeta['short_url'] }}">
<meta name="twitter:title" content="{{ $ogMeta['title'] }}">
@if($ogMeta['description'])
<meta name="twitter:description" content="{{ $ogMeta['description'] }}">
@endif
@if($shortUrl->og_image)
<meta name="twitter:image" content="{{ $ogImageUrl }}">
@if($ogMeta['image_url'])
<meta name="twitter:image" content="{{ $ogMeta['image_url'] }}">
@endif
{{-- If not cloaked, execute client-side redirect for users --}}
@if(!$shortUrl->is_cloaked)
<meta http-equiv="refresh" content="0;url={!! $destination !!}">
{{-- Human visitors only: bots must stay on this page to read OG tags --}}
@if(!$shortUrl->is_cloaked && !$isBot)
<meta http-equiv="refresh" content="0;url={{ e($destination) }}">
<script>
window.location.replace("{!! addslashes($destination) !!}");
window.location.replace(@json($destination));
</script>
@endif
@@ -79,18 +79,17 @@
@if($shortUrl->is_cloaked)
{{-- Link Cloaking Iframe --}}
<iframe src="{!! $destination !!}" title="{{ $shortUrl->og_title ?: $shortUrl->url_key }}"></iframe>
@else
{{-- Standard JS redirect page fallback --}}
<iframe src="{{ e($destination) }}" title="{{ $ogMeta['title'] }}"></iframe>
@elseif(!$isBot)
{{-- Standard JS redirect page fallback for human visitors --}}
<div class="text-center p-6 max-w-sm flex flex-col items-center gap-4 select-none">
{{-- Animated spinner --}}
<div class="flex items-center justify-center gap-1.5" aria-hidden="true">
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-300 dark:bg-neutral-600 animate-bounce [animation-delay:-0.3s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-400 dark:bg-neutral-500 animate-bounce [animation-delay:-0.15s]"></span>
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
</div>
<p class="text-lg font-medium text-neutral-600">Redirecting you to the destination website...</p>
<p class="text-xs text-neutral-400">If you are not redirected automatically, <a href="{!! $destination !!}" class="text-primary-600 underline font-semibold hover:text-primary-500">click here</a>.</p>
<p class="text-lg font-medium text-neutral-600">{{ __('filament-short-url::default.redirect_html_redirecting') }}</p>
<p class="text-xs text-neutral-400">If you are not redirected automatically, <a href="{{ e($destination) }}" class="text-primary-600 underline font-semibold hover:text-primary-500">click here</a>.</p>
</div>
@endif

View File

@@ -1,34 +1,18 @@
@php
$ogTitle = $get('og_title') ?: null;
$ogDescription = $get('og_description') ?: null;
// Resolve the OG image url
$ogImage = null;
$ogImageState = $get('og_image');
if ($ogImageState) {
if (is_array($ogImageState)) {
$first = reset($ogImageState);
if ($first) {
if (is_string($first)) {
$ogImage = \Illuminate\Support\Facades\Storage::disk('public')->url($first);
} elseif (method_exists($first, 'temporaryUrl')) {
$ogImage = $first->temporaryUrl();
}
}
} elseif (is_string($ogImageState)) {
$ogImage = \Illuminate\Support\Facades\Storage::disk('public')->url($ogImageState);
}
} else {
$ogImage = $get('og_image_scraped') ?: null;
}
$isScraping = $get('is_scraping') ?: false;
$ogTitle = $ogTitle ?? ($get('og_title') ?: null);
$ogDescription = $ogDescription ?? ($get('og_description') ?: null);
$ogImageUrl = $ogImageUrl ?? null;
$isScraping = $isScraping ?? ($get('is_scraping') ?: false);
@endphp
<div
class="sidebar-social-container"
wire:key="fsu-social-preview-{{ md5(json_encode([$ogTitle, $ogDescription, $ogImageUrl, (bool) $isScraping])) }}"
x-data="{ scraping: @js((bool) $isScraping), passwordProtected: @js((bool) $isPasswordProtected) }"
x-on:fsu-scraping-start.window="scraping = true"
x-on:fsu-scraping-end.window="scraping = false"
x-on:fsu-password-protection-changed.window="passwordProtected = !!$event.detail.protected"
x-on:fsu-og-image-updated.window="scraping = false"
>
{{-- Header: Title only (no "i" icon) --}}
@@ -115,9 +99,9 @@
</svg>
</button>
@if ($ogImage)
@if ($ogImageUrl)
{{-- OG Image preview --}}
<img src="{{ $ogImage }}" alt="OG Preview" class="absolute inset-0 w-full h-full object-cover">
<img src="{{ $ogImageUrl }}" alt="OG Preview" class="absolute inset-0 w-full h-full object-cover">
@else
{{-- Empty state: photo icon + text --}}
<div class="flex flex-col items-center gap-2 text-neutral-400 dark:text-neutral-500 select-none pointer-events-none">

View File

@@ -0,0 +1,44 @@
@php
$publicStatsUrl = $record->getPublicStatsUrl();
$copiedMsg = __('filament-short-url::default.public_stats_copied');
$copyBtnText = __('filament-short-url::default.share_copy');
$inputId = 'public_stats_url_'.$record->id;
@endphp
<div class="space-y-2">
@if (! $record->public_stats_enabled)
<p class="text-sm text-amber-600 dark:text-amber-400">
{{ __('filament-short-url::default.public_stats_save_to_activate') }}
</p>
@else
<p class="text-sm text-emerald-600 dark:text-emerald-400">
{{ __('filament-short-url::default.public_stats_enabled_status') }}
</p>
@endif
<div class="flex items-center gap-2">
<input type="text"
readonly
value="{{ e($publicStatsUrl) }}"
id="{{ $inputId }}"
class="min-w-0 flex-1 rounded-lg border border-gray-300 bg-gray-50 px-3.5 py-2.5 text-sm text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-100">
<button type="button"
onclick="
const input = document.getElementById('{{ $inputId }}');
input.select();
navigator.clipboard.writeText(input.value);
if (typeof FilamentNotification !== 'undefined') {
new FilamentNotification().title('{{ e($copiedMsg) }}').success().send();
}
"
class="inline-flex flex-shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-950 dark:hover:bg-white">
{{ $copyBtnText }}
</button>
<a href="{{ $publicStatsUrl }}" target="_blank" rel="noopener noreferrer"
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg border border-gray-300 px-3 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800">
{{ __('filament-short-url::default.public_stats_open') }}
</a>
</div>
</div>

View File

@@ -1,44 +1,10 @@
@php
$rawJson = '{
"event": "visited",
"timestamp": "2026-06-04T12:00:00+02:00",
"short_url": {
"id": 12,
"destination_url": "https://example.com/some-page",
"url_key": "promo26",
"short_url": "https://yoursite.com/s/promo26",
"total_visits": 150,
"unique_visits": 120
},
"visit": {
"id": 345,
"visited_at": "2026-06-04T12:00:00+02:00",
"device_type": "mobile",
"browser": "Chrome",
"browser_version": "120.0",
"operating_system": "Android",
"operating_system_version": "14",
"country": "Poland",
"country_code": "PL",
"city": "Warsaw",
"referer_url": "https://t.co/",
"referer_host": "t.co",
"utm_source": "twitter",
"utm_medium": "social",
"utm_campaign": "summer_sale",
"utm_term": null,
"utm_content": "banner_ad",
"is_qr_scan": false,
"browser_language": "pl"
}
}';
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WebhookPayloadExample;
$rawJson = $rawJson ?? WebhookPayloadExample::visitedEventSampleJson();
@endphp
<div class="space-y-2 mt-4">
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
{{ __('filament-short-url::default.webhook_show_payload') }}
</label>
<div class="marketing-webhook-payload-wrap">
<div
x-data="{
copied: false,
@@ -49,60 +15,22 @@
setTimeout(() => this.copied = false, 2000);
}
}"
style="position: relative; overflow: hidden; border-radius: 1rem; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #18181b; padding: 1.25rem; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);"
class="marketing-webhook-payload-code"
>
<!-- Copy Button in Top Right -->
<button
type="button"
x-on:click="copy"
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
style="position: absolute; top: 1rem; right: 1rem; display: flex; align-items: center; justify-content: center; height: 2rem; width: 2rem; border-radius: 0.5rem; background-color: rgba(255, 255, 255, 0.08); color: #a1a1aa; border: none; cursor: pointer; transition: all 0.2s;"
title="Copy payload to clipboard"
class="marketing-webhook-payload-copy"
title="{{ __('filament-short-url::default.webhook_payload_copy') }}"
>
<!-- Copy Icon -->
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
<!-- Check Icon -->
<svg x-show="copied" x-cloak style="height: 1rem; width: 1rem; color: #34d399;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Syntax Highlighted Payload -->
<pre style="margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: #d4d4d8; overflow-x: auto; white-space: pre-wrap; word-break: break-all; padding-right: 2.5rem;"><code style="font-family: inherit; font-size: inherit; color: inherit;">{
<span style="color: #f43f5e;">"event"</span>: <span style="color: #eab308;">"visited"</span>,
<span style="color: #f43f5e;">"timestamp"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"short_url"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">12</span>,
<span style="color: #f43f5e;">"destination_url"</span>: <span style="color: #eab308;">"https://example.com/some-page"</span>,
<span style="color: #f43f5e;">"url_key"</span>: <span style="color: #eab308;">"promo26"</span>,
<span style="color: #f43f5e;">"short_url"</span>: <span style="color: #eab308;">"https://yoursite.com/s/promo26"</span>,
<span style="color: #f43f5e;">"total_visits"</span>: <span style="color: #c084fc;">150</span>,
<span style="color: #f43f5e;">"unique_visits"</span>: <span style="color: #c084fc;">120</span>
},
<span style="color: #f43f5e;">"visit"</span>: {
<span style="color: #f43f5e;">"id"</span>: <span style="color: #c084fc;">345</span>,
<span style="color: #f43f5e;">"visited_at"</span>: <span style="color: #eab308;">"2026-06-04T12:00:00+02:00"</span>,
<span style="color: #f43f5e;">"device_type"</span>: <span style="color: #eab308;">"mobile"</span>,
<span style="color: #f43f5e;">"browser"</span>: <span style="color: #eab308;">"Chrome"</span>,
<span style="color: #f43f5e;">"browser_version"</span>: <span style="color: #eab308;">"120.0"</span>,
<span style="color: #f43f5e;">"operating_system"</span>: <span style="color: #eab308;">"Android"</span>,
<span style="color: #f43f5e;">"operating_system_version"</span>: <span style="color: #eab308;">"14"</span>,
<span style="color: #f43f5e;">"country"</span>: <span style="color: #eab308;">"Poland"</span>,
<span style="color: #f43f5e;">"country_code"</span>: <span style="color: #eab308;">"PL"</span>,
<span style="color: #f43f5e;">"city"</span>: <span style="color: #eab308;">"Warsaw"</span>,
<span style="color: #f43f5e;">"referer_url"</span>: <span style="color: #eab308;">"https://t.co/"</span>,
<span style="color: #f43f5e;">"referer_host"</span>: <span style="color: #eab308;">"t.co"</span>,
<span style="color: #f43f5e;">"utm_source"</span>: <span style="color: #eab308;">"twitter"</span>,
<span style="color: #f43f5e;">"utm_medium"</span>: <span style="color: #eab308;">"social"</span>,
<span style="color: #f43f5e;">"utm_campaign"</span>: <span style="color: #eab308;">"summer_sale"</span>,
<span style="color: #f43f5e;">"utm_term"</span>: <span style="color: #60a5fa;">null</span>,
<span style="color: #f43f5e;">"utm_content"</span>: <span style="color: #eab308;">"banner_ad"</span>,
<span style="color: #f43f5e;">"is_qr_scan"</span>: <span style="color: #60a5fa;">false</span>,
<span style="color: #f43f5e;">"browser_language"</span>: <span style="color: #eab308;">"pl"</span>
}
}</code></pre>
<pre class="marketing-webhook-payload-pre"><code class="marketing-webhook-payload-code-inner">{{ $rawJson }}</code></pre>
</div>
</div>

View File

@@ -46,7 +46,7 @@
default => ucfirst($device),
};
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: '{{ addslashes($device) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'device_type', value: @json($device) })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if (strtolower($device) === 'desktop')
@@ -84,7 +84,7 @@
<div class="space-y-3.5">
@forelse ($visitsByBrowser as $browser => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: '{{ addslashes($browser) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser', value: @json($browser) })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<x-filament::icon :icon="$browserIcons[$browser] ?? 'heroicon-m-globe-alt'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
@@ -124,7 +124,7 @@
<div class="space-y-3.5">
@forelse ($visitsByOs as $os => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: '{{ addslashes($os) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'operating_system', value: @json($os) })">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />

View File

@@ -44,7 +44,7 @@
$translatedCountry = strtoupper($code);
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: '{{ addslashes($code) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'country_code', value: @json($code) })">
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
@if ($code)
@@ -76,7 +76,7 @@
<div class="space-y-3.5">
@forelse ($visitsByCity as $city => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: '{{ addslashes($city) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'city', value: @json($city) })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $city }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
@@ -106,7 +106,7 @@
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: '{{ addslashes($langCode) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'browser_language', value: @json($langCode) })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $langName }} ({{ strtoupper($langCode) }})</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>

View File

@@ -1,12 +1,53 @@
<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.
Server-Sent Events push visit cursor updates to the browser.
onStreamUpdate() advances latestVisitId and triggers a render only when needed.
--}}
<div wire:poll.5s.visible="checkForUpdates"
<div
x-data="{
source: null,
cursor: @js(max($latestVisitId, 0)),
reconnectTimer: null,
connect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.source?.close();
const url = @js(route('short-url.live-feed.stream', ['shortUrl' => $record->id]))
+ '&cursor=' + encodeURIComponent(this.cursor);
this.source = new EventSource(url);
this.source.addEventListener('update', (event) => {
const payload = JSON.parse(event.data);
if (payload.latest_id !== undefined) {
this.cursor = payload.latest_id;
$wire.onStreamUpdate(payload.latest_id);
}
});
this.source.onerror = () => {
this.source?.close();
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
};
},
init() {
this.connect();
},
destroy() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.source?.close();
},
}"
x-init="init()"
@disconnect.window="destroy()"
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
<!-- Header -->
@@ -21,7 +62,11 @@
</h3>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
{{ __('filament-short-url::default.stats_live_feed_poll_interval') }}
@if ($usesRedisPush ?? false)
{{ __('filament-short-url::default.stats_live_feed_mode_redis') }}
@else
{{ __('filament-short-url::default.stats_live_feed_mode_poll') }}
@endif
</span>
</div>

View File

@@ -63,7 +63,7 @@
<div class="space-y-3.5">
@forelse ($visitsByReferer as $referer => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: '{{ addslashes($referer) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'referer_host', value: @json($referer) })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $referer }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span></span>
@@ -94,7 +94,7 @@
<div class="space-y-3.5 mt-2">
@forelse ($utmSources as $source => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: '{{ addslashes($source) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_source', value: @json($source) })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $source }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
@@ -115,7 +115,7 @@
<div class="space-y-3.5 mt-2">
@forelse ($utmMediums as $medium => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: '{{ addslashes($medium) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_medium', value: @json($medium) })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $medium }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
@@ -136,7 +136,7 @@
<div class="space-y-3.5 mt-2">
@forelse ($utmCampaigns as $campaign => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: '{{ addslashes($campaign) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_campaign', value: @json($campaign) })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $campaign }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
@@ -157,7 +157,7 @@
<div class="space-y-3.5 mt-2">
@forelse ($utmTerms as $term => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: '{{ addslashes($term) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_term', value: @json($term) })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $term }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
@@ -178,7 +178,7 @@
<div class="space-y-3.5 mt-2">
@forelse ($utmContents as $content => $count)
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: '{{ addslashes($content) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1 rounded-lg transition-colors flex items-center justify-between text-sm" x-on:click="$wire.dispatch('set-stats-filter', { key: 'utm_content', value: @json($content) })">
<span class="font-medium text-gray-700 dark:text-gray-300 truncate mr-2">{{ $content }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white shrink-0">
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>

View File

@@ -18,7 +18,7 @@
}
}
@endphp
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: '{{ addslashes($variant) }}' })">
<div class="group cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 -mx-2 px-2 py-1.5 rounded-lg transition-colors" x-on:click="$wire.dispatch('set-stats-filter', { key: 'selected_variant', value: @json($variant) })">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ $variant }}</span>
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">

View File

@@ -1,9 +1,16 @@
<?php
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlFolderApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLiveFeedStreamController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlLogoController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlPublicStatsController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlTagApiController;
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlUtilityController;
use Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
@@ -31,15 +38,45 @@ Route::match(
->where('key', '[a-zA-Z0-9_-]+')
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
Route::match(
['GET', 'POST'],
config('filament-short-url.route_prefix', 's').'/public-stats/{key}',
[ShortUrlPublicStatsController::class, 'show']
)
->name('short-url.public-stats')
->where('key', '[a-zA-Z0-9_-]+')
->middleware(array_merge(['web'], ['throttle:60,1']));
Route::match(['GET', 'POST'], 'short-url/public-stats/{key}', [ShortUrlPublicStatsController::class, 'show'])
->where('key', '[a-zA-Z0-9_-]+')
->middleware(array_merge(['web'], ['throttle:60,1']));
Route::prefix('api/short-url')
->middleware([
AuthenticateShortUrlApi::class,
])
->group(function () {
Route::get('links/exists', [ShortUrlApiController::class, 'exists']);
Route::get('links/random', [ShortUrlApiController::class, 'random']);
Route::get('links/info', [ShortUrlApiController::class, 'info']);
Route::put('links/upsert', [ShortUrlApiController::class, 'upsert']);
Route::post('links/bulk', [ShortUrlApiController::class, 'bulkStore']);
Route::post('links/bulk-delete', [ShortUrlApiController::class, 'bulkDestroy']);
Route::patch('links/bulk-update', [ShortUrlApiController::class, 'bulkUpdate']);
Route::get('tags', [ShortUrlTagApiController::class, 'index']);
Route::post('tags', [ShortUrlTagApiController::class, 'store']);
Route::match(['PUT', 'PATCH'], 'tags/{id}', [ShortUrlTagApiController::class, 'update']);
Route::delete('tags/{id}', [ShortUrlTagApiController::class, 'destroy']);
Route::get('folders', [ShortUrlFolderApiController::class, 'index']);
Route::post('folders', [ShortUrlFolderApiController::class, 'store']);
Route::match(['PUT', 'PATCH'], 'folders/{id}', [ShortUrlFolderApiController::class, 'update']);
Route::delete('folders/{id}', [ShortUrlFolderApiController::class, 'destroy']);
Route::get('links', [ShortUrlApiController::class, 'index']);
Route::post('links', [ShortUrlApiController::class, 'store']);
Route::get('links/{idOrKey}/visits/export', [ShortUrlApiController::class, 'exportVisits']);
Route::get('links/{idOrKey}', [ShortUrlApiController::class, 'show']);
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
Route::get('links/{idOrKey}/visits', [ShortUrlApiController::class, 'visits']);
Route::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
});
@@ -47,9 +84,28 @@ Route::prefix('api/short-url')
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
->name('short-url.logo');
Route::middleware(['web'])
->post('short-url/log-error', function (\Illuminate\Http\Request $request) {
\Illuminate\Support\Facades\Log::error('Client JS Error: ' . $request->input('message') . ' in ' . $request->input('file') . ' on line ' . $request->input('line') . ' col ' . $request->input('col') . ' stack: ' . $request->input('stack'));
Route::middleware(['web', 'auth', 'throttle:60,1'])
->get('short-url/live-feed/{shortUrl}/stream', ShortUrlLiveFeedStreamController::class)
->name('short-url.live-feed.stream');
Route::middleware(['web', 'auth', 'throttle:30,1'])
->post('short-url/log-error', function (Request $request) {
$validated = $request->validate([
'message' => 'required|string|max:2000',
'file' => 'nullable|string|max:500',
'line' => 'nullable|integer|min:0',
'col' => 'nullable|integer|min:0',
'stack' => 'nullable|string|max:8000',
]);
Log::error('[FilamentShortUrl JS] '.$validated['message'], [
'file' => $validated['file'] ?? null,
'line' => $validated['line'] ?? null,
'col' => $validated['col'] ?? null,
'stack' => $validated['stack'] ?? null,
'user_id' => auth()->id(),
]);
return response()->json(['status' => 'ok']);
})
->name('short-url.log-error');
@@ -58,6 +114,10 @@ Route::middleware(['web', 'auth'])
->get('short-url/scrape-meta', [ShortUrlRedirectController::class, 'scrapeMeta'])
->name('short-url.scrape-meta');
Route::middleware(['web', 'auth'])
->post('short-url/check-iframeable', [ShortUrlUtilityController::class, 'checkIframeable'])
->name('short-url.check-iframeable');
if (config('filament-short-url.enable_fallback_route', true)) {
Route::fallback(ShortUrlRedirectController::class)
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));

View File

@@ -0,0 +1,92 @@
/**
* Baseline HTTP load test for the short URL redirect hot path.
*
* Requires k6: https://grafana.com/docs/k6/latest/set-up/install-k6/
*
* Example (Herd / local app):
* k6 run scripts/k6/redirect-baseline.js \
* -e BASE_URL=https://wyachts-super-app.test \
* -e URL_KEY=bench-key \
* -e VUS=20 \
* -e DURATION=1m
*
* Env:
* BASE_URL — App origin (required), e.g. https://your-app.test
* URL_KEY — Existing link key (required)
* ROUTE_PREFIX — Default: s (from SHORT_URL_ROUTE_PREFIX)
* VUS — Virtual users, default 10
* DURATION — Scenario duration, default 30s
* P95_MS — p95 threshold in ms, default 500
* SLEEP_MS — Optional pause between iterations per VU
*
* Tip: create a dedicated link with track_visits=false for a cleaner redirect benchmark.
*/
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const redirectDuration = new Trend('redirect_duration', true);
const redirectFailures = new Rate('redirect_failures');
const p95ThresholdMs = Number(__ENV.P95_MS || 500);
export const options = {
scenarios: {
redirect_baseline: {
executor: 'constant-vus',
vus: Number(__ENV.VUS || 10),
duration: __ENV.DURATION || '30s',
},
},
thresholds: {
redirect_failures: ['rate<0.05'],
redirect_duration: [`p(95)<${p95ThresholdMs}`],
http_req_failed: ['rate<0.05'],
},
};
export function setup() {
const baseUrl = (__ENV.BASE_URL || '').replace(/\/$/, '');
const routePrefix = __ENV.ROUTE_PREFIX || 's';
const urlKey = __ENV.URL_KEY || '';
if (!baseUrl) {
throw new Error('BASE_URL is required (e.g. https://your-app.test)');
}
if (!urlKey) {
throw new Error('URL_KEY is required — use an existing short link key');
}
const url = `${baseUrl}/${routePrefix}/${urlKey}`;
const probe = http.get(url, { redirects: 0 });
if (![301, 302, 307, 308].includes(probe.status)) {
throw new Error(
`Setup probe failed: GET ${url} returned ${probe.status} (expected 301/302). ` +
'Create the link first or disable password/max_visits for the benchmark key.',
);
}
return { url };
}
export default function (data) {
const res = http.get(data.url, {
redirects: 0,
tags: { name: 'short_url_redirect' },
});
const ok = check(res, {
'redirect status 301/302/307/308': (r) => [301, 302, 307, 308].includes(r.status),
});
redirectDuration.add(res.timings.duration);
redirectFailures.add(!ok);
if (__ENV.SLEEP_MS) {
sleep(Number(__ENV.SLEEP_MS) / 1000);
}
}

View File

@@ -2,9 +2,12 @@
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
use Bjanczak\FilamentShortUrl\Services\Stats\CrossDimensionalStatsEngine;
use Bjanczak\FilamentShortUrl\Services\StatsSqlHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -27,20 +30,52 @@ class AggregateAndPruneVisitsCommand extends Command
default => 'DATE(visited_at)',
};
// 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();
$lastAggregationDate = DB::table('short_url_settings')
->where('key', 'last_aggregation_date')
->value('value');
$candidateDates = DB::table('short_url_visits')
->where('visited_at', '<', $today)
->where('is_bot', false)
->where('is_proxy', false)
->when($lastAggregationDate, fn ($query) => $query->where('visited_at', '>', Carbon::parse($lastAggregationDate)->endOfDay()))
->selectRaw("{$dateExpression} as aggregate_date")
->distinct()
->orderBy('aggregate_date')
->pluck('aggregate_date')
->map(function ($value): string {
return Carbon::parse($value)->toDateString();
})
->all();
$dates = [];
$current = $startCarbon->copy();
while ($current->lte($endCarbon)) {
$dates[] = $current->toDateString();
$current->addDay();
foreach ($candidateDates as $date) {
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
$rawCount = (int) DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->where('is_bot', false)
->where('is_proxy', false)
->count();
$dailyCountQuery = DB::table('short_url_daily_stats');
StatsSqlHelper::applyDailyStatsDateEquals($dailyCountQuery, $date);
$dailyCount = (int) $dailyCountQuery->sum('visits_count');
$missingCrossRollupsQuery = DB::table('short_url_daily_stats');
StatsSqlHelper::applyDailyStatsDateEquals($missingCrossRollupsQuery, $date);
$missingCrossRollups = $missingCrossRollupsQuery
->where(function ($query): void {
$query->whereNull('cross_dimensional_stats')
->orWhereNull('cross_filter_pairs');
})
->exists();
if ($dailyCount === 0 || $rawCount !== $dailyCount || $missingCrossRollups) {
$dates[] = $date;
}
}
@@ -49,23 +84,31 @@ class AggregateAndPruneVisitsCommand extends Command
} else {
$this->info('Found '.count($dates).' days to aggregate.');
$maxAggregatedDate = null;
$affectedShortUrlIds = [];
foreach ($dates as $date) {
// Wrap date aggregation in a database transaction for data integrity
DB::transaction(function () use ($date): void {
DB::transaction(function () use ($date, &$affectedShortUrlIds): void {
$nextDate = Carbon::parse($date)->addDay()->toDateString();
$start = $date.' 00:00:00';
$end = $nextDate.' 00:00:00';
// Driver-aware boolean count: MySQL/SQLite store booleans as TINYINT (= 1),
// PostgreSQL uses a native boolean type (cast to int for aggregation).
$driver = DB::connection()->getDriverName();
$qrExpr = $driver === 'pgsql'
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
: 'count(case when is_qr_scan = 1 then 1 end) as qr_scans';
$botExpr = $driver === 'pgsql'
? 'count(case when is_bot::int = 1 then 1 end) as bot_visits'
: 'count(case when is_bot = 1 then 1 end) as bot_visits';
$proxyExpr = $driver === 'pgsql'
? 'count(case when is_proxy::int = 1 then 1 end) as proxy_visits'
: 'count(case when is_proxy = 1 then 1 end) as proxy_visits';
$totals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->where('is_bot', false)
->where('is_proxy', false)
->select([
'short_url_id',
DB::raw('count(*) as total'),
@@ -75,124 +118,172 @@ class AggregateAndPruneVisitsCommand extends Command
->groupBy('short_url_id')
->get();
if ($totals->isEmpty()) {
$securityTotals = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->select([
'short_url_id',
DB::raw('count(*) as all_visits'),
DB::raw($botExpr),
DB::raw($proxyExpr),
])
->groupBy('short_url_id')
->get()
->keyBy('short_url_id');
if ($totals->isEmpty() && $securityTotals->isEmpty()) {
return;
}
$statsByUrl = [];
foreach ($totals as $row) {
$statsByUrl[$row->short_url_id] = [
$statsByUrl[$row->short_url_id] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
'total' => (int) $row->total,
'uniques' => (int) $row->uniques,
'qr_scans' => (int) $row->qr_scans,
'device_stats' => [],
'browser_stats' => [],
'os_stats' => [],
'country_stats' => [],
'city_stats' => [],
'referer_stats' => [],
'utm_source_stats' => [],
'utm_medium_stats' => [],
'utm_campaign_stats' => [],
'language_stats' => [],
'variant_stats' => [],
];
'all_visits' => 0,
'bot_visits' => 0,
'proxy_visits' => 0,
]);
}
// Helper to fetch and populate category stats natively in database GROUP BY
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
$urlIds = array_keys($statsByUrl);
if (empty($urlIds)) {
return;
}
$query = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->whereIn('short_url_id', $urlIds)
->whereNotNull($column)
->where($column, '<>', '');
if ($statsKey === 'city_stats') {
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
->groupBy(['short_url_id', 'city', 'country_code']);
} else {
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
->groupBy(['short_url_id', $column]);
}
$rows = $query->get();
foreach ($rows as $row) {
$urlId = $row->short_url_id;
foreach ($securityTotals as $urlId => $secRow) {
if (! isset($statsByUrl[$urlId])) {
$statsByUrl[$urlId] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
'total' => 0,
'uniques' => 0,
'qr_scans' => 0,
'all_visits' => (int) $secRow->all_visits,
'bot_visits' => (int) $secRow->bot_visits,
'proxy_visits' => (int) $secRow->proxy_visits,
]);
continue;
}
if ($statsKey === 'city_stats') {
$cityVal = $row->city;
$countryCode = $row->country_code;
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
$statsByUrl[$urlId]['all_visits'] = (int) $secRow->all_visits;
$statsByUrl[$urlId]['bot_visits'] = (int) $secRow->bot_visits;
$statsByUrl[$urlId]['proxy_visits'] = (int) $secRow->proxy_visits;
}
$cursor = DB::table('short_url_visits')
->where('visited_at', '>=', $start)
->where('visited_at', '<', $end)
->where('is_bot', false)
->where('is_proxy', false)
->select([
'short_url_id',
'country_code',
'city',
'device_type',
'browser',
'browser_version',
'operating_system',
'operating_system_version',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'browser_language',
'selected_variant',
'is_qr_scan',
])
->orderBy('id')
->cursor();
foreach ($cursor as $row) {
$urlId = $row->short_url_id;
if (! isset($statsByUrl[$urlId])) {
$statsByUrl[$urlId] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
'total' => 0,
'uniques' => 0,
'qr_scans' => 0,
'all_visits' => 0,
'bot_visits' => 0,
'proxy_visits' => 0,
]);
}
CrossDimensionalStatsEngine::accumulateHumanVisit($row, $statsByUrl[$urlId]);
}
foreach ($statsByUrl as $urlId => $bucket) {
$exported = CrossDimensionalStatsEngine::exportForPersistence($bucket);
$payload = [
'visits_count' => $bucket['total'],
'unique_visits_count' => $bucket['uniques'],
'all_visits_count' => $bucket['all_visits'],
'bot_visits_count' => $bucket['bot_visits'],
'proxy_visits_count' => $bucket['proxy_visits'],
'qr_visits_count' => $bucket['qr_scans'],
...$exported,
];
$existingDaily = ShortUrlDailyStats::query()
->where('short_url_id', $urlId);
StatsSqlHelper::applyDailyStatsDateEquals($existingDaily, $date);
$existingDaily = $existingDaily->first();
if ($existingDaily !== null) {
$existingDaily->update($payload);
} else {
$val = $row->$column;
}
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
}
};
// Populate all categories via 11 quick indexed database aggregations
$populateStats('device_type', 'device_stats');
$populateStats('browser', 'browser_stats');
$populateStats('operating_system', 'os_stats');
$populateStats('country_code', 'country_stats');
$populateStats('city', 'city_stats');
$populateStats('referer_host', 'referer_stats');
$populateStats('utm_source', 'utm_source_stats');
$populateStats('utm_medium', 'utm_medium_stats');
$populateStats('utm_campaign', 'utm_campaign_stats');
$populateStats('browser_language', 'language_stats');
$populateStats('selected_variant', 'variant_stats');
// Write aggregated stats to ShortUrlDailyStats
foreach ($statsByUrl as $urlId => $s) {
ShortUrlDailyStats::updateOrCreate([
ShortUrlDailyStats::query()->create([
'short_url_id' => $urlId,
'date' => $date,
], [
'visits_count' => $s['total'],
'unique_visits_count' => $s['uniques'],
'device_stats' => $s['device_stats'],
'browser_stats' => $s['browser_stats'],
'os_stats' => $s['os_stats'],
'country_stats' => $s['country_stats'],
'city_stats' => $s['city_stats'],
'referer_stats' => $s['referer_stats'],
'utm_source_stats' => $s['utm_source_stats'],
'utm_medium_stats' => $s['utm_medium_stats'],
'utm_campaign_stats' => $s['utm_campaign_stats'],
'qr_visits_count' => $s['qr_scans'],
'language_stats' => $s['language_stats'],
'variant_stats' => $s['variant_stats'],
...$payload,
]);
}
$affectedShortUrlIds[] = (int) $urlId;
}
});
$this->info("Aggregated stats for {$date}.");
$maxAggregatedDate = $date;
}
if ($maxAggregatedDate !== null) {
DB::table('short_url_settings')->updateOrInsert(
['key' => 'last_aggregation_date'],
[
'value' => $maxAggregatedDate,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$affectedShortUrlIds = array_values(array_unique($affectedShortUrlIds));
if (! empty($affectedShortUrlIds)) {
ShortUrl::whereIn('id', $affectedShortUrlIds)
->get()
->each(function (ShortUrl $shortUrl): void {
$shortUrl->clearStatsCache();
});
}
}
// 2. Prune old visits if enabled
if (config('filament-short-url.pruning.enabled', true)) {
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
$deleted = ShortUrlVisit::where('visited_at', '<', $cutoff)->delete();
$deleted = 0;
do {
$chunkDeleted = ShortUrlVisit::where('visited_at', '<', $cutoff)
->orderBy('id')
->limit(5000)
->delete();
$deleted += $chunkDeleted;
} while ($chunkDeleted > 0);
$this->info("Successfully pruned {$deleted} raw visit records older than {$retentionDays} days.");
}
// 3. Prune old temporary uploads (older than 24 hours) by hour bucket
$prunedCount = app(ShortUrlTempStorage::class)->pruneBucketsOlderThanHours(24);
if ($prunedCount > 0) {

View File

@@ -0,0 +1,83 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Console\Command;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
class StressRedirectCommand extends Command
{
/** @var string */
protected $signature = 'short-url:stress-redirect
{key : The short URL key to hit}
{--requests=50 : Number of in-process redirect requests}
{--warmup=0 : Warmup requests excluded from stats}';
/** @var string */
protected $description = 'Baseline redirect hot-path timing (in-process, no external HTTP)';
public function handle(Kernel $kernel): int
{
$key = (string) $this->argument('key');
$totalRequests = max(1, (int) $this->option('requests'));
$warmupRequests = max(0, min($totalRequests, (int) $this->option('warmup')));
if (! ShortUrl::query()->where('url_key', $key)->exists()) {
$this->error("Short URL key [{$key}] was not found.");
return self::FAILURE;
}
$prefix = config('filament-short-url.route_prefix', 's');
$path = '/'.$prefix.'/'.$key;
$durations = [];
for ($i = 0; $i < $totalRequests; $i++) {
$startedAt = microtime(true);
$request = Request::create($path, 'GET');
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
$elapsedMs = (microtime(true) - $startedAt) * 1000;
if ($i >= $warmupRequests) {
$durations[] = $elapsedMs;
}
}
sort($durations);
$count = count($durations);
$sum = array_sum($durations);
$avg = $count > 0 ? $sum / $count : 0.0;
$min = $count > 0 ? $durations[0] : 0.0;
$max = $count > 0 ? $durations[$count - 1] : 0.0;
$p95Index = $count > 0 ? (int) floor(($count - 1) * 0.95) : 0;
$p95 = $count > 0 ? $durations[$p95Index] : 0.0;
$this->table(
['Metric', 'Value'],
[
['Key', $key],
['Measured requests', (string) $count],
['Warmup skipped', (string) $warmupRequests],
['Avg (ms)', number_format($avg, 2)],
['Min (ms)', number_format($min, 2)],
['P95 (ms)', number_format($p95, 2)],
['Max (ms)', number_format($max, 2)],
]
);
$this->comment('In-process baseline only. For HTTP concurrency use: k6 run scripts/k6/redirect-baseline.js -e BASE_URL=... -e URL_KEY='.$key);
return self::SUCCESS;
}
}

View File

@@ -3,10 +3,11 @@
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Cache\RedisStore;
use Bjanczak\FilamentShortUrl\Services\VisitCounterBuffer;
use Bjanczak\FilamentShortUrl\Support\ShortUrlCacheInvalidator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Throwable;
class SyncBufferedCountersCommand extends Command
{
@@ -16,36 +17,10 @@ class SyncBufferedCountersCommand extends Command
/** @var string */
protected $description = 'Sync buffered short URL visit counters from cache to the database';
public function handle(): int
public function handle(VisitCounterBuffer $buffer): int
{
$prefix = config('filament-short-url.counter_buffering.cache_key_prefix', 'filament-short-url:buffer:');
$dirtyKey = "{$prefix}dirty_ids";
// Atomically pull the dirty-ID list so incoming increments during this command
// are written to a fresh list rather than being lost. Strategy is driver-aware:
// Redis uses RENAME + SMEMBERS (O(N)) for true atomicity; all other stores use
// Cache::pull() which is atomic on most drivers (file, database, memcached).
$store = Cache::store()->getStore();
$isRedis = $store instanceof RedisStore;
if ($isRedis) {
$tempKey = "{$dirtyKey}:temp:".time();
try {
$conn = $store->connection();
if ($conn->exists($dirtyKey)) {
$conn->rename($dirtyKey, $tempKey);
$rawIds = $conn->smembers($tempKey);
$conn->del($tempKey);
$dirtyIds = $rawIds ?: [];
} else {
$dirtyIds = [];
}
} catch (\Throwable) {
$dirtyIds = [];
}
} else {
$dirtyIds = Cache::pull($dirtyKey, []);
}
$pull = $buffer->pullDirtyIdsForSync();
$dirtyIds = $pull['ids'];
if (empty($dirtyIds)) {
$this->info('No buffered counters to synchronize.');
@@ -53,25 +28,14 @@ class SyncBufferedCountersCommand extends Command
return 0;
}
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
$processed = 0;
$updatesToMake = [];
foreach ($dirtyIds as $id) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$qrKey = "{$prefix}qr:{$id}";
$deltas = $buffer->pullDeltas((int) $id);
$totalDelta = (int) Cache::pull($totalKey, 0);
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
$qrDelta = (int) Cache::pull($qrKey, 0);
if ($totalDelta > 0 || $uniqueDelta > 0 || $qrDelta > 0) {
$updatesToMake[$id] = [
'total' => $totalDelta,
'unique' => $uniqueDelta,
'qr' => $qrDelta,
];
if ($deltas['total'] > 0 || $deltas['unique'] > 0 || $deltas['qr'] > 0) {
$updatesToMake[$id] = $deltas;
}
}
@@ -87,8 +51,6 @@ class SyncBufferedCountersCommand extends Command
->with('customDomain')
->get(['id', 'url_key', 'custom_domain_id']);
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
foreach ($updatesToMake as $id => $deltas) {
ShortUrl::where('id', $id)->update([
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
@@ -98,59 +60,26 @@ class SyncBufferedCountersCommand extends Command
$processed++;
}
// Bust redirect cache for all host-variant keys so subsequent requests
// see fresh counters (for max_visits enforcement etc.)
foreach ($shortUrls as $url) {
$hostsToForget = array_unique(array_filter([
'default',
$appHost,
$url->customDomain?->domain,
]));
foreach ($hostsToForget as $host) {
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
}
ShortUrlCacheInvalidator::forget($url);
}
});
} catch (\Throwable $e) {
// DB transaction failed — restore pulled cache values so no clicks are lost.
// Increments are used (not set) to safely merge with any new clicks that
// arrived during the failed transaction window.
foreach ($updatesToMake as $id => $deltas) {
$totalKey = "{$prefix}total:{$id}";
$uniqueKey = "{$prefix}unique:{$id}";
$qrKey = "{$prefix}qr:{$id}";
if ($deltas['total'] > 0) {
Cache::increment($totalKey, $deltas['total']);
}
if ($deltas['unique'] > 0) {
Cache::increment($uniqueKey, $deltas['unique']);
}
if ($deltas['qr'] > 0) {
Cache::increment($qrKey, $deltas['qr']);
}
}
// Restore dirty IDs using the same driver-aware strategy
if ($isRedis) {
$conn = $store->connection();
$conn->sadd($dirtyKey, ...array_keys($updatesToMake));
} else {
$lock = Cache::lock("{$prefix}dirty_ids_lock", 2);
$lock->get(function () use ($prefix, $updatesToMake) {
$cachedDirty = Cache::get("{$prefix}dirty_ids", []);
if (! is_array($cachedDirty)) {
$cachedDirty = [];
}
$merged = array_unique(array_merge($cachedDirty, array_keys($updatesToMake)));
Cache::forever("{$prefix}dirty_ids", $merged);
});
}
} catch (Throwable $e) {
$buffer->restoreDeltasAfterFailedSync($updatesToMake);
$buffer->restoreDirtyIds(
array_keys($updatesToMake),
$pull['connection'],
$pull['prefixedDirtyKey'],
$pull['requeueKey'],
);
throw $e;
}
if ($pull['requeue'] && $pull['requeueKey'] && $pull['connection']) {
$pull['connection']->del($pull['requeueKey']);
}
$this->info("Successfully synchronized counters for {$processed} short URLs.");
return 0;

View File

@@ -0,0 +1,55 @@
<?php
namespace Bjanczak\FilamentShortUrl\Console\Commands;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Illuminate\Console\Command;
class VerifyCustomDomainsCommand extends Command
{
/** @var string */
protected $signature = 'short-url:verify-custom-domains';
/** @var string */
protected $description = 'Re-verify DNS for active custom domains';
public function handle(): int
{
$activeDomains = ShortUrlCustomDomain::query()
->where('is_active', true)
->get();
if ($activeDomains->isEmpty()) {
$this->info('No active custom domains to verify.');
return self::SUCCESS;
}
$verified = 0;
$failed = 0;
foreach ($activeDomains as $domain) {
$isValid = false;
try {
$isValid = $domain->verifyDns();
} catch (\Throwable) {
$isValid = false;
}
if (! $isValid && $domain->is_verified) {
$domain->update(['is_verified' => false]);
}
if ($isValid) {
$verified++;
} else {
$failed++;
}
}
$this->info("DNS verification finished. Verified: {$verified}, failed: {$failed}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,194 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
use Closure;
use Filament\Forms\Components\Contracts\CanHaveNumericState;
use Filament\Forms\Components\Field;
use Filament\Schemas\Components\StateCasts\Contracts\StateCast;
use Filament\Schemas\Components\StateCasts\NumberStateCast;
class NumberStepper extends Field implements CanHaveNumericState
{
protected string $view = 'filament-short-url::forms.components.number-stepper';
/**
* @var scalar | Closure | null
*/
protected $minValue = null;
/**
* @var scalar | Closure | null
*/
protected $maxValue = null;
protected int|float|Closure $step = 1;
protected bool|Closure $isInteger = true;
protected bool|Closure $isNullable = false;
protected string|Closure $variant = 'primary';
protected string|Closure $size = 'md';
protected string|Closure|null $displaySuffix = null;
protected string|Closure|null $nullLabel = null;
/**
* @param scalar | Closure | null $value
*/
public function minValue($value): static
{
$this->minValue = $value;
$this->rule(static function (NumberStepper $component): string {
$value = $component->getMinValue();
return "min:{$value}";
}, static fn (NumberStepper $component): bool => filled($component->getMinValue()) && ! $component->isNullable());
return $this;
}
/**
* @param scalar | Closure | null $value
*/
public function maxValue($value): static
{
$this->maxValue = $value;
$this->rule(static function (NumberStepper $component): string {
$value = $component->getMaxValue();
return "max:{$value}";
}, static fn (NumberStepper $component): bool => filled($component->getMaxValue()));
return $this;
}
public function step(int|float|Closure $step): static
{
$this->step = $step;
return $this;
}
public function integer(bool|Closure $condition = true): static
{
$this->isInteger = $condition;
$this->rule('integer', $condition);
return $this;
}
public function nullable(bool|Closure $condition = true): static
{
$this->isNullable = $condition;
return $this;
}
public function variant(string|Closure $variant): static
{
$this->variant = $variant;
return $this;
}
public function size(string|Closure $size): static
{
$this->size = $size;
return $this;
}
public function suffix(string|Closure|null $suffix): static
{
$this->displaySuffix = $suffix;
return $this;
}
public function nullLabel(string|Closure|null $label): static
{
$this->nullLabel = $label;
return $this;
}
/**
* @return scalar | null
*/
public function getMinValue(): mixed
{
return $this->evaluate($this->minValue);
}
/**
* @return scalar | null
*/
public function getMaxValue(): mixed
{
return $this->evaluate($this->maxValue);
}
public function getStep(): int|float
{
return $this->evaluate($this->step);
}
public function isInteger(): bool
{
return (bool) $this->evaluate($this->isInteger);
}
public function isNullable(): bool
{
return (bool) $this->evaluate($this->isNullable);
}
public function getVariant(): string
{
return $this->evaluate($this->variant);
}
public function getSize(): string
{
return $this->evaluate($this->size);
}
public function getDisplaySuffix(): ?string
{
return $this->evaluate($this->displaySuffix);
}
public function getNullLabel(): ?string
{
return $this->evaluate($this->nullLabel);
}
public function isNumeric(): bool
{
return true;
}
/**
* @return array<StateCast>
*/
public function getDefaultStateCasts(): array
{
return [
...parent::getDefaultStateCasts(),
app(NumberStateCast::class, ['isNullable' => $this->isNullable()]),
];
}
}

View File

@@ -0,0 +1,223 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Forms\Components;
use Closure;
use Filament\Forms\Components\Field;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
class SegmentControl extends Field
{
protected string $view = 'filament-short-url::forms.components.segment-control';
/**
* @var array<string | int, string | array<string, mixed>> | Closure
*/
protected array|Closure $options = [];
/**
* @var array<string | int, string> | Closure
*/
protected array|Closure $icons = [];
/**
* @var array<string | int> | Closure
*/
protected array|Closure $disabledOptions = [];
protected string|Closure $size = 'md';
protected string|Closure $variant = 'default';
protected bool|Closure $hasSeparators = true;
protected bool|Closure $isFullWidth = false;
protected bool|Closure $isIconOnly = false;
protected bool|Closure $expandSelectedLabel = false;
/**
* @param array<string | int, string | array<string, mixed>> | Closure $options
*/
public function options(array|Closure $options): static
{
$this->options = $options;
return $this;
}
/**
* @param array<string | int, string> | Closure $icons
*/
public function icons(array|Closure $icons): static
{
$this->icons = $icons;
return $this;
}
/**
* @param array<string | int> | Closure $keys
*/
public function disabledOptions(array|Closure $keys): static
{
$this->disabledOptions = $keys;
return $this;
}
public function size(string|Closure $size): static
{
$this->size = $size;
return $this;
}
public function variant(string|Closure $variant): static
{
$this->variant = $variant;
return $this;
}
public function separators(bool|Closure $condition = true): static
{
$this->hasSeparators = $condition;
return $this;
}
public function fullWidth(bool|Closure $condition = true): static
{
$this->isFullWidth = $condition;
return $this;
}
public function iconOnly(bool|Closure $condition = true): static
{
$this->isIconOnly = $condition;
return $this;
}
public function expandSelectedLabel(bool|Closure $condition = true): static
{
$this->expandSelectedLabel = $condition;
return $this;
}
/**
* @return array<int, string|int>
*/
public function getOptionKeys(): array
{
return array_keys($this->getNormalizedOptions());
}
/**
* @return array<string|int, array{label: string, icon: ?string, disabled: bool, tooltip: ?string}>
*/
public function getNormalizedOptions(): array
{
$icons = $this->getIcons();
$disabledOptions = collect($this->getDisabledOptions())->map(fn ($key) => (string) $key);
$normalized = [];
foreach ($this->evaluate($this->options) as $value => $option) {
$key = is_int($value) ? $value : (string) $value;
if (is_string($option)) {
$normalized[$key] = [
'label' => $option,
'icon' => $icons[$key] ?? $icons[$value] ?? null,
'disabled' => $disabledOptions->contains((string) $key),
'tooltip' => null,
];
continue;
}
if (is_array($option)) {
$normalized[$key] = [
'label' => (string) ($option['label'] ?? $key),
'icon' => $option['icon'] ?? $icons[$key] ?? $icons[$value] ?? null,
'disabled' => (bool) ($option['disabled'] ?? false) || $disabledOptions->contains((string) $key),
'tooltip' => filled($option['tooltip'] ?? null) ? (string) $option['tooltip'] : null,
];
}
}
return $normalized;
}
/**
* @return array<string|int, string>
*/
public function getIcons(): array
{
return $this->evaluate($this->icons);
}
/**
* @return array<string|int>
*/
public function getDisabledOptions(): array
{
return Arr::wrap($this->evaluate($this->disabledOptions));
}
public function getSize(): string
{
return $this->evaluate($this->size);
}
public function getVariant(): string
{
return $this->evaluate($this->variant);
}
public function hasSeparators(): bool
{
return (bool) $this->evaluate($this->hasSeparators);
}
public function isFullWidth(): bool
{
return (bool) $this->evaluate($this->isFullWidth);
}
public function isIconOnly(): bool
{
return (bool) $this->evaluate($this->isIconOnly);
}
public function shouldExpandSelectedLabel(): bool
{
return (bool) $this->evaluate($this->expandSelectedLabel);
}
public function isOptionDisabled(string|int $key): bool
{
return $this->getNormalizedOptions()[(string) $key]['disabled'] ?? true;
}
protected function setUp(): void
{
parent::setUp();
$this->rule(function (SegmentControl $component): string {
return Rule::in($component->getOptionKeys());
});
}
}

View File

@@ -107,6 +107,10 @@ class ShortUrlCustomDomainResource extends Resource
->modifyQueryUsing(fn ($query) => $query
->withCount('shortUrls')
->withSum('shortUrls as total_clicks', 'total_visits')
->when(
config('filament-short-url.user.model') && auth()->check(),
fn ($q) => $q->where('user_id', auth()->id())
)
)
->filters([])
->actions([
@@ -153,6 +157,19 @@ class ShortUrlCustomDomainResource extends Resource
->label(__('filament-short-url::default.action_delete_domain'))
->icon('heroicon-o-trash')
->color('danger')
->before(function (ShortUrlCustomDomain $record, DeleteAction $action): void {
if (! $record->shortUrls()->exists()) {
return;
}
Notification::make()
->title('Cannot delete domain with assigned links.')
->danger()
->send();
$action->cancel();
})
->requiresConfirmation(fn (ShortUrlCustomDomain $record): bool => $record->shortUrls()->exists())
->modalHeading(__('filament-short-url::default.action_delete_domain'))
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))

View File

@@ -25,6 +25,13 @@ class ListShortUrlCustomDomains extends ManageRecords
->modalWidth('md')
->modalAutofocus(false)
->closeModalByClickingAway(false)
->mutateFormDataUsing(function (array $data): array {
if (auth()->check()) {
$data['user_id'] = auth()->id();
}
return $data;
})
->after(function ($livewire, $record) {
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
}),

View File

@@ -58,10 +58,12 @@ class ShortUrlPixelResource extends Resource
}
}
public static function form(Schema $schema): Schema
/**
* @return array<int, TextInput|Select|Toggle>
*/
public static function formComponents(): array
{
return $schema
->components([
return [
TextInput::make('name')
->label(__('filament-short-url::default.pixel_name'))
->required()
@@ -70,13 +72,7 @@ class ShortUrlPixelResource extends Resource
Select::make('type')
->label(__('filament-short-url::default.pixel_type'))
->options([
'meta' => 'Meta / Facebook Pixel',
'google' => 'Google Tag (GA4 / GTM)',
'linkedin' => 'LinkedIn Insight Tag',
'tiktok' => 'TikTok Pixel',
'pinterest' => 'Pinterest Tag',
])
->options(self::typeOptions())
->required()
->native(false),
@@ -89,7 +85,27 @@ class ShortUrlPixelResource extends Resource
Toggle::make('is_active')
->label(__('filament-short-url::default.pixel_status_active'))
->default(true),
])
];
}
/**
* @return array<string, string>
*/
public static function typeOptions(): array
{
return [
'meta' => 'Meta / Facebook Pixel',
'google' => 'Google Tag (GA4 / GTM)',
'linkedin' => 'LinkedIn Insight Tag',
'tiktok' => 'TikTok Pixel',
'pinterest' => 'Pinterest Tag',
];
}
public static function form(Schema $schema): Schema
{
return $schema
->components(static::formComponents())
->columns(1);
}

View File

@@ -10,9 +10,11 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortU
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables\ShortUrlsTable;
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Support\LinkUserScope;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ShortUrlResource extends Resource
{
@@ -88,6 +90,14 @@ class ShortUrlResource extends Resource
}
}
/**
* @return Builder<ShortUrl>
*/
public static function getEloquentQuery(): Builder
{
return LinkUserScope::applyToQuery(parent::getEloquentQuery());
}
public static function form(Schema $schema): Schema
{
return ShortUrlForm::configure($schema);

View File

@@ -3,6 +3,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
@@ -11,6 +12,9 @@ use Filament\Actions\CreateAction;
use Filament\Forms;
use Filament\Resources\Pages\ManageRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\HtmlString;
class ListShortUrls extends ManageRecords
@@ -42,7 +46,7 @@ class ListShortUrls extends ManageRecords
$data['url_key'] = $service->generateKey();
}
return $data;
return PasswordOpenGraphGuard::sanitizeSaveData($data);
})
->after(function (ShortUrl $record): void {
$id = json_encode($record->id);
@@ -142,7 +146,7 @@ class ListShortUrls extends ManageRecords
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
$qrOptionsJson = json_encode([
$qrOptions = [
'type' => 'svg',
'width' => 200,
'height' => 200,
@@ -160,9 +164,7 @@ class ListShortUrls extends ManageRecords
'logoShape' => $logoShape,
],
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
]);
$escapedQrOptions = e($qrOptionsJson);
];
$viewData = compact(
'shortUrl',
@@ -170,7 +172,7 @@ class ListShortUrls extends ManageRecords
'destHost',
'urlKey',
'eid',
'escapedQrOptions',
'qrOptions',
'successTitle',
'successSubtitle',
'successHelper',
@@ -208,4 +210,17 @@ class ListShortUrls extends ManageRecords
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
];
}
protected function paginateTableQuery(Builder $query): Paginator
{
$paginator = parent::paginateTableQuery($query);
if ($paginator instanceof AbstractPaginator) {
ShortUrl::preloadBufferedCountersForIds(
$paginator->getCollection()->pluck('id')->all()
);
}
return $paginator;
}
}

View File

@@ -8,8 +8,10 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\NumberStepper;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
@@ -17,6 +19,7 @@ use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\HtmlString;
class AdvancedTab
{
@@ -57,6 +60,10 @@ class AdvancedTab
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
->columns(3)
->schema([
Placeholder::make('rate_limiting_route_info')
->content(fn () => new HtmlString(__('filament-short-url::default.settings_rate_limiting_route_info')))
->columnSpanFull(),
Toggle::make('rate_limiting_enabled')
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
@@ -64,22 +71,24 @@ class AdvancedTab
->live()
->inline(false),
TextInput::make('rate_limiting_max_attempts')
NumberStepper::make('rate_limiting_max_attempts')
->label(__('filament-short-url::default.settings_rate_limiting_max_attempts'))
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(1000)
->variant('secondary')
->size('sm')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
TextInput::make('rate_limiting_decay_seconds')
NumberStepper::make('rate_limiting_decay_seconds')
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(3600)
->suffix('s')
->variant('secondary')
->size('sm')
->required()
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
]),
@@ -125,6 +134,36 @@ class AdvancedTab
])
->default('flag_only')
->required()
->live()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Placeholder::make('vpn_block_social_warning')
->content(fn () => new HtmlString(__('filament-short-url::default.settings_vpn_block_social_warning')))
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')
&& $get('vpn_block_action') === 'block_with_403')
->columnSpanFull(),
NumberStepper::make('vpn_detection_timeout')
->label(__('filament-short-url::default.settings_vpn_detection_timeout'))
->helperText(__('filament-short-url::default.settings_vpn_detection_timeout_helper'))
->minValue(1)
->maxValue(30)
->suffix('s')
->variant('tertiary')
->size('sm')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
NumberStepper::make('vpn_detection_cache_ttl')
->label(__('filament-short-url::default.settings_vpn_detection_cache_ttl'))
->helperText(__('filament-short-url::default.settings_vpn_detection_cache_ttl_helper'))
->minValue(60)
->maxValue(604800)
->step(60)
->suffix('s')
->variant('tertiary')
->size('sm')
->required()
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
Toggle::make('safe_browsing_enabled')
@@ -174,6 +213,46 @@ class AdvancedTab
)
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
]),
Section::make(__('filament-short-url::default.settings_section_analytics_security'))
->description(__('filament-short-url::default.settings_section_analytics_security_desc'))
->columns(2)
->schema([
Toggle::make('click_deduplication_enabled')
->label(__('filament-short-url::default.settings_click_dedup_enabled'))
->helperText(__('filament-short-url::default.settings_click_dedup_enabled_helper'))
->default(false)
->inline(false)
->live()
->columnSpanFull(),
TextInput::make('click_deduplication_hours')
->label(__('filament-short-url::default.settings_click_dedup_hours'))
->helperText(__('filament-short-url::default.settings_click_dedup_hours_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(168)
->default(1)
->suffix('h')
->required()
->visible(fn (Get $get): bool => (bool) $get('click_deduplication_enabled')),
Toggle::make('bot_verify_google_bot_ip')
->label(__('filament-short-url::default.settings_bot_verify_googlebot'))
->helperText(__('filament-short-url::default.settings_bot_verify_googlebot_helper'))
->default(false)
->inline(false)
->columnSpanFull(),
TextInput::make('bot_debug_secret')
->label(__('filament-short-url::default.settings_bot_debug_secret'))
->helperText(__('filament-short-url::default.settings_bot_debug_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••')
->columnSpanFull(),
]),
]);
}
}

View File

@@ -75,8 +75,12 @@ class DeveloperTab
Toggle::make('is_active')
->label(__('filament-short-url::default.active'))
->default(true),
TextInput::make('owner_user_id')
->label(__('filament-short-url::default.api_key_owner_user_id'))
->numeric()
->nullable(),
])
->columns(5)
->columns(6)
->default([]),
]),
@@ -104,11 +108,12 @@ class DeveloperTab
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
TextInput::make('webhook_signing_secret')
->label('Webhook Signing Secret')
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
->label(__('filament-short-url::default.webhook_signing_secret'))
->helperText(__('filament-short-url::default.webhook_signing_secret_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••')
->required(fn (Get $get): bool => (bool) $get('global_webhook_enabled'))
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),

View File

@@ -8,6 +8,8 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Bjanczak\FilamentShortUrl\Services\Ga4MeasurementProtocolService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
@@ -15,7 +17,6 @@ use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Illuminate\Support\Facades\Http;
class Ga4Tab
{
@@ -43,6 +44,12 @@ class Ga4Tab
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
->placeholder('1:1234567890:android:abcdef123456'),
TextInput::make('ga4_verify_measurement_id')
->label(__('filament-short-url::default.settings_ga4_verify_measurement_id'))
->helperText(__('filament-short-url::default.settings_ga4_verify_measurement_id_helper'))
->placeholder('G-XXXXXXXXXX')
->rule('nullable|regex:/^G-[A-Z0-9]+$/i'),
Actions::make([
Action::make('verifyGa4ApiSecret')
->label(__('filament-short-url::default.settings_ga4_verify'))
@@ -51,7 +58,11 @@ class Ga4Tab
->action(function (Get $get): void {
$secret = trim($get('ga4_api_secret') ?? '');
if (empty($secret)) {
if ($secret === '' || str_contains($secret, '••••')) {
$secret = (string) app(ShortUrlSettingsManager::class)->get('ga4_api_secret', '');
}
if ($secret === '') {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
->warning()
@@ -60,45 +71,43 @@ class Ga4Tab
return;
}
try {
$response = Http::timeout(5)
->withHeaders(['Content-Type' => 'application/json'])
->post(
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret='.urlencode($secret),
[
'client_id' => 'short-url-plugin-verify',
'events' => [
['name' => 'page_view', 'params' => []],
],
]
$measurementId = strtoupper(trim($get('ga4_verify_measurement_id') ?? ''));
$firebaseAppId = trim($get('ga4_firebase_app_id') ?? '');
if ($firebaseAppId === '' && $measurementId === '') {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_measurement_required'))
->warning()
->send();
return;
}
$result = app(Ga4MeasurementProtocolService::class)->validateCredentials(
$measurementId,
$secret,
$firebaseAppId !== '' ? $firebaseAppId : null,
);
$body = $response->json();
$messages = $body['validationMessages'] ?? [];
$hasAuthError = collect($messages)->contains(fn ($m) => str_contains(
strtolower($m['description'] ?? ''),
'api_secret'
));
if ($hasAuthError || $response->status() === 401) {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
->danger()
->send();
} else {
if ($result['valid']) {
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
->success()
->send();
return;
}
} catch (\Throwable $e) {
$firstMessage = collect($result['messages'])
->pluck('description')
->filter()
->first();
Notification::make()
->title(__('filament-short-url::default.settings_ga4_verify_error'))
->body($e->getMessage())
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
->body(is_string($firstMessage) ? $firstMessage : null)
->danger()
->send();
}
}),
]),
]),

View File

@@ -8,10 +8,16 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
use Bjanczak\FilamentShortUrl\Services\Queue\PluginQueueWorkerTester;
use Bjanczak\FilamentShortUrl\Services\Redis\PluginRedisConnectionTester;
use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
@@ -19,6 +25,72 @@ use Illuminate\Support\HtmlString;
class GeneralTab
{
private static function queueConnectionRequiresWorker(string $connection): bool
{
return ! in_array($connection, ['sync', 'deferred', 'background'], true);
}
private static function queueModeDescription(string $connection): string
{
return match ($connection) {
'redis' => __('filament-short-url::default.settings_queue_mode_desc_redis'),
'database' => __('filament-short-url::default.settings_queue_mode_desc_database'),
'sqs' => __('filament-short-url::default.settings_queue_mode_desc_sqs'),
'beanstalkd' => __('filament-short-url::default.settings_queue_mode_desc_beanstalkd'),
'deferred' => __('filament-short-url::default.settings_queue_mode_desc_deferred'),
'background' => __('filament-short-url::default.settings_queue_mode_desc_background'),
'failover' => __('filament-short-url::default.settings_queue_mode_desc_failover'),
default => __('filament-short-url::default.settings_queue_mode_desc_default', ['connection' => e($connection)]),
};
}
private static function queueCalloutHtml(string $content, string $variant = 'neutral'): string
{
$classes = $variant === 'emerald'
? 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30'
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10';
return '<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border '.$classes.'" data-callout-type="info">'
.'<div class="text-sm text-neutral-800 dark:text-neutral-300">'.$content.'</div></div>';
}
private static function renderQueueConnectionCallout(Get $get): HtmlString
{
$connection = (string) ($get('queue_connection') ?: 'sync');
if ($connection === 'sync') {
return new HtmlString('');
}
$queueName = (string) ($get('queue_name') ?: 'default');
$content = self::queueModeDescription($connection);
if (self::queueConnectionRequiresWorker($connection)) {
$content .= '<br><br>'.__('filament-short-url::default.settings_queue_worker_command', [
'connection' => e($connection),
'queue' => e($queueName),
]);
}
return new HtmlString(self::queueCalloutHtml($content, $connection === 'redis' ? 'emerald' : 'neutral'));
}
/**
* @return array<string, mixed>
*/
private static function queuePreviewSettings(Get $get): array
{
return [
'queue_connection' => $get('queue_connection') ?: 'sync',
'queue_name' => $get('queue_name') ?: 'default',
'redis_host' => $get('redis_host'),
'redis_port' => $get('redis_port'),
'redis_password' => $get('redis_password'),
'redis_database' => $get('redis_database'),
'redis_key_prefix' => $get('redis_key_prefix'),
];
}
/**
* Build the general settings form tab.
*/
@@ -46,13 +118,15 @@ class GeneralTab
->alphaDash()
->maxLength(20),
Select::make('redirect_status_code')
SegmentControl::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->size('sm')
->separators(false)
->required(),
Toggle::make('lock_url_key')
@@ -90,7 +164,14 @@ class GeneralTab
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
->columnSpanFull()
->inline(false)
->live(),
->live()
->disabled(fn (Get $get): bool => $get('geo_ip_driver') === 'headers')
->dehydrated(fn (Get $get): bool => $get('geo_ip_driver') !== 'headers'),
Placeholder::make('trust_cdn_headers_auto_enabled')
->content(fn () => new HtmlString(__('filament-short-url::default.settings_trust_cdn_headers_auto_enabled')))
->visible(fn (Get $get): bool => $get('geo_ip_driver') === 'headers')
->columnSpanFull(),
Placeholder::make('trust_cdn_headers_info')
->content(function () {
@@ -98,7 +179,8 @@ class GeneralTab
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers'))
->visible(fn (Get $get): bool => (bool) $get('trust_cdn_headers')
&& $get('geo_ip_driver') !== 'headers')
->columnSpanFull(),
]),
@@ -125,16 +207,111 @@ class GeneralTab
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
->default('default')
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync'),
Placeholder::make('queue_worker_info')
->content(function (Get $get) {
$queueName = $get('queue_name') ?: 'default';
$html = __('filament-short-url::default.settings_queue_worker_info', ['queue' => $queueName]);
return new HtmlString($html);
})
->visible(fn (Get $get): bool => $get('queue_connection') !== 'sync')
->live(),
Placeholder::make('queue_mode_info')
->hiddenLabel()
->content(fn (Get $get): HtmlString => self::renderQueueConnectionCallout($get))
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
->columnSpanFull(),
Section::make(__('filament-short-url::default.settings_section_redis'))
->description(__('filament-short-url::default.settings_section_redis_description'))
->columns(2)
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis')
->schema([
TextInput::make('redis_host')
->label(__('filament-short-url::default.settings_redis_host'))
->helperText(__('filament-short-url::default.settings_redis_host_helper'))
->required(fn (Get $get): bool => $get('queue_connection') === 'redis')
->maxLength(255),
TextInput::make('redis_port')
->label(__('filament-short-url::default.settings_redis_port'))
->helperText(__('filament-short-url::default.settings_redis_port_helper'))
->numeric()
->integer()
->minValue(1)
->maxValue(65535)
->required(fn (Get $get): bool => $get('queue_connection') === 'redis'),
TextInput::make('redis_password')
->label(__('filament-short-url::default.settings_redis_password'))
->helperText(__('filament-short-url::default.settings_redis_password_helper'))
->password()
->revealable()
->placeholder('••••••••••••••••••••'),
TextInput::make('redis_database')
->label(__('filament-short-url::default.settings_redis_database'))
->helperText(__('filament-short-url::default.settings_redis_database_helper'))
->numeric()
->integer()
->minValue(0)
->maxValue(15)
->required(fn (Get $get): bool => $get('queue_connection') === 'redis'),
TextInput::make('redis_key_prefix')
->label(__('filament-short-url::default.settings_redis_key_prefix'))
->helperText(__('filament-short-url::default.settings_redis_key_prefix_helper'))
->maxLength(100)
->columnSpanFull(),
])
->columnSpanFull(),
Actions::make([
Action::make('testRedisConnection')
->label(__('filament-short-url::default.settings_redis_test'))
->icon('heroicon-o-signal')
->color('gray')
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis')
->action(function (Get $get): void {
$result = app(PluginRedisConnectionTester::class)->test(
queueConnectionName: 'redis',
queueName: $get('queue_name') ?: 'default',
previewSettings: self::queuePreviewSettings($get),
);
$notification = Notification::make()
->title($result['ok']
? __('filament-short-url::default.settings_redis_test_ok')
: __('filament-short-url::default.settings_redis_test_fail'))
->body($result['message']);
if ($result['ok']) {
$notification->success();
} else {
$notification->danger();
}
$notification->send();
}),
Action::make('testQueueWorker')
->label(__('filament-short-url::default.settings_queue_worker_test'))
->icon('heroicon-o-play')
->color('gray')
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
->action(function (Get $get): void {
$result = app(PluginQueueWorkerTester::class)->test(self::queuePreviewSettings($get));
$notification = Notification::make()
->title($result['ok']
? __('filament-short-url::default.settings_queue_worker_test_ok')
: __('filament-short-url::default.settings_queue_worker_test_fail'))
->body($result['message']);
if ($result['ok']) {
$notification->success();
} else {
$notification->warning();
}
$notification->send();
}),
])
->visible(fn (Get $get): bool => ($get('queue_connection') ?: 'sync') !== 'sync')
->columnSpanFull(),
]),
@@ -142,7 +319,11 @@ class GeneralTab
->schema([
Toggle::make('counter_buffering_enabled')
->label(__('filament-short-url::default.settings_buffering_enabled'))
->helperText(__('filament-short-url::default.settings_buffering_helper'))
->helperText(fn (Get $get): string => ($get('queue_connection') === 'redis')
? __('filament-short-url::default.settings_buffering_redis_auto')
: __('filament-short-url::default.settings_buffering_helper'))
->disabled(fn (Get $get): bool => $get('queue_connection') === 'redis')
->dehydrated()
->inline(false)
->live(),
@@ -152,7 +333,7 @@ class GeneralTab
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('counter_buffering_enabled'))
->visible(fn (Get $get): bool => $get('queue_connection') === 'redis' || (bool) $get('counter_buffering_enabled'))
->columnSpanFull(),
]),
]);

View File

@@ -53,15 +53,14 @@ class GeoIpTab
->live()
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
Placeholder::make('geoip_headers_warning')
Placeholder::make('geoip_headers_info')
->content(function () {
$html = __('filament-short-url::default.settings_geoip_headers_warning');
$html = __('filament-short-url::default.settings_geoip_headers_info');
return new HtmlString($html);
})
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
$get('geo_ip_driver') === 'headers' &&
! (bool) $get('trust_cdn_headers')
$get('geo_ip_driver') === 'headers'
)
->columnSpanFull(),

View File

@@ -114,6 +114,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
'queue_connection' => $mgr->get('queue_connection', 'sync'),
'queue_name' => $mgr->get('queue_name', 'default'),
'redis_host' => $mgr->get('redis_host'),
'redis_port' => $mgr->get('redis_port'),
'redis_password' => filled($mgr->get('redis_password')) ? '••••••••••••••••••••' : null,
'redis_database' => $mgr->get('redis_database'),
'redis_key_prefix' => $mgr->get('redis_key_prefix'),
'ga4_api_secret' => $mgr->get('ga4_api_secret'),
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
@@ -133,8 +138,6 @@ class ShortUrlSettingsPage extends Page implements HasForms
'tracking_fields_referer_url' => $mgr->get('tracking_fields_referer_url', true),
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
'tracking_fields_browser_language' => $mgr->get('tracking_fields_browser_language', true),
'qr_size' => $mgr->get('qr_size', 300),
'qr_margin' => $mgr->get('qr_margin', 1),
'qr_dot_style' => $mgr->get('qr_dot_style', 'square'),
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'),
@@ -153,8 +156,14 @@ class ShortUrlSettingsPage extends Page implements HasForms
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
'vpnapi_key' => $mgr->get('vpnapi_key'),
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
'vpn_detection_cache_ttl' => $mgr->get('vpn_detection_cache_ttl', 86400),
'vpn_detection_timeout' => $mgr->get('vpn_detection_timeout', 2),
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
'click_deduplication_enabled' => $mgr->get('click_deduplication_enabled', false),
'click_deduplication_hours' => $mgr->get('click_deduplication_hours', 1),
'bot_verify_google_bot_ip' => $mgr->get('bot_verify_google_bot_ip', false),
'bot_debug_secret' => $mgr->get('bot_debug_secret'),
// Deep Linking v2.1
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
'aasa_json' => $aasa,

View File

@@ -16,6 +16,7 @@ use Filament\Schemas\Components\Grid;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Enums\PaginationMode;
use Filament\Tables\Table;
class ViewShortUrlLogs extends Page implements HasForms, HasTable
@@ -39,7 +40,12 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
public function table(Table $table): Table
{
return $table
->query(ShortUrlVisit::query()->where('short_url_id', $this->record->id))
->query(
ShortUrlVisit::query()
->where('short_url_id', $this->record->id)
->orderByDesc('visited_at')
->orderByDesc('id')
)
->columns([
Tables\Columns\TextColumn::make('visited_at')
->label(__('filament-short-url::default.stats_col_time'))
@@ -193,7 +199,18 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
])
->defaultSort('visited_at', 'desc')
->paginated([10, 25, 50, 100])
->paginationMode(PaginationMode::Cursor)
->queryStringIdentifier('shortUrlVisitLogs')
->filters([
Tables\Filters\TernaryFilter::make('counted_in_stats')
->label(__('filament-short-url::default.stats_filter_counted_in_stats'))
->default(true)
->queries(
true: fn ($query) => $query->where('is_bot', false)->where('is_proxy', false),
false: fn ($query) => $query,
blank: fn ($query) => $query,
),
Tables\Filters\SelectFilter::make('device_type')
->label(__('filament-short-url::default.stats_col_device'))
->options([

View File

@@ -12,10 +12,12 @@ use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
class ViewShortUrlStats extends Page implements HasForms
{
use AuthorizesRequests;
use InteractsWithForms;
protected static string $resource = ShortUrlResource::class;
@@ -78,6 +80,8 @@ class ViewShortUrlStats extends Page implements HasForms
public function mount(ShortUrl $record): void
{
$this->authorize('view', $record);
$this->record = $record;
$this->form->fill([

View File

@@ -8,6 +8,7 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Fields;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
@@ -24,7 +25,6 @@ use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\RawJs;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@@ -151,8 +151,8 @@ class QrDesignerSidebarField
'bg_transparent' => $parentOpts['bg_transparent'] ?? false,
'background_color' => $parentOpts['background_color'] ?? $defaults['background_color'] ?? '#ffffff',
'eye_config_enabled' => $parentOpts['eye_config_enabled'] ?? false,
'eye_square_style' => $parentOpts['eye_square_style'] ?? 'square',
'eye_dot_style' => $parentOpts['eye_dot_style'] ?? 'square',
'eye_square_style' => filled($parentOpts['eye_square_style'] ?? null) ? $parentOpts['eye_square_style'] : 'square',
'eye_dot_style' => filled($parentOpts['eye_dot_style'] ?? null) ? $parentOpts['eye_dot_style'] : 'square',
'eye_color' => $parentOpts['eye_color'] ?? '#000000',
'logo_file' => $parentLogo ? [$parentLogo] : [],
'logo_shape' => $parentOpts['logo_shape'] ?? 'square',
@@ -174,42 +174,25 @@ class QrDesignerSidebarField
->collapsible()
->compact()
->schema([
ToggleButtons::make('dot_style')
SegmentControl::make('dot_style')
->label(__('filament-short-url::default.qr_label_style'))
->options([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->icons([
'square' => 'fsu-qr-dots-square',
'dots' => 'fsu-qr-dots-dots',
'rounded' => 'fsu-qr-dots-rounded',
'classy' => 'fsu-qr-dots-classy',
'classy-rounded' => 'fsu-qr-dots-classy-rounded',
'extra-rounded' => 'fsu-qr-dots-extra-rounded',
])
->tooltips([
'square' => __('filament-short-url::default.qr_option_square'),
'dots' => __('filament-short-url::default.qr_option_dots'),
'rounded' => __('filament-short-url::default.qr_option_rounded'),
'classy' => __('filament-short-url::default.qr_option_classy'),
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->extraAttributes([
'class' => '[&_svg]:w-[25px] [&_svg]:h-[25px]',
])
->hiddenButtonLabels()
->grouped()
->options(self::qrIconSegmentOptions([
['square', 'qr_option_square', 'fsu-qr-dots-square'],
['dots', 'qr_option_dots', 'fsu-qr-dots-dots'],
['rounded', 'qr_option_rounded', 'fsu-qr-dots-rounded'],
['classy', 'qr_option_classy', 'fsu-qr-dots-classy'],
['classy-rounded', 'qr_option_classy_rounded', 'fsu-qr-dots-classy-rounded'],
['extra-rounded', 'qr_option_extra_rounded', 'fsu-qr-dots-extra-rounded'],
]))
->iconOnly()
->size('lg')
->separators(false)
->required()
->live(),
->live()
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only qr-segment-icon-only--style']),
ToggleButtons::make('color_mode')
->hiddenLabel()
SegmentControl::make('color_mode')
->label(__('filament-short-url::default.qr_label_color'))
->options([
'solid' => __('filament-short-url::default.qr_label_single_color'),
'gradient' => __('filament-short-url::default.qr_label_gradient'),
@@ -218,7 +201,7 @@ class QrDesignerSidebarField
'solid' => 'heroicon-o-paint-brush',
'gradient' => 'heroicon-o-sparkles',
])
->grouped()
->fullWidth()
->required()
->live(),
@@ -298,57 +281,33 @@ class QrDesignerSidebarField
->label(__('filament-short-url::default.qr_label_custom_eye_config'))
->live(),
ToggleButtons::make('eye_square_style')
SegmentControl::make('eye_square_style')
->label(__('filament-short-url::default.qr_label_eye_square_style'))
->options([
'' => __('filament-short-url::default.qr_option_none'),
'square' => __('filament-short-url::default.qr_option_square'),
'dot' => __('filament-short-url::default.qr_option_dot'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->icons([
'' => 'fsu-qr-eye-square-none',
'square' => 'fsu-qr-eye-square-square',
'dot' => 'fsu-qr-eye-square-dot',
'extra-rounded' => 'fsu-qr-eye-square-extra-rounded',
])
->tooltips([
'' => __('filament-short-url::default.qr_option_none'),
'square' => __('filament-short-url::default.qr_option_square'),
'dot' => __('filament-short-url::default.qr_option_dot'),
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
])
->extraAttributes([
'class' => '[&_svg]:w-[25px] [&_svg]:h-[25px]',
])
->hiddenButtonLabels()
->grouped()
->options(self::qrIconSegmentOptions([
['square', 'qr_option_square', 'fsu-qr-eye-square-square'],
['dot', 'qr_option_dot', 'fsu-qr-eye-square-dot'],
['extra-rounded', 'qr_option_extra_rounded', 'fsu-qr-eye-square-extra-rounded'],
]))
->default('square')
->iconOnly()
->size('lg')
->separators(false)
->live()
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only'])
->visible(fn (Get $get) => $get('eye_config_enabled')),
ToggleButtons::make('eye_dot_style')
SegmentControl::make('eye_dot_style')
->label(__('filament-short-url::default.qr_label_eye_dot_style'))
->options([
'' => __('filament-short-url::default.qr_option_none'),
'square' => __('filament-short-url::default.qr_option_square'),
'dot' => __('filament-short-url::default.qr_option_dot'),
])
->icons([
'' => 'fsu-qr-eye-dot-none',
'square' => 'fsu-qr-eye-dot-square',
'dot' => 'fsu-qr-eye-dot-dot',
])
->extraAttributes([
'class' => '[&_svg]:w-[25px] [&_svg]:h-[25px]',
])
->tooltips([
'' => __('filament-short-url::default.qr_option_none'),
'square' => __('filament-short-url::default.qr_option_square'),
'dot' => __('filament-short-url::default.qr_option_dot'),
])
->hiddenButtonLabels()
->grouped()
->options(self::qrIconSegmentOptions([
['square', 'qr_option_square', 'fsu-qr-eye-dot-square'],
['dot', 'qr_option_dot', 'fsu-qr-eye-dot-dot'],
]))
->default('square')
->iconOnly()
->size('lg')
->separators(false)
->live()
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only'])
->visible(fn (Get $get) => $get('eye_config_enabled')),
Grid::make(12)
@@ -471,8 +430,8 @@ class QrDesignerSidebarField
'bg_transparent' => (bool) ($data['bg_transparent'] ?? false),
'background_color' => $data['background_color'] ?? '#ffffff',
'eye_config_enabled' => (bool) ($data['eye_config_enabled'] ?? false),
'eye_square_style' => $data['eye_square_style'] ?? 'square',
'eye_dot_style' => $data['eye_dot_style'] ?? 'square',
'eye_square_style' => filled($data['eye_square_style'] ?? null) ? $data['eye_square_style'] : 'square',
'eye_dot_style' => filled($data['eye_dot_style'] ?? null) ? $data['eye_dot_style'] : 'square',
'eye_color' => $data['eye_color'] ?? '#000000',
'logo_shape' => $data['logo_shape'] ?? 'square',
'logo_size' => (float) ($data['logo_size'] ?? 0.55),
@@ -499,4 +458,25 @@ class QrDesignerSidebarField
]);
});
}
/**
* @param array<int, array{0: string, 1: string, 2: string}> $items
* @return array<string, array{label: string, icon: string, tooltip: string}>
*/
private static function qrIconSegmentOptions(array $items): array
{
$options = [];
foreach ($items as [$value, $labelKey, $icon]) {
$label = __("filament-short-url::default.{$labelKey}");
$options[$value] = [
'label' => $label,
'icon' => $icon,
'tooltip' => $label,
];
}
return $options;
}
}

View File

@@ -17,6 +17,7 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\T
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\TrackingTab;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\ValidityAndLimitsTab;
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
use Bjanczak\FilamentShortUrl\Services\OgFormImageResolver;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
@@ -71,7 +72,7 @@ class ShortUrlForm
->extraFieldWrapperAttributes([
'class' => 'create-link-sidebar-select-wrapper',
])
->label(new HtmlString('<span class="font-semibold">' . __('filament-short-url::default.folder_resource_title') . '</span>'))
->label(new HtmlString('<span class="font-semibold">'.__('filament-short-url::default.folder_resource_title').'</span>'))
->relationship('folder', 'name')
->allowHtml()
->getOptionLabelFromRecordUsing(fn ($record) => $record->getOptionHtml())
@@ -119,9 +120,45 @@ class ShortUrlForm
ViewField::make('sidebar_social_preview')
->view('filament-short-url::sidebar.social-preview')
->viewData(fn (?Get $get = null) => [
'isPasswordProtected' => $get ? (bool) $get('password_active_flag') : false,
])
->viewData(function (ViewField $component, ?Get $get = null) {
$formState = [];
if (method_exists($component, 'getContainer')) {
$formState = $component->getContainer()->getState();
}
if ($get) {
$formState = array_merge($formState, [
'og_title' => $get('og_title'),
'og_description' => $get('og_description'),
'og_image' => $get('og_image'),
'og_image_scraped' => $get('og_image_scraped'),
'is_scraping' => $get('is_scraping'),
]);
}
if (method_exists($component, 'getLivewire')) {
$livewire = $component->getLivewire();
if (isset($livewire->data) && is_array($livewire->data)) {
$formState = array_merge($livewire->data, $formState);
}
}
$resolver = app(OgFormImageResolver::class);
return [
'isPasswordProtected' => (bool) ($formState['password_active_flag'] ?? false)
|| filled($formState['password'] ?? null),
'ogTitle' => filled($formState['og_title'] ?? null) ? $formState['og_title'] : null,
'ogDescription' => filled($formState['og_description'] ?? null) ? $formState['og_description'] : null,
'ogImageUrl' => $resolver->resolvePreviewUrl(
$formState['og_image'] ?? null,
$formState['og_image_scraped'] ?? null,
),
'isScraping' => (bool) ($formState['is_scraping'] ?? false),
];
})
->dehydrated(false)
->columnSpanFull(),
])

View File

@@ -0,0 +1,84 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Livewire\Component;
class PasswordOpenGraphGuard
{
public static function isFormPasswordProtected(Get $get): bool
{
return (bool) $get('password_active_flag') || filled($get('password'));
}
public static function isSaveDataPasswordProtected(array $data): bool
{
return filled($data['password'] ?? null);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function sanitizeRecordDataForFill(array $data): array
{
if (! self::isSaveDataPasswordProtected($data)) {
return $data;
}
return self::stripOpenGraphKeys($data);
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function sanitizeSaveData(array $data): array
{
if (! self::isSaveDataPasswordProtected($data)) {
return $data;
}
return self::stripOpenGraphKeys($data);
}
public static function clearFormState(Set $set, ?Component $livewire = null): void
{
$set('og_title', null);
$set('og_description', null);
$set('og_image', null);
$set('og_image_scraped', null);
$set('is_scraping', false);
if ($livewire !== null) {
$livewire->js('window.fsuDispatchScraping(false); window.dispatchEvent(new CustomEvent("fsu-password-protection-changed", { detail: { protected: true } }))');
}
}
public static function purgeOpenGraphMetadata(ShortUrl $shortUrl): void
{
$shortUrl->purgeOpenGraphMetadata();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private static function stripOpenGraphKeys(array $data): array
{
$data['og_title'] = null;
$data['og_description'] = null;
$data['og_image'] = null;
return $data;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support;
use Illuminate\Support\HtmlString;
use Illuminate\View\ComponentAttributeBag;
use function Filament\Support\generate_icon_html;
class TabCardHeader
{
public static function make(
string $heroicon,
string $iconColorClass,
string $titleKey,
string $subtitleKey,
bool $compact = false,
): HtmlString {
$icon = generate_icon_html($heroicon, attributes: new ComponentAttributeBag([
'class' => 'fsu-tab-card-icon-svg',
]));
$headerClass = $compact
? 'validity-tab-card-header validity-tab-card-header--compact'
: 'validity-tab-card-header';
return new HtmlString(
'<div class="'.e($headerClass).'">'.
'<div class="validity-tab-card-icon '.e($iconColorClass).'">'.
($icon?->toHtml() ?? '').
'</div>'.
'<div class="validity-tab-card-toolbar-main">'.
'<p class="validity-tab-card-title">'.e(__("filament-short-url::default.{$titleKey}")).'</p>'.
'<p class="validity-tab-card-subtitle">'.e(__("filament-short-url::default.{$subtitleKey}")).'</p>'.
'</div>'.
'</div>'
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support;
class WebhookPayloadExample
{
/**
* @return list<string>
*/
public static function visitedShortUrlKeys(): array
{
return [
'id',
'destination_url',
'url_key',
'short_url',
'total_visits',
'unique_visits',
];
}
/**
* @return list<string>
*/
public static function visitedVisitKeys(): array
{
return [
'id',
'visited_at',
'device_type',
'browser',
'browser_version',
'operating_system',
'operating_system_version',
'country',
'country_code',
'city',
'referer_url',
'referer_host',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'is_qr_scan',
'browser_language',
];
}
/**
* Sample payload for the `visited` webhook event.
*
* @return array<string, mixed>
*/
public static function visitedEventSample(): array
{
return [
'event' => 'visited',
'timestamp' => '2026-06-04T12:00:00+02:00',
'short_url' => [
'id' => 12,
'destination_url' => 'https://example.com/some-page',
'url_key' => 'promo26',
'short_url' => 'https://yoursite.com/s/promo26',
'total_visits' => 150,
'unique_visits' => 120,
],
'visit' => [
'id' => 345,
'visited_at' => '2026-06-04T12:00:00+02:00',
'device_type' => 'mobile',
'browser' => 'Chrome',
'browser_version' => '120.0',
'operating_system' => 'Android',
'operating_system_version' => '14',
'country' => 'Poland',
'country_code' => 'PL',
'city' => 'Warsaw',
'referer_url' => 'https://t.co/',
'referer_host' => 't.co',
'utm_source' => 'twitter',
'utm_medium' => 'social',
'utm_campaign' => 'summer_sale',
'utm_term' => null,
'utm_content' => 'banner_ad',
'is_qr_scan' => false,
'browser_language' => 'pl',
],
];
}
public static function visitedEventSampleJson(): string
{
$json = json_encode(
self::visitedEventSample(),
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return is_string($json) ? $json : '{}';
}
}

View File

@@ -8,7 +8,9 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
@@ -17,20 +19,26 @@ use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Repeater\TableColumn;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Schemas\Components\FusedGroup;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\Size;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique;
class LinkTab
{
@@ -42,26 +50,35 @@ class LinkTab
return Tab::make(__('filament-short-url::default.tab_link'))
->icon('heroicon-o-link')
->schema([
Section::make()->schema([
ToggleButtons::make('destination_type')
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('link_destination_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-link',
'validity-tab-card-icon--link',
'link_destination_card_title',
'link_destination_card_subtitle',
compact: true,
)),
SegmentControl::make('destination_type')
->label(__('filament-short-url::default.destination_type'))
->options([
'single' => __('filament-short-url::default.destination_type_single'),
'split' => __('filament-short-url::default.destination_type_split'),
])
->colors([
'single' => 'primary',
'split' => 'warning',
])
->icons([
'single' => 'heroicon-o-link',
'split' => 'heroicon-o-arrow-path-rounded-square',
])
->default('single')
->live()
->inline()
->grouped()
->columnSpanFull()
->size('md')
->separators()
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
->afterStateUpdated(function ($state, Set $set, Get $get) {
if ($state === 'split' && empty($get('rotation_variants'))) {
$set('rotation_variants', [
@@ -71,7 +88,219 @@ class LinkTab
}
}),
TextInput::make('destination_url')
Group::make()
->extraAttributes(['class' => 'link-tab-panel'])
->schema([
self::destinationUrlField(),
self::rotationVariantsRepeater(),
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('rotation_variants')
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
]),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('link_short_url_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-key',
'validity-tab-card-icon--key',
'link_short_url_card_title',
'link_short_url_card_subtitle',
compact: true,
)),
Group::make()
->extraAttributes(['class' => 'link-tab-panel'])
->schema([
self::shortLinkFusedGroup(),
SegmentControl::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->options([
302 => [
'label' => '302',
'tooltip' => __('filament-short-url::default.redirect_code_302'),
],
301 => [
'label' => '301',
'tooltip' => __('filament-short-url::default.redirect_code_301'),
],
])
->default(fn () => config('filament-short-url.redirect_status_code', 302))
->size('md')
->separators(false)
->required()
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap']),
]),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('link_behavior_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-adjustments-horizontal',
'validity-tab-card-icon--behavior',
'link_behavior_card_title',
'link_behavior_card_subtitle',
compact: true,
)),
Grid::make(['default' => 1, 'md' => 2])
->extraAttributes(['class' => 'link-tab-panel link-tab-panel--flush'])
->schema([
self::toggleCard('is_enabled', 'status', 'link_status_desc', true),
self::toggleCard('forward_query_params', 'forward_query_params', 'forward_query_params_helper', false),
]),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('link_tags_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-tag',
'validity-tab-card-icon--tags',
'link_tags_card_title',
'link_tags_card_subtitle',
compact: true,
)),
Select::make('tags')
->label(__('filament-short-url::default.tags_navigation_label'))
->hiddenLabel()
->multiple()
->maxItems(5)
->relationship('tags', 'name')
->allowHtml()
->getOptionLabelFromRecordUsing(fn ($record) => $record->getOptionHtml())
->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'))
->allowHtml()
->options(ShortUrlTag::getColorOptions())
->default('gray')
->required()
->native(false),
])
->extraFieldWrapperAttributes(['class' => 'link-tab-panel-field']),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('link_notes_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-document-text',
'validity-tab-card-icon--notes',
'link_notes_card_title',
'link_notes_card_subtitle',
compact: true,
)),
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
->hiddenLabel()
->placeholder(__('filament-short-url::default.link_notes_placeholder'))
->rows(3)
->extraFieldWrapperAttributes(['class' => 'link-tab-panel-field']),
]),
]);
}
/**
* Synchronize campaign parameters into the query parameters of the destination URL.
*/
public static function syncUtmToDestination(Get $get, Set $set): void
{
$url = $get('destination_url');
if (! $url) {
return;
}
$parts = parse_url($url);
$query = [];
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
}
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
foreach ($utms as $utm) {
$val = $get($utm);
if ($val !== null && $val !== '') {
$query[$utm] = $val;
} else {
unset($query[$utm]);
}
}
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
$path = $parts['path'] ?? '';
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
}
private static function toggleCard(
string $name,
string $labelKey,
string $descKey,
bool $default,
): Group {
return Group::make()
->extraAttributes(['class' => 'validity-limit-block tracking-field-card'])
->schema([
Placeholder::make("{$name}_header")
->hiddenLabel()
->content(new HtmlString(
'<div class="tracking-field-card-copy">'.
'<p class="tracking-field-card-title">'.e(__("filament-short-url::default.{$labelKey}")).'</p>'.
'<p class="tracking-field-card-desc">'.e(__("filament-short-url::default.{$descKey}")).'</p>'.
'</div>'
)),
Toggle::make($name)
->label(__("filament-short-url::default.{$labelKey}"))
->hiddenLabel()
->default($default)
->inline(false)
->extraFieldWrapperAttributes(['class' => 'tracking-field-card-toggle'])
->extraAttributes([
'aria-label' => __("filament-short-url::default.{$labelKey}"),
]),
]);
}
private static function destinationUrlField(): TextInput
{
return TextInput::make('destination_url')
->label(__('filament-short-url::default.destination_url'))
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
@@ -104,9 +333,16 @@ class LinkTab
app(SafeUrl::class),
])
->live(debounce: 500)
->extraInputAttributes(['data-fsu-destination-url' => 'true'])
->afterStateUpdatedJs(<<<'JS'
window.fsuInitScrape($get, $el.querySelector('input'));
if ($state) {
if ($get('password_active_flag') || $get('password')) {
window.fsuDispatchScraping(false);
$set('is_scraping', false, false, true);
} else if (window.fsuIsScrapeLocked && window.fsuIsScrapeLocked($el.querySelector('input'), $get)) {
window.fsuStopScraping({ get: $get, set: $set });
} else if ($state) {
window.fsuScrape($state, $get, $set, $el.querySelector('input'));
}
JS)
@@ -144,16 +380,20 @@ class LinkTab
$set('utm_content', null);
}
})
->columnSpanFull(),
->columnSpanFull();
}
Repeater::make('rotation_variants')
private static function rotationVariantsRepeater(): Repeater
{
return Repeater::make('rotation_variants')
->hiddenLabel()
->compact()
->extraAttributes(['class' => 'ab-test-repeater'])
->table([
TableColumn::make(__('filament-short-url::default.variant_label'))
->width('30%'),
->width('36%'),
TableColumn::make(__('filament-short-url::default.variant_url'))
->width('70%'),
->width('64%'),
])
->schema([
TextInput::make('label')
@@ -164,6 +404,7 @@ class LinkTab
TextInput::make('url')
->hiddenLabel()
->url()
->placeholder('https://example.com/landing-page')
->required()
->maxLength(2048)
->rules([
@@ -190,24 +431,29 @@ class LinkTab
},
])
->deleteAction(
fn ($action) => $action
fn (Action $action) => $action
->icon(Heroicon::Trash)
->iconButton()
->color('danger')
->size(Size::Small)
->visible(fn (Get $get): bool => count($get('rotation_variants') ?? []) > 2)
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addAction(
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
fn (Action $action) => $action
->icon(Heroicon::Plus)
->outlined()
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addActionLabel(__('filament-short-url::default.add_url'))
->addActionAlignment(Alignment::Start)
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
->columnSpanFull();
}
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('rotation_variants')
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
FusedGroup::make([
private static function shortLinkFusedGroup(): FusedGroup
{
return FusedGroup::make([
Select::make('custom_domain_id')
->hiddenLabel()
->options(function () {
@@ -265,7 +511,16 @@ class LinkTab
->alphaDash()
->maxLength(32)
->default(fn (ShortUrlService $service) => $service->generateKey())
->unique('short_urls', 'url_key', ignoreRecord: true)
->unique(
table: 'short_urls',
column: 'url_key',
ignoreRecord: true,
modifyRuleUsing: function (Unique $rule, Get $get, ?ShortUrl $record): Unique {
$domainScopeId = (int) ($get('custom_domain_id') ?? $record?->custom_domain_id ?? 0);
return $rule->where('domain_scope_id', $domainScopeId);
},
)
->disabled(fn (?ShortUrl $record) => $record && $record->exists && config('filament-short-url.lock_url_key', false))
->placeholder('auto-generated')
->suffixAction(
@@ -278,113 +533,9 @@ class LinkTab
->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false)))
),
])
->extraAttributes(['class' => 'custom-fused'])
->extraAttributes(['class' => 'custom-fused link-short-fused'])
->label(__('filament-short-url::default.short_link_label'))
->columns(2)
->columnSpanFull(),
Select::make('redirect_status_code')
->label(__('filament-short-url::default.redirect_code'))
->options([
302 => __('filament-short-url::default.redirect_code_302'),
301 => __('filament-short-url::default.redirect_code_301'),
])
->native(false)
->default(fn () => config('filament-short-url.redirect_status_code', 302))
->required()->columnSpanFull(),
])->contained(false)->columns(2),
Section::make(__('filament-short-url::default.form_section_options'))->schema([
Toggle::make('is_enabled')
->label(__('filament-short-url::default.status'))
->default(true)
->inline(),
Toggle::make('forward_query_params')
->label(__('filament-short-url::default.forward_query_params'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.forward_query_params_helper'))
->default(false)
->inline(),
])->contained(false)->columns(2),
Section::make()
->schema([
Select::make('tags')
->label(__('filament-short-url::default.tags_navigation_label'))
->multiple()
->maxItems(5)
->relationship('tags', 'name')
->allowHtml()
->getOptionLabelFromRecordUsing(fn ($record) => $record->getOptionHtml())
->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'))
->allowHtml()
->options(ShortUrlTag::getColorOptions())
->default('gray')
->required()
->native(false),
])
->columnSpanFull(),
])
->contained(false)
->columns(1),
Section::make()
->schema([
Textarea::make('notes')
->label(__('filament-short-url::default.notes'))
->rows(3)
->columnSpanFull(),
])->contained(false),
]);
}
/**
* Synchronize campaign parameters into the query parameters of the destination URL.
*/
public static function syncUtmToDestination(Get $get, Set $set): void
{
$url = $get('destination_url');
if (! $url) {
return;
}
$parts = parse_url($url);
$query = [];
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
}
$utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
foreach ($utms as $utm) {
$val = $get($utm);
if ($val !== null && $val !== '') {
$query[$utm] = $val;
} else {
unset($query[$utm]);
}
}
$scheme = isset($parts['scheme']) ? $parts['scheme'].'://' : '';
$host = $parts['host'] ?? '';
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
$path = $parts['path'] ?? '';
$queryString = ! empty($query) ? '?'.http_build_query($query) : '';
$fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : '';
$set('destination_url', $scheme.$host.$port.$path.$queryString.$fragment);
->columnSpanFull();
}
}

View File

@@ -8,12 +8,21 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlPixelResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WebhookPayloadExample;
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
use Filament\Actions\Action;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Enums\Alignment;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\HtmlString;
class MarketingTab
@@ -26,36 +35,102 @@ class MarketingTab
return Tab::make(__('filament-short-url::default.tab_marketing'))
->icon('heroicon-o-megaphone')
->schema([
Section::make(__('filament-short-url::default.marketing_pixels_title'))
->description(__('filament-short-url::default.marketing_pixels_desc'))
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card marketing-pixels-card'])
->schema([
Placeholder::make('marketing_pixels_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-cursor-arrow-rays',
'validity-tab-card-icon--pixels',
'marketing_pixels_title',
'marketing_pixels_desc',
compact: true,
)),
Placeholder::make('marketing_pixels_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! self::hasSelectedPixels($get))
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.marketing_pixels_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.marketing_pixels_empty_desc')).'</p>'.
'</div>'
)),
Select::make('pixels')
->label(__('filament-short-url::default.pixels_navigation_label'))
->hiddenLabel()
->multiple()
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
->preload()
->searchable()
->columnSpanFull(),
])
->contained(false),
->live()
->placeholder(__('filament-short-url::default.marketing_pixels_select_placeholder'))
->createOptionForm(ShortUrlPixelResource::formComponents())
->createOptionUsing(function (array $data): int {
return ShortUrlPixel::query()->create([
'name' => $data['name'],
'type' => $data['type'],
'pixel_id' => $data['pixel_id'],
'is_active' => (bool) ($data['is_active'] ?? true),
])->getKey();
})
->createOptionAction(fn (Action $action): Action => $action
->label(__('filament-short-url::default.empty_state_pixel_action'))
->modalHeading(__('filament-short-url::default.empty_state_pixel_action'))
->icon(Heroicon::Plus)
->iconButton()
->color('gray')
->modalWidth('md')
->modalAutofocus(false)
->tooltip(__('filament-short-url::default.empty_state_pixel_action')))
->extraFieldWrapperAttributes(['class' => 'marketing-pixels-field']),
]),
Section::make(__('filament-short-url::default.marketing_webhooks_title'))
->description(__('filament-short-url::default.marketing_webhooks_desc'))
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card marketing-webhook-card'])
->schema([
Placeholder::make('webhook_info')
Placeholder::make('marketing_webhook_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-bolt',
'validity-tab-card-icon--webhook',
'marketing_webhooks_title',
'marketing_webhooks_desc',
compact: true,
)),
Placeholder::make('marketing_webhook_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! self::hasWebhookUrl($get))
->content(new HtmlString(
'<div class="callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-white/10" data-callout-type="info">'.
'<div class="mt-0.5 w-4" data-component-part="callout-icon">'.
'<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="flex-none size-5 text-neutral-800 dark:text-neutral-300" aria-label="Info">'.
'<path d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm.75-11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.5a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z"></path>'.
'</svg>'.
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.467.732-3.553" /></svg>'.
'</div>'.
'<div class="text-sm prose dark:prose-invert min-w-0 w-full text-neutral-800 dark:text-neutral-300" data-component-part="callout-content">'.
'<span data-as="p">'.
__('filament-short-url::default.webhook_helper_alert').
'</span>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.marketing_webhook_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.marketing_webhook_empty_desc')).'</p>'.
'</div>'
)),
Group::make()
->extraAttributes(['class' => 'marketing-webhook-panel'])
->schema([
Placeholder::make('marketing_webhook_callout')
->hiddenLabel()
->visible(fn (Get $get): bool => self::hasWebhookUrl($get))
->content(new HtmlString(
'<div class="marketing-tab-callout">'.
'<div class="marketing-tab-callout-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>'.
'</div>'.
'<p class="marketing-tab-callout-text">'.e(__('filament-short-url::default.webhook_helper_alert')).'</p>'.
'</div>'
))
->columnSpanFull(),
@@ -63,15 +138,46 @@ class MarketingTab
TextInput::make('webhook_url')
->label(__('filament-short-url::default.webhook_url'))
->placeholder('https://api.yourcrm.com/webhooks/clicks')
->prefixIcon('heroicon-m-link')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
ViewField::make('webhook_payload_example')
->view('filament-short-url::webhook-payload-example')
->columnSpanFull(),
->live(onBlur: true)
->extraFieldWrapperAttributes(['class' => 'marketing-webhook-field']),
Actions::make([
Action::make('show_webhook_payload')
->label(__('filament-short-url::default.webhook_show_payload'))
->icon(Heroicon::CodeBracketSquare)
->color('gray')
->outlined()
->size('sm')
->modalHeading(__('filament-short-url::default.webhook_payload_modal_title'))
->modalDescription(__('filament-short-url::default.webhook_payload_modal_desc'))
->modalWidth('3xl')
->modalContent(fn () => view('filament-short-url::webhook-payload-example', [
'rawJson' => WebhookPayloadExample::visitedEventSampleJson(),
]))
->modalSubmitAction(false)
->modalCancelActionLabel(__('filament-short-url::default.close_button')),
])
->contained(false),
->visible(fn (Get $get): bool => self::hasWebhookUrl($get))
->alignment(Alignment::Start)
->extraAttributes(['class' => 'marketing-webhook-payload-action']),
]),
]),
]);
}
private static function hasSelectedPixels(Get $get): bool
{
$pixels = $get('pixels') ?? [];
return is_array($pixels) && count($pixels) > 0;
}
private static function hasWebhookUrl(Get $get): bool
{
return filled($get('webhook_url'));
}
}

View File

@@ -8,6 +8,8 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
@@ -16,13 +18,16 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\Enums\Alignment;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\HtmlString;
use Livewire\Component;
class PasswordTab
{
@@ -45,84 +50,112 @@ class PasswordTab
->default(false),
Section::make()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password'))
->extraAttributes([
'class' => 'rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition duration-200 ring-0 shadow-none [&>div]:bg-transparent',
])
->contained(false)
->extraAttributes(['class' => 'validity-tab-card password-tab-card'])
->schema([
Group::make([
Placeholder::make('empty_state_icon')
Placeholder::make('password_card_header')
->hiddenLabel()
->content(new HtmlString('
<div class="flex flex-col items-center justify-center text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<svg class="h-6 w-6 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">Brak zabezpieczeń</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">Dodaj hasło, aby ograniczyć dostęp do tego skróconego linku tylko dla wybranych osób.</p>
</div>
')),
->visible(fn (Get $get): bool => ! $get('password_active_flag') || $get('is_entering_password'))
->content(TabCardHeader::make(
'heroicon-o-lock-closed',
'validity-tab-card-icon--password',
'password_card_title',
'password_card_subtitle',
)),
Placeholder::make('password_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password'))
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.password_empty_state_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.password_empty_state_desc')).'</p>'.
'</div>'
)),
Actions::make([
Action::make('setup_password')
->label(__('filament-short-url::default.set_password'))
->icon('heroicon-m-plus')
->color('primary')
->icon(Heroicon::Plus)
->outlined()
->extraAttributes(['class' => 'password-tab-setup-btn'])
->action(fn (Set $set) => $set('is_entering_password', true)),
])->alignment(Alignment::Center),
]),
]),
Section::make(__('filament-short-url::default.password_status_active'))
->visible(fn (Get $get): bool => (bool) $get('password_active_flag'))
->icon('heroicon-m-shield-check')
->iconColor('success')
->description('Link jest zabezpieczony. Dostęp wymaga podania prawidłowego hasła.')
->extraAttributes([
'class' => 'bg-white dark:bg-white/5 ring-1 ring-gray-950/5 dark:ring-white/10 rounded-xl shadow-sm',
])
->headerActions([
->alignment(Alignment::Center)
->extraAttributes(['class' => 'password-tab-setup-action'])
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
Grid::make(['default' => 1, 'md' => 12])
->visible(fn (Get $get): bool => (bool) $get('password_active_flag') && ! $get('is_entering_password'))
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid password-tab-active-toolbar-grid'])
->schema([
Placeholder::make('password_active_status')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-shield-check',
'validity-tab-card-icon--password-active',
'password_status_active',
'password_status_active_desc',
))
->columnSpan(['default' => 12, 'md' => 9]),
Actions::make([
Action::make('change_password')
->label('Zmień')
->icon('heroicon-m-pencil-square')
->label(__('filament-short-url::default.password_change_short'))
->icon(Heroicon::PencilSquare)
->color('gray')
->button()
->outlined()
->size('sm')
->action(function (Set $set) {
->action(function (Set $set, Get $get, Component $livewire) {
$set('password_active_flag', false);
$set('is_entering_password', true);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
self::syncPasswordPreview($livewire, self::isPasswordProtected($get));
}),
Action::make('remove_password')
->label('Usuń')
->icon('heroicon-m-trash')
->label(__('filament-short-url::default.password_remove_short'))
->icon(Heroicon::Trash)
->color('danger')
->button()
->outlined()
->size('sm')
->requiresConfirmation()
->action(function (Set $set, ?ShortUrl $record) {
->action(function (Set $set, ?ShortUrl $record, Component $livewire) {
$set('password', null);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
$set('password_active_flag', false);
$set('is_entering_password', false);
if ($record) {
$record->password = null;
}
self::syncPasswordPreview($livewire, false);
}),
])
->schema([]),
->alignment(Alignment::End)
->extraAttributes(['class' => 'password-tab-active-actions password-tab-toolbar-actions'])
->columnSpan(['default' => 12, 'md' => 3]),
]),
Section::make('Ustawienia hasła')
Group::make()
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
->extraAttributes(['class' => 'password-tab-form-panel'])
->schema([
Placeholder::make('password_form_heading')
->hiddenLabel()
->content(new HtmlString(
'<p class="password-tab-section-title">'.e(__('filament-short-url::default.password_settings_section')).'</p>'
))
->columnSpanFull(),
Grid::make(['default' => 1, 'md' => 2])
->schema([
Group::make([
TextInput::make('new_password_input')
->label(__('filament-short-url::default.new_password'))
->password()
@@ -140,23 +173,26 @@ class PasswordTab
->maxLength(255)
->dehydrated(false)
->required(fn (Get $get): bool => ! empty($get('new_password_input'))),
])->columns(2),
]),
Actions::make([
Action::make('cancel_password')
->label(__('filament-short-url::default.cancel'))
->color('gray')
->action(function (Get $get, Set $set, ?ShortUrl $record) {
$set('password_active_flag', $record && ! empty($record->password));
->outlined()
->action(function (Get $get, Set $set, ?ShortUrl $record, Component $livewire) {
$wasProtected = (bool) ($record && ! empty($record->password));
$set('password_active_flag', $wasProtected);
$set('is_entering_password', false);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
self::syncPasswordPreview($livewire, $wasProtected || filled($get('password')));
}),
Action::make('confirm_password')
->label('Zapisz hasło')
->label(__('filament-short-url::default.save_password'))
->color('primary')
->action(function (Get $get, Set $set) {
->action(function (Get $get, Set $set, Component $livewire) {
$password = $get('new_password_input');
$confirm = $get('new_password_confirmation_input');
@@ -165,6 +201,7 @@ class PasswordTab
return;
}
if ($password !== $confirm) {
Notification::make()->title(__('filament-short-url::default.password_mismatch_error'))->danger()->send();
@@ -174,20 +211,60 @@ class PasswordTab
$set('password', $password);
$set('password_active_flag', true);
$set('is_entering_password', false);
$set('new_password_input', null);
$set('new_password_confirmation_input', null);
PasswordOpenGraphGuard::clearFormState($set, $livewire);
}),
])->alignment(Alignment::End),
])
->alignment(Alignment::End)
->extraAttributes(['class' => 'password-tab-form-actions']),
]),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card password-warning-card'])
->schema([
Grid::make(['default' => 1, 'md' => 12])
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
->schema([
Placeholder::make('warning_page_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-exclamation-triangle',
'validity-tab-card-icon--warning',
'warning_page_card_title',
'warning_page_card_subtitle',
))
->columnSpan(['default' => 12, 'md' => 9]),
Toggle::make('show_warning_page')
->label(__('filament-short-url::default.show_warning_page'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.show_warning_page_helper'))
->hiddenLabel()
->default(false)
->inline(false),
->live()
->inline(false)
->extraFieldWrapperAttributes([
'class' => 'validity-tab-card-toolbar-action tracking-card-toolbar-action',
])
->compact()
->extraAttributes(['class' => 'bg-transparent border-none shadow-none mt-4']),
->extraAttributes([
'aria-label' => __('filament-short-url::default.show_warning_page'),
])
->columnSpan(['default' => 12, 'md' => 3]),
]),
]),
]);
}
private static function isPasswordProtected(Get $get): bool
{
return PasswordOpenGraphGuard::isFormPasswordProtected($get);
}
private static function syncPasswordPreview(Component $livewire, bool $protected): void
{
$protectedJs = $protected ? 'true' : 'false';
$livewire->js('window.dispatchEvent(new CustomEvent("fsu-password-protection-changed", { detail: { protected: '.$protectedJs.' } }))');
}
}

View File

@@ -8,18 +8,24 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Bjanczak\FilamentShortUrl\Services\OgImageImporter;
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@@ -34,41 +40,70 @@ class SeoAndCloakingTab
return Tab::make(__('filament-short-url::default.tab_seo_social'))
->icon('heroicon-o-globe-alt')
->schema([
Section::make(__('filament-short-url::default.seo_section_title'))
->description(__('filament-short-url::default.seo_section_desc'))
->schema([
Toggle::make('is_cloaked')
->label(__('filament-short-url::default.is_cloaked'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.is_cloaked_helper'))
->default(false)
->live()
->inline(),
Toggle::make('do_index')
->label(__('filament-short-url::default.do_index'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.do_index_helper'))
->default(false)
->live()
->inline(),
])
Section::make()
->contained(false)
->columns(2),
->extraAttributes(['class' => 'validity-tab-card seo-settings-card'])
->schema([
Placeholder::make('seo_settings_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-magnifying-glass-circle',
'validity-tab-card-icon--seo',
'seo_section_title',
'seo_section_desc',
compact: true,
)),
Section::make(__('filament-short-url::default.og_section_title'))
->description(__('filament-short-url::default.og_section_desc'))
Grid::make(['default' => 1, 'md' => 2])
->extraAttributes(['class' => 'seo-settings-grid'])
->schema([
self::toggleCard(
'is_cloaked',
'is_cloaked',
'is_cloaked_helper',
false,
),
self::toggleCard(
'do_index',
'do_index',
'do_index_helper',
false,
),
]),
]),
Section::make()
->contained(false)
->visible(fn (Get $get): bool => ! PasswordOpenGraphGuard::isFormPasswordProtected($get))
->extraAttributes(['class' => 'validity-tab-card seo-og-card'])
->schema([
Placeholder::make('seo_og_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-photo',
'validity-tab-card-icon--og',
'og_section_title',
'og_section_desc',
compact: true,
)),
Group::make()
->extraAttributes(['class' => 'seo-og-panel'])
->schema([
TextInput::make('og_title')
->label(__('filament-short-url::default.og_title'))
->placeholder('Custom Open Graph Title')
->placeholder(__('filament-short-url::default.og_title_placeholder'))
->maxLength(255)
->live(),
->live()
->extraFieldWrapperAttributes(['class' => 'seo-og-field']),
Textarea::make('og_description')
->label(__('filament-short-url::default.og_description'))
->placeholder('Custom Open Graph Description')
->placeholder(__('filament-short-url::default.og_description_placeholder'))
->maxLength(500)
->rows(3)
->live()
->extraFieldWrapperAttributes(['class' => 'seo-og-field'])
->columnSpanFull(),
FileUpload::make('og_image')
@@ -97,12 +132,42 @@ class SeoAndCloakingTab
return $storedPath;
})
->live()
->afterStateHydrated(function (mixed $state, Component $livewire): void {
if (blank($state)) {
return;
}
$livewire->js('window.fsuLockScrape && window.fsuLockScrape()');
})
->afterStateUpdated(function (mixed $state, Set $set, Component $livewire): void {
if (blank($state)) {
return;
}
$set('og_image_scraped', null);
$set('is_scraping', false);
$livewire->js('window.fsuDispatchScraping(false); window.dispatchEvent(new CustomEvent("fsu-og-image-updated"));');
})
->afterStateUpdatedJs(<<<'JS'
if ($state) {
window.fsuOnManualOgImage && window.fsuOnManualOgImage($get, $set);
} else {
window.fsuRetryScrapeAfterImageRemoved && window.fsuRetryScrapeAfterImageRemoved($get, $set);
}
JS)
->extraFieldWrapperAttributes(['class' => 'seo-og-field'])
->columnSpanFull(),
Hidden::make('og_image_scraped')
->dehydrated(false)
->live()
->afterStateUpdated(function (?string $state, Set $set, Get $get, OgImageImporter $importer, Component $livewire): void {
if (PasswordOpenGraphGuard::isFormPasswordProtected($get)) {
$set('og_image_scraped', null);
return;
}
if (blank($state) || filled($get('og_image'))) {
$set('is_scraping', false);
$livewire->js('window.fsuDispatchScraping(false)');
@@ -115,14 +180,46 @@ class SeoAndCloakingTab
if ($path !== null) {
$set('og_image', $path);
$livewire->js('window.fsuLockScrape && window.fsuLockScrape()');
}
} finally {
$set('is_scraping', false);
$livewire->js('window.fsuDispatchScraping(false)');
$livewire->js('window.fsuDispatchScraping(false); window.fsuLockScrape && window.fsuLockScrape()');
}
}),
])
->contained(false),
]),
]),
]);
}
private static function toggleCard(
string $name,
string $labelKey,
string $descKey,
bool $default,
): Group {
return Group::make()
->extraAttributes(['class' => 'validity-limit-block tracking-field-card'])
->schema([
Placeholder::make("{$name}_header")
->hiddenLabel()
->content(new HtmlString(
'<div class="tracking-field-card-copy">'.
'<p class="tracking-field-card-title">'.e(__("filament-short-url::default.{$labelKey}")).'</p>'.
'<p class="tracking-field-card-desc">'.e(__("filament-short-url::default.{$descKey}")).'</p>'.
'</div>'
)),
Toggle::make($name)
->label(__("filament-short-url::default.{$labelKey}"))
->hiddenLabel()
->default($default)
->live()
->inline(false)
->extraFieldWrapperAttributes(['class' => 'tracking-field-card-toggle'])
->extraAttributes([
'aria-label' => __("filament-short-url::default.{$labelKey}"),
]),
]);
}
}

View File

@@ -8,23 +8,32 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\TrafficSplitter;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Repeater\TableColumn;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\Size;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
@@ -38,19 +47,122 @@ class TargetingTab
return Tab::make(__('filament-short-url::default.tab_targeting'))
->icon('heroicon-o-funnel')
->schema([
Section::make(__('filament-short-url::default.targeting_rules'))
->compact()
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Repeater::make('targeting_rules')
->label(__('filament-short-url::default.targeting_rules'))
Placeholder::make('targeting_rules_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-funnel',
'validity-tab-card-icon--targeting',
'targeting_rules_card_title',
'targeting_rules_card_subtitle',
)),
Placeholder::make('targeting_rules_empty_state')
->hiddenLabel()
->extraAttributes(['class' => 'targeting-rules-empty-state'])
->visible(fn (Get $get): bool => count($get('targeting_rules') ?? []) === 0)
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.targeting_rules_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.targeting_rules_empty_desc')).'</p>'.
'</div>'
)),
self::targetingRulesRepeater(),
]),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Grid::make(['default' => 1, 'md' => 12])
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
->schema([
Placeholder::make('app_linking_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-device-phone-mobile',
'validity-tab-card-icon--app-linking',
'app_linking_card_title',
'app_linking_card_subtitle',
))
->columnSpan(['default' => 12, 'md' => 9]),
Toggle::make('auto_open_app_mobile')
->label(__('filament-short-url::default.auto_open_app_mobile'))
->hiddenLabel()
->default(false)
->live()
->inline(false)
->extraFieldWrapperAttributes([
'class' => 'validity-tab-card-toolbar-action tracking-card-toolbar-action',
])
->extraAttributes([
'aria-label' => __('filament-short-url::default.auto_open_app_mobile'),
])
->columnSpan(['default' => 12, 'md' => 3]),
]),
Placeholder::make('app_linking_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! $get('auto_open_app_mobile'))
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.app_linking_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.app_linking_empty_desc')).'</p>'.
'</div>'
)),
Group::make()
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile'))
->extraAttributes(['class' => 'targeting-app-linking-panel'])
->schema([
ViewField::make('app_linking_preview')
->view('filament-short-url::app-linking-preview')
->viewData(fn (Get $get) => [
'destinationUrl' => $get('destination_url'),
])
->columnSpanFull(),
]),
]),
]);
}
private static function targetingRulesRepeater(): Repeater
{
return Repeater::make('targeting_rules')
->hiddenLabel()
->extraAttributes(['class' => 'targeting-rules-repeater'])
->defaultItems(0)
->maxItems(10)
->reorderable(false)
->collapsible()
->collapsed()
->columns(12)
->addActionLabel(__('filament-short-url::default.add_targeting_rule'))
->addAction(
fn (Action $action) => $action
->icon(Heroicon::Plus)
->outlined()
->after(fn (Repeater $component) => self::refreshTargetingRulesEmptyState($component))
)
->addActionAlignment(Alignment::Start)
->grid(1)
->deleteAction(
fn (Action $action) => $action
->icon(Heroicon::Trash)
->iconButton()
->color('danger')
->size(Size::Small)
->extraAttributes(['class' => 'targeting-rule-delete-btn'])
->after(fn (Repeater $component) => self::refreshTargetingRulesEmptyState($component))
)
->afterStateHydrated(function (Repeater $component, $state) {
if (! is_array($state)) {
$component->state([]);
@@ -58,7 +170,6 @@ class TargetingTab
return;
}
// If it is legacy format (has 'type' key)
if (isset($state['type'])) {
$type = $state['type'];
$newRules = [];
@@ -150,26 +261,64 @@ class TargetingTab
$component->state($state);
})
->schema([
Select::make('match')
Group::make()
->extraAttributes(['class' => 'targeting-rule-item'])
->schema([
Placeholder::make('targeting_rule_conditions_heading')
->hiddenLabel()
->content(new HtmlString(
'<p class="targeting-rule-section-title">'.e(__('filament-short-url::default.targeting_rule_conditions_title')).'</p>'
))
->columnSpanFull(),
SegmentControl::make('match')
->label(__('filament-short-url::default.match'))
->options([
'or' => __('filament-short-url::default.match_or'),
'and' => __('filament-short-url::default.match_and'),
'or' => [
'label' => 'OR',
'tooltip' => __('filament-short-url::default.match_or'),
],
'and' => [
'label' => 'AND',
'tooltip' => __('filament-short-url::default.match_and'),
],
])
->icons([
'or' => 'heroicon-o-squares-2x2',
'and' => 'heroicon-o-squares-plus',
])
->default('or')
->size('md')
->separators()
->required()
->columnSpan(3),
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
->columnSpanFull(),
Select::make('destination_type')
self::filtersBuilder(),
Placeholder::make('targeting_rule_destination_heading')
->hiddenLabel()
->content(new HtmlString(
'<p class="targeting-rule-section-title targeting-rule-section-title--destination">'.e(__('filament-short-url::default.targeting_rule_destination_title')).'</p>'
))
->columnSpanFull(),
SegmentControl::make('destination_type')
->label(__('filament-short-url::default.destination_type'))
->options([
'single' => __('filament-short-url::default.destination_type_single'),
'split' => __('filament-short-url::default.destination_type_split'),
])
->icons([
'single' => 'heroicon-o-link',
'split' => 'heroicon-o-arrow-path-rounded-square',
])
->default('single')
->live()
->size('md')
->separators()
->required()
->columnSpan(3)
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
->afterStateUpdated(function ($state, Set $set, Get $get) {
if ($state === 'split' && empty($get('variants'))) {
$set('variants', [
@@ -177,27 +326,51 @@ class TargetingTab
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
]);
}
}),
})
->columnSpanFull(),
TextInput::make('url')
->label(__('filament-short-url::default.direct_to_url'))
->url()
->required(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->visible(fn (Get $get): bool => $get('destination_type') === 'single' || ! $get('destination_type'))
->placeholder('https://example.com/landing-page')
->maxLength(2048)
->rules([
app(SafeUrl::class),
])
->columnSpan(6),
->columnSpanFull(),
Repeater::make('variants')
Group::make()
->extraAttributes(['class' => 'targeting-tab-panel'])
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull()
->schema([
self::variantsRepeater(),
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('variants')
->columnSpanFull(),
]),
])
->columnSpanFull(),
])
->columnSpanFull()
->default([]);
}
private static function variantsRepeater(): Repeater
{
return Repeater::make('variants')
->hiddenLabel()
->compact()
->extraAttributes(['class' => 'ab-test-repeater'])
->table([
TableColumn::make(__('filament-short-url::default.variant_label'))
->width('30%'),
->width('36%'),
TableColumn::make(__('filament-short-url::default.variant_url'))
->width('70%'),
->width('64%'),
])
->schema([
TextInput::make('label')
@@ -208,6 +381,7 @@ class TargetingTab
TextInput::make('url')
->hiddenLabel()
->url()
->placeholder('https://example.com/landing-page')
->required()
->maxLength(2048)
->rules([
@@ -234,24 +408,34 @@ class TargetingTab
},
])
->deleteAction(
fn ($action) => $action
fn (Action $action) => $action
->icon(Heroicon::Trash)
->iconButton()
->color('danger')
->size(Size::Small)
->visible(fn (Get $get): bool => count($get('variants') ?? []) > 2)
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addAction(
fn ($action) => $action->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
fn (Action $action) => $action
->icon(Heroicon::Plus)
->outlined()
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
)
->addActionLabel(__('filament-short-url::default.add_url'))
->addActionAlignment(Alignment::Start)
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
->columnSpanFull();
}
TrafficSplitter::make('traffic_split')
->label(__('filament-short-url::default.traffic_split'))
->target('variants')
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
->columnSpanFull(),
private static function refreshTargetingRulesEmptyState(Repeater $component): void
{
$component->getLivewire()->partiallyRenderSchemaComponent('targeting_rules_empty_state');
}
Builder::make('filters')
->label(__('filament-short-url::default.add_filter'))
private static function filtersBuilder(): Builder
{
return Builder::make('filters')
->hiddenLabel()
->addActionLabel(__('filament-short-url::default.add_filter'))
->reorderable(false)
@@ -259,6 +443,7 @@ class TargetingTab
->blockNumbers(false)
->addBetweenAction(fn ($action) => $action->hidden())
->minItems(1)
->extraAttributes(['class' => 'targeting-filters-builder'])
->blocks([
Block::make('device')
->label(fn (?array $state) => $state === null
@@ -372,34 +557,11 @@ class TargetingTab
}
$types = collect($value)->pluck('type');
if ($types->duplicates()->isNotEmpty()) {
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
$fail(__('filament-short-url::default.filter_duplicate_error'));
}
};
},
])
->columnSpanFull(),
])
->columnSpanFull()
->default([]),
])->contained(false),
Section::make(__('filament-short-url::default.form_section_app_linking'))
->schema([
Toggle::make('auto_open_app_mobile')
->label(__('filament-short-url::default.auto_open_app_mobile'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.auto_open_app_mobile_helper'))
->default(false)
->inline(false)
->live(),
ViewField::make('app_linking_preview')
->view('filament-short-url::app-linking-preview')
->viewData(fn (Get $get) => [
'destinationUrl' => $get('destination_url'),
])
->columnSpanFull()
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
])->contained(false),
]);
->columnSpanFull();
}
}

View File

@@ -8,12 +8,17 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString;
class TrackingTab
{
@@ -22,79 +27,107 @@ class TrackingTab
return Tab::make(__('filament-short-url::default.tab_tracking'))
->icon('heroicon-o-chart-bar')
->schema([
Section::make(__('filament-short-url::default.form_section_tracking'))
->description('Włącz lub wyłącz globalne śledzenie ruchu i statystyk dla tego skróconego linku.')
->icon('heroicon-o-presentation-chart-line')
->aside()
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Grid::make(['default' => 1, 'md' => 12])
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
->schema([
Placeholder::make('tracking_visit_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-chart-bar',
'validity-tab-card-icon--tracking',
'tracking_visit_card_title',
'tracking_visit_card_subtitle',
))
->columnSpan(['default' => 12, 'md' => 9]),
Toggle::make('track_visits')
->label(__('filament-short-url::default.track_visits'))
->hiddenLabel()
->default(fn () => config('filament-short-url.tracking.enabled', true))
->live()
->inline(false)
->columnSpanFull(),
->extraFieldWrapperAttributes([
'class' => 'validity-tab-card-toolbar-action tracking-card-toolbar-action',
])
->extraAttributes([
'aria-label' => __('filament-short-url::default.track_visits'),
])
->columnSpan(['default' => 12, 'md' => 3]),
]),
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
->description('Dostosuj poziom szczegółowości zbieranych danych, włączając lub wyłączając konkretne metryki ze względów analitycznych i prywatności.')
->icon('heroicon-o-adjustments-horizontal')
->aside()
->hidden(fn (Get $get): bool => ! $get('track_visits'))
Placeholder::make('tracking_visit_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! $get('track_visits'))
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.tracking_visit_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.tracking_visit_empty_desc')).'</p>'.
'</div>'
)),
Group::make()
->visible(fn (Get $get): bool => (bool) $get('track_visits'))
->extraAttributes(['class' => 'tracking-fields-panel'])
->schema([
Toggle::make('track_ip_address')
->label(__('filament-short-url::default.track_ip'))
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Placeholder::make('tracking_fields_identity_heading')
->hiddenLabel()
->content(new HtmlString(
'<p class="tracking-fields-category">'.e(__('filament-short-url::default.tracking_fields_identity_title')).'</p>'
))
->columnSpanFull(),
Toggle::make('track_browser')
->label(__('filament-short-url::default.track_browser'))
->default(fn () => config('filament-short-url.tracking.fields.browser', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Grid::make(['default' => 1, 'md' => 2])
->schema([
...self::trackedFieldCards([
['track_ip_address', 'track_ip', 'track_ip_desc', 'filament-short-url.tracking.fields.ip_address'],
['track_referer_url', 'track_referer', 'track_referer_desc', 'filament-short-url.tracking.fields.referer_url'],
]),
]),
Toggle::make('track_browser_version')
->label(__('filament-short-url::default.track_browser_version'))
->default(fn () => config('filament-short-url.tracking.fields.browser_version', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Placeholder::make('tracking_fields_device_heading')
->hiddenLabel()
->content(new HtmlString(
'<p class="tracking-fields-category">'.e(__('filament-short-url::default.tracking_fields_device_title')).'</p>'
))
->columnSpanFull(),
Toggle::make('track_operating_system')
->label(__('filament-short-url::default.track_os'))
->default(fn () => config('filament-short-url.tracking.fields.operating_system', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Grid::make(['default' => 1, 'md' => 2])
->schema([
...self::trackedFieldCards([
['track_browser', 'track_browser', 'track_browser_desc', 'filament-short-url.tracking.fields.browser'],
['track_browser_version', 'track_browser_version', 'track_browser_version_desc', 'filament-short-url.tracking.fields.browser_version'],
['track_operating_system', 'track_os', 'track_os_desc', 'filament-short-url.tracking.fields.operating_system'],
['track_operating_system_version', 'track_os_version', 'track_os_version_desc', 'filament-short-url.tracking.fields.operating_system_version'],
['track_device_type', 'track_device_type', 'track_device_type_desc', 'filament-short-url.tracking.fields.device_type'],
['track_browser_language', 'track_browser_language', 'track_browser_language_desc', 'filament-short-url.tracking.fields.browser_language'],
]),
]),
]),
]),
Toggle::make('track_operating_system_version')
->label(__('filament-short-url::default.track_os_version'))
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card tracking-utm-card'])
->schema([
Placeholder::make('tracking_utm_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-megaphone',
'validity-tab-card-icon--utm',
'tracking_utm_card_title',
'tracking_utm_card_subtitle',
compact: true,
)),
Toggle::make('track_device_type')
->label(__('filament-short-url::default.track_device_type'))
->default(fn () => config('filament-short-url.tracking.fields.device_type', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_referer_url')
->label(__('filament-short-url::default.track_referer'))
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
Toggle::make('track_browser_language')
->label(__('filament-short-url::default.track_browser_language'))
->default(fn () => config('filament-short-url.tracking.fields.browser_language', true))
->inline(false)
->disabled(fn (Get $get): bool => ! $get('track_visits')),
])
->columns(2),
Section::make(__('filament-short-url::default.utm_builder'))
->description(__('filament-short-url::default.utm_builder_helper'))
->icon('heroicon-o-tag')
->aside()
Grid::make(['default' => 1, 'md' => 2])
->extraAttributes(['class' => 'tracking-utm-fields'])
->schema([
TextInput::make('utm_source')
->label(__('filament-short-url::default.utm_source'))
@@ -136,21 +169,75 @@ class TrackingTab
->live(onBlur: true)
->columnSpanFull()
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
])
->columns(2),
]),
]),
Section::make(__('filament-short-url::default.form_section_analytics'))
->description('Zintegruj ten link z Google Analytics, podając identyfikator strumienia danych.')
->icon('heroicon-o-chart-pie')
->aside()
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Placeholder::make('tracking_ga_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-chart-pie',
'validity-tab-card-icon--ga',
'tracking_ga_card_title',
'tracking_ga_card_subtitle',
compact: true,
)),
TextInput::make('ga_tracking_id')
->label(__('filament-short-url::default.ga_tracking_id'))
->hintIcon('heroicon-m-information-circle', tooltip: __('filament-short-url::default.ga_tracking_id_helper'))
->placeholder('G-XXXXXXXXXX')
->prefixIcon('heroicon-m-chart-bar')
->regex('/^G-[A-Z0-9]+$/')
->nullable(),
->nullable()
->extraFieldWrapperAttributes(['class' => 'tracking-ga-field']),
]),
]);
}
/**
* @param array<int, array{0: string, 1: string, 2: string, 3: string}> $fields
* @return array<int, Group>
*/
private static function trackedFieldCards(array $fields): array
{
return array_map(
fn (array $field): Group => self::trackedFieldCard(...$field),
$fields,
);
}
private static function trackedFieldCard(
string $name,
string $labelKey,
string $descKey,
string $configKey,
): Group {
return Group::make()
->extraAttributes(['class' => 'validity-limit-block tracking-field-card'])
->schema([
Placeholder::make("{$name}_header")
->hiddenLabel()
->content(new HtmlString(
'<div class="tracking-field-card-copy">'.
'<p class="tracking-field-card-title">'.e(__("filament-short-url::default.{$labelKey}")).'</p>'.
'<p class="tracking-field-card-desc">'.e(__("filament-short-url::default.{$descKey}")).'</p>'.
'</div>'
)),
Toggle::make($name)
->label(__("filament-short-url::default.{$labelKey}"))
->hiddenLabel()
->default(fn () => config($configKey, true))
->inline(false)
->extraFieldWrapperAttributes([
'class' => 'tracking-field-card-toggle',
])
->extraAttributes([
'aria-label' => __("filament-short-url::default.{$labelKey}"),
]),
]);
}

View File

@@ -8,14 +8,19 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\NumberStepper;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\HtmlString;
class ValidityAndLimitsTab
{
@@ -28,14 +33,40 @@ class ValidityAndLimitsTab
->icon('heroicon-o-clock')
->schema([
Section::make(__('filament-short-url::default.expiration_dates_section_title'))
->description('Zarządzaj dostępnością linku w czasie. Możesz zaplanować start kampanii lub jej automatyczne zakończenie.')
->description(__('filament-short-url::default.expiration_dates_section_desc'))
->icon('heroicon-o-calendar-days')
->aside()
->contained(false)
->schema([
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Grid::make(['default' => 1, 'md' => 12])
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
->schema([
Placeholder::make('schedule_card_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-calendar-days',
'validity-tab-card-icon--schedule',
'validity_schedule_card_title',
'validity_schedule_card_subtitle',
))
->columnSpan(['default' => 12, 'md' => 9]),
Toggle::make('use_date_validity')
->label(__('filament-short-url::default.use_date_validity'))
->hiddenLabel()
->dehydrated(false)
->live()
->inline(false)
->extraFieldWrapperAttributes([
'class' => 'validity-tab-card-toolbar-action',
])
->extraAttributes([
'aria-label' => __('filament-short-url::default.use_date_validity'),
])
->columnSpan(['default' => 12, 'md' => 3])
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
})
@@ -48,10 +79,45 @@ class ValidityAndLimitsTab
$set('expiration_redirect_url', null);
}
}),
]),
Group::make([
Placeholder::make('schedule_empty_state')
->hiddenLabel()
->visible(fn (Get $get): bool => ! $get('use_date_validity'))
->content(new HtmlString(
'<div class="validity-tab-empty">'.
'<div class="validity-tab-empty-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>'.
'</div>'.
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.validity_schedule_empty_title')).'</p>'.
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.validity_schedule_empty_desc')).'</p>'.
'</div>'
)),
Group::make()
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
->extraAttributes(['class' => 'validity-schedule-panel'])
->schema([
Placeholder::make('schedule_timeline_hint')
->hiddenLabel()
->content(new HtmlString(
'<div class="validity-timeline-hint">'.
'<span class="validity-timeline-dot validity-timeline-dot--start"></span>'.
'<span class="validity-timeline-line"></span>'.
'<span class="validity-timeline-dot validity-timeline-dot--end"></span>'.
'<span class="validity-timeline-label">'.e(__('filament-short-url::default.validity_schedule_timeline_label')).'</span>'.
'</div>'
))
->columnSpanFull(),
Grid::make([
'default' => 1,
'md' => 2,
])
->schema([
DateTimePicker::make('activated_at')
->label(__('filament-short-url::default.activated_at'))
->helperText(__('filament-short-url::default.validity_activated_at_helper'))
->prefixIcon('heroicon-m-play-circle')
->nullable()
->native(false)
@@ -63,44 +129,112 @@ class ValidityAndLimitsTab
DateTimePicker::make('expires_at')
->label(__('filament-short-url::default.expires_at'))
->helperText(__('filament-short-url::default.validity_expires_at_helper'))
->prefixIcon('heroicon-m-stop-circle')
->nullable()
->native(false)
->withoutSeconds()
->live(onBlur: true)
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
]),
TextInput::make('expiration_redirect_url')
->label(__('filament-short-url::default.expiration_redirect_url'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.expiration_redirect_url_helper'))
->placeholder('https://example.com/campaign-ended')
->url()
->maxLength(2048)
->nullable()
->columnSpanFull(),
])
->columns(2)
->visible(fn (Get $get): bool => (bool) $get('use_date_validity')),
]),
]),
]),
Section::make(__('filament-short-url::default.visit_limits_section_title'))
->description('Ogranicz maksymalną liczbę kliknięć. Idealne do biletów jednorazowych lub ofert limitowanych.')
->description(__('filament-short-url::default.visit_limits_section_desc'))
->icon('heroicon-o-cursor-arrow-rays')
->aside()
->contained(false)
->schema([
Section::make()
->contained(false)
->extraAttributes(['class' => 'validity-tab-card'])
->schema([
Grid::make([
'default' => 1,
'lg' => 12,
])
->schema([
Group::make()
->columnSpan(['default' => 1, 'lg' => 7])
->extraAttributes(['class' => 'validity-limit-block'])
->schema([
Grid::make(['default' => 1, 'md' => 12])
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
->schema([
Placeholder::make('single_use_header')
->hiddenLabel()
->content(TabCardHeader::make(
'heroicon-o-lock-closed',
'validity-tab-card-icon--limit',
'single_use',
'single_use_helper',
))
->columnSpan(['default' => 12, 'md' => 9]),
Toggle::make('single_use')
->label(__('filament-short-url::default.single_use'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.single_use_helper'))
->hiddenLabel()
->default(false)
->live(),
->live()
->inline(false)
->extraFieldWrapperAttributes([
'class' => 'validity-tab-card-toolbar-action',
])
->extraAttributes([
'aria-label' => __('filament-short-url::default.single_use'),
])
->columnSpan(['default' => 12, 'md' => 3]),
]),
]),
TextInput::make('max_visits')
->label(__('filament-short-url::default.max_visits'))
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.max_visits_helper'))
->numeric()
->integer()
Group::make()
->columnSpan(['default' => 1, 'lg' => 5])
->extraAttributes([
'class' => 'validity-limit-block validity-limit-block--counter validity-max-visits-card',
])
->schema([
Placeholder::make('max_visits_header')
->hiddenLabel()
->content(new HtmlString(
'<p class="validity-tab-card-title">'.e(__('filament-short-url::default.max_visits')).'</p>'.
'<p class="validity-tab-card-subtitle">'.e(__('filament-short-url::default.max_visits_helper')).'</p>'
)),
NumberStepper::make('max_visits')
->hiddenLabel()
->minValue(1)
->nullable()
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
->nullLabel(__('filament-short-url::default.max_visits_no_limit'))
->suffix(__('filament-short-url::default.max_visits_suffix'))
->variant('outline')
->size('md')
->disabled(fn (Get $get): bool => (bool) $get('single_use'))
->extraFieldWrapperAttributes(['class' => 'validity-stepper-wrap']),
Placeholder::make('max_visits_lock_overlay')
->hiddenLabel()
->visible(fn (Get $get): bool => (bool) $get('single_use'))
->content(new HtmlString(
'<div class="validity-max-visits-lock" role="presentation">'.
'<span class="sr-only">'.e(__('filament-short-url::default.validity_max_visits_locked')).'</span>'.
'<div class="validity-max-visits-lock-icon">'.
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-7"><path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z" clip-rule="evenodd" /></svg>'.
'</div>'.
'</div>'
)),
]),
]),
]),
]),
]);
}

View File

@@ -3,7 +3,9 @@
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Support\LockedUrlKeyGuard;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
@@ -12,6 +14,7 @@ use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -84,6 +87,11 @@ class ShortUrlsTable
->stickyModalHeader()
->modalCancelAction(false)
->extraModalWindowAttributes(['class' => 'update-link-modal modal-fsl'])
->mutateRecordDataUsing(fn (array $data): array => PasswordOpenGraphGuard::sanitizeRecordDataForFill($data))
->mutateFormDataUsing(fn (array $data, ShortUrl $record): array => LockedUrlKeyGuard::sanitizeSaveData(
PasswordOpenGraphGuard::sanitizeSaveData($data),
$record,
))
->modalFooterActions(static fn (EditAction $action): array => [
Action::make('save_changes')
->label(__('filament-actions::edit.single.modal.actions.save.label'))
@@ -111,6 +119,10 @@ class ShortUrlsTable
$record = $action->getRecord();
$livewire = $action->getLivewire();
$state = $livewire->getMountedTableActionForm()->getState();
$state = LockedUrlKeyGuard::sanitizeSaveData(
PasswordOpenGraphGuard::sanitizeSaveData($state),
$record,
);
$action->process(function () use ($record, $state) {
$record->update($state);
@@ -158,6 +170,61 @@ class ShortUrlsTable
->icon('heroicon-o-chart-bar')
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
Action::make('publicStats')
->label(fn () => new HtmlString('<div class="flex items-center justify-between w-full min-w-[140px] text-left"><span>'.__('filament-short-url::default.action_public_stats').'</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">P</span></div>'))
->icon('heroicon-o-globe-alt')
->modalHeading(__('filament-short-url::default.public_stats_modal_title'))
->modalDescription(__('filament-short-url::default.public_stats_modal_description'))
->modalWidth('lg')
->modalSubmitActionLabel(__('filament-short-url::default.public_stats_save'))
->fillForm(fn (ShortUrl $record): array => [
'public_stats_enabled' => (bool) $record->public_stats_enabled,
'public_stats_password' => '',
])
->form([
Forms\Components\Toggle::make('public_stats_enabled')
->label(__('filament-short-url::default.public_stats_enabled_label'))
->live(),
Forms\Components\TextInput::make('public_stats_password')
->label(__('filament-short-url::default.public_stats_password_label'))
->password()
->revealable()
->maxLength(255)
->visible(fn (Get $get): bool => (bool) $get('public_stats_enabled'))
->helperText(__('filament-short-url::default.public_stats_password_helper')),
Forms\Components\Placeholder::make('public_stats_disabled_hint')
->hiddenLabel()
->visible(fn (Get $get): bool => ! (bool) $get('public_stats_enabled'))
->content(__('filament-short-url::default.public_stats_disabled_hint')),
Forms\Components\Placeholder::make('public_stats_url')
->label(__('filament-short-url::default.public_stats_link_label'))
->visible(fn (Get $get): bool => (bool) $get('public_stats_enabled'))
->content(fn (ShortUrl $record) => view('filament-short-url::table.public-stats-url-field', ['record' => $record])),
])
->action(function (ShortUrl $record, array $data, Action $action): void {
$payload = [
'public_stats_enabled' => (bool) $data['public_stats_enabled'],
];
if (! $payload['public_stats_enabled']) {
$payload['public_stats_password'] = null;
} elseif (filled($data['public_stats_password'] ?? null)) {
$payload['public_stats_password'] = $data['public_stats_password'];
}
$record->update($payload);
$action->successNotificationTitle(
$payload['public_stats_enabled']
? __('filament-short-url::default.public_stats_saved_enabled')
: __('filament-short-url::default.public_stats_saved_disabled')
);
$action->success();
}),
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>'))

View File

@@ -5,6 +5,7 @@ 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 Bjanczak\FilamentShortUrl\Services\LiveFeedBroadcaster;
use Filament\Widgets\Widget;
class ShortUrlLiveFeedWidget extends Widget
@@ -27,7 +28,27 @@ class ShortUrlLiveFeedWidget extends Widget
protected string $view = 'filament-short-url::widgets.live-feed';
/**
* Called on every wire:poll tick.
* Called when the SSE stream reports a newer visit id.
*/
public function onStreamUpdate(int $latestId): void
{
if (! $this->record) {
$this->skipRender();
return;
}
if ($latestId <= $this->latestVisitId) {
$this->skipRender();
return;
}
$this->latestVisitId = $latestId;
}
/**
* Called on every wire:poll tick (legacy) or from SSE client.
*
* Does a single O(1) MAX(id) query against the composite index.
* If nothing changed since the last render → skipRender() → ~100-byte response.
@@ -61,7 +82,10 @@ class ShortUrlLiveFeedWidget extends Widget
protected function getViewData(): array
{
if (! $this->record) {
return ['visits' => []];
return [
'visits' => [],
'usesRedisPush' => LiveFeedBroadcaster::usesRedisPush(),
];
}
// BUG FIX #1: Include latestVisitId in the cache key.
@@ -132,6 +156,6 @@ class ShortUrlLiveFeedWidget extends Widget
->max('id');
}
return ['visits' => $visits];
return ['visits' => $visits, 'usesRedisPush' => LiveFeedBroadcaster::usesRedisPush()];
}
}

View File

@@ -43,63 +43,21 @@ class ShortUrlSecurityBreakdownWidget extends Widget
];
}
$totalClicks = 0;
$humanClicks = 0;
$botClicks = 0;
$humanPercentage = 0;
$botPercentage = 0;
$proxyClicks = 0;
$proxyPercentage = 0;
// Perform total count query only if we load bots or vpn
if (! empty($this->loadedTabs)) {
$totalQuery = $this->record->visits()
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($totalQuery, $this->filters);
$totalClicks = $totalQuery->count();
}
// Calculate Bot ratio
if (isset($this->loadedTabs['bots'])) {
$botQuery = $this->record->visits()
->where('is_bot', true)
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($botQuery, $this->filters);
$botClicks = $botQuery->count();
$humanClicks = max(0, $totalClicks - $botClicks);
$humanPercentage = $totalClicks > 0 ? round(($humanClicks / $totalClicks) * 100, 1) : 0;
$botPercentage = $totalClicks > 0 ? round(($botClicks / $totalClicks) * 100, 1) : 0;
}
// Calculate VPN / Proxy ratio
if (isset($this->loadedTabs['vpn'])) {
$proxyQuery = $this->record->visits()
->where('is_proxy', true)
->whereBetween('visited_at', [
($this->dateFrom ? $this->dateFrom.' 00:00:00' : '1970-01-01 00:00:00'),
($this->dateTo ? $this->dateTo.' 23:59:59' : now()->toDateTimeString()),
]);
$this->record->applyStatsFilters($proxyQuery, $this->filters);
$proxyClicks = $proxyQuery->count();
$proxyPercentage = $totalClicks > 0 ? round(($proxyClicks / $totalClicks) * 100, 1) : 0;
}
$stats = $this->record->getSecurityBreakdownStats(
dateFrom: $this->dateFrom,
dateTo: $this->dateTo,
filters: $this->filters,
);
return [
'activeTab' => $this->activeTab,
'totalClicks' => $totalClicks,
'humanClicks' => $humanClicks,
'botClicks' => $botClicks,
'humanPercentage' => $humanPercentage,
'botPercentage' => $botPercentage,
'proxyClicks' => $proxyClicks,
'proxyPercentage' => $proxyPercentage,
'totalClicks' => $stats['totalClicks'],
'humanClicks' => $stats['humanClicks'],
'botClicks' => $stats['botClicks'],
'humanPercentage' => $stats['humanPercentage'],
'botPercentage' => $stats['botPercentage'],
'proxyClicks' => $stats['proxyClicks'],
'proxyPercentage' => $stats['proxyPercentage'],
];
}
}

View File

@@ -4,6 +4,10 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\Stats\StatsCacheHelper;
use Bjanczak\FilamentShortUrl\Services\Stats\StatsScalingProfile;
use Bjanczak\FilamentShortUrl\Services\Stats\TodayStatsBuffer;
use Bjanczak\FilamentShortUrl\Services\StatsSqlHelper;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -118,10 +122,10 @@ class ShortUrlVisitsChart extends ChartWidget
if (empty($filters) && $granularity !== 'hourly') {
$dailyStatsQuery = DB::table('short_url_daily_stats')
->where('short_url_id', $this->record->id)
->where('date', '>=', $dateFromClean)
->where('date', '<=', $dateToClean)
->orderBy('date', 'asc');
->where('short_url_id', $this->record->id);
StatsSqlHelper::applyDailyStatsDateRange($dailyStatsQuery, $dateFromClean, $dateToClean);
$dailyStatsQuery->orderBy('date', 'asc');
$col = match ($metric) {
'unique' => 'unique_visits_count',
@@ -147,6 +151,20 @@ class ShortUrlVisitsChart extends ChartWidget
}
if ($dateToClean >= $today) {
$todayCount = 0;
$profile = app(StatsScalingProfile::class);
if (empty($filters) && $profile->usesRedisTodayBuffer()) {
$redisHourly = app(TodayStatsBuffer::class)->getHourlyTotals(
(int) $this->record->id,
Carbon::parse($today)->startOfDay(),
Carbon::parse($today)->endOfDay(),
$metric,
);
$todayCount = array_sum($redisHourly);
}
if ($todayCount === 0) {
$todayRawQuery = DB::table('short_url_visits')
->where('short_url_id', $this->record->id)
->where('visited_at', '>=', $today.' 00:00:00')
@@ -160,6 +178,7 @@ class ShortUrlVisitsChart extends ChartWidget
};
$todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt');
}
$carbonToday = Carbon::parse($today);
if ($granularity === 'weekly') {
@@ -174,41 +193,47 @@ class ShortUrlVisitsChart extends ChartWidget
$buckets[$todayBucket] += $todayCount;
}
}
} elseif (! empty($filters) && $granularity !== 'hourly' && $metric === 'total') {
$stats = $this->record->getCachedStats(
dateFrom: $dateFromClean,
dateTo: $dateToClean,
filters: $filters,
);
foreach ($stats['visitsByDay'] ?? [] as $date => $count) {
if ($granularity === 'monthly' && strlen((string) $date) === 7) {
$bucketKey = $date;
} else {
$carbonDate = Carbon::parse($date);
$bucketKey = match ($granularity) {
'weekly' => $carbonDate->format('o-W'),
'monthly' => $carbonDate->format('Y-m'),
default => $date,
};
}
if (array_key_exists($bucketKey, $buckets)) {
$buckets[$bucketKey] += (int) $count;
}
}
} elseif (empty($filters) && $granularity === 'hourly') {
$profile = app(StatsScalingProfile::class);
$useRedisToday = $profile->usesRedisTodayBuffer() && $dateToClean >= $today;
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
$rawCutoff = Carbon::today()->subDays($retentionDays)->toDateString();
$effectiveFrom = $dateFromClean >= $rawCutoff ? $dateFromClean : $rawCutoff;
if (! $useRedisToday || $effectiveFrom < $today) {
$query = DB::table('short_url_visits')
->where('short_url_id', $this->record->id)
->where('visited_at', '>=', $dateFromClean.' 00:00:00')
->where('visited_at', '>=', $effectiveFrom.' 00:00:00')
->where('visited_at', '<=', $dateToClean.' 23:59:59')
->where('is_bot', false)
->where('is_proxy', false);
$this->record->applyStatsFilters($query, $filters);
$dateExpression = '';
if ($granularity === 'hourly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
};
} elseif ($granularity === 'weekly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%W', visited_at)",
'pgsql' => "to_char(visited_at, 'IYYY-IW')",
default => 'YEARWEEK(visited_at, 3)',
};
} elseif ($granularity === 'monthly') {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM')",
default => "DATE_FORMAT(visited_at, '%Y-%m')",
};
} else {
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
};
if ($useRedisToday) {
$query->where('visited_at', '<', $today.' 00:00:00');
}
$aggregateExpression = match ($metric) {
@@ -217,6 +242,111 @@ class ShortUrlVisitsChart extends ChartWidget
default => 'COUNT(*)',
};
$dateExpression = match ($driver) {
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
};
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
->groupBy('time_bucket')
->pluck('count', 'time_bucket')
->toArray();
foreach ($rows as $bucket => $count) {
if (array_key_exists($bucket, $buckets)) {
$buckets[$bucket] = (int) $count;
}
}
}
if ($useRedisToday) {
$redisHourly = app(TodayStatsBuffer::class)->getHourlyTotals(
(int) $this->record->id,
$start->copy()->startOfHour()->max(Carbon::parse($today)->startOfDay()),
$end->copy()->endOfHour(),
$metric,
);
foreach ($redisHourly as $bucket => $count) {
if (array_key_exists($bucket, $buckets)) {
$buckets[$bucket] = (int) $count;
}
}
}
} else {
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
$rawCutoff = Carbon::today()->subDays($retentionDays)->toDateString();
$effectiveFrom = $dateFromClean >= $rawCutoff ? $dateFromClean : $rawCutoff;
$query = DB::table('short_url_visits')
->where('short_url_id', $this->record->id)
->where('visited_at', '>=', $effectiveFrom.' 00:00:00')
->where('visited_at', '<=', $dateToClean.' 23:59:59')
->where('is_bot', false)
->where('is_proxy', false);
$this->record->applyStatsFilters($query, $filters);
$aggregateExpression = match ($metric) {
'unique' => 'COUNT(DISTINCT ip_hash)',
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
default => 'COUNT(*)',
};
$weeklyHandled = false;
if ($granularity === 'weekly' && $driver === 'sqlite') {
$uniqueHashes = [];
foreach ($query->select(['visited_at', 'ip_hash', 'is_qr_scan'])->cursor() as $row) {
$bucketKey = Carbon::parse($row->visited_at)->format('o-W');
if (! array_key_exists($bucketKey, $buckets)) {
continue;
}
if ($metric === 'unique') {
$hash = $row->ip_hash ?? '';
if ($hash === '' || isset($uniqueHashes[$bucketKey][$hash])) {
continue;
}
$uniqueHashes[$bucketKey][$hash] = true;
$buckets[$bucketKey]++;
} elseif ($metric === 'qr') {
if ((bool) ($row->is_qr_scan ?? false)) {
$buckets[$bucketKey]++;
}
} else {
$buckets[$bucketKey]++;
}
}
$weeklyHandled = true;
}
if (! $weeklyHandled) {
$dateExpression = match ($granularity) {
'hourly' => match ($driver) {
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
},
'weekly' => match ($driver) {
'pgsql' => "to_char(visited_at, 'IYYY-IW')",
default => 'YEARWEEK(visited_at, 3)',
},
'monthly' => match ($driver) {
'sqlite' => "strftime('%Y-%m', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM')",
default => "DATE_FORMAT(visited_at, '%Y-%m')",
},
default => match ($driver) {
'sqlite' => "strftime('%Y-%m-%d', visited_at)",
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD')",
default => "DATE_FORMAT(visited_at, '%Y-%m-%d')",
},
};
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
->groupBy('time_bucket')
->pluck('count', 'time_bucket')
@@ -224,9 +354,9 @@ class ShortUrlVisitsChart extends ChartWidget
foreach ($rows as $bucket => $count) {
if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') {
if (strlen($bucket) === 6) {
$year = substr($bucket, 0, 4);
$week = substr($bucket, 4, 2);
if (strlen((string) $bucket) === 6) {
$year = substr((string) $bucket, 0, 4);
$week = substr((string) $bucket, 4, 2);
$bucket = "{$year}-{$week}";
}
}
@@ -236,6 +366,7 @@ class ShortUrlVisitsChart extends ChartWidget
}
}
}
}
return [
'data' => array_values($buckets),
@@ -257,18 +388,16 @@ class ShortUrlVisitsChart extends ChartWidget
$granularity = $this->filter ?: 'daily';
$filtersHash = md5(json_encode($this->filters));
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_chart_data_{$this->record->id}_".$start->toDateString().'_'.$end->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
$this->record->registerCacheKey($cacheKey);
$chartData = cache()->remember($cacheKey, $cacheTtl, function () use ($start, $end, $granularity) {
$chartData = StatsCacheHelper::remember($cacheKey, function () use ($start, $end, $granularity) {
return $this->getTimelineData($start, $end, $this->activeMetric, $granularity, $this->filters);
});
$currentValues = $chartData['data'] ?? [];
$labels = $chartData['labels'] ?? [];
// Dual-line Chart data generation: Query stats for previous period
$diffInDays = $start->diffInDays($end) + 1;
$prevStart = $start->copy()->subDays($diffInDays);
$prevEnd = $start->copy()->subDay();
@@ -276,7 +405,7 @@ class ShortUrlVisitsChart extends ChartWidget
$prevCacheKey = "short_url_chart_data_{$this->record->id}_".$prevStart->toDateString().'_'.$prevEnd->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
$this->record->registerCacheKey($prevCacheKey);
$prevChartData = cache()->remember($prevCacheKey, $cacheTtl, function () use ($prevStart, $prevEnd, $granularity) {
$prevChartData = StatsCacheHelper::remember($prevCacheKey, function () use ($prevStart, $prevEnd, $granularity) {
return $this->getTimelineData($prevStart, $prevEnd, $this->activeMetric, $granularity, $this->filters);
});

View File

@@ -4,10 +4,7 @@ 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;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ShortUrlWorldMapWidget extends Widget
{
@@ -29,7 +26,7 @@ class ShortUrlWorldMapWidget extends Widget
}
/**
* Build a country_code → visit count map, merging daily stats and today's raw visits.
* Build a country_code → visit count map via the shared stats cache layer.
*
* @return array<string, int>
*/
@@ -39,45 +36,21 @@ class ShortUrlWorldMapWidget extends Widget
return [];
}
$dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null;
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
$today = Carbon::today()->toDateString();
$stats = $this->record->getCachedStats(
dateFrom: $this->dateFrom,
dateTo: $this->dateTo,
filters: $this->filters,
);
$filtersHash = md5(json_encode($this->filters));
$cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300);
$cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all').'_'.$filtersHash;
$countries = $stats['visitsByCountry'] ?? [];
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
$counts = [];
$query = ShortUrlVisit::query()
->select('country_code', DB::raw('COUNT(*) as cnt'))
->where('short_url_id', $this->record->id)
->whereNotNull('country_code')
->where('country_code', '!=', '')
->where('is_bot', false)
->where('is_proxy', false);
if ($dateFromClean) {
$query->where('visited_at', '>=', $dateFromClean.' 00:00:00');
}
if ($dateToClean) {
$query->where('visited_at', '<=', $dateToClean.' 23:59:59');
if (! is_array($countries)) {
return [];
}
$this->record->applyStatsFilters($query, $this->filters);
arsort($countries);
foreach ($query->groupBy('country_code')->get() as $row) {
$code = strtoupper(trim($row->country_code));
if ($code) {
$counts[$code] = (int) $row->cnt;
}
}
arsort($counts);
return $counts;
});
return $countries;
}
/**
@@ -89,22 +62,28 @@ class ShortUrlWorldMapWidget extends Widget
$max = max(1, ...array_values($countryData) ?: [1]);
$total = array_sum($countryData);
// Normalise to 0100 for CSS opacity/intensity
$normalized = [];
foreach ($countryData as $code => $count) {
$normalized[$code] = round(($count / $max) * 100);
}
// Load and clean up raw SVG map on the server-side where paths are reliable
$svgPath = dirname(__FILE__, 6).'/resources/views/widgets/world-map.svg';
$svgContent = '';
if (file_exists($svgPath)) {
$svgContent = file_get_contents($svgPath);
// Remove XML declaration and DOCTYPE tags
$svgContent = preg_replace('/<\?xml[^>]*\?>/i', '', $svgContent);
$svgContent = preg_replace('/<!DOCTYPE[^>]*>/i', '', $svgContent);
$svgContent = cache()->remember('filament-short-url:world-map-svg', 86400, function () use ($svgPath): string {
if (! file_exists($svgPath)) {
return '';
}
$content = file_get_contents($svgPath);
if (! is_string($content)) {
return '';
}
$content = preg_replace('/<\?xml[^>]*\?>/i', '', $content) ?? $content;
$content = preg_replace('/<!DOCTYPE[^>]*>/i', '', $content) ?? $content;
return $content;
});
return [
'countryData' => $countryData,
'maxCount' => $max,

View File

@@ -10,20 +10,33 @@ namespace Bjanczak\FilamentShortUrl;
use Bjanczak\FilamentShortUrl\Assets\ShortUrlCss;
use Bjanczak\FilamentShortUrl\Assets\ShortUrlJs;
use Bjanczak\FilamentShortUrl\Console\Commands\StressRedirectCommand;
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
use Bjanczak\FilamentShortUrl\Console\Commands\VerifyCustomDomainsCommand;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Policies\ShortUrlPolicy;
use Bjanczak\FilamentShortUrl\Services\Ga4MeasurementProtocolService;
use Bjanczak\FilamentShortUrl\Services\GeoIpService;
use Bjanczak\FilamentShortUrl\Services\OgImageImporter;
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
use Bjanczak\FilamentShortUrl\Services\Queue\PluginQueueWorkerTester;
use Bjanczak\FilamentShortUrl\Services\Redis\PluginRedisConnection;
use Bjanczak\FilamentShortUrl\Services\Redis\PluginRedisConnectionTester;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
use Bjanczak\FilamentShortUrl\Services\Stats\StatsScalingProfile;
use Bjanczak\FilamentShortUrl\Services\Stats\StatsVisitRecorder;
use Bjanczak\FilamentShortUrl\Services\Stats\TodayStatsBuffer;
use Bjanczak\FilamentShortUrl\Services\UrlMetaScraper;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
use Bjanczak\FilamentShortUrl\Services\VisitCounterBuffer;
use BladeUI\Icons\Factory;
use Filament\Support\Facades\FilamentAsset;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Gate;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
@@ -49,10 +62,16 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
'2026_06_05_000003_add_custom_domain_id_to_short_urls_table',
'2026_06_06_000001_create_short_url_folders_and_tags_tables',
'2026_06_06_000002_add_og_metadata_to_short_urls_table',
'2026_06_08_000001_add_api_and_utm_fields_to_short_urls_table',
'2026_06_09_000001_audit_schema_and_performance_fixes',
'2026_06_10_000001_add_security_counts_to_daily_stats',
'2026_06_11_000001_add_cross_dimensional_stats_to_daily_stats',
])
->hasCommands([
SyncBufferedCountersCommand::class,
Console\Commands\AggregateAndPruneVisitsCommand::class,
VerifyCustomDomainsCommand::class,
StressRedirectCommand::class,
])
->hasRoutes(['web']);
}
@@ -64,6 +83,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
// Bind services as singletons for efficient reuse
$this->app->singleton(UserAgentParser::class);
$this->app->singleton(GeoIpService::class);
$this->app->singleton(Ga4MeasurementProtocolService::class);
$this->app->singleton(UrlMetaScraper::class);
$this->app->singleton(OgImageProcessor::class);
$this->app->singleton(OgImageImporter::class);
@@ -71,6 +91,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
$this->app->singleton(ShortUrlTracker::class);
$this->app->singleton(ProxyDetectionService::class);
$this->app->singleton(SafeBrowsingService::class);
$this->app->singleton(StatsScalingProfile::class);
$this->app->singleton(PluginRedisConnection::class);
$this->app->singleton(PluginRedisConnectionTester::class);
$this->app->singleton(PluginQueueWorkerTester::class);
$this->app->singleton(VisitCounterBuffer::class);
$this->app->singleton(TodayStatsBuffer::class);
$this->app->singleton(StatsVisitRecorder::class);
$this->callAfterResolving(Factory::class, function (Factory $factory) {
$factory->add('filament-short-url', [
@@ -82,7 +109,10 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
public function packageBooted(): void
{
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
$this->app->terminating(function (): void {
GeoIpService::flush();
ShortUrl::flushBufferedCounterMemory();
});
FilamentAsset::register([
ShortUrlCss::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
@@ -93,13 +123,25 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
// Automatically register scheduled tasks in the application scheduler
$this->app->booted(function (): void {
if (! Gate::getPolicyFor(ShortUrl::class)) {
Gate::policy(ShortUrl::class, ShortUrlPolicy::class);
}
$schedule = $this->app->make(Schedule::class);
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
$schedule->command('short-url:verify-custom-domains')->daily();
if (config('filament-short-url.counter_buffering.enabled', false)) {
$schedule->command('short-url:sync-counters')->everyMinute();
}
});
}
public function boot(): void
{
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
parent::boot();
}
}

View File

@@ -10,27 +10,37 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
use Bjanczak\FilamentShortUrl\Http\Requests\UpsertShortUrlRequest;
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlVisitResource;
use Bjanczak\FilamentShortUrl\Http\Support\ApiLinkScope;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\VisitCsvExporter;
use Bjanczak\FilamentShortUrl\Support\ApiStatsFilterParser;
use Bjanczak\FilamentShortUrl\Support\ShortUrlCacheInvalidator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlApiController extends Controller
{
public function __construct(
private readonly ShortUrlService $service,
private readonly SafeBrowsingService $safeBrowsing,
) {}
/**
* Display a listing of short URLs.
*/
public function index(): JsonResponse
public function index(Request $request): JsonResponse
{
$links = ShortUrl::with(['pixels', 'customDomain'])->orderBy('id', 'desc')->paginate(30);
$links = ApiLinkScope::query($request)
->with(['pixels', 'customDomain', 'tags', 'folder'])
->orderBy('id', 'desc')
->paginate(30);
return ShortUrlResource::collection($links)->response();
}
@@ -40,31 +50,172 @@ class ShortUrlApiController extends Controller
*/
public function store(StoreShortUrlRequest $request): JsonResponse
{
$validated = $request->validated();
$pixelIds = $validated['pixels'] ?? [];
// Clean up parameters that shouldn't be mass assigned
unset($validated['pixels']);
$shortUrl = $this->service->create($validated);
if (! empty($pixelIds)) {
$shortUrl->pixels()->sync($pixelIds);
}
$shortUrl = $this->persistLink($request, $request->validated());
return (new ShortUrlResource($shortUrl))
->additional(['message' => 'Short URL created successfully.'])
->additional(['message' => __('filament-short-url::default.api_created')])
->response()
->setStatusCode(201);
}
/**
* Create or update a short URL by external_id or url_key + destination match.
*/
public function upsert(UpsertShortUrlRequest $request): JsonResponse
{
$validated = $request->validated();
$existing = $request->getExistingModel();
if ($existing) {
$pixelIds = $validated['pixels'] ?? null;
$tagIds = $validated['tag_ids'] ?? null;
unset($validated['pixels'], $validated['tag_ids']);
DB::transaction(function () use ($existing, $validated, $pixelIds, $tagIds): void {
$existing->update($validated);
if ($pixelIds !== null) {
$existing->pixels()->sync($pixelIds);
}
if ($tagIds !== null) {
$existing->tags()->sync($tagIds);
}
});
ShortUrlCacheInvalidator::forget($existing);
return (new ShortUrlResource($existing->fresh(['pixels', 'customDomain', 'tags', 'folder'])))
->additional(['message' => __('filament-short-url::default.api_updated')])
->response();
}
$shortUrl = $this->persistLink($request, $validated);
return (new ShortUrlResource($shortUrl))
->additional(['message' => __('filament-short-url::default.api_created')])
->response()
->setStatusCode(201);
}
/**
* Bulk create short URLs (max 100).
*/
public function bulkStore(Request $request): JsonResponse
{
$request->validate([
'links' => 'required|array|min:1|max:100',
]);
$created = [];
DB::transaction(function () use ($request, &$created): void {
foreach ($request->input('links') as $index => $linkData) {
if (! is_array($linkData)) {
abort(422, "Link at index {$index} must be an object.");
}
$subRequest = StoreShortUrlRequest::createFrom($request);
$subRequest->setContainer(app());
$subRequest->setRedirector(app('redirect'));
$subRequest->replace($linkData);
$subRequest->validateResolved();
$created[] = $this->persistLink($subRequest, $subRequest->validated());
}
});
return ShortUrlResource::collection(collect($created))
->additional(['message' => __('filament-short-url::default.api_bulk_created'), 'count' => count($created)])
->response()
->setStatusCode(201);
}
/**
* Check whether a URL key is already taken.
*/
public function exists(Request $request): JsonResponse
{
$request->validate([
'url_key' => 'required|string|max:32',
'custom_domain_id' => 'nullable|integer',
]);
$urlKey = (string) $request->input('url_key');
$domainScopeId = (int) ($request->input('custom_domain_id') ?? 0);
return response()->json([
'exists' => ApiLinkScope::query($request)
->where('url_key', $urlKey)
->where('domain_scope_id', $domainScopeId)
->exists(),
'url_key' => $urlKey,
'domain_scope_id' => $domainScopeId,
]);
}
/**
* Return a random short URL from the scoped collection.
*/
public function random(Request $request): JsonResponse
{
$link = ApiLinkScope::query($request)
->with(['pixels', 'customDomain', 'tags', 'folder'])
->inRandomOrder()
->first();
if (! $link) {
abort(404, __('filament-short-url::default.short_url_not_found'));
}
return (new ShortUrlResource($link))->response();
}
/**
* Return lightweight info for a link by url_key or external_id.
*/
public function info(Request $request): JsonResponse
{
$request->validate([
'url_key' => 'required_without:external_id|nullable|string|max:32',
'external_id' => 'required_without:url_key|nullable|string|max:255',
]);
$query = ApiLinkScope::query($request);
if ($request->filled('url_key')) {
$link = $query->where('url_key', $request->query('url_key'))->first();
} else {
$link = $query->where('external_id', $request->query('external_id'))->first();
}
if (! $link) {
abort(404, __('filament-short-url::default.short_url_not_found'));
}
return response()->json([
'data' => [
'id' => $link->id,
'url_key' => $link->url_key,
'external_id' => $link->external_id,
'short_url' => $link->getShortUrl(),
'destination_url' => $link->destination_url,
'is_enabled' => (bool) $link->is_enabled,
'is_archived' => (bool) $link->is_archived,
'total_visits' => (int) $link->total_visits,
'unique_visits' => (int) $link->unique_visits,
'created_at' => $link->created_at->toIso8601String(),
'updated_at' => $link->updated_at?->toIso8601String(),
],
]);
}
/**
* Display the specified short URL.
*/
public function show(string|int $idOrKey): JsonResponse
public function show(Request $request, string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
$shortUrl = ApiLinkScope::find($request, $idOrKey);
return (new ShortUrlResource($shortUrl))->response();
}
@@ -74,33 +225,40 @@ class ShortUrlApiController extends Controller
*/
public function stats(Request $request, string|int $idOrKey): JsonResponse
{
$request->validate([
$request->validate(array_merge([
'date_from' => 'nullable|date_format:Y-m-d',
'date_to' => 'nullable|date_format:Y-m-d|after_or_equal:date_from',
]);
], ApiStatsFilterParser::validationRules()));
$shortUrl = $this->findLink($idOrKey);
$shortUrl = ApiLinkScope::find($request, $idOrKey);
$filters = ApiStatsFilterParser::fromRequest($request);
$stats = $shortUrl->getCachedStats(
dateFrom: $request->query('date_from'),
dateTo: $request->query('date_to')
dateTo: $request->query('date_to'),
filters: $filters,
);
return response()->json([
'data' => $stats,
'meta' => [
'date_from' => $request->query('date_from'),
'date_to' => $request->query('date_to'),
'filters' => $filters,
],
]);
}
/**
* Remove the specified short URL.
*/
public function destroy(string|int $idOrKey): JsonResponse
public function destroy(Request $request, string|int $idOrKey): JsonResponse
{
$shortUrl = $this->findLink($idOrKey);
$shortUrl = ApiLinkScope::find($request, $idOrKey);
$shortUrl->delete();
return response()->json([
'message' => 'Short URL deleted successfully.',
'message' => __('filament-short-url::default.api_deleted'),
]);
}
@@ -114,34 +272,186 @@ class ShortUrlApiController extends Controller
$validated = $request->validated();
$pixelIds = $validated['pixels'] ?? null;
unset($validated['pixels']);
$tagIds = $validated['tag_ids'] ?? null;
unset($validated['pixels'], $validated['tag_ids']);
DB::transaction(function () use ($shortUrl, $validated, $pixelIds, $tagIds): void {
$shortUrl->update($validated);
if ($pixelIds !== null) {
$shortUrl->pixels()->sync($pixelIds);
}
return (new ShortUrlResource($shortUrl->fresh()))
->additional(['message' => 'Short URL updated successfully.'])
if ($tagIds !== null) {
$shortUrl->tags()->sync($tagIds);
}
});
ShortUrlCacheInvalidator::forget($shortUrl);
return (new ShortUrlResource($shortUrl->fresh(['pixels', 'customDomain', 'tags', 'folder'])))
->additional(['message' => __('filament-short-url::default.api_updated')])
->response();
}
/**
* Find a ShortUrl by database ID or URL key.
* List visit logs for the specified short URL.
*/
private function findLink(string|int $idOrKey): ShortUrl
public function visits(Request $request, string|int $idOrKey): JsonResponse
{
if (is_numeric($idOrKey)) {
return ShortUrl::findOrFail((int) $idOrKey);
$request->validate([
'per_page' => 'nullable|integer|min:1|max:100',
]);
$shortUrl = ApiLinkScope::find($request, $idOrKey);
$perPage = (int) $request->query('per_page', 30);
$visits = ShortUrlVisit::query()
->where('short_url_id', $shortUrl->id)
->orderByDesc('visited_at')
->paginate($perPage);
return ShortUrlVisitResource::collection($visits)->response();
}
$link = ShortUrl::where('url_key', $idOrKey)->first();
/**
* Export visit logs as CSV for the specified short URL.
*/
public function exportVisits(Request $request, string|int $idOrKey, VisitCsvExporter $exporter): StreamedResponse
{
$shortUrl = ApiLinkScope::find($request, $idOrKey);
$filename = "visits-{$shortUrl->url_key}-".now()->format('Y-m-d').'.csv';
if (! $link) {
abort(404, 'Short URL not found.');
return response()->streamDownload(function () use ($shortUrl, $exporter) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF).chr(0xBB).chr(0xBF));
$exporter->stream(
ShortUrlVisit::query()
->where('short_url_id', $shortUrl->id)
->orderByDesc('visited_at')
->cursor(),
fn (array $row) => fputcsv($handle, $row)
);
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
return $link;
/**
* Bulk delete short URLs by ID or URL key.
*/
public function bulkDestroy(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required_without:keys|array|min:1|max:100',
'ids.*' => 'integer|exists:short_urls,id',
'keys' => 'required_without:ids|array|min:1|max:100',
'keys.*' => 'string|max:32',
]);
$query = ApiLinkScope::query($request);
if (! empty($validated['ids'])) {
$query->whereIn('id', $validated['ids']);
} else {
$query->whereIn('url_key', $validated['keys']);
}
$links = $query->with('customDomain')->get();
foreach ($links as $link) {
$link->delete();
}
ShortUrlCacheInvalidator::forgetMany($links);
$deleted = $links->count();
return response()->json([
'message' => __('filament-short-url::default.api_bulk_deleted'),
'deleted' => $deleted,
]);
}
/**
* Bulk update short URLs by ID or URL key.
*/
public function bulkUpdate(Request $request): JsonResponse
{
$validated = $request->validate([
'ids' => 'required_without:keys|array|min:1|max:100',
'ids.*' => 'integer|exists:short_urls,id',
'keys' => 'required_without:ids|array|min:1|max:100',
'keys.*' => 'string|max:32',
'data' => 'required|array|min:1',
'data.is_enabled' => 'sometimes|boolean',
'data.is_archived' => 'sometimes|boolean',
'data.notes' => 'sometimes|nullable|string|max:255',
'data.expires_at' => 'sometimes|nullable|date',
'data.max_visits' => 'sometimes|nullable|integer|min:1',
]);
$query = ApiLinkScope::query($request);
if (! empty($validated['ids'])) {
$query->whereIn('id', $validated['ids']);
} else {
$query->whereIn('url_key', $validated['keys']);
}
$links = $query->with('customDomain')->get();
foreach ($links as $link) {
$link->update($validated['data']);
}
ShortUrlCacheInvalidator::forgetMany($links);
$updated = $links->count();
return response()->json([
'message' => __('filament-short-url::default.api_bulk_updated'),
'updated' => $updated,
]);
}
/**
* @param array<string, mixed> $validated
*/
private function persistLink(Request $request, array $validated): ShortUrl
{
$pixelIds = $validated['pixels'] ?? [];
$tagIds = $validated['tag_ids'] ?? [];
unset($validated['pixels'], $validated['tag_ids']);
$ownerUserId = $this->ownerUserId($request);
if ($ownerUserId !== null) {
$validated['user_id'] = $ownerUserId;
}
$shortUrl = $this->service->create($validated);
if (! empty($pixelIds)) {
$shortUrl->pixels()->sync($pixelIds);
}
if (! empty($tagIds)) {
$shortUrl->tags()->sync($tagIds);
}
return $shortUrl->fresh(['pixels', 'customDomain', 'tags', 'folder']);
}
private function ownerUserId(Request $request): ?int
{
/** @var array<string, mixed>|null $apiKey */
$apiKey = $request->attributes->get('fsu_api_key');
if (is_array($apiKey) && ! empty($apiKey['owner_user_id'])) {
return (int) $apiKey['owner_user_id'];
}
return null;
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class ShortUrlFolderApiController extends Controller
{
public function index(Request $request): JsonResponse
{
$folders = $this->scopedQuery($request)
->orderBy('name')
->paginate(30);
return response()->json($folders);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'slug' => 'nullable|string|max:100|unique:short_url_folders,slug',
'color' => ['nullable', 'string', Rule::in(array_keys(ShortUrlFolder::getColors()))],
]);
if (empty($validated['slug'])) {
$validated['slug'] = Str::slug($validated['name']);
}
$ownerUserId = $this->ownerUserId($request);
if ($ownerUserId !== null) {
$validated['user_id'] = $ownerUserId;
}
$folder = ShortUrlFolder::create($validated);
return response()->json([
'data' => $folder,
'message' => 'Folder created successfully.',
], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$folder = $this->findFolder($request, $id);
$validated = $request->validate([
'name' => 'sometimes|required|string|max:100',
'slug' => 'sometimes|required|string|max:100|unique:short_url_folders,slug,'.$folder->id,
'color' => ['sometimes', 'nullable', 'string', Rule::in(array_keys(ShortUrlFolder::getColors()))],
]);
$folder->update($validated);
return response()->json([
'data' => $folder->fresh(),
'message' => 'Folder updated successfully.',
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$folder = $this->findFolder($request, $id);
$folder->delete();
return response()->json([
'message' => 'Folder deleted successfully.',
]);
}
private function findFolder(Request $request, int $id): ShortUrlFolder
{
return $this->scopedQuery($request)->findOrFail($id);
}
/**
* @return Builder<ShortUrlFolder>
*/
private function scopedQuery(Request $request)
{
$query = ShortUrlFolder::query();
$ownerUserId = $this->ownerUserId($request);
if ($ownerUserId !== null) {
$query->where('user_id', $ownerUserId);
}
return $query;
}
private function ownerUserId(Request $request): ?int
{
/** @var array<string, mixed>|null $apiKey */
$apiKey = $request->attributes->get('fsu_api_key');
if (is_array($apiKey) && ! empty($apiKey['owner_user_id'])) {
return (int) $apiKey['owner_user_id'];
}
return null;
}
}

View File

@@ -0,0 +1,150 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
use Bjanczak\FilamentShortUrl\Services\LiveFeedBroadcaster;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ShortUrlLiveFeedStreamController extends Controller
{
use AuthorizesRequests;
public function __invoke(Request $request, ShortUrl $shortUrl): StreamedResponse
{
$this->authorize('view', $shortUrl);
$cursor = (int) $request->query('cursor', 0);
$intervalSeconds = max(1, (int) config('filament-short-url.live_feed.sse_interval_seconds', 3));
$maxDurationSeconds = max(0, (int) config('filament-short-url.live_feed.sse_max_duration_seconds', 120));
return response()->stream(function () use ($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds): void {
if (LiveFeedBroadcaster::usesRedisPush()) {
$this->streamWithRedisPush($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds);
return;
}
$this->streamWithPolling($shortUrl, $cursor, $intervalSeconds, $maxDurationSeconds);
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, no-transform',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
}
/**
* Redis pub/sub push: block on SUBSCRIBE between heartbeats (no sleep polling, no DB).
*/
private function streamWithRedisPush(
ShortUrl $shortUrl,
int $cursor,
int $intervalSeconds,
int $maxDurationSeconds,
): void {
$lastId = $cursor;
$deadline = time() + $maxDurationSeconds;
while (time() < $deadline && ! connection_aborted()) {
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
}
$remaining = max(1, min($intervalSeconds, $deadline - time()));
$pushedId = LiveFeedBroadcaster::waitForPublish($shortUrl->id, $remaining);
if ($pushedId !== null && $pushedId > $lastId) {
$this->emitUpdate($pushedId);
$lastId = $pushedId;
continue;
}
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
} else {
$this->emitHeartbeat();
}
}
}
/**
* Fallback when Redis is unavailable: poll cache cursor (and DB once when cache is cold).
*/
private function streamWithPolling(
ShortUrl $shortUrl,
int $cursor,
int $intervalSeconds,
int $maxDurationSeconds,
): void {
$lastId = $cursor;
$deadline = time() + $maxDurationSeconds;
while (time() < $deadline && ! connection_aborted()) {
$latest = $this->resolveLatestVisitId($shortUrl);
if ($latest > $lastId) {
$this->emitUpdate($latest);
$lastId = $latest;
} else {
$this->emitHeartbeat();
}
sleep($intervalSeconds);
}
}
private function resolveLatestVisitId(ShortUrl $shortUrl): int
{
$latest = LiveFeedBroadcaster::latestId($shortUrl->id);
if ($latest > 0) {
return $latest;
}
return (int) ShortUrlVisit::query()
->where('short_url_id', $shortUrl->id)
->where('is_bot', false)
->where('is_proxy', false)
->max('id');
}
private function emitUpdate(int $latestId): void
{
echo 'event: update'."\n";
echo 'data: '.json_encode(['latest_id' => $latestId], JSON_THROW_ON_ERROR)."\n\n";
$this->flushOutput();
}
private function emitHeartbeat(): void
{
echo ": heartbeat\n\n";
$this->flushOutput();
}
private function flushOutput(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}

View File

@@ -8,14 +8,8 @@
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;

View File

@@ -0,0 +1,230 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Bjanczak\FilamentShortUrl\Services\ShortUrlPasswordHasher;
use Bjanczak\FilamentShortUrl\Support\HostNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\MessageBag;
use Illuminate\View\View;
class ShortUrlPublicStatsController extends Controller
{
public function show(Request $request, string $key): JsonResponse|RedirectResponse|Response|View
{
$request->validate([
'password' => 'nullable|string|max:255',
'date_from' => 'nullable|date_format:Y-m-d',
'date_to' => 'nullable|date_format:Y-m-d|after_or_equal:date_from',
]);
if ($request->query->has('password')) {
abort(403, __('filament-short-url::default.public_stats_password_invalid'));
}
$shortUrl = $this->resolveShortUrl($request, $key);
if (! $shortUrl) {
abort(404, __('filament-short-url::default.short_url_not_found'));
}
$statsLimiterKey = 'short_url_public_stats:'.$shortUrl->id.':'.sha1((string) $request->ip());
if (RateLimiter::tooManyAttempts($statsLimiterKey, 10)) {
return $this->rateLimitedResponse($request, RateLimiter::availableIn($statsLimiterKey));
}
RateLimiter::hit($statsLimiterKey, 60);
if ($this->wantsJsonResponse($request)) {
return $this->jsonResponse($request, $shortUrl);
}
return $this->htmlResponse($request, $shortUrl);
}
private function resolveShortUrl(Request $request, string $key): ?ShortUrl
{
$requestHost = HostNormalizer::normalize($request->getHost());
$appHost = HostNormalizer::normalize(parse_url(config('app.url'), PHP_URL_HOST));
$query = ShortUrl::query()->where('url_key', $key);
if ($requestHost && $appHost && strcasecmp($requestHost, $appHost) !== 0) {
$customDomain = ShortUrlCustomDomain::resolveForHost($requestHost);
if (! $customDomain) {
return null;
}
$query->where('domain_scope_id', $customDomain->id);
} else {
$query->where('domain_scope_id', 0);
}
$shortUrl = $query->first();
if (! $shortUrl || ! $shortUrl->public_stats_enabled) {
return null;
}
return $shortUrl;
}
private function wantsJsonResponse(Request $request): bool
{
if ($request->query('format') === 'json') {
return true;
}
return $request->expectsJson() || $request->isJson();
}
private function jsonResponse(Request $request, ShortUrl $shortUrl): JsonResponse
{
if (! $this->verifyProtectedAccess($request, $shortUrl)) {
return response()->json([
'error' => __('filament-short-url::default.public_stats_password_invalid'),
], 403);
}
return response()->json([
'data' => $this->publicStatsSubset(
$shortUrl->getCachedStats(
dateFrom: $request->query('date_from'),
dateTo: $request->query('date_to'),
)
),
]);
}
private function htmlResponse(Request $request, ShortUrl $shortUrl): RedirectResponse|Response|View
{
$sessionKey = $this->sessionKey($shortUrl);
if (filled($shortUrl->public_stats_password) && ! session()->get($sessionKey)) {
if ($request->isMethod('POST')) {
$passwordLimiterKey = 'short_url_public_stats_password:'.$shortUrl->id.':'.sha1((string) $request->ip());
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) {
abort(429, __('filament-short-url::default.public_stats_password_rate_limited'));
}
$password = (string) $request->input('password');
if ($password !== '' && app(ShortUrlPasswordHasher::class)->verify($password, $shortUrl->public_stats_password)) {
session()->put($sessionKey, true);
RateLimiter::clear($passwordLimiterKey);
$query = array_filter($request->only(['date_from', 'date_to']));
$target = route('short-url.public-stats', ['key' => $shortUrl->url_key]);
if ($query !== []) {
$target .= '?'.http_build_query($query);
}
return redirect()->to($target);
}
RateLimiter::hit($passwordLimiterKey, 60);
return response(view('filament-short-url::public-stats-password', [
'shortUrl' => $shortUrl,
'errors' => new MessageBag([
'password' => __('filament-short-url::default.public_stats_password_invalid'),
]),
]))->header('Content-Type', 'text/html');
}
return response(view('filament-short-url::public-stats-password', [
'shortUrl' => $shortUrl,
]))->header('Content-Type', 'text/html');
}
$stats = $shortUrl->getCachedStats(
dateFrom: $request->query('date_from'),
dateTo: $request->query('date_to'),
);
return response(view('filament-short-url::public-stats', [
'shortUrl' => $shortUrl,
'stats' => $this->publicStatsSubset($stats),
'dateFrom' => $request->query('date_from'),
'dateTo' => $request->query('date_to'),
]))->header('Content-Type', 'text/html');
}
private function verifyProtectedAccess(Request $request, ShortUrl $shortUrl): bool
{
if (blank($shortUrl->public_stats_password)) {
return true;
}
$password = null;
if ($request->isMethod('POST')) {
$password = $request->input('password');
}
if ((! is_string($password) || $password === '') && $request->hasHeader('Authorization')) {
$authorization = (string) $request->header('Authorization');
if (str_starts_with(strtolower($authorization), 'bearer ')) {
$password = trim(substr($authorization, 7));
} else {
$password = $authorization;
}
}
return is_string($password)
&& $password !== ''
&& app(ShortUrlPasswordHasher::class)->verify($password, $shortUrl->public_stats_password);
}
private function sessionKey(ShortUrl $shortUrl): string
{
return "short-url-public-stats-{$shortUrl->id}";
}
private function rateLimitedResponse(Request $request, int $retryAfter): JsonResponse|Response
{
if ($this->wantsJsonResponse($request)) {
return response()->json([
'error' => __('filament-short-url::default.public_stats_rate_limited'),
'retry_after' => $retryAfter,
], 429);
}
return response(
view('filament-short-url::public-stats-rate-limited', ['retryAfter' => $retryAfter]),
429
)->header('Content-Type', 'text/html');
}
/**
* @param array<string, mixed> $stats
* @return array<string, mixed>
*/
private function publicStatsSubset(array $stats): array
{
return [
'totalVisits' => $stats['totalVisits'] ?? 0,
'uniqueVisits' => $stats['uniqueVisits'] ?? 0,
'visitsToday' => $stats['visitsToday'] ?? 0,
'visitsThisWeek' => $stats['visitsThisWeek'] ?? 0,
'visitsThisMonth' => $stats['visitsThisMonth'] ?? 0,
'visitsByDay' => $stats['visitsByDay'] ?? [],
'qrScans' => $stats['qrScans'] ?? 0,
];
}
}

View File

@@ -2,17 +2,15 @@
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Bjanczak\FilamentShortUrl\Services\AppLinkingEngine;
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
use Bjanczak\FilamentShortUrl\Services\RobotsTagApplicator;
use Bjanczak\FilamentShortUrl\Services\ShortUrlRedirectHandler;
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
use Bjanczak\FilamentShortUrl\Support\HostNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\MessageBag;
use Symfony\Component\HttpFoundation\Response;
@@ -21,254 +19,42 @@ class ShortUrlRedirectController extends Controller
{
public function __construct(
private readonly ShortUrlService $service,
private readonly ProxyDetectionService $proxyDetector,
private readonly UserAgentParser $uaParser,
private readonly ShortUrlRedirectHandler $redirectHandler,
private readonly RobotsTagApplicator $robotsTagApplicator,
) {}
public function __invoke(Request $request, ?string $key = null): Response
{
if (empty($key)) {
$key = $request->path();
if ($legacyRedirect = $this->redirectHandler->resolveLegacyPrefixedRedirect($request)) {
return $legacyRedirect;
}
$host = $request->getHost();
$mainDomain = parse_url(config('app.url'), PHP_URL_HOST);
$isCustomDomain = $host && strcasecmp($host, $mainDomain) !== 0;
$key = $this->redirectHandler->resolveKey($request, $key);
$shortUrl = $this->redirectHandler->findShortUrl($request, $key);
if ($isCustomDomain) {
// Use a short-lived cache shared with ShortUrl::findByKey() to avoid two
// separate DB round-trips for the same custom domain on a single request.
$customDomain = cache()->remember(
"filament-short-url:custom-domain:{$host}",
300, // 5 minutes — invalidated by ShortUrlCustomDomain model events
fn () => ShortUrlCustomDomain::where('domain', $host)
->where('is_active', true)
->first()
);
if (! $customDomain) {
abort(404);
}
} else {
$prefix = config('filament-short-url.route_prefix');
if (! empty($prefix) && ! $request->route()?->named('short-url.redirect')) {
abort(404);
}
if ($inactive = $this->redirectHandler->respondIfInactive($shortUrl)) {
return $inactive;
}
if (str_contains($key, '/')) {
abort(404);
}
$this->redirectHandler->enforceAccessGuards($key, $request);
$shortUrl = ShortUrl::findByKey($key, $host);
// 404 if not found
if (! $shortUrl) {
abort(404);
}
// 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);
}
return response(view('filament-short-url::expired', [
'shortUrl' => $shortUrl,
]), 410)->header('Content-Type', 'text/html');
}
// 1. VPN/Proxy & Bot Blocking Check
if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') {
$ipAddress = ClientIpExtractor::getIp($request);
$detection = $this->proxyDetector->detect($ipAddress);
if ($detection['is_proxy'] || $detection['is_bot']) {
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
}
}
// 2. Rate Limiting Check
if (config('filament-short-url.rate_limiting.enabled', false)) {
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
$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);
}
// 3. Password Protection Check
if (! empty($shortUrl->password)) {
if ($shortUrl->hasPassword()) {
$queryString = $request->getQueryString();
return redirect()->to(
route('short-url.password-auth', ['key' => $key]).($queryString ? '?'.$queryString : ''),
return $this->robotsTagApplicator->applyToRedirect(
$shortUrl,
redirect()->to(
$shortUrl->passwordAuthUrl($request).($queryString ? '?'.$queryString : ''),
302
),
);
}
// 4. Resolve Destination URL (evaluating targeting rules and forwarding query parameters)
$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() ?? '');
$this->redirectHandler->assertDestinationIsSafe($destination);
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');
}
}
}
// 5. Warning / Intermediate Page Check
if ($shortUrl->show_warning_page && ! $request->has('confirmed')) {
return response(view('filament-short-url::warning', ['destinationUrl' => $destination]))
->header('Content-Type', 'text/html');
}
// 6. 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) {
// Never let tracking failures block the redirection of the user!
Log::error('[FilamentShortUrl] Redirect tracking failed', [
'url_key' => $key,
'error' => $e->getMessage(),
]);
}
}
// Atomically disable single-use URLs — prevents race condition under concurrent load
if ($shortUrl->single_use) {
$affected = ShortUrl::where('id', $shortUrl->id)
->where('is_enabled', true)
->update(['is_enabled' => false]);
// Another request beat us to it — this visit should 410
if ($affected === 0) {
return response(view('filament-short-url::expired', [
'shortUrl' => $shortUrl,
]), 410)->header('Content-Type', 'text/html');
}
// Clear ALL cache key variants for this url_key.
// The redirect cache uses host-suffixed keys (e.g. "filament-short-url:{key}:{host}").
// We must clear every variant to prevent a stale is_enabled=true cache entry
// on a different host from allowing the link to be re-used after it is disabled.
// This mirrors the logic in ShortUrl::saved().
$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}");
}
}
$isBot = $this->uaParser->getDeviceType($request->userAgent() ?? '') === 'robot';
$hasCustomOg = ! empty($shortUrl->og_title) || ! empty($shortUrl->og_description) || ! empty($shortUrl->og_image);
if ($shortUrl->is_cloaked || ($isBot && $hasCustomOg)) {
$activePixels = $shortUrl->pixels->where('is_active', true);
$pixelFired = $request->has('pixel_fired') || $request->has('confirmed');
if ($activePixels->isNotEmpty() && ! $pixelFired && ! $isBot) {
$queryString = $request->getQueryString();
$append = 'pixel_fired=1';
$nextUrl = $request->url().'?'.($queryString ? $queryString.'&'.$append : $append);
return response(view('filament-short-url::pixel-loading', [
'destination' => $nextUrl,
'pixels' => $activePixels,
]))->header('Content-Type', 'text/html');
}
return response(view('filament-short-url::redirect-html', [
'shortUrl' => $shortUrl,
'destination' => $destination,
'isBot' => $isBot,
]))->header('Content-Type', 'text/html');
}
$activePixels = $shortUrl->pixels->where('is_active', true);
if ($activePixels->isNotEmpty() && ! $request->has('confirmed')) {
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);
return $this->redirectHandler->buildResponse($shortUrl, $request, $key, $destination);
}
/**
@@ -340,7 +126,7 @@ class ShortUrlRedirectController extends Controller
/**
* Async endpoint to scrape metadata for a URL.
*/
public function scrapeMeta(Request $request): \Illuminate\Http\JsonResponse
public function scrapeMeta(Request $request): JsonResponse
{
$url = $request->query('url');
if (empty($url) || ! filter_var($url, FILTER_VALIDATE_URL)) {
@@ -357,241 +143,63 @@ class ShortUrlRedirectController extends Controller
*/
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));
}
}
$key = $this->redirectHandler->resolveKey($request, $key, passwordAuth: true);
$shortUrl = ShortUrl::findByKey($key, HostNormalizer::normalize($request->getHost()));
$host = $request->getHost();
$shortUrl = ShortUrl::findByKey($key, $host);
if (! $shortUrl || empty($shortUrl->password)) {
if (! $shortUrl || ! $shortUrl->hasPassword()) {
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 ($inactive = $this->redirectHandler->respondIfInactive($shortUrl)) {
return $inactive;
}
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);
}
$this->redirectHandler->enforceAccessGuards($key, $request);
$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
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) {
$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) {
if ($shortUrl->verifyPassword((string) $request->input('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
RateLimiter::hit($passwordLimiterKey, 60);
$errors = new MessageBag([
return $this->robotsTagApplicator->apply(
$shortUrl,
response(view('filament-short-url::password-prompt', [
'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');
]),
]))->header('Content-Type', 'text/html'),
);
}
return response(view('filament-short-url::password-prompt'))
->header('Content-Type', 'text/html');
return $this->robotsTagApplicator->apply(
$shortUrl,
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() ?? '');
$this->redirectHandler->assertDestinationIsSafe($destination);
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}");
}
}
$isBot = $this->uaParser->getDeviceType($request->userAgent() ?? '') === 'robot';
$hasCustomOg = ! empty($shortUrl->og_title) || ! empty($shortUrl->og_description) || ! empty($shortUrl->og_image);
if ($shortUrl->is_cloaked || ($isBot && $hasCustomOg)) {
$activePixels = $shortUrl->pixels->where('is_active', true);
$pixelFired = $request->has('pixel_fired') || $request->has('confirmed');
if ($activePixels->isNotEmpty() && ! $pixelFired && ! $isBot) {
$queryString = $request->getQueryString();
$append = 'pixel_fired=1';
$nextUrl = $request->url().'?'.($queryString ? $queryString.'&'.$append : $append);
return response(view('filament-short-url::pixel-loading', [
'destination' => $nextUrl,
'pixels' => $activePixels,
]))->header('Content-Type', 'text/html');
}
return response(view('filament-short-url::redirect-html', [
'shortUrl' => $shortUrl,
'destination' => $destination,
'isBot' => $isBot,
]))->header('Content-Type', 'text/html');
}
$activePixels = $shortUrl->pixels->where('is_active', true);
if ($activePixels->isNotEmpty() && ! $request->has('confirmed')) {
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);
return $this->redirectHandler->buildResponse($shortUrl, $request, $key, $destination);
}
}

View File

@@ -0,0 +1,114 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class ShortUrlTagApiController extends Controller
{
public function index(Request $request): JsonResponse
{
$tags = $this->scopedQuery($request)
->orderBy('name')
->paginate(30);
return response()->json($tags);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'slug' => 'nullable|string|max:100|unique:short_url_tags,slug',
'color' => ['nullable', 'string', Rule::in(array_keys(ShortUrlTag::getColors()))],
]);
if (empty($validated['slug'])) {
$validated['slug'] = Str::slug($validated['name']);
}
$ownerUserId = $this->ownerUserId($request);
if ($ownerUserId !== null) {
$validated['user_id'] = $ownerUserId;
}
$tag = ShortUrlTag::create($validated);
return response()->json([
'data' => $tag,
'message' => 'Tag created successfully.',
], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$tag = $this->findTag($request, $id);
$validated = $request->validate([
'name' => 'sometimes|required|string|max:100',
'slug' => 'sometimes|required|string|max:100|unique:short_url_tags,slug,'.$tag->id,
'color' => ['sometimes', 'nullable', 'string', Rule::in(array_keys(ShortUrlTag::getColors()))],
]);
$tag->update($validated);
return response()->json([
'data' => $tag->fresh(),
'message' => 'Tag updated successfully.',
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$tag = $this->findTag($request, $id);
$tag->delete();
return response()->json([
'message' => 'Tag deleted successfully.',
]);
}
private function findTag(Request $request, int $id): ShortUrlTag
{
return $this->scopedQuery($request)->findOrFail($id);
}
/**
* @return Builder<ShortUrlTag>
*/
private function scopedQuery(Request $request)
{
$query = ShortUrlTag::query();
$ownerUserId = $this->ownerUserId($request);
if ($ownerUserId !== null) {
$query->where('user_id', $ownerUserId);
}
return $query;
}
private function ownerUserId(Request $request): ?int
{
/** @var array<string, mixed>|null $apiKey */
$apiKey = $request->attributes->get('fsu_api_key');
if (is_array($apiKey) && ! empty($apiKey['owner_user_id'])) {
return (int) $apiKey['owner_user_id'];
}
return null;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
use Bjanczak\FilamentShortUrl\Services\IframeableChecker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class ShortUrlUtilityController extends Controller
{
public function __construct(
private readonly IframeableChecker $iframeableChecker,
) {}
/**
* Pre-check whether a destination URL can be embedded in a cloaking iframe.
*/
public function checkIframeable(Request $request): JsonResponse
{
$validated = $request->validate([
'url' => 'required|url|max:2048',
]);
$url = $validated['url'];
$iframeable = $this->iframeableChecker->isIframeable($url);
return response()->json([
'url' => $url,
'iframeable' => $iframeable,
]);
}
}

View File

@@ -21,7 +21,7 @@ class AuthenticateShortUrlApi
{
if (! config('filament-short-url.api_enabled', false)) {
return response()->json([
'error' => 'The Developer API is currently disabled. Enable it in Short URL Settings → API & Webhooks.',
'error' => __('filament-short-url::default.api_disabled'),
], 503);
}
@@ -35,7 +35,7 @@ class AuthenticateShortUrlApi
if (empty($apiKey)) {
return response()->json([
'error' => 'Unauthorized. API Key is missing.',
'error' => __('filament-short-url::default.api_key_missing'),
], 401);
}
@@ -50,35 +50,32 @@ class AuthenticateShortUrlApi
continue;
}
// Check hashed key (new format) or fall back to plaintext key (legacy format)
// Accept only hashed key format.
$storedHash = $keyObj['hashed_key'] ?? null;
if ($storedHash) {
if (hash_equals($storedHash, $hashedInput)) {
if ($storedHash && 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 || ! $matchedKey) {
return response()->json([
'error' => 'Unauthorized. Invalid or inactive API Key.',
'error' => __('filament-short-url::default.api_key_invalid'),
], 401);
}
if ((bool) config('filament-short-url.scope_links_to_user', true) && empty($matchedKey['owner_user_id'])) {
return response()->json([
'error' => __('filament-short-url::default.api_key_owner_required'),
], 403);
}
// 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.',
'error' => __('filament-short-url::default.api_key_read_only'),
], 403);
}
@@ -92,7 +89,7 @@ class AuthenticateShortUrlApi
$retryAfter = RateLimiter::availableIn($limiterKey);
return response()->json([
'error' => 'Too many requests. API key rate limit exceeded.',
'error' => __('filament-short-url::default.api_rate_limit_exceeded'),
], 429, [
'Retry-After' => $retryAfter,
]);
@@ -101,6 +98,8 @@ class AuthenticateShortUrlApi
RateLimiter::hit($limiterKey, 60);
}
$request->attributes->set('fsu_api_key', $matchedKey);
return $next($request);
}
}

View 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\Http\Requests\Concerns;
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
use Bjanczak\FilamentShortUrl\Models\ShortUrlTag;
use Bjanczak\FilamentShortUrl\Support\CustomDomainValidator;
use Bjanczak\FilamentShortUrl\Support\ResourceOwnershipValidator;
trait ShortUrlApiAttributes
{
/**
* @return array<string, mixed>
*/
protected function apiAttributeRules(): array
{
$ownerUserId = CustomDomainValidator::ownerUserIdFromRequest($this);
return [
'folder_id' => [
'nullable',
'integer',
'exists:short_url_folders,id',
ResourceOwnershipValidator::ownershipClosure(ShortUrlFolder::class, $ownerUserId),
],
'tag_ids' => 'nullable|array|max:5',
'tag_ids.*' => [
'integer',
'exists:short_url_tags,id',
ResourceOwnershipValidator::ownershipClosure(ShortUrlTag::class, $ownerUserId),
],
'is_archived' => 'sometimes|boolean',
'is_cloaked' => 'sometimes|boolean',
'do_index' => 'sometimes|boolean',
'og_title' => 'nullable|string|max:255',
'og_description' => 'nullable|string|max:500',
'og_image' => 'nullable|url|max:2048',
'external_id' => 'nullable|string|max:255',
'utm_source' => 'nullable|string|max:255',
'utm_medium' => 'nullable|string|max:255',
'utm_campaign' => 'nullable|string|max:255',
'utm_term' => 'nullable|string|max:255',
'utm_content' => 'nullable|string|max:255',
'ref' => 'nullable|string|max:255',
'public_stats_enabled' => 'sometimes|boolean',
'public_stats_password' => 'nullable|string|max:255',
];
}
}

View File

@@ -8,12 +8,19 @@
namespace Bjanczak\FilamentShortUrl\Http\Requests;
use Bjanczak\FilamentShortUrl\Http\Requests\Concerns\ShortUrlApiAttributes;
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
use Bjanczak\FilamentShortUrl\Rules\OutboundUrl;
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
use Bjanczak\FilamentShortUrl\Support\CustomDomainValidator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreShortUrlRequest extends FormRequest
{
use ShortUrlApiAttributes;
/**
* Determine if the user is authorized to make this request.
*/
@@ -30,6 +37,7 @@ class StoreShortUrlRequest extends FormRequest
public function rules(): array
{
$safeBrowsing = app(SafeBrowsingService::class);
$safeUrlRule = app(SafeUrl::class);
$countries = __('filament-short-url::countries');
$countryRule = is_array($countries) && ! empty($countries)
@@ -41,14 +49,7 @@ class StoreShortUrlRequest extends FormRequest
? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages))))
: 'string|max:10';
$safeBrowsingRule = function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) {
if (empty($value)) {
return;
}
if (! $safeBrowsing->isSafe($value)) {
$fail(__('filament-short-url::default.safe_browsing_error'));
}
};
$safeBrowsingRule = $safeUrlRule;
$isLegacyRules = is_array($this->input('targeting_rules')) && isset($this->input('targeting_rules')['type']);
@@ -325,6 +326,7 @@ class StoreShortUrlRequest extends FormRequest
$isRequired ? 'required' : 'nullable',
'integer',
'exists:short_url_custom_domains,id,is_active,1,is_verified,1',
CustomDomainValidator::ownershipClosure(CustomDomainValidator::ownerUserIdFromRequest($this)),
];
$rules = [
@@ -343,7 +345,11 @@ class StoreShortUrlRequest extends FormRequest
'string',
'alpha_dash',
'max:32',
'unique:short_urls,url_key',
Rule::unique('short_urls', 'url_key')->where(function ($query) {
$domainScopeId = (int) ($this->input('custom_domain_id') ?? 0);
return $query->where('domain_scope_id', $domainScopeId);
}),
],
'notes' => 'nullable|string|max:255',
'is_enabled' => 'sometimes|required|boolean',
@@ -351,15 +357,16 @@ class StoreShortUrlRequest extends FormRequest
'single_use' => 'sometimes|required|boolean',
'forward_query_params' => 'sometimes|required|boolean',
'max_visits' => 'nullable|integer|min:1',
'expiration_redirect_url' => 'nullable|url|max:255',
'expiration_redirect_url' => ['nullable', 'url', 'max:255', $safeUrlRule],
'activated_at' => 'nullable|date|after_or_equal:today',
'expires_at' => 'nullable|date|after_or_equal:activated_at',
'webhook_url' => 'nullable|url|max:2048',
'webhook_url' => ['nullable', 'url', 'max:2048', $safeUrlRule, app(OutboundUrl::class)],
];
$rules = array_merge($rules, $targetingRules);
return array_merge($rules, [
return array_merge($rules, $this->apiAttributeRules(), [
'external_id' => 'nullable|string|max:255|unique:short_urls,external_id',
'password' => 'nullable|string|max:255',
'show_warning_page' => 'sometimes|required|boolean',
'auto_open_app_mobile' => 'sometimes|required|boolean',

Some files were not shown because too many files have changed in this diff Show More