Files
Gnuboard7/app/Support/Routing/DualExtensionRoute.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

148 lines
6.3 KiB
PHP

<?php
namespace App\Support\Routing;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Route;
/**
* 동적 응답 라우트를 "확장자 형태"와 "확장자 없는 형태" 두 벌로 동시 등록하는 Route 매크로.
*
* ## 배경
*
* G7 은 동적 API 엔드포인트에 정적 파일 확장자를 붙여 쓴다 (`/api/templates/{id}/routes.json`).
* 그런데 nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며,
* nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로
* `try_files ... /index.php` 폴백이 실행될 기회 자체가 없다.
*
* ```nginx
* location ~* \.(js|css|json)$ { expires max; access_log off; }
* ```
*
* 이 설정 하에서는 PHP 로 요청이 넘어가지 않고 nginx 가 직접 파일시스템을 열려 시도해
* 404 가 되며, G7 은 부트스트랩 자산부터 죽는다. 관례를 깬 쪽이 G7 이므로 해소 책임도
* G7 에 있다는 판단으로, 두 형태를 모두 서빙하고 환경에 따라 택일한다.
*
* ## 규칙
*
* - 고정 접미사 엔드포인트는 접미사를 뗀다 (`routes.json` → `routes`)
* - 와일드카드 자산 라우트는 경로가 곧 파일명이라 뗄 수 없으므로 쿼리로 옮긴다
* (`assets/{id}/js/a.js` → `assets/{id}?file=js/a.js`).
* nginx 의 location 정규식은 쿼리스트링을 제외한 경로에만 매칭되므로 안전하다.
* - 두 형태 모두 영구 유지한다. 확장자 형태를 제거하면 URL 을 하드코딩한
* 서드파티 확장이 깨진다.
*
* ## 등록 순서
*
* 확장자 형태를 항상 **먼저** 등록한다. 확장자 없는 형태가 더 느슨한 패턴이라
* (예: `layouts/{tpl}/{layoutName}` 의 `layoutName` 정규식은 `.` 를 포함해 greedy)
* 순서가 뒤집히면 확장자 없는 라우트가 `.json` 요청까지 삼킨다.
*
* @see DualRouteProxy
*/
class DualExtensionRoute
{
/**
* 확장자 없는 자산 라우트에서 파일 경로를 담는 쿼리 파라미터명.
*/
public const FILE_QUERY_PARAM = 'file';
/**
* Route 매크로 3종(`dualSuffix` / `dualSuffixSegment` / `dualAsset`)을 등록합니다.
*
* `AppServiceProvider::register()` 에서 호출됩니다. boot() 가 아닌 이유:
* 라우트 파일은 프레임워크 라우팅 부트스트랩(boot 단계)에서 로드되므로,
* 프로바이더 간 boot 순서에 의존하면 매크로 미정의 시점에 라우트가 로드될 수 있다.
* 모든 프로바이더의 register() 는 어떤 boot() 보다 먼저 실행된다.
*/
public static function register(): void
{
static::registerDualSuffix();
static::registerDualSuffixSegment();
static::registerDualAsset();
}
/**
* `Route::dualSuffix()` 매크로를 등록합니다.
*
* 고정 접미사를 갖는 엔드포인트를 두 형태로 등록합니다.
*
* ```php
* Route::dualSuffix('templates/{identifier}/routes', 'json', [Ctrl::class, 'getRoutes'])
* ->name('api.public.templates.routes');
* // → GET templates/{identifier}/routes.json (name: api.public.templates.routes)
* // → GET templates/{identifier}/routes (name: api.public.templates.routes.extensionless)
* ```
*/
private static function registerDualSuffix(): void
{
Route::macro('dualSuffix', function (string $uri, string $suffix, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
// 확장자 형태를 먼저 등록 (더 구체적인 패턴이 우선 매칭되어야 함)
$extension = $this->get($base.'.'.ltrim($suffix, '.'), $action);
$extensionless = $this->get($base, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
/**
* `Route::dualSuffixSegment()` 매크로를 등록합니다.
*
* 접미사 자체가 의미를 담아 **뗄 수 없는** 엔드포인트용입니다.
* `bundle.js` 와 `bundle.css` 는 접미사를 제거하면 둘 다 `bundle` 이 되어 충돌하므로,
* 접미사를 삭제하는 대신 경로 세그먼트로 내린다.
*
* ```php
* Route::dualSuffixSegment('modules/bundle', 'js', [Ctrl::class, 'serveBundleJs'])
* ->name('api.public.modules.bundle.js');
* // → GET modules/bundle.js (name: api.public.modules.bundle.js)
* // → GET modules/bundle/js (name: api.public.modules.bundle.js.extensionless)
* ```
*/
private static function registerDualSuffixSegment(): void
{
Route::macro('dualSuffixSegment', function (string $uri, string $suffix, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
$normalized = ltrim($suffix, '.');
$extension = $this->get($base.'.'.$normalized, $action);
$extensionless = $this->get($base.'/'.$normalized, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
/**
* `Route::dualAsset()` 매크로를 등록합니다.
*
* 와일드카드 파일 경로를 갖는 자산 서빙 엔드포인트를 두 형태로 등록합니다.
* 확장자 형태는 `{path}` 세그먼트로, 확장자 없는 형태는 `?file=` 쿼리로 경로를 받으며
* 양쪽 모두 동일 컨트롤러로 들어갑니다 (흡수는 각 FormRequest 의
* `prepareForValidation()` 이 담당).
*
* ```php
* Route::dualAsset('templates/assets/{identifier}', [Ctrl::class, 'serveAsset'])
* ->name('api.public.templates.assets');
* // → GET templates/assets/{identifier}/{path} where path = .*
* // → GET templates/assets/{identifier} (?file=js/a.js)
* ```
*/
private static function registerDualAsset(): void
{
Route::macro('dualAsset', function (string $uri, mixed $action): DualRouteProxy {
/** @var Router $this */
$base = rtrim($uri, '/');
// 확장자 형태를 먼저 등록 (경로 세그먼트가 더 구체적)
$extension = $this->get($base.'/{path}', $action)->where('path', '.*');
$extensionless = $this->get($base, $action);
return new DualRouteProxy($extension, $extensionless);
});
}
}