release: v5.2.3 - Redis optimizations, stats scaling, DNS and CSP fixes
3
.gitignore
vendored
@@ -9,3 +9,6 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Internal maintainer notes (not shipped)
|
||||||
|
/AUDIT_IMPLEMENTATION.md
|
||||||
|
|||||||
363
CHANGELOG.md
Normal 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 (P0–P2)
|
||||||
|
|
||||||
|
- **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.
|
||||||
300
LICENSE
@@ -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")
|
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,
|
TERM AND CONDITION OF THIS AGREEMENT. IF YOU DO NOT ACCEPT, DO NOT DOWNLOAD,
|
||||||
INSTALL, OR USE THE SOFTWARE IN ANY WAY.
|
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
|
SECTION 1 — DEFINITIONS
|
||||||
================================================================================
|
================================================================================
|
||||||
@@ -21,27 +26,73 @@ SECTION 1 — DEFINITIONS
|
|||||||
documentation, and any future updates, patches, or versions thereof.
|
documentation, and any future updates, patches, or versions thereof.
|
||||||
|
|
||||||
1.2 "Copyright Holder" means Bartłomiej Janczak, sole and exclusive author
|
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
|
1.3 "You" (or "Your") means the individual or legal entity exercising rights
|
||||||
under this License.
|
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
|
(a) Personal, educational, research, or internal business use by a single
|
||||||
natural person or a single legal entity solely for that entity's own
|
natural person or a single legal entity solely for that entity's own
|
||||||
internal operations;
|
internal operations, including staging and non-production environments
|
||||||
(b) Deployment of the Software for a single, uniquely identified client as
|
controlled by that same entity;
|
||||||
a work-for-hire or freelance project, provided that:
|
(b) Deployment of the Software for one Distinct Client Engagement as a
|
||||||
(i) the client does not receive, resell, or otherwise transfer the
|
work-for-hire or freelance project, provided that:
|
||||||
Software or any portion thereof to any third party;
|
(i) You remain the sole licensee and operator responsible for
|
||||||
(ii) the deployment is limited to one (1) production environment per
|
compliance with this License;
|
||||||
distinct client engagement; and
|
(ii) the engagement uses no more than one (1) production environment
|
||||||
(iii) the monthly tracked-click/redirect volume of the resulting
|
for that client unless a Commercial License expressly authorizes
|
||||||
system does not exceed 1,000,000 (one million) events per
|
more;
|
||||||
calendar month across all deployed instances attributable to
|
(iii) You do not perform any Software Transfer to the client or any
|
||||||
that engagement.
|
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
|
sublicensing, or otherwise transferring — for monetary gain, in-kind
|
||||||
consideration, or any other commercial benefit — the Software, or any
|
consideration, or any other commercial benefit — the Software, or any
|
||||||
Derivative Work, or any Bundled Product, to one or more third parties.
|
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;
|
which the Software's functionality is a material component;
|
||||||
(c) providing the Software to a client under a contract where the client
|
(c) providing the Software to a client under a contract where the client
|
||||||
subsequently resells, licenses, or distributes the resulting product
|
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
|
modifies, adapts, translates, or is otherwise derived from the Software
|
||||||
or any substantial part thereof, regardless of the programming language,
|
or any substantial part thereof, regardless of the programming language,
|
||||||
file format, or packaging used.
|
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
|
theme, template, framework, boilerplate, starter kit, digital product, or
|
||||||
service offering in which the Software — in whole or in any part, modified
|
service offering in which the Software — in whole or in any part, modified
|
||||||
or unmodified — is included, embedded, required as a dependency, or
|
or unmodified — is included, embedded, required as a dependency, or
|
||||||
integrated in any manner, irrespective of whether the Software constitutes
|
integrated in any manner, irrespective of whether the Software constitutes
|
||||||
the primary component or a minor ancillary element.
|
the primary component or a minor ancillary element.
|
||||||
|
|
||||||
1.8 "High-Volume Use" means any deployment — whether by a single entity or
|
1.11 "Qualified Event" means each HTTP redirect response served through the
|
||||||
across multiple instances — that generates more than 1,000,000 (one
|
Software's redirect routes and each visit or click recorded by the
|
||||||
million) combined tracked clicks, redirects, or link events per calendar
|
Software's built-in tracking, counters, analytics, or daily-stats
|
||||||
month attributable to the Software's functionality.
|
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
|
including without limitation GitHub, GitLab, Bitbucket, Codeberg, or any
|
||||||
self-hosted equivalent, whether public, private, or restricted.
|
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.
|
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,
|
Copyright Holder grants You a worldwide, royalty-free, non-exclusive,
|
||||||
non-sublicensable, non-transferable license to:
|
non-sublicensable, non-transferable license to:
|
||||||
|
|
||||||
2.1 Download, install, and use the Software solely for Permitted Free Use as
|
2.1 Obtain copies of the Software only through Official Distribution Channels
|
||||||
defined in Section 1.4.
|
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
|
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,
|
(a) are kept strictly private and are never distributed, published,
|
||||||
shared, disclosed, or made accessible to any third party in any form;
|
shared, disclosed, or made accessible to any third party in any form;
|
||||||
(b) retain this entire License and all copyright notices without
|
(b) retain this entire License and all copyright notices without
|
||||||
alteration.
|
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.
|
No rights are granted except those expressly set out in this Section 2.
|
||||||
All other rights are reserved exclusively by the Copyright Holder.
|
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
|
The following acts are CATEGORICALLY AND UNCONDITIONALLY PROHIBITED unless
|
||||||
You hold a valid, current, written Commercial License issued by the Copyright
|
You hold a valid, current Commercial License issued by the Copyright Holder
|
||||||
Holder under Section 5:
|
under Section 5:
|
||||||
|
|
||||||
3.1 REDISTRIBUTION IN ANY FORM. You may not publish, upload, transmit,
|
3.1 REDISTRIBUTION IN ANY FORM. You may not publish, upload, transmit,
|
||||||
broadcast, mirror, host, or otherwise make available the Software — in
|
broadcast, mirror, host, or otherwise make available the Software — in
|
||||||
whole or in any part, modified or unmodified — through any means,
|
whole or in any part, modified or unmodified — through any means,
|
||||||
including but not limited to:
|
including but not limited to:
|
||||||
(a) any public or private Repository, regardless of access controls;
|
(a) any public or private Repository, regardless of access controls,
|
||||||
(b) any Package Registry;
|
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;
|
(c) file-sharing platforms, cloud storage, or peer-to-peer networks;
|
||||||
(d) email, instant messaging, or any other communication channel.
|
(d) email, instant messaging, or any other communication channel.
|
||||||
|
|
||||||
SOLE EXCEPTION — CONTRIBUTION FORKS: A single public fork of the
|
(A) CONTRIBUTION FORKS — SOLE EXCEPTION. Public fork(s) of the official
|
||||||
official repository on GitHub is permitted exclusively to submit a Pull
|
repository on GitHub are permitted exclusively to prepare and submit Pull
|
||||||
Request to the original repository. Such a fork:
|
Request(s) to the original repository. Each such fork:
|
||||||
(i) must be created solely for the purpose of the specific contribution;
|
(i) must be created and maintained solely for contribution work;
|
||||||
(ii) must not be used for any other purpose, including personal hosting
|
(ii) must not be used for hosting, demonstration, resale, or any
|
||||||
or showcase;
|
independent product;
|
||||||
(iii) must reproduce this License in full without modification;
|
(iii) must reproduce this License in full without modification;
|
||||||
(iv) must be deleted or converted to private within thirty (30) calendar
|
(iv) must be deleted or converted to private within thirty (30) calendar
|
||||||
days of the earlier of: (A) the Pull Request being closed, merged,
|
days of the earlier of: (A) all related Pull Request(s) being
|
||||||
or rejected; or (B) ninety (90) calendar days of inactivity. Failure
|
closed, merged, or rejected; or (B) ninety (90) calendar days of
|
||||||
to do so constitutes a material breach of this License.
|
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
|
(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
|
part, into any Bundled Product that is or will be subject to
|
||||||
Commercial Distribution;
|
Commercial Distribution;
|
||||||
@@ -144,30 +220,38 @@ Holder under Section 5:
|
|||||||
end users, whether individually priced or as part of a bundle or
|
end users, whether individually priced or as part of a bundle or
|
||||||
subscription.
|
subscription.
|
||||||
|
|
||||||
3.3 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
|
3.4 COMMERCIAL DISTRIBUTION. You may not engage in any act constituting
|
||||||
Commercial Distribution as defined in Section 1.5.
|
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
|
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
|
(a) publish, distribute, or otherwise make available the Software under a
|
||||||
different package name, author name, or vendor identity;
|
different package name, author name, or vendor identity;
|
||||||
(b) remove, obscure, alter, or misrepresent the copyright notice or author
|
(b) remove, obscure, alter, or misrepresent the copyright notice, license
|
||||||
attribution in any copy or portion of the Software;
|
text, or author attribution in any copy or portion of the Software;
|
||||||
(c) represent, imply, or permit others to believe that the Software was
|
(c) represent, imply, or permit others to believe that the Software was
|
||||||
created by anyone other than Bartłomiej Janczak.
|
created by anyone other than Bartłomiej Janczak.
|
||||||
|
|
||||||
3.6 SUBLICENSING AND TRANSFER. You may not grant any sublicense, assign any
|
3.7 SUBLICENSING AND TRANSFER OF LICENSE RIGHTS. You may not grant any
|
||||||
rights, or otherwise transfer this License or any rights granted hereunder
|
sublicense, assign any rights, or otherwise transfer this License or any
|
||||||
to any third party.
|
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
|
technical implementation with the intent or effect of circumventing any
|
||||||
restriction in this Section 3, including without limitation by splitting a
|
restriction in this Section 3, including without limitation by splitting a
|
||||||
Bundled Product into artificially separate components or by using an
|
Bundled Product into artificially separate components, using an
|
||||||
intermediary entity.
|
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)
|
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.
|
terms the Copyright Holder deems appropriate, including proprietary terms.
|
||||||
|
|
||||||
4.2 You represent and warrant that:
|
4.2 You represent and warrant that:
|
||||||
(a) You are the sole author of the contribution and have the full legal
|
(a) You are the sole author of the contribution or have obtained all
|
||||||
right to grant the license in Section 4.1;
|
rights necessary to grant the license in Section 4.1;
|
||||||
(b) the contribution does not infringe any third-party intellectual
|
(b) the contribution does not infringe any third-party intellectual
|
||||||
property rights;
|
property rights;
|
||||||
(c) the contribution is not subject to any license, agreement, or
|
(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
|
4.3 The Copyright Holder retains the right to reject any contribution without
|
||||||
giving reasons and without incurring any obligation to the contributor.
|
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
|
5.1 Any use not expressly permitted by Section 2 — including Commercial
|
||||||
Distribution, Bundled Product distribution, High-Volume Use, or any
|
Distribution, Software Transfer beyond Section 1.5(i)–(ii), Bundled
|
||||||
other commercial exploitation of the Software — requires a separate,
|
Product distribution, High-Volume Use, competing products under
|
||||||
written Commercial License issued by the Copyright Holder prior to such
|
Section 3.9, or any other commercial exploitation of the Software —
|
||||||
use.
|
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:
|
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
|
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
|
and may be subject to a one-time fee, recurring royalty, revenue-sharing
|
||||||
arrangement, or other terms as agreed in writing. No Commercial License
|
arrangement, or other terms as agreed in a written instrument signed by
|
||||||
is effective until signed in writing by the Copyright Holder.
|
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
|
5.4 Any act of Commercial Distribution, prohibited Software Transfer,
|
||||||
High-Volume Use without a valid Commercial License:
|
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;
|
(a) is null and void ab initio;
|
||||||
(b) constitutes a material breach of this License;
|
(b) constitutes a material breach of this License;
|
||||||
(c) constitutes direct infringement of the Copyright Holder's exclusive
|
(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,
|
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,
|
title, and interest in and to the Software, including all copyrights,
|
||||||
moral rights, trade secrets, and any other intellectual property rights,
|
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
|
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
|
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
|
janczakb/filament-short-url package and does not modify or supersede the
|
||||||
terms of any third-party license.
|
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
|
SECTION 7 — TERMINATION
|
||||||
================================================================================
|
================================================================================
|
||||||
@@ -262,7 +358,8 @@ SECTION 7 — TERMINATION
|
|||||||
(a) immediately cease all use, reproduction, and distribution of the
|
(a) immediately cease all use, reproduction, and distribution of the
|
||||||
Software;
|
Software;
|
||||||
(b) permanently delete all copies, full or partial, of the Software in
|
(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
|
(c) upon request, certify in writing to the Copyright Holder that You have
|
||||||
complied with Section 7.2(b).
|
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
|
FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. THE COPYRIGHT
|
||||||
HOLDER DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, OPERATE
|
HOLDER DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, OPERATE
|
||||||
WITHOUT INTERRUPTION, OR BE FREE FROM ERRORS OR SECURITY VULNERABILITIES.
|
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
|
This Section limits claims that a Licensee (or any user acting under this
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
License) may bring AGAINST the Copyright Holder. It does NOT limit, reduce,
|
||||||
CONSEQUENTIAL, OR PUNITIVE DAMAGES (INCLUDING BUT NOT LIMITED TO LOSS OF
|
or cap any remedy, claim, or enforcement action that the Copyright Holder may
|
||||||
PROFITS, LOSS OF DATA, BUSINESS INTERRUPTION, OR COST OF SUBSTITUTE GOODS OR
|
bring against You for breach of this License, copyright infringement, or
|
||||||
SERVICES) ARISING OUT OF OR IN CONNECTION WITH THIS LICENSE OR THE USE OR
|
unauthorized use, including without limitation Sections 5.4, 7, and 10.8.
|
||||||
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
|
9.1 EXCLUSION OF DAMAGES. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW,
|
||||||
THIS LICENSE SHALL NOT EXCEED EUR 100 (ONE HUNDRED EURO).
|
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
|
SECTION 10 — GENERAL PROVISIONS
|
||||||
@@ -324,11 +459,18 @@ SECTION 10 — GENERAL PROVISIONS
|
|||||||
publish revised versions of this License. Each version will be identified
|
publish revised versions of this License. Each version will be identified
|
||||||
by a version number. The version under which You obtained the Software
|
by a version number. The version under which You obtained the Software
|
||||||
governs Your use unless the Copyright Holder expressly states otherwise.
|
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
|
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.
|
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
|
END OF LICENSE
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|||||||
98
art/diagrams/01-system-overview.svg
Normal 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 |
109
art/diagrams/02-redirect-lifecycle.svg
Normal 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 |
78
art/diagrams/03-stateless-vs-stateful.svg
Normal 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 |
80
art/diagrams/04-custom-domain-routing.svg
Normal 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&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 |
85
art/diagrams/05-targeting-and-ab.svg
Normal 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 |
83
art/diagrams/06-webhooks-and-security.svg
Normal 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 |
116
art/diagrams/07-stats-and-queue.svg
Normal 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 |
143
art/diagrams/08-audit-trust-summary.svg
Normal 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 |
353
art/diagrams/generate-glass-diagrams.py
Normal 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&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}")
|
||||||
BIN
art/v3_1.png
|
Before Width: | Height: | Size: 241 KiB |
BIN
art/v3_2.png
|
Before Width: | Height: | Size: 251 KiB |
BIN
art/v3_3.png
|
Before Width: | Height: | Size: 467 KiB |
BIN
art/v3_4.png
|
Before Width: | Height: | Size: 252 KiB |
BIN
art/v3_5.png
|
Before Width: | Height: | Size: 460 KiB |
24
composer.lock
generated
@@ -1676,16 +1676,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/guzzle",
|
"name": "guzzlehttp/guzzle",
|
||||||
"version": "7.11.0",
|
"version": "7.11.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/guzzle.git",
|
"url": "https://github.com/guzzle/guzzle.git",
|
||||||
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b"
|
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b",
|
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||||
"reference": "c987f8ce84b8434fa430795eca0f3430663da72b",
|
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1704,7 +1704,7 @@
|
|||||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"guzzle/client-integration-tests": "3.0.2",
|
"guzzle/client-integration-tests": "3.0.2",
|
||||||
"guzzlehttp/test-server": "^0.4",
|
"guzzlehttp/test-server": "^0.5",
|
||||||
"php-http/message-factory": "^1.1",
|
"php-http/message-factory": "^1.1",
|
||||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
||||||
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
||||||
@@ -1784,7 +1784,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
"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": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1800,7 +1800,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-02T12:40:51+00:00"
|
"time": "2026-06-07T22:54:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/promises",
|
"name": "guzzlehttp/promises",
|
||||||
@@ -2156,16 +2156,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v12.61.0",
|
"version": "v12.61.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf"
|
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/1124062a1ca92d290c8bcb9b7f649920fa6816bf",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||||
"reference": "1124062a1ca92d290c8bcb9b7f649920fa6816bf",
|
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2374,7 +2374,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"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",
|
"name": "laravel/prompts",
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ return [
|
|||||||
*/
|
*/
|
||||||
'disable_default_domain' => env('SHORT_URL_DISABLE_DEFAULT_DOMAIN', false),
|
'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
|
| Geo-IP Settings
|
||||||
@@ -120,7 +131,8 @@ return [
|
|||||||
| Queue Connection
|
| Queue Connection
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Visit tracking is dispatched to a queue for ultra-fast redirects.
|
| 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'),
|
'queue_connection' => env('SHORT_URL_QUEUE', 'sync'),
|
||||||
|
|
||||||
@@ -133,6 +145,22 @@ return [
|
|||||||
*/
|
*/
|
||||||
'queue_name' => env('SHORT_URL_QUEUE_NAME', 'default'),
|
'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
|
| Redirect Cache TTL
|
||||||
@@ -195,6 +223,58 @@ return [
|
|||||||
*/
|
*/
|
||||||
'trust_cdn_headers' => (bool) env('SHORT_URL_TRUST_CDN_HEADERS', false),
|
'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
|
| Data Pruning & Aggregation
|
||||||
@@ -235,15 +315,56 @@ return [
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Redirect Route Middleware
|
| Redirect Route Middleware
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| The middleware list applied to the short URL redirect route.
|
| Middleware applied to the main short URL redirect route (/s/{key}).
|
||||||
| By default, standard web middleware and rate limiting are applied.
|
| 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' => [
|
'middleware' => [
|
||||||
'web',
|
|
||||||
'throttle:120,1',
|
'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
|
| 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
|
'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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
@@ -0,0 +1,9 @@
|
|||||||
|
includes:
|
||||||
|
- vendor/larastan/larastan/extension.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
level: 5
|
||||||
|
tmpDir: build/phpstan
|
||||||
|
checkMissingIterableValueType: false
|
||||||
2
resources/dist/filament-short-url.css
vendored
2
resources/dist/filament-short-url.js
vendored
@@ -488,7 +488,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
domain = this.domains[domainId];
|
domain = this.domains[domainId];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.routePrefix) {
|
if (this.routePrefix && !domainId) {
|
||||||
return `${this.protocol}://${domain}/${this.routePrefix}/${key}`;
|
return `${this.protocol}://${domain}/${this.routePrefix}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
216
resources/dist/meta-scraper.js
vendored
@@ -52,12 +52,145 @@ window.fsuResolveFormApi = function ($get, $set, $el) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
window.fsuScrape = function (val, $get, $set, $el) {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
if ($el) {
|
|
||||||
$el.dataset.scraped = 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,16 +203,24 @@ window.fsuScrape = function (val, $get, $set, $el) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($el && $el.dataset.scraped === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var api = window.fsuResolveFormApi($get, $set, $el);
|
var api = window.fsuResolveFormApi($get, $set, $el);
|
||||||
|
|
||||||
if (!api) {
|
if (!api) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.fsuIsPasswordProtected(api)) {
|
||||||
|
window.fsuStopScraping(api);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.fsuShouldSkipScrape($el, val, api.get)) {
|
||||||
|
window.fsuStopScraping(api);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.fsuDispatchScraping(true);
|
window.fsuDispatchScraping(true);
|
||||||
api.set('is_scraping', true);
|
api.set('is_scraping', true);
|
||||||
|
|
||||||
@@ -88,6 +229,18 @@ window.fsuScrape = function (val, $get, $set, $el) {
|
|||||||
return r.ok ? r.json() : null;
|
return r.ok ? r.json() : null;
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.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 && (data.title || data.description || data.image)) {
|
||||||
if (data.title && !api.get('og_title')) {
|
if (data.title && !api.get('og_title')) {
|
||||||
api.set('og_title', data.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')) {
|
if (data.description && !api.get('og_description')) {
|
||||||
api.set('og_description', data.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);
|
api.set('og_image_scraped', data.image);
|
||||||
|
window.fsuLockScrape($el);
|
||||||
} else {
|
} else {
|
||||||
api.set('is_scraping', false);
|
window.fsuStopScraping(api);
|
||||||
window.fsuDispatchScraping(false);
|
|
||||||
}
|
|
||||||
if ($el) {
|
|
||||||
$el.dataset.scraped = 'true';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.fsuMarkDestinationScraped($el, val);
|
||||||
} else {
|
} else {
|
||||||
api.set('is_scraping', false);
|
window.fsuStopScraping(api);
|
||||||
window.fsuDispatchScraping(false);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
api.set('is_scraping', false);
|
window.fsuStopScraping(api);
|
||||||
window.fsuDispatchScraping(false);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.fsuInitScrape = function ($get, $el) {
|
window.fsuInitScrape = function ($get, $el) {
|
||||||
var api = window.fsuResolveFormApi($get, null, $el);
|
var api = window.fsuResolveFormApi($get, null, $el);
|
||||||
|
|
||||||
if ($el && api) {
|
if (!$el || !api) {
|
||||||
try {
|
return;
|
||||||
$el.dataset.scraped = (api.get('og_title') || api.get('og_description') || api.get('og_image_scraped')) ? 'true' : 'false';
|
}
|
||||||
} catch (_) {
|
|
||||||
$el.dataset.scraped = 'false';
|
var dest = api.get('destination_url');
|
||||||
}
|
|
||||||
} else if ($el) {
|
if (!dest) {
|
||||||
$el.dataset.scraped = 'false';
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,10 +46,68 @@ return [
|
|||||||
'activated_at' => 'Active From',
|
'activated_at' => 'Active From',
|
||||||
'max_visits' => 'Max Visits Limit',
|
'max_visits' => 'Max Visits Limit',
|
||||||
'max_visits_helper' => 'Optional limit of total visits allowed before this link is deactivated.',
|
'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' => '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).',
|
'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',
|
'form_section_validity' => 'Validity & Limits',
|
||||||
'use_date_validity' => 'Set Date Range 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',
|
'notes' => 'Internal Notes',
|
||||||
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
|
'ga_tracking_id' => 'Google Analytics 4 Measurement ID',
|
||||||
'ga_tracking_id_helper' => 'Optional G-XXXXXXXXXX ID to track redirects server-side.',
|
'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' => 'Redirect Cache TTL',
|
||||||
'settings_cache_ttl_helper' => 'Seconds to cache resolved short URL records. Set to 0 to disable (not recommended in production).',
|
'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' => '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 (0–15). 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' => 'Enable Geo-IP Detection',
|
||||||
'settings_geoip_enabled_helper' => 'Detect and record the visitor\'s country on each visit.',
|
'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_ok' => '✅ API Secret is valid — GA4 connection successful',
|
||||||
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
|
'settings_ga4_verify_fail' => '❌ Invalid API Secret — GA4 rejected the request',
|
||||||
'settings_ga4_verify_empty' => 'Please enter an API Secret first.',
|
'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_ga4_verify_error' => '⚠️ Connection error — could not reach GA4',
|
||||||
|
|
||||||
'settings_section_buffering' => 'Visit Counters Buffering',
|
'settings_section_buffering' => 'Visit Counters Buffering',
|
||||||
'settings_buffering_enabled' => 'Buffer Visit Counts in Cache',
|
'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_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>',
|
'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
|
// 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_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_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_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 & 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 View & Export Localization
|
||||||
'stats_filter_visited_from' => 'Visited From',
|
'stats_filter_visited_from' => 'Visited From',
|
||||||
'stats_filter_visited_until' => 'Visited Until',
|
'stats_filter_visited_until' => 'Visited Until',
|
||||||
|
'stats_filter_counted_in_stats' => 'Stats-counted visits only',
|
||||||
'stats_action_export' => 'Export CSV',
|
'stats_action_export' => 'Export CSV',
|
||||||
'stats_csv_time' => 'Time',
|
'stats_csv_time' => 'Time',
|
||||||
'stats_csv_ip' => 'IP Address',
|
'stats_csv_ip' => 'IP Address',
|
||||||
@@ -308,8 +404,11 @@ return [
|
|||||||
'form_section_options' => 'Options',
|
'form_section_options' => 'Options',
|
||||||
'form_section_notes' => 'Internal Notes',
|
'form_section_notes' => 'Internal Notes',
|
||||||
'form_section_tracking' => 'Visit Tracking',
|
'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' => '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' => 'Third-Party Analytics',
|
||||||
|
'form_section_analytics_desc' => 'Integrate this link with Google Analytics by providing a data stream identifier.',
|
||||||
|
|
||||||
// New Dashboard Analytics Keys
|
// New Dashboard Analytics Keys
|
||||||
'stats_card_top_source' => 'Top UTM Source',
|
'stats_card_top_source' => 'Top UTM Source',
|
||||||
@@ -338,11 +437,27 @@ return [
|
|||||||
'security_section_title' => 'Security Controls',
|
'security_section_title' => 'Security Controls',
|
||||||
'password' => 'Access Password',
|
'password' => 'Access Password',
|
||||||
'password_helper' => 'Require visitors to enter a password before being redirected.',
|
'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',
|
'confirm_password' => 'Confirm Password',
|
||||||
'new_password' => 'New Password',
|
'new_password' => 'New Password',
|
||||||
'change_password' => 'Change Password',
|
'change_password' => 'Change Password',
|
||||||
'remove_password' => 'Remove Password',
|
'remove_password' => 'Remove Password',
|
||||||
'password_status_active' => 'Password protection is enabled.',
|
'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',
|
'set_password' => 'Set Password',
|
||||||
'cancel' => 'Cancel',
|
'cancel' => 'Cancel',
|
||||||
'confirm' => 'Confirm',
|
'confirm' => 'Confirm',
|
||||||
@@ -374,6 +489,7 @@ return [
|
|||||||
'variant_url' => 'Variant URL',
|
'variant_url' => 'Variant URL',
|
||||||
'variant_weight' => 'Traffic Share (%)',
|
'variant_weight' => 'Traffic Share (%)',
|
||||||
'safe_browsing_error' => 'This URL has been flagged by Google Safe Browsing as unsafe.',
|
'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
|
// New Advanced Targeting Builder
|
||||||
'targeting_rules' => 'Targeting Rules',
|
'targeting_rules' => 'Targeting Rules',
|
||||||
@@ -399,8 +515,8 @@ return [
|
|||||||
'settings_section_aggregation' => 'High-Traffic Log Management',
|
'settings_section_aggregation' => 'High-Traffic Log Management',
|
||||||
'settings_retention_days' => 'Raw Log Retention Period',
|
'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_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' => 'Enable raw visit log pruning',
|
||||||
'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_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_30_days' => '30 Days',
|
||||||
'retention_60_days' => '60 Days',
|
'retention_60_days' => '60 Days',
|
||||||
'retention_90_days' => '90 Days',
|
'retention_90_days' => '90 Days',
|
||||||
@@ -410,6 +526,7 @@ return [
|
|||||||
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
|
'settings_section_rate_limiting' => 'Rate Limiting / Bot Protection',
|
||||||
'settings_rate_limiting_enabled' => 'Enable Limit 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_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' => 'Max Redirects Allowed',
|
||||||
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
|
'settings_rate_limiting_max_attempts_helper' => 'Maximum allowed redirection requests within the decay window.',
|
||||||
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
|
'settings_rate_limiting_decay_seconds' => 'Decay Window (Seconds)',
|
||||||
@@ -418,7 +535,7 @@ return [
|
|||||||
// Queue Settings Additions
|
// Queue Settings Additions
|
||||||
'settings_queue_name' => 'Queue Name',
|
'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_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' => '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).',
|
'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',
|
'tab_marketing' => 'Marketing & API',
|
||||||
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
|
'marketing_pixels_title' => 'Retargeting Pixels (Client-Side)',
|
||||||
'marketing_pixels_desc' => 'Add ad tracking scripts to capture visitor cookies and build remarketing lists.',
|
'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_meta' => 'Meta Pixel ID',
|
||||||
'pixel_google' => 'Google Tag / GA4 ID',
|
'pixel_google' => 'Google Tag / GA4 ID',
|
||||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||||
'marketing_webhooks_title' => 'Link Webhook Integration',
|
'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.',
|
'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_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_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 additions
|
||||||
'settings_tab_developer' => 'API & Webhooks',
|
'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_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',
|
'settings_api_keys' => 'Active API Keys',
|
||||||
'api_key_name' => 'Key Name (Description)',
|
'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',
|
'api_key' => 'API Key',
|
||||||
'active' => 'Active',
|
'active' => 'Active',
|
||||||
'api_key_generated' => 'API Key Generated',
|
'api_key_generated' => 'API Key Generated',
|
||||||
@@ -507,6 +634,16 @@ return [
|
|||||||
// Security v2.0
|
// Security v2.0
|
||||||
'settings_section_security_v2' => 'Security & Anti-Fraud 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_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' => 'Enable VPN & Proxy Detection',
|
||||||
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
|
'settings_vpn_detection_enabled_helper' => 'When enabled, incoming visits will be checked for VPN, proxy, or Tor usage.',
|
||||||
'settings_vpn_driver' => 'Detection Driver',
|
'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_vpnapi_key_helper' => 'Your API key from vpnapi.io (required for vpnapi driver).',
|
||||||
'settings_vpn_block_action' => 'VPN Block Action',
|
'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_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_flag_only' => 'Flag in stats only (allow redirect)',
|
||||||
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
|
'settings_vpn_block_block_403' => 'Block traffic (serve 403 Forbidden)',
|
||||||
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
|
'settings_safe_browsing_enabled' => 'Enable Google Safe Browsing URL Verification',
|
||||||
@@ -575,6 +718,7 @@ return [
|
|||||||
'app_linking_auto_open' => 'Auto-Open',
|
'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_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_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_linking_standard_redirect' => 'This link will open in a standard mobile browser (no matching mobile app found).',
|
||||||
|
|
||||||
// App Redirect Page
|
// App Redirect Page
|
||||||
@@ -595,6 +739,11 @@ return [
|
|||||||
'balance_weights' => 'Adjust to 100%',
|
'balance_weights' => 'Adjust to 100%',
|
||||||
'url_key_locked_error' => 'The URL key is locked and cannot be changed.',
|
'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_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 View & Granularity & PoP Trends
|
||||||
'stats_prev_period' => 'prev period',
|
'stats_prev_period' => 'prev period',
|
||||||
@@ -732,7 +881,9 @@ return [
|
|||||||
// Live Feed
|
// Live Feed
|
||||||
'stats_tab_live_feed' => '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_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',
|
'stats_col_visitor' => 'Visitor / Location',
|
||||||
|
|
||||||
// Empty state mock values
|
// Empty state mock values
|
||||||
@@ -759,6 +910,7 @@ return [
|
|||||||
'tab_seo_social' => 'SEO & Social',
|
'tab_seo_social' => 'SEO & Social',
|
||||||
'is_cloaked' => 'Link Cloaking',
|
'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_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' => 'Search Engine Indexing',
|
||||||
'do_index_helper' => 'Allow search engines (like Google) to index this short URL. Otherwise, it is served with a noindex tag.',
|
'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',
|
'og_title' => 'Custom Title',
|
||||||
@@ -778,4 +930,60 @@ return [
|
|||||||
'og_empty_state_hint' => 'Paste a link to auto-generate preview',
|
'og_empty_state_hint' => 'Paste a link to auto-generate preview',
|
||||||
'password_prompt_preview_desc' => 'Visitors will see a password prompt before being redirected.',
|
'password_prompt_preview_desc' => 'Visitors will see a password prompt before being redirected.',
|
||||||
'fetching_metadata' => 'Fetching preview...',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -42,10 +42,68 @@ return [
|
|||||||
'activated_at' => 'Aktywny od',
|
'activated_at' => 'Aktywny od',
|
||||||
'max_visits' => 'Maksymalny limit wejść',
|
'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_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' => '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).',
|
'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',
|
'form_section_validity' => 'Ważność i limity',
|
||||||
'use_date_validity' => 'Ustaw limity datowe',
|
'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',
|
'notes' => 'Wewnętrzne notatki',
|
||||||
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
|
'ga_tracking_id' => 'Identyfikator śledzenia Google Analytics 4',
|
||||||
'ga_tracking_id_helper' => 'Opcjonalny identyfikator G-XXXXXXXXXX do śledzenia przekierowań po stronie serwera.',
|
'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' => 'TTL cache przekierowania',
|
||||||
'settings_cache_ttl_helper' => 'Sekundy cachowania rekordów krótkich URL. Ustaw 0 aby wyłączyć (niezalecane na produkcji).',
|
'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' => '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 (0–15). 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' => 'Włącz wykrywanie Geo-IP',
|
||||||
'settings_geoip_enabled_helper' => 'Wykrywaj i zapisuj kraj odwiedzającego przy każdej wizycie.',
|
'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_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_fail' => '❌ Nieprawidłowy klucz API — GA4 odrzuciło żądanie',
|
||||||
'settings_ga4_verify_empty' => 'Najpierw wprowadź klucz API.',
|
'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_ga4_verify_error' => '⚠️ Błąd połączenia — nie można nawiązać kontaktu z GA4',
|
||||||
|
|
||||||
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
'settings_section_buffering' => 'Buforowanie liczników wizyt',
|
||||||
'settings_buffering_enabled' => 'Buforuj kliknięcia w pamięci podręcznej (Cache)',
|
'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_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>',
|
'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
|
// 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_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_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_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 View & Export Localization
|
||||||
'stats_filter_visited_from' => 'Odwiedzono od',
|
'stats_filter_visited_from' => 'Odwiedzono od',
|
||||||
'stats_filter_visited_until' => 'Odwiedzono do',
|
'stats_filter_visited_until' => 'Odwiedzono do',
|
||||||
|
'stats_filter_counted_in_stats' => 'Tylko wizyty w statystykach',
|
||||||
'stats_action_export' => 'Eksportuj CSV',
|
'stats_action_export' => 'Eksportuj CSV',
|
||||||
'stats_csv_time' => 'Czas',
|
'stats_csv_time' => 'Czas',
|
||||||
'stats_csv_ip' => 'Adres IP',
|
'stats_csv_ip' => 'Adres IP',
|
||||||
@@ -305,8 +401,11 @@ return [
|
|||||||
'form_section_options' => 'Opcje',
|
'form_section_options' => 'Opcje',
|
||||||
'form_section_notes' => 'Wewnętrzne notatki',
|
'form_section_notes' => 'Wewnętrzne notatki',
|
||||||
'form_section_tracking' => 'Śledzenie wizyt',
|
'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' => 'Ś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' => 'Zewnętrzne systemy analityczne',
|
||||||
|
'form_section_analytics_desc' => 'Zintegruj ten link z Google Analytics, podając identyfikator strumienia danych.',
|
||||||
|
|
||||||
// New Dashboard Analytics Keys
|
// New Dashboard Analytics Keys
|
||||||
'stats_card_top_source' => 'Główne źródło UTM',
|
'stats_card_top_source' => 'Główne źródło UTM',
|
||||||
@@ -335,11 +434,27 @@ return [
|
|||||||
'security_section_title' => 'Kontrola bezpieczeństwa',
|
'security_section_title' => 'Kontrola bezpieczeństwa',
|
||||||
'password' => 'Hasło dostępu',
|
'password' => 'Hasło dostępu',
|
||||||
'password_helper' => 'Wymagaj od odwiedzających wprowadzenia hasła przed przekierowaniem.',
|
'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',
|
'confirm_password' => 'Potwierdź hasło',
|
||||||
'new_password' => 'Nowe hasło',
|
'new_password' => 'Nowe hasło',
|
||||||
'change_password' => 'Zmień hasło',
|
'change_password' => 'Zmień hasło',
|
||||||
'remove_password' => 'Usuń hasło',
|
'remove_password' => 'Usuń hasło',
|
||||||
'password_status_active' => 'Ochrona hasłem jest włączona.',
|
'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',
|
'set_password' => 'Ustaw hasło',
|
||||||
'cancel' => 'Anuluj',
|
'cancel' => 'Anuluj',
|
||||||
'confirm' => 'Potwierdź',
|
'confirm' => 'Potwierdź',
|
||||||
@@ -396,8 +511,8 @@ return [
|
|||||||
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
'settings_section_aggregation' => 'Zarządzanie logami o dużym natężeniu ruchu',
|
||||||
'settings_retention_days' => 'Czas retencji surowych logów',
|
'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_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' => 'Włącz automatyczne usuwanie surowych 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_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_30_days' => '30 dni',
|
||||||
'retention_60_days' => '60 dni',
|
'retention_60_days' => '60 dni',
|
||||||
'retention_90_days' => '90 dni',
|
'retention_90_days' => '90 dni',
|
||||||
@@ -407,6 +522,7 @@ return [
|
|||||||
'settings_section_rate_limiting' => 'Ograniczanie częstotliwości żądań / Ochrona przed botami',
|
'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' => 'Włącz ochronę limitów',
|
||||||
'settings_rate_limiting_enabled_helper' => 'Ograniczaj liczbę przekierowań na adres IP klienta.',
|
'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' => 'Maksymalna dopuszczalna liczba przekierowań',
|
||||||
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
|
'settings_rate_limiting_max_attempts_helper' => 'Maksymalna liczba żądań w oknie wygasania.',
|
||||||
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
|
'settings_rate_limiting_decay_seconds' => 'Okno wygasania (sekundy)',
|
||||||
@@ -415,7 +531,7 @@ return [
|
|||||||
// Queue Settings Additions
|
// Queue Settings Additions
|
||||||
'settings_queue_name' => 'Nazwa kolejki',
|
'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_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' => '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).',
|
'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',
|
'tab_marketing' => 'Marketing i API',
|
||||||
'marketing_pixels_title' => 'Piksele retargetingowe (Client-Side)',
|
'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_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_meta' => 'Meta Pixel ID',
|
||||||
'pixel_google' => 'Google Tag / GA4 ID',
|
'pixel_google' => 'Google Tag / GA4 ID',
|
||||||
'pixel_linkedin' => 'LinkedIn Partner ID',
|
'pixel_linkedin' => 'LinkedIn Partner ID',
|
||||||
'marketing_webhooks_title' => 'Integracja Webhook linku',
|
'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.',
|
'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_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_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 additions
|
||||||
'settings_tab_developer' => 'API i Webhooki',
|
'settings_tab_developer' => 'API i Webhooki',
|
||||||
@@ -504,6 +628,19 @@ return [
|
|||||||
// Ochrona v2.0
|
// Ochrona v2.0
|
||||||
'settings_section_security_v2' => 'Bezpieczeństwo i ochrona przed nadużyciami 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_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' => '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_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',
|
'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_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' => '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_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) cache’ować 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_flag_only' => 'Tylko flaguj w statystykach (zezwól na przekierowanie)',
|
||||||
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
|
'settings_vpn_block_block_403' => 'Blokuj ruch (wyświetl błąd 403 Forbidden)',
|
||||||
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
|
'settings_safe_browsing_enabled' => 'Włącz weryfikację przez Google Safe Browsing',
|
||||||
@@ -572,6 +715,7 @@ return [
|
|||||||
'app_linking_auto_open' => 'Autootwieranie',
|
'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_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_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_linking_standard_redirect' => 'Ten link otworzy się w zwykłej przeglądarce internetowej (brak dopasowanej aplikacji mobilnej).',
|
||||||
|
|
||||||
// App Redirect Page
|
// App Redirect Page
|
||||||
@@ -592,6 +736,11 @@ return [
|
|||||||
'balance_weights' => 'Wyreguluj do 100%',
|
'balance_weights' => 'Wyreguluj do 100%',
|
||||||
'url_key_locked_error' => 'Klucz URL jest zablokowany i nie może być zmieniony.',
|
'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_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 View & Granularity & PoP Trends
|
||||||
'stats_prev_period' => 'poprzedni okres',
|
'stats_prev_period' => 'poprzedni okres',
|
||||||
@@ -730,7 +879,9 @@ return [
|
|||||||
// Live Feed
|
// Live Feed
|
||||||
'stats_tab_live_feed' => 'Strumień aktywności',
|
'stats_tab_live_feed' => 'Strumień aktywności',
|
||||||
'stats_live_feed_empty' => 'Brak wizyt. Nowe wizyty pojawią się tutaj w czasie rzeczywistym.',
|
'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',
|
'stats_col_visitor' => 'Odwiedzający / Lokalizacja',
|
||||||
|
|
||||||
// Puste stany - makiety
|
// Puste stany - makiety
|
||||||
@@ -757,6 +908,7 @@ return [
|
|||||||
'tab_seo_social' => 'SEO i Social',
|
'tab_seo_social' => 'SEO i Social',
|
||||||
'is_cloaked' => 'Maskowanie linku (Cloaking)',
|
'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_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' => '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.',
|
'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ł',
|
'og_title' => 'Niestandardowy tytuł',
|
||||||
@@ -770,4 +922,60 @@ return [
|
|||||||
'live_qr_preview' => 'Podgląd kodu QR',
|
'live_qr_preview' => 'Podgląd kodu QR',
|
||||||
'advanced_options_section' => 'Zaawansowane opcje przekierowań i stanu',
|
'advanced_options_section' => 'Zaawansowane opcje przekierowań i stanu',
|
||||||
'fetching_metadata' => 'Pobieranie podglądu...',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
@php
|
@php
|
||||||
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
|
$matchedAppId = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::matchApp($destinationUrl);
|
||||||
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
$apps = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::getSupportedApps();
|
||||||
|
$appCount = count($apps);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
<div class="app-linking-preview">
|
||||||
|
|
||||||
{{-- Matched App Banner --}}
|
|
||||||
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
@if ($matchedAppId && isset($apps[$matchedAppId]))
|
||||||
@php
|
@php
|
||||||
$matchedApp = $apps[$matchedAppId];
|
$matchedApp = $apps[$matchedAppId];
|
||||||
@@ -13,106 +12,100 @@
|
|||||||
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
$matchedFavicon = "https://icons.duckduckgo.com/ip2/{$matchedDomain}.ico";
|
||||||
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
$deepLink = \Bjanczak\FilamentShortUrl\Services\AppLinkingEngine::convertToScheme($destinationUrl, $matchedAppId);
|
||||||
@endphp
|
@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="app-linking-status app-linking-status--matched">
|
||||||
<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">
|
<div class="app-linking-status-main">
|
||||||
<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-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>
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
<div class="app-linking-status-copy">
|
||||||
<span class="text-[11px] font-bold text-emerald-700 dark:text-emerald-400 truncate">{{ $matchedApp['name'] }}</span>
|
<div class="app-linking-status-title-row">
|
||||||
<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">
|
<p class="app-linking-status-title">{{ $matchedApp['name'] }}</p>
|
||||||
<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>
|
<span class="app-linking-status-badge app-linking-status-badge--success">
|
||||||
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
{{ __('filament-short-url::default.app_linking_auto_open') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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'])]) !!}
|
{!! __('filament-short-url::default.app_linking_matched_description', ['app' => e($matchedApp['name'])]) !!}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
{{-- Standard Web Redirect notice --}}
|
<div class="app-linking-status app-linking-status--browser">
|
||||||
<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="app-linking-status-icon app-linking-status-icon--browser">
|
||||||
<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 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||||
<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="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" />
|
||||||
<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"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[11px] text-neutral-500 dark:text-neutral-400 leading-snug">
|
|
||||||
{{ __('filament-short-url::default.app_linking_standard_redirect') }}
|
<div class="app-linking-status-copy">
|
||||||
</span>
|
<p class="app-linking-status-title">{{ __('filament-short-url::default.app_linking_standard_redirect') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Supported Apps Section --}}
|
<div class="app-linking-catalog">
|
||||||
<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-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="app-linking-os-badges" aria-label="{{ __('filament-short-url::default.app_linking_supported_os') }}">
|
||||||
<div class="flex items-center justify-between px-3 py-2.5 border-b border-neutral-100 dark:border-neutral-800">
|
<span class="app-linking-os-badge">iOS</span>
|
||||||
<span class="text-[10px] font-bold uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
<span class="app-linking-os-badge">Android</span>
|
||||||
{{ __('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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- iOS-style App Grid --}}
|
<div class="app-linking-grid">
|
||||||
<div class="p-2 grid grid-cols-4 gap-1">
|
|
||||||
@foreach ($apps as $appId => $app)
|
@foreach ($apps as $appId => $app)
|
||||||
@php
|
@php
|
||||||
$isMatched = ($appId === $matchedAppId);
|
$isMatched = ($appId === $matchedAppId);
|
||||||
$appDomain = explode('/', $app['domains'][0])[0];
|
$appDomain = explode('/', $app['domains'][0])[0];
|
||||||
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
|
$appFavicon = "https://icons.duckduckgo.com/ip2/{$appDomain}.ico";
|
||||||
@endphp
|
@endphp
|
||||||
<div class="flex flex-col items-center gap-1 py-2 px-1 rounded-xl transition-colors relative
|
|
||||||
{{ $isMatched
|
<div @class([
|
||||||
? 'bg-emerald-50 dark:bg-emerald-500/10'
|
'app-linking-app',
|
||||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50' }}">
|
'app-linking-app--matched' => $isMatched,
|
||||||
{{-- App Icon --}}
|
])>
|
||||||
<div class="w-10 h-10 rounded-[10px] flex items-center justify-center
|
<div class="app-linking-app-icon-wrap">
|
||||||
{{ $isMatched
|
|
||||||
? 'bg-white dark:bg-neutral-800 shadow-sm ring-1 ring-emerald-400/30'
|
|
||||||
: 'bg-neutral-100 dark:bg-neutral-800' }}">
|
|
||||||
<img
|
<img
|
||||||
src="{{ $appFavicon }}"
|
src="{{ $appFavicon }}"
|
||||||
alt="{{ $app['name'] }}"
|
alt=""
|
||||||
class="w-6 h-6 object-contain"
|
class="app-linking-app-icon"
|
||||||
|
loading="lazy"
|
||||||
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
|
onerror="this.src='https://icons.duckduckgo.com/ip2/google.com.ico'"
|
||||||
>
|
>
|
||||||
|
@if ($isMatched)
|
||||||
|
<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>
|
</div>
|
||||||
{{-- App Name --}}
|
<span class="app-linking-app-name">{{ $app['name'] }}</span>
|
||||||
<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"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Footer hint --}}
|
<p class="app-linking-footnote">
|
||||||
<div class="px-3 py-2 border-t border-neutral-100 dark:border-neutral-800">
|
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
|
||||||
<p class="text-[10px] text-neutral-400 dark:text-neutral-500 leading-relaxed">
|
</p>
|
||||||
{{ __('filament-short-url::default.app_linking_supported_apps_helper') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
216
resources/views/forms/components/number-stepper.blade.php
Normal 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>
|
||||||
169
resources/views/forms/components/segment-control.blade.php
Normal 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>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<div class="w-1.5" x-show="index > 0"></div>
|
<div class="w-1.5" x-show="index > 0"></div>
|
||||||
|
|
||||||
<!-- Segment Box -->
|
<!-- 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="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>
|
<span class="font-medium text-neutral-600 dark:text-neutral-400" x-show="item.weight >= 12" x-text="item.weight + '%'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
- $destHost string Host of destination URL (for favicon)
|
- $destHost string Host of destination URL (for favicon)
|
||||||
- $urlKey string URL key (used for QR download filenames)
|
- $urlKey string URL key (used for QR download filenames)
|
||||||
- $eid string Unique element ID prefix
|
- $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
|
- $successTitle string Modal heading text
|
||||||
- $successSubtitle string Modal subtitle text
|
- $successSubtitle string Modal subtitle text
|
||||||
- $successHelper string Modal helper text
|
- $successHelper string Modal helper text
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{-- QR toggle 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="
|
onclick="
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
window.location.replace("{!! addslashes($destination) !!}");
|
window.location.replace(@json($destination));
|
||||||
}, 250);
|
}, 250);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
48
resources/views/public-stats-layout.blade.php
Normal 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>
|
||||||
27
resources/views/public-stats-password.blade.php
Normal 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
|
||||||
12
resources/views/public-stats-rate-limited.blade.php
Normal 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
|
||||||
69
resources/views/public-stats.blade.php
Normal 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
|
||||||
@@ -3,48 +3,48 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 --}}
|
{{-- Robots Search Indexing configuration --}}
|
||||||
@if($shortUrl->do_index)
|
@if($shortUrl->do_index)
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="{{ $ogMeta['canonical_url'] }}">
|
||||||
@else
|
@else
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Open Graph / Facebook Metadata --}}
|
{{-- Open Graph / Facebook Metadata --}}
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="{{ $shortUrl->getShortUrl() }}">
|
<meta property="og:url" content="{{ $ogMeta['short_url'] }}">
|
||||||
<meta property="og:title" content="{{ $shortUrl->og_title ?: ($shortUrl->title ?: $shortUrl->url_key) }}">
|
<meta property="og:site_name" content="{{ $ogMeta['site_name'] }}">
|
||||||
@if($shortUrl->og_description)
|
<meta property="og:title" content="{{ $ogMeta['title'] }}">
|
||||||
<meta property="og:description" content="{{ $shortUrl->og_description }}">
|
@if($ogMeta['description'])
|
||||||
|
<meta property="og:description" content="{{ $ogMeta['description'] }}">
|
||||||
@endif
|
@endif
|
||||||
@if($shortUrl->og_image)
|
@if($ogMeta['image_url'])
|
||||||
@php
|
<meta property="og:image" content="{{ $ogMeta['image_url'] }}">
|
||||||
$ogImageUrl = $shortUrl->og_image;
|
@if($ogMeta['image_width'] && $ogMeta['image_height'])
|
||||||
if (!str_starts_with($ogImageUrl, 'http')) {
|
<meta property="og:image:width" content="{{ $ogMeta['image_width'] }}">
|
||||||
$ogImageUrl = \Illuminate\Support\Facades\Storage::disk('public')->url($ogImageUrl);
|
<meta property="og:image:height" content="{{ $ogMeta['image_height'] }}">
|
||||||
}
|
@endif
|
||||||
@endphp
|
|
||||||
<meta property="og:image" content="{{ $ogImageUrl }}">
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Twitter Metadata --}}
|
{{-- Twitter Metadata --}}
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:url" content="{{ $shortUrl->getShortUrl() }}">
|
<meta name="twitter:url" content="{{ $ogMeta['short_url'] }}">
|
||||||
<meta name="twitter:title" content="{{ $shortUrl->og_title ?: ($shortUrl->title ?: $shortUrl->url_key) }}">
|
<meta name="twitter:title" content="{{ $ogMeta['title'] }}">
|
||||||
@if($shortUrl->og_description)
|
@if($ogMeta['description'])
|
||||||
<meta name="twitter:description" content="{{ $shortUrl->og_description }}">
|
<meta name="twitter:description" content="{{ $ogMeta['description'] }}">
|
||||||
@endif
|
@endif
|
||||||
@if($shortUrl->og_image)
|
@if($ogMeta['image_url'])
|
||||||
<meta name="twitter:image" content="{{ $ogImageUrl }}">
|
<meta name="twitter:image" content="{{ $ogMeta['image_url'] }}">
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- If not cloaked, execute client-side redirect for users --}}
|
{{-- Human visitors only: bots must stay on this page to read OG tags --}}
|
||||||
@if(!$shortUrl->is_cloaked)
|
@if(!$shortUrl->is_cloaked && !$isBot)
|
||||||
<meta http-equiv="refresh" content="0;url={!! $destination !!}">
|
<meta http-equiv="refresh" content="0;url={{ e($destination) }}">
|
||||||
<script>
|
<script>
|
||||||
window.location.replace("{!! addslashes($destination) !!}");
|
window.location.replace(@json($destination));
|
||||||
</script>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -79,18 +79,17 @@
|
|||||||
|
|
||||||
@if($shortUrl->is_cloaked)
|
@if($shortUrl->is_cloaked)
|
||||||
{{-- Link Cloaking Iframe --}}
|
{{-- Link Cloaking Iframe --}}
|
||||||
<iframe src="{!! $destination !!}" title="{{ $shortUrl->og_title ?: $shortUrl->url_key }}"></iframe>
|
<iframe src="{{ e($destination) }}" title="{{ $ogMeta['title'] }}"></iframe>
|
||||||
@else
|
@elseif(!$isBot)
|
||||||
{{-- Standard JS redirect page fallback --}}
|
{{-- 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">
|
<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">
|
<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-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-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>
|
<span class="block w-2.5 h-2.5 rounded-full bg-neutral-500 dark:bg-neutral-400 animate-bounce"></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-medium text-neutral-600">Redirecting you to the destination website...</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="{!! $destination !!}" class="text-primary-600 underline font-semibold hover:text-primary-500">click here</a>.</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,18 @@
|
|||||||
@php
|
@php
|
||||||
$ogTitle = $get('og_title') ?: null;
|
$ogTitle = $ogTitle ?? ($get('og_title') ?: null);
|
||||||
$ogDescription = $get('og_description') ?: null;
|
$ogDescription = $ogDescription ?? ($get('og_description') ?: null);
|
||||||
|
$ogImageUrl = $ogImageUrl ?? null;
|
||||||
// Resolve the OG image url
|
$isScraping = $isScraping ?? ($get('is_scraping') ?: false);
|
||||||
$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;
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="sidebar-social-container"
|
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-data="{ scraping: @js((bool) $isScraping), passwordProtected: @js((bool) $isPasswordProtected) }"
|
||||||
x-on:fsu-scraping-start.window="scraping = true"
|
x-on:fsu-scraping-start.window="scraping = true"
|
||||||
x-on:fsu-scraping-end.window="scraping = false"
|
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) --}}
|
{{-- Header: Title only (no "i" icon) --}}
|
||||||
@@ -115,9 +99,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if ($ogImage)
|
@if ($ogImageUrl)
|
||||||
{{-- OG Image preview --}}
|
{{-- 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
|
@else
|
||||||
{{-- Empty state: photo icon + text --}}
|
{{-- 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">
|
<div class="flex flex-col items-center gap-2 text-neutral-400 dark:text-neutral-500 select-none pointer-events-none">
|
||||||
|
|||||||
44
resources/views/table/public-stats-url-field.blade.php
Normal 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>
|
||||||
@@ -1,46 +1,12 @@
|
|||||||
@php
|
@php
|
||||||
$rawJson = '{
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WebhookPayloadExample;
|
||||||
"event": "visited",
|
|
||||||
"timestamp": "2026-06-04T12:00:00+02:00",
|
$rawJson = $rawJson ?? WebhookPayloadExample::visitedEventSampleJson();
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}';
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-2 mt-4">
|
<div class="marketing-webhook-payload-wrap">
|
||||||
<label class="text-sm font-medium leading-6 text-gray-950 dark:text-white">
|
<div
|
||||||
{{ __('filament-short-url::default.webhook_show_payload') }}
|
x-data="{
|
||||||
</label>
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-data="{
|
|
||||||
copied: false,
|
copied: false,
|
||||||
rawJson: @js($rawJson),
|
rawJson: @js($rawJson),
|
||||||
copy() {
|
copy() {
|
||||||
@@ -48,61 +14,23 @@
|
|||||||
this.copied = true;
|
this.copied = true;
|
||||||
setTimeout(() => this.copied = false, 2000);
|
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
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="copy"
|
x-on:click="copy"
|
||||||
x-on:mouseenter="$el.style.backgroundColor='rgba(255, 255, 255, 0.15)'; $el.style.color='#ffffff';"
|
class="marketing-webhook-payload-copy"
|
||||||
x-on:mouseleave="$el.style.backgroundColor='rgba(255, 255, 255, 0.08)'; $el.style.color='#a1a1aa';"
|
title="{{ __('filament-short-url::default.webhook_payload_copy') }}"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<!-- Copy Icon -->
|
|
||||||
<svg x-show="!copied" style="height: 1rem; width: 1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<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" />
|
<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>
|
</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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Syntax Highlighted Payload -->
|
<pre class="marketing-webhook-payload-pre"><code class="marketing-webhook-payload-code-inner">{{ $rawJson }}</code></pre>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
default => ucfirst($device),
|
default => ucfirst($device),
|
||||||
};
|
};
|
||||||
@endphp
|
@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">
|
<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">
|
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||||
@if (strtolower($device) === 'desktop')
|
@if (strtolower($device) === 'desktop')
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="space-y-3.5">
|
<div class="space-y-3.5">
|
||||||
@forelse ($visitsByBrowser as $browser => $count)
|
@forelse ($visitsByBrowser as $browser => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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 flex-col min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<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" />
|
<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">
|
<div class="space-y-3.5">
|
||||||
@forelse ($visitsByOs as $os => $count)
|
@forelse ($visitsByOs as $os => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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 flex-col min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<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" />
|
<x-filament::icon :icon="$osIcons[$os] ?? 'heroicon-m-cpu-chip'" class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
$translatedCountry = strtoupper($code);
|
$translatedCountry = strtoupper($code);
|
||||||
}
|
}
|
||||||
@endphp
|
@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">
|
<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">
|
<span class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-300">
|
||||||
@if ($code)
|
@if ($code)
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<div class="space-y-3.5">
|
<div class="space-y-3.5">
|
||||||
@forelse ($visitsByCity as $city => $count)
|
@forelse ($visitsByCity as $city => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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">
|
<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-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>
|
<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;
|
$pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0;
|
||||||
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
|
$langName = \Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlLanguagesWidget::getLanguageTranslation($langCode);
|
||||||
@endphp
|
@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">
|
<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-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>
|
<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>
|
||||||
|
|||||||
@@ -1,13 +1,54 @@
|
|||||||
<x-filament-widgets::widget>
|
<x-filament-widgets::widget>
|
||||||
{{--
|
{{--
|
||||||
wire:poll calls checkForUpdates() instead of blindly re-rendering.
|
Server-Sent Events push visit cursor updates to the browser.
|
||||||
checkForUpdates() does a single MAX(id) query; if nothing changed it
|
onStreamUpdate() advances latestVisitId and triggers a render only when needed.
|
||||||
calls skipRender() and Livewire returns a ~100-byte "no diff" response.
|
|
||||||
Only when a new visit is detected does the full render happen.
|
|
||||||
.visible stops polling entirely when the widget is scrolled out of view.
|
|
||||||
--}}
|
--}}
|
||||||
<div wire:poll.5s.visible="checkForUpdates"
|
<div
|
||||||
class="fi-section rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900 overflow-hidden">
|
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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
|
<div class="flex items-center justify-between border-b border-gray-200 dark:border-white/10 bg-gray-50/50 dark:bg-white/5 px-6 py-4">
|
||||||
@@ -21,7 +62,11 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<div class="space-y-3.5">
|
<div class="space-y-3.5">
|
||||||
@forelse ($visitsByReferer as $referer => $count)
|
@forelse ($visitsByReferer as $referer => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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">
|
<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-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>
|
<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">
|
<div class="space-y-3.5 mt-2">
|
||||||
@forelse ($utmSources as $source => $count)
|
@forelse ($utmSources as $source => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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-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">
|
<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>
|
{{ 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">
|
<div class="space-y-3.5 mt-2">
|
||||||
@forelse ($utmMediums as $medium => $count)
|
@forelse ($utmMediums as $medium => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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-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">
|
<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>
|
{{ 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">
|
<div class="space-y-3.5 mt-2">
|
||||||
@forelse ($utmCampaigns as $campaign => $count)
|
@forelse ($utmCampaigns as $campaign => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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-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">
|
<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>
|
{{ 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">
|
<div class="space-y-3.5 mt-2">
|
||||||
@forelse ($utmTerms as $term => $count)
|
@forelse ($utmTerms as $term => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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-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">
|
<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>
|
{{ 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">
|
<div class="space-y-3.5 mt-2">
|
||||||
@forelse ($utmContents as $content => $count)
|
@forelse ($utmContents as $content => $count)
|
||||||
@php $pct = $totalVisits > 0 ? round(($count / $totalVisits) * 100, 1) : 0; @endphp
|
@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-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">
|
<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>
|
{{ number_format($count) }} <span class="text-gray-400 dark:text-gray-500">({{ $pct }}%)</span>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@endphp
|
@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">
|
<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-medium text-gray-700 dark:text-gray-300">{{ $variant }}</span>
|
||||||
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
<span class="font-mono text-xs font-semibold text-gray-900 dark:text-white">
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlApiController;
|
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\ShortUrlLogoController;
|
||||||
|
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlPublicStatsController;
|
||||||
use Bjanczak\FilamentShortUrl\Http\Controllers\ShortUrlRedirectController;
|
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 Bjanczak\FilamentShortUrl\Http\Middleware\AuthenticateShortUrlApi;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
Route::get('/.well-known/apple-app-site-association', [ShortUrlRedirectController::class, 'serveAasa'])
|
||||||
@@ -31,15 +38,45 @@ Route::match(
|
|||||||
->where('key', '[a-zA-Z0-9_-]+')
|
->where('key', '[a-zA-Z0-9_-]+')
|
||||||
->middleware(array_merge(['web'], config('filament-short-url.middleware', ['throttle:120,1'])));
|
->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')
|
Route::prefix('api/short-url')
|
||||||
->middleware([
|
->middleware([
|
||||||
AuthenticateShortUrlApi::class,
|
AuthenticateShortUrlApi::class,
|
||||||
])
|
])
|
||||||
->group(function () {
|
->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::get('links', [ShortUrlApiController::class, 'index']);
|
||||||
Route::post('links', [ShortUrlApiController::class, 'store']);
|
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}', [ShortUrlApiController::class, 'show']);
|
||||||
Route::get('links/{idOrKey}/stats', [ShortUrlApiController::class, 'stats']);
|
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::match(['PUT', 'PATCH'], 'links/{idOrKey}', [ShortUrlApiController::class, 'update']);
|
||||||
Route::delete('links/{idOrKey}', [ShortUrlApiController::class, 'destroy']);
|
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'])
|
Route::get('short-url/logo/{filename}', [ShortUrlLogoController::class, 'serveLogo'])
|
||||||
->name('short-url.logo');
|
->name('short-url.logo');
|
||||||
|
|
||||||
Route::middleware(['web'])
|
Route::middleware(['web', 'auth', 'throttle:60,1'])
|
||||||
->post('short-url/log-error', function (\Illuminate\Http\Request $request) {
|
->get('short-url/live-feed/{shortUrl}/stream', ShortUrlLiveFeedStreamController::class)
|
||||||
\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'));
|
->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']);
|
return response()->json(['status' => 'ok']);
|
||||||
})
|
})
|
||||||
->name('short-url.log-error');
|
->name('short-url.log-error');
|
||||||
@@ -58,6 +114,10 @@ Route::middleware(['web', 'auth'])
|
|||||||
->get('short-url/scrape-meta', [ShortUrlRedirectController::class, 'scrapeMeta'])
|
->get('short-url/scrape-meta', [ShortUrlRedirectController::class, 'scrapeMeta'])
|
||||||
->name('short-url.scrape-meta');
|
->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)) {
|
if (config('filament-short-url.enable_fallback_route', true)) {
|
||||||
Route::fallback(ShortUrlRedirectController::class)
|
Route::fallback(ShortUrlRedirectController::class)
|
||||||
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
->middleware(config('filament-short-url.middleware', ['throttle:120,1']));
|
||||||
|
|||||||
92
scripts/k6/redirect-baseline.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||||
|
|
||||||
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlDailyStats;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
||||||
|
use Bjanczak\FilamentShortUrl\Services\Stats\CrossDimensionalStatsEngine;
|
||||||
|
use Bjanczak\FilamentShortUrl\Services\StatsSqlHelper;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -27,20 +30,52 @@ class AggregateAndPruneVisitsCommand extends Command
|
|||||||
default => 'DATE(visited_at)',
|
default => 'DATE(visited_at)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Find the oldest and newest visit dates before today using highly optimized min/max index scanning
|
$lastAggregationDate = DB::table('short_url_settings')
|
||||||
$oldestVisit = ShortUrlVisit::where('visited_at', '<', $today)->min('visited_at');
|
->where('key', 'last_aggregation_date')
|
||||||
if (! $oldestVisit) {
|
->value('value');
|
||||||
$dates = [];
|
|
||||||
} else {
|
|
||||||
$latestVisit = ShortUrlVisit::where('visited_at', '<', $today)->max('visited_at');
|
|
||||||
$startCarbon = Carbon::parse($oldestVisit)->startOfDay();
|
|
||||||
$endCarbon = Carbon::parse($latestVisit)->startOfDay();
|
|
||||||
|
|
||||||
$dates = [];
|
$candidateDates = DB::table('short_url_visits')
|
||||||
$current = $startCarbon->copy();
|
->where('visited_at', '<', $today)
|
||||||
while ($current->lte($endCarbon)) {
|
->where('is_bot', false)
|
||||||
$dates[] = $current->toDateString();
|
->where('is_proxy', false)
|
||||||
$current->addDay();
|
->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 = [];
|
||||||
|
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 {
|
} else {
|
||||||
$this->info('Found '.count($dates).' days to aggregate.');
|
$this->info('Found '.count($dates).' days to aggregate.');
|
||||||
|
|
||||||
|
$maxAggregatedDate = null;
|
||||||
|
$affectedShortUrlIds = [];
|
||||||
|
|
||||||
foreach ($dates as $date) {
|
foreach ($dates as $date) {
|
||||||
// Wrap date aggregation in a database transaction for data integrity
|
DB::transaction(function () use ($date, &$affectedShortUrlIds): void {
|
||||||
DB::transaction(function () use ($date): void {
|
|
||||||
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
$nextDate = Carbon::parse($date)->addDay()->toDateString();
|
||||||
$start = $date.' 00:00:00';
|
$start = $date.' 00:00:00';
|
||||||
$end = $nextDate.' 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();
|
$driver = DB::connection()->getDriverName();
|
||||||
$qrExpr = $driver === 'pgsql'
|
$qrExpr = $driver === 'pgsql'
|
||||||
? 'count(case when is_qr_scan::int = 1 then 1 end) as qr_scans'
|
? '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';
|
: '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')
|
$totals = DB::table('short_url_visits')
|
||||||
->where('visited_at', '>=', $start)
|
->where('visited_at', '>=', $start)
|
||||||
->where('visited_at', '<', $end)
|
->where('visited_at', '<', $end)
|
||||||
|
->where('is_bot', false)
|
||||||
|
->where('is_proxy', false)
|
||||||
->select([
|
->select([
|
||||||
'short_url_id',
|
'short_url_id',
|
||||||
DB::raw('count(*) as total'),
|
DB::raw('count(*) as total'),
|
||||||
@@ -75,124 +118,172 @@ class AggregateAndPruneVisitsCommand extends Command
|
|||||||
->groupBy('short_url_id')
|
->groupBy('short_url_id')
|
||||||
->get();
|
->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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$statsByUrl = [];
|
$statsByUrl = [];
|
||||||
|
|
||||||
foreach ($totals as $row) {
|
foreach ($totals as $row) {
|
||||||
$statsByUrl[$row->short_url_id] = [
|
$statsByUrl[$row->short_url_id] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
|
||||||
'total' => (int) $row->total,
|
'total' => (int) $row->total,
|
||||||
'uniques' => (int) $row->uniques,
|
'uniques' => (int) $row->uniques,
|
||||||
'qr_scans' => (int) $row->qr_scans,
|
'qr_scans' => (int) $row->qr_scans,
|
||||||
'device_stats' => [],
|
'all_visits' => 0,
|
||||||
'browser_stats' => [],
|
'bot_visits' => 0,
|
||||||
'os_stats' => [],
|
'proxy_visits' => 0,
|
||||||
'country_stats' => [],
|
]);
|
||||||
'city_stats' => [],
|
|
||||||
'referer_stats' => [],
|
|
||||||
'utm_source_stats' => [],
|
|
||||||
'utm_medium_stats' => [],
|
|
||||||
'utm_campaign_stats' => [],
|
|
||||||
'language_stats' => [],
|
|
||||||
'variant_stats' => [],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to fetch and populate category stats natively in database GROUP BY
|
foreach ($securityTotals as $urlId => $secRow) {
|
||||||
$populateStats = function (string $column, string $statsKey) use ($start, $end, &$statsByUrl): void {
|
if (! isset($statsByUrl[$urlId])) {
|
||||||
$urlIds = array_keys($statsByUrl);
|
$statsByUrl[$urlId] = array_merge(CrossDimensionalStatsEngine::emptyBucket(), [
|
||||||
if (empty($urlIds)) {
|
'total' => 0,
|
||||||
return;
|
'uniques' => 0,
|
||||||
|
'qr_scans' => 0,
|
||||||
|
'all_visits' => (int) $secRow->all_visits,
|
||||||
|
'bot_visits' => (int) $secRow->bot_visits,
|
||||||
|
'proxy_visits' => (int) $secRow->proxy_visits,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$query = DB::table('short_url_visits')
|
$statsByUrl[$urlId]['all_visits'] = (int) $secRow->all_visits;
|
||||||
->where('visited_at', '>=', $start)
|
$statsByUrl[$urlId]['bot_visits'] = (int) $secRow->bot_visits;
|
||||||
->where('visited_at', '<', $end)
|
$statsByUrl[$urlId]['proxy_visits'] = (int) $secRow->proxy_visits;
|
||||||
->whereIn('short_url_id', $urlIds)
|
}
|
||||||
->whereNotNull($column)
|
|
||||||
->where($column, '<>', '');
|
|
||||||
|
|
||||||
if ($statsKey === 'city_stats') {
|
$cursor = DB::table('short_url_visits')
|
||||||
$query->select(['short_url_id', 'city', 'country_code', DB::raw('count(*) as count')])
|
->where('visited_at', '>=', $start)
|
||||||
->groupBy(['short_url_id', 'city', 'country_code']);
|
->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 {
|
} else {
|
||||||
$query->select(['short_url_id', $column, DB::raw('count(*) as count')])
|
ShortUrlDailyStats::query()->create([
|
||||||
->groupBy(['short_url_id', $column]);
|
'short_url_id' => $urlId,
|
||||||
|
'date' => $date,
|
||||||
|
...$payload,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $query->get();
|
$affectedShortUrlIds[] = (int) $urlId;
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$urlId = $row->short_url_id;
|
|
||||||
if (! isset($statsByUrl[$urlId])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statsKey === 'city_stats') {
|
|
||||||
$cityVal = $row->city;
|
|
||||||
$countryCode = $row->country_code;
|
|
||||||
$val = $countryCode ? "{$cityVal} ({$countryCode})" : $cityVal;
|
|
||||||
} else {
|
|
||||||
$val = $row->$column;
|
|
||||||
}
|
|
||||||
|
|
||||||
$statsByUrl[$urlId][$statsKey][$val] = (int) $row->count;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Populate all categories via 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([
|
|
||||||
'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'],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->info("Aggregated stats for {$date}.");
|
$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)) {
|
if (config('filament-short-url.pruning.enabled', true)) {
|
||||||
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
|
$retentionDays = (int) config('filament-short-url.pruning.retention_days', 90);
|
||||||
$cutoff = Carbon::now()->subDays($retentionDays)->toDateTimeString();
|
$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.");
|
$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);
|
$prunedCount = app(ShortUrlTempStorage::class)->pruneBucketsOlderThanHours(24);
|
||||||
|
|
||||||
if ($prunedCount > 0) {
|
if ($prunedCount > 0) {
|
||||||
|
|||||||
83
src/Console/Commands/StressRedirectCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
namespace Bjanczak\FilamentShortUrl\Console\Commands;
|
||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
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\Console\Command;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class SyncBufferedCountersCommand extends Command
|
class SyncBufferedCountersCommand extends Command
|
||||||
{
|
{
|
||||||
@@ -16,36 +17,10 @@ class SyncBufferedCountersCommand extends Command
|
|||||||
/** @var string */
|
/** @var string */
|
||||||
protected $description = 'Sync buffered short URL visit counters from cache to the database';
|
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:');
|
$pull = $buffer->pullDirtyIdsForSync();
|
||||||
$dirtyKey = "{$prefix}dirty_ids";
|
$dirtyIds = $pull['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, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($dirtyIds)) {
|
if (empty($dirtyIds)) {
|
||||||
$this->info('No buffered counters to synchronize.');
|
$this->info('No buffered counters to synchronize.');
|
||||||
@@ -53,25 +28,14 @@ class SyncBufferedCountersCommand extends Command
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dirtyIds = array_unique(array_filter(array_map('intval', $dirtyIds)));
|
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
$updatesToMake = [];
|
$updatesToMake = [];
|
||||||
|
|
||||||
foreach ($dirtyIds as $id) {
|
foreach ($dirtyIds as $id) {
|
||||||
$totalKey = "{$prefix}total:{$id}";
|
$deltas = $buffer->pullDeltas((int) $id);
|
||||||
$uniqueKey = "{$prefix}unique:{$id}";
|
|
||||||
$qrKey = "{$prefix}qr:{$id}";
|
|
||||||
|
|
||||||
$totalDelta = (int) Cache::pull($totalKey, 0);
|
if ($deltas['total'] > 0 || $deltas['unique'] > 0 || $deltas['qr'] > 0) {
|
||||||
$uniqueDelta = (int) Cache::pull($uniqueKey, 0);
|
$updatesToMake[$id] = $deltas;
|
||||||
$qrDelta = (int) Cache::pull($qrKey, 0);
|
|
||||||
|
|
||||||
if ($totalDelta > 0 || $uniqueDelta > 0 || $qrDelta > 0) {
|
|
||||||
$updatesToMake[$id] = [
|
|
||||||
'total' => $totalDelta,
|
|
||||||
'unique' => $uniqueDelta,
|
|
||||||
'qr' => $qrDelta,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +51,6 @@ class SyncBufferedCountersCommand extends Command
|
|||||||
->with('customDomain')
|
->with('customDomain')
|
||||||
->get(['id', 'url_key', 'custom_domain_id']);
|
->get(['id', 'url_key', 'custom_domain_id']);
|
||||||
|
|
||||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
|
||||||
|
|
||||||
foreach ($updatesToMake as $id => $deltas) {
|
foreach ($updatesToMake as $id => $deltas) {
|
||||||
ShortUrl::where('id', $id)->update([
|
ShortUrl::where('id', $id)->update([
|
||||||
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
|
'total_visits' => DB::raw("total_visits + {$deltas['total']}"),
|
||||||
@@ -98,59 +60,26 @@ class SyncBufferedCountersCommand extends Command
|
|||||||
$processed++;
|
$processed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bust redirect cache for all host-variant keys so subsequent requests
|
|
||||||
// see fresh counters (for max_visits enforcement etc.)
|
|
||||||
foreach ($shortUrls as $url) {
|
foreach ($shortUrls as $url) {
|
||||||
$hostsToForget = array_unique(array_filter([
|
ShortUrlCacheInvalidator::forget($url);
|
||||||
'default',
|
|
||||||
$appHost,
|
|
||||||
$url->customDomain?->domain,
|
|
||||||
]));
|
|
||||||
|
|
||||||
foreach ($hostsToForget as $host) {
|
|
||||||
Cache::forget("filament-short-url:{$url->url_key}:{$host}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// DB transaction failed — restore pulled cache values so no clicks are lost.
|
$buffer->restoreDeltasAfterFailedSync($updatesToMake);
|
||||||
// Increments are used (not set) to safely merge with any new clicks that
|
$buffer->restoreDirtyIds(
|
||||||
// arrived during the failed transaction window.
|
array_keys($updatesToMake),
|
||||||
foreach ($updatesToMake as $id => $deltas) {
|
$pull['connection'],
|
||||||
$totalKey = "{$prefix}total:{$id}";
|
$pull['prefixedDirtyKey'],
|
||||||
$uniqueKey = "{$prefix}unique:{$id}";
|
$pull['requeueKey'],
|
||||||
$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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($pull['requeue'] && $pull['requeueKey'] && $pull['connection']) {
|
||||||
|
$pull['connection']->del($pull['requeueKey']);
|
||||||
|
}
|
||||||
|
|
||||||
$this->info("Successfully synchronized counters for {$processed} short URLs.");
|
$this->info("Successfully synchronized counters for {$processed} short URLs.");
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
55
src/Console/Commands/VerifyCustomDomainsCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/Filament/Forms/Components/NumberStepper.php
Normal 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()]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/Filament/Forms/Components/SegmentControl.php
Normal 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,10 @@ class ShortUrlCustomDomainResource extends Resource
|
|||||||
->modifyQueryUsing(fn ($query) => $query
|
->modifyQueryUsing(fn ($query) => $query
|
||||||
->withCount('shortUrls')
|
->withCount('shortUrls')
|
||||||
->withSum('shortUrls as total_clicks', 'total_visits')
|
->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([])
|
->filters([])
|
||||||
->actions([
|
->actions([
|
||||||
@@ -153,6 +157,19 @@ class ShortUrlCustomDomainResource extends Resource
|
|||||||
->label(__('filament-short-url::default.action_delete_domain'))
|
->label(__('filament-short-url::default.action_delete_domain'))
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->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'))
|
->modalHeading(__('filament-short-url::default.action_delete_domain'))
|
||||||
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
|
->modalDescription(__('filament-short-url::default.delete_domain_confirmation_desc'))
|
||||||
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
|
->modalSubmitActionLabel(__('filament-short-url::default.action_delete_domain'))
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class ListShortUrlCustomDomains extends ManageRecords
|
|||||||
->modalWidth('md')
|
->modalWidth('md')
|
||||||
->modalAutofocus(false)
|
->modalAutofocus(false)
|
||||||
->closeModalByClickingAway(false)
|
->closeModalByClickingAway(false)
|
||||||
|
->mutateFormDataUsing(function (array $data): array {
|
||||||
|
if (auth()->check()) {
|
||||||
|
$data['user_id'] = auth()->id();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
})
|
||||||
->after(function ($livewire, $record) {
|
->after(function ($livewire, $record) {
|
||||||
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
|
$livewire->dispatch('mount-dns-setup-modal', recordId: $record->id);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -58,38 +58,54 @@ class ShortUrlPixelResource extends Resource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, TextInput|Select|Toggle>
|
||||||
|
*/
|
||||||
|
public static function formComponents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
TextInput::make('name')
|
||||||
|
->label(__('filament-short-url::default.pixel_name'))
|
||||||
|
->required()
|
||||||
|
->maxLength(150)
|
||||||
|
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
||||||
|
|
||||||
|
Select::make('type')
|
||||||
|
->label(__('filament-short-url::default.pixel_type'))
|
||||||
|
->options(self::typeOptions())
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
|
||||||
|
TextInput::make('pixel_id')
|
||||||
|
->label(__('filament-short-url::default.pixel_id_label'))
|
||||||
|
->required()
|
||||||
|
->maxLength(100)
|
||||||
|
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
||||||
|
|
||||||
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components(static::formComponents())
|
||||||
TextInput::make('name')
|
|
||||||
->label(__('filament-short-url::default.pixel_name'))
|
|
||||||
->required()
|
|
||||||
->maxLength(150)
|
|
||||||
->placeholder('e.g. Meta Ads - Yacht Promo'),
|
|
||||||
|
|
||||||
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',
|
|
||||||
])
|
|
||||||
->required()
|
|
||||||
->native(false),
|
|
||||||
|
|
||||||
TextInput::make('pixel_id')
|
|
||||||
->label(__('filament-short-url::default.pixel_id_label'))
|
|
||||||
->required()
|
|
||||||
->maxLength(100)
|
|
||||||
->placeholder('e.g. 1234567890 or G-XXXXXXXXXX'),
|
|
||||||
|
|
||||||
Toggle::make('is_active')
|
|
||||||
->label(__('filament-short-url::default.pixel_status_active'))
|
|
||||||
->default(true),
|
|
||||||
])
|
|
||||||
->columns(1);
|
->columns(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\ShortU
|
|||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables\ShortUrlsTable;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables\ShortUrlsTable;
|
||||||
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
use Bjanczak\FilamentShortUrl\FilamentShortUrlPlugin;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
|
use Bjanczak\FilamentShortUrl\Support\LinkUserScope;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ShortUrlResource extends Resource
|
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
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return ShortUrlForm::configure($schema);
|
return ShortUrlForm::configure($schema);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages;
|
||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
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\Filament\Resources\ShortUrlResource\Widgets\ShortUrlGlobalOverview;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||||
@@ -11,6 +12,9 @@ use Filament\Actions\CreateAction;
|
|||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Resources\Pages\ManageRecords;
|
use Filament\Resources\Pages\ManageRecords;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Contracts\Pagination\Paginator;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\AbstractPaginator;
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class ListShortUrls extends ManageRecords
|
class ListShortUrls extends ManageRecords
|
||||||
@@ -42,7 +46,7 @@ class ListShortUrls extends ManageRecords
|
|||||||
$data['url_key'] = $service->generateKey();
|
$data['url_key'] = $service->generateKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return PasswordOpenGraphGuard::sanitizeSaveData($data);
|
||||||
})
|
})
|
||||||
->after(function (ShortUrl $record): void {
|
->after(function (ShortUrl $record): void {
|
||||||
$id = json_encode($record->id);
|
$id = json_encode($record->id);
|
||||||
@@ -142,7 +146,7 @@ class ListShortUrls extends ManageRecords
|
|||||||
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
$logoHideBackground = $qrDefaults['logo_hide_background'] ?? true;
|
||||||
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
$logoShape = $qrDefaults['logo_shape'] ?? 'square';
|
||||||
|
|
||||||
$qrOptionsJson = json_encode([
|
$qrOptions = [
|
||||||
'type' => 'svg',
|
'type' => 'svg',
|
||||||
'width' => 200,
|
'width' => 200,
|
||||||
'height' => 200,
|
'height' => 200,
|
||||||
@@ -160,9 +164,7 @@ class ListShortUrls extends ManageRecords
|
|||||||
'logoShape' => $logoShape,
|
'logoShape' => $logoShape,
|
||||||
],
|
],
|
||||||
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
'qrOptions' => ['errorCorrectionLevel' => $logo ? 'H' : 'M'],
|
||||||
]);
|
];
|
||||||
|
|
||||||
$escapedQrOptions = e($qrOptionsJson);
|
|
||||||
|
|
||||||
$viewData = compact(
|
$viewData = compact(
|
||||||
'shortUrl',
|
'shortUrl',
|
||||||
@@ -170,7 +172,7 @@ class ListShortUrls extends ManageRecords
|
|||||||
'destHost',
|
'destHost',
|
||||||
'urlKey',
|
'urlKey',
|
||||||
'eid',
|
'eid',
|
||||||
'escapedQrOptions',
|
'qrOptions',
|
||||||
'successTitle',
|
'successTitle',
|
||||||
'successSubtitle',
|
'successSubtitle',
|
||||||
'successHelper',
|
'successHelper',
|
||||||
@@ -208,4 +210,17 @@ class ListShortUrls extends ManageRecords
|
|||||||
->modifyQueryUsing(fn ($query) => $query->where('is_archived', true)),
|
->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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
||||||
|
|
||||||
|
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\NumberStepper;
|
||||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
@@ -17,6 +19,7 @@ use Filament\Notifications\Notification;
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class AdvancedTab
|
class AdvancedTab
|
||||||
{
|
{
|
||||||
@@ -57,6 +60,10 @@ class AdvancedTab
|
|||||||
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
Section::make(__('filament-short-url::default.settings_section_rate_limiting'))
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->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')
|
Toggle::make('rate_limiting_enabled')
|
||||||
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
->label(__('filament-short-url::default.settings_rate_limiting_enabled'))
|
||||||
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
->helperText(__('filament-short-url::default.settings_rate_limiting_enabled_helper'))
|
||||||
@@ -64,22 +71,24 @@ class AdvancedTab
|
|||||||
->live()
|
->live()
|
||||||
->inline(false),
|
->inline(false),
|
||||||
|
|
||||||
TextInput::make('rate_limiting_max_attempts')
|
NumberStepper::make('rate_limiting_max_attempts')
|
||||||
->label(__('filament-short-url::default.settings_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'))
|
->helperText(__('filament-short-url::default.settings_rate_limiting_max_attempts_helper'))
|
||||||
->numeric()
|
|
||||||
->integer()
|
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
|
->maxValue(1000)
|
||||||
|
->variant('secondary')
|
||||||
|
->size('sm')
|
||||||
->required()
|
->required()
|
||||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
->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'))
|
->label(__('filament-short-url::default.settings_rate_limiting_decay_seconds'))
|
||||||
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
->helperText(__('filament-short-url::default.settings_rate_limiting_decay_seconds_helper'))
|
||||||
->numeric()
|
|
||||||
->integer()
|
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
|
->maxValue(3600)
|
||||||
->suffix('s')
|
->suffix('s')
|
||||||
|
->variant('secondary')
|
||||||
|
->size('sm')
|
||||||
->required()
|
->required()
|
||||||
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')),
|
||||||
]),
|
]),
|
||||||
@@ -125,6 +134,36 @@ class AdvancedTab
|
|||||||
])
|
])
|
||||||
->default('flag_only')
|
->default('flag_only')
|
||||||
->required()
|
->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')),
|
->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')),
|
||||||
|
|
||||||
Toggle::make('safe_browsing_enabled')
|
Toggle::make('safe_browsing_enabled')
|
||||||
@@ -174,6 +213,46 @@ class AdvancedTab
|
|||||||
)
|
)
|
||||||
->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')),
|
->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(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,12 @@ class DeveloperTab
|
|||||||
Toggle::make('is_active')
|
Toggle::make('is_active')
|
||||||
->label(__('filament-short-url::default.active'))
|
->label(__('filament-short-url::default.active'))
|
||||||
->default(true),
|
->default(true),
|
||||||
|
TextInput::make('owner_user_id')
|
||||||
|
->label(__('filament-short-url::default.api_key_owner_user_id'))
|
||||||
|
->numeric()
|
||||||
|
->nullable(),
|
||||||
])
|
])
|
||||||
->columns(5)
|
->columns(6)
|
||||||
->default([]),
|
->default([]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -104,11 +108,12 @@ class DeveloperTab
|
|||||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||||
|
|
||||||
TextInput::make('webhook_signing_secret')
|
TextInput::make('webhook_signing_secret')
|
||||||
->label('Webhook Signing Secret')
|
->label(__('filament-short-url::default.webhook_signing_secret'))
|
||||||
->helperText('If configured, outgoing webhook requests will include the HMAC signature in X-ShortUrl-Signature.')
|
->helperText(__('filament-short-url::default.webhook_signing_secret_helper'))
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->placeholder('••••••••••••••••••••')
|
->placeholder('••••••••••••••••••••')
|
||||||
|
->required(fn (Get $get): bool => (bool) $get('global_webhook_enabled'))
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
->visible(fn (Get $get): bool => (bool) $get('global_webhook_enabled')),
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
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\Actions\Action;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@@ -15,7 +17,6 @@ use Filament\Schemas\Components\Actions;
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
class Ga4Tab
|
class Ga4Tab
|
||||||
{
|
{
|
||||||
@@ -43,6 +44,12 @@ class Ga4Tab
|
|||||||
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
->helperText(__('filament-short-url::default.settings_ga4_firebase_app_id_helper'))
|
||||||
->placeholder('1:1234567890:android:abcdef123456'),
|
->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([
|
Actions::make([
|
||||||
Action::make('verifyGa4ApiSecret')
|
Action::make('verifyGa4ApiSecret')
|
||||||
->label(__('filament-short-url::default.settings_ga4_verify'))
|
->label(__('filament-short-url::default.settings_ga4_verify'))
|
||||||
@@ -51,7 +58,11 @@ class Ga4Tab
|
|||||||
->action(function (Get $get): void {
|
->action(function (Get $get): void {
|
||||||
$secret = trim($get('ga4_api_secret') ?? '');
|
$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()
|
Notification::make()
|
||||||
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
->title(__('filament-short-url::default.settings_ga4_verify_empty'))
|
||||||
->warning()
|
->warning()
|
||||||
@@ -60,45 +71,43 @@ class Ga4Tab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$measurementId = strtoupper(trim($get('ga4_verify_measurement_id') ?? ''));
|
||||||
$response = Http::timeout(5)
|
$firebaseAppId = trim($get('ga4_firebase_app_id') ?? '');
|
||||||
->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' => []],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$body = $response->json();
|
if ($firebaseAppId === '' && $measurementId === '') {
|
||||||
$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 {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('filament-short-url::default.settings_ga4_verify_error'))
|
->title(__('filament-short-url::default.settings_ga4_verify_measurement_required'))
|
||||||
->body($e->getMessage())
|
->warning()
|
||||||
->danger()
|
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$result = app(Ga4MeasurementProtocolService::class)->validateCredentials(
|
||||||
|
$measurementId,
|
||||||
|
$secret,
|
||||||
|
$firebaseAppId !== '' ? $firebaseAppId : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['valid']) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('filament-short-url::default.settings_ga4_verify_ok'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstMessage = collect($result['messages'])
|
||||||
|
->pluck('description')
|
||||||
|
->filter()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('filament-short-url::default.settings_ga4_verify_fail'))
|
||||||
|
->body(is_string($firstMessage) ? $firstMessage : null)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -8,10 +8,16 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages\Settings\Tabs;
|
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\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Schemas\Components\Actions;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
@@ -19,6 +25,72 @@ use Illuminate\Support\HtmlString;
|
|||||||
|
|
||||||
class GeneralTab
|
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.
|
* Build the general settings form tab.
|
||||||
*/
|
*/
|
||||||
@@ -46,13 +118,15 @@ class GeneralTab
|
|||||||
->alphaDash()
|
->alphaDash()
|
||||||
->maxLength(20),
|
->maxLength(20),
|
||||||
|
|
||||||
Select::make('redirect_status_code')
|
SegmentControl::make('redirect_status_code')
|
||||||
->label(__('filament-short-url::default.redirect_code'))
|
->label(__('filament-short-url::default.redirect_code'))
|
||||||
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
->helperText(__('filament-short-url::default.settings_redirect_code_helper'))
|
||||||
->options([
|
->options([
|
||||||
302 => __('filament-short-url::default.redirect_code_302'),
|
302 => __('filament-short-url::default.redirect_code_302'),
|
||||||
301 => __('filament-short-url::default.redirect_code_301'),
|
301 => __('filament-short-url::default.redirect_code_301'),
|
||||||
])
|
])
|
||||||
|
->size('sm')
|
||||||
|
->separators(false)
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
Toggle::make('lock_url_key')
|
Toggle::make('lock_url_key')
|
||||||
@@ -90,7 +164,14 @@ class GeneralTab
|
|||||||
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
->helperText(__('filament-short-url::default.settings_trust_cdn_headers_helper'))
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->inline(false)
|
->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')
|
Placeholder::make('trust_cdn_headers_info')
|
||||||
->content(function () {
|
->content(function () {
|
||||||
@@ -98,7 +179,8 @@ class GeneralTab
|
|||||||
|
|
||||||
return new HtmlString($html);
|
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(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -125,16 +207,111 @@ class GeneralTab
|
|||||||
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
->helperText(__('filament-short-url::default.settings_queue_name_helper'))
|
||||||
->default('default')
|
->default('default')
|
||||||
->required(fn (Get $get): bool => $get('queue_connection') !== 'sync')
|
->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')
|
->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(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -142,7 +319,11 @@ class GeneralTab
|
|||||||
->schema([
|
->schema([
|
||||||
Toggle::make('counter_buffering_enabled')
|
Toggle::make('counter_buffering_enabled')
|
||||||
->label(__('filament-short-url::default.settings_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)
|
->inline(false)
|
||||||
->live(),
|
->live(),
|
||||||
|
|
||||||
@@ -152,7 +333,7 @@ class GeneralTab
|
|||||||
|
|
||||||
return new HtmlString($html);
|
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(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -53,15 +53,14 @@ class GeoIpTab
|
|||||||
->live()
|
->live()
|
||||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled')),
|
||||||
|
|
||||||
Placeholder::make('geoip_headers_warning')
|
Placeholder::make('geoip_headers_info')
|
||||||
->content(function () {
|
->content(function () {
|
||||||
$html = __('filament-short-url::default.settings_geoip_headers_warning');
|
$html = __('filament-short-url::default.settings_geoip_headers_info');
|
||||||
|
|
||||||
return new HtmlString($html);
|
return new HtmlString($html);
|
||||||
})
|
})
|
||||||
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
->visible(fn (Get $get): bool => (bool) $get('geo_ip_enabled') &&
|
||||||
$get('geo_ip_driver') === 'headers' &&
|
$get('geo_ip_driver') === 'headers'
|
||||||
! (bool) $get('trust_cdn_headers')
|
|
||||||
)
|
)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ class ShortUrlSettingsPage extends Page implements HasForms
|
|||||||
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
|
'geo_ip_stats_cache_ttl' => $mgr->get('geo_ip_stats_cache_ttl', 300),
|
||||||
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
'queue_connection' => $mgr->get('queue_connection', 'sync'),
|
||||||
'queue_name' => $mgr->get('queue_name', 'default'),
|
'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_api_secret' => $mgr->get('ga4_api_secret'),
|
||||||
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
'ga4_firebase_app_id' => $mgr->get('ga4_firebase_app_id'),
|
||||||
'counter_buffering_enabled' => $mgr->get('counter_buffering_enabled', false),
|
'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_referer_url' => $mgr->get('tracking_fields_referer_url', true),
|
||||||
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
|
'tracking_fields_device_type' => $mgr->get('tracking_fields_device_type', true),
|
||||||
'tracking_fields_browser_language' => $mgr->get('tracking_fields_browser_language', 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_dot_style' => $mgr->get('qr_dot_style', 'square'),
|
||||||
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
|
'qr_foreground_color' => $mgr->get('qr_foreground_color', '#000000'),
|
||||||
'qr_background_color' => $mgr->get('qr_background_color', '#ffffff'),
|
'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'),
|
'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'),
|
||||||
'vpnapi_key' => $mgr->get('vpnapi_key'),
|
'vpnapi_key' => $mgr->get('vpnapi_key'),
|
||||||
'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'),
|
'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),
|
'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false),
|
||||||
'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'),
|
'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 v2.1
|
||||||
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
'deep_linking_enabled' => $mgr->get('deep_linking_enabled', false),
|
||||||
'aasa_json' => $aasa,
|
'aasa_json' => $aasa,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Filament\Schemas\Components\Grid;
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Enums\PaginationMode;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
||||||
@@ -39,7 +40,12 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $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([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('visited_at')
|
Tables\Columns\TextColumn::make('visited_at')
|
||||||
->label(__('filament-short-url::default.stats_col_time'))
|
->label(__('filament-short-url::default.stats_col_time'))
|
||||||
@@ -193,7 +199,18 @@ class ViewShortUrlLogs extends Page implements HasForms, HasTable
|
|||||||
])
|
])
|
||||||
->defaultSort('visited_at', 'desc')
|
->defaultSort('visited_at', 'desc')
|
||||||
->paginated([10, 25, 50, 100])
|
->paginated([10, 25, 50, 100])
|
||||||
|
->paginationMode(PaginationMode::Cursor)
|
||||||
|
->queryStringIdentifier('shortUrlVisitLogs')
|
||||||
->filters([
|
->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')
|
Tables\Filters\SelectFilter::make('device_type')
|
||||||
->label(__('filament-short-url::default.stats_col_device'))
|
->label(__('filament-short-url::default.stats_col_device'))
|
||||||
->options([
|
->options([
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ use Filament\Forms\Contracts\HasForms;
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\Page;
|
use Filament\Resources\Pages\Page;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
class ViewShortUrlStats extends Page implements HasForms
|
class ViewShortUrlStats extends Page implements HasForms
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
|
|
||||||
protected static string $resource = ShortUrlResource::class;
|
protected static string $resource = ShortUrlResource::class;
|
||||||
@@ -78,6 +80,8 @@ class ViewShortUrlStats extends Page implements HasForms
|
|||||||
|
|
||||||
public function mount(ShortUrl $record): void
|
public function mount(ShortUrl $record): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('view', $record);
|
||||||
|
|
||||||
$this->record = $record;
|
$this->record = $record;
|
||||||
|
|
||||||
$this->form->fill([
|
$this->form->fill([
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Fields;
|
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Fields;
|
||||||
|
|
||||||
|
use Bjanczak\FilamentShortUrl\Filament\Forms\Components\SegmentControl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||||
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
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\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Support\RawJs;
|
use Filament\Support\RawJs;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
@@ -151,8 +151,8 @@ class QrDesignerSidebarField
|
|||||||
'bg_transparent' => $parentOpts['bg_transparent'] ?? false,
|
'bg_transparent' => $parentOpts['bg_transparent'] ?? false,
|
||||||
'background_color' => $parentOpts['background_color'] ?? $defaults['background_color'] ?? '#ffffff',
|
'background_color' => $parentOpts['background_color'] ?? $defaults['background_color'] ?? '#ffffff',
|
||||||
'eye_config_enabled' => $parentOpts['eye_config_enabled'] ?? false,
|
'eye_config_enabled' => $parentOpts['eye_config_enabled'] ?? false,
|
||||||
'eye_square_style' => $parentOpts['eye_square_style'] ?? 'square',
|
'eye_square_style' => filled($parentOpts['eye_square_style'] ?? null) ? $parentOpts['eye_square_style'] : 'square',
|
||||||
'eye_dot_style' => $parentOpts['eye_dot_style'] ?? 'square',
|
'eye_dot_style' => filled($parentOpts['eye_dot_style'] ?? null) ? $parentOpts['eye_dot_style'] : 'square',
|
||||||
'eye_color' => $parentOpts['eye_color'] ?? '#000000',
|
'eye_color' => $parentOpts['eye_color'] ?? '#000000',
|
||||||
'logo_file' => $parentLogo ? [$parentLogo] : [],
|
'logo_file' => $parentLogo ? [$parentLogo] : [],
|
||||||
'logo_shape' => $parentOpts['logo_shape'] ?? 'square',
|
'logo_shape' => $parentOpts['logo_shape'] ?? 'square',
|
||||||
@@ -174,42 +174,25 @@ class QrDesignerSidebarField
|
|||||||
->collapsible()
|
->collapsible()
|
||||||
->compact()
|
->compact()
|
||||||
->schema([
|
->schema([
|
||||||
ToggleButtons::make('dot_style')
|
SegmentControl::make('dot_style')
|
||||||
->label(__('filament-short-url::default.qr_label_style'))
|
->label(__('filament-short-url::default.qr_label_style'))
|
||||||
->options([
|
->options(self::qrIconSegmentOptions([
|
||||||
'square' => __('filament-short-url::default.qr_option_square'),
|
['square', 'qr_option_square', 'fsu-qr-dots-square'],
|
||||||
'dots' => __('filament-short-url::default.qr_option_dots'),
|
['dots', 'qr_option_dots', 'fsu-qr-dots-dots'],
|
||||||
'rounded' => __('filament-short-url::default.qr_option_rounded'),
|
['rounded', 'qr_option_rounded', 'fsu-qr-dots-rounded'],
|
||||||
'classy' => __('filament-short-url::default.qr_option_classy'),
|
['classy', 'qr_option_classy', 'fsu-qr-dots-classy'],
|
||||||
'classy-rounded' => __('filament-short-url::default.qr_option_classy_rounded'),
|
['classy-rounded', 'qr_option_classy_rounded', 'fsu-qr-dots-classy-rounded'],
|
||||||
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
|
['extra-rounded', 'qr_option_extra_rounded', 'fsu-qr-dots-extra-rounded'],
|
||||||
])
|
]))
|
||||||
->icons([
|
->iconOnly()
|
||||||
'square' => 'fsu-qr-dots-square',
|
->size('lg')
|
||||||
'dots' => 'fsu-qr-dots-dots',
|
->separators(false)
|
||||||
'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()
|
|
||||||
->required()
|
->required()
|
||||||
->live(),
|
->live()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only qr-segment-icon-only--style']),
|
||||||
|
|
||||||
ToggleButtons::make('color_mode')
|
SegmentControl::make('color_mode')
|
||||||
->hiddenLabel()
|
->label(__('filament-short-url::default.qr_label_color'))
|
||||||
->options([
|
->options([
|
||||||
'solid' => __('filament-short-url::default.qr_label_single_color'),
|
'solid' => __('filament-short-url::default.qr_label_single_color'),
|
||||||
'gradient' => __('filament-short-url::default.qr_label_gradient'),
|
'gradient' => __('filament-short-url::default.qr_label_gradient'),
|
||||||
@@ -218,7 +201,7 @@ class QrDesignerSidebarField
|
|||||||
'solid' => 'heroicon-o-paint-brush',
|
'solid' => 'heroicon-o-paint-brush',
|
||||||
'gradient' => 'heroicon-o-sparkles',
|
'gradient' => 'heroicon-o-sparkles',
|
||||||
])
|
])
|
||||||
->grouped()
|
->fullWidth()
|
||||||
->required()
|
->required()
|
||||||
->live(),
|
->live(),
|
||||||
|
|
||||||
@@ -298,57 +281,33 @@ class QrDesignerSidebarField
|
|||||||
->label(__('filament-short-url::default.qr_label_custom_eye_config'))
|
->label(__('filament-short-url::default.qr_label_custom_eye_config'))
|
||||||
->live(),
|
->live(),
|
||||||
|
|
||||||
ToggleButtons::make('eye_square_style')
|
SegmentControl::make('eye_square_style')
|
||||||
->label(__('filament-short-url::default.qr_label_eye_square_style'))
|
->label(__('filament-short-url::default.qr_label_eye_square_style'))
|
||||||
->options([
|
->options(self::qrIconSegmentOptions([
|
||||||
'' => __('filament-short-url::default.qr_option_none'),
|
['square', 'qr_option_square', 'fsu-qr-eye-square-square'],
|
||||||
'square' => __('filament-short-url::default.qr_option_square'),
|
['dot', 'qr_option_dot', 'fsu-qr-eye-square-dot'],
|
||||||
'dot' => __('filament-short-url::default.qr_option_dot'),
|
['extra-rounded', 'qr_option_extra_rounded', 'fsu-qr-eye-square-extra-rounded'],
|
||||||
'extra-rounded' => __('filament-short-url::default.qr_option_extra_rounded'),
|
]))
|
||||||
])
|
->default('square')
|
||||||
->icons([
|
->iconOnly()
|
||||||
'' => 'fsu-qr-eye-square-none',
|
->size('lg')
|
||||||
'square' => 'fsu-qr-eye-square-square',
|
->separators(false)
|
||||||
'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()
|
|
||||||
->live()
|
->live()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only'])
|
||||||
->visible(fn (Get $get) => $get('eye_config_enabled')),
|
->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'))
|
->label(__('filament-short-url::default.qr_label_eye_dot_style'))
|
||||||
->options([
|
->options(self::qrIconSegmentOptions([
|
||||||
'' => __('filament-short-url::default.qr_option_none'),
|
['square', 'qr_option_square', 'fsu-qr-eye-dot-square'],
|
||||||
'square' => __('filament-short-url::default.qr_option_square'),
|
['dot', 'qr_option_dot', 'fsu-qr-eye-dot-dot'],
|
||||||
'dot' => __('filament-short-url::default.qr_option_dot'),
|
]))
|
||||||
])
|
->default('square')
|
||||||
->icons([
|
->iconOnly()
|
||||||
'' => 'fsu-qr-eye-dot-none',
|
->size('lg')
|
||||||
'square' => 'fsu-qr-eye-dot-square',
|
->separators(false)
|
||||||
'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()
|
|
||||||
->live()
|
->live()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'qr-segment-icon-only'])
|
||||||
->visible(fn (Get $get) => $get('eye_config_enabled')),
|
->visible(fn (Get $get) => $get('eye_config_enabled')),
|
||||||
|
|
||||||
Grid::make(12)
|
Grid::make(12)
|
||||||
@@ -471,8 +430,8 @@ class QrDesignerSidebarField
|
|||||||
'bg_transparent' => (bool) ($data['bg_transparent'] ?? false),
|
'bg_transparent' => (bool) ($data['bg_transparent'] ?? false),
|
||||||
'background_color' => $data['background_color'] ?? '#ffffff',
|
'background_color' => $data['background_color'] ?? '#ffffff',
|
||||||
'eye_config_enabled' => (bool) ($data['eye_config_enabled'] ?? false),
|
'eye_config_enabled' => (bool) ($data['eye_config_enabled'] ?? false),
|
||||||
'eye_square_style' => $data['eye_square_style'] ?? 'square',
|
'eye_square_style' => filled($data['eye_square_style'] ?? null) ? $data['eye_square_style'] : 'square',
|
||||||
'eye_dot_style' => $data['eye_dot_style'] ?? 'square',
|
'eye_dot_style' => filled($data['eye_dot_style'] ?? null) ? $data['eye_dot_style'] : 'square',
|
||||||
'eye_color' => $data['eye_color'] ?? '#000000',
|
'eye_color' => $data['eye_color'] ?? '#000000',
|
||||||
'logo_shape' => $data['logo_shape'] ?? 'square',
|
'logo_shape' => $data['logo_shape'] ?? 'square',
|
||||||
'logo_size' => (float) ($data['logo_size'] ?? 0.55),
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\TrackingTab;
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\ValidityAndLimitsTab;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs\ValidityAndLimitsTab;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlFolder;
|
||||||
|
use Bjanczak\FilamentShortUrl\Services\OgFormImageResolver;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@@ -71,7 +72,7 @@ class ShortUrlForm
|
|||||||
->extraFieldWrapperAttributes([
|
->extraFieldWrapperAttributes([
|
||||||
'class' => 'create-link-sidebar-select-wrapper',
|
'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')
|
->relationship('folder', 'name')
|
||||||
->allowHtml()
|
->allowHtml()
|
||||||
->getOptionLabelFromRecordUsing(fn ($record) => $record->getOptionHtml())
|
->getOptionLabelFromRecordUsing(fn ($record) => $record->getOptionHtml())
|
||||||
@@ -119,9 +120,45 @@ class ShortUrlForm
|
|||||||
|
|
||||||
ViewField::make('sidebar_social_preview')
|
ViewField::make('sidebar_social_preview')
|
||||||
->view('filament-short-url::sidebar.social-preview')
|
->view('filament-short-url::sidebar.social-preview')
|
||||||
->viewData(fn (?Get $get = null) => [
|
->viewData(function (ViewField $component, ?Get $get = null) {
|
||||||
'isPasswordProtected' => $get ? (bool) $get('password_active_flag') : false,
|
$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)
|
->dehydrated(false)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\Forms\Components\TrafficSplitter;
|
||||||
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||||
@@ -17,20 +19,26 @@ use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
|||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Repeater;
|
use Filament\Forms\Components\Repeater;
|
||||||
use Filament\Forms\Components\Repeater\TableColumn;
|
use Filament\Forms\Components\Repeater\TableColumn;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Components\ToggleButtons;
|
|
||||||
use Filament\Schemas\Components\FusedGroup;
|
use Filament\Schemas\Components\FusedGroup;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
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\HtmlString;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Unique;
|
||||||
|
|
||||||
class LinkTab
|
class LinkTab
|
||||||
{
|
{
|
||||||
@@ -42,275 +50,135 @@ class LinkTab
|
|||||||
return Tab::make(__('filament-short-url::default.tab_link'))
|
return Tab::make(__('filament-short-url::default.tab_link'))
|
||||||
->icon('heroicon-o-link')
|
->icon('heroicon-o-link')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make()->schema([
|
Section::make()
|
||||||
ToggleButtons::make('destination_type')
|
->contained(false)
|
||||||
->label(__('filament-short-url::default.destination_type'))
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->options([
|
->schema([
|
||||||
'single' => __('filament-short-url::default.destination_type_single'),
|
Placeholder::make('link_destination_card_header')
|
||||||
'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()
|
|
||||||
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
|
||||||
if ($state === 'split' && empty($get('rotation_variants'))) {
|
|
||||||
$set('rotation_variants', [
|
|
||||||
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
|
||||||
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
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'))
|
|
||||||
->url()
|
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.destination_url_helper'))
|
|
||||||
->hint(function (Get $get) {
|
|
||||||
$isScraping = $get('is_scraping') ? 'true' : 'false';
|
|
||||||
$label = e(__('filament-short-url::default.fetching_metadata'));
|
|
||||||
|
|
||||||
return new HtmlString(
|
|
||||||
'<span'
|
|
||||||
.' x-data="{ scraping: '.$isScraping.' }"'
|
|
||||||
.' x-on:fsu-scraping-start.window="scraping = true"'
|
|
||||||
.' x-on:fsu-scraping-end.window="scraping = false"'
|
|
||||||
.' x-show="scraping"'
|
|
||||||
.' x-cloak'
|
|
||||||
.' class="flex items-center gap-x-1.5 text-xs text-primary-600 dark:text-primary-400 font-semibold"'
|
|
||||||
.'>'
|
|
||||||
.'<svg class="animate-spin h-3.5 w-3.5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24">'
|
|
||||||
.'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>'
|
|
||||||
.'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>'
|
|
||||||
.'</svg>'
|
|
||||||
.$label
|
|
||||||
.'</span>'
|
|
||||||
);
|
|
||||||
})
|
|
||||||
->placeholder('https://example.com/site-url')
|
|
||||||
->maxLength(2048)
|
|
||||||
->rules([
|
|
||||||
app(SafeUrl::class),
|
|
||||||
])
|
|
||||||
->live(debounce: 500)
|
|
||||||
->afterStateUpdatedJs(<<<'JS'
|
|
||||||
window.fsuInitScrape($get, $el.querySelector('input'));
|
|
||||||
if ($state) {
|
|
||||||
window.fsuScrape($state, $get, $set, $el.querySelector('input'));
|
|
||||||
}
|
|
||||||
JS)
|
|
||||||
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
|
||||||
if (! $state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$parts = parse_url($state);
|
|
||||||
if (isset($parts['query'])) {
|
|
||||||
parse_str($parts['query'], $query);
|
|
||||||
$set('utm_source', $query['utm_source'] ?? null);
|
|
||||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
|
||||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
|
||||||
$set('utm_term', $query['utm_term'] ?? null);
|
|
||||||
$set('utm_content', $query['utm_content'] ?? null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
|
||||||
if (! $state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$parts = parse_url($state);
|
|
||||||
if (isset($parts['query'])) {
|
|
||||||
parse_str($parts['query'], $query);
|
|
||||||
$set('utm_source', $query['utm_source'] ?? null);
|
|
||||||
$set('utm_medium', $query['utm_medium'] ?? null);
|
|
||||||
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
|
||||||
$set('utm_term', $query['utm_term'] ?? null);
|
|
||||||
$set('utm_content', $query['utm_content'] ?? null);
|
|
||||||
} else {
|
|
||||||
$set('utm_source', null);
|
|
||||||
$set('utm_medium', null);
|
|
||||||
$set('utm_campaign', null);
|
|
||||||
$set('utm_term', null);
|
|
||||||
$set('utm_content', null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
Repeater::make('rotation_variants')
|
|
||||||
->hiddenLabel()
|
|
||||||
->extraAttributes(['class' => 'ab-test-repeater'])
|
|
||||||
->table([
|
|
||||||
TableColumn::make(__('filament-short-url::default.variant_label'))
|
|
||||||
->width('30%'),
|
|
||||||
TableColumn::make(__('filament-short-url::default.variant_url'))
|
|
||||||
->width('70%'),
|
|
||||||
])
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->hiddenLabel()
|
|
||||||
->placeholder('e.g. Variant A')
|
|
||||||
->required()
|
|
||||||
->maxLength(100),
|
|
||||||
TextInput::make('url')
|
|
||||||
->hiddenLabel()
|
|
||||||
->url()
|
|
||||||
->required()
|
|
||||||
->maxLength(2048)
|
|
||||||
->rules([
|
|
||||||
app(SafeUrl::class),
|
|
||||||
]),
|
|
||||||
Hidden::make('weight'),
|
|
||||||
])
|
|
||||||
->defaultItems(2)
|
|
||||||
->minItems(2)
|
|
||||||
->maxItems(5)
|
|
||||||
->reorderable(false)
|
|
||||||
->live()
|
|
||||||
->rules([
|
|
||||||
function () {
|
|
||||||
return function (string $attribute, $value, \Closure $fail) {
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$sum = array_sum(array_column($value, 'weight'));
|
|
||||||
if ($sum !== 100) {
|
|
||||||
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->deleteAction(
|
|
||||||
fn ($action) => $action
|
|
||||||
->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))
|
|
||||||
)
|
|
||||||
->addActionLabel(__('filament-short-url::default.add_url'))
|
|
||||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
|
||||||
->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([
|
|
||||||
Select::make('custom_domain_id')
|
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->options(function () {
|
->content(TabCardHeader::make(
|
||||||
$domains = ShortUrlCustomDomain::where('is_active', true)
|
'heroicon-o-link',
|
||||||
->where('is_verified', true)
|
'validity-tab-card-icon--link',
|
||||||
->pluck('domain', 'id');
|
'link_destination_card_title',
|
||||||
|
'link_destination_card_subtitle',
|
||||||
|
compact: true,
|
||||||
|
)),
|
||||||
|
|
||||||
if (! config('filament-short-url.disable_default_domain', false)) {
|
SegmentControl::make('destination_type')
|
||||||
$defaultDomain = request()->getHost() ?: parse_url(config('app.url'), PHP_URL_HOST);
|
->label(__('filament-short-url::default.destination_type'))
|
||||||
$domains = collect(['default' => $defaultDomain])->union($domains);
|
->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()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
|
||||||
|
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
||||||
|
if ($state === 'split' && empty($get('rotation_variants'))) {
|
||||||
|
$set('rotation_variants', [
|
||||||
|
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
||||||
|
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
return $domains;
|
Group::make()
|
||||||
})
|
->extraAttributes(['class' => 'link-tab-panel'])
|
||||||
->default(function () {
|
->schema([
|
||||||
if (! config('filament-short-url.disable_default_domain', false)) {
|
self::destinationUrlField(),
|
||||||
return 'default';
|
self::rotationVariantsRepeater(),
|
||||||
}
|
TrafficSplitter::make('traffic_split')
|
||||||
|
->label(__('filament-short-url::default.traffic_split'))
|
||||||
$firstDomain = ShortUrlCustomDomain::where('is_active', true)
|
->target('rotation_variants')
|
||||||
->where('is_verified', true)
|
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||||
->first();
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
return $firstDomain ? $firstDomain->id : null;
|
]),
|
||||||
})
|
|
||||||
->afterStateHydrated(function (Select $component, $state) {
|
|
||||||
if ($state === null && ! config('filament-short-url.disable_default_domain', false)) {
|
|
||||||
$component->state('default');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->dehydrateStateUsing(fn ($state) => $state === 'default' ? null : $state)
|
|
||||||
->disabled(function (?ShortUrl $record) {
|
|
||||||
if ($record && $record->exists && config('filament-short-url.lock_url_key', false)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$domainsCount = ShortUrlCustomDomain::where('is_active', true)
|
|
||||||
->where('is_verified', true)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$defaultEnabled = ! config('filament-short-url.disable_default_domain', false);
|
|
||||||
$totalOptionsCount = $domainsCount + ($defaultEnabled ? 1 : 0);
|
|
||||||
|
|
||||||
return $totalOptionsCount <= 1;
|
|
||||||
})
|
|
||||||
->dehydrated()
|
|
||||||
->required(fn () => (bool) config('filament-short-url.disable_default_domain', false))
|
|
||||||
->selectablePlaceholder(false)
|
|
||||||
->nullable()
|
|
||||||
->native(false),
|
|
||||||
|
|
||||||
TextInput::make('url_key')
|
|
||||||
->hiddenLabel()
|
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.url_key_helper'))
|
|
||||||
->alphaDash()
|
|
||||||
->maxLength(32)
|
|
||||||
->default(fn (ShortUrlService $service) => $service->generateKey())
|
|
||||||
->unique('short_urls', 'url_key', ignoreRecord: true)
|
|
||||||
->disabled(fn (?ShortUrl $record) => $record && $record->exists && config('filament-short-url.lock_url_key', false))
|
|
||||||
->placeholder('auto-generated')
|
|
||||||
->suffixAction(
|
|
||||||
Action::make('regenerate')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->tooltip('Generate new key')
|
|
||||||
->action(function (Set $set, ShortUrlService $service): void {
|
|
||||||
$set('url_key', $service->generateKey());
|
|
||||||
})
|
|
||||||
->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false)))
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->extraAttributes(['class' => 'custom-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()
|
Section::make()
|
||||||
|
->contained(false)
|
||||||
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->schema([
|
->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')
|
Select::make('tags')
|
||||||
->label(__('filament-short-url::default.tags_navigation_label'))
|
->label(__('filament-short-url::default.tags_navigation_label'))
|
||||||
|
->hiddenLabel()
|
||||||
->multiple()
|
->multiple()
|
||||||
->maxItems(5)
|
->maxItems(5)
|
||||||
->relationship('tags', 'name')
|
->relationship('tags', 'name')
|
||||||
@@ -337,18 +205,30 @@ class LinkTab
|
|||||||
->required()
|
->required()
|
||||||
->native(false),
|
->native(false),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->extraFieldWrapperAttributes(['class' => 'link-tab-panel-field']),
|
||||||
])
|
]),
|
||||||
->contained(false)
|
|
||||||
->columns(1),
|
|
||||||
|
|
||||||
Section::make()
|
Section::make()
|
||||||
|
->contained(false)
|
||||||
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->schema([
|
->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')
|
Textarea::make('notes')
|
||||||
->label(__('filament-short-url::default.notes'))
|
->label(__('filament-short-url::default.notes'))
|
||||||
|
->hiddenLabel()
|
||||||
|
->placeholder(__('filament-short-url::default.link_notes_placeholder'))
|
||||||
->rows(3)
|
->rows(3)
|
||||||
->columnSpanFull(),
|
->extraFieldWrapperAttributes(['class' => 'link-tab-panel-field']),
|
||||||
])->contained(false),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,4 +267,275 @@ class LinkTab
|
|||||||
|
|
||||||
$set('destination_url', $scheme.$host.$port.$path.$queryString.$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'))
|
||||||
|
->url()
|
||||||
|
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.destination_url_helper'))
|
||||||
|
->hint(function (Get $get) {
|
||||||
|
$isScraping = $get('is_scraping') ? 'true' : 'false';
|
||||||
|
$label = e(__('filament-short-url::default.fetching_metadata'));
|
||||||
|
|
||||||
|
return new HtmlString(
|
||||||
|
'<span'
|
||||||
|
.' x-data="{ scraping: '.$isScraping.' }"'
|
||||||
|
.' x-on:fsu-scraping-start.window="scraping = true"'
|
||||||
|
.' x-on:fsu-scraping-end.window="scraping = false"'
|
||||||
|
.' x-show="scraping"'
|
||||||
|
.' x-cloak'
|
||||||
|
.' class="flex items-center gap-x-1.5 text-xs text-primary-600 dark:text-primary-400 font-semibold"'
|
||||||
|
.'>'
|
||||||
|
.'<svg class="animate-spin h-3.5 w-3.5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24">'
|
||||||
|
.'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>'
|
||||||
|
.'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>'
|
||||||
|
.'</svg>'
|
||||||
|
.$label
|
||||||
|
.'</span>'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->placeholder('https://example.com/site-url')
|
||||||
|
->maxLength(2048)
|
||||||
|
->rules([
|
||||||
|
app(SafeUrl::class),
|
||||||
|
])
|
||||||
|
->live(debounce: 500)
|
||||||
|
->extraInputAttributes(['data-fsu-destination-url' => 'true'])
|
||||||
|
->afterStateUpdatedJs(<<<'JS'
|
||||||
|
window.fsuInitScrape($get, $el.querySelector('input'));
|
||||||
|
|
||||||
|
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)
|
||||||
|
->afterStateHydrated(function (TextInput $component, $state, Set $set) {
|
||||||
|
if (! $state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$parts = parse_url($state);
|
||||||
|
if (isset($parts['query'])) {
|
||||||
|
parse_str($parts['query'], $query);
|
||||||
|
$set('utm_source', $query['utm_source'] ?? null);
|
||||||
|
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||||
|
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||||
|
$set('utm_term', $query['utm_term'] ?? null);
|
||||||
|
$set('utm_content', $query['utm_content'] ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
||||||
|
if (! $state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$parts = parse_url($state);
|
||||||
|
if (isset($parts['query'])) {
|
||||||
|
parse_str($parts['query'], $query);
|
||||||
|
$set('utm_source', $query['utm_source'] ?? null);
|
||||||
|
$set('utm_medium', $query['utm_medium'] ?? null);
|
||||||
|
$set('utm_campaign', $query['utm_campaign'] ?? null);
|
||||||
|
$set('utm_term', $query['utm_term'] ?? null);
|
||||||
|
$set('utm_content', $query['utm_content'] ?? null);
|
||||||
|
} else {
|
||||||
|
$set('utm_source', null);
|
||||||
|
$set('utm_medium', null);
|
||||||
|
$set('utm_campaign', null);
|
||||||
|
$set('utm_term', null);
|
||||||
|
$set('utm_content', null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->columnSpanFull();
|
||||||
|
}
|
||||||
|
|
||||||
|
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('36%'),
|
||||||
|
TableColumn::make(__('filament-short-url::default.variant_url'))
|
||||||
|
->width('64%'),
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
TextInput::make('label')
|
||||||
|
->hiddenLabel()
|
||||||
|
->placeholder('e.g. Variant A')
|
||||||
|
->required()
|
||||||
|
->maxLength(100),
|
||||||
|
TextInput::make('url')
|
||||||
|
->hiddenLabel()
|
||||||
|
->url()
|
||||||
|
->placeholder('https://example.com/landing-page')
|
||||||
|
->required()
|
||||||
|
->maxLength(2048)
|
||||||
|
->rules([
|
||||||
|
app(SafeUrl::class),
|
||||||
|
]),
|
||||||
|
Hidden::make('weight'),
|
||||||
|
])
|
||||||
|
->defaultItems(2)
|
||||||
|
->minItems(2)
|
||||||
|
->maxItems(5)
|
||||||
|
->reorderable(false)
|
||||||
|
->live()
|
||||||
|
->rules([
|
||||||
|
function () {
|
||||||
|
return function (string $attribute, $value, \Closure $fail) {
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$sum = array_sum(array_column($value, 'weight'));
|
||||||
|
if ($sum !== 100) {
|
||||||
|
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->deleteAction(
|
||||||
|
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) => $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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shortLinkFusedGroup(): FusedGroup
|
||||||
|
{
|
||||||
|
return FusedGroup::make([
|
||||||
|
Select::make('custom_domain_id')
|
||||||
|
->hiddenLabel()
|
||||||
|
->options(function () {
|
||||||
|
$domains = ShortUrlCustomDomain::where('is_active', true)
|
||||||
|
->where('is_verified', true)
|
||||||
|
->pluck('domain', 'id');
|
||||||
|
|
||||||
|
if (! config('filament-short-url.disable_default_domain', false)) {
|
||||||
|
$defaultDomain = request()->getHost() ?: parse_url(config('app.url'), PHP_URL_HOST);
|
||||||
|
$domains = collect(['default' => $defaultDomain])->union($domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domains;
|
||||||
|
})
|
||||||
|
->default(function () {
|
||||||
|
if (! config('filament-short-url.disable_default_domain', false)) {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstDomain = ShortUrlCustomDomain::where('is_active', true)
|
||||||
|
->where('is_verified', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $firstDomain ? $firstDomain->id : null;
|
||||||
|
})
|
||||||
|
->afterStateHydrated(function (Select $component, $state) {
|
||||||
|
if ($state === null && ! config('filament-short-url.disable_default_domain', false)) {
|
||||||
|
$component->state('default');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->dehydrateStateUsing(fn ($state) => $state === 'default' ? null : $state)
|
||||||
|
->disabled(function (?ShortUrl $record) {
|
||||||
|
if ($record && $record->exists && config('filament-short-url.lock_url_key', false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domainsCount = ShortUrlCustomDomain::where('is_active', true)
|
||||||
|
->where('is_verified', true)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$defaultEnabled = ! config('filament-short-url.disable_default_domain', false);
|
||||||
|
$totalOptionsCount = $domainsCount + ($defaultEnabled ? 1 : 0);
|
||||||
|
|
||||||
|
return $totalOptionsCount <= 1;
|
||||||
|
})
|
||||||
|
->dehydrated()
|
||||||
|
->required(fn () => (bool) config('filament-short-url.disable_default_domain', false))
|
||||||
|
->selectablePlaceholder(false)
|
||||||
|
->nullable()
|
||||||
|
->native(false),
|
||||||
|
|
||||||
|
TextInput::make('url_key')
|
||||||
|
->hiddenLabel()
|
||||||
|
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.url_key_helper'))
|
||||||
|
->alphaDash()
|
||||||
|
->maxLength(32)
|
||||||
|
->default(fn (ShortUrlService $service) => $service->generateKey())
|
||||||
|
->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(
|
||||||
|
Action::make('regenerate')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->tooltip('Generate new key')
|
||||||
|
->action(function (Set $set, ShortUrlService $service): void {
|
||||||
|
$set('url_key', $service->generateKey());
|
||||||
|
})
|
||||||
|
->visible(fn (?ShortUrl $record) => ! ($record && $record->exists && config('filament-short-url.lock_url_key', false)))
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->extraAttributes(['class' => 'custom-fused link-short-fused'])
|
||||||
|
->label(__('filament-short-url::default.short_link_label'))
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,21 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
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\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
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;
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class MarketingTab
|
class MarketingTab
|
||||||
@@ -26,52 +35,149 @@ class MarketingTab
|
|||||||
return Tab::make(__('filament-short-url::default.tab_marketing'))
|
return Tab::make(__('filament-short-url::default.tab_marketing'))
|
||||||
->icon('heroicon-o-megaphone')
|
->icon('heroicon-o-megaphone')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make(__('filament-short-url::default.marketing_pixels_title'))
|
Section::make()
|
||||||
->description(__('filament-short-url::default.marketing_pixels_desc'))
|
->contained(false)
|
||||||
|
->extraAttributes(['class' => 'validity-tab-card marketing-pixels-card'])
|
||||||
->schema([
|
->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')
|
Select::make('pixels')
|
||||||
->label(__('filament-short-url::default.pixels_navigation_label'))
|
->label(__('filament-short-url::default.pixels_navigation_label'))
|
||||||
|
->hiddenLabel()
|
||||||
->multiple()
|
->multiple()
|
||||||
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
->relationship('pixels', 'name', modifyQueryUsing: fn ($query) => $query->where('is_active', true))
|
||||||
->preload()
|
->preload()
|
||||||
->searchable()
|
->searchable()
|
||||||
->columnSpanFull(),
|
->live()
|
||||||
])
|
->placeholder(__('filament-short-url::default.marketing_pixels_select_placeholder'))
|
||||||
->contained(false),
|
->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'))
|
Section::make()
|
||||||
->description(__('filament-short-url::default.marketing_webhooks_desc'))
|
->contained(false)
|
||||||
|
->extraAttributes(['class' => 'validity-tab-card marketing-webhook-card'])
|
||||||
->schema([
|
->schema([
|
||||||
Placeholder::make('webhook_info')
|
Placeholder::make('marketing_webhook_card_header')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->content(new HtmlString(
|
->content(TabCardHeader::make(
|
||||||
'<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">'.
|
'heroicon-o-bolt',
|
||||||
'<div class="mt-0.5 w-4" data-component-part="callout-icon">'.
|
'validity-tab-card-icon--webhook',
|
||||||
'<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">'.
|
'marketing_webhooks_title',
|
||||||
'<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>'.
|
'marketing_webhooks_desc',
|
||||||
'</svg>'.
|
compact: true,
|
||||||
'</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>'.
|
|
||||||
'</div>'.
|
|
||||||
'</div>'
|
|
||||||
))
|
|
||||||
->columnSpanFull(),
|
|
||||||
|
|
||||||
TextInput::make('webhook_url')
|
Placeholder::make('marketing_webhook_empty_state')
|
||||||
->label(__('filament-short-url::default.webhook_url'))
|
->hiddenLabel()
|
||||||
->placeholder('https://api.yourcrm.com/webhooks/clicks')
|
->visible(fn (Get $get): bool => ! self::hasWebhookUrl($get))
|
||||||
->url()
|
->content(new HtmlString(
|
||||||
->maxLength(2048)
|
'<div class="validity-tab-empty">'.
|
||||||
->nullable()
|
'<div class="validity-tab-empty-icon">'.
|
||||||
->columnSpanFull(),
|
'<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>'.
|
||||||
ViewField::make('webhook_payload_example')
|
'</div>'.
|
||||||
->view('filament-short-url::webhook-payload-example')
|
'<p class="validity-tab-empty-title">'.e(__('filament-short-url::default.marketing_webhook_empty_title')).'</p>'.
|
||||||
->columnSpanFull(),
|
'<p class="validity-tab-empty-desc">'.e(__('filament-short-url::default.marketing_webhook_empty_desc')).'</p>'.
|
||||||
])
|
'</div>'
|
||||||
->contained(false),
|
)),
|
||||||
|
|
||||||
|
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(),
|
||||||
|
|
||||||
|
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()
|
||||||
|
->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')),
|
||||||
|
])
|
||||||
|
->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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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 Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
@@ -16,13 +18,16 @@ use Filament\Forms\Components\TextInput;
|
|||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Schemas\Components\Actions;
|
use Filament\Schemas\Components\Actions;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Group;
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
use Filament\Support\Enums\Alignment;
|
use Filament\Support\Enums\Alignment;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class PasswordTab
|
class PasswordTab
|
||||||
{
|
{
|
||||||
@@ -45,149 +50,221 @@ class PasswordTab
|
|||||||
->default(false),
|
->default(false),
|
||||||
|
|
||||||
Section::make()
|
Section::make()
|
||||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password'))
|
->contained(false)
|
||||||
->extraAttributes([
|
->extraAttributes(['class' => 'validity-tab-card password-tab-card'])
|
||||||
'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',
|
|
||||||
])
|
|
||||||
->schema([
|
->schema([
|
||||||
Group::make([
|
Placeholder::make('password_card_header')
|
||||||
Placeholder::make('empty_state_icon')
|
->hiddenLabel()
|
||||||
->hiddenLabel()
|
->visible(fn (Get $get): bool => ! $get('password_active_flag') || $get('is_entering_password'))
|
||||||
->content(new HtmlString('
|
->content(TabCardHeader::make(
|
||||||
<div class="flex flex-col items-center justify-center text-center">
|
'heroicon-o-lock-closed',
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
'validity-tab-card-icon--password',
|
||||||
<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">
|
'password_card_title',
|
||||||
<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" />
|
'password_card_subtitle',
|
||||||
</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>
|
|
||||||
')),
|
|
||||||
|
|
||||||
Actions::make([
|
Placeholder::make('password_empty_state')
|
||||||
Action::make('setup_password')
|
->hiddenLabel()
|
||||||
->label(__('filament-short-url::default.set_password'))
|
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password'))
|
||||||
->icon('heroicon-m-plus')
|
->content(new HtmlString(
|
||||||
->color('primary')
|
'<div class="validity-tab-empty">'.
|
||||||
->action(fn (Set $set) => $set('is_entering_password', true)),
|
'<div class="validity-tab-empty-icon">'.
|
||||||
])->alignment(Alignment::Center),
|
'<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>'.
|
||||||
Section::make(__('filament-short-url::default.password_status_active'))
|
'</div>'
|
||||||
->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([
|
|
||||||
Action::make('change_password')
|
|
||||||
->label('Zmień')
|
|
||||||
->icon('heroicon-m-pencil-square')
|
|
||||||
->color('gray')
|
|
||||||
->button()
|
|
||||||
->outlined()
|
|
||||||
->size('sm')
|
|
||||||
->action(function (Set $set) {
|
|
||||||
$set('password_active_flag', false);
|
|
||||||
$set('is_entering_password', true);
|
|
||||||
$set('new_password_input', null);
|
|
||||||
$set('new_password_confirmation_input', null);
|
|
||||||
}),
|
|
||||||
|
|
||||||
Action::make('remove_password')
|
|
||||||
->label('Usuń')
|
|
||||||
->icon('heroicon-m-trash')
|
|
||||||
->color('danger')
|
|
||||||
->button()
|
|
||||||
->outlined()
|
|
||||||
->size('sm')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (Set $set, ?ShortUrl $record) {
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->schema([]),
|
|
||||||
|
|
||||||
Section::make('Ustawienia hasła')
|
|
||||||
->visible(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password'))
|
|
||||||
->schema([
|
|
||||||
Group::make([
|
|
||||||
TextInput::make('new_password_input')
|
|
||||||
->label(__('filament-short-url::default.new_password'))
|
|
||||||
->password()
|
|
||||||
->revealable()
|
|
||||||
->live()
|
|
||||||
->maxLength(255)
|
|
||||||
->dehydrated(false)
|
|
||||||
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
|
|
||||||
|
|
||||||
TextInput::make('new_password_confirmation_input')
|
|
||||||
->label(__('filament-short-url::default.confirm_password'))
|
|
||||||
->password()
|
|
||||||
->revealable()
|
|
||||||
->same('new_password_input')
|
|
||||||
->maxLength(255)
|
|
||||||
->dehydrated(false)
|
|
||||||
->required(fn (Get $get): bool => ! empty($get('new_password_input'))),
|
|
||||||
])->columns(2),
|
|
||||||
|
|
||||||
Actions::make([
|
Actions::make([
|
||||||
Action::make('cancel_password')
|
Action::make('setup_password')
|
||||||
->label(__('filament-short-url::default.cancel'))
|
->label(__('filament-short-url::default.set_password'))
|
||||||
->color('gray')
|
->icon(Heroicon::Plus)
|
||||||
->action(function (Get $get, Set $set, ?ShortUrl $record) {
|
->outlined()
|
||||||
$set('password_active_flag', $record && ! empty($record->password));
|
->extraAttributes(['class' => 'password-tab-setup-btn'])
|
||||||
$set('is_entering_password', false);
|
->action(fn (Set $set) => $set('is_entering_password', true)),
|
||||||
$set('new_password_input', null);
|
])
|
||||||
$set('new_password_confirmation_input', null);
|
->alignment(Alignment::Center)
|
||||||
}),
|
->extraAttributes(['class' => 'password-tab-setup-action'])
|
||||||
|
->visible(fn (Get $get): bool => ! $get('password_active_flag') && ! $get('is_entering_password')),
|
||||||
|
|
||||||
Action::make('confirm_password')
|
Grid::make(['default' => 1, 'md' => 12])
|
||||||
->label('Zapisz hasło')
|
->visible(fn (Get $get): bool => (bool) $get('password_active_flag') && ! $get('is_entering_password'))
|
||||||
->color('primary')
|
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid password-tab-active-toolbar-grid'])
|
||||||
->action(function (Get $get, Set $set) {
|
->schema([
|
||||||
$password = $get('new_password_input');
|
Placeholder::make('password_active_status')
|
||||||
$confirm = $get('new_password_confirmation_input');
|
->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]),
|
||||||
|
|
||||||
if (empty($password)) {
|
Actions::make([
|
||||||
Notification::make()->title(__('filament-short-url::default.password_required_error'))->danger()->send();
|
Action::make('change_password')
|
||||||
|
->label(__('filament-short-url::default.password_change_short'))
|
||||||
|
->icon(Heroicon::PencilSquare)
|
||||||
|
->color('gray')
|
||||||
|
->outlined()
|
||||||
|
->size('sm')
|
||||||
|
->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));
|
||||||
|
}),
|
||||||
|
|
||||||
return;
|
Action::make('remove_password')
|
||||||
}
|
->label(__('filament-short-url::default.password_remove_short'))
|
||||||
if ($password !== $confirm) {
|
->icon(Heroicon::Trash)
|
||||||
Notification::make()->title(__('filament-short-url::default.password_mismatch_error'))->danger()->send();
|
->color('danger')
|
||||||
|
->outlined()
|
||||||
|
->size('sm')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->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);
|
||||||
|
|
||||||
return;
|
if ($record) {
|
||||||
}
|
$record->password = null;
|
||||||
|
}
|
||||||
|
|
||||||
$set('password', $password);
|
self::syncPasswordPreview($livewire, false);
|
||||||
$set('password_active_flag', true);
|
}),
|
||||||
$set('is_entering_password', false);
|
])
|
||||||
}),
|
->alignment(Alignment::End)
|
||||||
])->alignment(Alignment::End),
|
->extraAttributes(['class' => 'password-tab-active-actions password-tab-toolbar-actions'])
|
||||||
|
->columnSpan(['default' => 12, 'md' => 3]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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([
|
||||||
|
TextInput::make('new_password_input')
|
||||||
|
->label(__('filament-short-url::default.new_password'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->live()
|
||||||
|
->maxLength(255)
|
||||||
|
->dehydrated(false)
|
||||||
|
->required(fn (Get $get): bool => ! $get('password_active_flag') && $get('is_entering_password')),
|
||||||
|
|
||||||
|
TextInput::make('new_password_confirmation_input')
|
||||||
|
->label(__('filament-short-url::default.confirm_password'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->same('new_password_input')
|
||||||
|
->maxLength(255)
|
||||||
|
->dehydrated(false)
|
||||||
|
->required(fn (Get $get): bool => ! empty($get('new_password_input'))),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Actions::make([
|
||||||
|
Action::make('cancel_password')
|
||||||
|
->label(__('filament-short-url::default.cancel'))
|
||||||
|
->color('gray')
|
||||||
|
->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(__('filament-short-url::default.save_password'))
|
||||||
|
->color('primary')
|
||||||
|
->action(function (Get $get, Set $set, Component $livewire) {
|
||||||
|
$password = $get('new_password_input');
|
||||||
|
$confirm = $get('new_password_confirmation_input');
|
||||||
|
|
||||||
|
if (empty($password)) {
|
||||||
|
Notification::make()->title(__('filament-short-url::default.password_required_error'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== $confirm) {
|
||||||
|
Notification::make()->title(__('filament-short-url::default.password_mismatch_error'))->danger()->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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)
|
||||||
|
->extraAttributes(['class' => 'password-tab-form-actions']),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make()
|
Section::make()
|
||||||
|
->contained(false)
|
||||||
|
->extraAttributes(['class' => 'validity-tab-card password-warning-card'])
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('show_warning_page')
|
Grid::make(['default' => 1, 'md' => 12])
|
||||||
->label(__('filament-short-url::default.show_warning_page'))
|
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.show_warning_page_helper'))
|
->schema([
|
||||||
->default(false)
|
Placeholder::make('warning_page_card_header')
|
||||||
->inline(false),
|
->hiddenLabel()
|
||||||
])
|
->content(TabCardHeader::make(
|
||||||
->compact()
|
'heroicon-o-exclamation-triangle',
|
||||||
->extraAttributes(['class' => 'bg-transparent border-none shadow-none mt-4']),
|
'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'))
|
||||||
|
->hiddenLabel()
|
||||||
|
->default(false)
|
||||||
|
->live()
|
||||||
|
->inline(false)
|
||||||
|
->extraFieldWrapperAttributes([
|
||||||
|
'class' => 'validity-tab-card-toolbar-action tracking-card-toolbar-action',
|
||||||
|
])
|
||||||
|
->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.' } }))');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,24 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\OgImageImporter;
|
||||||
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlTempStorage;
|
||||||
use Filament\Forms\Components\FileUpload;
|
use Filament\Forms\Components\FileUpload;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
@@ -34,95 +40,186 @@ class SeoAndCloakingTab
|
|||||||
return Tab::make(__('filament-short-url::default.tab_seo_social'))
|
return Tab::make(__('filament-short-url::default.tab_seo_social'))
|
||||||
->icon('heroicon-o-globe-alt')
|
->icon('heroicon-o-globe-alt')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make(__('filament-short-url::default.seo_section_title'))
|
Section::make()
|
||||||
->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(),
|
|
||||||
])
|
|
||||||
->contained(false)
|
->contained(false)
|
||||||
->columns(2),
|
->extraAttributes(['class' => 'validity-tab-card seo-settings-card'])
|
||||||
|
|
||||||
Section::make(__('filament-short-url::default.og_section_title'))
|
|
||||||
->description(__('filament-short-url::default.og_section_desc'))
|
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('og_title')
|
Placeholder::make('seo_settings_card_header')
|
||||||
->label(__('filament-short-url::default.og_title'))
|
->hiddenLabel()
|
||||||
->placeholder('Custom Open Graph Title')
|
->content(TabCardHeader::make(
|
||||||
->maxLength(255)
|
'heroicon-o-magnifying-glass-circle',
|
||||||
->live(),
|
'validity-tab-card-icon--seo',
|
||||||
|
'seo_section_title',
|
||||||
|
'seo_section_desc',
|
||||||
|
compact: true,
|
||||||
|
)),
|
||||||
|
|
||||||
Textarea::make('og_description')
|
Grid::make(['default' => 1, 'md' => 2])
|
||||||
->label(__('filament-short-url::default.og_description'))
|
->extraAttributes(['class' => 'seo-settings-grid'])
|
||||||
->placeholder('Custom Open Graph Description')
|
->schema([
|
||||||
->maxLength(500)
|
self::toggleCard(
|
||||||
->rows(3)
|
'is_cloaked',
|
||||||
->live()
|
'is_cloaked',
|
||||||
->columnSpanFull(),
|
'is_cloaked_helper',
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
self::toggleCard(
|
||||||
|
'do_index',
|
||||||
|
'do_index',
|
||||||
|
'do_index_helper',
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
FileUpload::make('og_image')
|
Section::make()
|
||||||
->label(__('filament-short-url::default.og_image'))
|
->contained(false)
|
||||||
->image()
|
->visible(fn (Get $get): bool => ! PasswordOpenGraphGuard::isFormPasswordProtected($get))
|
||||||
->disk('public')
|
->extraAttributes(['class' => 'validity-tab-card seo-og-card'])
|
||||||
->directory(fn (ShortUrlTempStorage $temp): string => $temp->bucketDirectory())
|
->schema([
|
||||||
->visibility('public')
|
Placeholder::make('seo_og_card_header')
|
||||||
->maxSize(4096)
|
->hiddenLabel()
|
||||||
->rule(Rule::dimensions()->minWidth(600)->minHeight(313))
|
->content(TabCardHeader::make(
|
||||||
->automaticallyCropImagesToAspectRatio('16:9')
|
'heroicon-o-photo',
|
||||||
->automaticallyResizeImagesMode('cover')
|
'validity-tab-card-icon--og',
|
||||||
->automaticallyResizeImagesToWidth('1200')
|
'og_section_title',
|
||||||
->automaticallyResizeImagesToHeight('630')
|
'og_section_desc',
|
||||||
->saveUploadedFileUsing(function (TemporaryUploadedFile $file, OgImageProcessor $processor): string {
|
compact: true,
|
||||||
$storedPath = $processor->storeWebpFromPath($file->getRealPath(), ShortUrlTempStorage::ROOT);
|
)),
|
||||||
|
|
||||||
if ($storedPath === null) {
|
Group::make()
|
||||||
return $file->storePubliclyAs(
|
->extraAttributes(['class' => 'seo-og-panel'])
|
||||||
app(ShortUrlTempStorage::class)->bucketDirectory(),
|
->schema([
|
||||||
$file->getClientOriginalName(),
|
TextInput::make('og_title')
|
||||||
'public',
|
->label(__('filament-short-url::default.og_title'))
|
||||||
);
|
->placeholder(__('filament-short-url::default.og_title_placeholder'))
|
||||||
}
|
->maxLength(255)
|
||||||
|
->live()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'seo-og-field']),
|
||||||
|
|
||||||
return $storedPath;
|
Textarea::make('og_description')
|
||||||
})
|
->label(__('filament-short-url::default.og_description'))
|
||||||
->live()
|
->placeholder(__('filament-short-url::default.og_description_placeholder'))
|
||||||
->columnSpanFull(),
|
->maxLength(500)
|
||||||
|
->rows(3)
|
||||||
|
->live()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'seo-og-field'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Hidden::make('og_image_scraped')
|
FileUpload::make('og_image')
|
||||||
->dehydrated(false)
|
->label(__('filament-short-url::default.og_image'))
|
||||||
->live()
|
->image()
|
||||||
->afterStateUpdated(function (?string $state, Set $set, Get $get, OgImageImporter $importer, Component $livewire): void {
|
->disk('public')
|
||||||
if (blank($state) || filled($get('og_image'))) {
|
->directory(fn (ShortUrlTempStorage $temp): string => $temp->bucketDirectory())
|
||||||
$set('is_scraping', false);
|
->visibility('public')
|
||||||
$livewire->js('window.fsuDispatchScraping(false)');
|
->maxSize(4096)
|
||||||
|
->rule(Rule::dimensions()->minWidth(600)->minHeight(313))
|
||||||
|
->automaticallyCropImagesToAspectRatio('16:9')
|
||||||
|
->automaticallyResizeImagesMode('cover')
|
||||||
|
->automaticallyResizeImagesToWidth('1200')
|
||||||
|
->automaticallyResizeImagesToHeight('630')
|
||||||
|
->saveUploadedFileUsing(function (TemporaryUploadedFile $file, OgImageProcessor $processor): string {
|
||||||
|
$storedPath = $processor->storeWebpFromPath($file->getRealPath(), ShortUrlTempStorage::ROOT);
|
||||||
|
|
||||||
return;
|
if ($storedPath === null) {
|
||||||
}
|
return $file->storePubliclyAs(
|
||||||
|
app(ShortUrlTempStorage::class)->bucketDirectory(),
|
||||||
|
$file->getClientOriginalName(),
|
||||||
|
'public',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
return $storedPath;
|
||||||
$path = $importer->importFromUrl($state);
|
})
|
||||||
|
->live()
|
||||||
|
->afterStateHydrated(function (mixed $state, Component $livewire): void {
|
||||||
|
if (blank($state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($path !== null) {
|
$livewire->js('window.fsuLockScrape && window.fsuLockScrape()');
|
||||||
$set('og_image', $path);
|
})
|
||||||
}
|
->afterStateUpdated(function (mixed $state, Set $set, Component $livewire): void {
|
||||||
} finally {
|
if (blank($state)) {
|
||||||
$set('is_scraping', false);
|
return;
|
||||||
$livewire->js('window.fsuDispatchScraping(false)');
|
}
|
||||||
}
|
|
||||||
}),
|
$set('og_image_scraped', null);
|
||||||
])
|
$set('is_scraping', false);
|
||||||
->contained(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)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$path = $importer->importFromUrl($state);
|
||||||
|
|
||||||
|
if ($path !== null) {
|
||||||
|
$set('og_image', $path);
|
||||||
|
$livewire->js('window.fsuLockScrape && window.fsuLockScrape()');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$set('is_scraping', false);
|
||||||
|
$livewire->js('window.fsuDispatchScraping(false); window.fsuLockScrape && window.fsuLockScrape()');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,32 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\Forms\Components\TrafficSplitter;
|
||||||
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\TabCardHeader;
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\WeightBalancer;
|
||||||
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Builder;
|
use Filament\Forms\Components\Builder;
|
||||||
use Filament\Forms\Components\Builder\Block;
|
use Filament\Forms\Components\Builder\Block;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\Hidden;
|
use Filament\Forms\Components\Hidden;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Repeater;
|
use Filament\Forms\Components\Repeater;
|
||||||
use Filament\Forms\Components\Repeater\TableColumn;
|
use Filament\Forms\Components\Repeater\TableColumn;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Components\ViewField;
|
use Filament\Forms\Components\ViewField;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
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\HtmlString;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -38,368 +47,521 @@ class TargetingTab
|
|||||||
return Tab::make(__('filament-short-url::default.tab_targeting'))
|
return Tab::make(__('filament-short-url::default.tab_targeting'))
|
||||||
->icon('heroicon-o-funnel')
|
->icon('heroicon-o-funnel')
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make()
|
||||||
Section::make(__('filament-short-url::default.targeting_rules'))
|
->contained(false)
|
||||||
->compact()
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->schema([
|
->schema([
|
||||||
Repeater::make('targeting_rules')
|
Placeholder::make('targeting_rules_card_header')
|
||||||
->label(__('filament-short-url::default.targeting_rules'))
|
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->defaultItems(0)
|
->content(TabCardHeader::make(
|
||||||
->maxItems(10)
|
'heroicon-o-funnel',
|
||||||
->reorderable(false)
|
'validity-tab-card-icon--targeting',
|
||||||
->collapsible()
|
'targeting_rules_card_title',
|
||||||
->collapsed()
|
'targeting_rules_card_subtitle',
|
||||||
->columns(12)
|
)),
|
||||||
->afterStateHydrated(function (Repeater $component, $state) {
|
|
||||||
if (! is_array($state)) {
|
|
||||||
$component->state([]);
|
|
||||||
|
|
||||||
return;
|
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>'
|
||||||
|
)),
|
||||||
|
|
||||||
// If it is legacy format (has 'type' key)
|
self::targetingRulesRepeater(),
|
||||||
if (isset($state['type'])) {
|
]),
|
||||||
$type = $state['type'];
|
|
||||||
$newRules = [];
|
|
||||||
|
|
||||||
if ($type === 'device') {
|
Section::make()
|
||||||
$devices = $state['device'] ?? [];
|
->contained(false)
|
||||||
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
if ($mobileUrl) {
|
->schema([
|
||||||
$newRules[] = [
|
Grid::make(['default' => 1, 'md' => 12])
|
||||||
'match' => 'or',
|
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
|
||||||
'url' => $mobileUrl,
|
|
||||||
'filters' => [
|
|
||||||
[
|
|
||||||
'type' => 'device',
|
|
||||||
'data' => ['devices' => ['mobile']],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
|
|
||||||
if ($tabletUrl) {
|
|
||||||
$newRules[] = [
|
|
||||||
'match' => 'or',
|
|
||||||
'url' => $tabletUrl,
|
|
||||||
'filters' => [
|
|
||||||
[
|
|
||||||
'type' => 'device',
|
|
||||||
'data' => ['devices' => ['tablet']],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$desktopUrl = $devices['desktop'] ?? null;
|
|
||||||
if ($desktopUrl) {
|
|
||||||
$newRules[] = [
|
|
||||||
'match' => 'or',
|
|
||||||
'url' => $desktopUrl,
|
|
||||||
'filters' => [
|
|
||||||
[
|
|
||||||
'type' => 'device',
|
|
||||||
'data' => ['devices' => ['desktop']],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} elseif ($type === 'geo') {
|
|
||||||
foreach ($state['geo'] ?? [] as $geoRule) {
|
|
||||||
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
|
|
||||||
$newRules[] = [
|
|
||||||
'match' => 'or',
|
|
||||||
'url' => $geoRule['url'],
|
|
||||||
'filters' => [
|
|
||||||
[
|
|
||||||
'type' => 'country',
|
|
||||||
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} elseif ($type === 'language') {
|
|
||||||
foreach ($state['language'] ?? [] as $langRule) {
|
|
||||||
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
|
|
||||||
$newRules[] = [
|
|
||||||
'match' => 'or',
|
|
||||||
'url' => $langRule['url'],
|
|
||||||
'filters' => [
|
|
||||||
[
|
|
||||||
'type' => 'language',
|
|
||||||
'data' => ['languages' => [strtolower($langRule['language_code'])]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$component->state($newRules);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! array_is_list($state)) {
|
|
||||||
$component->state([]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$component->state($state);
|
|
||||||
})
|
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('match')
|
Placeholder::make('app_linking_card_header')
|
||||||
->label(__('filament-short-url::default.match'))
|
|
||||||
->options([
|
|
||||||
'or' => __('filament-short-url::default.match_or'),
|
|
||||||
'and' => __('filament-short-url::default.match_and'),
|
|
||||||
])
|
|
||||||
->default('or')
|
|
||||||
->required()
|
|
||||||
->columnSpan(3),
|
|
||||||
|
|
||||||
Select::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'),
|
|
||||||
])
|
|
||||||
->default('single')
|
|
||||||
->live()
|
|
||||||
->required()
|
|
||||||
->columnSpan(3)
|
|
||||||
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
|
||||||
if ($state === 'split' && empty($get('variants'))) {
|
|
||||||
$set('variants', [
|
|
||||||
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
|
||||||
(string) Str::uuid() => ['label' => 'Variant B', 'url' => '', 'weight' => 50],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
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'))
|
|
||||||
->maxLength(2048)
|
|
||||||
->rules([
|
|
||||||
app(SafeUrl::class),
|
|
||||||
])
|
|
||||||
->columnSpan(6),
|
|
||||||
|
|
||||||
Repeater::make('variants')
|
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->extraAttributes(['class' => 'ab-test-repeater'])
|
->content(TabCardHeader::make(
|
||||||
->table([
|
'heroicon-o-device-phone-mobile',
|
||||||
TableColumn::make(__('filament-short-url::default.variant_label'))
|
'validity-tab-card-icon--app-linking',
|
||||||
->width('30%'),
|
'app_linking_card_title',
|
||||||
TableColumn::make(__('filament-short-url::default.variant_url'))
|
'app_linking_card_subtitle',
|
||||||
->width('70%'),
|
))
|
||||||
])
|
->columnSpan(['default' => 12, 'md' => 9]),
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
Toggle::make('auto_open_app_mobile')
|
||||||
->hiddenLabel()
|
->label(__('filament-short-url::default.auto_open_app_mobile'))
|
||||||
->placeholder('e.g. Variant A')
|
->hiddenLabel()
|
||||||
->required()
|
->default(false)
|
||||||
->maxLength(100),
|
|
||||||
TextInput::make('url')
|
|
||||||
->hiddenLabel()
|
|
||||||
->url()
|
|
||||||
->required()
|
|
||||||
->maxLength(2048)
|
|
||||||
->rules([
|
|
||||||
app(SafeUrl::class),
|
|
||||||
]),
|
|
||||||
Hidden::make('weight'),
|
|
||||||
])
|
|
||||||
->defaultItems(2)
|
|
||||||
->minItems(2)
|
|
||||||
->maxItems(5)
|
|
||||||
->reorderable(false)
|
|
||||||
->live()
|
->live()
|
||||||
->rules([
|
->inline(false)
|
||||||
function () {
|
->extraFieldWrapperAttributes([
|
||||||
return function (string $attribute, $value, \Closure $fail) {
|
'class' => 'validity-tab-card-toolbar-action tracking-card-toolbar-action',
|
||||||
if (! is_array($value)) {
|
])
|
||||||
return;
|
->extraAttributes([
|
||||||
}
|
'aria-label' => __('filament-short-url::default.auto_open_app_mobile'),
|
||||||
$sum = array_sum(array_column($value, 'weight'));
|
])
|
||||||
if ($sum !== 100) {
|
->columnSpan(['default' => 12, 'md' => 3]),
|
||||||
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
]),
|
||||||
}
|
|
||||||
};
|
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'),
|
||||||
])
|
])
|
||||||
->deleteAction(
|
|
||||||
fn ($action) => $action
|
|
||||||
->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))
|
|
||||||
)
|
|
||||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function targetingRulesRepeater(): Repeater
|
||||||
|
{
|
||||||
|
return Repeater::make('targeting_rules')
|
||||||
|
->hiddenLabel()
|
||||||
|
->extraAttributes(['class' => 'targeting-rules-repeater'])
|
||||||
|
->defaultItems(0)
|
||||||
|
->maxItems(10)
|
||||||
|
->reorderable(false)
|
||||||
|
->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([]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($state['type'])) {
|
||||||
|
$type = $state['type'];
|
||||||
|
$newRules = [];
|
||||||
|
|
||||||
|
if ($type === 'device') {
|
||||||
|
$devices = $state['device'] ?? [];
|
||||||
|
$mobileUrl = $devices['mobile'] ?? $devices['ios'] ?? null;
|
||||||
|
if ($mobileUrl) {
|
||||||
|
$newRules[] = [
|
||||||
|
'match' => 'or',
|
||||||
|
'url' => $mobileUrl,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'type' => 'device',
|
||||||
|
'data' => ['devices' => ['mobile']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$tabletUrl = $devices['tablet'] ?? $devices['android'] ?? null;
|
||||||
|
if ($tabletUrl) {
|
||||||
|
$newRules[] = [
|
||||||
|
'match' => 'or',
|
||||||
|
'url' => $tabletUrl,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'type' => 'device',
|
||||||
|
'data' => ['devices' => ['tablet']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$desktopUrl = $devices['desktop'] ?? null;
|
||||||
|
if ($desktopUrl) {
|
||||||
|
$newRules[] = [
|
||||||
|
'match' => 'or',
|
||||||
|
'url' => $desktopUrl,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'type' => 'device',
|
||||||
|
'data' => ['devices' => ['desktop']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} elseif ($type === 'geo') {
|
||||||
|
foreach ($state['geo'] ?? [] as $geoRule) {
|
||||||
|
if (! empty($geoRule['url']) && ! empty($geoRule['country_code'])) {
|
||||||
|
$newRules[] = [
|
||||||
|
'match' => 'or',
|
||||||
|
'url' => $geoRule['url'],
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'type' => 'country',
|
||||||
|
'data' => ['countries' => [strtoupper($geoRule['country_code'])]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($type === 'language') {
|
||||||
|
foreach ($state['language'] ?? [] as $langRule) {
|
||||||
|
if (! empty($langRule['url']) && ! empty($langRule['language_code'])) {
|
||||||
|
$newRules[] = [
|
||||||
|
'match' => 'or',
|
||||||
|
'url' => $langRule['url'],
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'type' => 'language',
|
||||||
|
'data' => ['languages' => [strtolower($langRule['language_code'])]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$component->state($newRules);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_is_list($state)) {
|
||||||
|
$component->state([]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$component->state($state);
|
||||||
|
})
|
||||||
|
->schema([
|
||||||
|
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' => [
|
||||||
|
'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()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
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()
|
||||||
|
->extraFieldWrapperAttributes(['class' => 'link-segment-wrap'])
|
||||||
|
->afterStateUpdated(function ($state, Set $set, Get $get) {
|
||||||
|
if ($state === 'split' && empty($get('variants'))) {
|
||||||
|
$set('variants', [
|
||||||
|
(string) Str::uuid() => ['label' => 'Variant A', 'url' => '', 'weight' => 50],
|
||||||
|
(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),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Group::make()
|
||||||
|
->extraAttributes(['class' => 'targeting-tab-panel'])
|
||||||
|
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||||
|
->columnSpanFull()
|
||||||
|
->schema([
|
||||||
|
self::variantsRepeater(),
|
||||||
|
|
||||||
TrafficSplitter::make('traffic_split')
|
TrafficSplitter::make('traffic_split')
|
||||||
->label(__('filament-short-url::default.traffic_split'))
|
->label(__('filament-short-url::default.traffic_split'))
|
||||||
->target('variants')
|
->target('variants')
|
||||||
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull()
|
||||||
|
->default([]);
|
||||||
|
}
|
||||||
|
|
||||||
Builder::make('filters')
|
private static function variantsRepeater(): Repeater
|
||||||
->label(__('filament-short-url::default.add_filter'))
|
{
|
||||||
->hiddenLabel()
|
return Repeater::make('variants')
|
||||||
->addActionLabel(__('filament-short-url::default.add_filter'))
|
->hiddenLabel()
|
||||||
->reorderable(false)
|
->compact()
|
||||||
->collapsible()
|
->extraAttributes(['class' => 'ab-test-repeater'])
|
||||||
->blockNumbers(false)
|
->table([
|
||||||
->addBetweenAction(fn ($action) => $action->hidden())
|
TableColumn::make(__('filament-short-url::default.variant_label'))
|
||||||
->minItems(1)
|
->width('36%'),
|
||||||
->blocks([
|
TableColumn::make(__('filament-short-url::default.variant_url'))
|
||||||
Block::make('device')
|
->width('64%'),
|
||||||
->label(fn (?array $state) => $state === null
|
])
|
||||||
? __('filament-short-url::default.filter_device')
|
->schema([
|
||||||
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
TextInput::make('label')
|
||||||
)
|
->hiddenLabel()
|
||||||
->icon('heroicon-o-device-phone-mobile')
|
->placeholder('e.g. Variant A')
|
||||||
->schema([
|
->required()
|
||||||
CheckboxList::make('devices')
|
->maxLength(100),
|
||||||
->label(__('filament-short-url::default.select_devices'))
|
TextInput::make('url')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->options([
|
->url()
|
||||||
'desktop' => __('filament-short-url::default.device_desktop_label'),
|
->placeholder('https://example.com/landing-page')
|
||||||
'mobile' => __('filament-short-url::default.device_mobile_label'),
|
->required()
|
||||||
'tablet' => __('filament-short-url::default.device_tablet_label'),
|
->maxLength(2048)
|
||||||
])
|
->rules([
|
||||||
->required()
|
app(SafeUrl::class),
|
||||||
->columns(3)
|
]),
|
||||||
->columnSpanFull(),
|
Hidden::make('weight'),
|
||||||
])
|
])
|
||||||
->maxItems(1),
|
->defaultItems(2)
|
||||||
Block::make('platform')
|
->minItems(2)
|
||||||
->label(fn (?array $state) => $state === null
|
->maxItems(5)
|
||||||
? __('filament-short-url::default.filter_platform')
|
->reorderable(false)
|
||||||
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
->live()
|
||||||
)
|
->rules([
|
||||||
->icon('heroicon-o-computer-desktop')
|
function () {
|
||||||
->schema([
|
return function (string $attribute, $value, \Closure $fail) {
|
||||||
CheckboxList::make('platforms')
|
if (! is_array($value)) {
|
||||||
->label(__('filament-short-url::default.select_platforms'))
|
return;
|
||||||
->hiddenLabel()
|
}
|
||||||
->options([
|
$sum = array_sum(array_column($value, 'weight'));
|
||||||
'android' => 'Android',
|
if ($sum !== 100) {
|
||||||
'fire_os' => 'Fire OS',
|
$fail(__('filament-short-url::default.weights_sum_error', ['sum' => $sum]));
|
||||||
'ios' => 'iOS / iPadOS',
|
}
|
||||||
'linux' => 'Linux',
|
};
|
||||||
'mac' => 'macOS',
|
},
|
||||||
'windows' => 'Windows',
|
])
|
||||||
])
|
->deleteAction(
|
||||||
->required()
|
fn (Action $action) => $action
|
||||||
->columns(3)
|
->icon(Heroicon::Trash)
|
||||||
->columnSpanFull(),
|
->iconButton()
|
||||||
])
|
->color('danger')
|
||||||
->maxItems(1),
|
->size(Size::Small)
|
||||||
Block::make('country')
|
->visible(fn (Get $get): bool => count($get('variants') ?? []) > 2)
|
||||||
->label(fn (?array $state) => $state === null
|
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||||
? __('filament-short-url::default.filter_country')
|
)
|
||||||
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
->addAction(
|
||||||
)
|
fn (Action $action) => $action
|
||||||
->icon('heroicon-o-globe-alt')
|
->icon(Heroicon::Plus)
|
||||||
->schema([
|
->outlined()
|
||||||
Select::make('countries')
|
->after(fn ($component) => WeightBalancer::balanceWeightsEqually($component))
|
||||||
->label(__('filament-short-url::default.select_countries'))
|
)
|
||||||
->hiddenLabel()
|
->addActionLabel(__('filament-short-url::default.add_url'))
|
||||||
->multiple()
|
->addActionAlignment(Alignment::Start)
|
||||||
->searchable()
|
->visible(fn (Get $get): bool => $get('destination_type') === 'split')
|
||||||
->allowHtml()
|
->columnSpanFull();
|
||||||
->options(function (): array {
|
}
|
||||||
$countries = __('filament-short-url::countries');
|
|
||||||
if (is_array($countries)) {
|
|
||||||
asort($countries, SORT_LOCALE_STRING);
|
|
||||||
|
|
||||||
$htmlOptions = [];
|
private static function refreshTargetingRulesEmptyState(Repeater $component): void
|
||||||
foreach ($countries as $code => $name) {
|
{
|
||||||
$lowerCode = strtolower($code);
|
$component->getLivewire()->partiallyRenderSchemaComponent('targeting_rules_empty_state');
|
||||||
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $htmlOptions;
|
private static function filtersBuilder(): Builder
|
||||||
}
|
{
|
||||||
|
return Builder::make('filters')
|
||||||
return [];
|
->hiddenLabel()
|
||||||
})
|
->addActionLabel(__('filament-short-url::default.add_filter'))
|
||||||
->optionsLimit(300)
|
->reorderable(false)
|
||||||
->required()
|
->collapsible()
|
||||||
->columnSpanFull(),
|
->blockNumbers(false)
|
||||||
])
|
->addBetweenAction(fn ($action) => $action->hidden())
|
||||||
->maxItems(1),
|
->minItems(1)
|
||||||
Block::make('language')
|
->extraAttributes(['class' => 'targeting-filters-builder'])
|
||||||
->label(fn (?array $state) => $state === null
|
->blocks([
|
||||||
? __('filament-short-url::default.filter_language')
|
Block::make('device')
|
||||||
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
->label(fn (?array $state) => $state === null
|
||||||
)
|
? __('filament-short-url::default.filter_device')
|
||||||
->icon('heroicon-o-language')
|
: new HtmlString(__('filament-short-url::default.select_devices').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||||
->schema([
|
)
|
||||||
Select::make('languages')
|
->icon('heroicon-o-device-phone-mobile')
|
||||||
->label(__('filament-short-url::default.select_languages'))
|
|
||||||
->hiddenLabel()
|
|
||||||
->multiple()
|
|
||||||
->searchable()
|
|
||||||
->options(function (): array {
|
|
||||||
$languages = __('filament-short-url::languages');
|
|
||||||
if (is_array($languages)) {
|
|
||||||
asort($languages, SORT_LOCALE_STRING);
|
|
||||||
|
|
||||||
return $languages;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
->required()
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->maxItems(1),
|
|
||||||
])
|
|
||||||
->rules([
|
|
||||||
function () {
|
|
||||||
return function (string $attribute, $value, \Closure $fail) {
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$types = collect($value)->pluck('type');
|
|
||||||
if ($types->duplicates()->isNotEmpty()) {
|
|
||||||
$fail('Each filter type (Device, Platform, Country, Language) can only be added once.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull()
|
|
||||||
->default([]),
|
|
||||||
])->contained(false),
|
|
||||||
|
|
||||||
Section::make(__('filament-short-url::default.form_section_app_linking'))
|
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('auto_open_app_mobile')
|
CheckboxList::make('devices')
|
||||||
->label(__('filament-short-url::default.auto_open_app_mobile'))
|
->label(__('filament-short-url::default.select_devices'))
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.auto_open_app_mobile_helper'))
|
->hiddenLabel()
|
||||||
->default(false)
|
->options([
|
||||||
->inline(false)
|
'desktop' => __('filament-short-url::default.device_desktop_label'),
|
||||||
->live(),
|
'mobile' => __('filament-short-url::default.device_mobile_label'),
|
||||||
|
'tablet' => __('filament-short-url::default.device_tablet_label'),
|
||||||
ViewField::make('app_linking_preview')
|
|
||||||
->view('filament-short-url::app-linking-preview')
|
|
||||||
->viewData(fn (Get $get) => [
|
|
||||||
'destinationUrl' => $get('destination_url'),
|
|
||||||
])
|
])
|
||||||
->columnSpanFull()
|
->required()
|
||||||
->visible(fn (Get $get): bool => (bool) $get('auto_open_app_mobile')),
|
->columns(3)
|
||||||
])->contained(false),
|
->columnSpanFull(),
|
||||||
]);
|
])
|
||||||
|
->maxItems(1),
|
||||||
|
Block::make('platform')
|
||||||
|
->label(fn (?array $state) => $state === null
|
||||||
|
? __('filament-short-url::default.filter_platform')
|
||||||
|
: new HtmlString(__('filament-short-url::default.select_platforms').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||||
|
)
|
||||||
|
->icon('heroicon-o-computer-desktop')
|
||||||
|
->schema([
|
||||||
|
CheckboxList::make('platforms')
|
||||||
|
->label(__('filament-short-url::default.select_platforms'))
|
||||||
|
->hiddenLabel()
|
||||||
|
->options([
|
||||||
|
'android' => 'Android',
|
||||||
|
'fire_os' => 'Fire OS',
|
||||||
|
'ios' => 'iOS / iPadOS',
|
||||||
|
'linux' => 'Linux',
|
||||||
|
'mac' => 'macOS',
|
||||||
|
'windows' => 'Windows',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->columns(3)
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->maxItems(1),
|
||||||
|
Block::make('country')
|
||||||
|
->label(fn (?array $state) => $state === null
|
||||||
|
? __('filament-short-url::default.filter_country')
|
||||||
|
: new HtmlString(__('filament-short-url::default.select_countries').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||||
|
)
|
||||||
|
->icon('heroicon-o-globe-alt')
|
||||||
|
->schema([
|
||||||
|
Select::make('countries')
|
||||||
|
->label(__('filament-short-url::default.select_countries'))
|
||||||
|
->hiddenLabel()
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->allowHtml()
|
||||||
|
->options(function (): array {
|
||||||
|
$countries = __('filament-short-url::countries');
|
||||||
|
if (is_array($countries)) {
|
||||||
|
asort($countries, SORT_LOCALE_STRING);
|
||||||
|
|
||||||
|
$htmlOptions = [];
|
||||||
|
foreach ($countries as $code => $name) {
|
||||||
|
$lowerCode = strtolower($code);
|
||||||
|
$htmlOptions[$code] = "<span class=\"flex items-center gap-2\"><img src=\"https://flagcdn.com/h20/{$lowerCode}.webp\" class=\"w-5 h-auto rounded-sm inline-block mr-2\" alt=\"{$name}\" style=\"vertical-align: middle;\" /><span>{$name}</span></span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $htmlOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
->optionsLimit(300)
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->maxItems(1),
|
||||||
|
Block::make('language')
|
||||||
|
->label(fn (?array $state) => $state === null
|
||||||
|
? __('filament-short-url::default.filter_language')
|
||||||
|
: new HtmlString(__('filament-short-url::default.select_languages').' <sup class="text-danger-600 dark:text-danger-400 fi-fo-field-label-required-mark" style="color: rgb(220, 38, 38) !important;">*</sup>')
|
||||||
|
)
|
||||||
|
->icon('heroicon-o-language')
|
||||||
|
->schema([
|
||||||
|
Select::make('languages')
|
||||||
|
->label(__('filament-short-url::default.select_languages'))
|
||||||
|
->hiddenLabel()
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->options(function (): array {
|
||||||
|
$languages = __('filament-short-url::languages');
|
||||||
|
if (is_array($languages)) {
|
||||||
|
asort($languages, SORT_LOCALE_STRING);
|
||||||
|
|
||||||
|
return $languages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->maxItems(1),
|
||||||
|
])
|
||||||
|
->rules([
|
||||||
|
function () {
|
||||||
|
return function (string $attribute, $value, \Closure $fail) {
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$types = collect($value)->pluck('type');
|
||||||
|
if ($types->duplicates()->isNotEmpty()) {
|
||||||
|
$fail(__('filament-short-url::default.filter_duplicate_error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->columnSpanFull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class TrackingTab
|
class TrackingTab
|
||||||
{
|
{
|
||||||
@@ -22,135 +27,217 @@ class TrackingTab
|
|||||||
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
return Tab::make(__('filament-short-url::default.tab_tracking'))
|
||||||
->icon('heroicon-o-chart-bar')
|
->icon('heroicon-o-chart-bar')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make(__('filament-short-url::default.form_section_tracking'))
|
Section::make()
|
||||||
->description('Włącz lub wyłącz globalne śledzenie ruchu i statystyk dla tego skróconego linku.')
|
->contained(false)
|
||||||
->icon('heroicon-o-presentation-chart-line')
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->aside()
|
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('track_visits')
|
Grid::make(['default' => 1, 'md' => 12])
|
||||||
->label(__('filament-short-url::default.track_visits'))
|
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
|
||||||
->default(fn () => config('filament-short-url.tracking.enabled', true))
|
->schema([
|
||||||
->live()
|
Placeholder::make('tracking_visit_card_header')
|
||||||
->inline(false)
|
->hiddenLabel()
|
||||||
->columnSpanFull(),
|
->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)
|
||||||
|
->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]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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([
|
||||||
|
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(),
|
||||||
|
|
||||||
|
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'],
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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(),
|
||||||
|
|
||||||
|
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'],
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make(__('filament-short-url::default.form_section_tracked_fields'))
|
Section::make()
|
||||||
->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.')
|
->contained(false)
|
||||||
->icon('heroicon-o-adjustments-horizontal')
|
->extraAttributes(['class' => 'validity-tab-card tracking-utm-card'])
|
||||||
->aside()
|
|
||||||
->hidden(fn (Get $get): bool => ! $get('track_visits'))
|
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('track_ip_address')
|
Placeholder::make('tracking_utm_card_header')
|
||||||
->label(__('filament-short-url::default.track_ip'))
|
->hiddenLabel()
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.ip_address', true))
|
->content(TabCardHeader::make(
|
||||||
->inline(false)
|
'heroicon-o-megaphone',
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
'validity-tab-card-icon--utm',
|
||||||
|
'tracking_utm_card_title',
|
||||||
|
'tracking_utm_card_subtitle',
|
||||||
|
compact: true,
|
||||||
|
)),
|
||||||
|
|
||||||
Toggle::make('track_browser')
|
Grid::make(['default' => 1, 'md' => 2])
|
||||||
->label(__('filament-short-url::default.track_browser'))
|
->extraAttributes(['class' => 'tracking-utm-fields'])
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.browser', true))
|
->schema([
|
||||||
->inline(false)
|
TextInput::make('utm_source')
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
->label(__('filament-short-url::default.utm_source'))
|
||||||
|
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
|
||||||
|
->prefixIcon('heroicon-m-megaphone')
|
||||||
|
->dehydrated(false)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||||
|
|
||||||
Toggle::make('track_browser_version')
|
TextInput::make('utm_medium')
|
||||||
->label(__('filament-short-url::default.track_browser_version'))
|
->label(__('filament-short-url::default.utm_medium'))
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.browser_version', true))
|
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
|
||||||
->inline(false)
|
->prefixIcon('heroicon-m-device-phone-mobile')
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
->dehydrated(false)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||||
|
|
||||||
Toggle::make('track_operating_system')
|
TextInput::make('utm_campaign')
|
||||||
->label(__('filament-short-url::default.track_os'))
|
->label(__('filament-short-url::default.utm_campaign'))
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system', true))
|
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
|
||||||
->inline(false)
|
->prefixIcon('heroicon-m-flag')
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
->dehydrated(false)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||||
|
|
||||||
Toggle::make('track_operating_system_version')
|
TextInput::make('utm_term')
|
||||||
->label(__('filament-short-url::default.track_os_version'))
|
->label(__('filament-short-url::default.utm_term'))
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.operating_system_version', true))
|
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
|
||||||
->inline(false)
|
->prefixIcon('heroicon-m-magnifying-glass')
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
->dehydrated(false)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||||
|
|
||||||
Toggle::make('track_device_type')
|
TextInput::make('utm_content')
|
||||||
->label(__('filament-short-url::default.track_device_type'))
|
->label(__('filament-short-url::default.utm_content'))
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.device_type', true))
|
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
|
||||||
->inline(false)
|
->prefixIcon('heroicon-m-document-text')
|
||||||
->disabled(fn (Get $get): bool => ! $get('track_visits')),
|
->dehydrated(false)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->columnSpanFull()
|
||||||
|
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
Toggle::make('track_referer_url')
|
Section::make()
|
||||||
->label(__('filament-short-url::default.track_referer'))
|
->contained(false)
|
||||||
->default(fn () => config('filament-short-url.tracking.fields.referer_url', true))
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->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()
|
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('utm_source')
|
Placeholder::make('tracking_ga_card_header')
|
||||||
->label(__('filament-short-url::default.utm_source'))
|
->hiddenLabel()
|
||||||
->placeholder(__('filament-short-url::default.utm_source_placeholder'))
|
->content(TabCardHeader::make(
|
||||||
->prefixIcon('heroicon-m-megaphone')
|
'heroicon-o-chart-pie',
|
||||||
->dehydrated(false)
|
'validity-tab-card-icon--ga',
|
||||||
->live(onBlur: true)
|
'tracking_ga_card_title',
|
||||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
'tracking_ga_card_subtitle',
|
||||||
|
compact: true,
|
||||||
|
)),
|
||||||
|
|
||||||
TextInput::make('utm_medium')
|
|
||||||
->label(__('filament-short-url::default.utm_medium'))
|
|
||||||
->placeholder(__('filament-short-url::default.utm_medium_placeholder'))
|
|
||||||
->prefixIcon('heroicon-m-device-phone-mobile')
|
|
||||||
->dehydrated(false)
|
|
||||||
->live(onBlur: true)
|
|
||||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
|
||||||
|
|
||||||
TextInput::make('utm_campaign')
|
|
||||||
->label(__('filament-short-url::default.utm_campaign'))
|
|
||||||
->placeholder(__('filament-short-url::default.utm_campaign_placeholder'))
|
|
||||||
->prefixIcon('heroicon-m-flag')
|
|
||||||
->dehydrated(false)
|
|
||||||
->live(onBlur: true)
|
|
||||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
|
||||||
|
|
||||||
TextInput::make('utm_term')
|
|
||||||
->label(__('filament-short-url::default.utm_term'))
|
|
||||||
->placeholder(__('filament-short-url::default.utm_term_placeholder'))
|
|
||||||
->prefixIcon('heroicon-m-magnifying-glass')
|
|
||||||
->dehydrated(false)
|
|
||||||
->live(onBlur: true)
|
|
||||||
->afterStateUpdated(fn (Get $get, Set $set) => LinkTab::syncUtmToDestination($get, $set)),
|
|
||||||
|
|
||||||
TextInput::make('utm_content')
|
|
||||||
->label(__('filament-short-url::default.utm_content'))
|
|
||||||
->placeholder(__('filament-short-url::default.utm_content_placeholder'))
|
|
||||||
->prefixIcon('heroicon-m-document-text')
|
|
||||||
->dehydrated(false)
|
|
||||||
->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()
|
|
||||||
->schema([
|
|
||||||
TextInput::make('ga_tracking_id')
|
TextInput::make('ga_tracking_id')
|
||||||
->label(__('filament-short-url::default.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'))
|
->hintIcon('heroicon-m-information-circle', tooltip: __('filament-short-url::default.ga_tracking_id_helper'))
|
||||||
->placeholder('G-XXXXXXXXXX')
|
->placeholder('G-XXXXXXXXXX')
|
||||||
->prefixIcon('heroicon-m-chart-bar')
|
->prefixIcon('heroicon-m-chart-bar')
|
||||||
->regex('/^G-[A-Z0-9]+$/')
|
->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}"),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,19 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Tabs;
|
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\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Group;
|
use Filament\Schemas\Components\Group;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
use Filament\Schemas\Components\Utilities\Set;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class ValidityAndLimitsTab
|
class ValidityAndLimitsTab
|
||||||
{
|
{
|
||||||
@@ -28,79 +33,208 @@ class ValidityAndLimitsTab
|
|||||||
->icon('heroicon-o-clock')
|
->icon('heroicon-o-clock')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make(__('filament-short-url::default.expiration_dates_section_title'))
|
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')
|
->icon('heroicon-o-calendar-days')
|
||||||
->aside()
|
->contained(false)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('use_date_validity')
|
Section::make()
|
||||||
->label(__('filament-short-url::default.use_date_validity'))
|
->contained(false)
|
||||||
->dehydrated(false)
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->live()
|
->schema([
|
||||||
->afterStateHydrated(function (Toggle $component, $state, Get $get, Set $set) {
|
Grid::make(['default' => 1, 'md' => 12])
|
||||||
$set('use_date_validity', $get('activated_at') !== null || $get('expires_at') !== null);
|
->extraAttributes(['class' => 'validity-tab-card-toolbar-grid'])
|
||||||
})
|
->schema([
|
||||||
->afterStateUpdated(function ($state, Set $set) {
|
Placeholder::make('schedule_card_header')
|
||||||
if ($state) {
|
->hiddenLabel()
|
||||||
$set('activated_at', now()->startOfMinute());
|
->content(TabCardHeader::make(
|
||||||
} else {
|
'heroicon-o-calendar-days',
|
||||||
$set('activated_at', null);
|
'validity-tab-card-icon--schedule',
|
||||||
$set('expires_at', null);
|
'validity_schedule_card_title',
|
||||||
$set('expiration_redirect_url', null);
|
'validity_schedule_card_subtitle',
|
||||||
}
|
))
|
||||||
}),
|
->columnSpan(['default' => 12, 'md' => 9]),
|
||||||
|
|
||||||
Group::make([
|
Toggle::make('use_date_validity')
|
||||||
DateTimePicker::make('activated_at')
|
->label(__('filament-short-url::default.use_date_validity'))
|
||||||
->label(__('filament-short-url::default.activated_at'))
|
->hiddenLabel()
|
||||||
->prefixIcon('heroicon-m-play-circle')
|
->dehydrated(false)
|
||||||
->nullable()
|
->live()
|
||||||
->native(false)
|
->inline(false)
|
||||||
->withoutSeconds()
|
->extraFieldWrapperAttributes([
|
||||||
->live(onBlur: true)
|
'class' => 'validity-tab-card-toolbar-action',
|
||||||
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
])
|
||||||
->minDate(now()->startOfDay())
|
->extraAttributes([
|
||||||
->maxDate(fn (Get $get) => $get('expires_at')),
|
'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);
|
||||||
|
})
|
||||||
|
->afterStateUpdated(function ($state, Set $set) {
|
||||||
|
if ($state) {
|
||||||
|
$set('activated_at', now()->startOfMinute());
|
||||||
|
} else {
|
||||||
|
$set('activated_at', null);
|
||||||
|
$set('expires_at', null);
|
||||||
|
$set('expiration_redirect_url', null);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
|
||||||
DateTimePicker::make('expires_at')
|
Placeholder::make('schedule_empty_state')
|
||||||
->label(__('filament-short-url::default.expires_at'))
|
->hiddenLabel()
|
||||||
->prefixIcon('heroicon-m-stop-circle')
|
->visible(fn (Get $get): bool => ! $get('use_date_validity'))
|
||||||
->nullable()
|
->content(new HtmlString(
|
||||||
->native(false)
|
'<div class="validity-tab-empty">'.
|
||||||
->withoutSeconds()
|
'<div class="validity-tab-empty-icon">'.
|
||||||
->live(onBlur: true)
|
'<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>'.
|
||||||
->minDate(fn (Get $get) => $get('activated_at') ?: now()->startOfDay()),
|
'</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>'
|
||||||
|
)),
|
||||||
|
|
||||||
TextInput::make('expiration_redirect_url')
|
Group::make()
|
||||||
->label(__('filament-short-url::default.expiration_redirect_url'))
|
->visible(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.expiration_redirect_url_helper'))
|
->extraAttributes(['class' => 'validity-schedule-panel'])
|
||||||
->url()
|
->schema([
|
||||||
->maxLength(2048)
|
Placeholder::make('schedule_timeline_hint')
|
||||||
->nullable()
|
->hiddenLabel()
|
||||||
->columnSpanFull(),
|
->content(new HtmlString(
|
||||||
])
|
'<div class="validity-timeline-hint">'.
|
||||||
->columns(2)
|
'<span class="validity-timeline-dot validity-timeline-dot--start"></span>'.
|
||||||
->visible(fn (Get $get): bool => (bool) $get('use_date_validity')),
|
'<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)
|
||||||
|
->withoutSeconds()
|
||||||
|
->live(onBlur: true)
|
||||||
|
->required(fn (Get $get): bool => (bool) $get('use_date_validity'))
|
||||||
|
->minDate(now()->startOfDay())
|
||||||
|
->maxDate(fn (Get $get) => $get('expires_at')),
|
||||||
|
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label(__('filament-short-url::default.expires_at'))
|
||||||
|
->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(),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make(__('filament-short-url::default.visit_limits_section_title'))
|
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')
|
->icon('heroicon-o-cursor-arrow-rays')
|
||||||
->aside()
|
->contained(false)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('single_use')
|
Section::make()
|
||||||
->label(__('filament-short-url::default.single_use'))
|
->contained(false)
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.single_use_helper'))
|
->extraAttributes(['class' => 'validity-tab-card'])
|
||||||
->default(false)
|
->schema([
|
||||||
->live(),
|
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]),
|
||||||
|
|
||||||
TextInput::make('max_visits')
|
Toggle::make('single_use')
|
||||||
->label(__('filament-short-url::default.max_visits'))
|
->label(__('filament-short-url::default.single_use'))
|
||||||
->hintIcon('heroicon-o-information-circle', tooltip: __('filament-short-url::default.max_visits_helper'))
|
->hiddenLabel()
|
||||||
->numeric()
|
->default(false)
|
||||||
->integer()
|
->live()
|
||||||
->minValue(1)
|
->inline(false)
|
||||||
->nullable()
|
->extraFieldWrapperAttributes([
|
||||||
->hidden(fn (Get $get): bool => (bool) $get('single_use')),
|
'class' => 'validity-tab-card-toolbar-action',
|
||||||
|
])
|
||||||
|
->extraAttributes([
|
||||||
|
'aria-label' => __('filament-short-url::default.single_use'),
|
||||||
|
])
|
||||||
|
->columnSpan(['default' => 12, 'md' => 3]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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()
|
||||||
|
->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>'
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
|
namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Tables;
|
||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource;
|
||||||
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Schemas\Support\PasswordOpenGraphGuard;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
|
use Bjanczak\FilamentShortUrl\Support\LockedUrlKeyGuard;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
@@ -12,6 +14,7 @@ use Filament\Actions\DeleteAction;
|
|||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Tables\Columns\Layout\Stack;
|
use Filament\Tables\Columns\Layout\Stack;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
@@ -84,6 +87,11 @@ class ShortUrlsTable
|
|||||||
->stickyModalHeader()
|
->stickyModalHeader()
|
||||||
->modalCancelAction(false)
|
->modalCancelAction(false)
|
||||||
->extraModalWindowAttributes(['class' => 'update-link-modal modal-fsl'])
|
->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 => [
|
->modalFooterActions(static fn (EditAction $action): array => [
|
||||||
Action::make('save_changes')
|
Action::make('save_changes')
|
||||||
->label(__('filament-actions::edit.single.modal.actions.save.label'))
|
->label(__('filament-actions::edit.single.modal.actions.save.label'))
|
||||||
@@ -111,6 +119,10 @@ class ShortUrlsTable
|
|||||||
$record = $action->getRecord();
|
$record = $action->getRecord();
|
||||||
$livewire = $action->getLivewire();
|
$livewire = $action->getLivewire();
|
||||||
$state = $livewire->getMountedTableActionForm()->getState();
|
$state = $livewire->getMountedTableActionForm()->getState();
|
||||||
|
$state = LockedUrlKeyGuard::sanitizeSaveData(
|
||||||
|
PasswordOpenGraphGuard::sanitizeSaveData($state),
|
||||||
|
$record,
|
||||||
|
);
|
||||||
|
|
||||||
$action->process(function () use ($record, $state) {
|
$action->process(function () use ($record, $state) {
|
||||||
$record->update($state);
|
$record->update($state);
|
||||||
@@ -158,6 +170,61 @@ class ShortUrlsTable
|
|||||||
->icon('heroicon-o-chart-bar')
|
->icon('heroicon-o-chart-bar')
|
||||||
->url(fn (ShortUrl $record): string => ShortUrlResource::getUrl('stats', ['record' => $record])),
|
->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([
|
ActionGroup::make([
|
||||||
Action::make('move')
|
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>'))
|
->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>'))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
|||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||||
|
use Bjanczak\FilamentShortUrl\Services\LiveFeedBroadcaster;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
class ShortUrlLiveFeedWidget extends Widget
|
class ShortUrlLiveFeedWidget extends Widget
|
||||||
@@ -27,7 +28,27 @@ class ShortUrlLiveFeedWidget extends Widget
|
|||||||
protected string $view = 'filament-short-url::widgets.live-feed';
|
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.
|
* Does a single O(1) MAX(id) query against the composite index.
|
||||||
* If nothing changed since the last render → skipRender() → ~100-byte response.
|
* If nothing changed since the last render → skipRender() → ~100-byte response.
|
||||||
@@ -61,7 +82,10 @@ class ShortUrlLiveFeedWidget extends Widget
|
|||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
if (! $this->record) {
|
if (! $this->record) {
|
||||||
return ['visits' => []];
|
return [
|
||||||
|
'visits' => [],
|
||||||
|
'usesRedisPush' => LiveFeedBroadcaster::usesRedisPush(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// BUG FIX #1: Include latestVisitId in the cache key.
|
// BUG FIX #1: Include latestVisitId in the cache key.
|
||||||
@@ -132,6 +156,6 @@ class ShortUrlLiveFeedWidget extends Widget
|
|||||||
->max('id');
|
->max('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['visits' => $visits];
|
return ['visits' => $visits, 'usesRedisPush' => LiveFeedBroadcaster::usesRedisPush()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,63 +43,21 @@ class ShortUrlSecurityBreakdownWidget extends Widget
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalClicks = 0;
|
$stats = $this->record->getSecurityBreakdownStats(
|
||||||
$humanClicks = 0;
|
dateFrom: $this->dateFrom,
|
||||||
$botClicks = 0;
|
dateTo: $this->dateTo,
|
||||||
$humanPercentage = 0;
|
filters: $this->filters,
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'activeTab' => $this->activeTab,
|
'activeTab' => $this->activeTab,
|
||||||
'totalClicks' => $totalClicks,
|
'totalClicks' => $stats['totalClicks'],
|
||||||
'humanClicks' => $humanClicks,
|
'humanClicks' => $stats['humanClicks'],
|
||||||
'botClicks' => $botClicks,
|
'botClicks' => $stats['botClicks'],
|
||||||
'humanPercentage' => $humanPercentage,
|
'humanPercentage' => $stats['humanPercentage'],
|
||||||
'botPercentage' => $botPercentage,
|
'botPercentage' => $stats['botPercentage'],
|
||||||
'proxyClicks' => $proxyClicks,
|
'proxyClicks' => $stats['proxyClicks'],
|
||||||
'proxyPercentage' => $proxyPercentage,
|
'proxyPercentage' => $stats['proxyPercentage'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
|||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
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 Filament\Widgets\ChartWidget;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -118,10 +122,10 @@ class ShortUrlVisitsChart extends ChartWidget
|
|||||||
|
|
||||||
if (empty($filters) && $granularity !== 'hourly') {
|
if (empty($filters) && $granularity !== 'hourly') {
|
||||||
$dailyStatsQuery = DB::table('short_url_daily_stats')
|
$dailyStatsQuery = DB::table('short_url_daily_stats')
|
||||||
->where('short_url_id', $this->record->id)
|
->where('short_url_id', $this->record->id);
|
||||||
->where('date', '>=', $dateFromClean)
|
|
||||||
->where('date', '<=', $dateToClean)
|
StatsSqlHelper::applyDailyStatsDateRange($dailyStatsQuery, $dateFromClean, $dateToClean);
|
||||||
->orderBy('date', 'asc');
|
$dailyStatsQuery->orderBy('date', 'asc');
|
||||||
|
|
||||||
$col = match ($metric) {
|
$col = match ($metric) {
|
||||||
'unique' => 'unique_visits_count',
|
'unique' => 'unique_visits_count',
|
||||||
@@ -147,19 +151,34 @@ class ShortUrlVisitsChart extends ChartWidget
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dateToClean >= $today) {
|
if ($dateToClean >= $today) {
|
||||||
$todayRawQuery = DB::table('short_url_visits')
|
$todayCount = 0;
|
||||||
->where('short_url_id', $this->record->id)
|
$profile = app(StatsScalingProfile::class);
|
||||||
->where('visited_at', '>=', $today.' 00:00:00')
|
|
||||||
->where('is_bot', false)
|
|
||||||
->where('is_proxy', false);
|
|
||||||
|
|
||||||
$aggregateExpression = match ($metric) {
|
if (empty($filters) && $profile->usesRedisTodayBuffer()) {
|
||||||
'unique' => 'COUNT(DISTINCT ip_hash)',
|
$redisHourly = app(TodayStatsBuffer::class)->getHourlyTotals(
|
||||||
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
(int) $this->record->id,
|
||||||
default => 'COUNT(*)',
|
Carbon::parse($today)->startOfDay(),
|
||||||
};
|
Carbon::parse($today)->endOfDay(),
|
||||||
|
$metric,
|
||||||
|
);
|
||||||
|
$todayCount = array_sum($redisHourly);
|
||||||
|
}
|
||||||
|
|
||||||
$todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt');
|
if ($todayCount === 0) {
|
||||||
|
$todayRawQuery = DB::table('short_url_visits')
|
||||||
|
->where('short_url_id', $this->record->id)
|
||||||
|
->where('visited_at', '>=', $today.' 00:00:00')
|
||||||
|
->where('is_bot', false)
|
||||||
|
->where('is_proxy', false);
|
||||||
|
|
||||||
|
$aggregateExpression = match ($metric) {
|
||||||
|
'unique' => 'COUNT(DISTINCT ip_hash)',
|
||||||
|
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
||||||
|
default => 'COUNT(*)',
|
||||||
|
};
|
||||||
|
|
||||||
|
$todayCount = (int) $todayRawQuery->selectRaw("{$aggregateExpression} as cnt")->value('cnt');
|
||||||
|
}
|
||||||
|
|
||||||
$carbonToday = Carbon::parse($today);
|
$carbonToday = Carbon::parse($today);
|
||||||
if ($granularity === 'weekly') {
|
if ($granularity === 'weekly') {
|
||||||
@@ -174,65 +193,177 @@ class ShortUrlVisitsChart extends ChartWidget
|
|||||||
$buckets[$todayBucket] += $todayCount;
|
$buckets[$todayBucket] += $todayCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} elseif (! empty($filters) && $granularity !== 'hourly' && $metric === 'total') {
|
||||||
$query = DB::table('short_url_visits')
|
$stats = $this->record->getCachedStats(
|
||||||
->where('short_url_id', $this->record->id)
|
dateFrom: $dateFromClean,
|
||||||
->where('visited_at', '>=', $dateFromClean.' 00:00:00')
|
dateTo: $dateToClean,
|
||||||
->where('visited_at', '<=', $dateToClean.' 23:59:59')
|
filters: $filters,
|
||||||
->where('is_bot', false)
|
);
|
||||||
->where('is_proxy', false);
|
|
||||||
|
|
||||||
$this->record->applyStatsFilters($query, $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', '>=', $effectiveFrom.' 00:00:00')
|
||||||
|
->where('visited_at', '<=', $dateToClean.' 23:59:59')
|
||||||
|
->where('is_bot', false)
|
||||||
|
->where('is_proxy', false);
|
||||||
|
|
||||||
|
if ($useRedisToday) {
|
||||||
|
$query->where('visited_at', '<', $today.' 00:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
$aggregateExpression = match ($metric) {
|
||||||
|
'unique' => 'COUNT(DISTINCT ip_hash)',
|
||||||
|
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
||||||
|
default => 'COUNT(*)',
|
||||||
|
};
|
||||||
|
|
||||||
$dateExpression = '';
|
|
||||||
if ($granularity === 'hourly') {
|
|
||||||
$dateExpression = match ($driver) {
|
$dateExpression = match ($driver) {
|
||||||
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
|
'sqlite' => "strftime('%Y-%m-%d %H:00', visited_at)",
|
||||||
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
|
'pgsql' => "to_char(visited_at, 'YYYY-MM-DD HH24:00')",
|
||||||
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
|
default => "DATE_FORMAT(visited_at, '%Y-%m-%d %H:00')",
|
||||||
};
|
};
|
||||||
} elseif ($granularity === 'weekly') {
|
|
||||||
$dateExpression = match ($driver) {
|
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
|
||||||
'sqlite' => "strftime('%Y-%W', visited_at)",
|
->groupBy('time_bucket')
|
||||||
'pgsql' => "to_char(visited_at, 'IYYY-IW')",
|
->pluck('count', 'time_bucket')
|
||||||
default => 'YEARWEEK(visited_at, 3)',
|
->toArray();
|
||||||
};
|
|
||||||
} elseif ($granularity === 'monthly') {
|
foreach ($rows as $bucket => $count) {
|
||||||
$dateExpression = match ($driver) {
|
if (array_key_exists($bucket, $buckets)) {
|
||||||
'sqlite' => "strftime('%Y-%m', visited_at)",
|
$buckets[$bucket] = (int) $count;
|
||||||
'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) {
|
||||||
|
$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) {
|
$aggregateExpression = match ($metric) {
|
||||||
'unique' => 'COUNT(DISTINCT ip_hash)',
|
'unique' => 'COUNT(DISTINCT ip_hash)',
|
||||||
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
'qr' => 'SUM(CASE WHEN is_qr_scan = 1 THEN 1 ELSE 0 END)',
|
||||||
default => 'COUNT(*)',
|
default => 'COUNT(*)',
|
||||||
};
|
};
|
||||||
|
|
||||||
$rows = $query->select(DB::raw("{$dateExpression} as time_bucket"), DB::raw("{$aggregateExpression} as count"))
|
$weeklyHandled = false;
|
||||||
->groupBy('time_bucket')
|
|
||||||
->pluck('count', 'time_bucket')
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
foreach ($rows as $bucket => $count) {
|
if ($granularity === 'weekly' && $driver === 'sqlite') {
|
||||||
if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') {
|
$uniqueHashes = [];
|
||||||
if (strlen($bucket) === 6) {
|
|
||||||
$year = substr($bucket, 0, 4);
|
foreach ($query->select(['visited_at', 'ip_hash', 'is_qr_scan'])->cursor() as $row) {
|
||||||
$week = substr($bucket, 4, 2);
|
$bucketKey = Carbon::parse($row->visited_at)->format('o-W');
|
||||||
$bucket = "{$year}-{$week}";
|
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]++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists($bucket, $buckets)) {
|
$weeklyHandled = true;
|
||||||
$buckets[$bucket] = (int) $count;
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as $bucket => $count) {
|
||||||
|
if ($granularity === 'weekly' && $driver !== 'sqlite' && $driver !== 'pgsql') {
|
||||||
|
if (strlen((string) $bucket) === 6) {
|
||||||
|
$year = substr((string) $bucket, 0, 4);
|
||||||
|
$week = substr((string) $bucket, 4, 2);
|
||||||
|
$bucket = "{$year}-{$week}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($bucket, $buckets)) {
|
||||||
|
$buckets[$bucket] = (int) $count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,18 +388,16 @@ class ShortUrlVisitsChart extends ChartWidget
|
|||||||
$granularity = $this->filter ?: 'daily';
|
$granularity = $this->filter ?: 'daily';
|
||||||
$filtersHash = md5(json_encode($this->filters));
|
$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;
|
$cacheKey = "short_url_chart_data_{$this->record->id}_".$start->toDateString().'_'.$end->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
|
||||||
$this->record->registerCacheKey($cacheKey);
|
$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);
|
return $this->getTimelineData($start, $end, $this->activeMetric, $granularity, $this->filters);
|
||||||
});
|
});
|
||||||
|
|
||||||
$currentValues = $chartData['data'] ?? [];
|
$currentValues = $chartData['data'] ?? [];
|
||||||
$labels = $chartData['labels'] ?? [];
|
$labels = $chartData['labels'] ?? [];
|
||||||
|
|
||||||
// Dual-line Chart data generation: Query stats for previous period
|
|
||||||
$diffInDays = $start->diffInDays($end) + 1;
|
$diffInDays = $start->diffInDays($end) + 1;
|
||||||
$prevStart = $start->copy()->subDays($diffInDays);
|
$prevStart = $start->copy()->subDays($diffInDays);
|
||||||
$prevEnd = $start->copy()->subDay();
|
$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;
|
$prevCacheKey = "short_url_chart_data_{$this->record->id}_".$prevStart->toDateString().'_'.$prevEnd->toDateString().'_'.$granularity.'_'.$filtersHash.'_'.$this->activeMetric;
|
||||||
$this->record->registerCacheKey($prevCacheKey);
|
$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);
|
return $this->getTimelineData($prevStart, $prevEnd, $this->activeMetric, $granularity, $this->filters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets;
|
|||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\Concerns\HasStatsFilters;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ShortUrlWorldMapWidget extends Widget
|
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>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
@@ -39,45 +36,21 @@ class ShortUrlWorldMapWidget extends Widget
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null;
|
$stats = $this->record->getCachedStats(
|
||||||
$dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null;
|
dateFrom: $this->dateFrom,
|
||||||
$today = Carbon::today()->toDateString();
|
dateTo: $this->dateTo,
|
||||||
|
filters: $this->filters,
|
||||||
|
);
|
||||||
|
|
||||||
$filtersHash = md5(json_encode($this->filters));
|
$countries = $stats['visitsByCountry'] ?? [];
|
||||||
$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;
|
|
||||||
|
|
||||||
return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) {
|
if (! is_array($countries)) {
|
||||||
$counts = [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$query = ShortUrlVisit::query()
|
arsort($countries);
|
||||||
->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) {
|
return $countries;
|
||||||
$query->where('visited_at', '>=', $dateFromClean.' 00:00:00');
|
|
||||||
}
|
|
||||||
if ($dateToClean) {
|
|
||||||
$query->where('visited_at', '<=', $dateToClean.' 23:59:59');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->record->applyStatsFilters($query, $this->filters);
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,21 +62,27 @@ class ShortUrlWorldMapWidget extends Widget
|
|||||||
$max = max(1, ...array_values($countryData) ?: [1]);
|
$max = max(1, ...array_values($countryData) ?: [1]);
|
||||||
$total = array_sum($countryData);
|
$total = array_sum($countryData);
|
||||||
|
|
||||||
// Normalise to 0–100 for CSS opacity/intensity
|
|
||||||
$normalized = [];
|
$normalized = [];
|
||||||
foreach ($countryData as $code => $count) {
|
foreach ($countryData as $code => $count) {
|
||||||
$normalized[$code] = round(($count / $max) * 100);
|
$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';
|
$svgPath = dirname(__FILE__, 6).'/resources/views/widgets/world-map.svg';
|
||||||
$svgContent = '';
|
$svgContent = cache()->remember('filament-short-url:world-map-svg', 86400, function () use ($svgPath): string {
|
||||||
if (file_exists($svgPath)) {
|
if (! file_exists($svgPath)) {
|
||||||
$svgContent = file_get_contents($svgPath);
|
return '';
|
||||||
// Remove XML declaration and DOCTYPE tags
|
}
|
||||||
$svgContent = preg_replace('/<\?xml[^>]*\?>/i', '', $svgContent);
|
|
||||||
$svgContent = preg_replace('/<!DOCTYPE[^>]*>/i', '', $svgContent);
|
$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 [
|
return [
|
||||||
'countryData' => $countryData,
|
'countryData' => $countryData,
|
||||||
|
|||||||
@@ -10,20 +10,33 @@ namespace Bjanczak\FilamentShortUrl;
|
|||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Assets\ShortUrlCss;
|
use Bjanczak\FilamentShortUrl\Assets\ShortUrlCss;
|
||||||
use Bjanczak\FilamentShortUrl\Assets\ShortUrlJs;
|
use Bjanczak\FilamentShortUrl\Assets\ShortUrlJs;
|
||||||
|
use Bjanczak\FilamentShortUrl\Console\Commands\StressRedirectCommand;
|
||||||
use Bjanczak\FilamentShortUrl\Console\Commands\SyncBufferedCountersCommand;
|
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\GeoIpService;
|
||||||
use Bjanczak\FilamentShortUrl\Services\OgImageImporter;
|
use Bjanczak\FilamentShortUrl\Services\OgImageImporter;
|
||||||
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
use Bjanczak\FilamentShortUrl\Services\OgImageProcessor;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ProxyDetectionService;
|
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\SafeBrowsingService;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlTracker;
|
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\UrlMetaScraper;
|
||||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
||||||
|
use Bjanczak\FilamentShortUrl\Services\VisitCounterBuffer;
|
||||||
use BladeUI\Icons\Factory;
|
use BladeUI\Icons\Factory;
|
||||||
use Filament\Support\Facades\FilamentAsset;
|
use Filament\Support\Facades\FilamentAsset;
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Spatie\LaravelPackageTools\Package;
|
use Spatie\LaravelPackageTools\Package;
|
||||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
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_05_000003_add_custom_domain_id_to_short_urls_table',
|
||||||
'2026_06_06_000001_create_short_url_folders_and_tags_tables',
|
'2026_06_06_000001_create_short_url_folders_and_tags_tables',
|
||||||
'2026_06_06_000002_add_og_metadata_to_short_urls_table',
|
'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([
|
->hasCommands([
|
||||||
SyncBufferedCountersCommand::class,
|
SyncBufferedCountersCommand::class,
|
||||||
Console\Commands\AggregateAndPruneVisitsCommand::class,
|
Console\Commands\AggregateAndPruneVisitsCommand::class,
|
||||||
|
VerifyCustomDomainsCommand::class,
|
||||||
|
StressRedirectCommand::class,
|
||||||
])
|
])
|
||||||
->hasRoutes(['web']);
|
->hasRoutes(['web']);
|
||||||
}
|
}
|
||||||
@@ -64,6 +83,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
|||||||
// Bind services as singletons for efficient reuse
|
// Bind services as singletons for efficient reuse
|
||||||
$this->app->singleton(UserAgentParser::class);
|
$this->app->singleton(UserAgentParser::class);
|
||||||
$this->app->singleton(GeoIpService::class);
|
$this->app->singleton(GeoIpService::class);
|
||||||
|
$this->app->singleton(Ga4MeasurementProtocolService::class);
|
||||||
$this->app->singleton(UrlMetaScraper::class);
|
$this->app->singleton(UrlMetaScraper::class);
|
||||||
$this->app->singleton(OgImageProcessor::class);
|
$this->app->singleton(OgImageProcessor::class);
|
||||||
$this->app->singleton(OgImageImporter::class);
|
$this->app->singleton(OgImageImporter::class);
|
||||||
@@ -71,6 +91,13 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
|||||||
$this->app->singleton(ShortUrlTracker::class);
|
$this->app->singleton(ShortUrlTracker::class);
|
||||||
$this->app->singleton(ProxyDetectionService::class);
|
$this->app->singleton(ProxyDetectionService::class);
|
||||||
$this->app->singleton(SafeBrowsingService::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) {
|
$this->callAfterResolving(Factory::class, function (Factory $factory) {
|
||||||
$factory->add('filament-short-url', [
|
$factory->add('filament-short-url', [
|
||||||
@@ -82,7 +109,10 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider
|
|||||||
|
|
||||||
public function packageBooted(): void
|
public function packageBooted(): void
|
||||||
{
|
{
|
||||||
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
|
$this->app->terminating(function (): void {
|
||||||
|
GeoIpService::flush();
|
||||||
|
ShortUrl::flushBufferedCounterMemory();
|
||||||
|
});
|
||||||
|
|
||||||
FilamentAsset::register([
|
FilamentAsset::register([
|
||||||
ShortUrlCss::make('filament-short-url', __DIR__.'/../resources/dist/filament-short-url.css'),
|
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
|
// Automatically register scheduled tasks in the application scheduler
|
||||||
$this->app->booted(function (): void {
|
$this->app->booted(function (): void {
|
||||||
|
if (! Gate::getPolicyFor(ShortUrl::class)) {
|
||||||
|
Gate::policy(ShortUrl::class, ShortUrlPolicy::class);
|
||||||
|
}
|
||||||
|
|
||||||
$schedule = $this->app->make(Schedule::class);
|
$schedule = $this->app->make(Schedule::class);
|
||||||
|
|
||||||
$schedule->command('short-url:aggregate-and-prune')->dailyAt('02:00');
|
$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)) {
|
if (config('filament-short-url.counter_buffering.enabled', false)) {
|
||||||
$schedule->command('short-url:sync-counters')->everyMinute();
|
$schedule->command('short-url:sync-counters')->everyMinute();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->app->make(ShortUrlSettingsManager::class)->applyConfigOverrides();
|
||||||
|
|
||||||
|
parent::boot();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,37 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
|||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
|
use Bjanczak\FilamentShortUrl\Http\Requests\StoreShortUrlRequest;
|
||||||
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
|
use Bjanczak\FilamentShortUrl\Http\Requests\UpdateShortUrlRequest;
|
||||||
|
use Bjanczak\FilamentShortUrl\Http\Requests\UpsertShortUrlRequest;
|
||||||
use Bjanczak\FilamentShortUrl\Http\Resources\ShortUrlResource;
|
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\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlVisit;
|
||||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class ShortUrlApiController extends Controller
|
class ShortUrlApiController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ShortUrlService $service,
|
private readonly ShortUrlService $service,
|
||||||
private readonly SafeBrowsingService $safeBrowsing,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of short URLs.
|
* 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();
|
return ShortUrlResource::collection($links)->response();
|
||||||
}
|
}
|
||||||
@@ -40,31 +50,172 @@ class ShortUrlApiController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(StoreShortUrlRequest $request): JsonResponse
|
public function store(StoreShortUrlRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$shortUrl = $this->persistLink($request, $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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (new ShortUrlResource($shortUrl))
|
return (new ShortUrlResource($shortUrl))
|
||||||
->additional(['message' => 'Short URL created successfully.'])
|
->additional(['message' => __('filament-short-url::default.api_created')])
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->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.
|
* 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();
|
return (new ShortUrlResource($shortUrl))->response();
|
||||||
}
|
}
|
||||||
@@ -74,33 +225,40 @@ class ShortUrlApiController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function stats(Request $request, string|int $idOrKey): JsonResponse
|
public function stats(Request $request, string|int $idOrKey): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate(array_merge([
|
||||||
'date_from' => 'nullable|date_format:Y-m-d',
|
'date_from' => 'nullable|date_format:Y-m-d',
|
||||||
'date_to' => 'nullable|date_format:Y-m-d|after_or_equal:date_from',
|
'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(
|
$stats = $shortUrl->getCachedStats(
|
||||||
dateFrom: $request->query('date_from'),
|
dateFrom: $request->query('date_from'),
|
||||||
dateTo: $request->query('date_to')
|
dateTo: $request->query('date_to'),
|
||||||
|
filters: $filters,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $stats,
|
'data' => $stats,
|
||||||
|
'meta' => [
|
||||||
|
'date_from' => $request->query('date_from'),
|
||||||
|
'date_to' => $request->query('date_to'),
|
||||||
|
'filters' => $filters,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified short URL.
|
* 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();
|
$shortUrl->delete();
|
||||||
|
|
||||||
return response()->json([
|
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();
|
$validated = $request->validated();
|
||||||
|
|
||||||
$pixelIds = $validated['pixels'] ?? null;
|
$pixelIds = $validated['pixels'] ?? null;
|
||||||
unset($validated['pixels']);
|
$tagIds = $validated['tag_ids'] ?? null;
|
||||||
|
unset($validated['pixels'], $validated['tag_ids']);
|
||||||
|
|
||||||
$shortUrl->update($validated);
|
DB::transaction(function () use ($shortUrl, $validated, $pixelIds, $tagIds): void {
|
||||||
|
$shortUrl->update($validated);
|
||||||
|
|
||||||
if ($pixelIds !== null) {
|
if ($pixelIds !== null) {
|
||||||
$shortUrl->pixels()->sync($pixelIds);
|
$shortUrl->pixels()->sync($pixelIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new ShortUrlResource($shortUrl->fresh()))
|
if ($tagIds !== null) {
|
||||||
->additional(['message' => 'Short URL updated successfully.'])
|
$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();
|
->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)) {
|
$request->validate([
|
||||||
return ShortUrl::findOrFail((int) $idOrKey);
|
'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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$link = ShortUrl::where('url_key', $idOrKey)->first();
|
$links = $query->with('customDomain')->get();
|
||||||
|
|
||||||
if (! $link) {
|
foreach ($links as $link) {
|
||||||
abort(404, 'Short URL not found.');
|
$link->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $link;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/Http/Controllers/ShortUrlFolderApiController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/Http/Controllers/ShortUrlLiveFeedStreamController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,8 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
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\Routing\Controller;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
|||||||
230
src/Http/Controllers/ShortUrlPublicStatsController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||||
|
|
||||||
use Bjanczak\FilamentShortUrl\Jobs\TrackShortUrlVisitJob;
|
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
|
||||||
use Bjanczak\FilamentShortUrl\Services\AppLinkingEngine;
|
|
||||||
use Bjanczak\FilamentShortUrl\Services\ClientIpExtractor;
|
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\ShortUrlService;
|
||||||
use Bjanczak\FilamentShortUrl\Services\UserAgentParser;
|
use Bjanczak\FilamentShortUrl\Support\HostNormalizer;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\MessageBag;
|
use Illuminate\Support\MessageBag;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -21,254 +19,42 @@ class ShortUrlRedirectController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ShortUrlService $service,
|
private readonly ShortUrlService $service,
|
||||||
private readonly ProxyDetectionService $proxyDetector,
|
private readonly ShortUrlRedirectHandler $redirectHandler,
|
||||||
private readonly UserAgentParser $uaParser,
|
private readonly RobotsTagApplicator $robotsTagApplicator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request, ?string $key = null): Response
|
public function __invoke(Request $request, ?string $key = null): Response
|
||||||
{
|
{
|
||||||
if (empty($key)) {
|
if ($legacyRedirect = $this->redirectHandler->resolveLegacyPrefixedRedirect($request)) {
|
||||||
$key = $request->path();
|
return $legacyRedirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = $request->getHost();
|
$key = $this->redirectHandler->resolveKey($request, $key);
|
||||||
$mainDomain = parse_url(config('app.url'), PHP_URL_HOST);
|
$shortUrl = $this->redirectHandler->findShortUrl($request, $key);
|
||||||
$isCustomDomain = $host && strcasecmp($host, $mainDomain) !== 0;
|
|
||||||
|
|
||||||
if ($isCustomDomain) {
|
if ($inactive = $this->redirectHandler->respondIfInactive($shortUrl)) {
|
||||||
// Use a short-lived cache shared with ShortUrl::findByKey() to avoid two
|
return $inactive;
|
||||||
// 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 (str_contains($key, '/')) {
|
$this->redirectHandler->enforceAccessGuards($key, $request);
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$shortUrl = ShortUrl::findByKey($key, $host);
|
if ($shortUrl->hasPassword()) {
|
||||||
|
|
||||||
// 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)) {
|
|
||||||
$queryString = $request->getQueryString();
|
$queryString = $request->getQueryString();
|
||||||
|
|
||||||
return redirect()->to(
|
return $this->robotsTagApplicator->applyToRedirect(
|
||||||
route('short-url.password-auth', ['key' => $key]).($queryString ? '?'.$queryString : ''),
|
$shortUrl,
|
||||||
302
|
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);
|
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||||
|
|
||||||
// App Linking / Deep Links Auto-Open Check
|
$this->redirectHandler->assertDestinationIsSafe($destination);
|
||||||
if ($shortUrl->auto_open_app_mobile) {
|
|
||||||
$deviceType = $this->uaParser->getDeviceType($request->userAgent() ?? '');
|
|
||||||
|
|
||||||
if ($deviceType === 'mobile' || $deviceType === 'tablet') {
|
return $this->redirectHandler->buildResponse($shortUrl, $request, $key, $destination);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,7 +126,7 @@ class ShortUrlRedirectController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Async endpoint to scrape metadata for a URL.
|
* 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');
|
$url = $request->query('url');
|
||||||
if (empty($url) || ! filter_var($url, FILTER_VALIDATE_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
|
public function handlePasswordAuth(Request $request, ?string $key = null): Response
|
||||||
{
|
{
|
||||||
if (empty($key)) {
|
$key = $this->redirectHandler->resolveKey($request, $key, passwordAuth: true);
|
||||||
$key = $request->path();
|
$shortUrl = ShortUrl::findByKey($key, HostNormalizer::normalize($request->getHost()));
|
||||||
$prefix = config('filament-short-url.route_prefix', 's').'-auth/';
|
|
||||||
if (str_starts_with($key, $prefix)) {
|
|
||||||
$key = substr($key, strlen($prefix));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$host = $request->getHost();
|
if (! $shortUrl || ! $shortUrl->hasPassword()) {
|
||||||
$shortUrl = ShortUrl::findByKey($key, $host);
|
|
||||||
|
|
||||||
if (! $shortUrl || empty($shortUrl->password)) {
|
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $shortUrl->isActive()) {
|
if ($inactive = $this->redirectHandler->respondIfInactive($shortUrl)) {
|
||||||
if ($shortUrl->isExpired() || ($shortUrl->deactivated_at && $shortUrl->deactivated_at->isPast())) {
|
return $inactive;
|
||||||
$expiredKey = "fsu:expired-webhook-sent:{$shortUrl->id}";
|
|
||||||
if (cache()->add($expiredKey, true, 86400 * 30)) {
|
|
||||||
$shortUrl->dispatchWebhook('expired');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($shortUrl->expiration_redirect_url && $shortUrl->is_enabled) {
|
|
||||||
return redirect()->away($shortUrl->expiration_redirect_url, 302);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(view('filament-short-url::expired', [
|
|
||||||
'shortUrl' => $shortUrl,
|
|
||||||
]), 410)->header('Content-Type', 'text/html');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VPN/Proxy & Bot Blocking Check
|
$this->redirectHandler->enforceAccessGuards($key, $request);
|
||||||
if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') {
|
|
||||||
$ipAddress = ClientIpExtractor::getIp($request);
|
|
||||||
$detection = $this->proxyDetector->detect($ipAddress);
|
|
||||||
if ($detection['is_proxy'] || $detection['is_bot']) {
|
|
||||||
abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate Limiting Check
|
|
||||||
if (config('filament-short-url.rate_limiting.enabled', false)) {
|
|
||||||
$maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60);
|
|
||||||
$decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60);
|
|
||||||
$ipAddress = ClientIpExtractor::getIp($request);
|
|
||||||
$limiterKey = "short_url_limit:{$key}:".$ipAddress;
|
|
||||||
|
|
||||||
if (RateLimiter::tooManyAttempts($limiterKey, $maxAttempts)) {
|
|
||||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
|
||||||
abort(429, 'Too many requests. Please try again in '.$retryAfter.' seconds.', [
|
|
||||||
'Retry-After' => $retryAfter,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
RateLimiter::hit($limiterKey, $decaySeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sessionKey = "short-url-auth-{$shortUrl->id}";
|
$sessionKey = "short-url-auth-{$shortUrl->id}";
|
||||||
|
|
||||||
if (! session()->get($sessionKey)) {
|
if (! session()->get($sessionKey)) {
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
// Password brute force protection
|
|
||||||
$ipAddress = ClientIpExtractor::getIp($request);
|
$ipAddress = ClientIpExtractor::getIp($request);
|
||||||
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
|
$passwordLimiterKey = "short_url_password_limit:{$key}:".$ipAddress;
|
||||||
|
|
||||||
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) { // Max 5 attempts
|
if (RateLimiter::tooManyAttempts($passwordLimiterKey, 5)) {
|
||||||
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
|
$retryAfter = RateLimiter::availableIn($passwordLimiterKey);
|
||||||
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
|
abort(429, 'Too many incorrect password attempts. Please try again in '.$retryAfter.' seconds.', [
|
||||||
'Retry-After' => $retryAfter,
|
'Retry-After' => $retryAfter,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$submitted = $request->input('password');
|
if ($shortUrl->verifyPassword((string) $request->input('password'))) {
|
||||||
if ($submitted === $shortUrl->password) {
|
|
||||||
session()->put($sessionKey, true);
|
session()->put($sessionKey, true);
|
||||||
RateLimiter::clear($passwordLimiterKey);
|
RateLimiter::clear($passwordLimiterKey);
|
||||||
|
|
||||||
// Redirect to the same URL to process final redirection steps
|
|
||||||
return redirect()->to($request->fullUrl());
|
return redirect()->to($request->fullUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::hit($passwordLimiterKey, 60); // 1 minute decay
|
RateLimiter::hit($passwordLimiterKey, 60);
|
||||||
|
|
||||||
$errors = new MessageBag([
|
return $this->robotsTagApplicator->apply(
|
||||||
'password' => __('filament-short-url::default.password_error'),
|
$shortUrl,
|
||||||
]);
|
response(view('filament-short-url::password-prompt', [
|
||||||
|
'errors' => new MessageBag([
|
||||||
return response(view('filament-short-url::password-prompt', ['errors' => $errors]))
|
'password' => __('filament-short-url::default.password_error'),
|
||||||
->header('Content-Type', 'text/html');
|
]),
|
||||||
|
]))->header('Content-Type', 'text/html'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(view('filament-short-url::password-prompt'))
|
return $this->robotsTagApplicator->apply(
|
||||||
->header('Content-Type', 'text/html');
|
$shortUrl,
|
||||||
|
response(view('filament-short-url::password-prompt'))
|
||||||
|
->header('Content-Type', 'text/html'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve Destination URL
|
|
||||||
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
$destination = $this->service->resolveRedirectUrl($shortUrl, $request);
|
||||||
|
|
||||||
// App Linking / Deep Links Auto-Open Check
|
$this->redirectHandler->assertDestinationIsSafe($destination);
|
||||||
if ($shortUrl->auto_open_app_mobile) {
|
|
||||||
$deviceType = $this->uaParser->getDeviceType($request->userAgent() ?? '');
|
|
||||||
|
|
||||||
if ($deviceType === 'mobile' || $deviceType === 'tablet') {
|
return $this->redirectHandler->buildResponse($shortUrl, $request, $key, $destination);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/Http/Controllers/ShortUrlTagApiController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Http/Controllers/ShortUrlUtilityController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ class AuthenticateShortUrlApi
|
|||||||
{
|
{
|
||||||
if (! config('filament-short-url.api_enabled', false)) {
|
if (! config('filament-short-url.api_enabled', false)) {
|
||||||
return response()->json([
|
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);
|
], 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class AuthenticateShortUrlApi
|
|||||||
|
|
||||||
if (empty($apiKey)) {
|
if (empty($apiKey)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Unauthorized. API Key is missing.',
|
'error' => __('filament-short-url::default.api_key_missing'),
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,35 +50,32 @@ class AuthenticateShortUrlApi
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check hashed key (new format) or fall back to plaintext key (legacy format)
|
// Accept only hashed key format.
|
||||||
$storedHash = $keyObj['hashed_key'] ?? null;
|
$storedHash = $keyObj['hashed_key'] ?? null;
|
||||||
if ($storedHash) {
|
if ($storedHash && hash_equals($storedHash, $hashedInput)) {
|
||||||
if (hash_equals($storedHash, $hashedInput)) {
|
$valid = true;
|
||||||
$valid = true;
|
$matchedKey = $keyObj;
|
||||||
$matchedKey = $keyObj;
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$storedPlain = $keyObj['key'] ?? '';
|
|
||||||
if ($storedPlain !== '' && hash_equals($storedPlain, $apiKey)) {
|
|
||||||
$valid = true;
|
|
||||||
$matchedKey = $keyObj;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $valid || ! $matchedKey) {
|
if (! $valid || ! $matchedKey) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Unauthorized. Invalid or inactive API Key.',
|
'error' => __('filament-short-url::default.api_key_invalid'),
|
||||||
], 401);
|
], 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)
|
// 1. API Scope Authorization (backward compatible with read-write)
|
||||||
$scope = $matchedKey['scope'] ?? 'links:read-write';
|
$scope = $matchedKey['scope'] ?? 'links:read-write';
|
||||||
if ($scope === 'links:read-only' && ! $request->isMethod('GET')) {
|
if ($scope === 'links:read-only' && ! $request->isMethod('GET')) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Forbidden. This API key has read-only permissions.',
|
'error' => __('filament-short-url::default.api_key_read_only'),
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +89,7 @@ class AuthenticateShortUrlApi
|
|||||||
$retryAfter = RateLimiter::availableIn($limiterKey);
|
$retryAfter = RateLimiter::availableIn($limiterKey);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Too many requests. API key rate limit exceeded.',
|
'error' => __('filament-short-url::default.api_rate_limit_exceeded'),
|
||||||
], 429, [
|
], 429, [
|
||||||
'Retry-After' => $retryAfter,
|
'Retry-After' => $retryAfter,
|
||||||
]);
|
]);
|
||||||
@@ -101,6 +98,8 @@ class AuthenticateShortUrlApi
|
|||||||
RateLimiter::hit($limiterKey, 60);
|
RateLimiter::hit($limiterKey, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('fsu_api_key', $matchedKey);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/Http/Requests/Concerns/ShortUrlApiAttributes.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Bartek Janczak <barek122@gmail.com>
|
||||||
|
* @copyright 2026 Bartek Janczak
|
||||||
|
* @license Custom Source-Available License (see LICENSE file)
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Bjanczak\FilamentShortUrl\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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,19 @@
|
|||||||
|
|
||||||
namespace Bjanczak\FilamentShortUrl\Http\Requests;
|
namespace Bjanczak\FilamentShortUrl\Http\Requests;
|
||||||
|
|
||||||
|
use Bjanczak\FilamentShortUrl\Http\Requests\Concerns\ShortUrlApiAttributes;
|
||||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
use Bjanczak\FilamentShortUrl\Models\ShortUrlCustomDomain;
|
||||||
|
use Bjanczak\FilamentShortUrl\Rules\OutboundUrl;
|
||||||
|
use Bjanczak\FilamentShortUrl\Rules\SafeUrl;
|
||||||
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService;
|
||||||
|
use Bjanczak\FilamentShortUrl\Support\CustomDomainValidator;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreShortUrlRequest extends FormRequest
|
class StoreShortUrlRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
use ShortUrlApiAttributes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the user is authorized to make this request.
|
* Determine if the user is authorized to make this request.
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +37,7 @@ class StoreShortUrlRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$safeBrowsing = app(SafeBrowsingService::class);
|
$safeBrowsing = app(SafeBrowsingService::class);
|
||||||
|
$safeUrlRule = app(SafeUrl::class);
|
||||||
|
|
||||||
$countries = __('filament-short-url::countries');
|
$countries = __('filament-short-url::countries');
|
||||||
$countryRule = is_array($countries) && ! empty($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))))
|
? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages))))
|
||||||
: 'string|max:10';
|
: 'string|max:10';
|
||||||
|
|
||||||
$safeBrowsingRule = function (string $attribute, $value, \Closure $fail) use ($safeBrowsing) {
|
$safeBrowsingRule = $safeUrlRule;
|
||||||
if (empty($value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! $safeBrowsing->isSafe($value)) {
|
|
||||||
$fail(__('filament-short-url::default.safe_browsing_error'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$isLegacyRules = is_array($this->input('targeting_rules')) && isset($this->input('targeting_rules')['type']);
|
$isLegacyRules = is_array($this->input('targeting_rules')) && isset($this->input('targeting_rules')['type']);
|
||||||
|
|
||||||
@@ -325,6 +326,7 @@ class StoreShortUrlRequest extends FormRequest
|
|||||||
$isRequired ? 'required' : 'nullable',
|
$isRequired ? 'required' : 'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
'exists:short_url_custom_domains,id,is_active,1,is_verified,1',
|
'exists:short_url_custom_domains,id,is_active,1,is_verified,1',
|
||||||
|
CustomDomainValidator::ownershipClosure(CustomDomainValidator::ownerUserIdFromRequest($this)),
|
||||||
];
|
];
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -343,7 +345,11 @@ class StoreShortUrlRequest extends FormRequest
|
|||||||
'string',
|
'string',
|
||||||
'alpha_dash',
|
'alpha_dash',
|
||||||
'max:32',
|
'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',
|
'notes' => 'nullable|string|max:255',
|
||||||
'is_enabled' => 'sometimes|required|boolean',
|
'is_enabled' => 'sometimes|required|boolean',
|
||||||
@@ -351,15 +357,16 @@ class StoreShortUrlRequest extends FormRequest
|
|||||||
'single_use' => 'sometimes|required|boolean',
|
'single_use' => 'sometimes|required|boolean',
|
||||||
'forward_query_params' => 'sometimes|required|boolean',
|
'forward_query_params' => 'sometimes|required|boolean',
|
||||||
'max_visits' => 'nullable|integer|min:1',
|
'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',
|
'activated_at' => 'nullable|date|after_or_equal:today',
|
||||||
'expires_at' => 'nullable|date|after_or_equal:activated_at',
|
'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);
|
$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',
|
'password' => 'nullable|string|max:255',
|
||||||
'show_warning_page' => 'sometimes|required|boolean',
|
'show_warning_page' => 'sometimes|required|boolean',
|
||||||
'auto_open_app_mobile' => 'sometimes|required|boolean',
|
'auto_open_app_mobile' => 'sometimes|required|boolean',
|
||||||
|
|||||||