feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다. 브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데 자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기 하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발 대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다. 런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다. 자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그 실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML 에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다. 편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다. 두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의 custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에 의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다. 확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을 고치면 그 변경을 감지해 재게시까지 예약된다. FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접 넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로 나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과 분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠 화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를 함께 뒀다. 동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에 써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
This commit is contained in:
1 parent
72f2e94c1a
commit
3945b6f1b3
1042 files changed
+107435
-947
No files matched your search
@@ -54,6 +54,8 @@
|
||||
| `vitest.config.ts` | Vitest 테스트 설정 |
|
||||
| `tsconfig.json` | TypeScript 설정 |
|
||||
|
||||
구동 에셋은 템플릿이 자체 제공한다. `template.json` 의 `externals` 는 제3자 CDN 주소(`url`)가 아니라 동봉 파일의 상대 경로(`asset`)로 선언하고, 라이브러리는 `dist/vendor/{lib}/{version}/` 에 원본 라이선스 파일과 함께 담는다. CDN 에 도달하지 못하는 환경에서는 아이콘·글꼴이 오류 표시 없이 사라져 화면이 조작 불능이 되기 때문이다. 자체 호스팅이 성립하지 않는 서비스 SDK 만 예외이며, 그때는 manifest 에 신뢰 호스트와 그 사유를 함께 선언한다. 상세: [template-basics.md](../../extension/template-basics.md) "외부 리소스 (externals)".
|
||||
|
||||
## 생성되는 디렉토리
|
||||
|
||||
- `src/components/basic/` — 기본 컴포넌트 (Div, Button, Input 등)
|
||||
|
||||
@@ -43,13 +43,12 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
## 2. 코어 훅 (CoreActivityLogListener)
|
||||
|
||||
**파일**: `app/Listeners/CoreActivityLogListener.php`
|
||||
**총 66훅** (스냅샷 캡처용 before 훅 포함)
|
||||
**총 66훅**
|
||||
|
||||
### User (9훅)
|
||||
### User (8훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.user.before_update` | `captureUserSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.user.after_create` | `handleUserAfterCreate` | `user.create` | Admin | User |
|
||||
| `core.user.after_update` | `handleUserAfterUpdate` | `user.update` | Admin | User |
|
||||
| `core.user.after_delete` | `handleUserAfterDelete` | `user.delete` | Admin | User |
|
||||
@@ -59,7 +58,7 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
| `core.user.after_search` | `handleUserAfterSearch` | `user.search` | Admin | - |
|
||||
| `sirsoft-core.user.after_bulk_update` | `handleUserAfterBulkUpdate` | `user.bulk_update` | Admin | - |
|
||||
|
||||
### Auth (6훅)
|
||||
### Auth (9훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
@@ -69,23 +68,34 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
| `core.auth.forgot_password` | `handleAuthForgotPassword` | `auth.forgot_password` | User | - |
|
||||
| `core.auth.reset_password` | `handleAuthResetPassword` | `auth.reset_password` | User | - |
|
||||
| `core.auth.record_consents` | `handleAuthRecordConsents` | `auth.record_consents` | User | User |
|
||||
| `core.auth.login_failed` | `handleAuthLoginFailed` | `auth.login_failed` | User | - |
|
||||
| `core.auth.account_locked` | `handleAuthAccountLocked` | `auth.account_locked` | User | User |
|
||||
| `core.auth.account_unlocked` | `handleAuthAccountUnlocked` | `auth.account_unlocked` | User | User |
|
||||
|
||||
### Role (6훅, 스냅샷 포함)
|
||||
### Identity (3훅)
|
||||
|
||||
본인인증(IDV) 훅은 DTO(`VerificationChallenge`/`VerificationResult`)를 인자로 받으므로 `sync => true` 로 등록된다 — 큐 직렬화 대상에 POPO 가 포함되지 않아 큐로 넘기면 `null` 이 전달된다.
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.identity.after_request` | `handleIdentityRequested` | `identity.request` | User | - |
|
||||
| `core.identity.after_verify` | `handleIdentityVerified` | `identity.verify` / `identity.verify_failed` | User | - |
|
||||
| `core.identity.challenge_expired` | `handleIdentityExpired` | `identity.expired` | User | - |
|
||||
|
||||
### Role (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.role.before_update` | `captureRoleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.role.after_create` | `handleRoleAfterCreate` | `role.create` | Admin | Role |
|
||||
| `core.role.after_update` | `handleRoleAfterUpdate` | `role.update` | Admin | Role |
|
||||
| `core.role.after_delete` | `handleRoleAfterDelete` | `role.delete` | Admin | Role |
|
||||
| `core.role.after_sync_permissions` | `handleRoleAfterSyncPermissions` | `role.sync_permissions` | Admin | Role |
|
||||
| `core.role.after_toggle_status` | `handleRoleAfterToggleStatus` | `role.toggle_status` | Admin | Role |
|
||||
|
||||
### Menu (7훅, 스냅샷 포함)
|
||||
### Menu (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.menu.before_update` | `captureMenuSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.menu.after_create` | `handleMenuAfterCreate` | `menu.create` | Admin | Menu |
|
||||
| `core.menu.after_update` | `handleMenuAfterUpdate` | `menu.update` | Admin | Menu |
|
||||
| `core.menu.after_delete` | `handleMenuAfterDelete` | `menu.delete` | Admin | Menu |
|
||||
@@ -100,11 +110,10 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
| `core.settings.after_save` | `handleSettingsAfterSave` | `settings.save` | Admin | - |
|
||||
| `core.settings.after_set` | `handleSettingsAfterSet` | `settings.set` | Admin | - |
|
||||
|
||||
### Schedule (7훅, 스냅샷 포함)
|
||||
### Schedule (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.schedule.before_update` | `captureScheduleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.schedule.after_create` | `handleScheduleAfterCreate` | `schedule.create` | Admin | Schedule |
|
||||
| `core.schedule.after_update` | `handleScheduleAfterUpdate` | `schedule.update` | Admin | Schedule |
|
||||
| `core.schedule.after_delete` | `handleScheduleAfterDelete` | `schedule.delete` | Admin | Schedule |
|
||||
@@ -152,6 +161,14 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
| `core.templates.after_version_update` | `handleTemplateAfterVersionUpdate` | `template.version_update` | Admin | - |
|
||||
| `core.templates.after_refresh_layouts` | `handleTemplateAfterRefreshLayouts` | `template.refresh_layouts` | Admin | - |
|
||||
|
||||
### Extension 공통 (1훅)
|
||||
|
||||
템플릿·모듈·플러그인 세 타입이 같은 훅을 공유한다 (권한도 `core.extensions.custom_assets.manage` 하나다).
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.custom_assets.after_change` | `handleCustomAssetAfterChange` | `custom_asset.{save\|upload\|delete}` | Admin | 운영자 CSS·JS 는 사이트 전 화면에서 실행되므로 변경 이력이 남아야 한다 |
|
||||
|
||||
### Layout (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|
||||
@@ -200,7 +200,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
## 코어 API 레퍼런스
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 36 · **엔드포인트 수**: 319
|
||||
- **문서 수**: 36 · **엔드포인트 수**: 324
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
@@ -213,7 +213,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
| [changelog.md](changelog.md) | `changelog` | 1 |
|
||||
| [core-update.md](core-update.md) | `core-update` | 2 |
|
||||
| [dashboard.md](dashboard.md) | `dashboard` | 5 |
|
||||
| [extensions.md](extensions.md) | `extensions` | 3 |
|
||||
| [extensions.md](extensions.md) | `extensions` | 8 |
|
||||
| [identity.md](identity.md) | `identity` | 27 |
|
||||
| [language-packs.md](language-packs.md) | `language-packs` | 15 |
|
||||
| [layouts.md](layouts.md) | `layouts` | 2 |
|
||||
|
||||
@@ -198,4 +198,334 @@ HTTP/1.1 200
|
||||
|
||||
**설명** 코어와 재호환된 확장을 원클릭으로 복구(재활성화)합니다. 경로의 `{type}`/`{identifier}`로 대상을 지정하며, 대상이 `IncompatibleCore` 사유로 자동 비활성화된 상태인지 검증한 뒤 코어 버전 재검증을 거쳐 활성화합니다. 잘못된 타입은 422, 미존재 확장은 404, hidden 확장이나 자동 비활성화가 아닌 경우는 error_code와 함께 422를 반환하고, 재검증 실패 시 글로벌 핸들러가 core_version_mismatch로 변환합니다. `core.plugins.activate` 권한이 필요합니다.
|
||||
|
||||
### GET /api/admin/extensions/{type}/{identifier}/custom-assets
|
||||
<!-- @generated:start:api.admin.extensions.custom-assets.index -->
|
||||
- **라우트명**: `api.admin.extensions.custom-assets.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminExtensionCustomAssetController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.extensions.custom_assets.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | `module` \| `plugin` \| `template` | 대상 확장 타입 |
|
||||
| identifier | path | string | 예 | — | 대상 확장 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/extensions/template/sirsoft-admin_basic/custom-assets HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| files | array | `[{"path":"10-overrides.css", …}]` | `custom/` 이하 파일 목록 (상대 경로 오름차순) |
|
||||
| files[].path | string | `10-overrides.css` | `custom/` 기준 상대 경로 |
|
||||
| files[].name | string | `10-overrides.css` | 파일명 |
|
||||
| files[].extension | string | `css` | 소문자 확장자 |
|
||||
| files[].size | integer | `42` | 바이트 크기 |
|
||||
| files[].modified_at | string | `2026-08-26T10:00:00+09:00` | 최종 수정 시각 (ISO 8601) |
|
||||
| files[].editable | boolean | `true` | 본문을 직접 편집할 수 있는지 (텍스트 형식만 true) |
|
||||
| files[].loaded | boolean | `true` | 실제로 페이지에 실리는지 (규약 스캔·선언 파일 판정 결과) |
|
||||
| editable_extensions | array | `["css","js","mjs","json"]` | 본문 편집이 가능한 확장자 |
|
||||
| uploadable_extensions | array | `["js","css","png","woff2", …]` | 업로드 허용 확장자 (자산 서빙 화이트리스트와 동일) |
|
||||
| max_text_bytes | integer | `524288` | 본문 편집 최대 크기 |
|
||||
| max_upload_bytes | integer | `5242880` | 업로드 최대 크기 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "목록을 불러왔습니다.",
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"path": "10-overrides.css",
|
||||
"name": "10-overrides.css",
|
||||
"extension": "css",
|
||||
"size": 42,
|
||||
"modified_at": "2026-08-26T10:00:00+09:00",
|
||||
"editable": true,
|
||||
"loaded": true
|
||||
}
|
||||
],
|
||||
"editable_extensions": ["css", "js", "mjs", "json"],
|
||||
"uploadable_extensions": ["js", "mjs", "css", "json", "png", "jpg", "jpeg", "svg", "webp", "gif", "woff", "woff2", "ttf", "otf", "eot"],
|
||||
"max_text_bytes": 524288,
|
||||
"max_upload_bytes": 5242880
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | `core.extensions.custom_assets.manage` 권한이 없는 경우 (레이아웃 편집 권한만으로는 통과하지 못한다) |
|
||||
| 404 | Not Found | `type` 이 `module`·`plugin`·`template` 이 아닌 경우 (라우트 정규식이 거부) |
|
||||
| 500 | Server Error | 디렉토리 조회 실패 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 운영자가 확장의 `custom/` 디렉토리에 넣은 파일 목록과 편집기 메타(허용 확장자·크기 상한)를 반환합니다. 규약 스캔이 자동으로 싣지 않는 폰트·이미지도 목록에 포함됩니다 — 목록에서 빠지면 지울 방법이 없어지기 때문입니다. 권한은 레이아웃 편집(`core.templates.layouts.edit`)과 분리되어 있습니다: 여기서 올린 스크립트는 레이아웃 한 장이 아니라 사이트 전 화면에서 실행됩니다.
|
||||
|
||||
|
||||
### GET /api/admin/extensions/{type}/{identifier}/custom-assets/content
|
||||
<!-- @generated:start:api.admin.extensions.custom-assets.show -->
|
||||
- **라우트명**: `api.admin.extensions.custom-assets.show`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminExtensionCustomAssetController@show`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.extensions.custom_assets.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | `module` \| `plugin` \| `template` | 대상 확장 타입 |
|
||||
| identifier | path | string | 예 | — | 대상 확장 식별자 |
|
||||
| path | query | string | 예 | 최대 255자 | `custom/` 기준 상대 경로 (상위 이동·절대 경로 불가) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/extensions/template/sirsoft-admin_basic/custom-assets/content?path=10-overrides.css HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| path | string | `10-overrides.css` | 요청한 상대 경로 |
|
||||
| content | string | `body { color: red; }` | 파일 본문 |
|
||||
| size | integer | `20` | 바이트 크기 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "목록을 불러왔습니다.",
|
||||
"data": {
|
||||
"path": "10-overrides.css",
|
||||
"content": "body { color: red; }",
|
||||
"size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | `core.extensions.custom_assets.manage` 권한이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 경로 검증 실패 / 파일 부재 / 편집 불가 형식 / 크기 초과 |
|
||||
| 500 | Server Error | 파일 읽기 실패 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 텍스트 형식(`css`·`js`·`mjs`·`json`) 파일의 본문을 반환합니다. 그 밖의 형식은 422 로 거부합니다 — 바이너리를 텍스트 편집기에 열면 저장 시 내용이 손상되기 때문입니다.
|
||||
|
||||
|
||||
### PUT /api/admin/extensions/{type}/{identifier}/custom-assets/content
|
||||
<!-- @generated:start:api.admin.extensions.custom-assets.store -->
|
||||
- **라우트명**: `api.admin.extensions.custom-assets.store`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminExtensionCustomAssetController@store`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.extensions.custom_assets.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | `module` \| `plugin` \| `template` | 대상 확장 타입 |
|
||||
| identifier | path | string | 예 | — | 대상 확장 식별자 |
|
||||
| path | body | string | 예 | 최대 255자, 확장자 `css`\|`js`\|`mjs`\|`json` | 저장할 상대 경로 (없으면 새로 만든다) |
|
||||
| content | body | string | 예 (빈 문자열 허용) | 최대 524288바이트 | 파일 본문 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/admin/extensions/template/sirsoft-admin_basic/custom-assets/content HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
{
|
||||
"path": "10-overrides.css",
|
||||
"content": "body { color: red; }"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| path | string | `10-overrides.css` | 저장한 상대 경로 |
|
||||
| size | integer | `20` | 저장된 바이트 크기 |
|
||||
| modified_at | string | `2026-08-26T10:00:00+09:00` | 저장 시각 (ISO 8601) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "저장했습니다.",
|
||||
"data": {
|
||||
"path": "10-overrides.css",
|
||||
"size": 20,
|
||||
"modified_at": "2026-08-26T10:00:00+09:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | `core.extensions.custom_assets.manage` 권한이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 경로 검증 실패(상위 이동·절대 경로·빈 세그먼트) / 편집 불가 형식 / 크기 초과 |
|
||||
| 500 | Server Error | 파일 쓰기 실패 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 텍스트 파일을 저장합니다(없으면 생성). 빈 본문 저장을 허용합니다 — 운영자가 CSS 를 통째로 비우는 것은 정당한 조작이고, 그것을 막으면 파일을 지우는 것 말고는 되돌릴 방법이 없어집니다. 저장 성공 시 확장 캐시 버전이 올라 정적 게시본이 다시 만들어지므로, 편집 결과는 다음 화면부터 반영됩니다.
|
||||
|
||||
|
||||
### POST /api/admin/extensions/{type}/{identifier}/custom-assets/upload
|
||||
<!-- @generated:start:api.admin.extensions.custom-assets.upload -->
|
||||
- **라우트명**: `api.admin.extensions.custom-assets.upload`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminExtensionCustomAssetController@upload`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.extensions.custom_assets.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | `module` \| `plugin` \| `template` | 대상 확장 타입 |
|
||||
| identifier | path | string | 예 | — | 대상 확장 식별자 |
|
||||
| file | body (multipart) | file | 예 | 최대 5MB, 자산 서빙 허용 확장자 | 올릴 파일 |
|
||||
| directory | body (multipart) | string | 아니오 | 최대 200자 | `custom/` 기준 하위 디렉토리 (없으면 바로 아래) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/extensions/template/sirsoft-admin_basic/custom-assets/upload HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: multipart/form-data; boundary=----g7
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
------g7
|
||||
Content-Disposition: form-data; name="file"; filename="brand.woff2"
|
||||
Content-Type: font/woff2
|
||||
|
||||
(binary)
|
||||
------g7
|
||||
Content-Disposition: form-data; name="directory"
|
||||
|
||||
fonts
|
||||
------g7--
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| path | string | `fonts/brand.woff2` | 저장된 상대 경로 |
|
||||
| size | integer | `18240` | 바이트 크기 |
|
||||
| modified_at | string | `2026-08-26T10:00:00+09:00` | 저장 시각 (ISO 8601) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "파일을 올렸습니다.",
|
||||
"data": {
|
||||
"path": "fonts/brand.woff2",
|
||||
"size": 18240,
|
||||
"modified_at": "2026-08-26T10:00:00+09:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | `core.extensions.custom_assets.manage` 권한이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 허용되지 않는 확장자 / 크기 초과 / 디렉토리 경로 검증 실패 |
|
||||
| 500 | Server Error | 파일 저장 실패 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 폰트·이미지 등 바이너리를 포함한 파일을 올립니다. 파일명은 안전한 문자(`A-Z a-z 0-9 . _ -`)로 정규화되며, 허용 확장자는 자산 서빙 화이트리스트(`AllowedTemplateFileType`)와 동일합니다 — 여기만 넓히면 올릴 수는 있는데 서빙되지 않는 파일이 생기고, 여기만 좁히면 서빙 규칙이 사문화됩니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/extensions/{type}/{identifier}/custom-assets
|
||||
<!-- @generated:start:api.admin.extensions.custom-assets.destroy -->
|
||||
- **라우트명**: `api.admin.extensions.custom-assets.destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\AdminExtensionCustomAssetController@destroy`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.extensions.custom_assets.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | `module` \| `plugin` \| `template` | 대상 확장 타입 |
|
||||
| identifier | path | string | 예 | — | 대상 확장 식별자 |
|
||||
| path | query | string | 예 | 최대 255자 | 삭제할 상대 경로 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/admin/extensions/template/sirsoft-admin_basic/custom-assets?path=10-overrides.css HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| path | string | `10-overrides.css` | 삭제한 상대 경로 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "파일을 삭제했습니다.",
|
||||
"data": {
|
||||
"path": "10-overrides.css"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | `core.extensions.custom_assets.manage` 권한이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 경로 검증 실패 / 파일 부재 / 삭제 실패 |
|
||||
| 500 | Server Error | 파일시스템 오류 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 파일을 삭제합니다. 편집 불가 형식(폰트·이미지)도 삭제 대상입니다 — 삭제까지 편집 확장자로 좁히면 올린 폰트를 지울 방법이 없어집니다. 삭제 후 확장 캐시 버전이 올라 정적 게시본에서도 제거됩니다.
|
||||
|
||||
|
||||
@@ -1139,14 +1139,29 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `success('module.uninstall_success')` 를 데이터 인자 없이 호출합니다._
|
||||
| 이름 | 타입 | 예시 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| preserved_backups | array | `[{"directory":"custom","archive":"…/extension-custom-backups/…"}]` | 삭제 전에 보관한 운영자 소유 디렉토리의 사본 목록. 보관 대상이 없으면 빈 배열 |
|
||||
| preserved_backups[].directory | string | `custom` | 보관된 디렉토리 이름 |
|
||||
| preserved_backups[].archive | string | `storage/app/extension-custom-backups/{identifier}-{Ymd_His}/custom` | 사본이 놓인 절대 경로 |
|
||||
|
||||
> 운영자가 `custom/` 에 넣은 파일은 확장 삭제와 함께 사라지지만, 삭제 직전에 사본이
|
||||
> 보관됩니다. 이 필드가 그 경로를 알리는 유일한 통로이므로 화면에 노출해야 합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "모듈이 성공적으로 제거되었습니다."
|
||||
"message": "모듈이 성공적으로 제거되었습니다.",
|
||||
"data": {
|
||||
"preserved_backups": [
|
||||
{
|
||||
"directory": "custom",
|
||||
"archive": "/var/www/g7/storage/app/extension-custom-backups/sirsoft-board-20260825_231500/custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1166,7 +1166,14 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._
|
||||
| 이름 | 타입 | 예시 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| preserved_backups | array | `[{"directory":"custom","archive":"…/extension-custom-backups/…"}]` | 삭제 전에 보관한 운영자 소유 디렉토리의 사본 목록. 보관 대상이 없으면 빈 배열 |
|
||||
| preserved_backups[].directory | string | `custom` | 보관된 디렉토리 이름 |
|
||||
| preserved_backups[].archive | string | `storage/app/extension-custom-backups/{identifier}-{Ymd_His}/custom` | 사본이 놓인 절대 경로 |
|
||||
|
||||
> 운영자가 `custom/` 에 넣은 파일은 확장 삭제와 함께 사라지지만, 삭제 직전에 사본이
|
||||
> 보관됩니다. 이 필드가 그 경로를 알리는 유일한 통로이므로 화면에 노출해야 합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -1174,7 +1181,14 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
{
|
||||
"success": true,
|
||||
"message": "플러그인이 성공적으로 제거되었습니다.",
|
||||
"data": null
|
||||
"data": {
|
||||
"preserved_backups": [
|
||||
{
|
||||
"directory": "custom",
|
||||
"archive": "/var/www/g7/storage/app/extension-custom-backups/sirsoft-gdpr-20260825_231500/custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1088,7 +1088,14 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`, 성공 메시지만)._
|
||||
| 이름 | 타입 | 예시 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| preserved_backups | array | `[{"directory":"custom","archive":"…/extension-custom-backups/…"}]` | 삭제 전에 보관한 운영자 소유 디렉토리의 사본 목록. 보관 대상이 없으면 빈 배열 |
|
||||
| preserved_backups[].directory | string | `custom` | 보관된 디렉토리 이름 |
|
||||
| preserved_backups[].archive | string | `storage/app/extension-custom-backups/{identifier}-{Ymd_His}/custom` | 사본이 놓인 절대 경로 |
|
||||
|
||||
> 운영자가 `custom/` 에 넣은 파일은 확장 삭제와 함께 사라지지만, 삭제 직전에 사본이
|
||||
> 보관됩니다. 이 필드가 그 경로를 알리는 유일한 통로이므로 화면에 노출해야 합니다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -1096,7 +1103,14 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
|
||||
{
|
||||
"success": true,
|
||||
"message": "템플릿이 성공적으로 제거되었습니다.",
|
||||
"data": null
|
||||
"data": {
|
||||
"preserved_backups": [
|
||||
{
|
||||
"directory": "custom",
|
||||
"archive": "/var/www/g7/storage/app/extension-custom-backups/sirsoft-basic-20260825_231500/custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -97374,7 +97388,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| error_config | object | `{"layouts":{"401":"errors\/401","403":"errors\/403","404"…` | 에러 표시 설정 객체 |
|
||||
| github_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 저장소 URL |
|
||||
| github_changelog_url | string | `https://github.com/gnuboard/g7-templa…` | GitHub 변경 내역 URL |
|
||||
| externals | array | `[{"id":"fontawesome","type":"style","url":"https:\/\/cdnj…` | 외부 의존 라이브러리 목록 (번들에서 제외되고 전역에서 해석) |
|
||||
| externals | array | `[{"id":"fontawesome","type":"style","url":"\/api\/templat…` | 외부 의존 라이브러리 목록 (번들에서 제외되고 전역에서 해석) |
|
||||
| cache_version | integer | `1784000969` | 에셋 캐시 무효화 버전 (번들 URL 파일명에 포함) |
|
||||
|
||||
**응답 예시**
|
||||
@@ -97519,20 +97533,17 @@ HTTP/1.1 200
|
||||
{
|
||||
"id": "fontawesome",
|
||||
"type": "style",
|
||||
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
|
||||
"preconnect": "https://cdnjs.cloudflare.com"
|
||||
"url": "/api/templates/assets/sirsoft-admin_basic?file=vendor%2Ffont-awesome%2F6.4.0%2Fcss%2Fall.inlined.css&v=1785848038"
|
||||
},
|
||||
{
|
||||
"id": "pretendard",
|
||||
"type": "webfont",
|
||||
"url": "https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css",
|
||||
"preconnect": "https://cdn.jsdelivr.net",
|
||||
"crossorigin": "anonymous"
|
||||
"url": "/api/templates/assets/sirsoft-admin_basic?file=vendor%2Fpretendard%2F1.3.9%2Fpretendard-variable.css&v=1785848038"
|
||||
},
|
||||
{
|
||||
"id": "flag-icons",
|
||||
"type": "style",
|
||||
"url": "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
|
||||
"url": "/api/templates/assets/sirsoft-admin_basic?file=vendor%2Fflag-icons%2F7.2.3%2Fcss%2Fflag-icons.min.css&v=1785848038"
|
||||
}
|
||||
],
|
||||
"cache_version": 1785848038
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 terminating 훅이 재생성
|
||||
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 custom 파일 변경마다 terminating 훅이 재생성 (활성 템플릿 + 활성 모듈·플러그인 custom + 병합 번들)
|
||||
2. 서빙 게이트 3조건: 프로덕션 + G7_STATIC_CACHE(기본 on) + 게시 완료(manifest 존재)
|
||||
3. 폴백 2층: 태그 계층은 파일 단위 file_exists + 파샬 역변환, fetch 계층은 fetchStaticFirst
|
||||
4. 무효화는 버전 디렉토리 — 포인터(cache_version)가 바뀔 뿐 파일 덮어쓰기가 없다
|
||||
@@ -42,6 +42,38 @@ public/build/ext/{cache_version}/
|
||||
- 쓰기 안전: 식별자(vendor-name)·로케일 패턴 화이트리스트, dist 복사는 허용 확장자 화이트리스트(자산 서빙 검증 규칙과 동일 목록) + `*.map` 제외 + realpath 컨테인먼트.
|
||||
- `config.json` 과 레이아웃 JSON 은 게시 대상이 **아니다** — 전자는 버전 핸드셰이크의 SSoT(항상 신선해야 함), 후자는 인증 문맥(optional.sanctum) 의존.
|
||||
|
||||
## 2-1. 동봉 자산(`dist/vendor/`)과 운영자 자산(`custom/`)
|
||||
|
||||
확장이 구동에 필요해 함께 담는 제3자 자산은 `dist/vendor/{라이브러리}/{버전}/` 에 둡니다.
|
||||
템플릿의 `dist/**` 는 정적 게시 대상이므로 동봉 자산도 웹서버가 직접 서빙합니다.
|
||||
|
||||
운영자가 덧붙이는 자산(`custom/`)도 **모듈·플러그인·템플릿 모두 같은 방식으로 게시됩니다.**
|
||||
게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대 `url()` 이 해석되지
|
||||
않습니다 — 쿼리 형태(`?file=`)는 기준 URL 이 `/api/{타입}/assets/` 라 `url('./font.woff2')` 가
|
||||
그 디렉토리를 가리키고, 확장자 형태는 정적 최적화 서버가 먼저 가로챕니다. 즉 정적 확장자
|
||||
URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, "폰트·이미지를 `custom/` 에 두고 상대
|
||||
경로로 참조" 를 성립시키는 방법은 게시뿐입니다.
|
||||
|
||||
갱신 축도 확장 자산과 같습니다. 운영자가 파일을 고치면 뷰 컴포저가 파일 서명 변화를 감지해
|
||||
확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약합니다. 그 요청은 서술자를 새 버전으로
|
||||
다시 해석하므로 고친 내용이 그 화면부터 반영됩니다(게시본이 아직 없는 동안에는 API 경로로
|
||||
떨어지고, 그 응답이 디스크 최신 내용을 줍니다).
|
||||
|
||||
모듈·플러그인은 **활성 확장의 `custom/` 만** 게시합니다 — 자산 서빙이 활성 확장에만 응답하므로
|
||||
비활성 확장의 파일을 게시해 봐야 아무도 참조하지 않는 사본이 버전 디렉토리마다 쌓입니다.
|
||||
확장의 빌드 산출물은 개별 파일이 아니라 병합 번들로 게시되므로, `custom/` 이 그 확장에서
|
||||
유일한 개별 게시 대상입니다.
|
||||
|
||||
버전 디렉토리를 쓰는 이유는 업그레이드 시 구버전 삭제 대상이 명확해지기 때문입니다.
|
||||
|
||||
동봉 자산을 다시 만드는 절차는 명령으로 고정하고(`npm run vendor:*`), 그 명령은 **설치된
|
||||
패키지 버전과 출력 경로의 버전 디렉토리를 대조한 뒤에만** 씁니다. 손으로 복사하면 어느
|
||||
버전을 옮겼는지가 어디에도 남지 않아, 설치 트리가 갱신된 뒤 다시 복사하는 순간 `7.2.3/`
|
||||
디렉토리에 7.5.0 이 들어앉습니다 — 오류도 로그도 없이 배포본의 버전 라벨만 틀리고,
|
||||
파일은 정상으로 서빙되므로 브라우저에서도 드러나지 않습니다. 두 버전이 어긋나거나
|
||||
설치 패키지의 `package.json` 이 없어 확인할 수 없으면 생성기는 아무것도 쓰지 않고
|
||||
중단합니다. 재생성 전에는 `npm ci` 로 lock 에 선언된 버전을 설치합니다.
|
||||
|
||||
## 3. 서빙·폴백 모델
|
||||
|
||||
- 실파일이므로 Apache(`RewriteCond !-f`)/nginx(`try_files $uri`) 어느 쪽이든 서버 설정 추가 없이 rewrite 전에 직접 서빙된다. 정적 확장자 정규식 location(`location ~* \.(js|css|json)$`)이 있는 서버에서는 그 location 이 곧 서빙 메커니즘이 된다.
|
||||
@@ -57,10 +89,25 @@ public/build/ext/{cache_version}/
|
||||
| 트리거 | 지점 | 방식 |
|
||||
|---|---|---|
|
||||
| 수명주기 전체 | `incrementExtensionCacheVersion()` 내부 | terminating 게시 예약 — 프로세스당 1회, **실행 시점의 최종 버전**으로 게시 (연속 bump 자연 병합) |
|
||||
| 자가 치유 | blade 렌더의 `staticExtBase()` 게이트 | 현재 버전 미게시 감지 시 terminating 게시 예약. 이번 응답은 API URL (첫 방문자 1회만 종전 속도) |
|
||||
| 자가 치유 | blade 렌더의 `staticExtBase()` 게이트 | 현재 버전 미게시 감지 시 terminating 게시 예약. 이번 응답은 API URL — 아래 「자가 치유 창의 실제 비용」 참조 |
|
||||
| 수동/워밍 | `php artisan ext-static:publish [--force]` | 설치기 완료 단계에서도 호출 |
|
||||
| GC | `php artisan ext-static:cleanup` + 게시 성공 직후 인라인 GC | 현재 + 직전 1개 보존. 스케줄 일 1회 등록 |
|
||||
|
||||
### 자가 치유 창의 실제 비용
|
||||
|
||||
확장 수명주기 작업(설치·활성화·비활성화·삭제·업데이트)은 CLI 에서 캐시 버전만 올리고 게시는 다음 웹 렌더에 위임한다. 그래서 그 **직후 첫 페이지 로드 1회**는 API URL 로 나간다.
|
||||
|
||||
이 창의 비용은 속도만이 아니다. `general.asset_url_mode` 가 `extensionless` 인 서버에서는 자산 URL 이 쿼리 형태(`?file=`)가 되는데, **CSS 안의 상대 경로 `url()` 은 그 형태에서 해석되지 않는다.** 실측(관리자 대시보드):
|
||||
|
||||
| 자산 | 게시본 사용 | 자가 치유 창 (API 폴백) |
|
||||
| --- | --- | --- |
|
||||
| 아이콘 폰트 (CSS 에 인라인) | 정상 | **정상** — 하위 파일 참조가 없다 |
|
||||
| 본문 글꼴 (`pretendard-variable.css` → `woff2/…`) | 정상 | **404** — FontFace `status: "error"`, 시스템 글꼴로 대체 |
|
||||
|
||||
기능이 사라지지는 않는다(조작 수단인 아이콘은 인라인이라 영향 없음). 화면이 한 번 다른 글꼴로 보이고, 그 뒤 게시가 끝나면 정상으로 돌아온다.
|
||||
|
||||
이 창을 없애려면 수명주기 작업 뒤에 `php artisan ext-static:publish` 를 명시적으로 실행한다. 다만 그 명령을 **웹 계정이 아닌 사용자로** 돌리면 게시 산출물 소유권이 어긋날 수 있으므로, 위 「소유권」 절의 주의사항을 함께 본다.
|
||||
|
||||
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
|
||||
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
|
||||
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 `public, max-age` 를 부여하지 않는다 (브라우저/CDN 박제 방지).
|
||||
|
||||
@@ -202,6 +202,17 @@ core.settings.available_mail_drivers
|
||||
# 드라이버 확장 훅 (Action) — 플러그인 드라이버 선택 시 Config 적용
|
||||
core.settings.apply_driver_config
|
||||
|
||||
# 사용자 추가 에셋 훅 (Filter) — 운영자가 확장에 덧붙인 CSS·JS 목록을 보정/추가
|
||||
core.assets.custom_assets # applyFilters($assets, $extensionType, $identifier)
|
||||
|
||||
# 사용자 추가 에셋 관리 훅 (Action) — 화면에서 파일을 저장/업로드/삭제한 직후
|
||||
core.custom_assets.after_change # doAction($extensionType, $identifier, $operation, $path)
|
||||
|
||||
# 사용자 추가 에셋 관리 검증 훅 (Filter) — 관리 API 의 FormRequest 규칙 확장
|
||||
core.extension_custom_asset.read_validation_rules
|
||||
core.extension_custom_asset.save_validation_rules
|
||||
core.extension_custom_asset.upload_validation_rules
|
||||
|
||||
# SEO 렌더링 훅 (Filter)
|
||||
core.seo.filter_context # DataSource 결합 후 컨텍스트 보강 ($context 배열)
|
||||
core.seo.filter_og_data # OG 태그 분기별 hook ($og 배열) — image_width/site_name/extra 주입
|
||||
|
||||
@@ -127,19 +127,35 @@
|
||||
|
||||
#### `trusted_script_hosts` — 외부 스크립트 신뢰 호스트
|
||||
|
||||
**구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다.** 브라우저가 화면을
|
||||
그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도 남기지 않고 화면 기능만
|
||||
조용히 사라집니다 — 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어
|
||||
운영자가 원인을 특정할 수 없습니다. 동봉 위치는 `dist/vendor/{라이브러리}/{버전}/` 입니다.
|
||||
|
||||
이 필드는 **자체 제공이 불가능한 경우**에만 씁니다 — 라이브러리가 아니라 그 회사 서버와
|
||||
통신하는 서비스 SDK(주소 검색 등)가 그렇습니다. 자체 호스팅해도 동작하지 않으므로 외부
|
||||
의존이 남습니다.
|
||||
|
||||
레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin
|
||||
경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은
|
||||
저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면
|
||||
그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 —
|
||||
manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"trusted_script_hosts": ["cdn.ckeditor.com"]
|
||||
}
|
||||
```
|
||||
- 값은 호스트명만(스킴/경로 없이). 예: `"t1.daumcdn.net"`.
|
||||
- **`trusted_script_hosts_reason` 에 호스트별 사유를 함께 선언합니다.** 사유가 없는 선언은
|
||||
자체 제공 원칙의 예외로 인정되지 않습니다 — 왜 외부로 나가는지가 코드에 남아야 합니다.
|
||||
|
||||
- 값은 호스트명만(스킴/경로 없이). 예: `"cdn.ckeditor.com"`, `"t1.daumcdn.net"`.
|
||||
```jsonc
|
||||
{
|
||||
"trusted_script_hosts": ["t1.daumcdn.net"],
|
||||
"trusted_script_hosts_reason": {
|
||||
"t1.daumcdn.net": "라이브러리가 아니라 Daum 이 운영하는 서비스 SDK 다. 스크립트가 Daum 서버와 통신하므로 자체 호스팅해도 동작하지 않는다."
|
||||
}
|
||||
}
|
||||
```
|
||||
- 외부 의존이 남는 기능은 **그 자산을 못 불러왔을 때의 동작**을 함께 갖춰야 합니다. 예: 주소
|
||||
검색 SDK 가 없으면 우편번호·주소를 직접 입력할 수 있게 두고 안내를 띄웁니다.
|
||||
- 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 `g7_version` 을 `>=7.0.7` 로 두는
|
||||
것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해).
|
||||
- 관련 보안 정책 상세: [frontend/security.md](../frontend/security.md).
|
||||
@@ -568,7 +584,45 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
|
||||
## 외부 라이브러리
|
||||
|
||||
외부 CDN 스크립트를 조건부로 로드할 수 있습니다.
|
||||
라이브러리는 확장이 함께 담아 자체 제공합니다(`dist/vendor/{라이브러리}/{버전}/`). 외부 CDN
|
||||
실시간 로드는 그 CDN 에 도달하지 못하는 환경에서 기능이 조용히 사라지게 만듭니다.
|
||||
|
||||
아래 조건부 외부 로드는 **자체 제공이 불가능한 서비스 SDK** 에만 씁니다. 그 경우 manifest 에
|
||||
`trusted_script_hosts` 와 `trusted_script_hosts_reason` 을 함께 선언해야 하며, 자산을 못
|
||||
불러왔을 때의 동작도 함께 갖춰야 합니다.
|
||||
|
||||
### 동봉 자산의 URL 생성
|
||||
|
||||
동봉한 자산을 런타임에 불러올 때는 URL 을 문자열로 조립하지 않고 `G7Core.asset` 을 씁니다.
|
||||
확장 번들은 코어 모듈을 import 할 수 없으므로 이 전역이 유일한 통로입니다.
|
||||
|
||||
```javascript
|
||||
// 플러그인 — 확장 루트 기준이라 dist/ 를 포함한다
|
||||
const url = G7Core.asset.plugin('sirsoft-ckeditor5', 'dist/vendor/ckeditor5/43.3.1/ckeditor5.umd.js');
|
||||
await G7Core.asset.loadScript(url, { id: 'ckeditor5' });
|
||||
|
||||
// 모듈
|
||||
G7Core.asset.module('sirsoft-board', 'dist/vendor/chart.js/4.4.0/chart.umd.js');
|
||||
|
||||
// 템플릿 — 서버가 dist/ 를 자동으로 붙이므로 path 에 포함하지 않는다
|
||||
G7Core.asset.template('sirsoft-admin_basic', 'vendor/flag-icons/7.2.3/css/flag-icons.min.css');
|
||||
```
|
||||
|
||||
자산 URL 은 서버 설정과 정적 게시 상태에 따라 확장자 형태(`.../lib.js`), 쿼리 형태
|
||||
(`...?file=...`), 정적 게시본 경로(`/build/ext/{버전}/...`) 중 하나로 해석됩니다. 문자열로
|
||||
조립하면 그 판정을 건너뛰어, 정규식 location 이 확장자를 먼저 가로채는 서버에서 그 자산만
|
||||
조용히 404 가 됩니다.
|
||||
|
||||
AMD 로더나 워커처럼 디렉토리 접두 뒤에 파일명을 이어 붙이는 소비자는 `templateDir()` 을
|
||||
씁니다 — 쿼리 형태는 뒤에 파일명을 이어 붙일 수 없기 때문입니다. 확장자 없는 모드에서 404
|
||||
일 수 있으므로 그 소비자는 폴백을 갖춰야 합니다.
|
||||
|
||||
로드에 끝내 실패하면 `G7Core.assets.notifyFailure({ id, label, retry })` 로 사용자에게
|
||||
알립니다. `console.error` 한 줄로 끝내면 사용자에게는 빈 자리로만 나타나고 자체 서버 로그에도
|
||||
흔적이 남지 않아 운영자가 원인을 특정할 수 없습니다.
|
||||
|
||||
전체 시그니처와 `AssetFailure` 필드는
|
||||
[G7Core 전역 API 레퍼런스](../frontend/g7core-api.md)의 「확장 자산」 절을 참조하세요.
|
||||
|
||||
### module.json 설정
|
||||
|
||||
@@ -603,6 +657,149 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
|
||||
---
|
||||
|
||||
## 사용자 추가 에셋 (`custom/`)
|
||||
|
||||
운영자가 자기 CSS·JS·정적 파일을 덧붙일 자리를 각 확장이 제공한다.
|
||||
|
||||
종전에는 그런 자리가 없었다. CSS 한 줄을 더하려면 확장 소스(`src/styles/`)를 고치고 Node.js 로 빌드해야 했고 — 그렇게 넣은 파일은 다음 확장 업데이트에 **통째로 사라졌다**(확장 교체가 활성 디렉토리를 전부 갈아끼우기 때문이다). 빌드 산출물(`dist/`)을 직접 고치는 것도 다음 빌드에 사라진다.
|
||||
|
||||
### 자리
|
||||
|
||||
```
|
||||
templates/{id}/custom/ ← 운영자 소유. 확장 교체가 보존한다
|
||||
modules/{id}/custom/
|
||||
plugins/{id}/custom/
|
||||
├── custom.css 규약 자동 로드 (파일명 오름차순)
|
||||
├── 10-override.css
|
||||
├── custom.js
|
||||
├── fonts/MyFont.woff2 정적 파일 — CSS 가 상대 경로로 참조
|
||||
└── assets.json (선택) 선언이 있으면 이것이 우선
|
||||
```
|
||||
|
||||
- 빌드하지 않는다. 파일을 놓으면 다음 요청부터 적용된다.
|
||||
- 확장 업데이트·재설치가 이 디렉토리만은 보존한다.
|
||||
- 확장을 **삭제**하면 확장 디렉토리와 함께 사라진다. 삭제 전 백업을 안내한다.
|
||||
- 번들 확장은 `custom/` 을 담아 배포하지 않는다 — 보존 계층이 덮어쓰지 않으므로 그 파일은 기존 설치본에 영영 반영되지 않는다. 확장이 담을 자산은 `dist/vendor/{lib}/{version}/` 에 둔다.
|
||||
|
||||
### 선언 파일 (`custom/assets.json`)
|
||||
|
||||
순서를 바꾸거나, 일부만 싣거나, 외부 URL 을 등록할 때만 쓴다. 필드는 `template.json` 의 `externals` 와 같은 어휘다.
|
||||
|
||||
```json
|
||||
{
|
||||
"assets": [
|
||||
{ "type": "style", "file": "10-override.css" },
|
||||
{ "type": "script", "file": "custom.js" },
|
||||
{ "type": "style", "url": "https://fonts.example.com/x.css", "reason": "본문 웹폰트" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `file` 은 `custom/` 기준 상대 경로다. 상위 디렉토리 이탈은 차단된다.
|
||||
- `url` 은 운영자가 자기 사이트에 직접 등록하는 외부 자산이다. **`reason`(사유)이 없으면 싣지 않는다** — 왜 외부로 나가는지가 파일에 남아야 한다. 외부 서비스에는 방문자의 IP·UA 가 전달된다.
|
||||
- 선언 파일이 있으면 규약 스캔은 하지 않는다. 둘을 합치면 "선언에서 뺐는데 왜 아직 로드되나" 가 된다.
|
||||
- 선언 파일이 깨졌으면(JSON 파싱 실패) **규약 스캔으로 되돌아가지 않고** 그 확장의 custom 을 비운다. 되돌아가면 운영자가 의도적으로 뺀 파일이 되살아난다.
|
||||
|
||||
### 로드 순서
|
||||
|
||||
```
|
||||
① 템플릿 외부 리소스 → 코어 엔진 → 템플릿 CSS
|
||||
② 확장 병합 번들 (모듈 → 플러그인)
|
||||
③ 사용자 추가 에셋 (모듈 → 플러그인 → 템플릿) ← 언제나 마지막
|
||||
```
|
||||
|
||||
CSS 는 나중에 온 규칙이 이긴다. 운영자가 덧붙인 스타일이 확장 스타일보다 뒤에 와야 재정의가 성립한다. 템플릿이 ③ 의 마지막인 이유는 화면 외관의 최종 책임이 템플릿에 있어서다.
|
||||
|
||||
같은 확장 안에서는 선언 순서(없으면 파일명 오름차순), CSS 를 JS 보다 먼저 싣는다.
|
||||
|
||||
사용자 추가 에셋은 JS 부팅 이후에 붙으므로 아주 짧은 스타일 적용 지연이 있다. 이는 확장 번들 CSS 가 이미 갖고 있는 성질이며, 순서 정합을 깨는 것보다 낫다.
|
||||
|
||||
### 캐시 무효화
|
||||
|
||||
파일을 고치면 URL 이 바뀐다 — 캐시를 지우라고 안내할 필요가 없다. 다만 그 방법이 확장 타입에 따라 다르다.
|
||||
|
||||
**세 타입 모두** 확장 자산과 **같은 메커니즘**으로 정적 게시된다. 그래서 URL 도 같은 축(확장 캐시 버전)을 쓴다 — 정적 경로는 언제나 현재 게시 버전이므로, 파일 서명을 URL 에 실으면 버전 일치 게이트에 걸려 정적 분기가 영영 선택되지 않는다. 대신 운영자가 파일을 고치면 뷰 컴포저가 그 변화를 감지해 확장 캐시 버전을 올리고, 그 단일 지점이 재게시까지 예약한다.
|
||||
|
||||
이 게시가 필요한 이유는 성능이 아니라 **상대 경로**다. API 경로에서는 CSS 내부 `url('./font.woff2')` 가 해석되지 않는다(기준 URL 이 자산 디렉토리가 아니다). 정적 확장자 URL 은 public 아래 실제 파일일 때만 200 이 되므로, 게시본만이 상대 참조를 성립시킨다.
|
||||
|
||||
**모듈·플러그인**도 같다. 다만 게시 대상은 **활성** 확장의 `custom/` 뿐이다 — 자산 서빙이 활성 확장에만 응답하므로 비활성 확장의 파일을 게시해 봐야 아무도 참조하지 않는 사본이 쌓인다. 확장의 빌드 산출물은 개별 파일이 아니라 병합 번들로 게시되므로, `custom/` 이 그 확장에서 유일한 개별 게시 대상이다.
|
||||
|
||||
### 확장하기
|
||||
|
||||
해석기는 **출처에 의존하지 않는 서술자**를 돌려준다.
|
||||
|
||||
```php
|
||||
['id' => 'custom:templates:sirsoft-basic:10-override.css',
|
||||
'type' => 'style', // style | script
|
||||
'url' => '/api/templates/assets/sirsoft-basic?file=custom%2F10-override.css&v=…',
|
||||
'version' => 1787660208,
|
||||
'source' => 'file'] // file | url | (확장이 더한 출처)
|
||||
```
|
||||
|
||||
소비자(뷰 컴포저·프론트 로더·서빙·순서 규칙)는 `source` 를 보지 않는다. 다른 출처를 더하려면 해석기 끝의 필터 훅을 쓴다.
|
||||
|
||||
```php
|
||||
HookManager::addFilter('core.assets.custom_assets', function (array $assets, string $type, string $id): array {
|
||||
$assets[] = [
|
||||
'id' => 'custom:my-source:'.$id,
|
||||
'type' => 'style',
|
||||
'url' => '/api/my-endpoint.css',
|
||||
'version' => $updatedAt,
|
||||
'source' => 'my-source',
|
||||
];
|
||||
|
||||
return $assets;
|
||||
}, 10, 3);
|
||||
```
|
||||
|
||||
훅으로 더한 항목도 자기 `version` 을 실어야 한다 — 캐시 서명이 항목별 버전의 합성이기 때문이다.
|
||||
|
||||
### 화면에서 관리하기 (레이아웃 편집기)
|
||||
|
||||
FTP 나 서버 셸이 유일한 경로였다면, 그 접근이 없는 운영자에게는 이 기능이 없는 것과
|
||||
같다. 레이아웃 편집기 상단의 [커스텀 자산] 버튼이 같은 디렉토리를 화면에서 다룬다 —
|
||||
텍스트(`css`·`js`·`mjs`·`json`)는 본문을 열어 고치고, 폰트·이미지는 올리고 지운다.
|
||||
모달 상단의 대상 선택기로 **편집 중인 템플릿과 활성 모듈·플러그인**을 오갈 수 있다.
|
||||
|
||||
- 저장·업로드·삭제는 확장 캐시 버전을 올려 **정적 게시본까지 갱신**한다. 파일만 바꾸고
|
||||
게시본이 그대로면 운영자에게는 "고쳤는데 화면이 그대로" 로만 나타난다.
|
||||
- 바이너리는 본문 편집기를 열지 않는다. 텍스트로 열어 저장하면 내용이 손상된다.
|
||||
- 업로드 허용 확장자는 자산 서빙 화이트리스트와 **같은 목록**이다. 관리 쪽만 넓히면
|
||||
올릴 수는 있는데 서빙되지 않는 파일이 생기고, 좁히면 서빙 규칙이 사문화된다.
|
||||
|
||||
권한은 레이아웃 편집(`core.templates.layouts.edit`)과 **분리**된
|
||||
`core.extensions.custom_assets.manage` 하나다. 확장 타입별로 쪼개지 않는다 — 쪼개면 운영자가
|
||||
셋을 다 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는" 상태가 실질적 의미 없이
|
||||
생긴다. 여기서 올린 스크립트는 그 레이아웃 한 장이
|
||||
아니라 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수
|
||||
없다. 기존 사이트에는 코어 업데이트의 표준 권한 동기화로 도달한다(관리자 역할은 자동 부여).
|
||||
|
||||
API 레퍼런스: [docs/backend/api/extensions.md](../backend/api/extensions.md) 의
|
||||
`custom-assets` 엔드포인트 5종. 세 타입이 한 엔드포인트
|
||||
(`extensions/{type}/{identifier}/custom-assets`)를 공유한다 — 타입별로 나누면 같은 검증·문서·
|
||||
테스트가 세 벌로 갈리고, 그중 하나만 약해지면 그 경로가 조용한 우회로가 된다.
|
||||
|
||||
### 자기 CSS 에 갇히지 않기 (`?custom=off`)
|
||||
|
||||
운영자가 넣은 CSS 한 줄이 화면을 조작 불능으로 만들 수 있다. 그런데 그것을 고칠 관리자
|
||||
화면에도 같은 CSS 가 실려 있어, 고치러 들어갈 수가 없다.
|
||||
|
||||
주소에 `?custom=off` 를 붙여 다시 열면 **서버가 목록을 비운다.** 자산이 페이지에
|
||||
도달하지 않으므로, 이미 깨진 화면에서 자바스크립트가 돌기를 기대하지 않아도 된다.
|
||||
레이아웃 편집기 툴바에도 같은 동작의 토글이 있고, 꺼진 동안에는 지금 화면이 평소와 다른
|
||||
상태임을 버튼이 드러낸다.
|
||||
|
||||
이 파라미터는 그 요청 한 번에만 작용하며 저장되지 않는다. 화면을 고친 뒤 파라미터 없이
|
||||
열면 곧바로 원래대로 돌아온다.
|
||||
|
||||
### 지원하지 않는 것
|
||||
|
||||
| 방법 | 판정 |
|
||||
|---|---|
|
||||
| `dist/css/components.css` 직접 수정 | 다음 빌드·업데이트에 소실된다 |
|
||||
| `src/styles/custom.css` + 빌드 | 확장 **저작자**의 방법으로는 유효하다. 운영자에게는 부적합(빌드 필요 + 업데이트 소실) |
|
||||
| `custom/custom.css` 에 파일만 놓기 | 권장 |
|
||||
|
||||
## AbstractModule 에셋 메서드
|
||||
|
||||
AbstractModule은 에셋 관련 헬퍼 메서드를 제공합니다:
|
||||
|
||||
@@ -394,6 +394,18 @@ core.users.view
|
||||
core.users.manage
|
||||
```
|
||||
|
||||
`[entity]` 가 특정 확장 타입이 아니라 `extensions` 인 권한은 **템플릿·모듈·플러그인 세 타입이 공유**한다.
|
||||
타입별로 쪼개면 운영자가 같은 성격의 권한을 셋 다 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는
|
||||
안 되는" 상태가 실질적 의미 없이 생긴다.
|
||||
|
||||
```text
|
||||
core.extensions.custom_assets.manage
|
||||
```
|
||||
|
||||
이 권한은 그룹 `core.extensions`(확장 공통) 에 속한다. 레이아웃 편집 권한
|
||||
(`core.templates.layouts.edit`) 과 분리한 이유는 여기서 올린 스크립트가 레이아웃 한 장이 아니라
|
||||
**사이트 전 화면에서 실행**되기 때문이다 — 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다.
|
||||
|
||||
### 모듈 권한
|
||||
|
||||
```text
|
||||
|
||||
@@ -228,8 +228,7 @@ public function restoreVersion(int $layoutId, int $version): TemplateLayout
|
||||
{
|
||||
"id": "fontawesome",
|
||||
"type": "style",
|
||||
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
|
||||
"preconnect": "https://cdnjs.cloudflare.com"
|
||||
"asset": "vendor/font-awesome/6.4.0/css/all.inlined.css"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -249,15 +248,31 @@ public function restoreVersion(int $layoutId, int $version): TemplateLayout
|
||||
|
||||
### 외부 리소스 (externals)
|
||||
|
||||
`externals`는 admin/user 템플릿에 공통 적용되는 선택 필드입니다. 최초 HTML 문서에 정적으로 필요한 외부 스타일, 웹폰트, 스크립트, 리소스 힌트를 선언합니다. `external_styles`는 사용하지 않습니다.
|
||||
`externals`는 admin/user 템플릿에 공통 적용되는 선택 필드입니다. 최초 HTML 문서에 정적으로 필요한 스타일, 웹폰트, 스크립트, 리소스 힌트를 선언합니다. `external_styles`는 사용하지 않습니다.
|
||||
|
||||
자산을 가리키는 방법은 `asset`과 `url` 두 가지이며, 항목마다 **하나를 고릅니다**.
|
||||
|
||||
- `asset` — 템플릿이 **자체 제공**하는 파일의 `dist/` 이하 경로. 구동 에셋은 자체 제공이 원칙이므로 이쪽이 기본입니다.
|
||||
- `url` — 절대 URL. 자체 제공이 불가능한 외부 서비스에만 씁니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "style",
|
||||
"id": "font-awesome",
|
||||
"asset": "vendor/font-awesome/6.4.0/css/all.inlined.css"
|
||||
}
|
||||
```
|
||||
|
||||
구동에 필요한 에셋을 제3자 CDN에서 실시간으로 받으면, 그 도달 실패는 예외도 로그도 남기지 않고 화면 기능만 사라집니다. 폐쇄망·방화벽·광고차단기 환경에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없습니다. 라이브러리·웹폰트·아이콘은 `dist/vendor/{lib}/{version}/`에 동봉하고 `asset`으로 가리킵니다.
|
||||
|
||||
| 속성 | 타입 | 필수 | 적용 type | 허용값/형식 | 렌더링/동작 |
|
||||
|---|---:|---:|---|---|---|
|
||||
| `id` | string | 권장 | all | 영문/숫자/`-`/`_` | HTML `id` 속성 |
|
||||
| `type` | string | 필수 | all | `style`, `webfont`, `script`, `preconnect`, `dns-prefetch`, `preload`, `modulepreload` | 출력 태그와 위치 결정 |
|
||||
| `url` | string | 필수 | all | `https://...` | link 계열은 `href`, script는 `src` |
|
||||
| `preconnect` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | `https://cdn.example.com` | 리소스보다 먼저 `<link rel="preconnect">` 출력, 중복 제거 |
|
||||
| `crossorigin` | boolean/string | 선택 | `style`, `webfont`, `script`, `preconnect`, `preload`, `modulepreload` | `true`, `anonymous`, `use-credentials` | `true`는 `anonymous`로 정규화 |
|
||||
| `asset` | string | `url`과 택일 | `preconnect`·`dns-prefetch` 제외 | `dist/` 이하 상대 경로 (`vendor/font-awesome/6.4.0/css/all.inlined.css`). `/`로 시작 금지, `..` 금지, `A-Za-z0-9._-/`만 허용 | 자산 URL 이중 모드와 정적 게시를 반영한 same-origin URL로 해석. `url`보다 우선하며, 둘 다 선언하면 `url`은 무시되고 경고가 남는다 |
|
||||
| `url` | string | `asset`과 택일 | all | `https://...` 또는 `/`로 시작하는 same-origin 경로 | link 계열은 `href`, script는 `src` |
|
||||
| `preconnect` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | `https://cdn.example.com` | 리소스보다 먼저 `<link rel="preconnect">` 출력, 중복 제거. **same-origin 항목에서는 무시** |
|
||||
| `crossorigin` | boolean/string | 선택 | `style`, `webfont`, `script`, `preconnect`, `preload`, `modulepreload` | `true`, `anonymous`, `use-credentials` | `true`는 `anonymous`로 정규화. **same-origin 항목에서는 무시** |
|
||||
| `integrity` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | SRI hash | HTML `integrity` |
|
||||
| `referrerpolicy` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | 표준 referrer policy | HTML `referrerpolicy` |
|
||||
| `media` | string | 선택 | `style`, `webfont` | CSS media query | stylesheet link의 `media` |
|
||||
@@ -552,6 +567,27 @@ Body: { "layout_strategy": "apply_new" }
|
||||
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
|
||||
```
|
||||
|
||||
### `custom/` 은 운영자 자리입니다 — 저작자가 선점하지 않습니다
|
||||
|
||||
`templates/{identifier}/custom/` 은 **사이트 운영자**가 자기 CSS·JS 를 덧붙이는 자리입니다.
|
||||
확장 교체(업데이트·재설치)가 이 디렉토리만은 보존하므로, 운영자가 넣은 파일은 템플릿을
|
||||
업데이트해도 살아남습니다.
|
||||
|
||||
그래서 템플릿 저작자는 `_bundled` 에 `custom/` 을 담아 배포하지 않습니다. 보존 계층은
|
||||
"덮어쓰지 않음" 이라, 저작자가 담은 파일은 이미 `custom/` 을 가진 사이트에서 **영영
|
||||
반영되지 않는 상태**가 됩니다. 저작자의 기본 스타일은 `src/styles/` 에 두고 빌드에
|
||||
포함시킵니다.
|
||||
|
||||
| 대상 | 자리 | 빌드 | 업데이트 시 |
|
||||
|---|---|---|---|
|
||||
| 저작자 기본 스타일 | `src/styles/` | 필요 | 새 배포본으로 교체 |
|
||||
| 저작자 동봉 구동 자산 | `dist/vendor/{lib}/{version}/` | 불필요 | 새 배포본으로 교체 |
|
||||
| **운영자 추가 CSS·JS** | **`custom/`** | **불필요** | **보존** |
|
||||
|
||||
운영자가 `dist/` 를 직접 고치는 것은 방법이 아닙니다 — 다음 빌드와 다음 업데이트에
|
||||
사라집니다. 로드 순서·`custom/assets.json` 선언 형식·외부 URL 등록 규칙은
|
||||
[module-assets.md 「사용자 추가 에셋」](module-assets.md) 을 참조하세요.
|
||||
|
||||
### 개발 워크플로우
|
||||
|
||||
```text
|
||||
|
||||
@@ -132,8 +132,7 @@ Phase 6: 빌드 및 설치
|
||||
{
|
||||
"id": "fontawesome",
|
||||
"type": "style",
|
||||
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
|
||||
"preconnect": "https://cdnjs.cloudflare.com"
|
||||
"asset": "vendor/font-awesome/6.4.0/css/all.inlined.css"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -179,14 +179,13 @@ Pro 버전 아이콘은 라이선스가 필요하므로 사용할 수 없습니
|
||||
{
|
||||
"id": "fontawesome",
|
||||
"type": "style",
|
||||
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
|
||||
"preconnect": "https://cdnjs.cloudflare.com"
|
||||
"asset": "vendor/font-awesome/6.4.0/css/all.inlined.css"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Font Awesome은 컴포넌트에서 직접 import하지 않고, admin/user 템플릿의 `template.json` `externals`에 선언해 Blade가 최초 HTML에서 로드합니다.
|
||||
Font Awesome은 컴포넌트에서 직접 import하지 않고, admin/user 템플릿의 `template.json` `externals`에 선언해 Blade가 최초 HTML에서 로드합니다. 구동에 필요한 에셋은 자체 제공이 원칙이므로 `dist/vendor/{lib}/{version}/`에 동봉한 파일을 `asset`으로 가리킵니다 — 제3자 CDN에서 실시간으로 받으면 도달 실패가 예외도 로그도 없이 아이콘만 사라집니다.
|
||||
|
||||
### 아이콘 사용 방법
|
||||
|
||||
|
||||
@@ -916,6 +916,25 @@ declare global {
|
||||
};
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// 확장 자산 URL (engine-v1.62.0+)
|
||||
asset: {
|
||||
template: (identifier: string, path: string, version?: number | string | null) => string;
|
||||
templateDir: (identifier: string, path: string) => string;
|
||||
module: (identifier: string, path: string, version?: number | string | null) => string;
|
||||
plugin: (identifier: string, path: string, version?: number | string | null) => string;
|
||||
convertToCurrentMode: (url: string) => string;
|
||||
loadScript: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
||||
loadStylesheet: (url: string, attrs?: Record<string, string>, options?: Record<string, unknown>) => Promise<void>;
|
||||
};
|
||||
|
||||
// 자산 실패 안내 (engine-v1.62.0+)
|
||||
assets: {
|
||||
notifyFailure: (failure: AssetFailure) => void;
|
||||
clearFailure: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
getFailures: () => AssetFailure[];
|
||||
retryAll: () => Promise<void>;
|
||||
};
|
||||
// 액션
|
||||
dispatch: (action: ActionConfig) => Promise<ActionResult>;
|
||||
|
||||
@@ -1012,7 +1031,7 @@ declare global {
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [G7Core 기본 API](g7core-api.md) - 상태 관리, 토스트, 모달, 네비게이션, 플러그인/모듈 설정
|
||||
- [G7Core 기본 API](g7core-api.md) - 상태 관리, 토스트, 모달, 네비게이션, 플러그인/모듈 설정, 확장 자산
|
||||
- [state-management.md](state-management.md) - 전역 상태 관리 상세
|
||||
- [components.md](components.md) - 컴포넌트 개발 규칙
|
||||
- [data-binding.md](data-binding.md) - 데이터 바인딩 문법
|
||||
|
||||
+113
-2
@@ -23,7 +23,7 @@
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| **g7core-api.md** (현재) | 개요, 상태 관리, 토스트 알림, 모달 관리, 네비게이션, 스타일 헬퍼, 플러그인/모듈 설정, 위지윅 편집기 |
|
||||
| **g7core-api.md** (현재) | 개요, 상태 관리, 토스트 알림, 모달 관리, 네비게이션, 스타일 헬퍼, 플러그인/모듈 설정, 확장 자산, 위지윅 편집기 |
|
||||
| [g7core-api-advanced.md](g7core-api-advanced.md) | 다국어, 액션 실행, 컴포넌트 이벤트, 이벤트 생성 헬퍼, 렌더링 헬퍼, 인증/API, WebSocket, 반응형, React Hooks, 타입 정의 |
|
||||
|
||||
---
|
||||
@@ -38,7 +38,8 @@
|
||||
6. [스타일 헬퍼 (G7Core.style)](#스타일-헬퍼-g7corestyle)
|
||||
7. [플러그인 설정 (G7Core.plugin)](#플러그인-설정-g7coreplugin)
|
||||
8. [모듈 설정 (G7Core.module)](#모듈-설정-g7coremodule)
|
||||
9. [위지윅 편집기 (G7Core.wysiwyg)](#위지윅-편집기-g7corewysiwyg)
|
||||
9. [확장 자산 (G7Core.asset, G7Core.assets)](#확장-자산-g7coreasset-g7coreassets)
|
||||
10. [위지윅 편집기 (G7Core.wysiwyg)](#위지윅-편집기-g7corewysiwyg)
|
||||
|
||||
---
|
||||
|
||||
@@ -65,6 +66,8 @@
|
||||
| 인증 | `G7Core.AuthManager` | 인증 상태 관리 | [고급 API](g7core-api-advanced.md) |
|
||||
| API | `G7Core.api` | API 클라이언트 | [고급 API](g7core-api-advanced.md) |
|
||||
| WebSocket | `G7Core.websocket` | 실시간 통신 | [고급 API](g7core-api-advanced.md) |
|
||||
| 확장 자산 URL | `G7Core.asset` | 템플릿/모듈/플러그인 자산 URL 생성, 재시도 로더 | 현재 문서 |
|
||||
| 자산 실패 안내 | `G7Core.assets` | 자산 로드 실패 표면화 및 재시도 | 현재 문서 |
|
||||
| 위지윅 | `G7Core.wysiwyg` | 레이아웃 편집기 | 현재 문서 |
|
||||
| React Hooks | `G7Core.useControllableState` | 상태 패턴 훅 | [고급 API](g7core-api-advanced.md) |
|
||||
|
||||
@@ -791,6 +794,114 @@ const PriceDisplay: React.FC<{ price: number }> = ({ price }) => {
|
||||
|
||||
---
|
||||
|
||||
## 확장 자산 (G7Core.asset, G7Core.assets)
|
||||
|
||||
> **버전**: engine-v1.62.0+
|
||||
|
||||
확장(템플릿·모듈·플러그인)이 **자기 자산을 런타임에 불러올 때** 쓰는 API입니다. 확장 번들은
|
||||
코어 모듈을 import 할 수 없으므로, 자산 URL 생성과 실패 표면화는 이 전역을 통해 제공됩니다.
|
||||
|
||||
구동에 필요한 자산(js·css·웹폰트)은 제3자 CDN 에서 실시간으로 받지 않고 확장이 함께 담아
|
||||
`dist/vendor/{라이브러리}/{버전}/` 에서 자체 제공합니다. 자세한 규약은
|
||||
[module-assets.md](../extension/module-assets.md)를 참조하세요.
|
||||
|
||||
### G7Core.asset — 자산 URL 생성
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `template` | `(identifier, path, version?) => string` | 템플릿 자산 URL |
|
||||
| `templateDir` | `(identifier, path) => string` | 템플릿 자산 **디렉토리** URL |
|
||||
| `module` | `(identifier, path, version?) => string` | 모듈 자산 URL |
|
||||
| `plugin` | `(identifier, path, version?) => string` | 플러그인 자산 URL |
|
||||
| `convertToCurrentMode` | `(url) => string` | 서버가 확장자 형태로 굳혀 내려준 URL 을 현재 모드로 보정 |
|
||||
| `loadScript` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스크립트 로더 |
|
||||
| `loadStylesheet` | `(url, attrs?, options?) => Promise<void>` | 재시도 계층을 갖춘 스타일시트 로더 |
|
||||
|
||||
`path` 기준이 확장 타입마다 다릅니다. **템플릿은 서버가 `dist/` 를 자동으로 붙이므로 `path` 에
|
||||
`dist/` 를 포함하지 않고**, 모듈·플러그인은 확장 루트 기준이라 `dist/` 를 직접 포함합니다.
|
||||
서버측 `App\Support\AssetUrl` 과 같은 비대칭입니다.
|
||||
|
||||
```javascript
|
||||
// 템플릿 — dist/ 를 붙이지 않는다
|
||||
G7Core.asset.template('sirsoft-admin_basic', 'vendor/monaco-editor/0.54.0/vs/loader.js');
|
||||
|
||||
// 플러그인 — 확장 루트 기준이라 dist/ 를 포함한다
|
||||
G7Core.asset.plugin('sirsoft-ckeditor5', 'dist/vendor/ckeditor5/43.3.1/ckeditor5.umd.js');
|
||||
|
||||
// 모듈
|
||||
G7Core.asset.module('sirsoft-board', 'dist/js/board.js');
|
||||
```
|
||||
|
||||
### 자산 URL 을 문자열로 조립하지 않습니다
|
||||
|
||||
```javascript
|
||||
// ❌ 금지 — 확장자를 정적 location 이 가로채는 서버에서 404 가 된다
|
||||
const url = '/api/plugins/assets/sirsoft-ckeditor5/dist/vendor/ckeditor5/43.3.1/ckeditor5.umd.js';
|
||||
|
||||
// ✅ 올바른 사용
|
||||
const url = G7Core.asset.plugin('sirsoft-ckeditor5', 'dist/vendor/ckeditor5/43.3.1/ckeditor5.umd.js');
|
||||
```
|
||||
|
||||
자산 URL 은 서버 설정(`general.asset_url_mode`)과 정적 게시 상태에 따라 **확장자 형태**
|
||||
(`.../ckeditor5.umd.js`)와 **쿼리 형태**(`...?file=...`), **정적 게시본 경로**
|
||||
(`/build/ext/{버전}/...`) 중 하나로 해석됩니다. 문자열로 조립하면 그 판정을 건너뛰어, 정규식
|
||||
location 이 확장자를 먼저 가로채는 서버에서 그 자산만 조용히 404 가 됩니다.
|
||||
|
||||
### templateDir — 디렉토리 접두가 필요한 소비자
|
||||
|
||||
AMD 로더나 워커처럼 **디렉토리 접두 뒤에 파일명을 이어 붙이는** 소비자는 `template()` 대신
|
||||
`templateDir()` 을 씁니다. 쿼리 형태(`?file=`)는 뒤에 파일명을 이어 붙일 수 없기 때문입니다.
|
||||
확장자 없는 모드에서 404 일 수 있으므로 **소비자가 폴백을 갖춰야 합니다.**
|
||||
|
||||
```javascript
|
||||
loader.config({
|
||||
paths: { vs: G7Core.asset.templateDir('sirsoft-admin_basic', 'vendor/monaco-editor/0.54.0/vs') },
|
||||
});
|
||||
```
|
||||
|
||||
### G7Core.assets — 자산 실패 표면화
|
||||
|
||||
로드에 끝내 실패한 자산을 화면 상단 배너로 알리고 [다시 시도] 를 제공합니다. 실패를
|
||||
`console.error` 한 줄로 끝내면 사용자에게는 "빈 자리" 로만 나타나고 자체 서버 로그에도 흔적이
|
||||
남지 않아 운영자가 원인을 특정할 수 없습니다.
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `notifyFailure` | `(failure: AssetFailure) => void` | 실패 등록 (같은 `id` 는 갱신) |
|
||||
| `clearFailure` | `(id: string) => void` | 해당 실패 해제 |
|
||||
| `clearAll` | `() => void` | 전체 해제 |
|
||||
| `getFailures` | `() => AssetFailure[]` | 현재 등록된 실패 목록 |
|
||||
| `retryAll` | `() => Promise<void>` | 등록된 실패 전부 재시도 — 각 `retry` 를 순차 await 하므로 완료를 기다리려면 반환값을 await 한다 |
|
||||
|
||||
`AssetFailure` 필드:
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `id` | `string` | 중복 누적을 막는 식별자 (필수) |
|
||||
| `label` | `string` | 짧은 항목명 — 여러 건 합산 표시에 쓰인다 |
|
||||
| `message` | `string?` | 사용자에게 보일 안내 문장. 생략하면 `label` 기반 기본 문구 |
|
||||
| `retry` | `() => Promise<void> \| void` | 생략하면 [다시 시도] 버튼을 렌더하지 않는다 |
|
||||
|
||||
```javascript
|
||||
try {
|
||||
await G7Core.asset.loadStylesheet(url, { id: 'my-plugin-css' });
|
||||
G7Core.assets.clearFailure('my-plugin-css');
|
||||
} catch (error) {
|
||||
G7Core.assets.notifyFailure({
|
||||
id: 'my-plugin-css',
|
||||
label: '편집기 스타일',
|
||||
retry: () => G7Core.asset.loadStylesheet(url, { id: 'my-plugin-css' }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
배너는 독립 레이아웃(`extends` 없음)에서도 떠야 하므로 호스트 컴포넌트에 의존하지 않고
|
||||
DOM 에 직접 주입됩니다. Toast 와 달리 **사용자가 닫을 때까지 유지**되며 `role="alert"` 를
|
||||
갖습니다. `retry` 가 resolve 되면 그 실패는 자동으로 해제되고, reject 되면 배너를 유지한 채
|
||||
재시도 실패를 알립니다.
|
||||
|
||||
---
|
||||
|
||||
## 위지윅 편집기 (G7Core.wysiwyg)
|
||||
|
||||
> **engine-v1.11.0+** 추가
|
||||
|
||||
@@ -203,12 +203,15 @@ HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) **
|
||||
|
||||
레이아웃의 `scripts[].src` 와 `data_sources[].endpoint` 는 기본적으로 **same-origin 절대 경로**(`/` 로 시작)만 허용합니다. `//`(protocol-relative)·scheme 포함 외부 URL 은 원격 코드 로드 경로이므로 런타임 스크립트 로더가 차단합니다.
|
||||
|
||||
일부 확장은 외부 CDN 스크립트를 정당하게 사용합니다(예: CKEditor5 → `cdn.ckeditor.com`, Daum 우편번호 → `t1.daumcdn.net`). 이런 확장은 자신의 manifest 에 신뢰 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을 집계해 `window.G7Config.trustedScriptHosts` 로 노출합니다. 런타임 로더·저장측 검증·정적 검사는 모두 이 목록에 속한 호스트만 예외로 허용합니다.
|
||||
구동에 필요한 자산은 확장이 함께 담아 자체 제공하는 것이 원칙입니다. 자체 제공이 불가능한 경우 — 라이브러리가 아니라 그 회사 서버와 통신하는 **서비스 SDK**(예: Daum 우편번호 → `t1.daumcdn.net`) — 에만 외부 호스트를 씁니다. 이런 확장은 자신의 manifest 에 신뢰 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을 집계해 `window.G7Config.trustedScriptHosts` 로 노출합니다. 런타임 로더·저장측 검증·정적 검사는 모두 이 목록에 속한 호스트만 예외로 허용합니다.
|
||||
|
||||
```json
|
||||
// 확장 manifest (module.json / plugin.json / template.json)
|
||||
{
|
||||
"trusted_script_hosts": ["cdn.ckeditor.com"]
|
||||
"trusted_script_hosts": ["t1.daumcdn.net"],
|
||||
"trusted_script_hosts_reason": {
|
||||
"t1.daumcdn.net": "Daum 이 운영하는 서비스 SDK 라 자체 호스팅해도 동작하지 않는다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -427,6 +427,27 @@ export default defineConfig({
|
||||
|
||||
---
|
||||
|
||||
## 4-1. 운영자가 CSS·JS 를 덧붙이는 자리 (`custom/`)
|
||||
|
||||
템플릿 소스(`src/styles/`)를 고치고 빌드하는 방법은 **템플릿 저작자**의 방법입니다. 운영자에게는
|
||||
부적합합니다 — Node.js 가 필요하고, 그렇게 넣은 파일은 다음 템플릿 업데이트에 사라집니다.
|
||||
빌드 산출물(`dist/css/`)을 직접 고치는 것도 다음 빌드에 사라집니다.
|
||||
|
||||
운영자는 활성 템플릿 디렉토리의 `custom/` 에 파일을 놓습니다.
|
||||
|
||||
```
|
||||
templates/{템플릿-식별자}/custom/custom.css
|
||||
```
|
||||
|
||||
- 빌드하지 않습니다. 파일을 놓으면 다음 요청부터 적용됩니다.
|
||||
- 템플릿을 업데이트해도 이 디렉토리는 보존됩니다.
|
||||
- 적용 순서는 **템플릿·확장 스타일보다 뒤**라서 재정의가 그대로 반영됩니다.
|
||||
- 여러 파일을 놓으면 파일명 오름차순으로 적용됩니다(`10-`, `20-` 접두사로 순서를 정할 수 있습니다).
|
||||
- 폰트·이미지 같은 파일도 같은 디렉토리에 두고 CSS 에서 상대 경로로 참조합니다.
|
||||
- 순서를 바꾸거나 일부만 싣고 싶으면 `custom/assets.json` 에 선언합니다.
|
||||
|
||||
모듈·플러그인도 같은 규약을 씁니다. 상세: [module-assets.md](../extension/module-assets.md) "사용자 추가 에셋".
|
||||
|
||||
## 5. 템플릿 네이밍 규칙
|
||||
|
||||
### 형식
|
||||
|
||||
@@ -343,6 +343,19 @@ Composer 설치 방식을 선택할 때만 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 7-1. 오프라인·폐쇄망 동작 범위
|
||||
|
||||
화면 구동에 필요한 자산(아이콘·글꼴·편집기·코드 편집기·국기 아이콘·이미지 압축)은 모두
|
||||
설치본에 함께 담겨 사이트 자신의 서버에서 제공됩니다. 외부 인터넷에 나가지 못하는 환경에서도
|
||||
관리자·사용자 화면이 정상 동작합니다.
|
||||
|
||||
**외부 연결이 필요한 유일한 기능은 주소 검색(우편번호)입니다.** 이 기능은 라이브러리가 아니라
|
||||
Daum 이 운영하는 서비스 SDK 를 쓰므로, 자체 호스팅해도 동작하지 않습니다. 연결하지 못하는
|
||||
환경에서는 우편번호·주소를 직접 입력할 수 있으며 안내가 표시됩니다.
|
||||
|
||||
운영자가 별도로 설정한 외부 서비스(검색엔진 분석 도구 등)는 이 범위와 무관하게 그 설정을
|
||||
따릅니다.
|
||||
|
||||
## 8. 호스팅 환경별 제한사항
|
||||
|
||||
### 8.1 공유 호스팅 (Shared Hosting)
|
||||
|
||||
@@ -104,6 +104,25 @@ spec 이 전부 실패한다.
|
||||
`getByText('전체회원수')` 처럼 한국어만 단언하는 곳은 물론이고, `getByRole('button', { name: /저장/ })`
|
||||
같은 접근 가능한 이름 조회도 함께 깨진다.
|
||||
|
||||
### 브라우저 UA 고정 (`userAgent`)
|
||||
|
||||
코어와 확장의 모든 config 은 `use.userAgent` 에 실제 데스크탑 Chrome UA 를 지정한다.
|
||||
|
||||
Playwright 의 기본 UA 에는 `HeadlessChrome` 이 들어 있고, `SeoMiddleware` 의 봇 판정이 그것을
|
||||
검색엔진 크롤러로 본다. 그러면 공개 사용자 경로 요청이 SPA 가 아니라 **검색엔진용 정적 HTML** 을
|
||||
받는다 — `window.G7Core` 도 엔진 스크립트도 없는 화면이다.
|
||||
|
||||
이 상태가 위험한 이유는 실패가 아니라 **통과**로 나타나기 때문이다. 서버가 심은 글꼴·아이콘은
|
||||
그대로 정상이라 "페이지가 잘 뜬다" 로 보이고, 정작 재려던 SPA 동작(테마 적용·핸들러 등록·확장
|
||||
번들 로드·상태 바인딩)은 한 번도 실행되지 않은 채 단언이 통과한다. 실측(2026-08-26)에서
|
||||
사용자 홈을 재던 spec 들이 전부 이 경로였다.
|
||||
|
||||
봇 화면을 의도적으로 검증하는 spec 은 UA 가 아니라 `?_escaped_fragment_=` 로 그 경로를 유발하므로,
|
||||
UA 를 실제 브라우저 값으로 고정해도 그 검증은 그대로 동작한다.
|
||||
|
||||
측정으로 확인하려면 `typeof window.G7Core` 가 `'object'` 인지 본다 — `'undefined'` 면 SPA 가 아니라
|
||||
봇 화면을 재고 있는 것이다.
|
||||
|
||||
### 실행 시 유의 (경험칙)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|
||||
Reference in new issue
Block a user