Files
Gnuboard7/app/Http/Controllers/Concerns/ServesRewritableCssAssets.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

116 lines
5.0 KiB
PHP

<?php
namespace App\Http\Controllers\Concerns;
use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 확장 자산 서빙에서 CSS 안의 상대 참조를 절대 자산 URL 로 바꿔 내보냅니다.
*
* 배경:
* `general.asset_url_mode` 가 `extensionless` 면 자산 URL 이
* `/api/templates/assets/{id}?file=vendor%2Fx%2Fa.css` 형태가 된다. 브라우저는 CSS 안의
* 상대 `url()` 을 스타일시트 URL 의 **디렉토리** 기준으로 푸는데, 이 형태에서는 디렉토리가
* `/api/templates/assets/` 라서 `./woff2/f.woff2` 가 엉뚱한 곳을 가리킨다. 확장자 모드나
* 정적 게시본에서는 경로 형태라 정상 해석되므로, 어긋남은 이 조합에서만 나타난다.
*
* 증상은 404 하나뿐이다 — 글꼴은 기본 서체로 대체되고 아이콘은 빈칸이 되며, 서버 로그에는
* 정상 요청으로 남는다. 그래서 운영자에게는 원인을 특정할 단서가 없다.
*
* 모드와 무관하게 항상 치환하는 이유:
* 확장자 모드에서도 결과 URL 은 브라우저가 상대 해석으로 얻던 것과 같은 주소다. 모드에
* 따라 치환 여부를 가르면 두 경로가 서로 다른 코드로 갈라져, 정작 깨지는 쪽만 검증에서
* 빠지기 쉽다. 한 경로로 두고 두 모드를 같은 테스트로 잠근다.
*
* @see AssetCssUrlRewriter 치환 규칙(대상·비대상 판정)
*/
trait ServesRewritableCssAssets
{
/**
* 확장 자산 응답을 만듭니다 — CSS 면 상대 참조를 치환해 내보냅니다.
*
* CSS 가 아니면 종전 `fileResponse()` 와 동일하게 동작합니다.
*
* @param string $filePath 실제 파일 절대 경로
* @param string $mimeType MIME 타입
* @param string $extensionType `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $requestedPath 확장 기준 요청 경로 (CSS 상대 참조의 해석 기준)
* @param int $maxAge 캐시 유지 시간 (초)
* @return BinaryFileResponse|Response 자산 응답 (If-None-Match 일치 시 304)
*/
protected function rewritableAssetResponse(
string $filePath,
string $mimeType,
string $extensionType,
string $identifier,
string $requestedPath,
int $maxAge = 31536000
): BinaryFileResponse|Response {
if (! $this->isCssAsset($mimeType, $requestedPath)) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
$css = @file_get_contents($filePath);
// 읽기에 실패하면 치환을 포기하고 원본을 그대로 서빙한다 — 치환은 편의 장치이므로
// 그 실패가 자산 자체를 못 내보내는 사유가 되어서는 안 된다.
if ($css === false) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
// 서브리소스에는 CSS 자신이 받은 캐시 버전을 그대로 승계한다. 버전이 오르면 CSS URL
// 이 바뀌어 재요청되고, 그 안의 서브리소스 URL 도 같은 버전을 달고 나가므로 두 계층의
// 무효화 시점이 어긋나지 않는다.
$version = request()->query('v');
$version = is_string($version) && $version !== '' ? $version : null;
$rewritten = AssetCssUrlRewriter::rewrite(
$css,
$requestedPath,
static fn (string $path): string => AssetUrl::extensionApiAsset($extensionType, $identifier, $path, $version)
);
// ETag 는 **내보내는 본문** 기준이어야 한다. 파일 stat 기준으로 잡으면 URL 모드가
// 바뀌어 본문이 달라져도 같은 ETag 가 나와 브라우저가 옛 본문을 계속 쓴다.
$etag = md5($rewritten);
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}, immutable"
: 'no-cache';
return response($rewritten, 200, [
'Content-Type' => $mimeType,
'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT',
'ETag' => $etag,
'Cache-Control' => $cacheControl,
]);
}
/**
* 이 자산이 CSS 인지 판정합니다.
*
* MIME 과 확장자를 함께 본다 — 서빙 계층이 돌려주는 MIME 은 환경에 따라
* `text/plain` 으로 떨어질 수 있고, 그때 확장자가 유일한 단서다.
*
* @param string $mimeType MIME 타입
* @param string $path 확장 기준 요청 경로
* @return bool CSS 여부
*/
private function isCssAsset(string $mimeType, string $path): bool
{
if (str_contains(strtolower($mimeType), 'css')) {
return true;
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'css';
}
}