Files
Gnuboard7/app/Support/AssetCssUrlRewriter.php
T
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무
전수조사에서 확정한 결함군을 닫는다.

## 디버그 라우트 게이트 (F1~F5)

게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에
`File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고,
production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은
User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴
구조였다.

게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로
올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다.
`withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는
제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치
우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다.

## 확장 자산 CSS 상대 참조 (범위 추가 — 결정)

`asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준
디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가
존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘
약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서
통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다.

둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이
정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류
갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 —
갈라지면 한쪽만 고쳐진 채 남는다.

이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가
정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다.

## 회귀 잠금

되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입,
`channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들
치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다.

게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과
구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00

153 lines
6.0 KiB
PHP

<?php
namespace App\Support;
/**
* 확장 자산으로 서빙되는 CSS 안의 **상대 경로 참조**를 절대 자산 URL 로 바꿉니다.
*
* 왜 필요한가:
* 자산 URL 은 두 모드로 나간다 (`general.asset_url_mode`).
* - `extension` → `/api/templates/assets/{id}/vendor/x/1.0/a.css`
* - `extensionless` → `/api/templates/assets/{id}?file=vendor%2Fx%2F1.0%2Fa.css`
*
* 브라우저는 CSS 안의 상대 `url()` 을 **그 스타일시트 URL의 디렉토리** 기준으로 푼다.
* 확장자 모드에서는 디렉토리가 `.../vendor/x/1.0/` 이라 `./woff2/f.woff2` 가 제대로 풀리지만,
* 확장자 없는 모드에서는 경로의 마지막 세그먼트가 식별자(`{id}`)이고 파일명은 쿼리에 있으므로
* 디렉토리가 `/api/templates/assets/` 로 잡힌다 — `./woff2/f.woff2` 가
* `/api/templates/assets/woff2/f.woff2` 라는 존재하지 않는 주소가 된다.
*
* 이 실패는 서버 로그에 아무 흔적을 남기지 않는다. 요청은 정상 404 이고, 화면은 글꼴이
* 기본 서체로 대체되거나 아이콘이 빈칸으로 보일 뿐이라 운영자가 원인을 특정할 단서가 없다.
* 실제로 사용자 템플릿의 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다.
*
* 왜 서빙 시점인가:
* 최종 URL 은 런타임 모드와 캐시 버전이 정한다 — 빌드 시점에는 알 수 없다. 그리고 동봉
* 자산은 제3자 산출물이라 원본을 손대면 상류 갱신 때마다 재작업이 된다. 그래서 원본은
* 상대 경로 그대로 두고, 내보내는 순간에만 해석한다.
*
* 대상이 아닌 것:
* 절대 URL(`https://`, `//`), 루트 상대(`/`), `data:`/`about:` 등 스킴 참조, 빈 참조.
* 정적 게시본(`/build/ext/{v}/...`)은 웹서버가 직접 서빙하고 경로 형태라 상대 해석이
* 정상이므로 이 경로를 타지 않는다.
*/
class AssetCssUrlRewriter
{
/** `url(...)` 참조 — 따옴표 3종(없음/홑/겹)을 모두 받는다 */
private const URL_RE = '/\burl\(\s*(["\']?)(.*?)\1\s*\)/s';
/** `@import "..."` / `@import \'...\'` (url() 없이 문자열만 오는 형태) */
private const IMPORT_RE = '/@import\s+(["\'])(.*?)\1/s';
/**
* CSS 안의 상대 참조를 절대 자산 URL 로 치환합니다.
*
* @param string $css 원본 CSS
* @param string $cssPath 확장 기준 CSS 경로 (서빙 요청에 쓰인 것과 같은 좌표계)
* @param callable(string): string $urlFor 확장 기준 경로 → 절대 자산 URL 변환기
* @return string 치환된 CSS
*/
public static function rewrite(string $css, string $cssPath, callable $urlFor): string
{
$baseDir = self::baseDirectory($cssPath);
$replace = function (array $m) use ($baseDir, $urlFor): string {
$quote = $m[1];
$ref = trim($m[2]);
$resolved = self::resolve($ref, $baseDir);
if ($resolved === null) {
return $m[0];
}
[$path, $fragment] = $resolved;
$url = $urlFor($path).$fragment;
// 따옴표가 없던 참조도 겹따옴표로 감싼다 — 생성된 URL 은 `?`·`&` 를 포함할 수
// 있는데, 따옴표 없는 url() 토큰에서 그 문자들은 CSS 문법상 허용되지 않는다.
$quote = $quote !== '' ? $quote : '"';
return str_starts_with($m[0], '@import')
? '@import '.$quote.$url.$quote
: 'url('.$quote.$url.$quote.')';
};
$css = preg_replace_callback(self::URL_RE, $replace, $css) ?? $css;
return preg_replace_callback(self::IMPORT_RE, $replace, $css) ?? $css;
}
/**
* 참조가 상대 경로인지 판정하고, 확장 기준 절대 경로로 해석합니다.
*
* @param string $ref CSS 안의 원본 참조
* @param array<int, string> $baseDir CSS 가 놓인 디렉토리 세그먼트
* @return array{0: string, 1: string}|null `[확장 기준 경로, 프래그먼트]` 또는 대상 아님이면 null
*/
private static function resolve(string $ref, array $baseDir): ?array
{
if ($ref === '') {
return null;
}
// 루트 상대(`/x`) · 프로토콜 상대(`//host/x`) · 스킴 참조(`https:`, `data:`, `#`) 는 그대로 둔다.
if ($ref[0] === '/' || $ref[0] === '#' || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $ref) === 1) {
return null;
}
// 프래그먼트는 보존하고(레거시 `#iefix` 등), 참조 자신의 쿼리는 버린다 —
// 생성되는 자산 URL 이 자기 캐시 버전 쿼리를 갖는다.
$fragment = '';
if (($hash = strpos($ref, '#')) !== false) {
$fragment = substr($ref, $hash);
$ref = substr($ref, 0, $hash);
}
if (($q = strpos($ref, '?')) !== false) {
$ref = substr($ref, 0, $q);
}
if ($ref === '') {
return null;
}
$segments = $baseDir;
foreach (explode('/', $ref) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
if ($segments === []) {
return null;
}
return [implode('/', $segments), $fragment];
}
/**
* CSS 경로가 놓인 디렉토리 세그먼트를 구합니다.
*
* @param string $cssPath 확장 기준 CSS 경로
* @return array<int, string> 디렉토리 세그먼트 (루트면 빈 배열)
*/
private static function baseDirectory(string $cssPath): array
{
$parts = explode('/', trim(str_replace('\\', '/', $cssPath), '/'));
array_pop($parts);
return array_values(array_filter($parts, static fn (string $p): bool => $p !== '' && $p !== '.'));
}
}