Files
filament-short-url/src/Models/ShortUrlCustomDomain.php
Bartłomiej Janczak 2107a3e6c8 feat: v5.0.0 — Live Feed page, Folders & Tags, Link Archiving, API scopes
## Analytics — Live Activity Feed
- Dedicated /stats/live standalone page (mirrors /stats/logs pattern)
- Consistent 3-tab navigation across Statistics / Live Feed / Visit Logs
- checkForUpdates() with skipRender(): O(1) MAX(id) poll — ~100-byte response on no-change
- latestVisitId sentinel (-1) prevents unnecessary first-poll re-render
- Cache key versioned by latestVisitId — fixes bug where concurrent users
  could permanently miss a visit due to stale thundering-herd cache
- 3-second thundering-herd cache (key: url_id + date + filters + latestVisitId)
- Country flags via flagcdn.com (precomputed in PHP, Alpine.js CSP-safe onerror)
- time_ago precomputed in PHP — no Carbon::parse() double-instantiation in Blade
- SELECT only 12 needed columns instead of SELECT *; limit 25 rows
- wire:poll.5s.visible — polling stops when widget is off-screen

## Link Organization
- Folders: one folder per link, clickable → filtered link list, link count badge
- Tags: up to 5 tags per link, clickable → filtered link list, link count badge
- Archiving: soft-archive links instead of permanent deletion; restorable

## REST API
- Per-key API scopes: links:read-only (GET) / links:read-write (full CRUD)
- Per-key rate limiting: individual requests/minute per API key

## Translations
- Added stats_live_feed_poll_interval (EN: 'Updates every 5s', PL: 'Aktualizacja co 5s')

## Docs
- README.md: corrected API scopes/rate-limiting docs (features were already implemented)
- README.md: added Folders, Tags, Archiving to features list
- README.md: added v5.0.0 changelog entry
2026-06-06 02:16:07 +02:00

213 lines
6.6 KiB
PHP

<?php
/**
* @author Bartek Janczak <barek122@gmail.com>
* @copyright 2026 Bartek Janczak
* @license Custom Source-Available License (see LICENSE file)
*/
namespace Bjanczak\FilamentShortUrl\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int|null $user_id
* @property string $domain
* @property bool $is_verified
* @property bool $is_active
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class ShortUrlCustomDomain extends Model
{
protected $table = 'short_url_custom_domains';
protected $fillable = [
'domain',
'is_verified',
'is_active',
'user_id',
];
protected $casts = [
'is_verified' => 'boolean',
'is_active' => 'boolean',
];
/**
* Get the owner of this custom domain.
*/
public function user(): BelongsTo
{
$userModel = config('filament-short-url.user.model', User::class);
return $this->belongsTo($userModel, 'user_id');
}
/**
* Get short URLs mapped to this custom domain.
*/
public function shortUrls(): HasMany
{
return $this->hasMany(ShortUrl::class, 'custom_domain_id');
}
/**
* Invalidate the redirect-layer custom-domain cache on any state change.
* This ensures that verifying, deactivating, or deleting a domain is reflected
* immediately without waiting for the 5-minute cache TTL to expire.
*/
protected static function booted(): void
{
static::saved(function (self $m): void {
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
$domainChanged = $m->wasChanged('domain');
if ($domainChanged) {
$oldDomain = $m->getOriginal('domain');
if ($oldDomain) {
cache()->forget("filament-short-url:custom-domain:{$oldDomain}");
}
}
// Invalidate the redirect cache keys for all short URLs mapped to this domain
// if the domain name changes or its active status is toggled.
if ($domainChanged || $m->wasChanged('is_active')) {
$hosts = [$m->domain];
if ($domainChanged && isset($oldDomain)) {
$hosts[] = $oldDomain;
}
$shortUrls = $m->shortUrls()->get(['url_key']);
foreach ($shortUrls as $url) {
foreach ($hosts as $host) {
cache()->forget("filament-short-url:{$url->url_key}:{$host}");
}
}
}
});
static::deleted(function (self $m): void {
cache()->forget("filament-short-url:custom-domain:{$m->domain}");
// Clear redirect caches for all URLs mapped to this deleted domain
$shortUrls = $m->shortUrls()->get(['url_key']);
foreach ($shortUrls as $url) {
cache()->forget("filament-short-url:{$url->url_key}:{$m->domain}");
}
});
}
/**
* Dynamically resolve DNS records for this custom domain to verify if it points
* back to the host application server (via CNAME or A record).
*/
public function verifyDns(): bool
{
$domain = trim(strtolower($this->domain));
if (empty($domain)) {
return false;
}
// Get target host domain dynamically from configuration
$hostDomain = parse_url(config('app.url'), PHP_URL_HOST);
if (empty($hostDomain)) {
$hostDomain = request()?->getHost();
}
if (empty($hostDomain)) {
return false;
}
$hostDomain = trim(strtolower($hostDomain));
// 1. Resolve host server IP dynamically
$hostIp = null;
try {
if (function_exists('dns_get_record')) {
$records = @dns_get_record($hostDomain, DNS_A);
if (is_array($records) && ! empty($records)) {
$hostIp = $records[0]['ip'] ?? null;
}
}
if (empty($hostIp)) {
$output = shell_exec('dig +short +time=1 +tries=1 A '.escapeshellarg($hostDomain));
if ($output) {
$ips = array_filter(array_map('trim', explode("\n", trim($output))));
$hostIp = reset($ips) ?: null;
}
}
} catch (\Throwable $e) {
// ignore
}
if (empty($hostIp)) {
$hostIp = $_SERVER['SERVER_ADDR'] ?? null;
}
$isVerified = false;
// 2. Resolve domain A records
$aIps = [];
try {
if (function_exists('dns_get_record')) {
$records = @dns_get_record($domain, DNS_A);
if (is_array($records)) {
$aIps = array_filter(array_column($records, 'ip'));
}
}
if (empty($aIps)) {
$output = shell_exec('dig +short +time=1 +tries=1 A '.escapeshellarg($domain));
if ($output) {
$aIps = array_filter(array_map('trim', explode("\n", trim($output))));
}
}
} catch (\Throwable $e) {
// ignore
}
if (! empty($aIps) && $hostIp && in_array($hostIp, $aIps)) {
$isVerified = true;
}
// 3. Check CNAME record
if (! $isVerified) {
$cnameTarget = null;
try {
if (function_exists('dns_get_record')) {
$records = @dns_get_record($domain, DNS_CNAME);
if (is_array($records) && ! empty($records)) {
$cnameTarget = trim(strtolower(rtrim($records[0]['target'] ?? '', '.')));
}
}
if (empty($cnameTarget)) {
$output = shell_exec('dig +short +time=1 +tries=1 CNAME '.escapeshellarg($domain));
if ($output) {
$cnameTarget = trim(strtolower(rtrim(trim($output), '.')));
}
}
} catch (\Throwable $e) {
// ignore
}
if ($cnameTarget && strcasecmp($cnameTarget, $hostDomain) === 0) {
$isVerified = true;
}
}
if ($this->exists && $this->is_verified !== $isVerified) {
$this->update(['is_verified' => $isVerified]);
}
return $isVerified;
}
}