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
2026-08-27 16:47:14 +09:00
parent 72f2e94c1a
commit 3945b6f1b3
1042 changed files with 107435 additions and 947 deletions
+27 -2
View File
@@ -41,7 +41,7 @@
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 termi... |
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트와 운영자 cu... |
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
@@ -153,7 +153,7 @@
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 324 |
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
@@ -416,6 +416,31 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
### 확장·템플릿 구동 에셋은 자체 제공한다
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 **예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 구동 자산(js/css/웹폰트)을 외부 CDN 에서 실시간 로드 | 확장이 `dist/vendor/{lib}/{version}/` 에 동봉하고 same-origin 서빙 |
| `trusted_script_hosts` 만 선언하고 사유는 생략 | `trusted_script_hosts_reason` 에 호스트별 사유 동반 — 자체 제공이 원칙이고 예외는 근거가 코드에 남는다 |
| 자산 URL 을 문자열로 조립 (`'/api/plugins/assets/'+id+'/…'`) | `G7Core.asset.{template,module,plugin}` — 확장자를 정적 location 이 가로채는 서버에서 조립 URL 만 404 가 된다 |
| AMD 로더·워커에 `G7Core.asset.template()` 결과를 base 로 전달 | `G7Core.asset.templateDir()` — 쿼리 형태(`?file=`)는 뒤에 파일명을 이어 붙일 수 없다. 확장자 없는 모드에서 404 일 수 있으므로 **소비자가 폴백을 갖춘다** |
| CSS 로드에 `onerror` 미설치 또는 `resolve()` 로 삼킴 | `loadStylesheetWithRetry` — 아이콘만으로 조작하는 버튼이 있는 화면에서 스타일 소실은 곧 조작 불능이다 |
| 자산 실패를 `console.error` 한 줄로 끝냄 | `G7Core.assets.notifyFailure({id,label,retry})` — 사용자가 사실을 알고 조치할 수 있어야 한다 |
| 편집기·코드편집기 확보 실패 시 빈 컨테이너를 남김 | 평문 입력(textarea) 폴백 + 저장 계약 유지(`{name}_mode='text'`) + 재시도 시 입력 내용 승계 |
| 확장 `dist/`·`src/` 에 운영자 CSS 를 둠 | 확장 디렉토리 안의 **`custom/`** — 빌드 불필요, 확장 교체가 보존 |
| 번들 확장이 `custom/` 을 담아 배포 | `dist/vendor/` 에 담는다. `custom/` 은 운영자 소유라 보존 계층이 덮어쓰지 않아 **저작자 파일이 영영 반영되지 않는다** |
| 사용자 추가 에셋 URL 을 `ext.cache_version` 으로 무효화 | 파일 서명(수정 시각) — 확장 캐시 버전은 운영자가 파일을 고쳤다고 오르지 않는다 |
| `custom/` 보존을 rename 경로에만 적용 | 교체 **두 경로 모두**(rename · 제자리 동기화 폴백) — 한쪽만 고치면 Windows 잠금 상황에서만 조용히 사라진다 |
동봉 자산은 배포 산출물이므로 `sourceMappingURL` 참조를 남기지 않는다(`.map` 은 gitignore 대상이라 404 가 된다). 인라인 여부는 "없으면 조작 불능인가" 로 가른다 — 아이콘 폰트는 인라인, 글꼴·장식 아이콘은 파일 분리(자산 URL 이 쿼리 형태가 되는 서버에서 CSS 내부 상대 `url()` 이 해석되지 않는 조합이 남는다).
사용자 추가 에셋(`custom/`)은 **출처에 의존하지 않는 서술자**로 해석하고 `core.assets.custom_assets` 필터 훅을 해석기 끝에 둔다. 소비자(뷰 컴포저·프론트 로더·서빙)가 출처를 보면, 나중에 다른 출처(템플릿 환경설정의 화면 입력 등)가 붙을 때 평행 경로가 생기고 "운영자 CSS 가 어디서 오는가" 의 SSoT 가 둘로 갈린다.
> 상세: [module-assets.md](docs/extension/module-assets.md) "사용자 추가 에셋", [static-asset-publishing.md](docs/backend/static-asset-publishing.md)
> 정적 검사가 외부 자산 URL 과 번들 확장의 `custom/` 배포를 차단한다. 서술자 형태와 교체 2경로 보존은 테스트가 잠근다.
### 목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
+10
View File
@@ -8,14 +8,24 @@
### Added
- 화면 구동에 필요한 아이콘·글꼴·편집기 등을 외부 CDN 이 아니라 사이트 자신의 서버에서 불러옵니다. 폐쇄망이나 외부 접속이 제한된 환경에서도 관리자·사용자 화면이 정상 동작합니다. 외부 연결이 필요한 기능은 주소 검색 하나만 남았습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 운영자가 자기 CSS·JS 를 덧붙일 자리를 각 확장이 제공합니다. 확장 디렉토리의 `custom/` 에 파일을 놓으면 빌드 없이 바로 적용되고, 확장을 업데이트해도 그 파일은 지워지지 않습니다. 적용 순서는 항상 확장 스타일보다 뒤라서 재정의가 그대로 반영됩니다. (sir.kr 커뮤니티에서 문의해주신 내용입니다.)
- 화면에 필요한 파일을 끝내 불러오지 못하면 안내와 [다시 시도] 를 표시합니다. 아이콘 글꼴이나 본문 글꼴처럼 페이지가 직접 불러오는 파일도 대상이라, 아이콘이 통째로 사라져 버튼을 못 누르게 되는 상황에서도 원인이 화면에 남습니다. 종전에는 아무 표시 없이 기능만 사라져 원인을 알 수 없었습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 설치 마법사와 개발 대시보드도 외부 CDN 없이 동작합니다.
- 확장을 삭제할 때 `custom/` 에 넣어 둔 운영자 파일을 먼저 사본으로 보관하고, 그 보관 경로를 삭제 결과에 함께 알립니다. 관리자 화면과 콘솔 양쪽에서 확인할 수 있습니다.
- 운영자가 덧붙인 CSS·JS·글꼴·이미지를 레이아웃 편집기 화면에서 직접 넣고 고칠 수 있습니다. 서버 접속 없이 파일을 만들고 편집하고 지울 수 있으며, 저장하면 다음 화면부터 바로 반영됩니다. 편집 중인 템플릿뿐 아니라 설치된 모듈·플러그인도 대상으로 고를 수 있습니다. 이 기능은 레이아웃 편집과 별도의 「커스텀 자산 관리」 권한으로 열립니다 — 여기서 올린 스크립트는 사이트 전 화면에서 실행되기 때문입니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 주소 끝에 `?custom=off` 를 붙여 열면 운영자가 추가한 CSS·JS 없이 화면이 표시됩니다. 추가한 스타일이 화면을 망가뜨려 고치러 들어갈 수조차 없게 된 상황에서 쓰는 탈출구이며, 레이아웃 편집기 툴바에도 같은 동작의 버튼이 있습니다. 이 설정은 저장되지 않고 그 화면에만 적용됩니다.
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다.
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
### Changed
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다.
### Fixed
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 빌드 산출물 정리 Trait
*
* 확장이 구동에 필요한 제3자 자산을 `dist/vendor/` 에 동봉하면서, vite 의
* `emptyOutDir: true` 를 그대로 둘 수 없게 됐다 — 매 빌드마다 동봉 자산이 삭제된다.
* 각 확장의 vite config 를 `emptyOutDir: false` 로 바꾸는 대신, **정리 책임을 빌드
* 커맨드로 옮긴다.** 확장마다 정리 규칙을 복제하면 한 곳만 빠져도 그 확장에서만
* 해시 청크가 누적되고, 그것은 배포본이 커진 뒤에야 드러난다.
*
* 정리 범위는 종전 `emptyOutDir: true` 와 같다 — `dist/` 전체를 비우되 **보존 대상만
* 남긴다.** "빌드가 만드는 것만 골라 지운다" 로 좁히면 소스에서 사라진 파일의 산출물이
* `dist/` 에 stale 로 남는다.
*/
trait PrunesBuildOutput
{
/**
* 빌드 산출물 디렉토리에서 보존 대상을 제외한 전부를 삭제합니다.
*
* 감시(watch) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
* 참조 중인 파일이 사라진다.
*
* @param string $buildPath 확장 루트 경로 (`dist/` 의 부모)
* @param array<int, string> $preserve 삭제하지 않을 최상위 항목명
* @return array<int, string> 삭제한 최상위 항목명 목록
*/
private function pruneBuildOutput(string $buildPath, array $preserve = ['vendor']): array
{
$distPath = rtrim($buildPath, '/\\').DIRECTORY_SEPARATOR.'dist';
if (! File::isDirectory($distPath)) {
return [];
}
$removed = [];
foreach (new \FilesystemIterator($distPath, \FilesystemIterator::SKIP_DOTS) as $item) {
$name = $item->getBasename();
if (in_array($name, $preserve, true)) {
continue;
}
$deleted = $item->isDir()
? File::deleteDirectory($item->getPathname())
: File::delete($item->getPathname());
if ($deleted) {
$removed[] = $name;
continue;
}
// 삭제 실패는 빌드를 막을 이유가 못 된다 — vite 가 같은 경로를 덮어쓴다.
// 다만 조용히 넘기면 stale 산출물이 남은 것을 알 수 없으므로 기록한다.
Log::warning('빌드 산출물 정리 실패 (다음 빌드에서 재시도)', [
'path' => $item->getPathname(),
]);
}
return $removed;
}
}
+16 -3
View File
@@ -88,7 +88,7 @@ class BuildCoreCommand extends Command
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
if ($watchMode) {
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
return $this->runWatchBundles($projectPath);
@@ -125,7 +125,17 @@ class BuildCoreCommand extends Command
return $devtoolsResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
// ── 4) 개발 대시보드 CSS (자체 제공 — 종전 Tailwind Play CDN 대체) ──
$this->info('🔨 코어 빌드 시작 (개발 대시보드 CSS)'.($productionMode ? ' (프로덕션)' : ''));
$dashboardResult = $this->runNpmCommand(['npm', 'run', 'build:core-devdashboard'], $projectPath, true, $buildEnv);
if ($dashboardResult !== Command::SUCCESS) {
$this->error('❌ 개발 대시보드 CSS 빌드 실패');
return $dashboardResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
@@ -140,11 +150,14 @@ class BuildCoreCommand extends Command
*/
private function runWatchBundles(string $projectPath): int
{
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
// 엔진 + 편집기 + DevTools + 개발 대시보드 CSS 를 각각 vite --watch 로 병렬 감시.
// 1회 빌드가 굽는 산출물과 같은 집합이어야 한다 — 한쪽만 빠지면 감시 모드에서
// 그 산출물만 조용히 stale 해진다.
$bundles = [
'engine' => ['npm', 'run', 'build:core-watch'],
'editor' => ['npm', 'run', 'build:core-editor-watch'],
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
'dashboard' => ['npm', 'run', 'build:core-devdashboard-watch'],
];
/** @var array<string, Process> $processes */
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
@@ -13,6 +14,7 @@ class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -189,6 +191,14 @@ class BuildModuleCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -95,7 +95,13 @@ class UninstallModuleCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(ModuleManager::UNINSTALL_STEPS);
try {
$result = $this->moduleManager->uninstallModule($identifier, $deleteData, $onProgress);
$result = $this->moduleManager->uninstallModule(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -108,6 +114,16 @@ class UninstallModuleCommand extends Command
$this->info(' - '.__('modules.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('modules.commands.uninstall.menus_deleted', ['count' => $menusCount]));
$this->info(' - '.__('modules.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('modules.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('modules.commands.uninstall.success', ['module' => $identifier]));
return Command::SUCCESS;
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
@@ -13,6 +14,7 @@ class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -189,6 +191,14 @@ class BuildPluginCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -92,7 +92,13 @@ class UninstallPluginCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(PluginManager::UNINSTALL_STEPS);
try {
$result = $this->pluginManager->uninstallPlugin($identifier, $deleteData, $onProgress);
$result = $this->pluginManager->uninstallPlugin(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -104,6 +110,16 @@ class UninstallPluginCommand extends Command
$this->info(' - '.__('plugins.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
$this->info(' - '.__('plugins.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('plugins.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('plugins.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
return Command::SUCCESS;
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildTemplateCommand extends Command
{
use ClearsTemplateCaches;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -181,6 +183,14 @@ class BuildTemplateCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -71,7 +71,7 @@ class UninstallTemplateCommand extends Command
// 템플릿 삭제
$onProgress = $this->createProgressCallback(TemplateManager::UNINSTALL_STEPS);
try {
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress);
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress, $preservedBackups);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -83,6 +83,15 @@ class UninstallTemplateCommand extends Command
$this->info('✅ '.__('templates.commands.uninstall.success', ['template' => $identifier]));
$this->info(' - '.__('templates.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('templates.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('templates.commands.uninstall.success', ['template' => $identifier]));
return Command::SUCCESS;
@@ -0,0 +1,32 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 사용자 추가 에셋(`custom/`) 관리 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존해 컨트롤러가 원본 키로 응답할 수 있게 한다. 이미 번역된
* 문장을 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문이 그대로 화면에 나간다.
*
* `TemplateOperationException` 과 같은 형태지만 별도 클래스로 둔다 — 컨트롤러가 이
* 예외만 골라 4xx 로 바꾸기 위해서다. 부모(`RuntimeException`)를 잡으면 인프라 예외까지
* 입력 오류로 위장된다.
*/
class CustomAssetOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'custom_assets.errors.not_found')
* @param array<string, mixed> $params 메시지 파라미터
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
public readonly string $errorKey,
public readonly array $params = [],
?Throwable $previous = null,
) {
parent::__construct(__($errorKey, $params), 0, $previous);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Extension\Helpers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 실패한 설치가 남긴 활성 디렉토리를 되돌리는 헬퍼
*
* 설치 흐름은 원본(`_pending`/`_bundled`)을 활성 디렉토리로 **먼저 복사한 뒤** 확장을
* 로드해 코어 버전·의존성·언어 경로 등을 검증한다. 검증에 로드된 확장 인스턴스가 필요해
* 순서를 뒤집을 수 없는데, 그 검증이 실패하면 방금 만든 활성 디렉토리가 그대로 남는다.
*
* 남은 디렉토리는 DB 행이 없어 목록에도 뜨지 않고 번들 병합에도 참여하지 않는다 — 즉
* 오류도 경고도 없이 디스크만 점유하는 고아가 된다. 더 나쁜 것은 다음 설치 시도가 그
* 디렉토리를 "이미 있는 설치본" 으로 보고 원본 복사를 건너뛸 수 있다는 점이다.
*
* 이 헬퍼는 **이번 호출이 만든 디렉토리만** 지운다. 이미 설치돼 있던 확장을 `--force` 로
* 다시 설치하다 실패한 경우에는 운영자의 기존 설치본이므로 손대지 않는다.
*/
final class ExtensionInstallRollbackHelper
{
/**
* 이번 설치가 만든 활성 디렉토리를 제거합니다.
*
* @param string $activePath 활성 디렉토리 절대경로
* @param bool $existedBefore 이번 설치 이전에 그 디렉토리가 있었는지
* @param string $identifier 확장 식별자 (로그용)
* @param string $type 확장 유형 (module|plugin|template, 로그용)
* @return bool 실제로 제거했으면 true
*/
public static function removeIfCreatedByThisInstall(
string $activePath,
bool $existedBefore,
string $identifier,
string $type,
): bool {
if ($existedBefore || ! File::isDirectory($activePath)) {
return false;
}
$removed = File::deleteDirectory($activePath);
Log::warning('설치 실패로 활성 디렉토리를 되돌렸습니다.', [
'type' => $type,
'identifier' => $identifier,
'path' => $activePath,
'removed' => $removed,
]);
return $removed;
}
}
@@ -21,6 +21,21 @@ class ExtensionPendingHelper
'node_modules',
];
/**
* 확장 교체 시 보존할 최상위 디렉토리명 목록
*
* `custom/` 은 운영자가 자기 CSS·JS·정적 파일을 두는 자리다. 확장이 소유한 것이
* 아니므로 새 배포본으로 덮어써서는 안 된다 — 덮어쓰면 업데이트할 때마다 운영자가
* 넣은 파일이 사라지고, 그 사실이 어디에도 남지 않는다(파일이 조용히 없어질 뿐이다).
*
* 보존은 **교체 경로 둘 다**에서 성립해야 한다: 디렉토리 rename 경로와, 하위 트리에
* 열린 핸들이 있을 때의 제자리 동기화 폴백. 한쪽만 고치면 Windows 잠금 상황에서만
* 조용히 사라진다.
*/
public const PRESERVED_DIRECTORIES = [
'custom',
];
/**
* _pending 또는 _bundled 디렉토리에서 확장 메타데이터를 로드합니다.
*
@@ -162,6 +177,9 @@ class ExtensionPendingHelper
try {
self::copyDirectoryWithProgress($sourcePath, $tempPath, $sourcePath, $onProgress);
// 운영자 소유 디렉토리를 새 트리로 옮겨 심는다 (교체 전에 해 둬야 원본이 살아 있다)
self::carryOverPreservedDirectories($targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패 시 임시 디렉토리 정리 후 예외 전파
if (File::isDirectory($tempPath)) {
@@ -379,7 +397,7 @@ class ExtensionPendingHelper
}
$staleFailures = [];
self::removeStaleEntries($source, $dest, $staleFailures);
self::removeStaleEntries($source, $dest, $staleFailures, true);
if (! empty($staleFailures)) {
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
@@ -389,6 +407,40 @@ class ExtensionPendingHelper
}
}
/**
* 운영자 소유 디렉토리를 기존 활성 디렉토리에서 새 트리로 옮겨 심습니다.
*
* 새 배포본에 같은 이름의 디렉토리가 있으면 **그것을 치우고** 기존 것을 심는다 —
* `custom/` 은 확장이 소유하지 않는 자리이므로, 확장이 그 자리에 무언가를 담아
* 배포했더라도 운영자 파일이 우선한다(그런 배포 자체를 정적 검사가 막는다).
*
* @param string $existingPath 기존 활성 디렉토리
* @param string $stagingPath 새 배포본이 복사된 임시 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*/
private static function carryOverPreservedDirectories(
string $existingPath,
string $stagingPath,
?\Closure $onProgress = null
): void {
foreach (self::PRESERVED_DIRECTORIES as $name) {
$from = $existingPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($from)) {
continue;
}
$to = $stagingPath.DIRECTORY_SEPARATOR.$name;
if (File::isDirectory($to)) {
File::deleteDirectory($to);
}
$onProgress?->__invoke(null, "운영자 파일 보존: {$name}/");
self::copyDirectoryWithProgress($from, $to, $from, $onProgress);
}
}
/**
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
*
@@ -550,7 +602,7 @@ class ExtensionPendingHelper
* @param string $dest 활성 디렉토리
* @param array $failures 삭제 실패 경로 수집 (참조)
*/
private static function removeStaleEntries(string $source, string $dest, array &$failures): void
private static function removeStaleEntries(string $source, string $dest, array &$failures, bool $isRoot = false): void
{
if (! is_dir($dest)) {
return;
@@ -559,6 +611,11 @@ class ExtensionPendingHelper
$items = new \FilesystemIterator($dest, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
// 운영자 소유 디렉토리는 소스에 없어도 정리 대상이 아니다 (확장 루트에서만 판정)
if ($isRoot && $item->isDir() && in_array($item->getBasename(), self::PRESERVED_DIRECTORIES, true)) {
continue;
}
$counterpart = $source.DIRECTORY_SEPARATOR.$item->getBasename();
if ($item->isDir()) {
@@ -627,14 +684,85 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관된 운영자 디렉토리 목록
*/
public static function deleteExtensionDirectory(string $basePath, string $identifier): void
public static function deleteExtensionDirectory(string $basePath, string $identifier): array
{
$targetPath = $basePath.DIRECTORY_SEPARATOR.$identifier;
if (File::isDirectory($targetPath)) {
File::deleteDirectory($targetPath);
if (! File::isDirectory($targetPath)) {
return [];
}
$archived = self::archivePreservedDirectories($targetPath, $identifier);
File::deleteDirectory($targetPath);
return $archived;
}
/**
* 삭제 전에 운영자 소유 디렉토리를 보관합니다.
*
* 확장을 삭제하면 그 안의 `custom/` 도 함께 사라진다 — 운영자가 넣은 파일이므로
* 되돌릴 방법 없이 없어지면 안 된다. 교체(업데이트)는 보존이 답이지만 삭제는
* "확장을 없앤다" 는 명시적 의사이므로 막지 않고, 대신 사본을 남기고 그 사실을
* 기록한다.
*
* 보관 실패가 삭제를 막지는 않는다 — 삭제는 운영자가 요청한 동작이다.
*
* @param string $targetPath 삭제 대상 확장 디렉토리
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관에 성공한 디렉토리 목록
*/
private static function archivePreservedDirectories(string $targetPath, string $identifier): array
{
$archived = [];
foreach (self::PRESERVED_DIRECTORIES as $name) {
$source = $targetPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($source) || self::isEmptyDirectory($source)) {
continue;
}
$archivePath = storage_path(
'app'.DIRECTORY_SEPARATOR.'extension-custom-backups'
.DIRECTORY_SEPARATOR.$identifier.'-'.date('Ymd_His')
.DIRECTORY_SEPARATOR.$name
);
try {
self::copyDirectoryWithProgress($source, $archivePath, $source, null);
Log::info('확장 삭제: 운영자 파일을 보관했습니다', [
'identifier' => $identifier,
'directory' => $name,
'archive' => $archivePath,
]);
$archived[] = ['directory' => $name, 'archive' => $archivePath];
} catch (\Throwable $e) {
Log::warning('확장 삭제: 운영자 파일 보관에 실패했습니다 (삭제는 계속합니다)', [
'identifier' => $identifier,
'directory' => $name,
'error' => $e->getMessage(),
]);
}
}
return $archived;
}
/**
* 디렉토리가 비어 있는지 판정합니다.
*
* @param string $path 대상 디렉토리
* @return bool 비어 있으면 true
*/
private static function isEmptyDirectory(string $path): bool
{
return ! (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS))->valid();
}
/**
+28 -1
View File
@@ -21,6 +21,7 @@ use App\Enums\PermissionType;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
@@ -372,9 +373,16 @@ class ModuleManager implements ModuleManagerInterface
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 모듈 설치 시)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
$rollbackActivePath = $this->modulesPath.DIRECTORY_SEPARATOR.$moduleName;
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackDirExisted = File::isDirectory($rollbackActivePath);
$onProgress?->__invoke('copy', '파일 복사 중...');
$this->copyFromPendingOrBundled($moduleName, $onProgress, $force);
try {
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
$module = $this->getModule($moduleName);
if (! $module) {
@@ -534,6 +542,17 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.installed', $moduleName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$moduleName,
'module',
);
throw $e;
}
}
/**
@@ -905,6 +924,9 @@ class ModuleManager implements ModuleManagerInterface
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없을 때
@@ -914,8 +936,10 @@ class ModuleManager implements ModuleManagerInterface
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
@@ -1045,7 +1069,10 @@ class ModuleManager implements ModuleManagerInterface
// 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->modulesPath,
$module->getIdentifier()
);
// 메모리에서 모듈 제거
unset($this->modules[$module->getIdentifier()]);
+27 -1
View File
@@ -21,6 +21,7 @@ use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
@@ -357,9 +358,15 @@ class PluginManager implements PluginManagerInterface
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 플러그인 설치 시)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackDirExisted = File::isDirectory($activePath);
$onProgress?->__invoke('copy', '파일 복사 중...');
$this->copyFromPendingOrBundled($pluginName, $onProgress, $force);
try {
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
@@ -518,6 +525,17 @@ class PluginManager implements PluginManagerInterface
HookManager::doAction('core.plugins.installed', $pluginName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$activePath,
$rollbackDirExisted,
$pluginName,
'plugin',
);
throw $e;
}
}
/**
@@ -932,6 +950,9 @@ class PluginManager implements PluginManagerInterface
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없을 때
@@ -941,8 +962,10 @@ class PluginManager implements PluginManagerInterface
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
@@ -1075,7 +1098,10 @@ class PluginManager implements PluginManagerInterface
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->pluginsPath,
$plugin->getIdentifier()
);
// 메모리에서 플러그인 제거
unset($this->plugins[$plugin->getIdentifier()]);
+32 -3
View File
@@ -13,6 +13,7 @@ use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
@@ -425,6 +426,12 @@ class TemplateManager implements TemplateManagerInterface
);
}
// 검증(2단계)은 복사된 활성 디렉토리를 읽어야 해서 복사보다 뒤에 온다. 그래서 검증이
// 실패하면 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고
// 오류도 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackActivePath = $this->templatesPath.DIRECTORY_SEPARATOR.$templateName;
$rollbackDirExisted = File::isDirectory($rollbackActivePath);
// 1. _pending/_bundled에서 활성 디렉토리로 복사 (활성 디렉토리에 없는 경우)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
$onProgress?->__invoke('copy', '파일 복사 중...');
@@ -435,6 +442,7 @@ class TemplateManager implements TemplateManagerInterface
// 2. 검증
$onProgress?->__invoke('validate', '검증 중...');
try {
return DB::transaction(function () use ($templateName, $onProgress) {
$template = $this->getTemplate($templateName);
if (! $template) {
@@ -508,6 +516,16 @@ class TemplateManager implements TemplateManagerInterface
return true;
});
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$templateName,
'template',
);
throw $e;
}
}
/**
@@ -731,12 +749,20 @@ class TemplateManager implements TemplateManagerInterface
*
* @param string $templateName 제거할 템플릿명 (identifier)
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없을 때
*/
public function uninstallTemplate(string $templateName, ?\Closure $onProgress = null): bool
{
public function uninstallTemplate(
string $templateName,
?\Closure $onProgress = null,
?array &$preservedBackups = null,
): bool {
$preservedBackups = [];
// 1. 캐시 삭제
$onProgress?->__invoke('cache', '캐시 삭제 중...');
@@ -782,7 +808,10 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('files', '파일 삭제 중...');
// 활성 템플릿 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
ExtensionPendingHelper::deleteExtensionDirectory($this->templatesPath, $templateName);
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->templatesPath,
$templateName
);
// 메모리에서 템플릿 제거
unset($this->templates[$templateName]);
@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CustomAssetOperationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\Extension\ReadExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\SaveExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\UploadExtensionCustomAssetRequest;
use App\Rules\AllowedTemplateFileType;
use App\Services\CustomAssetService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 확장 사용자 추가 에셋(`custom/`) 어드민 컨트롤러
*
* 운영자가 자기 CSS·JS·폰트·이미지를 화면에서 직접 넣고 고칠 수 있게 한다. 레이아웃
* 편집기의 [커스텀 자산] 모달이 본 API 를 호출한다.
*
* 모듈·플러그인·템플릿을 **한 엔드포인트**가 다룬다. 타입별로 나누면 같은 검증·문서·테스트가
* 세 벌로 갈리고, 그중 하나만 약해지면 그 경로가 조용한 우회로가 된다. 기존
* `extensions/{type}/{identifier}` 선례(확장 복구 API)와 같은 형태다.
*
* 권한은 라우트의 permission 미들웨어(`core.extensions.custom_assets.manage`)가 담당한다.
* 레이아웃 편집 권한과 **분리**된 이유: 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라
* 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다.
*/
class AdminExtensionCustomAssetController extends AdminBaseController
{
/**
* 라우트 파라미터(단수) → 해석기 어휘(복수)
*
* 라우트는 기존 확장 공통 API 와 같은 단수형을 쓰고(`module|plugin|template`),
* 해석기·서빙은 디렉토리 이름과 같은 복수형을 쓴다. 변환을 한 곳에 모아 둔다 —
* 흩어지면 한쪽 표기만 고쳐 놓고 다른 쪽에서 조용히 빈 목록이 된다.
*/
private const TYPE_MAP = [
'module' => 'modules',
'plugin' => 'plugins',
'template' => 'templates',
];
public function __construct(
private CustomAssetService $service,
) {
parent::__construct();
}
/**
* 사용자 추가 에셋 목록 조회.
*
* @param string $type 확장 타입 (`module` | `plugin` | `template`)
* @param string $identifier 확장 식별자
* @return JsonResponse 파일 목록 + 편집기 메타(허용 확장자·크기 상한)
*/
public function index(string $type, string $identifier): JsonResponse
{
try {
$files = $this->service->list($this->resolveType($type), $identifier);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 목록 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => 'custom/']);
}
return $this->success('custom_assets.messages.listed', [
'files' => $files,
'editable_extensions' => CustomAssetService::EDITABLE_EXTENSIONS,
'uploadable_extensions' => AllowedTemplateFileType::getAllowedExtensions(),
'max_text_bytes' => CustomAssetService::MAX_TEXT_BYTES,
'max_upload_bytes' => CustomAssetService::MAX_UPLOAD_BYTES,
]);
}
/**
* 텍스트 파일 본문 조회.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 본문 응답
*/
public function show(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$file = $this->service->read($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 본문 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.listed', $file);
}
/**
* 텍스트 파일 본문 저장 (없으면 생성).
*
* @param SaveExtensionCustomAssetRequest $request 검증된 요청 (`path`, `content`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 저장 결과
*/
public function store(SaveExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$saved = $this->service->save(
$this->resolveType($type),
$identifier,
$path,
(string) $request->validated('content'),
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 저장 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.saved', $saved);
}
/**
* 파일 업로드 (폰트·이미지 등 바이너리 포함).
*
* @param UploadExtensionCustomAssetRequest $request 검증된 요청 (`file`, `directory`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 업로드 결과
*/
public function upload(UploadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$directory = $request->validated('directory');
try {
$uploaded = $this->service->upload(
$this->resolveType($type),
$identifier,
$request->file('file'),
is_string($directory) ? $directory : null,
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 업로드 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => (string) $directory]);
}
return $this->success('custom_assets.messages.uploaded', $uploaded);
}
/**
* 파일 삭제.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 삭제 결과
*/
public function destroy(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$this->service->delete($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 삭제 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.delete_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.deleted', ['path' => $path]);
}
/**
* 라우트 타입 파라미터를 해석기 어휘로 바꿉니다.
*
* 라우트 정규식이 이미 세 값으로 제한하지만, 매핑에 없으면 그대로 넘긴다 —
* 해석기가 알 수 없는 타입을 무효로 판정해 422 를 만든다(빈 목록으로 조용히
* 통과시키지 않는다).
*
* @param string $type 라우트 파라미터
* @return string 해석기 어휘
*/
private function resolveType(string $type): string
{
return self::TYPE_MAP[$type] ?? $type;
}
}
@@ -390,10 +390,19 @@ class ModuleController extends AdminBaseController
$moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason);
$result = $this->moduleService->uninstallModule(
$moduleName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('module.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('module.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('module.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
@@ -397,10 +397,19 @@ class PluginController extends AdminBaseController
$pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData, $uninstallFailureReason);
$result = $this->pluginService->uninstallPlugin(
$pluginName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('plugins.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('plugins.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('plugins.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
@@ -286,10 +286,14 @@ class TemplateController extends AdminBaseController
$templateName = $validated['template_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->templateService->uninstallTemplate($templateName, $deleteData);
$result = $this->templateService->uninstallTemplate($templateName, $deleteData, $preservedBackups);
if ($result) {
return $this->success('templates.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('templates.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('templates.uninstall_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\SafeCustomAssetPath;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 본문 조회·삭제 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당한다.
*
* 조회와 삭제가 같은 요청 클래스를 쓰는 이유: 둘 다 "존재하는 파일 하나를 경로로 지목"
* 이라는 동일한 입력이고, 검증 강도가 갈리면 약한 쪽이 우회로가 된다.
*/
class ReadExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
// 삭제는 폰트·이미지도 대상이므로 확장자 제한을 두지 않는다. 편집 가능 여부는
// 서비스가 판정한다 — 여기서 편집 확장자로 좁히면 올린 폰트를 지울 수 없다.
'path' => ['required', 'string', 'max:255', new SafeCustomAssetPath],
];
return HookManager::applyFilters('core.extension_custom_asset.read_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'path.required' => __('custom_assets.validation.path_required'),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\SafeCustomAssetPath;
use App\Services\CustomAssetService;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 본문 저장 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당하므로
* authorize()는 true 를 고정 반환한다.
*/
class SaveExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'path' => ['required', 'string', 'max:255', new SafeCustomAssetPath(CustomAssetService::EDITABLE_EXTENSIONS)],
// 빈 문자열 저장을 허용한다 — 운영자가 CSS 를 통째로 비우는 것은 정당한 조작이고,
// 그것을 막으면 파일을 지우는 것 말고는 되돌릴 방법이 없어진다.
'content' => ['present', 'string', 'max:'.CustomAssetService::MAX_TEXT_BYTES],
];
return HookManager::applyFilters('core.extension_custom_asset.save_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'path.required' => __('custom_assets.validation.path_required'),
'content.present' => __('custom_assets.validation.content_present'),
'content.max' => __('custom_assets.errors.too_large_to_edit', [
'limit' => (string) CustomAssetService::MAX_TEXT_BYTES,
]),
];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\AllowedTemplateFileType;
use App\Rules\SafeCustomAssetPath;
use App\Services\CustomAssetService;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 업로드 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당한다.
*
* 허용 확장자는 자산 서빙과 **같은 목록**(`AllowedTemplateFileType`)을 쓴다. 여기만 넓히면
* 올릴 수는 있는데 서빙되지 않는 파일이 생기고, 여기만 좁히면 서빙 규칙이 사문화된다.
*/
class UploadExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$maxKilobytes = (int) (CustomAssetService::MAX_UPLOAD_BYTES / 1024);
$rules = [
'file' => [
'required',
'file',
'max:'.$maxKilobytes,
'mimes:'.implode(',', AllowedTemplateFileType::getAllowedExtensions()),
],
// 하위 디렉토리는 선택 — 없으면 `custom/` 바로 아래에 놓인다.
'directory' => ['nullable', 'string', 'max:200', new SafeCustomAssetPath],
];
return HookManager::applyFilters('core.extension_custom_asset.upload_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'file.required' => __('custom_assets.validation.file_required'),
'file.file' => __('custom_assets.validation.file_invalid'),
'file.mimes' => __('custom_assets.validation.file_mimes', [
'allowed' => implode(', ', AllowedTemplateFileType::getAllowedExtensions()),
]),
'file.max' => __('custom_assets.errors.upload_too_large', [
'limit' => (string) CustomAssetService::MAX_UPLOAD_BYTES,
]),
];
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Template;
use App\Rules\AllowedTemplateFileType;
use App\Rules\SafeTemplatePath;
use App\Support\CustomAssets;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Foundation\Http\FormRequest;
@@ -32,9 +33,19 @@ class ServeTemplateAssetRequest extends FormRequest
// 확장 가능한 "동적 필드" 가 없는 요청이라 룰의 취지(필드 확장)도 해당하지 않는다.
public function rules(): array
{
// 템플릿 식별자로부터 기준 경로 구성
// 템플릿 식별자로부터 기준 경로 구성.
//
// 컨테인먼트 기준은 **실제로 읽는 디렉토리**여야 한다. 템플릿 자산은 `dist/` 이하가
// 기본이지만 운영자 소유 디렉토리(`custom/`)만은 그 밖에 있고, 서빙측
// `TemplateService::getAssetFilePath()` 가 그 분기를 갖는다. 기준을 `dist` 로
// 고정하면 `custom/**` 은 realpath 가 실패해 문자열 접두 비교로만 통과하므로,
// 검증한 경로와 읽는 경로가 서로 다른 상태가 된다. 두 곳의 분기를 같은 조건으로
// 맞춰 둔다 — 한쪽만 바뀌면 custom 서빙이 조용히 깨지거나 검증이 헐거워진다.
$identifier = $this->route('identifier');
$basePath = base_path("templates/{$identifier}/dist");
$requestedPath = (string) $this->input('path');
$basePath = str_starts_with($requestedPath, CustomAssets::DIRECTORY.'/')
? base_path("templates/{$identifier}")
: base_path("templates/{$identifier}/dist");
return [
'identifier' => ['required', 'string'],
+13 -2
View File
@@ -4,6 +4,7 @@ namespace App\Http\Resources;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Services\ExtensionStaticCacheService;
use App\Support\TemplateExternals;
use Composer\Semver\Semver;
use Illuminate\Http\Request;
@@ -118,8 +119,18 @@ class TemplateResource extends BaseApiResource
'components' => $this->getValue('components', ['basic' => [], 'composite' => []]),
'license' => $this->getValue('license'),
'metadata' => $this->getValue('metadata', []),
// 외부 리소스 (template.json externals) — 정규화 책임은 TemplateExternals 에 위임
'externals' => TemplateExternals::normalize($this->getValue('externals', [])),
// 외부 리소스 (template.json externals) — 정규화 책임은 TemplateExternals 에 위임.
// `asset` 항목의 URL 해석에는 식별자와 캐시 버전이 필요하므로 뷰 컴포저
// (CollectsTemplateExternals) 와 같은 인자를 넘긴다 — 빠뜨리면 `asset` 항목이
// "식별자 미상" 으로 조용히 버려져 응답에서 사라진다.
'externals' => TemplateExternals::normalize(
$this->getValue('externals', []),
$this->getValue('identifier'),
// 트레이트를 사용하는 클래스 경유로 호출한다 — 트레이트 정적 메서드
// 직접 호출(`ClearsTemplateCaches::`)은 PHP 8.1+ E_DEPRECATED 라
// 템플릿 상세 응답마다 로그를 남긴다 (AssetUrl::staticExtBase 와 동형).
ExtensionStaticCacheService::getExtensionCacheVersion(),
),
// 의존성 상세 정보
'dependencies' => $this->getDetailedDependencies(),
// 타임스탬프
+8 -3
View File
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsCustomAssets;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
@@ -20,6 +21,7 @@ use Illuminate\View\View;
class TemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsCustomAssets;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
@@ -95,12 +97,13 @@ class TemplateComposer
$appConfig = [];
}
// 템플릿의 외부 리소스 정보 수집
$templateExternals = $this->collectTemplateExternals($activeTemplate);
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 템플릿의 외부 리소스 정보 수집
// (자체 제공 `asset` 항목의 URL 을 만들 때 캐시 버전이 필요해 뒤로 옮겼다)
$templateExternals = $this->collectTemplateExternals($activeTemplate, $extensionCacheVersion);
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
@@ -120,6 +123,8 @@ class TemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('customAssets', $this->collectCustomAssets($activeTemplate));
$view->with('customAssetsDisabled', $this->customAssetsDisabledByRequest());
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Http\View\Composers\Traits;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Support\CustomAssets;
use Illuminate\Support\Facades\Log;
/**
* 사용자 추가 에셋(`custom/`) 수집 Trait
*
* 활성 확장의 `custom/` 자산을 모아 프론트로 넘긴다. 로드 자체는 프론트가 하며,
* **확장 병합 번들 뒤**에 붙는다 — CSS 는 나중에 온 규칙이 이기므로, 운영자가 덧붙인
* 스타일이 확장 스타일보다 뒤에 와야 재정의가 성립한다.
*
* 확장 간 순서는 모듈 → 플러그인 → 템플릿이다. 화면 외관의 최종 책임이 템플릿에 있어,
* 템플릿 운영자의 재정의가 가장 뒤에 와야 한다.
*
* @property ModuleManagerInterface $moduleManager
* @property PluginManagerInterface $pluginManager
*/
trait CollectsCustomAssets
{
use ClearsTemplateCaches;
/**
* 활성 확장의 사용자 추가 에셋을 수집합니다.
*
* 비활성 확장은 제외한다 — 자산 서빙이 활성 확장만 응답하므로, 넣어 봐야 404 가
* 될 항목을 페이지에 실을 이유가 없다.
*
* `?custom=off` 가 붙은 요청은 목록을 비운다 — 탈출구(D33). 운영자가 넣은 CSS 한
* 줄이 관리자 화면을 조작 불능으로 만들면 그것을 고칠 화면에도 그 CSS 가 실려 있어
* 스스로 갇힌다. 서버가 목록을 비우면 자산이 페이지에 **도달하지 않으므로**, 이미
* 깨진 화면에서 자바스크립트가 돌기를 기대할 필요가 없다.
*
* @param string|null $templateIdentifier 활성 템플릿 식별자
* @return array<int, array<string, mixed>> 서술자 목록 (로드 순서대로)
*/
private function collectCustomAssets(?string $templateIdentifier): array
{
if ($this->customAssetsDisabledByRequest()) {
return [];
}
$assets = $this->resolveCustomAssets($templateIdentifier);
// 변경을 감지해 버전이 올랐다면 방금 만든 URL 은 **옛 버전**을 가리킨다.
// 그대로 내보내면 운영자가 파일을 고친 그 화면에서만 옛 CSS 가 보이고, 새로고침
// 한 번을 더 해야 반영된다 — 원인을 알 수 없는 한 박자 지연으로만 나타난다.
// 새 버전으로 다시 해석한다. 게시본은 아직 없으므로 URL 은 API 형태로 떨어지고,
// 그 응답은 디스크의 최신 내용을 그대로 준다. 다음 요청부터 정적 경로가 된다.
if ($this->syncCustomAssetCacheVersion($assets, $templateIdentifier)) {
CustomAssets::flushCache();
$assets = $this->resolveCustomAssets($templateIdentifier);
}
return $assets;
}
/**
* 활성 확장의 사용자 추가 에셋 서술자를 해석합니다.
*
* @param string|null $templateIdentifier 활성 템플릿 식별자
* @return array<int, array<string, mixed>> 서술자 목록 (로드 순서대로)
*/
private function resolveCustomAssets(?string $templateIdentifier): array
{
$assets = [];
foreach ($this->activeExtensionIdentifiers('modules') as $identifier) {
$assets = array_merge($assets, CustomAssets::forExtension('modules', $identifier));
}
foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) {
$assets = array_merge($assets, CustomAssets::forExtension('plugins', $identifier));
}
if (! empty($templateIdentifier)) {
$assets = array_merge($assets, CustomAssets::forExtension('templates', $templateIdentifier));
}
return $assets;
}
/**
* 운영자가 파일을 고쳤으면 확장 캐시 버전을 올립니다.
*
* custom 자산은 확장 자산과 **같은 메커니즘**으로 정적 게시되므로 갱신 축도 같아야
* 한다. 그런데 확장 캐시 버전은 수명주기 이벤트에서만 오르고, 운영자가 FTP 로 파일
* 하나를 바꾸는 것은 그 이벤트가 아니다. 그래서 파일 서명이 달라진 것을 여기서
* 감지해 같은 단일 지점(`incrementExtensionCacheVersion`)을 호출한다 — 그 지점이
* 재게시 예약까지 담당하므로 새로 만들 기계가 없다.
*
* 서명이 **저장된 적 없으면 올리지 않는다.** 캐시 스토어가 요청마다 비는 환경
* (array 스토어 등)에서 "매번 달라짐" 으로 읽혀 버전이 무한히 오르고 재게시가
* 끝없이 돌게 되기 때문이다. 첫 관측은 기록만 하고 다음 변화부터 반응한다.
*
* 버전을 올렸으면 `true` 를 돌려준다 — 호출자가 서술자를 다시 해석해야 한다.
* 이미 만든 URL 은 옛 버전을 가리키기 때문이다.
*
* 서명은 **렌더 스코프별로** 따로 기억한다. 수집 대상이 모듈·플러그인(양쪽 동일)에
* 더해 `resolveCustomAssets` 가 싣는 **그 렌더의 템플릿 하나**라, 관리자 렌더와
* 사용자 렌더의 서명은 파일이 그대로여도 정상적으로 다르다. 기억할 자리가 하나면
* 두 렌더가 번갈아 덮어쓰며 매번 "파일이 바뀌었다" 로 읽혀, 운영자가 아무것도
* 건드리지 않았는데 페이지를 오갈 때마다 확장 캐시 버전이 오르고 전체 재게시가
* 예약된다(모든 자산 URL 이 상시 변동 → 브라우저 캐시 무효). 예외도 화면 이상도
* 없어 로그 외에는 드러나지 않는다.
*
* 스코프를 나누되 **키는 하나로 두고 값을 맵으로** 쓴다. `CustomAssetService` 가
* 쓰기 뒤 이 키 하나를 `forget` 하는 것에 의존하므로(같은 키를 보는 두 소비자),
* 키를 쪼개면 그 소비자가 일부만 지우게 되어 같은 결함군이 재생산된다.
*
* @param array<int, array<string, mixed>> $assets 수집된 서술자 목록
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
* @return bool 확장 캐시 버전을 올렸으면 true
*/
private function syncCustomAssetCacheVersion(array $assets, ?string $scope = null): bool
{
try {
$parts = [];
foreach ($assets as $asset) {
// 파일 출처만 본다 — 외부 URL·훅이 더한 항목은 파일 서명이 없다
if (($asset['source'] ?? null) !== 'file') {
continue;
}
$parts[] = ($asset['id'] ?? '').'@'.($asset['version'] ?? '');
}
sort($parts, SORT_STRING);
$signature = $parts === [] ? 'empty' : md5(implode('|', $parts));
$scopeKey = $scope ?? '';
$cache = app(CacheInterface::class);
// 구 배포본이 남긴 스칼라 값은 맵이 아니다 — 어느 스코프의 것인지 알 수 없으므로
// 서명으로 쓰지 않고 미관측으로 취급한다(첫 관측은 기록만 하니 헛된 bump 가 없다).
$storedMap = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY);
$storedMap = is_array($storedMap) ? $storedMap : [];
$stored = $storedMap[$scopeKey] ?? null;
if ($stored === $signature) {
return false;
}
$storedMap[$scopeKey] = $signature;
$cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap);
if ($stored === null) {
return false;
}
Log::info('사용자 추가 에셋 변경 감지 — 확장 캐시 버전을 올립니다.', [
'scope' => $scopeKey,
'previous' => $stored,
'current' => $signature,
]);
$this->incrementExtensionCacheVersion();
return true;
} catch (\Exception $e) {
// 감지 실패가 화면을 막지 않는다 — 최악이라도 URL 이 한동안 옛 버전일 뿐이다
Log::warning('사용자 추가 에셋 변경 감지 실패', ['error' => $e->getMessage()]);
return false;
}
}
/**
* 이번 요청이 사용자 추가 에셋을 끄도록 요구했는지 판정합니다.
*
* 판정을 수집 **맨 앞**에 두는 것이 중요하다. 뒤에 두면 빈 목록이 변경 감지
* (`syncCustomAssetCacheVersion`)에 도달해 "전부 사라짐" 으로 읽히고, 그 다음 정상
* 요청이 다시 "전부 생김" 으로 읽혀 요청마다 확장 캐시 버전이 오르내린다.
*
* @return bool `?custom=off` 이면 true
*/
private function customAssetsDisabledByRequest(): bool
{
try {
return request()->query('custom') === 'off';
} catch (\Exception $e) {
// 요청 컨텍스트가 없는 렌더(콘솔·SEO 사전 렌더 등)는 끄지 않는다
return false;
}
}
/**
* 활성 확장 식별자 목록을 돌려줍니다.
*
* @param string $extensionType `modules` | `plugins`
* @return array<int, string> 식별자 목록
*/
private function activeExtensionIdentifiers(string $extensionType): array
{
try {
$active = $extensionType === 'modules'
? $this->moduleManager->getActiveModules()
: $this->pluginManager->getActivePlugins();
return array_keys($active);
} catch (\Exception $e) {
Log::warning("Failed to collect active {$extensionType} for custom assets: ".$e->getMessage());
return [];
}
}
}
@@ -21,10 +21,14 @@ trait CollectsTemplateExternals
*
* template.json의 externals 배열만 사용하며 legacy key fallback은 제공하지 않습니다.
*
* 항목이 `asset`(템플릿이 자체 제공하는 `dist/` 이하 경로)을 선언하면 식별자와
* 캐시 버전으로 자산 URL 을 만든다 — 그래서 둘을 함께 받는다.
*
* @param string|null $templateIdentifier 템플릿 식별자
* @param int|string|null $version 캐시 무효화 버전 (`asset` 항목의 `?v`)
* @return array<int, array<string, mixed>>
*/
private function collectTemplateExternals(?string $templateIdentifier): array
private function collectTemplateExternals(?string $templateIdentifier, int|string|null $version = null): array
{
if (empty($templateIdentifier)) {
return [];
@@ -37,7 +41,7 @@ trait CollectsTemplateExternals
return [];
}
return TemplateExternals::normalize($template['externals']);
return TemplateExternals::normalize($template['externals'], $templateIdentifier, $version);
} catch (\Exception $e) {
Log::warning('Failed to collect template externals: '.$e->getMessage());
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsCustomAssets;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
@@ -25,6 +26,7 @@ use Illuminate\View\View;
class UserTemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsCustomAssets;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
@@ -101,12 +103,13 @@ class UserTemplateComposer
$appConfig = [];
}
// 템플릿의 외부 리소스 정보 수집
$templateExternals = $this->collectTemplateExternals($activeTemplate);
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 템플릿의 외부 리소스 정보 수집
// (자체 제공 `asset` 항목의 URL 을 만들 때 캐시 버전이 필요해 뒤로 옮겼다)
$templateExternals = $this->collectTemplateExternals($activeTemplate, $extensionCacheVersion);
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
@@ -126,6 +129,8 @@ class UserTemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('customAssets', $this->collectCustomAssets($activeTemplate));
$view->with('customAssetsDisabled', $this->customAssetsDisabledByRequest());
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
+26
View File
@@ -130,6 +130,7 @@ class CoreActivityLogListener implements HookListenerInterface
'core.templates.after_uninstall' => ['method' => 'handleTemplateAfterUninstall', 'priority' => 20],
'core.templates.after_version_update' => ['method' => 'handleTemplateAfterVersionUpdate', 'priority' => 20],
'core.templates.after_refresh_layouts' => ['method' => 'handleTemplateAfterRefreshLayouts', 'priority' => 20],
'core.custom_assets.after_change' => ['method' => 'handleCustomAssetAfterChange', 'priority' => 20],
// ─── Layout ───
'core.layout.after_update' => ['method' => 'handleLayoutAfterUpdate', 'priority' => 20],
@@ -1103,6 +1104,31 @@ class CoreActivityLogListener implements HookListenerInterface
]);
}
/**
* 사용자 추가 에셋(`custom/`) 변경 후 로그 기록
*
* @param string $extensionType 확장 타입 (`templates` 등)
* @param string $identifier 확장 식별자
* @param string $operation 수행한 작업 (`save` | `upload` | `delete`)
* @param string $path `custom/` 기준 상대 경로
*/
public function handleCustomAssetAfterChange(
string $extensionType,
string $identifier,
string $operation,
string $path
): void {
$this->logActivity('custom_asset.'.$operation, [
'description_key' => 'activity_log.description.custom_asset_'.$operation,
'description_params' => ['identifier' => $identifier, 'path' => $path],
'properties' => [
'extension_type' => $extensionType,
'identifier' => $identifier,
'path' => $path,
],
]);
}
// ═══════════════════════════════════════════
// Layout 핸들러
// ═══════════════════════════════════════════
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 사용자 추가 에셋(`custom/`) 상대 경로 검증 규칙
*
* 판정은 **세그먼트 단위**로 한다. 문자열 포함 검사(`str_contains('..')`)는 정상 파일명
* (`v1..2.css`)을 막으면서 정작 인코딩된 탈출은 놓치고, 접두 비교(`str_starts_with`)는
* 아직 없는 파일에서 `realpath` 가 실패해 형제 디렉토리(`custom-evil/`)를 통과시킨다.
*
* 서비스 계층에도 같은 판정이 있다. 중복이 아니라 이중화다 — 검증은 사용자에게 422 로
* 사유를 알리는 자리이고, 서비스의 판정은 다른 호출부(콘솔·훅)까지 덮는 최종 방어선이다.
*/
class SafeCustomAssetPath implements ValidationRule
{
/**
* @param array<int, string>|null $allowedExtensions 허용 확장자 (null 이면 확장자 검사 생략)
*/
public function __construct(private readonly ?array $allowedExtensions = null) {}
/**
* 경로가 안전한지 검증합니다.
*
* @param string $attribute 속성명
* @param mixed $value 값
* @param Closure $fail 실패 콜백
* @return void
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('custom_assets.errors.invalid_path', ['path' => '']));
return;
}
$normalized = str_replace('\\', '/', trim($value));
if ($normalized === '' || str_starts_with($normalized, '/')) {
$fail(__('custom_assets.errors.invalid_path', ['path' => $value]));
return;
}
foreach (explode('/', $normalized) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
$fail(__('custom_assets.errors.invalid_path', ['path' => $value]));
return;
}
}
if ($this->allowedExtensions === null) {
return;
}
$extension = strtolower(pathinfo($normalized, PATHINFO_EXTENSION));
if (! in_array($extension, $this->allowedExtensions, true)) {
$fail(__('custom_assets.errors.extension_not_allowed', ['extension' => $extension]));
}
}
}
+40 -1
View File
@@ -485,7 +485,10 @@ class SeoRenderer implements SeoRendererInterface
// stylesheets: 템플릿 자체 CSS + seo-config.json 선언 stylesheets 병합
$templateCssUrls = $this->getTemplateCssUrls($templateIdentifier);
$configStylesheets = $seoTemplateConfig['stylesheets'] ?? [];
$configStylesheets = $this->resolveConfigStylesheets(
$seoTemplateConfig['stylesheets'] ?? [],
$templateIdentifier
);
$allStylesheets = array_merge($templateCssUrls, $configStylesheets);
$viewData = [
@@ -700,6 +703,42 @@ class SeoRenderer implements SeoRendererInterface
}
}
/**
* `seo-config.json` 의 stylesheets 선언을 실제 URL 로 해석합니다.
*
* 절대 URL(`http://`·`https://`·`//`)이나 `/` 로 시작하는 경로는 그대로 쓴다.
* 그 외 값은 **템플릿이 자체 제공하는 자산의 `dist/` 이하 경로**로 보고 자산 URL 을
* 만든다 — 봇이 보는 화면도 사용자 화면과 같은 자산을 같은 origin 에서 받아야 한다.
*
* 정적 게시 경로는 쓰지 않는다(`allowStatic: false`). SEO 페이지는 캐시에 오래
* 남는데, 정적 게시본은 GC(현재+직전 1개 보존) 대상이라 캐시된 HTML 이 사라진
* 버전 디렉토리를 가리키게 된다.
*
* @param array<int, mixed> $stylesheets 선언 목록
* @param string $templateIdentifier 템플릿 식별자
* @return array<int, string> 해석된 URL 목록
*/
private function resolveConfigStylesheets(array $stylesheets, string $templateIdentifier): array
{
$resolved = [];
foreach ($stylesheets as $stylesheet) {
if (! is_string($stylesheet) || $stylesheet === '') {
continue;
}
if (preg_match('#^(https?:)?//#i', $stylesheet) === 1 || str_starts_with($stylesheet, '/')) {
$resolved[] = $stylesheet;
continue;
}
$resolved[] = AssetUrl::templateAsset($templateIdentifier, $stylesheet, null, false);
}
return $resolved;
}
/**
* 템플릿의 CSS 에셋 URL 목록을 반환합니다.
*
+392
View File
@@ -0,0 +1,392 @@
<?php
namespace App\Services;
use App\Contracts\Extension\CacheInterface;
use App\Exceptions\CustomAssetOperationException;
use App\Extension\HookManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Rules\AllowedTemplateFileType;
use App\Support\CustomAssets;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
/**
* 사용자 추가 에셋(`custom/`) 관리 서비스
*
* 운영자가 자기 CSS·JS·폰트·이미지를 확장의 `custom/` 디렉토리에 넣고 고칠 수 있게 한다.
* 종전에는 FTP 나 서버 셸이 유일한 경로였다 — 그 접근이 없는 운영자에게는 기능 자체가
* 없는 것과 같았고, 있는 운영자에게도 "고쳤는데 화면에 안 나온다"(정적 게시본 미갱신)가
* 남았다.
*
* 쓰기 뒤에는 반드시 캐시 버전을 올린다. 그 단일 지점이 재게시까지 예약하므로, 편집한
* 파일이 게시본에 반영되는 경로가 구조적으로 보장된다.
*
* @see docs/extension/module-assets.md "사용자 추가 에셋"
*/
class CustomAssetService
{
use ClearsTemplateCaches;
/**
* 편집기가 본문을 직접 열고 고칠 수 있는 확장자
*
* 이 목록 밖(폰트·이미지)은 업로드·삭제만 가능하다 — 바이너리를 텍스트 편집기에
* 열면 내용이 손상된 채 저장된다.
*/
public const EDITABLE_EXTENSIONS = ['css', 'js', 'mjs', 'json'];
/** 텍스트 편집 대상 파일의 최대 크기 (바이트) */
public const MAX_TEXT_BYTES = 524288;
/** 업로드 파일의 최대 크기 (바이트) */
public const MAX_UPLOAD_BYTES = 5242880;
/**
* 확장의 사용자 추가 에셋 목록을 돌려줍니다.
*
* 서빙 여부와 무관하게 디스크에 있는 파일을 전부 싣는다 — 규약 스캔이 자동으로
* 싣지 않는 폰트·이미지도 운영자에게는 관리 대상이고, 목록에서 빠지면 지울 방법이
* 없어진다.
*
* @param string $extensionType `templates` | `modules` | `plugins`
* @param string $identifier 확장 식별자
* @return array<int, array<string, mixed>> 파일 목록 (상대 경로 오름차순)
*/
public function list(string $extensionType, string $identifier): array
{
$directory = CustomAssets::directory($extensionType, $identifier);
if ($directory === null || ! is_dir($directory)) {
return [];
}
// 로드 대상 서술자를 미리 만들어, 목록의 각 파일이 실제로 페이지에 실리는지
// (`loaded`) 알려준다. 규약 스캔·선언 파일 어느 쪽이든 결과는 같은 형태다.
//
// 서술자는 상대 경로 필드를 갖지 않는다 — 소비자가 출처에 의존하지 않도록 URL 과
// id 만 노출하는 계약이다. 그래서 id 접두(`custom:{type}:{identifier}:`)를 떼어
// 상대 경로를 얻는다. 훅이 더한 항목은 이 접두가 없어 자연히 제외되는데, 그것이
// 옳다 — 디스크에 없는 항목을 파일 목록에 표시할 이유가 없다.
$idPrefix = 'custom:'.$extensionType.':'.$identifier.':';
$loadedPaths = [];
foreach (CustomAssets::forExtension($extensionType, $identifier) as $asset) {
$id = (string) ($asset['id'] ?? '');
if (($asset['source'] ?? null) !== 'file' || ! str_starts_with($id, $idPrefix)) {
continue;
}
$loadedPaths[substr($id, strlen($idPrefix))] = true;
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen($directory) + 1));
$extension = strtolower($file->getExtension());
$files[] = [
'path' => $relative,
'name' => $file->getFilename(),
'extension' => $extension,
'size' => $file->getSize(),
'modified_at' => date('c', $file->getMTime()),
'editable' => in_array($extension, self::EDITABLE_EXTENSIONS, true),
'loaded' => isset($loadedPaths[$relative]),
];
}
usort($files, fn (array $a, array $b) => strcmp($a['path'], $b['path']));
return $files;
}
/**
* 텍스트 파일 본문을 읽습니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $relative `custom/` 기준 상대 경로
* @return array{path: string, content: string, size: int} 본문
*
* @throws CustomAssetOperationException 파일 부재·비편집 대상·크기 초과 시
*/
public function read(string $extensionType, string $identifier, string $relative): array
{
$absolute = $this->resolveExisting($extensionType, $identifier, $relative);
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
if (! in_array($extension, self::EDITABLE_EXTENSIONS, true)) {
throw new CustomAssetOperationException('custom_assets.errors.not_editable', ['extension' => $extension]);
}
$size = (int) filesize($absolute);
if ($size > self::MAX_TEXT_BYTES) {
throw new CustomAssetOperationException('custom_assets.errors.too_large_to_edit', [
'limit' => (string) self::MAX_TEXT_BYTES,
]);
}
$content = file_get_contents($absolute);
if ($content === false) {
throw new CustomAssetOperationException('custom_assets.errors.read_failed', ['path' => $relative]);
}
return ['path' => $relative, 'content' => $content, 'size' => $size];
}
/**
* 텍스트 파일 본문을 저장합니다 (없으면 생성).
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $relative `custom/` 기준 상대 경로
* @param string $content 본문
* @return array<string, mixed> 저장된 파일 정보
*
* @throws CustomAssetOperationException 경로 무효·쓰기 실패 시
*/
public function save(string $extensionType, string $identifier, string $relative, string $content): array
{
$absolute = $this->resolveWritable($extensionType, $identifier, $relative);
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
if (! in_array($extension, self::EDITABLE_EXTENSIONS, true)) {
throw new CustomAssetOperationException('custom_assets.errors.not_editable', ['extension' => $extension]);
}
if (strlen($content) > self::MAX_TEXT_BYTES) {
throw new CustomAssetOperationException('custom_assets.errors.too_large_to_edit', [
'limit' => (string) self::MAX_TEXT_BYTES,
]);
}
$this->ensureDirectory(dirname($absolute));
if (file_put_contents($absolute, $content) === false) {
throw new CustomAssetOperationException('custom_assets.errors.write_failed', ['path' => $relative]);
}
$this->invalidate($extensionType, $identifier, 'save', $relative);
return [
'path' => $relative,
'size' => strlen($content),
'modified_at' => date('c'),
];
}
/**
* 업로드 파일을 `custom/` 에 저장합니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param UploadedFile $file 업로드 파일
* @param string|null $directory `custom/` 기준 하위 디렉토리 (선택)
* @return array<string, mixed> 저장된 파일 정보
*
* @throws CustomAssetOperationException 경로 무효·확장자 불허·쓰기 실패 시
*/
public function upload(
string $extensionType,
string $identifier,
UploadedFile $file,
?string $directory = null
): array {
$name = $this->sanitizeFileName($file->getClientOriginalName());
$relative = $directory !== null && $directory !== '' ? trim($directory, '/').'/'.$name : $name;
$absolute = $this->resolveWritable($extensionType, $identifier, $relative);
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (! in_array($extension, AllowedTemplateFileType::getAllowedExtensions(), true)) {
throw new CustomAssetOperationException('custom_assets.errors.extension_not_allowed', [
'extension' => $extension,
]);
}
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
throw new CustomAssetOperationException('custom_assets.errors.upload_too_large', [
'limit' => (string) self::MAX_UPLOAD_BYTES,
]);
}
$this->ensureDirectory(dirname($absolute));
$file->move(dirname($absolute), basename($absolute));
$this->invalidate($extensionType, $identifier, 'upload', $relative);
return [
'path' => $relative,
'size' => (int) @filesize($absolute),
'modified_at' => date('c'),
];
}
/**
* 파일을 삭제합니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $relative `custom/` 기준 상대 경로
* @return void
*
* @throws CustomAssetOperationException 파일 부재·삭제 실패 시
*/
public function delete(string $extensionType, string $identifier, string $relative): void
{
$absolute = $this->resolveExisting($extensionType, $identifier, $relative);
if (! @unlink($absolute)) {
throw new CustomAssetOperationException('custom_assets.errors.delete_failed', ['path' => $relative]);
}
$this->invalidate($extensionType, $identifier, 'delete', $relative);
}
/**
* 쓰기 뒤 캐시·게시본을 무효화합니다.
*
* 확장 캐시 버전을 올리면 그 단일 지점이 정적 재게시까지 예약한다 — 편집한 파일이
* 게시본에 반영되는 경로가 여기 한 곳으로 모인다.
*
* 변경 감지 서명은 **지운다**. 뷰 컴포저가 다음 렌더에서 같은 변경을 다시 발견해
* 버전을 한 번 더 올리는 것을 막기 위해서다 (서명이 없으면 그 관측은 기록만 한다).
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $operation 수행한 작업 (로그용)
* @param string $relative 대상 상대 경로 (로그용)
* @return void
*/
private function invalidate(string $extensionType, string $identifier, string $operation, string $relative): void
{
CustomAssets::flushCache();
try {
app(CacheInterface::class)->forget(CustomAssets::SIGNATURE_CACHE_KEY);
} catch (\Exception $e) {
Log::warning('사용자 추가 에셋 서명 캐시 삭제 실패', ['error' => $e->getMessage()]);
}
$this->incrementExtensionCacheVersion();
Log::info('사용자 추가 에셋 변경', [
'extension_type' => $extensionType,
'identifier' => $identifier,
'operation' => $operation,
'path' => $relative,
]);
// 운영자가 올린 스크립트는 사이트 전 화면에서 실행된다 — 누가 언제 무엇을 바꿨는지
// 남지 않으면 사후에 되짚을 수단이 없다. 활동 로그가 그 유일한 기록이다.
HookManager::doAction('core.custom_assets.after_change', $extensionType, $identifier, $operation, $relative);
}
/**
* 존재하는 파일의 절대 경로를 해석합니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $relative 상대 경로
* @return string 절대 경로
*
* @throws CustomAssetOperationException 경로 무효·파일 부재 시
*/
private function resolveExisting(string $extensionType, string $identifier, string $relative): string
{
$absolute = $this->resolveWritable($extensionType, $identifier, $relative);
if (! is_file($absolute)) {
throw new CustomAssetOperationException('custom_assets.errors.not_found', ['path' => $relative]);
}
return $absolute;
}
/**
* 쓰기 대상 절대 경로를 해석합니다 (아직 없어도 됩니다).
*
* 컨테인먼트는 문자열 접두 비교가 아니라 세그먼트 검사로 판정한다. 아직 없는
* 파일은 `realpath` 가 실패하므로 실경로 정규화에 기댈 수 없고, 접두 비교만으로는
* `custom-evil/` 같은 형제 디렉토리가 통과한다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $relative 상대 경로
* @return string 절대 경로
*
* @throws CustomAssetOperationException 경로가 무효한 경우
*/
private function resolveWritable(string $extensionType, string $identifier, string $relative): string
{
$directory = CustomAssets::directory($extensionType, $identifier);
if ($directory === null) {
throw new CustomAssetOperationException('custom_assets.errors.invalid_extension_target', [
'identifier' => $identifier,
]);
}
$normalized = str_replace('\\', '/', trim($relative));
if ($normalized === '' || str_starts_with($normalized, '/')) {
throw new CustomAssetOperationException('custom_assets.errors.invalid_path', ['path' => $relative]);
}
foreach (explode('/', $normalized) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
throw new CustomAssetOperationException('custom_assets.errors.invalid_path', ['path' => $relative]);
}
}
return $directory.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $normalized);
}
/**
* 업로드 파일명을 안전한 형태로 정규화합니다.
*
* @param string $name 원본 파일명
* @return string 정규화된 파일명
*/
private function sanitizeFileName(string $name): string
{
$base = basename(str_replace('\\', '/', $name));
return preg_replace('/[^A-Za-z0-9._-]/', '_', $base) ?? '';
}
/**
* 디렉토리를 보장합니다.
*
* @param string $directory 절대 경로
* @return void
*
* @throws CustomAssetOperationException 생성 실패 시
*/
private function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {
return;
}
if (! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
throw new CustomAssetOperationException('custom_assets.errors.directory_failed', ['path' => $directory]);
}
}
}
+116 -3
View File
@@ -2,6 +2,8 @@
namespace App\Services;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Exceptions\StaticCachePublishException;
use App\Extension\Helpers\FilePermissionHelper;
@@ -9,6 +11,7 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\ResponseHelper;
use App\Models\Template;
use App\Rules\AllowedTemplateFileType;
use App\Support\CustomAssets;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -55,6 +58,8 @@ class ExtensionStaticCacheService
public function __construct(
private TemplateService $templateService,
private TemplateRepositoryInterface $templateRepository,
private ModuleRepositoryInterface $moduleRepository,
private PluginRepositoryInterface $pluginRepository,
private ExtensionBundleService $bundleService,
private LanguagePackService $languagePackService,
) {}
@@ -312,6 +317,8 @@ class ExtensionStaticCacheService
$this->publishBundles($tmp, $version, $files);
$this->publishExtensionCustomAssets($tmp, $files);
// 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename
if (File::isDirectory($final)) {
File::deleteDirectory($final);
@@ -341,9 +348,14 @@ class ExtensionStaticCacheService
return true;
} catch (\Throwable $e) {
// 예외 종류와 발생 위치까지 남긴다. 메시지만으로는 파일시스템 오류인지 병합
// 오류인지 구분되지 않아, 운영자도 개발자도 재현부터 다시 해야 한다 —
// 게시 실패는 사이트를 멈추지 않고 폴백으로 넘어가므로 이 로그가 유일한 흔적이다.
Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [
'version' => $version,
'exception' => $e::class,
'error' => $e->getMessage(),
'at' => $e->getFile().':'.$e->getLine(),
]);
File::deleteDirectory($tmp);
@@ -443,18 +455,43 @@ class ExtensionStaticCacheService
$files,
$tmp
);
// 5. 운영자 소유 디렉토리(`custom/`) 사본
//
// 게시하지 않으면 이 자산만 API 경로에 남는데, 그 경로에서는 CSS 내부 상대
// `url()` 이 해석되지 않는다 — `?file=` 형태는 기준 URL 이 `/api/templates/assets/`
// 라 `url('./font.woff2')` 가 그 디렉토리를 가리키고, 확장자 형태는 정적 최적화
// 서버가 먼저 가로챈다(그래서 `extensionless` 모드가 존재한다). 즉 정적 확장자
// URL 은 **public 아래 실제 파일일 때만** 200 이 되므로, 문서가 안내하는
// "폰트·이미지를 custom/ 에 두고 상대 경로로 참조" 를 성립시키는 방법은 게시뿐이다.
//
// 갱신 축은 확장 자산과 동일하다 — 운영자가 파일을 고치면 `CustomAssets` 가
// 그것을 감지해 `ext.cache_version` 을 올리고, 그 단일 지점이 재게시까지 예약한다.
$this->publishDistAssets(
base_path("templates/{$identifier}/".CustomAssets::DIRECTORY),
$templateDir.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY,
$files,
$tmp,
excludeCustom: false
);
}
/**
* 템플릿 dist 디렉토리를 재귀 복사합니다 (허용 확장자만, 소스맵 제외).
*
* @param string $sourceDir 원본 dist 절대 경로
* @param string $sourceDir 원본 절대 경로 (dist 또는 custom)
* @param string $targetDir 게시 대상 절대 경로
* @param array<string> $files 기록된 상대 경로 누적 (참조)
* @param string $tmp tmp 루트 (상대 경로 계산용)
* @param bool $excludeCustom 원본 안의 `custom/` 하위를 건너뛸지 (dist 원본에서만 참)
*/
private function publishDistAssets(string $sourceDir, string $targetDir, array &$files, string $tmp): void
{
private function publishDistAssets(
string $sourceDir,
string $targetDir,
array &$files,
string $tmp,
bool $excludeCustom = true
): void {
$realSource = realpath($sourceDir);
if ($realSource === false || ! is_dir($realSource)) {
@@ -486,10 +523,33 @@ class ExtensionStaticCacheService
}
$relative = substr($realFile, strlen($realSource) + 1);
// dist 원본에서는 `custom/` 하위를 건너뛴다 — 운영자 파일은 자기 원본
// 루트로 따로 게시되므로, dist 를 통해 한 번 더 실리면 같은 파일이 두 경로에
// 놓여 어느 쪽이 유효한지가 갈린다. custom 원본으로 호출될 때는 끄고 들어온다
// (그러지 않으면 `custom/custom/…` 이 조용히 누락된다).
if ($excludeCustom && $this->isCustomAssetPath($relative)) {
continue;
}
$this->copyFile($realFile, $targetDir.DIRECTORY_SEPARATOR.$relative, $files, $tmp);
}
}
/**
* 상대 경로가 운영자 소유 디렉토리(`custom/`) 소속인지 판정합니다.
*
* @param string $relative 게시 원본 기준 상대 경로
* @return bool custom 소속이면 true
*/
private function isCustomAssetPath(string $relative): bool
{
$normalized = str_replace(DIRECTORY_SEPARATOR, '/', $relative);
return $normalized === CustomAssets::DIRECTORY
|| str_starts_with($normalized, CustomAssets::DIRECTORY.'/');
}
/**
* 확장 병합 번들 4종(modules/plugins × js/css)을 게시합니다.
*
@@ -519,6 +579,59 @@ class ExtensionStaticCacheService
}
}
/**
* 활성 모듈·플러그인의 운영자 소유 디렉토리(`custom/`)를 게시합니다.
*
* 모듈·플러그인의 빌드 산출물은 **병합 번들**로만 게시되는데, `custom/` 은 그 번들에
* 들어가지 않는다(운영자 파일은 번들보다 뒤에 따로 로드되어야 재정의가 성립한다).
* 그래서 게시하지 않으면 확장 자산 중 이것만 요청마다 PHP 를 거치고, 무엇보다
* CSS 내부 상대 `url()` 이 해석되지 않는다 — `?file=` 형태는 기준 URL 이
* `/api/modules/assets/` 라 `url('./font.woff2')` 가 그 디렉토리를 가리키고,
* 확장자 형태는 정적 최적화 서버가 먼저 가로챈다. 정적 확장자 URL 은 **public 아래
* 실제 파일일 때만** 200 이 되므로 게시가 유일한 방법이다 (템플릿과 같은 사유).
*
* **활성 확장만** 게시한다. 자산 서빙이 활성 확장에만 응답하므로, 비활성 확장의 파일을
* 게시해 봐야 아무도 참조하지 않는 사본이 버전 디렉토리마다 쌓일 뿐이다.
*
* @param string $tmp tmp 디렉토리 절대 경로
* @param array<string> $files 기록된 상대 경로 누적 (참조)
*/
private function publishExtensionCustomAssets(string $tmp, array &$files): void
{
// 활성 목록은 **레포지토리**에서 읽는다. 매니저(`getActiveModules()`)는 확장을
// 실제로 적재하는 부작용이 있어, 게시 중에 그 부작용을 끌어들이면 게시가 확장
// 부팅 상태에 좌우된다. 같은 메서드가 템플릿을 `templateRepository->getActive()`
// 로 세는 것과 대칭이다.
$sources = [
'modules' => $this->moduleRepository->getActiveModuleIdentifiers(),
'plugins' => $this->pluginRepository->getActivePluginIdentifiers(),
];
foreach ($sources as $root => $identifiers) {
foreach ($identifiers as $identifier) {
$identifier = (string) $identifier;
if (! preg_match(self::IDENTIFIER_PATTERN, $identifier)) {
Log::warning('정적 게시 제외 — 식별자 패턴 불일치', [
'type' => $root,
'identifier' => $identifier,
]);
continue;
}
$this->publishDistAssets(
base_path("{$root}/{$identifier}/".CustomAssets::DIRECTORY),
$tmp.DIRECTORY_SEPARATOR.$root.DIRECTORY_SEPARATOR.$identifier
.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.CustomAssets::DIRECTORY,
$files,
$tmp,
excludeCustom: false
);
}
}
}
/**
* 게시 대상 로케일을 열거합니다 — `/api/locales/active` 와 동일 소스
* (언어팩이 추가한 로케일 포함).
+16 -3
View File
@@ -366,13 +366,20 @@ class ModuleService
* @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터
* @return bool 제거 성공 여부
*
* @throws ValidationException 모듈 제거 실패 시
*/
public function uninstallModule(string $moduleName, bool $deleteData = false, ?string &$failureReason = null): bool
{
public function uninstallModule(
string $moduleName,
bool $deleteData = false,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
HookManager::doAction('core.modules.before_uninstall', $moduleName, $deleteData);
@@ -381,7 +388,13 @@ class ModuleService
$this->moduleManager->loadModules();
$moduleInfo = $this->moduleManager->getModuleInfo($moduleName);
$result = $this->moduleManager->uninstallModule($moduleName, $deleteData, null, $failureReason);
$result = $this->moduleManager->uninstallModule(
$moduleName,
$deleteData,
null,
$failureReason,
$preservedBackups
);
if ($result) {
$module = $this->moduleRepository->findByName($moduleName);
+16 -3
View File
@@ -243,20 +243,33 @@ class PluginService
* @param string $pluginName 플러그인 식별자
* @param bool $deleteData 플러그인이 생성한 DB 데이터/스토리지 디렉토리까지 삭제 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터
* @return bool 제거 성공 여부
*
* @throws ValidationException 제거 실패 시
*/
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?string &$failureReason = null): bool
{
public function uninstallPlugin(
string $pluginName,
bool $deleteData = false,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
HookManager::doAction('core.plugins.before_uninstall', $pluginName, $deleteData);
try {
$this->pluginManager->loadPlugins();
$result = $this->pluginManager->uninstallPlugin($pluginName, $deleteData, null, $failureReason);
$result = $this->pluginManager->uninstallPlugin(
$pluginName,
$deleteData,
null,
$failureReason,
$preservedBackups
);
HookManager::doAction('core.plugins.after_uninstall', $pluginName, $deleteData, $result);
+18 -4
View File
@@ -16,6 +16,7 @@ use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\ZipInstallHelper;
use App\Extension\HookManager;
use App\Extension\Traits\ResolvesLanguageFragments;
use App\Support\CustomAssets;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -432,19 +433,26 @@ class TemplateService
*
* @param string $identifier 제거할 템플릿 식별자
* @param bool $deleteData 템플릿 관련 데이터 삭제 여부
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터
* @return array|null 제거된 템플릿 정보 또는 null
*
* @throws ValidationException 제거 실패 시
*/
public function uninstallTemplate(string $identifier, bool $deleteData = false): ?array
{
public function uninstallTemplate(
string $identifier,
bool $deleteData = false,
?array &$preservedBackups = null,
): ?array {
$preservedBackups = [];
HookManager::doAction('core.templates.before_uninstall', $identifier, $deleteData);
try {
// 제거 전 템플릿 정보 보존
$templateInfo = $this->templateManager->getTemplateInfo($identifier);
$result = $this->templateManager->uninstallTemplate($identifier);
$result = $this->templateManager->uninstallTemplate($identifier, null, $preservedBackups);
if ($result) {
HookManager::doAction('core.templates.after_uninstall', $identifier, $templateInfo, $deleteData);
@@ -660,7 +668,13 @@ class TemplateService
$safePath = $this->sanitizePath($path);
// 3. 파일 경로 구성
$filePath = base_path("templates/{$identifier}/dist/{$safePath}");
//
// 템플릿 자산은 `dist/` 이하가 기본이지만, 운영자 소유 디렉토리(`custom/`)만은
// 그 밖에 있다 — 빌드 산출물이 아니라 사람이 넣은 파일이고, 확장 교체가
// 보존하는 대상이라 빌드 디렉토리에 둘 수 없다.
$filePath = str_starts_with($safePath, CustomAssets::DIRECTORY.'/')
? base_path("templates/{$identifier}/{$safePath}")
: base_path("templates/{$identifier}/dist/{$safePath}");
// 4. 파일 존재 확인
if (! file_exists($filePath) || ! is_file($filePath)) {
+53 -6
View File
@@ -264,9 +264,13 @@ class AssetUrl
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function moduleAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('modules', $identifier, $path, $version);
public static function moduleAsset(
string $identifier,
string $path,
int|string|null $version = null,
bool $allowStatic = false
): string {
return self::extensionStaticOrApi('modules', $identifier, $path, $version, $allowStatic);
}
/**
@@ -277,9 +281,52 @@ class AssetUrl
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @return string 생성된 URL
*/
public static function pluginAsset(string $identifier, string $path, int|string|null $version = null): string
{
return self::asset('plugins', $identifier, $path, $version);
public static function pluginAsset(
string $identifier,
string $path,
int|string|null $version = null,
bool $allowStatic = false
): string {
return self::extensionStaticOrApi('plugins', $identifier, $path, $version, $allowStatic);
}
/**
* 모듈·플러그인 자산의 정적 게시본 우선 URL 을 만듭니다.
*
* 정적 분기가 **기본 꺼짐**인 것이 템플릿과 다른 점이고, 그것이 의도다. 모듈·플러그인의
* 빌드 산출물은 개별 파일로 게시되지 않고 **병합 번들**로만 게시되므로, 그 경로에
* 존재 검사를 걸어 봐야 언제나 실패하는 파일시스템 조회만 늘어난다. 개별 게시 대상은
* 운영자 소유 디렉토리(`custom/`) 하나뿐이라 그 호출부만 켜서 쓴다.
*
* @param string $root `modules` | `plugins`
* @param string $identifier 확장 식별자
* @param string $path 확장 루트 기준 파일 경로
* @param int|string|null $version 캐시 무효화 버전
* @param bool $allowStatic 정적 게시본 우선 여부
* @return string 생성된 URL
*/
private static function extensionStaticOrApi(
string $root,
string $identifier,
string $path,
int|string|null $version,
bool $allowStatic
): string {
// 게이트는 템플릿과 동일하다 — 프로덕션·kill-switch·게시 완료·개별 파일 존재를
// `staticTagUrl` 이 확인하고, 버전이 현재 게시본과 다르면 정적 분기를 건너뛴다.
if ($allowStatic) {
$base = self::staticExtBase();
if ($base !== null && ($version === null || $base === '/build/ext/'.$version)) {
$static = self::staticTagUrl($root.'/'.$identifier.'/assets/'.ltrim($path, '/'));
if ($static !== null) {
return $static;
}
}
}
return self::asset($root, $identifier, $path, $version);
}
/**
+433
View File
@@ -0,0 +1,433 @@
<?php
namespace App\Support;
use App\Extension\HookManager;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
* 사용자 추가 에셋(`custom/`) 해석기
*
* 운영자가 자기 CSS·JS·정적 파일을 덧붙일 자리를 각 확장이 제공한다. 종전에는 그런
* 자리가 없어서, CSS 한 줄을 더하려면 확장 소스(`src/styles/`)를 고치고 Node.js 로
* 빌드해야 했고 — 그렇게 넣은 파일은 다음 확장 업데이트에 통째로 사라졌다.
*
* 이 클래스는 **여러 출처를 합쳐 서술자 목록을 만드는 해석기**다. 지금 출처는 둘
* (선언 파일 `custom/assets.json`, 규약 스캔)이지만, 소비자(뷰 컴포저·blade·프론트
* 로더·서빙)는 출처를 보지 않는다. 나중에 템플릿 환경설정이 화면에서 입력한 CSS 를
* 실어 보내더라도 `core.assets.custom_assets` 필터로 항목을 더하면 그만이다.
*
* @see docs/extension/module-assets.md "사용자 추가 에셋"
*/
class CustomAssets
{
/** 운영자 소유 디렉토리명 */
public const DIRECTORY = 'custom';
/** 선언 파일명 */
public const DECLARATION_FILE = 'assets.json';
/**
* 운영자 파일 서명 캐시 키 (변경 감지용)
*
* 뷰 컴포저(변경 감지)와 관리 API(직접 편집)가 같은 키를 본다. 관리 API 는 자기가
* 캐시 버전을 올린 뒤 이 키를 지워, 다음 렌더의 감지가 "첫 관측"(기록만)으로 끝나게
* 한다 — 안 그러면 같은 변경으로 버전이 두 번 오르고 재게시도 두 번 돈다.
*/
public const SIGNATURE_CACHE_KEY = 'ext.custom_signature';
/** 규약 스캔이 자동으로 싣는 확장자 → 자산 타입 */
private const CONVENTION_TYPES = [
'css' => 'style',
'js' => 'script',
];
/** 확장 타입 → 확장 루트 디렉토리 */
private const ROOTS = [
'templates' => 'templates',
'modules' => 'modules',
'plugins' => 'plugins',
];
/** 요청 스코프 메모이즈 (같은 요청에서 같은 확장을 여러 번 묻는 경로 대비) */
private static array $cache = [];
/**
* 확장 하나의 사용자 추가 에셋 목록을 돌려줍니다.
*
* @param string $extensionType `templates` | `modules` | `plugins`
* @param string $identifier 확장 식별자
* @return array<int, array<string, mixed>> 서술자 목록
*/
public static function forExtension(string $extensionType, string $identifier): array
{
$cacheKey = $extensionType.'|'.$identifier;
if (array_key_exists($cacheKey, self::$cache)) {
return self::$cache[$cacheKey];
}
$assets = self::resolve($extensionType, $identifier);
// 7.1.0 템플릿 환경설정 등 다른 출처가 항목을 더할 수 있는 지점.
// 소비자는 출처를 보지 않으므로 여기서 더한 항목도 같은 규칙으로 로드된다.
$assets = HookManager::applyFilters('core.assets.custom_assets', $assets, $extensionType, $identifier);
self::$cache[$cacheKey] = is_array($assets) ? array_values($assets) : [];
return self::$cache[$cacheKey];
}
/**
* 요청 스코프 캐시를 비웁니다 (테스트·파일 변경 직후용).
*
* @return void
*/
public static function flushCache(): void
{
self::$cache = [];
}
/**
* 확장의 `custom/` 디렉토리 절대 경로를 돌려줍니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @return string|null 경로, 타입이 유효하지 않으면 null
*/
public static function directory(string $extensionType, string $identifier): ?string
{
$root = self::ROOTS[$extensionType] ?? null;
if ($root === null || $identifier === '' || ! self::isSafeIdentifier($identifier)) {
return null;
}
return base_path($root.'/'.$identifier.'/'.self::DIRECTORY);
}
/**
* 선언 파일 또는 규약 스캔으로 자산 목록을 만듭니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @return array<int, array<string, mixed>> 서술자 목록
*/
private static function resolve(string $extensionType, string $identifier): array
{
$directory = self::directory($extensionType, $identifier);
if ($directory === null || ! is_dir($directory)) {
return [];
}
$declaration = $directory.DIRECTORY_SEPARATOR.self::DECLARATION_FILE;
if (is_file($declaration)) {
return self::fromDeclaration($declaration, $extensionType, $identifier, $directory);
}
return self::fromConvention($extensionType, $identifier, $directory);
}
/**
* `custom/assets.json` 선언을 해석합니다.
*
* 선언이 있으면 규약 스캔은 하지 않는다 — 둘을 합치면 "선언에서 뺐는데 왜 아직
* 로드되나" 가 된다. 선언이 깨졌을 때도 스캔으로 되돌아가지 않는다. 되돌아가면
* 운영자가 의도적으로 뺀 파일이 되살아난다.
*
* @param string $declaration 선언 파일 경로
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $directory `custom/` 절대 경로
* @return array<int, array<string, mixed>> 서술자 목록
*/
private static function fromDeclaration(
string $declaration,
string $extensionType,
string $identifier,
string $directory
): array {
$decoded = json_decode((string) file_get_contents($declaration), true);
if (! is_array($decoded) || ! isset($decoded['assets']) || ! is_array($decoded['assets'])) {
Log::warning('사용자 추가 에셋 선언을 읽을 수 없습니다 (해당 확장의 custom 자산을 로드하지 않습니다).', [
'declaration' => $declaration,
'json_error' => json_last_error_msg(),
]);
return [];
}
$assets = [];
foreach ($decoded['assets'] as $index => $entry) {
if (! is_array($entry)) {
continue;
}
$type = $entry['type'] ?? null;
if (! in_array($type, ['style', 'script'], true)) {
Log::warning('사용자 추가 에셋 항목의 type 이 올바르지 않습니다.', [
'declaration' => $declaration,
'index' => $index,
'type' => is_scalar($type) ? $type : gettype($type),
]);
continue;
}
// 외부 URL — 운영자가 자기 사이트에 직접 등록한 것만 허용한다 (D14).
// 확장 저작자의 기본값이 아니라 운영자 본인의 선택이라서 성립하는 예외이며,
// 왜 외부로 나가는지가 파일에 남도록 사유를 요구한다.
if (isset($entry['url'])) {
$url = $entry['url'];
$reason = $entry['reason'] ?? null;
if (! is_string($url) || ! preg_match('#^https://#i', $url)) {
Log::warning('사용자 추가 에셋의 외부 URL 은 https 여야 합니다.', [
'declaration' => $declaration,
'index' => $index,
]);
continue;
}
if (! is_string($reason) || trim($reason) === '') {
Log::warning('사용자 추가 에셋의 외부 URL 에는 reason(사유)이 필요합니다.', [
'declaration' => $declaration,
'index' => $index,
'url' => $url,
]);
continue;
}
$assets[] = [
'id' => self::assetId($extensionType, $identifier, $url),
'type' => $type,
'url' => $url,
'version' => null,
'source' => 'url',
];
continue;
}
$file = $entry['file'] ?? null;
if (! is_string($file) || $file === '') {
Log::warning('사용자 추가 에셋 항목에 file 또는 url 이 없습니다.', [
'declaration' => $declaration,
'index' => $index,
]);
continue;
}
$descriptor = self::fileDescriptor($extensionType, $identifier, $directory, $file, $type);
if ($descriptor !== null) {
$assets[] = $descriptor;
}
}
return $assets;
}
/**
* 규약 스캔으로 자산 목록을 만듭니다.
*
* `custom/*.css` · `custom/*.js` 를 파일명 오름차순으로 싣는다. 하위 디렉토리는
* 훑지 않는다 — 폰트·이미지는 CSS 가 상대 경로로 참조하는 대상이지 그 자체로
* 로드할 것이 아니다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $directory `custom/` 절대 경로
* @return array<int, array<string, mixed>> 서술자 목록
*/
private static function fromConvention(string $extensionType, string $identifier, string $directory): array
{
$entries = scandir($directory);
if ($entries === false) {
return [];
}
sort($entries, SORT_STRING);
$styles = [];
$scripts = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || ! is_file($directory.DIRECTORY_SEPARATOR.$entry)) {
continue;
}
$extension = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
$type = self::CONVENTION_TYPES[$extension] ?? null;
if ($type === null) {
continue;
}
$descriptor = self::fileDescriptor($extensionType, $identifier, $directory, $entry, $type);
if ($descriptor === null) {
continue;
}
// CSS 를 JS 보다 먼저 — 스타일이 먼저 붙어야 스크립트가 만드는 DOM 도 즉시 적용된다
if ($type === 'style') {
$styles[] = $descriptor;
} else {
$scripts[] = $descriptor;
}
}
return array_merge($styles, $scripts);
}
/**
* 파일 기반 서술자를 만듭니다.
*
* `version` 은 파일 수정 시각이다. 확장 캐시 버전(`ext.cache_version`)은 운영자가
* 파일을 고쳤다고 오르지 않으므로, 그 값으로 URL 을 만들면 수정이 브라우저에
* 반영되지 않는다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $directory `custom/` 절대 경로
* @param string $file `custom/` 기준 상대 경로
* @param string $type `style` | `script`
* @return array<string, mixed>|null 서술자, 유효하지 않으면 null
*/
private static function fileDescriptor(
string $extensionType,
string $identifier,
string $directory,
string $file,
string $type
): ?array {
$relative = ltrim(str_replace('\\', '/', $file), '/');
if ($relative === '' || str_contains($relative, '..') || str_contains($relative, "\0")) {
Log::warning('사용자 추가 에셋 경로가 안전하지 않습니다.', [
'identifier' => $identifier,
'file' => $file,
]);
return null;
}
if (! self::isAllowedExtension($relative)) {
Log::warning('사용자 추가 에셋의 확장자가 허용 목록에 없습니다.', [
'identifier' => $identifier,
'file' => $relative,
]);
return null;
}
$absolute = $directory.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (! is_file($absolute)) {
Log::warning('선언된 사용자 추가 에셋 파일이 없습니다.', [
'identifier' => $identifier,
'file' => $relative,
]);
return null;
}
$servePath = self::DIRECTORY.'/'.$relative;
$version = @filemtime($absolute) ?: null;
return [
'id' => self::assetId($extensionType, $identifier, $relative),
'type' => $type,
'url' => self::assetUrl($extensionType, $identifier, $servePath, $version),
'version' => $version,
'source' => 'file',
];
}
/**
* 확장 타입에 맞는 자산 URL 을 만듭니다.
*
* 템플릿은 서버가 `dist/` 를 자동 부가하지만 `custom/` 은 그 밖에 있다 —
* `TemplateService::getAssetFilePath` 가 `custom/` 접두를 따로 해석한다.
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $servePath 서빙 경로 (`custom/...`)
* @param int|null $version 캐시 무효화 버전
* @return string 자산 URL
*/
private static function assetUrl(string $extensionType, string $identifier, string $servePath, ?int $version): string
{
// 세 타입 모두 확장 자산과 **같은 메커니즘**으로 정적 게시되므로 URL 축도 같다.
// 파일 서명(mtime)을 넘기지 않는 이유: 정적 경로는 언제나 **현재 게시 버전**이라,
// 다른 값을 넘기면 `AssetUrl` 의 버전 일치 게이트에 걸려 정적 분기가 영영 선택되지
// 않는다. 운영자가 파일을 고치면 `CollectsCustomAssets` 가 그것을 감지해 확장 캐시
// 버전을 올리고, 그 단일 지점이 재게시까지 예약한다.
//
// 게시본이 아직 없으면(게시 직전 창·비프로덕션·kill-switch) `AssetUrl` 이 API 경로로
// 떨어지고, 그 응답은 디스크의 최신 내용을 그대로 준다. 그 경로의 `?v` 도 캐시
// 버전이라 파일 수정 → 감지 → bump 로 함께 갱신된다.
//
// `$version`(파일 mtime)은 URL 에 쓰지 않는다. 서술자의 `version` 필드로만 남아
// 변경 감지 서명의 재료가 된다 — URL 축과 감지 축은 목적이 다르다.
$current = ExtensionStaticCacheService::getExtensionCacheVersion();
return match ($extensionType) {
'templates' => AssetUrl::templateAsset($identifier, $servePath, $current),
'modules' => AssetUrl::moduleAsset($identifier, $servePath, $current, allowStatic: true),
default => AssetUrl::pluginAsset($identifier, $servePath, $current, allowStatic: true),
};
}
/**
* 서술자 식별자를 만듭니다 (중복 로드 방지 · 실패 표면화 키).
*
* @param string $extensionType 확장 타입
* @param string $identifier 확장 식별자
* @param string $suffix 파일 경로 또는 URL
* @return string 식별자
*/
private static function assetId(string $extensionType, string $identifier, string $suffix): string
{
return 'custom:'.$extensionType.':'.$identifier.':'.$suffix;
}
/**
* 확장자가 허용 목록에 있는지 판정합니다.
*
* 자산 서빙이 다시 검증하지만, 목록에 없는 파일을 URL 로 만들어 페이지에 실어
* 보낼 이유가 없다.
*
* @param string $relative 상대 경로
* @return bool 허용되면 true
*/
private static function isAllowedExtension(string $relative): bool
{
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
return in_array($extension, ['css', 'js', 'mjs'], true);
}
/**
* 확장 식별자가 경로로 안전한지 판정합니다.
*
* @param string $identifier 확장 식별자
* @return bool 안전하면 true
*/
private static function isSafeIdentifier(string $identifier): bool
{
return preg_match('/^[A-Za-z0-9._-]+$/', $identifier) === 1 && ! str_contains($identifier, '..');
}
}
+232 -6
View File
@@ -2,10 +2,28 @@
namespace App\Support;
use App\Rules\SafeLayoutExpressions;
use Illuminate\Support\Facades\Log;
class TemplateExternals
{
/**
* same-origin 자산 항목에서 무시하는 키.
*
* `preconnect`·`dns-prefetch` 는 다른 origin 과의 커넥션을 미리 여는 힌트이고,
* `crossorigin` 은 교차 출처 요청의 자격 증명 모드를 정하는 속성이다. 자기 서버의
* 자산에는 둘 다 의미가 없다 — 남겨두면 브라우저가 자기 origin 에 preconnect 를
* 걸고, 폰트 CSS 에 불필요한 CORS 모드가 붙는다.
*/
private const EXTERNAL_ONLY_KEYS = ['preconnect', 'crossorigin'];
/**
* 자체 제공(same-origin)이 불가능한 타입.
*
* 리소스 힌트는 정의상 다른 origin 을 향한다.
*/
private const EXTERNAL_ONLY_TYPES = ['preconnect', 'dns-prefetch'];
private const TYPES = [
'style',
'webfont',
@@ -35,10 +53,19 @@ class TemplateExternals
];
/**
* @param array<int, mixed> $externals
* externals 선언을 정규화합니다.
*
* 항목은 두 형태 중 하나로 자산을 가리킨다:
* - `asset`: 템플릿이 **자체 제공**하는 파일의 `dist/` 이하 경로. `AssetUrl::templateAsset()`
* 이 자산 URL 이중 모드·정적 게시를 반영한 URL 을 만든다. 자체 제공이 원칙이므로 이쪽이 기본.
* - `url`: 절대 URL. `https://` 외부 URL 또는 `/` 로 시작하는 same-origin 경로.
*
* @param array<int, mixed> $externals template.json 의 externals 배열
* @param string|null $templateIdentifier `asset` 해석에 필요한 템플릿 식별자
* @param int|string|null $version 캐시 무효화 버전
* @return array<int, array<string, mixed>>
*/
public static function normalize(array $externals): array
public static function normalize(array $externals, ?string $templateIdentifier = null, int|string|null $version = null): array
{
$normalized = [];
$seen = [];
@@ -48,7 +75,7 @@ class TemplateExternals
continue;
}
$item = self::normalizeItem($external);
$item = self::normalizeItem($external, $templateIdentifier, $version);
if ($item === null) {
continue;
@@ -133,6 +160,7 @@ class TemplateExternals
if (in_array($external['type'], ['style', 'webfont'], true)) {
self::appendIfString($attributes, 'media', $external['media'] ?? null);
$attributes['onerror'] = self::failureHandlerScript($external);
}
if ($external['type'] === 'preload') {
@@ -167,9 +195,59 @@ class TemplateExternals
$attributes['defer'] = true;
}
$attributes['onerror'] = self::failureHandlerScript($external);
return $attributes;
}
/**
* 자산 로드 실패를 알리는 인라인 핸들러를 만듭니다.
*
* 이 태그들은 서버가 HTML 에 직접 심으므로, 브라우저가 자산에 도달하지 못해도
* 자바스크립트 쪽에는 아무 신호가 오지 않는다. 실패는 "아이콘이 안 보인다" ·
* "글꼴이 다르다" 로만 나타나고 자체 서버 로그에도 흔적이 남지 않아 운영자가
* 원인을 특정할 수 없다. `onerror` 가 그 유일한 신호다.
*
* 부팅 초기에는 엔진이 아직 없을 수 있으므로 핸들러는 `template-externals-head`
* 가 먼저 심어 두는 부트스트랩(대기열)을 부른다.
*
* @param array<string, mixed> $external 정규화된 externals 항목
* @return string onerror 속성값
*/
private static function failureHandlerScript(array $external): string
{
$label = self::failureLabel($external);
return "window.__g7ExternalAssetFailed&&window.__g7ExternalAssetFailed(this,'".$label."')";
}
/**
* 안내 배너에 표시할 짧은 항목명을 만듭니다.
*
* 선언된 `id` 가 있으면 그것을, 없으면 URL 의 파일명을 쓴다 — 운영자가 어느 자산이
* 실패했는지 알아볼 수 있어야 한다.
*
* @param array<string, mixed> $external 정규화된 externals 항목
* @return string 항목명 (작은따옴표 안전)
*/
private static function failureLabel(array $external): string
{
$id = $external['id'] ?? null;
if (is_string($id) && $id !== '') {
return $id;
}
$path = parse_url((string) ($external['url'] ?? ''), PHP_URL_PATH) ?: '';
$basename = basename($path);
if ($basename === '') {
return 'asset';
}
return preg_replace('/[^A-Za-z0-9._-]/', '', $basename) ?: 'asset';
}
/**
* @param array<string, string|bool> $attributes
*/
@@ -192,15 +270,41 @@ class TemplateExternals
return $html;
}
private static function normalizeItem(array $external): ?array
/**
* externals 항목 1건을 정규화합니다.
*
* @param array<string, mixed> $external 원본 항목
* @param string|null $templateIdentifier `asset` 해석용 템플릿 식별자
* @param int|string|null $version 캐시 무효화 버전
* @return array<string, mixed>|null 정규화 결과, 유효하지 않으면 null
*/
private static function normalizeItem(array $external, ?string $templateIdentifier = null, int|string|null $version = null): ?array
{
$type = $external['type'] ?? null;
$url = $external['url'] ?? null;
if (! is_string($type) || ! in_array($type, self::TYPES, true) || ! self::isHttpsUrl($url)) {
if (! is_string($type) || ! in_array($type, self::TYPES, true)) {
Log::warning('Template external skipped because type is invalid.', [
'type' => is_scalar($type) ? $type : gettype($type),
]);
return null;
}
$resolved = self::resolveUrl($external, $type, $templateIdentifier, $version);
if ($resolved === null) {
return null;
}
[$url, $isSameOrigin] = $resolved;
// same-origin 항목에서는 외부 전용 키를 떨어뜨린다 (선언에 남아 있어도 무시)
if ($isSameOrigin) {
foreach (self::EXTERNAL_ONLY_KEYS as $key) {
unset($external[$key]);
}
}
if ($type === 'preload' && empty($external['as'])) {
Log::warning('Template external preload skipped because as is missing.', ['url' => $url]);
@@ -264,6 +368,128 @@ class TemplateExternals
return $item;
}
/**
* 항목이 가리키는 자산 URL 을 해석합니다.
*
* 종전에는 `https://` 로 시작하지 않는 항목을 **로그 없이 버렸다**. 그래서 자체 제공
* 경로를 적으면 자산이 조용히 사라지고, 선언은 파일에 남아 있어 원인을 찾을 수 없었다.
*
* @param array<string, mixed> $external 원본 항목
* @param string $type 항목 타입
* @param string|null $templateIdentifier `asset` 해석용 템플릿 식별자
* @param int|string|null $version 캐시 무효화 버전
* @return array{0: string, 1: bool}|null [URL, same-origin 여부], 유효하지 않으면 null
*/
private static function resolveUrl(array $external, string $type, ?string $templateIdentifier, int|string|null $version): ?array
{
$asset = $external['asset'] ?? null;
if (is_string($asset) && $asset !== '') {
if (in_array($type, self::EXTERNAL_ONLY_TYPES, true)) {
Log::warning('Template external skipped because asset is not applicable to this type.', [
'type' => $type,
'asset' => $asset,
]);
return null;
}
if ($templateIdentifier === null || $templateIdentifier === '') {
Log::warning('Template external skipped because template identifier is unknown.', [
'asset' => $asset,
]);
return null;
}
if (! self::isSafeAssetPath($asset)) {
Log::warning('Template external skipped because asset path is unsafe.', [
'asset' => $asset,
]);
return null;
}
if (isset($external['url'])) {
Log::warning('Template external url is ignored because asset takes precedence.', [
'asset' => $asset,
'url' => is_scalar($external['url']) ? $external['url'] : gettype($external['url']),
]);
}
return [AssetUrl::templateAsset($templateIdentifier, $asset, $version), true];
}
$url = $external['url'] ?? null;
if (self::isHttpsUrl($url)) {
return [$url, false];
}
if (is_string($url) && self::isSameOriginPath($url)) {
if (in_array($type, self::EXTERNAL_ONLY_TYPES, true)) {
Log::warning('Template external skipped because same-origin url is not applicable to this type.', [
'type' => $type,
'url' => $url,
]);
return null;
}
return [$url, true];
}
Log::warning('Template external skipped because url is neither https nor a same-origin path.', [
'type' => $type,
'url' => is_scalar($url) ? $url : gettype($url),
]);
return null;
}
/**
* `asset` 경로가 안전한지 판정합니다.
*
* 서버측 자산 서빙이 다시 검증하지만, URL 을 만들기 전에 걸러야 잘못된 선언이
* 페이지에 실려 나가지 않는다.
*
* @param string $path `dist/` 이하 상대 경로
* @return bool 안전하면 true
*/
private static function isSafeAssetPath(string $path): bool
{
if (str_starts_with($path, '/') || str_contains($path, '..') || str_contains($path, "\0")) {
return false;
}
return preg_match('#^[A-Za-z0-9._\-/]+$#', $path) === 1;
}
/**
* same-origin path-only URL 인지 판정합니다.
*
* 접두 문자열만 보면 `/\evil.com/x.css` 같은 값이 통과한다 — 브라우저는 그것을
* 외부 origin 으로 해석한다. 저장측·런타임·정적검사가 공유하는 정규화(SSoT:
* `TrustedScriptHosts::normalizeForOriginCheck`)를 거친 뒤 판정한다.
*
* @param string $url 검사 대상 URL
* @return bool same-origin 경로면 true
*/
private static function isSameOriginPath(string $url): bool
{
$normalized = SafeLayoutExpressions::normalizeForOriginCheck($url);
if (str_starts_with($normalized, '//')) {
return false;
}
if (preg_match('/^[a-z][a-z0-9+.\-]*:/i', $normalized) === 1) {
return false;
}
return str_starts_with($normalized, '/');
}
/**
* @param array<int, array<string, mixed>> $hints
* @param array<string, bool> $seen
+16
View File
@@ -268,6 +268,22 @@ return [
['identifier' => 'core.templates.layouts.edit', 'type' => 'admin', 'name' => ['ko' => '레이아웃 편집', 'en' => 'Edit Layouts'], 'description' => ['ko' => '템플릿 레이아웃을 편집할 수 있습니다.', 'en' => 'Can edit template layouts.'], 'order' => 5],
],
],
[
'identifier' => 'core.extensions',
'name' => ['ko' => '확장 공통', 'en' => 'Extension Common'],
'description' => ['ko' => '모듈·플러그인·템플릿에 공통으로 적용되는 권한', 'en' => 'Permissions that apply across modules, plugins and templates'],
'category' => 'extensions',
'order' => 5.5,
'type' => 'admin',
'permissions' => [
// 확장 타입을 가리지 않는 단일 권한이다. 타입별로 쪼개면 운영자가 셋을 모두
// 부여해야 하고, "모듈 CSS 는 되는데 템플릿 CSS 는 안 되는" 상태가 실질적
// 의미 없이 생긴다. 레이아웃 편집 권한과는 분리한다 — 여기서 올린 스크립트는
// 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로, 레이아웃을 고칠
// 수 있다는 것이 곧 그 권한이 될 수 없다.
['identifier' => 'core.extensions.custom_assets.manage', 'type' => 'admin', 'name' => ['ko' => '커스텀 자산 관리', 'en' => 'Manage Custom Assets'], 'description' => ['ko' => '모듈·플러그인·템플릿에 운영자 CSS·JS·폰트·이미지를 추가하거나 수정할 수 있습니다. 추가한 스크립트는 사이트 전체에서 실행되므로 레이아웃 편집과 별도로 부여합니다.', 'en' => 'Can add or edit operator CSS/JS/fonts/images on modules, plugins and templates. Added scripts run across the whole site, so this is granted separately from layout editing.'], 'order' => 1],
],
],
[
'identifier' => 'core.permissions',
'name' => ['ko' => '권한 관리', 'en' => 'Permission Management'],
+2
View File
@@ -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 등)
+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 박제 방지).
+11
View File
@@ -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 주입
+204 -7
View File
@@ -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은 에셋 관련 헬퍼 메서드를 제공합니다:
+12
View File
@@ -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
+42 -6
View File
@@ -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
+1 -2
View File
@@ -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"
}
]
}
+2 -3
View File
@@ -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에서 실시간으로 받으면 도달 실패가 예외도 로그도 없이 아이콘만 사라집니다.
### 아이콘 사용 방법
+20 -1
View File
@@ -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
View File
@@ -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+** 추가
+5 -2
View File
@@ -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 라 자체 호스팅해도 동작하지 않는다."
}
}
```
+21
View File
@@ -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. 템플릿 네이밍 규칙
### 형식
+13
View File
@@ -343,6 +343,19 @@ Composer 설치 방식을 선택할 때만 필요하다.
---
## 7-1. 오프라인·폐쇄망 동작 범위
화면 구동에 필요한 자산(아이콘·글꼴·편집기·코드 편집기·국기 아이콘·이미지 압축)은 모두
설치본에 함께 담겨 사이트 자신의 서버에서 제공됩니다. 외부 인터넷에 나가지 못하는 환경에서도
관리자·사용자 화면이 정상 동작합니다.
**외부 연결이 필요한 유일한 기능은 주소 검색(우편번호)입니다.** 이 기능은 라이브러리가 아니라
Daum 이 운영하는 서비스 SDK 를 쓰므로, 자체 호스팅해도 동작하지 않습니다. 연결하지 못하는
환경에서는 우편번호·주소를 직접 입력할 수 있으며 안내가 표시됩니다.
운영자가 별도로 설정한 외부 서비스(검색엔진 분석 도구 등)는 이 범위와 무관하게 그 설정을
따릅니다.
## 8. 호스팅 환경별 제한사항
### 8.1 공유 호스팅 (Shared Hosting)
+19
View File
@@ -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 가 아니라
봇 화면을 재고 있는 것이다.
### 실행 시 유의 (경험칙)
| 항목 | 내용 |
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.9] - 2026-08-25
### Added
- 화면에 필요한 파일을 불러오지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 안내 제목, 여러 항목이 실패했을 때의 합산 문구, [다시 시도]·[닫기] 버튼과 재시도 실패 안내가 일본어 로케일에서 표시됩니다.
- 운영자가 덧붙인 CSS·JS·글꼴·이미지를 다루는 관리 화면의 일본어 번역을 추가했습니다 — 파일 목록과 본문 편집, 파일 올리기·삭제, 관리 대상 확장 선택, 권한이 없을 때의 안내가 일본어 로케일에서 표시됩니다.
- 레이아웃 편집기 툴바에서 운영자 추가 에셋을 잠시 끄고 켜는 항목의 일본어 번역을 추가했습니다 — 추가한 파일이 화면을 깨뜨렸을 때 그 파일 없이 화면을 다시 여는 안내가 함께 표시됩니다.
- 확장을 제거할 때 운영자가 넣어 둔 파일의 사본을 보관했다는 안내의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로가 일본어로 표시됩니다.
- 운영자 추가 에셋의 저장·올리기·삭제가 활동 로그에 남을 때 표시되는 문구의 일본어 번역을 추가했습니다.
## [1.0.8] - 2026-08-24
### Added
@@ -206,6 +206,9 @@ return [
'activity_log_index' => 'アクティビティログ一覧の閲覧',
'activity_log_delete' => 'アクティビティログ削除 (ID: :log_id)',
'activity_log_bulk_delete' => 'アクティビティログ一括削除 (:count件)',
'custom_asset_save' => 'カスタム資産保存 (:identifier — :path)',
'custom_asset_upload' => 'カスタム資産アップロード (:identifier — :path)',
'custom_asset_delete' => 'カスタム資産削除 (:identifier — :path)',
],
'fields' => [
'name' => '名前',
@@ -0,0 +1,30 @@
<?php
return [
'errors' => [
'not_found' => 'ファイルが見つかりません: :path',
'not_editable' => 'この形式(:extension)は本文を直接編集できません。ファイルを新しくアップロードして置き換えてください。',
'too_large_to_edit' => 'ファイルが大きすぎるため、エディターで開くことができません。(最大 :limit バイト)',
'read_failed' => 'ファイルを読み込めませんでした: :path',
'write_failed' => 'ファイルを保存できませんでした: :path',
'delete_failed' => 'ファイルを削除できませんでした: :path',
'directory_failed' => 'ディレクトリを作成できませんでした: :path',
'invalid_path' => '許可されていないパスです: :path',
'invalid_extension_target' => 'ターゲット拡張が見つかりません: :identifier',
'extension_not_allowed' => '許可されていないファイル形式です: :extension',
'upload_too_large' => 'アップロードファイルが大きすぎます。(最大 :limit バイト)',
],
'validation' => [
'path_required' => 'ファイルパスを指定してください。',
'content_present' => '本文フィールドが必要です。',
'file_required' => 'アップロードするファイルを選択してください。',
'file_invalid' => '有効なファイルではありません。',
'file_mimes' => '許可されていないファイル形式です。(許可: :allowed)',
],
'messages' => [
'listed' => 'リストを読み込みました。',
'saved' => '保存しました。',
'uploaded' => 'ファイルをアップロードしました。',
'deleted' => 'ファイルを削除しました。',
],
];
@@ -66,6 +66,7 @@ return [
'confirm_question' => '本当に削除してもよろしいですか?',
'aborted' => 'モジュールの削除がキャンセルされました。',
'not_installed' => 'モジュール ":module" がインストールされていません。',
'custom_preserved' => '運営者ファイル(:directory)を :archive に保管しました。',
],
'cache_clear' => [
'clearing_all' => 'すべてのモジュールキャッシュを削除します...',
@@ -184,6 +184,7 @@ return [
'confirm_question' => '本当に削除してもよろしいですか?',
'aborted' => 'プラグイン削除がキャンセルされました。',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'custom_preserved' => '運営者ファイル(:directory)を:archiveに保管しました。',
],
'cache_clear' => [
'clearing_all' => 'すべてのプラグインキャッシュを削除します...',
@@ -230,6 +230,7 @@ return [
'layouts_deleted' => 'レイアウト:count個を削除しました',
'versions_deleted' => 'バージョン履歴:count個を削除しました',
'aborted' => '削除がキャンセルされました。',
'custom_preserved' => '運営者ファイル(:directory)を:archiveに保管しました。',
],
'list' => [
'no_templates' => '登録されたテンプレートがありません。',
@@ -2,6 +2,9 @@
"core": {
"errors": {
"$partial": "partial/errors.json"
},
"assets": {
"$partial": "partial/assets.json"
}
},
"layout_editor": {
@@ -0,0 +1,8 @@
{
"load_failed": "{label}を読み込めませんでした。",
"load_failed_multiple": "{count}個の項目を読み込めませんでした。",
"retry": "再試行",
"retrying": "再試行中...",
"close": "閉じる",
"retry_failed": "再試行しましたが失敗しました。しばらく後に再度お試しください。"
}
@@ -35,7 +35,13 @@
"translations": "多言語",
"data_sources": "データ",
"reset": "初期化",
"switch_template": "別のテンプレートに切り替える"
"switch_template": "別のテンプレートに切り替える",
"custom_assets": "カスタムアセット",
"custom_assets_hint": "運営者が追加したCSS·JS·フォント·画像を管理します。",
"custom_assets_off": "カスタムアセット オフ",
"custom_assets_on": "カスタムアセット オン",
"custom_assets_off_hint": "運営者が追加したCSS·JSなしでこの画面を再度開きます。追加されたアセットが画面を破損した場合に使用します。",
"custom_assets_on_hint": "現在、運営者が追加したアセットはオフになっています。クリックして再度適用します。"
},
"route_tree": {
"panel_title": "画面/ルート",
@@ -1357,5 +1363,26 @@
"invalid": "有効なJSON形式ではありません。",
"must_be_object": "オブジェクト(JSON)形式である必要があります。例:{ \"名前\": 値 }",
"invalid_keys": "値の名前は英文字で始まり、英文字・数字・_ のみ使用できます:{keys}"
},
"custom_assets": {
"title": "カスタムアセット",
"close": "閉じる",
"loading": "読み込み中…",
"empty": "追加されたファイルがありません。以下にパスを入力して保存するか、ファイルをアップロードして開始してください。",
"forbidden": "カスタムアセットを管理する権限がありません。管理者に「カスタムアセット管理」権限をリクエストしてください。",
"new_path_placeholder": "例:10-overrides.css",
"path_label": "ファイルパス",
"content_label": "ファイル本文",
"new_file": "新しいファイル",
"save": "保存",
"upload": "ファイルアップロード",
"delete": "削除",
"delete_confirm": "このファイルを削除します。元に戻すことはできません。",
"binary_hint": "このフォーマット(:extension)は本文を直接編集できません。同じ名前で再度アップロードして置き換えてください。",
"apply_hint": "保存すると次の画面から適用されます。",
"target_label": "管理対象の拡張",
"type_template": "テンプレート",
"type_module": "モジュール",
"type_plugin": "プラグイン"
}
}
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.8",
"version": "1.0.9",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-08-25
### Added
- 편집기를 불러오지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 임시 입력창으로 전환되었고 작성한 내용은 그대로 저장된다는 안내가 일본어 로케일에서 표시됩니다.
- 그 안내 배너에 표시되는 항목 이름(편집기·편집기 스타일)의 일본어 번역을 추가했습니다 — 여러 항목이 함께 실패했을 때 무엇이 실패했는지 일본어로 구분됩니다.
## [1.0.1] - 2026-08-19
### Added
@@ -3,6 +3,11 @@
"data_source": {
"settings": "エディター設定",
"ckeditor5Uploads": "アップロード画像一覧"
},
"asset": {
"label": "エディター",
"style_label": "エディタースタイル",
"fallback_notice": "エディターを読み込めませんでした。一時的な入力ウィンドウに切り替えました。作成した内容はそのまま保存されます。"
}
},
"settings": {
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-ckeditor5) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-ckeditor5) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-ckeditor5",
@@ -0,0 +1,17 @@
# Changelog
이 언어팩의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-08-25
### Added
- 주소 검색을 불러오지 못했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다 — 주소를 직접 입력하라는 안내가 일본어 로케일에서 표시됩니다.
## [1.0.0] - 2026-06-11
### Added
- 다음 우편번호 플러그인(sirsoft-daum_postcode)의 일본어 번들 언어팩 초기 제공
@@ -19,7 +19,8 @@
"selected": "住所が選択されました。",
"error": {
"load_failed": "郵便番号サービスの読み込みに失敗しました。",
"search_failed": "住所検索に失敗しました。"
"search_failed": "住所検索に失敗しました。",
"sdk_unavailable": "住所検索を読み込めませんでした。住所を直接入力してください。"
}
},
"settings": {
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-daum_postcode) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-daum_postcode) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-daum_postcode",
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.8] - 2026-08-26
### Added
- 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다.
- 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다.
## [1.0.7] - 2026-08-24
### Added
@@ -738,7 +738,10 @@
"no_modified_layouts": "修正されたレイアウトがありません。",
"modified_layouts_list_title": "保持される修正レイアウト ({{count}}個)",
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が保持されます。",
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。",
"preserved_title": "管理者ファイルコピー保存",
"preserved_desc": "削除前に custom/ ディレクトリのコピーを以下のパスに保存しました。拡張を再度インストール後、custom/ に移動することでそのまま再利用できます。",
"uninstall_done_title": "モジュール削除完了"
},
"tabs": {
"file_upload": "ファイル アップロード",
@@ -944,7 +947,10 @@
"no_modified_layouts": "編集されたレイアウトがありません。",
"modified_layouts_list_title": "維持される編集レイアウト({{count}}個)",
"modified_layouts_keep_notice": "上記のレイアウトはアップデート後も現在の内容が維持されます。",
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。",
"preserved_title": "管理者ファイルコピー保存",
"preserved_desc": "削除前に custom/ ディレクトリのコピーを以下のパスに保存しました。拡張を再度インストール後、custom/ に移動することでそのまま再利用できます。",
"uninstall_done_title": "プラグイン削除完了"
},
"tabs": {
"file_upload": "ファイルアップロード",
@@ -2213,7 +2219,10 @@
"lines": "行",
"chars": "文字"
},
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。"
"modified_layouts_check_failed": "編集の確認ができませんでした。ネットワークの状態を確認してからもう一度試すか、安全に「編集を保持」を選択してください。",
"preserved_title": "管理者ファイルコピー保存",
"preserved_desc": "削除前に custom/ ディレクトリのコピーを以下のパスに保存しました。拡張を再度インストール後、custom/ に移動することでそのまま再利用できます。",
"uninstall_done_title": "テンプレート削除完了"
},
"tabs_install": {
"file_upload": "ファイルアップロード",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.7",
"version": "1.0.8",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
+3
View File
@@ -2,6 +2,9 @@
"core": {
"errors": {
"$partial": "partial/en/errors.json"
},
"assets": {
"$partial": "partial/en/assets.json"
}
},
"layout_editor": {
+3
View File
@@ -244,6 +244,9 @@ return [
'template_install_from_file' => 'Template installed from file',
'template_install_from_github' => 'Template installed from GitHub',
'template_refresh_layouts' => 'Template layouts refreshed (:template_name)',
'custom_asset_save' => 'Custom asset saved (:identifier — :path)',
'custom_asset_upload' => 'Custom asset uploaded (:identifier — :path)',
'custom_asset_delete' => 'Custom asset deleted (:identifier — :path)',
// Core update
'core_update_check' => 'Core update checked',
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
'errors' => [
'not_found' => 'File not found: :path',
'not_editable' => 'This file type (:extension) cannot be edited directly. Upload a replacement file instead.',
'too_large_to_edit' => 'The file is too large to open in the editor. (limit: :limit bytes)',
'read_failed' => 'Failed to read the file: :path',
'write_failed' => 'Failed to save the file: :path',
'delete_failed' => 'Failed to delete the file: :path',
'directory_failed' => 'Failed to create the directory: :path',
'invalid_path' => 'The path is not allowed: :path',
'invalid_extension_target' => 'Target extension not found: :identifier',
'extension_not_allowed' => 'File type not allowed: :extension',
'upload_too_large' => 'The uploaded file is too large. (limit: :limit bytes)',
],
'validation' => [
'path_required' => 'Please specify the file path.',
'content_present' => 'The content field is required.',
'file_required' => 'Please choose a file to upload.',
'file_invalid' => 'The upload is not a valid file.',
'file_mimes' => 'File type not allowed. (allowed: :allowed)',
],
'messages' => [
'listed' => 'Loaded.',
'saved' => 'Saved.',
'uploaded' => 'File uploaded.',
'deleted' => 'File deleted.',
],
];
+1
View File
@@ -58,6 +58,7 @@ return [
'permissions_deleted' => ':count permissions deleted',
'menus_deleted' => ':count menus deleted',
'layouts_deleted' => ':count layouts deleted',
'custom_preserved' => 'Operator files (:directory) were archived to :archive.',
'confirm_prompt' => 'Are you sure you want to uninstall module ":module"?',
'confirm_details' => [
'roles' => '- :count roles will be deleted.',
+1
View File
@@ -172,6 +172,7 @@ return [
'roles_deleted' => ':count roles deleted',
'permissions_deleted' => ':count permissions deleted',
'layouts_deleted' => ':count layouts deleted',
'custom_preserved' => 'Operator files (:directory) were archived to :archive.',
'confirm_prompt' => 'Are you sure you want to uninstall plugin ":plugin"?',
'confirm_details' => [
'roles' => '- :count roles will be deleted.',
+1
View File
@@ -258,6 +258,7 @@ return [
'confirm_question' => 'Do you want to continue?',
'layouts_deleted' => ':count layout(s) deleted',
'versions_deleted' => ':count version(s) deleted',
'custom_preserved' => 'Operator files (:directory) were archived to :archive.',
'aborted' => 'Uninstall aborted.',
],
'list' => [
+3
View File
@@ -2,6 +2,9 @@
"core": {
"errors": {
"$partial": "partial/ko/errors.json"
},
"assets": {
"$partial": "partial/ko/assets.json"
}
},
"layout_editor": {
+3
View File
@@ -244,6 +244,9 @@ return [
'template_install_from_file' => '파일에서 템플릿 설치',
'template_install_from_github' => 'GitHub에서 템플릿 설치',
'template_refresh_layouts' => '템플릿 레이아웃 갱신 (:template_name)',
'custom_asset_save' => '커스텀 자산 저장 (:identifier — :path)',
'custom_asset_upload' => '커스텀 자산 업로드 (:identifier — :path)',
'custom_asset_delete' => '커스텀 자산 삭제 (:identifier — :path)',
// 코어 업데이트
'core_update_check' => '코어 업데이트 확인',
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
'errors' => [
'not_found' => '파일을 찾을 수 없습니다: :path',
'not_editable' => '이 형식(:extension)은 본문을 직접 편집할 수 없습니다. 파일을 새로 올려 교체해 주세요.',
'too_large_to_edit' => '파일이 너무 커서 편집기에서 열 수 없습니다. (최대 :limit 바이트)',
'read_failed' => '파일을 읽지 못했습니다: :path',
'write_failed' => '파일을 저장하지 못했습니다: :path',
'delete_failed' => '파일을 삭제하지 못했습니다: :path',
'directory_failed' => '디렉토리를 만들지 못했습니다: :path',
'invalid_path' => '허용되지 않는 경로입니다: :path',
'invalid_extension_target' => '대상 확장을 찾을 수 없습니다: :identifier',
'extension_not_allowed' => '허용되지 않는 파일 형식입니다: :extension',
'upload_too_large' => '업로드 파일이 너무 큽니다. (최대 :limit 바이트)',
],
'validation' => [
'path_required' => '파일 경로를 지정해 주세요.',
'content_present' => '본문 필드가 필요합니다.',
'file_required' => '올릴 파일을 선택해 주세요.',
'file_invalid' => '올바른 파일이 아닙니다.',
'file_mimes' => '허용되지 않는 파일 형식입니다. (허용: :allowed)',
],
'messages' => [
'listed' => '목록을 불러왔습니다.',
'saved' => '저장했습니다.',
'uploaded' => '파일을 올렸습니다.',
'deleted' => '파일을 삭제했습니다.',
],
];
+1
View File
@@ -58,6 +58,7 @@ return [
'permissions_deleted' => ':count개 권한 삭제됨',
'menus_deleted' => ':count개 메뉴 삭제됨',
'layouts_deleted' => ':count개 레이아웃 삭제됨',
'custom_preserved' => '운영자 파일(:directory)을 :archive 에 보관했습니다.',
'confirm_prompt' => '모듈 ":module"을(를) 삭제하시겠습니까?',
'confirm_details' => [
'roles' => '- :count개의 역할이 삭제됩니다.',
+1
View File
@@ -172,6 +172,7 @@ return [
'roles_deleted' => ':count개 역할 삭제됨',
'permissions_deleted' => ':count개 권한 삭제됨',
'layouts_deleted' => ':count개 레이아웃 삭제됨',
'custom_preserved' => '운영자 파일(:directory)을 :archive 에 보관했습니다.',
'confirm_prompt' => '플러그인 ":plugin"을(를) 삭제하시겠습니까?',
'confirm_details' => [
'roles' => '- :count개의 역할이 삭제됩니다.',
+1
View File
@@ -258,6 +258,7 @@ return [
'confirm_question' => '계속하시겠습니까?',
'layouts_deleted' => '레이아웃 :count개 삭제됨',
'versions_deleted' => '버전 히스토리 :count개 삭제됨',
'custom_preserved' => '운영자 파일(:directory)을 :archive 에 보관했습니다.',
'aborted' => '삭제가 취소되었습니다.',
],
'list' => [
+8
View File
@@ -0,0 +1,8 @@
{
"load_failed": "Could not load {label}.",
"load_failed_multiple": "Could not load {count} items.",
"retry": "Retry",
"retrying": "Retrying...",
"close": "Close",
"retry_failed": "The retry failed. Please try again in a moment."
}
+28 -1
View File
@@ -35,7 +35,13 @@
"translations": "Translations",
"data_sources": "Data",
"reset": "Reset",
"switch_template": "Switch to another template"
"switch_template": "Switch to another template",
"custom_assets": "Custom assets",
"custom_assets_hint": "Manage operator-added CSS, JS, fonts and images.",
"custom_assets_off": "Disable custom assets",
"custom_assets_on": "Enable custom assets",
"custom_assets_off_hint": "Reopen this screen without operator-added CSS/JS. Use it when an added asset breaks the screen.",
"custom_assets_on_hint": "Operator-added assets are currently disabled. Click to apply them again."
},
"route_tree": {
"panel_title": "Screens / Routes",
@@ -106,6 +112,27 @@
"extension_gated_desc_with_states": "The area this extension injects into only appears in a specific screen state. Switch to that state using the state selector at the top to edit it.",
"slot_area_label": "Content area"
},
"custom_assets": {
"target_label": "Target extension",
"type_template": "Template",
"type_module": "Module",
"type_plugin": "Plugin",
"title": "Custom assets",
"close": "Close",
"loading": "Loading…",
"empty": "No files yet. Type a path below and save, or upload a file to start.",
"forbidden": "You do not have permission to manage custom assets. Ask an administrator for the “Manage Custom Assets” permission.",
"new_path_placeholder": "e.g. 10-overrides.css",
"path_label": "File path",
"content_label": "File content",
"new_file": "New file",
"save": "Save",
"upload": "Upload file",
"delete": "Delete",
"delete_confirm": "This file will be deleted. This cannot be undone.",
"binary_hint": "This file type (:extension) cannot be edited directly. Upload a replacement with the same name.",
"apply_hint": "Saved changes apply from the next page load."
},
"version_history": {
"title": "Version history",
"close": "Close",
+8
View File
@@ -0,0 +1,8 @@
{
"load_failed": "{label}을(를) 불러오지 못했습니다.",
"load_failed_multiple": "{count}개 항목을 불러오지 못했습니다.",
"retry": "다시 시도",
"retrying": "다시 시도 중...",
"close": "닫기",
"retry_failed": "다시 시도했지만 실패했습니다. 잠시 후 다시 시도해 주세요."
}
+28 -1
View File
@@ -35,7 +35,13 @@
"translations": "다국어",
"data_sources": "데이터",
"reset": "초기화",
"switch_template": "다른 템플릿으로 전환"
"switch_template": "다른 템플릿으로 전환",
"custom_assets": "커스텀 자산",
"custom_assets_hint": "운영자가 추가한 CSS·JS·폰트·이미지를 관리합니다.",
"custom_assets_off": "커스텀 자산 끄기",
"custom_assets_on": "커스텀 자산 켜기",
"custom_assets_off_hint": "운영자가 추가한 CSS·JS 없이 이 화면을 다시 엽니다. 추가 자산이 화면을 망가뜨렸을 때 씁니다.",
"custom_assets_on_hint": "지금은 운영자 추가 자산이 꺼져 있습니다. 눌러서 다시 적용합니다."
},
"route_tree": {
"panel_title": "화면 / 라우트",
@@ -106,6 +112,27 @@
"extension_gated_desc_with_states": "이 확장이 들어가는 영역은 특정 화면 상태에서만 나타납니다. 상단의 상태 선택에서 해당 상태로 바꾸면 편집할 수 있습니다.",
"slot_area_label": "콘텐츠 영역"
},
"custom_assets": {
"target_label": "관리 대상 확장",
"type_template": "템플릿",
"type_module": "모듈",
"type_plugin": "플러그인",
"title": "커스텀 자산",
"close": "닫기",
"loading": "불러오는 중…",
"empty": "추가한 파일이 없습니다. 아래에 경로를 적고 저장하거나, 파일을 올려 시작하세요.",
"forbidden": "커스텀 자산을 관리할 권한이 없습니다. 관리자에게 «커스텀 자산 관리» 권한을 요청해 주세요.",
"new_path_placeholder": "예: 10-overrides.css",
"path_label": "파일 경로",
"content_label": "파일 본문",
"new_file": "새 파일",
"save": "저장",
"upload": "파일 올리기",
"delete": "삭제",
"delete_confirm": "이 파일을 삭제합니다. 되돌릴 수 없습니다.",
"binary_hint": "이 형식(:extension)은 본문을 직접 편집할 수 없습니다. 같은 이름으로 다시 올려 교체하세요.",
"apply_hint": "저장하면 다음 화면부터 적용됩니다."
},
"version_history": {
"title": "버전 기록",
"close": "닫기",
@@ -64,6 +64,18 @@ export default defineConfig({
['list'],
],
use: {
// 실제 브라우저 UA 를 지정한다.
//
// Playwright 기본 UA 에는 `HeadlessChrome` 이 들어 있어 `SeoMiddleware` 의 봇 판정에
// 걸린다. 그러면 공개 사용자 경로 요청이 SPA 가 아니라 **검색엔진용 정적 HTML** 을
// 받는다 — `window.G7Core` 도 엔진 스크립트도 없는 화면이다. 그 상태에서도 서버가
// 심은 글꼴·아이콘은 정상이라 "페이지가 잘 뜬다" 로 보이고, 정작 재려던 SPA 동작
// (테마 적용·핸들러·확장 번들 로드)은 한 번도 실행되지 않은 채 통과한다.
//
// 봇 경로를 의도적으로 재는 spec 은 UA 가 아니라 `?_escaped_fragment_=` 로 유발하므로
// 여기서 실제 UA 를 고정해도 그 검증은 그대로 동작한다.
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Listeners\CoreActivityLogListener;
use App\Models\User;
@@ -47,6 +48,19 @@ class EcommerceSettingsSeoCacheInvalidationTest extends ModuleTestCase
'sirsoft-ecommerce.settings.update',
]);
// 모듈 훅 리스너는 부팅 시점의 `getActiveModuleIdentifiers()` 가 이 모듈을 활성으로
// 봐야 등록된다. 그런데 그 목록은 빈 결과를 캐시하지 않고(빈 값이 굳으면 모든 확장이
// 꺼진 것처럼 동작하므로 의도된 설계다), 모듈 등록 행은 부팅 **이후** setUp 에서
// 만들어진다. 그래서 확장 상태 캐시가 비어 있는 채로 부팅하면 이 모듈이 비활성으로
// 보여 리스너가 조용히 등록되지 않는다 — 예외도 로그도 남지 않고, 훅만 발화하지
// 않아 "무효화가 수행되지 않았다" 로만 나타난다.
//
// 앞선 테스트가 그 캐시를 비웠는지에 따라 결과가 갈리므로(실행 순서 의존) 이
// 테스트가 스스로 리스너 등록을 보장한다. 손으로 addAction 하지 않고 실제 등록
// 경로(`HookListenerRegistrar`)를 태워 큐 래핑·우선순위 계약까지 그대로 재현한다.
// 부팅이 이미 등록했다면 프로세스 내 중복 등록 가드가 이 호출을 skip 한다.
HookListenerRegistrar::register(SeoSettingsCacheListener::class, 'sirsoft-ecommerce');
$this->received = [];
HookManager::addAction('core.module_settings.after_save', function (...$args) {
$this->received[] = $args;
@@ -75,6 +75,18 @@ export default defineConfig({
['list'],
],
use: {
// 실제 브라우저 UA 를 지정한다.
//
// Playwright 기본 UA 에는 `HeadlessChrome` 이 들어 있어 `SeoMiddleware` 의 봇 판정에
// 걸린다. 그러면 공개 사용자 경로 요청이 SPA 가 아니라 **검색엔진용 정적 HTML** 을
// 받는다 — `window.G7Core` 도 엔진 스크립트도 없는 화면이다. 그 상태에서도 서버가
// 심은 글꼴·아이콘은 정상이라 "페이지가 잘 뜬다" 로 보이고, 정작 재려던 SPA 동작
// (테마 적용·핸들러·확장 번들 로드)은 한 번도 실행되지 않은 채 통과한다.
//
// 봇 경로를 의도적으로 재는 spec 은 UA 가 아니라 `?_escaped_fragment_=` 로 유발하므로
// 여기서 실제 UA 를 고정해도 그 검증은 그대로 동작한다.
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
@@ -17,7 +17,7 @@ export default defineConfig({
// 빌드 출력 설정
outDir: 'dist',
emptyOutDir: true,
emptyOutDir: false, // 동봉 vendor 보존 — 산출물 정리는 빌드 커맨드가 한다
// 소스맵 생성
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.

Some files were not shown because too many files have changed in this diff Show More