Files
Gnuboard7/app/Support/AssetUrl.php
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

253 lines
9.4 KiB
PHP

<?php
namespace App\Support;
use App\Support\Routing\DualExtensionRoute;
/**
* 자산·동적 엔드포인트 URL 생성기 (서버측 SSoT).
*
* ## 왜 필요한가
*
* G7 은 동적 API 엔드포인트에 정적 파일 확장자를 붙여 쓴다. 그런데 nginx/Apache 의
* 표준적 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 URL 마지막 확장자로
* 분기하며, nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로
* `try_files ... /index.php` 폴백이 실행될 기회가 없다. 그런 환경에서는 확장자 붙은
* 동적 응답이 PHP 에 도달하지 못하고 404 가 된다.
*
* 라우트는 이미 두 형태로 등록되어 있다(`DualExtensionRoute`). 남은 문제는 **URL 을
* 만드는 쪽**이 13개 지점에 흩어져 하드코딩되어 있었다는 것이다. 한 곳만 빠뜨려도
* 그 자산만 404 가 되고, 어느 지점이 빠졌는지는 화면이 죽어야 알 수 있다.
* 그래서 생성 경로를 여기 하나로 모은다.
*
* ## 모드
*
* `general.asset_url_mode` 설정값을 따른다.
*
* | 모드 | 의미 |
* |---|---|
* | `extension` (기본) | 확장자 유지 — 정상 환경. 확장자 기반 캐시/gzip 최적화를 보존한다 |
* | `extensionless` | 확장자 제거 — 정적 블록이 가로채는 환경 |
*
* 기본값이 `extension` 인 이유는 계획서 §"채택 방향" 을 따른다. 확장자를 일괄 제거하면
* `expires max` / `gzip_static` / CDN TTL 규칙이 함께 걸린 다수의 정상 환경에서
* 그 최적화를 전부 잃는다.
*
* @see DualExtensionRoute 라우트 이중 등록
*/
class AssetUrl
{
/**
* 확장자 유지 모드 식별자.
*/
public const MODE_EXTENSION = 'extension';
/**
* 확장자 제거 모드 식별자.
*/
public const MODE_EXTENSIONLESS = 'extensionless';
/**
* 확장자 없는 자산 URL 에서 파일 경로를 담는 쿼리 파라미터명.
*/
public const FILE_QUERY_PARAM = 'file';
/**
* 테스트/렌더 단위에서 모드를 강제하기 위한 오버라이드 값.
*
* null 이면 설정값을 조회한다.
*/
private static ?string $modeOverride = null;
/**
* 현재 자산 URL 모드를 반환합니다.
*
* 설정 조회가 실패해도(설치 전·마이그레이션 전 등) 예외를 던지지 않고
* 기본 모드로 폴백한다 — 이 값은 blade 렌더 경로에서 읽히므로 여기서
* 터지면 화면 전체가 죽는다.
*
* @return string `extension` 또는 `extensionless`
*/
public static function mode(): string
{
if (self::$modeOverride !== null) {
return self::$modeOverride;
}
try {
$mode = g7_core_settings('general.asset_url_mode', self::MODE_EXTENSION);
} catch (\Throwable $e) {
return self::MODE_EXTENSION;
}
return $mode === self::MODE_EXTENSIONLESS ? self::MODE_EXTENSIONLESS : self::MODE_EXTENSION;
}
/**
* 현재 모드가 확장자 없는 모드인지 여부를 반환합니다.
*
* @return bool 확장자 없는 모드이면 true
*/
public static function isExtensionless(): bool
{
return self::mode() === self::MODE_EXTENSIONLESS;
}
/**
* 모드를 강제로 지정합니다 (테스트 전용).
*
* @param string|null $mode 강제할 모드. null 이면 오버라이드 해제
*/
public static function forceMode(?string $mode): void
{
self::$modeOverride = $mode;
}
/**
* 템플릿 자산 URL 을 생성합니다.
*
* 템플릿은 서버가 `dist/` 를 자동 부가하므로 `$path` 는 `dist/` 를 포함하지 않는다
* (`TemplateService::getAssetFilePath`). 모듈/플러그인과 비대칭이므로 주의.
*
* @param string $identifier 템플릿 식별자
* @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function templateAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('templates', $identifier, $path, $version);
}
/**
* 모듈 자산 URL 을 생성합니다.
*
* 모듈은 모듈 루트 기준이라 `$path` 에 `dist/` 를 직접 포함해야 한다
* (`ModuleService::getAssetFilePath`).
*
* @param string $identifier 모듈 식별자
* @param string $path 모듈 루트 기준 파일 경로 (예: `dist/js/x.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function moduleAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('modules', $identifier, $path, $version);
}
/**
* 플러그인 자산 URL 을 생성합니다.
*
* @param string $identifier 플러그인 식별자
* @param string $path 플러그인 루트 기준 파일 경로 (예: `dist/js/x.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function pluginAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('plugins', $identifier, $path, $version);
}
/**
* 확장 타입을 인자로 받는 자산 URL 생성기.
*
* 모듈/플러그인을 같은 코드 경로로 처리하는 호출부(공용 트레이트 등)용.
* 타입이 컴파일 시점에 정해져 있으면 `moduleAsset()` / `pluginAsset()` 를 쓴다.
*
* @param string $type `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $path 파일 경로
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function extensionAsset(string $type, string $identifier, string $path, int|string|null $version = null): string
{
return self::asset($type, $identifier, $path, $version);
}
/**
* 확장 병합 번들 URL 을 생성합니다.
*
* 접미사(js/css)가 번들 종류를 구분하므로 제거할 수 없다.
* 확장자 없는 모드에서는 경로 세그먼트로 내린다 (`bundle.js` → `bundle/js`).
*
* @param string $type `modules` 또는 `plugins`
* @param string $kind `js` 또는 `css`
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function extensionBundle(string $type, string $kind, int|string|null $version = null): string
{
$base = self::isExtensionless()
? "/api/{$type}/bundle/{$kind}"
: "/api/{$type}/bundle.{$kind}";
return $base.self::versionQuery($version);
}
/**
* 고정 접미사를 갖는 동적 엔드포인트 URL 을 생성합니다.
*
* 확장자 없는 모드에서는 접미사를 제거한다 (`routes.json` → `routes`).
*
* @param string $path 접미사를 제외한 경로 (예: `/api/templates/foo/routes`)
* @param string $suffix 접미사 (예: `json`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function suffixed(string $path, string $suffix, int|string|null $version = null): string
{
$base = rtrim($path, '/');
$normalized = ltrim($suffix, '.');
$url = self::isExtensionless() ? $base : $base.'.'.$normalized;
return $url.self::versionQuery($version);
}
/**
* 확장 자산 URL 을 생성하는 공통 구현.
*
* 확장자 없는 모드에서는 파일 경로를 `?file=` 쿼리로 옮긴다. 경로가 곧 파일명이라
* 접미사만 떼어낼 수 없기 때문이며, nginx 의 location 정규식이 쿼리스트링을 제외한
* 경로에만 매칭되므로 이 형태가 안전하다.
*
* @param string $type `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $path 파일 경로
* @param int|string|null $version 캐시 무효화 버전
* @return string 생성된 URL
*/
private static function asset(string $type, string $identifier, string $path, int|string|null $version): string
{
$path = ltrim($path, '/');
if (! self::isExtensionless()) {
return "/api/{$type}/assets/{$identifier}/{$path}".self::versionQuery($version);
}
$query = self::FILE_QUERY_PARAM.'='.rawurlencode($path);
if ($version !== null && $version !== '') {
$query .= '&v='.$version;
}
return "/api/{$type}/assets/{$identifier}?{$query}";
}
/**
* 캐시 무효화 쿼리스트링을 생성합니다.
*
* @param int|string|null $version 버전 값
* @return string `?v=...` 또는 빈 문자열
*/
private static function versionQuery(int|string|null $version): string
{
if ($version === null || $version === '') {
return '';
}
return '?v='.$version;
}
}