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 님께서 제보해주셨습니다.
This commit is contained in:
1 parent
74df8a4b09
commit
15cd4452bf
90 files changed
+4279
-546
No files matched your search
@@ -105,6 +105,52 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
}
|
||||
```
|
||||
|
||||
### 자산 URL 이중 모드
|
||||
|
||||
정적 파일 확장자(`.js` / `.css` / `.json`)로 끝나는 동적 엔드포인트는 **확장자 없는 형태를 함께 제공**합니다.
|
||||
아래 문서에 실린 URI 는 확장자 형태를 기준으로 표기하지만, 각 엔드포인트는 대응하는 확장자 없는 형태로도
|
||||
동일한 응답·동일한 권한 가드로 호출할 수 있습니다.
|
||||
|
||||
이유는 서버 설정입니다. nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며,
|
||||
nginx 에서 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로 `try_files ... /index.php` 폴백이
|
||||
실행될 기회가 없습니다. 그런 환경에서는 확장자 붙은 동적 응답이 PHP 에 도달하지 못하고 404 가 됩니다.
|
||||
|
||||
```nginx
|
||||
location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
```
|
||||
|
||||
| 확장자 형태 | 확장자 없는 형태 | 변환 규칙 |
|
||||
| --- | --- | --- |
|
||||
| `/api/templates/{id}/routes.json` | `/api/templates/{id}/routes` | 접미사 제거 |
|
||||
| `/api/layouts/{tpl}/{layout}.json` | `/api/layouts/{tpl}/{layout}` | 접미사 제거 |
|
||||
| `/api/modules/bundle.js` | `/api/modules/bundle/js` | 접미사를 경로 세그먼트로 (js/css 구분이 필요) |
|
||||
| `/api/templates/assets/{id}/js/a.js` | `/api/templates/assets/{id}?file=js/a.js` | 파일 경로를 `file` 쿼리로 (경로가 곧 파일명이라 제거 불가) |
|
||||
|
||||
`file` 쿼리 형태가 안전한 이유는 nginx 의 location 정규식이 쿼리스트링을 제외한 경로에만 매칭되기 때문입니다.
|
||||
확장자 없는 형태에도 경로 탈출 방어와 확장자 화이트리스트가 동일하게 적용됩니다.
|
||||
|
||||
두 형태는 **모두 영구 유지**됩니다. 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨집니다.
|
||||
|
||||
어느 형태를 쓸지는 서버 환경에 따라 결정되며, 다음 프로브 엔드포인트로 판정합니다.
|
||||
|
||||
| 메서드 | URI | 인증/권한 | 설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/system/asset-probe.js` | 공개 (인증 불필요) | 확장자 형태 프로브 |
|
||||
| GET | `/api/system/asset-probe` | 공개 (인증 불필요) | 대조군 |
|
||||
|
||||
두 URL 을 **브라우저에서** 쌍으로 요청합니다(서버측 loopback curl 은 vhost·프록시 체인을 우회해 오판합니다).
|
||||
응답은 `application/javascript` 이며 본문에 매직 토큰 `G7_ASSET_PROBE_OK` 를 담습니다. DB 에 접근하지 않고
|
||||
`Cache-Control: no-store` 로 캐시되지 않습니다.
|
||||
|
||||
| `asset-probe.js` | `asset-probe` | 판정 |
|
||||
| --- | --- | --- |
|
||||
| 성공 | 성공 | 확장자 형태 사용 가능 |
|
||||
| 실패 | 성공 | 정적 블록 가로채기 확정 — 확장자 없는 형태 사용 |
|
||||
| 실패 | 실패 | 모드 문제가 아님 (PHP/라우팅 장애) |
|
||||
|
||||
성공 판정은 상태코드가 아니라 **본문의 매직 토큰과 Content-Type** 으로 합니다. 상태코드만 보면
|
||||
"404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서 영원히 오판합니다.
|
||||
|
||||
## 코어 API 레퍼런스
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
|
||||
@@ -821,7 +821,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**설명** 데이터소스 websocket 후보용으로 등록된 브로드캐스트 채널/이벤트 카탈로그를 반환합니다(BroadcastCatalogService::collect). 편집기 전용 가드 하에서만 노출되며(admin 전역 broadcast 회피), `identifier`는 라우트 일관성용이고 카탈로그는 설치본 전역 기준입니다. `core.templates.layouts.edit` 권한이 필요합니다.
|
||||
|
||||
|
||||
### GET /api/admin/templates/{identifier}/editor/components.css
|
||||
### GET /api/admin/templates/{identifier}/editor/component-styles.css
|
||||
<!-- @generated:start:api.admin.templates.editor-css -->
|
||||
- **라우트명**: `api.admin.templates.editor-css`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminTemplateAssetController@serveEditorCss`
|
||||
@@ -836,7 +836,7 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/templates/{identifier}/editor/components.css HTTP/1.1
|
||||
GET /api/admin/templates/{identifier}/editor/component-styles.css HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
@@ -862,6 +862,8 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**설명** 레이아웃 편집기 프리뷰 전용 CSS를 서빙합니다. 편집기 진입 시에만 components.css의 다크 조상 셀렉터를 프리뷰 마커로 치환하고(editor-spec `darkMode.previewIsolation` 규칙), 필요 시 `@layer` 래퍼를 평탄화해 라이트/다크 프리뷰를 격리합니다. 변환 결과는 캐시 버전+파일 mtime 키로 캐시하며, CSS 부재 시 빈 응답으로 폴백합니다. `core.templates.layouts.edit` 권한이 필요합니다.
|
||||
|
||||
URI 가 `editor/components.css` 가 아니라 `editor/component-styles.css` 인 이유는 [자산 URL 이중 모드](README.md#자산-url-이중-모드) 때문입니다. 확장자를 뗀 형태(`editor/components`)가 `editor/components.json` 의 확장자 없는 형태와 충돌하므로 CSS 쪽 URI 를 분리했습니다. 확장자 없는 형태는 `editor/component-styles` 입니다.
|
||||
|
||||
|
||||
### GET /api/admin/templates/{identifier}/editor/components.json
|
||||
<!-- @generated:start:api.admin.templates.editor-components -->
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
3. URL: /api/admin/*, /api/auth/*, /api/public/*
|
||||
4. 권한: permission: 또는 Middleware에서 체크
|
||||
5. REST 패턴: index, store, show, update, destroy
|
||||
6. .js/.css/.json/.map 로 끝나는 라우트 = dualSuffix/dualSuffixSegment/dualAsset 매크로 필수
|
||||
```
|
||||
|
||||
---
|
||||
@@ -101,6 +102,51 @@ GET /api/admin/plugins/{identifier}/license # 플러그인 LICENSE 반환
|
||||
GET /api/admin/templates/{identifier}/license # 템플릿 LICENSE 반환
|
||||
```
|
||||
|
||||
### 정적 확장자로 끝나는 동적 엔드포인트
|
||||
|
||||
`.js` / `.css` / `.json` / `.map` 으로 끝나는 라우트는 `Route::get()` 으로 단일 등록하지 않는다.
|
||||
`Route::dualSuffix()` / `dualSuffixSegment()` / `dualAsset()` 매크로로 **확장자 형태와 확장자 없는 형태를
|
||||
동시에** 등록한다. 코어 라우트와 확장 라우트 파일 모두에 적용된다.
|
||||
|
||||
nginx/Apache 의 표준적 정적 최적화 블록은 URL 마지막 확장자로 분기하며, nginx 에서 정규식 location 은
|
||||
프리픽스 location 보다 먼저 매칭된다. 그래서 `try_files ... /index.php` 폴백이 실행될 기회 없이 nginx 가
|
||||
직접 파일시스템을 열려 시도해 404 가 된다. aaPanel / CyberPanel / Plesk 기본 템플릿에 들어있는 블록이라
|
||||
드물지 않으며, 그런 서버에서는 해당 엔드포인트가 통째로 죽는다.
|
||||
|
||||
```nginx
|
||||
location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
```
|
||||
|
||||
```php
|
||||
// 잘못된 등록 — 확장자 없는 형태가 생기지 않는다
|
||||
Route::get('{identifier}/routes.json', [Ctrl::class, 'getRoutes'])->name('...');
|
||||
|
||||
// 접미사 제거형 — routes.json + routes
|
||||
Route::dualSuffix('{identifier}/routes', 'json', [Ctrl::class, 'getRoutes'])
|
||||
->name('api.public.templates.routes');
|
||||
|
||||
// 세그먼트 강등형 — 접미사가 종류를 구분해 제거 불가 (bundle.js + bundle/js)
|
||||
Route::dualSuffixSegment('bundle', 'js', [Ctrl::class, 'serveBundleJs'])
|
||||
->name('api.public.modules.bundle.js');
|
||||
|
||||
// 쿼리 이동형 — 경로가 곧 파일명 (assets/{id}/js/a.js + assets/{id}?file=js/a.js)
|
||||
Route::dualAsset('assets/{identifier}', [Ctrl::class, 'serveAsset'])
|
||||
->name('api.public.templates.assets');
|
||||
```
|
||||
|
||||
매크로가 반환하는 프록시는 `name()` 을 양쪽에 적용하며(확장자 없는 쪽은 `.extensionless` 접미사),
|
||||
`middleware()` 등 나머지 호출도 두 라우트에 함께 전달한다. 한쪽에만 걸리는 가드가 생기지 않는다.
|
||||
|
||||
URL 을 만드는 쪽도 문자열로 직접 조립하지 않는다. 서버는 `App\Support\AssetUrl`, 프론트엔드는
|
||||
`resources/js/core/support/assetUrl.ts` 를 경유해야 현재 모드에 맞는 형태가 나온다. 두 구현은 동일 규칙을
|
||||
공유하므로 한쪽만 바꾸면 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 된다.
|
||||
|
||||
자동 차단: audit 룰 `dynamic-route-static-extension` (error). 면제는 인라인 주석
|
||||
`// audit:allow dynamic-route-static-extension reason: ...`.
|
||||
|
||||
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
|
||||
엔드포인트별 변환 규칙 표와 프로브 판정 절차: [API 레퍼런스 진입점](./api/README.md) "자산 URL 이중 모드".
|
||||
|
||||
---
|
||||
|
||||
## 권한 체크
|
||||
|
||||
@@ -608,7 +608,7 @@ Tailwind 는 빌드 시 safelist 에 없는 임의값 클래스(`dark:text-[#hex
|
||||
}
|
||||
```
|
||||
|
||||
코어 CSS 서빙 API(`/api/admin/templates/{id}/editor/components.css`)가 편집기 진입
|
||||
코어 CSS 서빙 API(`/api/admin/templates/{id}/editor/component-styles.css`)가 편집기 진입
|
||||
시에만 이 선언대로 CSS 의 다크 조상 셀렉터(`rewriteSelector`)를 프리뷰 전용 마커
|
||||
(`replaceWith`)로 치환해 서빙한다. 일반 사용자 페이지 CSS 는 원본 그대로다
|
||||
(사용자 페이지 무영향). `strategy: "none"` 또는 미선언이면 편집기 다크 탭이 비노출된다.
|
||||
|
||||
Reference in new issue
Block a user