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:
HeuJung committed 2026-08-27 16:47:14 +09:00
1 parent 72f2e94c1a
commit 3945b6f1b3
1042 files changed
+107435 -947

No files matched your search

+27 -10
View File
@@ -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 |
+2 -2
View File
@@ -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 |
+330
View File
@@ -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 -->
**설명** 파일을 삭제합니다. 편집 불가 형식(폰트·이미지)도 삭제 대상입니다 — 삭제까지 편집 확장자로 좁히면 올린 폰트를 지울 방법이 없어집니다. 삭제 후 확장 캐시 버전이 올라 정적 게시본에서도 제거됩니다.
+17 -2
View File
@@ -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"
}
]
}
}
```
+16 -2
View File
@@ -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"
}
]
}
}
```
+20 -9
View File
@@ -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
+49 -2
View File
@@ -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 박제 방지).