소문자 host 목록 (중복 제거, 해석 불가면 빈 배열) */ public static function hosts(): array { $hosts = []; $appHost = TrustedScriptHosts::hostOf((string) config('app.url', '')); if ($appHost !== null) { $hosts[] = $appHost; } $disk = PublicAssetDisk::resolve(); if ($disk !== null) { $diskHost = self::publicAssetDiskHost($disk); if ($diskHost !== null && ! in_array($diskHost, $hosts, true)) { $hosts[] = $diskHost; } } return $hosts; } /** * http/https 절대 URL 이고 그 host 가 사이트 자산 host 이면 true 를 돌려줍니다. * * 경로만 있는 값(`/api/...`)은 host 가 없어 이 판정의 대상이 아니고(호출측이 경로 * 규칙으로 다룹니다), protocol-relative(`//host/...`)와 http/https 밖의 스킴은 host 가 * 같아도 false 입니다 — 그 축은 종전 판정을 그대로 둡니다. * * @param string $url 검사 대상 URL * @param array|null $hosts host 목록 (미지정 시 self::hosts()) * @return bool 사이트 자산 host 의 http(s) 절대 URL 이면 true */ public static function isSiteAssetUrl(string $url, ?array $hosts = null): bool { $normalized = TrustedScriptHosts::normalizeForOriginCheck(trim($url)); if (preg_match('#^https?://#i', $normalized) !== 1) { return false; } $host = TrustedScriptHosts::hostOf($normalized); if ($host === null) { return false; } $hosts ??= self::hosts(); return in_array($host, $hosts, true); } /** * 공개 자산 디스크의 기준 URL host 를 해석합니다. * * 첨부 API 가 직접 URL 을 만드는 것과 같은 경로(`CoreStorageDriver::url()` — 디스크 url * 설정과 `core.storage.filter_url` 훅을 모두 거친다)로 빈 경로의 URL 을 만들어 host 만 * 취합니다. 설정값을 직접 읽으면 훅이 바꾼 host 를 놓칩니다. * * @param string $disk PublicAssetDisk::resolve() 가 돌려준 디스크 * @return string|null 소문자 host (직접 URL 을 만들 수 없는 디스크면 null) */ private static function publicAssetDiskHost(string $disk): ?string { try { // StorageInterface 는 컨텍스트 바인딩만 있어 컨테이너로 못 받는다 (ExtensionBundleService 와 같은 선례). $base = (new CoreStorageDriver($disk))->url('', ''); } catch (\Throwable $e) { Log::warning('SiteAssetHosts: 공개 자산 디스크 기준 URL 해석 실패 - '.$e->getMessage(), ['disk' => $disk]); return null; } return is_string($base) ? TrustedScriptHosts::hostOf($base) : null; } }