lthn.io/app/Core/Cdn/Services/StorageUrlResolver.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

500 lines
16 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Cdn\Services;
use Carbon\Carbon;
use Core\Cdn\Jobs\PushAssetToCdn;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Storage;
/**
* Context-aware URL resolver for CDN/storage architecture.
*
* Provides intelligent URL resolution based on request context:
* - Admin/internal requests -> Origin URLs (Hetzner)
* - Public/embed requests -> CDN URLs (BunnyCDN)
* - API requests -> Both URLs returned
*
* Supports vBucket scoping for workspace-isolated CDN paths using LTHN QuasiHash.
*
* URL building is delegated to CdnUrlBuilder for consistency across services.
*
* ## Methods
*
* | Method | Returns | Description |
* |--------|---------|-------------|
* | `vBucketId()` | `string` | Generate vBucket ID for a domain |
* | `vBucketCdn()` | `string` | Get CDN URL with vBucket scoping |
* | `vBucketOrigin()` | `string` | Get origin URL with vBucket scoping |
* | `vBucketPath()` | `string` | Build vBucket-scoped storage path |
* | `vBucketUrls()` | `array` | Get both URLs with vBucket scoping |
* | `cdn()` | `string` | Get CDN delivery URL for a path |
* | `origin()` | `string` | Get origin URL (Hetzner) for a path |
* | `private()` | `string` | Get private storage URL for a path |
* | `signedUrl()` | `string\|null` | Get signed URL for private content |
* | `apex()` | `string` | Get apex domain URL for a path |
* | `asset()` | `string` | Get context-aware URL for a path |
* | `urls()` | `array` | Get both CDN and origin URLs |
* | `allUrls()` | `array` | Get all URLs (cdn, origin, private, apex) |
* | `detectContext()` | `string` | Detect current request context |
* | `isAdminContext()` | `bool` | Check if current context is admin |
* | `pushToCdn()` | `bool` | Push a file to CDN storage zone |
* | `deleteFromCdn()` | `bool` | Delete a file from CDN storage zone |
* | `purge()` | `bool` | Purge a path from CDN cache |
* | `cachedAsset()` | `string` | Get cached CDN URL with intelligent caching |
* | `publicDisk()` | `Filesystem` | Get the public storage disk |
* | `privateDisk()` | `Filesystem` | Get the private storage disk |
* | `storePublic()` | `bool` | Store file to public bucket |
* | `storePrivate()` | `bool` | Store file to private bucket |
* | `deleteAsset()` | `bool` | Delete file from storage and CDN |
* | `pathPrefix()` | `string` | Get path prefix for a category |
* | `categoryPath()` | `string` | Build full path with category prefix |
*
* @see CdnUrlBuilder For the underlying URL building logic
*/
class StorageUrlResolver
{
protected BunnyStorageService $bunnyStorage;
protected CdnUrlBuilder $urlBuilder;
public function __construct(BunnyStorageService $bunnyStorage, ?CdnUrlBuilder $urlBuilder = null)
{
$this->bunnyStorage = $bunnyStorage;
$this->urlBuilder = $urlBuilder ?? new CdnUrlBuilder;
}
/**
* Get the URL builder instance.
*/
public function getUrlBuilder(): CdnUrlBuilder
{
return $this->urlBuilder;
}
/**
* Generate a vBucket ID for a domain/workspace.
*
* Uses LTHN QuasiHash protocol for deterministic, scoped identifiers.
* Format: cdn.host.uk.com/{vBucketId}/path/to/asset.js
*
* @param string $domain The domain name (e.g., "host.uk.com")
* @return string 16-character vBucket identifier
*/
public function vBucketId(string $domain): string
{
return $this->urlBuilder->vBucketId($domain);
}
/**
* Get CDN URL with vBucket scoping for workspace isolation.
*
* @param string $domain The workspace domain for scoping
* @param string $path Path relative to storage root
*/
public function vBucketCdn(string $domain, string $path): string
{
return $this->urlBuilder->vBucket($domain, $path);
}
/**
* Get origin URL with vBucket scoping for workspace isolation.
*
* @param string $domain The workspace domain for scoping
* @param string $path Path relative to storage root
*/
public function vBucketOrigin(string $domain, string $path): string
{
$scopedPath = $this->urlBuilder->vBucketPath($domain, $path);
return $this->urlBuilder->origin($scopedPath);
}
/**
* Build a vBucket-scoped storage path.
*
* @param string $domain The workspace domain for scoping
* @param string $path Path relative to vBucket root
*/
public function vBucketPath(string $domain, string $path): string
{
return $this->urlBuilder->vBucketPath($domain, $path);
}
/**
* Get both URLs with vBucket scoping for API responses.
*
* @param string $domain The workspace domain for scoping
* @param string $path Path relative to storage root
* @return array{cdn: string, origin: string, vbucket: string}
*/
public function vBucketUrls(string $domain, string $path): array
{
return $this->urlBuilder->vBucketUrls($domain, $path);
}
/**
* Get the CDN delivery URL for a path.
* Always returns the BunnyCDN pull zone URL.
*
* @param string $path Path relative to storage root
*/
public function cdn(string $path): string
{
return $this->urlBuilder->cdn($path);
}
/**
* Get the origin URL for a path (Hetzner public bucket).
* Direct access to origin storage, bypassing CDN.
*
* @param string $path Path relative to storage root
*/
public function origin(string $path): string
{
return $this->urlBuilder->origin($path);
}
/**
* Get the private storage URL for a path.
* For DRM/gated content - not publicly accessible.
*
* @param string $path Path relative to storage root
*/
public function private(string $path): string
{
return $this->urlBuilder->private($path);
}
/**
* Get a signed URL for private CDN content with token authentication.
* Generates time-limited access URLs for gated/DRM content.
*
* @param string $path Path relative to storage root
* @param int|Carbon|null $expiry Expiry time in seconds, or a Carbon instance for absolute expiry.
* Defaults to config('cdn.signed_url_expiry', 3600) when null.
* @return string|null Signed URL or null if token not configured
*/
public function signedUrl(string $path, int|Carbon|null $expiry = null): ?string
{
return $this->urlBuilder->signed($path, $expiry);
}
/**
* Build the base URL for signed private URLs.
* Uses config for the private pull zone URL.
*
* @deprecated Use CdnUrlBuilder::signed() instead
*/
protected function buildSignedUrlBase(): string
{
$pullZone = config('cdn.bunny.private.pull_zone');
// Support both full URL and just hostname in config
if (str_starts_with($pullZone, 'https://') || str_starts_with($pullZone, 'http://')) {
return rtrim($pullZone, '/');
}
return "https://{$pullZone}";
}
/**
* Get the apex domain URL for a path.
* Fallback for assets served through main domain.
*
* @param string $path Path relative to web root
*/
public function apex(string $path): string
{
return $this->urlBuilder->apex($path);
}
/**
* Get context-aware URL for a path.
* Automatically determines whether to return CDN or origin URL.
*
* @param string $path Path relative to storage root
* @param string|null $context Force context ('admin', 'public', or null for auto)
*/
public function asset(string $path, ?string $context = null): string
{
$context = $context ?? $this->detectContext();
return $this->urlBuilder->asset($path, $context);
}
/**
* Get both CDN and origin URLs for API responses.
*
* @param string $path Path relative to storage root
* @return array{cdn: string, origin: string}
*/
public function urls(string $path): array
{
return $this->urlBuilder->urls($path);
}
/**
* Get all URLs for a path (including private and apex).
*
* @param string $path Path relative to storage root
* @return array{cdn: string, origin: string, private: string, apex: string}
*/
public function allUrls(string $path): array
{
return $this->urlBuilder->allUrls($path);
}
/**
* Detect the current request context based on headers and route.
*
* Checks for admin headers and route prefixes to determine context.
*
* @return string 'admin' or 'public'
*/
public function detectContext(): string
{
// Check for admin headers
foreach (config('cdn.context.admin_headers', []) as $header) {
if (Request::hasHeader($header)) {
return 'admin';
}
}
// Check for admin route prefixes
$path = Request::path();
foreach (config('cdn.context.admin_prefixes', []) as $prefix) {
if (str_starts_with($path, $prefix)) {
return 'admin';
}
}
return config('cdn.context.default', 'public');
}
/**
* Check if the current context is admin/internal.
*
* @return bool True if in admin context
*/
public function isAdminContext(): bool
{
return $this->detectContext() === 'admin';
}
/**
* Push a file to the CDN storage zone.
*
* @param string $disk Laravel disk name ('hetzner-public' or 'hetzner-private')
* @param string $path Path within the disk
* @param string $zone Target zone ('public' or 'private')
*/
public function pushToCdn(string $disk, string $path, string $zone = 'public'): bool
{
if (! $this->bunnyStorage->isPushEnabled()) {
return false;
}
return $this->bunnyStorage->copyFromDisk($disk, $path, $zone);
}
/**
* Delete a file from the CDN storage zone.
*
* @param string $path Path within the storage zone
* @param string $zone Target zone ('public' or 'private')
*/
public function deleteFromCdn(string $path, string $zone = 'public'): bool
{
return $this->bunnyStorage->delete($path, $zone);
}
/**
* Purge a path from the CDN cache.
* Uses the existing BunnyCdnService for pull zone API.
*
* @param string $path Path to purge
*/
public function purge(string $path): bool
{
// Use existing BunnyCdnService for pull zone operations
$bunnyCdnService = app(BunnyCdnService::class);
if (! $bunnyCdnService->isConfigured()) {
return false;
}
return $bunnyCdnService->purgeUrl($this->cdn($path));
}
/**
* Get cached CDN URL with intelligent caching.
*
* @param string $path Path relative to storage root
* @param string|null $context Force context ('admin', 'public', or null for auto)
*/
public function cachedAsset(string $path, ?string $context = null): string
{
if (! config('cdn.cache.enabled', true)) {
return $this->asset($path, $context);
}
$context = $context ?? $this->detectContext();
$cacheKey = config('cdn.cache.prefix', 'cdn_url').':'.$context.':'.md5($path);
$ttl = config('cdn.cache.ttl', 3600);
return Cache::remember($cacheKey, $ttl, fn () => $this->asset($path, $context));
}
/**
* Build a URL from base URL and path.
*
* @param string|null $baseUrl Base URL (falls back to apex if null)
* @param string $path Path to append
* @return string Full URL
*
* @deprecated Use CdnUrlBuilder::build() instead
*/
protected function buildUrl(?string $baseUrl, string $path): string
{
return $this->urlBuilder->build($baseUrl, $path);
}
/**
* Get the public storage disk.
*
* @return Filesystem
*/
public function publicDisk()
{
return Storage::disk(config('cdn.disks.public', 'hetzner-public'));
}
/**
* Get the private storage disk.
*
* @return Filesystem
*/
public function privateDisk()
{
return Storage::disk(config('cdn.disks.private', 'hetzner-private'));
}
/**
* Store file to public bucket and optionally push to CDN.
*
* @param string $path Target path
* @param string|resource $contents File contents or stream
* @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
*/
public function storePublic(string $path, $contents, bool $pushToCdn = true): bool
{
$stored = $this->publicDisk()->put($path, $contents);
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
// Queue the push if configured, otherwise push synchronously
if ($queue = config('cdn.pipeline.queue')) {
dispatch(new PushAssetToCdn('hetzner-public', $path, 'public'))->onQueue($queue);
} else {
$this->pushToCdn('hetzner-public', $path, 'public');
}
}
return $stored;
}
/**
* Store file to private bucket and optionally push to CDN.
*
* @param string $path Target path
* @param string|resource $contents File contents or stream
* @param bool $pushToCdn Whether to also push to BunnyCDN storage zone
*/
public function storePrivate(string $path, $contents, bool $pushToCdn = true): bool
{
$stored = $this->privateDisk()->put($path, $contents);
if ($stored && $pushToCdn && config('cdn.pipeline.auto_push', true)) {
if ($queue = config('cdn.pipeline.queue')) {
dispatch(new PushAssetToCdn('hetzner-private', $path, 'private'))->onQueue($queue);
} else {
$this->pushToCdn('hetzner-private', $path, 'private');
}
}
return $stored;
}
/**
* Delete file from storage and CDN.
*
* @param string $path File path
* @param string $bucket 'public' or 'private'
*/
public function deleteAsset(string $path, string $bucket = 'public'): bool
{
$disk = $bucket === 'private' ? $this->privateDisk() : $this->publicDisk();
$deleted = $disk->delete($path);
if ($deleted) {
$this->deleteFromCdn($path, $bucket);
if (config('cdn.pipeline.auto_purge', true)) {
$this->purge($path);
}
}
return $deleted;
}
/**
* Get the path prefix for a content category.
*
* @param string $category Category key from config (media, social, page, etc.)
*/
public function pathPrefix(string $category): string
{
return $this->urlBuilder->pathPrefix($category);
}
/**
* Build a full path with category prefix.
*
* @param string $category Category key
* @param string $path Relative path within category
*/
public function categoryPath(string $category, string $path): string
{
return $this->urlBuilder->categoryPath($category, $path);
}
/**
* Resolve expiry parameter to a Unix timestamp.
*
* @param int|Carbon|null $expiry Expiry in seconds, Carbon instance, or null for config default
* @return int Unix timestamp when the URL expires
*
* @deprecated Use CdnUrlBuilder internally instead
*/
protected function resolveExpiry(int|Carbon|null $expiry): int
{
if ($expiry instanceof Carbon) {
return $expiry->timestamp;
}
$expirySeconds = $expiry ?? (int) config('cdn.signed_url_expiry', 3600);
return time() + $expirySeconds;
}
}