Files
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

88 lines
2.8 KiB
PHP

<?php
namespace App\Support\Routing;
use Illuminate\Routing\Route;
/**
* 이중 등록된 두 라우트(확장자 형태 · 확장자 없는 형태)를 하나처럼 체이닝하기 위한 프록시.
*
* `Route::dualSuffix()` / `Route::dualAsset()` 매크로가 반환하며, `name()` 을 제외한
* 모든 호출(`middleware()`, `where()`, `defaults()` 등)을 양쪽 라우트에 그대로 전달한다.
* 한쪽에만 적용되어 조용히 어긋나는 상황을 구조적으로 차단하는 것이 목적이다.
*
* @mixin Route
*/
class DualRouteProxy
{
/**
* 확장자 없는 형태의 라우트 이름에 붙는 접미사.
*
* 두 라우트에 같은 이름을 주면 Laravel 의 이름 조회가 나중에 등록된 쪽으로
* 덮여 `route()` 결과가 조용히 바뀐다. 확장자 형태가 기존 이름을 유지하고
* 확장자 없는 형태만 접미사를 갖는다 (하위호환).
*/
public const EXTENSIONLESS_NAME_SUFFIX = '.extensionless';
/**
* @param Route $extension 확장자 형태 라우트 (예: `templates/{id}/routes.json`)
* @param Route $extensionless 확장자 없는 형태 라우트 (예: `templates/{id}/routes`)
*/
public function __construct(
private readonly Route $extension,
private readonly Route $extensionless,
) {}
/**
* 확장자 형태 라우트를 반환합니다.
*
* @return Route 확장자 형태 라우트 인스턴스
*/
public function extensionRoute(): Route
{
return $this->extension;
}
/**
* 확장자 없는 형태 라우트를 반환합니다.
*
* @return Route 확장자 없는 형태 라우트 인스턴스
*/
public function extensionlessRoute(): Route
{
return $this->extensionless;
}
/**
* 두 라우트에 이름을 부여합니다.
*
* 확장자 형태는 전달된 이름을 그대로, 확장자 없는 형태는
* `.extensionless` 접미사를 붙여 등록합니다.
*
* @param string $name 라우트 이름
* @return self 체이닝을 위한 자기 자신
*/
public function name(string $name): self
{
$this->extension->name($name);
$this->extensionless->name($name.self::EXTENSIONLESS_NAME_SUFFIX);
return $this;
}
/**
* 그 외 모든 호출을 두 라우트에 동일하게 전달합니다.
*
* @param string $method 호출된 메서드명
* @param array<int, mixed> $arguments 전달 인자
* @return self 체이닝을 위한 자기 자신
*/
public function __call(string $method, array $arguments): self
{
$this->extension->{$method}(...$arguments);
$this->extensionless->{$method}(...$arguments);
return $this;
}
}