Merge branch 'develop'
This commit is contained in:
+35
-1
@@ -3,7 +3,17 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.9
|
||||
APP_VERSION=7.0.10
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
|
||||
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
|
||||
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
|
||||
# TRUSTED_PROXIES=*
|
||||
|
||||
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
@@ -108,6 +118,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ── .env 키 단위 우선 (선택) ────────────────────────────────────────────────
|
||||
# 기본값은 관리자 환경설정(admin UI)이 운영 SSoT 이고, 저장된 값이 아래 항목들의
|
||||
# .env 값을 덮습니다. 이 스위치를 켜면 .env 에 **값이 명시된 항목만** 그 덮어쓰기에서
|
||||
# 빠져 .env 가 권위를 갖고, 관리자 화면의 해당 필드는 편집 불가로 표시됩니다.
|
||||
# 켜지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
|
||||
# 주의: 이 파일은 값을 채워 배포되므로, 켜는 즉시 APP_NAME·LOG_LEVEL·
|
||||
# G7_UPDATE_GITHUB_URL 같은 이미 채워진 항목이 함께 잠깁니다.
|
||||
# .env 만 편집한 뒤에는 php artisan config:cache 를 다시 실행해야 반영됩니다.
|
||||
# 대상 항목과 규약: docs/backend/admin-settings-access.md
|
||||
# G7_ENV_PRIORITY=true
|
||||
|
||||
# 코어 업데이트
|
||||
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
|
||||
G7_UPDATE_GITHUB_TOKEN=
|
||||
@@ -117,3 +138,16 @@ G7_UPDATE_PENDING_PATH=
|
||||
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
|
||||
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
|
||||
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
|
||||
|
||||
# 부트스트랩 리소스 정적 게시 (public/build/ext) — 끄면 전면 API 폴백(종전 동작).
|
||||
# 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」
|
||||
# G7_STATIC_CACHE=true
|
||||
|
||||
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
|
||||
# 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다).
|
||||
# 기본값은 config/app.php 의 update 절과 같다.
|
||||
# G7_UPDATE_EXCLUDES=node_modules,.git,bootstrap/cache,build/ext
|
||||
# G7_UPDATE_TARGETS=app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,scripts,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vite.config.devdashboard.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled
|
||||
# G7_UPDATE_PROTECTED_PATHS= (기본 목록은 config/app.php 의 update.protected_paths — 재정의할 때 그 목록을 그대로 옮겨 적고 항목을 덧붙인다)
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
# G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
|
||||
|
||||
+15
-1
@@ -3,7 +3,17 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.9
|
||||
APP_VERSION=7.0.10
|
||||
|
||||
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
|
||||
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
|
||||
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
|
||||
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
|
||||
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
|
||||
# TRUSTED_PROXIES=*
|
||||
|
||||
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
|
||||
# SESSION_SECURE_COOKIE=true
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
@@ -97,3 +107,7 @@ INSTALLER_COMPLETED=true
|
||||
# Telescope Configuration
|
||||
TELESCOPE_ENABLED=false
|
||||
TELESCOPE_DB_CONNECTION=mysql
|
||||
|
||||
# .env 키 단위 우선(G7_ENV_PRIORITY)은 테스트 환경에서 활성화하지 않습니다.
|
||||
# 이 파일에 활성 라인을 두면 테스트가 개발 머신의 .env 명시 상태에 좌우되어
|
||||
# 재현 불가능해집니다. 잠금 축은 config 를 직접 주입해 검증합니다.
|
||||
|
||||
@@ -145,3 +145,9 @@ test-results/
|
||||
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
|
||||
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
|
||||
*.map
|
||||
|
||||
# ===== 부트스트랩 리소스 정적 게시 (bake, #122) =====
|
||||
# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는
|
||||
# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다.
|
||||
# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
|
||||
/public/build/ext/
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-quick-reference -->
|
||||
|
||||
### 백엔드 [backend/](docs/backend/) (34개)
|
||||
### 백엔드 [backend/](docs/backend/) (36개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
|
||||
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
|
||||
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
|
||||
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
|
||||
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
|
||||
@@ -35,17 +35,19 @@
|
||||
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
|
||||
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
|
||||
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
|
||||
| [reverse-proxy.md](docs/backend/reverse-proxy.md) | 리버스 프록시 환경 (Reverse Proxy) | 프록시 뒤에서는 요청이 스스로 스킴·IP 를 증명하지 못한다 — 신뢰할 프록시를 지정해야 한다 |
|
||||
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
|
||||
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
|
||||
| [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... |
|
||||
| [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}/ — 수명주기 이벤트와 운영자 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에 검증 로직 배치 금지) |
|
||||
|
||||
### 프론트엔드 [frontend/](docs/frontend/) (50개)
|
||||
### 프론트엔드 [frontend/](docs/frontend/) (44개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
@@ -93,20 +95,15 @@
|
||||
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 |
|
||||
| [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
|
||||
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
|
||||
| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
|
||||
| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
|
||||
| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
|
||||
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
|
||||
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
|
||||
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
|
||||
|
||||
### 확장 시스템 [extension/](docs/extension/) (30개)
|
||||
### 확장 시스템 [extension/](docs/extension/) (31개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
|
||||
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
|
||||
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
|
||||
| [extension-documentation.md](docs/extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) | 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다 |
|
||||
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
|
||||
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
|
||||
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
|
||||
@@ -152,7 +149,7 @@
|
||||
|
||||
| 대상 | 진입점 | 문서/엔드포인트 |
|
||||
|------|--------|----------------|
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 325 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
|
||||
@@ -177,6 +174,34 @@
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
|
||||
|
||||
|
||||
### 확장 개발자 문서 (20개 확장, 자동 스캔)
|
||||
|
||||
> 확장을 수정하기 전에 읽는 문서. 설계 의도 · 디렉토리 지도 · 확장점(발행/구독 훅) · 수정 시 동반 의무 · 금지 패턴을 담는다. `php artisan ext:docgen` 이 실측 부분을 유지하며, 이 표는 `{modules,plugins,templates}/_bundled/*/docs/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
| 확장 | 유형 | 에이전트 가이드 | 문서 목차 | 실측 집계 |
|
||||
|------|------|----------------|----------|----------|
|
||||
| `gnuboard7-hello_module` | 모듈 | [AGENTS.md](modules/_bundled/gnuboard7-hello_module/AGENTS.md) | [docs/](modules/_bundled/gnuboard7-hello_module/docs/README.md) | 훅 1 · 라우트 7 · 모델 1 · 레이아웃 3 |
|
||||
| `sirsoft-board` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-board/AGENTS.md) | [docs/](modules/_bundled/sirsoft-board/docs/README.md) | 훅 90 · 라우트 80 · 모델 9 · 레이아웃 46 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-ecommerce/AGENTS.md) | [docs/](modules/_bundled/sirsoft-ecommerce/docs/README.md) | 훅 508 · 라우트 239 · 모델 47 · 레이아웃 206 |
|
||||
| `sirsoft-page` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-page/AGENTS.md) | [docs/](modules/_bundled/sirsoft-page/docs/README.md) | 훅 21 · 라우트 17 · 모델 3 · 레이아웃 3 |
|
||||
| `gnuboard7-hello_plugin` | 플러그인 | [AGENTS.md](plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md) | [docs/](plugins/_bundled/gnuboard7-hello_plugin/docs/README.md) | 훅 1 · 라우트 0 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-ckeditor5/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-ckeditor5/docs/README.md) | 훅 4 · 라우트 5 · 모델 1 · 레이아웃 2 |
|
||||
| `sirsoft-daum_postcode` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-daum_postcode/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-daum_postcode/docs/README.md) | 훅 2 · 라우트 0 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-gdpr/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-gdpr/docs/README.md) | 훅 2 · 라우트 15 · 모델 3 · 레이아웃 4 |
|
||||
| `sirsoft-marketing` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-marketing/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-marketing/docs/README.md) | 훅 4 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-message_bizppurio/docs/README.md) | 훅 1 · 라우트 21 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 5 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||
| `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||
| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 145 |
|
||||
| `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-quick-reference -->
|
||||
|
||||
---
|
||||
@@ -288,6 +313,26 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
| await 후 캡처된 상태 사용 | await 후 `G7Core.state.getLocal()` 재조회 |
|
||||
| setState params 키에 `{{}}` 사용 | 키는 정적 경로만, 배열 조작은 `.map()`/`.filter()` |
|
||||
|
||||
### 저장소 B 통째 교체 금지
|
||||
|
||||
엔진은 폼 상태를 React `localDynamicState`(저장소 A)와 `globalState._local`(저장소 B)에 이중 저장한다. `TemplateApp.setGlobalState` 는 최상위 키를 **얕게** 병합하므로 `setGlobalState({ _local: X })` 는 B 를 patch 가 아니라 **통째 교체**한다. X 가 A 계열 스냅샷이면, A 가 아직 받지 못한 값이 조용히 사라진다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| `globalStateUpdater({ _local: <A 계열 스냅샷> })` (저장소 B 통째 교체) | live B(`getGlobalState()._local`)를 base 로 변경 키만 얹기 |
|
||||
| sequence 반환값을 stale base 로 구성 | 반환값도 live B 기반 + `addMissingLeafKeys` 로 A 전용 키 보충 |
|
||||
| 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 |
|
||||
| `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 |
|
||||
| 자동바인딩이 `__g7PendingLocalState` 에 저장소 A 스냅샷을 그대로 대입 | 렌더러와 같은 순서로 `__g7ForcedLocalFields` 를 얹고 방금 입력한 경로를 다시 적용 — pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이다 |
|
||||
| 저장소 A 에만 쓰는 `_local` 경로 (`context.setState(payload)` 단독) | 같은 지배 분기 안에서 B 도 갱신 — `G7Core.state.setLocal(payload, { render: false })`. B 에 이미 키가 있으면 보충 대상에서 빠져 A 의 값이 조용히 유실된다 |
|
||||
| 미러를 **형제 분기**에 두고 이 분기도 지켜진다고 간주 | 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 — 긴 함수를 통째로 보면 한 분기의 미러가 다른 분기를 면죄한다 |
|
||||
|
||||
A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다.
|
||||
|
||||
pending 은 저장소 B 의 base 가 된다 — `setLocal` 이 `currentSnapshot = pendingState || baseLocal` 로 pending 을 우선 채택하기 때문이다. 그래서 A 스냅샷을 그대로 실으면 위와 같은 통째 교체가 **저장 클릭 전, 키입력 시점에** 일어난다. 방아쇠는 memo deps 와 무관한 리렌더(폭 변경 등)가 선행하는 것이고, 그것이 없으면 성립하지 않는다.
|
||||
|
||||
이 결함군은 예외도 콘솔 에러도 남기지 않는다 — 화면에는 본문이 그대로 보이는데 요청 body 만 비어 나가고(작성 화면 422), 수정 화면에서는 성공 토스트와 함께 **직전 본문이 저장되어 편집분이 사라진다**. 정적 검사가 `_local` 동기화 호출의 base 를 검사한다.
|
||||
|
||||
### 핸들러 정의
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -335,6 +380,22 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
|
||||
|
||||
### 조건부로만 열리는 라우트군의 게이트 (디버그·개발 라우트)
|
||||
|
||||
특정 조건에서만 열려야 하는 라우트군은 판정을 핸들러 안이 아니라 그룹 미들웨어에 둔다. 게이트가 핸들러마다 흩어져 있으면 라우트를 추가할 때 함께 적는 것을 잊게 되고, 빠뜨려도 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| 디버그 라우트 핸들러 안에서 `DebugGate::isEnabled()` 로 개별 판정 | `bootstrap/app.php` 의 그룹 래퍼(`Route::middleware(['api', 'debug.gate'])`)가 단일 부착 |
|
||||
| catch-all 제외 패턴에 예약 프리픽스 누락 (`_boost`·`modules`) | `(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)` 전수 제외 — shadow 를 보호로 삼지 않는다 |
|
||||
| 게이트 부착을 행위 테스트(403 이 나오는지)로만 확인 | 라우트군 전체의 `gatherMiddleware()` 에 게이트 별칭이 있는지 단언하는 등록 계약 테스트 + 모집단 가드 |
|
||||
| 그룹 게이트가 라우트 캐시에도 구워질 것이라 가정 | 캐시 상태에서의 차단도 검증 — 라우트 캐시는 확장 수명주기 지점에서 자동 생성되어 오히려 흔한 상태다 |
|
||||
| `withRouting(channels: ...)` 로 채널 정의를 로드 | 프로바이더에서 `require routes/channels.php` — `channels:` 인자는 `Broadcast::routes()`(게이트 없는 `/broadcasting/auth`)까지 자동 등록해 킬스위치 우회로를 만든다 |
|
||||
|
||||
catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 라우트는 도달 불가라 게이트가 없어도 증상이 없고, 제외 패턴이 한 줄 바뀌는 순간 무방비로 노출된다 — 실제로 `_boost` GET 4종이 그 상태였고, 같은 그룹의 `DELETE clear` 는 shadow 밖이라 운영 환경·`APP_DEBUG=false` 에서 미인증 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128). 등록 계약 축을 행위 테스트로 대체할 수 없는 이유도 같다: 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않는다.
|
||||
|
||||
정적 검사가 디버그 라우트 파일의 개별 게이트를 차단하며, 부착·행위·캐시 축과 방송 인증 라우트 단일성은 테스트가 잠근다. 상세: [routing.md](docs/backend/routing.md) "디버그·개발 라우트는 그룹 단위로 게이트한다".
|
||||
|
||||
### 목록 컨텍스트 왕복 (list context round-trip)
|
||||
|
||||
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
|
||||
@@ -415,6 +476,140 @@ 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자 라이브러리는 쓰기 경로를 지정받는다
|
||||
|
||||
제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125).
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 제3자 라이브러리를 기본 설정 그대로 인스턴스화 | 캐시·임시파일 경로를 `ExtensionStoragePath::module($id, 'cache/…')` 로 명시 — 기본값은 **라이브러리 자기 설치 폴더**다 |
|
||||
| 쓰기 경로만 지정하고 디렉토리 생성은 라이브러리에 맡김 | `FilePermissionHelper::ensureWritableDirectory()` 로 **먼저 확보한다** — 라이브러리는 대개 하위 디렉토리만 만들고, base 가 없으면 경고만 내고 끝난다 |
|
||||
| 확보 절차(억제 생성·chmod·setgid·소유권·쓰기 판정)를 호출부가 자기 안에 복사 | 코어 프리미티브 한 곳에서 수행 — 사본은 서로 다른 하드닝을 갖고 갈라진다(실제로 억제 mkdir·setgid·`clearstatcache` 가 사본마다 한쪽씩 빠져 있었다) |
|
||||
| 확장 저장 경로를 `storage_path('app/modules/…')` 로 직접 조립 | `ExtensionStoragePath::{module,plugin}()` — 디스크 root 가 단일 출처이고 테스트 환경을 인지하므로, 확장이 `runningUnitTests()` 분기를 복사하지 않는다. 복사본은 한 곳만 빠뜨려도 그 확장의 테스트가 **운영 설정 파일을 덮어쓴다** |
|
||||
| 캐시 쓰기 실패를 그대로 500 으로 흘림 | 캐시는 성능 장치다 — 확보 실패 시 캐시만 끄고 본래 기능은 계속한다. **정화·검증 자체를 건너뛰는 폴백은 금지** |
|
||||
| 폴백 통지를 `Log::warning` 으로 남김 | `Log::error` — 출하 기본 로그 수준(`config/settings/defaults.json` 의 `log_level`)이 `error` 라 `warning` 은 기본 설치 상태에서 파일에 기록되지 않는다. 기능은 성공하므로 그 통지가 유일한 흔적이다 |
|
||||
|
||||
확보 프리미티브는 **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()` 는 `mkdir()` 을 억제 없이 부르므로 생성 실패가 `E_WARNING` → `ErrorException` 으로 승격되어, 막으려던 500 이 다른 줄에서 그대로 난다. 실패는 `bool` 과 사유(`occupied_by_file` / `ancestor_not_writable` / `create_failed` / `not_writable`)로 올라오고, 그 사유를 통지에 실어 운영자가 고칠 대상을 지목한다.
|
||||
|
||||
경로는 `ExtensionStoragePath` 가 해석한다. `getBasePath('cache')` 는 `Storage::disk()->path()` 위임이라 비로컬 디스크(S3 등)에서 파일시스템 경로가 아니게 되는데, 그러면 라이브러리가 상대경로를 CWD 기준으로 해석해 **조용히 엉뚱한 곳에 쓴다** — 지금 결함보다 나쁘다. 대부분의 정의 캐시는 `file_put_contents` 로 쓰는 로컬 전용 장치다.
|
||||
|
||||
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
|
||||
|
||||
### 직접 전송로는 자격증명을 스스로 싣는다
|
||||
|
||||
레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 게이트된 엔드포인트를 `G7Core.api.*` / `fetch` 로 부르며 자격증명 헤더를 생략 | 호출부가 직접 싣는다 — 비밀글 첨부는 `X-Board-Secret-View-Token`, 비회원 주문은 `X-Guest-Order-Token` |
|
||||
| 자격증명이 없을 때 조용히 `return null` 로 이탈 | 회원/비회원 두 경로를 모두 구성한다 — 한쪽을 비우면 서버가 지원하는 기능이 도달 불가로만 남는다 |
|
||||
| 헤더 구성을 호출부마다 복제 | 확장·템플릿 안에 단일 지점(`secretContentHeaders()` / `buildOrderRequestHeaders()`)을 두고 경유 |
|
||||
| `<img src>` 에 헤더를 실으려 시도 | 이미지 태그는 헤더를 실을 수 없다 — 한시 서명 URL 을 발급하거나 blob 으로 받아 그린다 |
|
||||
| 자격증명을 GET 쿼리 문자열로 전달 | 헤더로 보낸다 — 쿼리는 웹서버 접근 기록과 `Referer` 에 그대로 남는다 |
|
||||
|
||||
같은 기능을 여러 확장이 제공할 때는 **형제 구현의 강도가 갈리지 않는지** 확인한다. 서버가 비회원을 지원하는데 프론트 한쪽만 토큰을 보내면, 나머지 확장에서는 그 서버 기능이 존재하지만 도달 불가인 상태로 남는다.
|
||||
|
||||
### 확장·템플릿 구동 에셋은 자체 제공한다
|
||||
|
||||
브라우저가 화면을 그리기 위해 제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 가 된다). 인라인 여부는 "없으면 조작 불능인가" 로 가른다 — 아이콘 폰트는 인라인, 글꼴·장식 아이콘은 파일 분리. 분리한 자산을 CSS 가 상대 경로로 가리켜도 된다: 확장 자산 CSS 는 서빙 시점에 내부 상대 참조가 절대 자산 URL 로 치환된다(`ServesRewritableCssAssets`). 치환이 없으면 쿼리 형태(`?file=`) 서버에서 그 참조가 조용히 404 가 된다.
|
||||
|
||||
사용자 추가 에셋(`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경로 보존은 테스트가 잠근다.
|
||||
|
||||
### 확장은 자기 개발자 문서를 소유한다
|
||||
|
||||
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 고치려는 쪽이 실제로 묻는 것은 그 앞이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.** 그 답이 코드 안에만 있으면 매번 `src/` 전체를 훑어 구조를 재발견하게 되고, 확장이 발행하는 훅은 확장점인데도 사실상 비공개가 된다.
|
||||
|
||||
확장마다 `AGENTS.md`(고치는 쪽) · `README.md`(도입·운영 쪽) · `docs/**`(상세)를 두고, 코드에서 실측되는 표는 `php artisan ext:docgen` 이 유지한다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 확장 표면(훅·라우트·권한·모델·레이아웃·핸들러)을 바꾸고 그 확장 문서를 그대로 둠 | 같은 작업 단위에 `ext:docgen --scope={type}:{id}` 재실행 + 낡은 서술 정정 |
|
||||
| 자동 생성 블록(`@generated:*`) 안쪽을 손으로 고침 | 생성기가 교체하는 자리다 — 코드를 고치거나 블록 **밖**에 서술한다 |
|
||||
| 생성기에 파괴적 재생성 플래그(`--force`)를 추가 | 기본 동작이 "블록 안쪽 교체" 다. 사람 서술이 소실될 경로를 만들지 않는다 |
|
||||
| 문서에 없는 블록 키를 생성기가 임의 위치에 주입 | 누락으로 보고하고 사람이 마커 자리를 정한다 (문서 구조는 사람 소유) |
|
||||
| 필수 문서·섹션·블록 목록을 검사 스크립트에 복제 | `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT — 스크립트는 `ext:docgen --check --json` 을 소비한다 |
|
||||
| `TODO:` 마커를 추측으로 채움 | 코드 근거를 읽어 서술한다 — 다섯 자리(의도·흐름·금지패턴·사용방법·트러블슈팅)는 생성기가 채울 수 없는 **왜** 다 |
|
||||
| `5. 수정 시 동반 의무` 에 코어 횡단 규정을 전부 나열 | 그 확장에 **실제로 걸리는 것만** 추린다 — 전부 적으면 정작 걸리는 항목이 묻힌다 |
|
||||
| 신규 확장을 문서 없이 스캐폴딩 | `php artisan ext:docgen --scope={type}:{id} --init` 으로 골격을 함께 만든다 — 없으면 21번째 확장부터 다시 문서 없이 태어난다 |
|
||||
| 확장 문서를 활성 디렉토리에서 작성 | `_bundled` 에서만 작성하고 update 커맨드로 반영 (문서만이면 빌드 불필요) |
|
||||
| 확장이 훅을 추가할 때 코어 문서를 고침 | 훅 집계는 그 확장의 `docs/extension-points.md` 소유 — 코어에는 총계와 링크만 |
|
||||
| 레이아웃에 `data_source` 를 추가하고 `editor-spec.json` 의 `sampleData` 를 그대로 둠 | 같은 ID 로 프리뷰 샘플 추가 — 없으면 **편집기 캔버스에서만** 그 영역이 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다 |
|
||||
| 컴포넌트를 추가하고 팔레트에만 등록 | 템플릿 스펙은 `componentPalette.entries` · `componentPalette.groups` · `nesting` · `componentCapabilities` **넷 다** — 하나만 빠지면 편집기에서 절반만 동작하고, 어느 단계가 빠졌는지는 증상으로만 구분된다 |
|
||||
| 모듈·플러그인 스펙에 `componentPalette` 선언 | 컴포넌트는 템플릿 소유 — 모듈·플러그인 스펙은 도메인 데이터(`sampleData`·`states`)만 담는다. 같은 자리를 두고 다투면 어느 쪽이 이기는지가 병합 순서에 좌우된다 |
|
||||
| 공용 ID(`settings`·`roles`·`me`)를 확장마다 각자 선언 | 템플릿 스펙 한 곳 — 사본이 갈라져도 오류가 나지 않는다 |
|
||||
| 편집기 스펙 `description` 에 작업 단계·심사 판정·작업 방법을 적음 (`Phase 4/5 에서 추가`·`— 정당`·`전수 스캔 기반`) | **무엇을 담았는가**만 적는다 — 확장만 내려받은 제3자에게 내부 맥락은 해석 불가이고, "다음에 추가" 는 그 항목이 실제로 들어온 뒤에도 남아 **거짓이 된다**. 문서의 한 줄 요약은 이 필드를 옮기지 않고 실측에서 생성한다 |
|
||||
| 편집기 스펙을 고치고 update 커맨드 생략 | 서빙은 **활성 디렉토리만** 읽는다(`_bundled` 폴백 없음) — 파일은 고쳤는데 편집기에 직전 내용이 그대로 보인다 |
|
||||
|
||||
이 결함군은 오류를 남기지 않는다. 문서가 코드와 어긋난 채로 계속 읽히는 것이 유일한 증상이며, 훅 이름이 어긋나면 그 확장을 잡으려던 쪽이 **잡히지 않는 훅을 구독**하게 된다(예외도 경고도 없이 리스너가 호출되지 않을 뿐이다).
|
||||
|
||||
mermaid 문법 오류는 GitHub 렌더 시점에만 드러난다. 구조 검사가 잡을 수 있는 것은 선언된 다이어그램 종류·빈 본문·괄호 균형까지이므로, 새 형식은 실제 렌더를 눈으로 확인한다.
|
||||
|
||||
`docs/editor-spec.md` 는 세 유형 공통이다 — 편집기 스펙을 두지 않는 확장에도 문서를 둔다. 미보유가 정상일 수 있고("이 확장은 공용 ID 만 쓴다") 그 정상 여부를 적을 자리가 없으면 다음 사람이 부재를 누락으로 오해하거나 필요한 시점을 놓친다. 그 문서의 "샘플 데이터와 페이지 상태" 절은 그 확장 레이아웃의 `data_source` 중 프리뷰 샘플이 붙지 않는 것을 실측해 나열한다 — 이 결함은 편집기 캔버스에서만 빈 화면으로 나타나므로 그 목록이 유일한 통로다.
|
||||
|
||||
> 상세: [extension-documentation.md](docs/extension/extension-documentation.md)
|
||||
> 정적 검사가 확장 표면 변경 시 문서 미동반과 미채움 마커 잔존을 검출한다. 생성기의 비파괴 계약(블록 밖 손실 0 · 재실행 멱등 · 미존재 키 미주입)과 필수 문서·섹션·블록 목록은 테스트가 잠근다.
|
||||
|
||||
### 의존성 감사 신호는 거짓일 수 있다
|
||||
|
||||
`npm audit --omit=dev` 는 **`dependencies` 에 선언된 것만** 본다. 실행에 쓰이는 라이브러리가 `devDependencies` 에 있으면 그 패키지는 검사 대상에서 통째로 빠지고, 취약점이 있어도 감사는 0건을 돌려준다. "운영 의존성 취약점 없음" 이라는 완료 조건이 취약한 상태로도 충족된 것처럼 보인다.
|
||||
|
||||
동봉(vendored) 제3자 자산은 더 나아가 **어떤 잠금파일에도 없어** 감사 도구가 원리상 볼 수 없다. 그 안에 재번들된 라이브러리가 브라우저로 나간다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 런타임 소스가 import 하는 패키지를 `devDependencies` 에만 선언 | `dependencies` 로 선언 — 검사 대상에 들어가야 감사 신호가 사실이 된다 |
|
||||
| 루트 잠금파일만 감사하고 완료 선언 | 확장의 잠금파일까지 전수 (`php artisan security:audit-dependencies`) — 루트만 보면 확장 전부가 사각이다 |
|
||||
| 동봉 자산을 감사 범위에 들었다고 간주 | 잠금파일에 없으므로 **사람이 확인한다** — 점검 명령이 목록으로 노출한다 |
|
||||
| 잠금만 올리고 재빌드를 생략 | 브라우저가 받는 버전은 커밋된 `dist/` 가 정한다 — 잠금 갱신 후 반드시 재빌드·재게시 |
|
||||
| "점검 대상 0" 과 "점검 불가" 를 같은 문구로 보고 | 구분 보고 — 뭉뚱그리면 운영자가 "전부 정상" 으로 읽는다 |
|
||||
| 동봉 자산 버전을 여러 곳에 적고 상향 시 일부만 갱신 | 모든 기재(디렉토리 · 의존성 핀 · 복사 스크립트 · 소스 상수 · 테스트 어서션)를 한 버전으로 — 하나만 어긋나도 그 자산이 404 가 되는데 빌드와 테스트는 통과한다 |
|
||||
| 상류가 못 고치는 잔여 취약을 감사에서 가리기(`overrides` 로 무마) | 잔여는 **하한과 사유를 기록**하고 그 아래로 내려가는 것만 막는다 — 가리면 그 지점이 영원히 보이지 않는다 |
|
||||
|
||||
이 결함군은 예외도 로그도 남기지 않는다. 취약한 라이브러리가 정상 동작하고 감사가 0건을 보고하는 것이 유일한 증상이다. 정적 검사가 앞의 두 항목을 차단하고, 출하 산출물의 정화기 버전과 점검 명령의 모집단은 테스트가 고정한다.
|
||||
|
||||
> 상세: [module-assets.md](docs/extension/module-assets.md), [cheatsheet.md](docs/cheatsheet.md)
|
||||
|
||||
### 프록시 뒤 요청은 스킴·IP 를 스스로 증명하지 않는다
|
||||
|
||||
TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB, CloudFront, Cloudflare, nginx/Apache 리버스 프록시, ngrok)에서 신뢰할 프록시가 지정되지 않으면 Laravel 은 `X-Forwarded-*` 를 전부 무시한다. 요청 객체가 평문 HTTP 로 인식되어 `asset()`/`url()` 이 `http://` 를 만들고(HTTPS 페이지에서 혼합 콘텐츠로 차단 → **사이트 전체 백지**), `$request->ip()` 가 프록시 IP 가 되어 통보 IP 화이트리스트·rate limit·IP 기록·GeoIP 가 동시에 무너진다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| `bootstrap/app.php` 에서 `trustProxies(at: env('TRUSTED_PROXIES'))` | `config/trustedproxy.php` 의 `'proxies' => env('TRUSTED_PROXIES')` — `withMiddleware` 클로저는 `.env` 로드 전에 평가되어 `env()` 가 항상 `null` 이다(오류 없이 no-op) |
|
||||
| 신뢰 프록시를 `'*'` 로 하드코딩 | env opt-in — 앱이 직접 노출된 환경에서 `X-Forwarded-For` 위조로 기록 IP·IP 제한이 조작된다 |
|
||||
| `$middleware->trustHosts()` 호출 | 호출 자체가 모든 설치처에서 Host 검증을 켠다(미등록 호스트 400) — opt-in 원칙 위반 |
|
||||
| IP 화이트리스트·rate limit·IP 기록을 `$request->ip()` 로 두면서 프록시 구성을 문서화하지 않음 | 그 기능이 프록시 신뢰 설정에 의존한다는 사실을 문서에 남긴다 |
|
||||
| 절대 URL 이 필요한 곳에서 요청 스킴 의존(`url()`/`asset()`)과 설정 앵커(`config('app.url')`)를 혼용 | 외부 시스템에 등록·전송되는 URL(PG 콜백·webhook 안내)은 설정 앵커, 화면 자산은 요청 기준 |
|
||||
| 진단 판정을 "HTTPS 인식 실패" 로 세움 | `X-Forwarded-* 수신 중 AND 신뢰 프록시 미설정` — HTTP 전용 사이트가 프록시 뒤에 있으면 **화면은 완전히 정상 렌더되면서** webhook 403·IP 왜곡만 계속된다. HTTPS 기준 판정은 그 구성에서 침묵한다 |
|
||||
| 같은 판정을 노출면(대시보드·환경설정·설치 마법사·커맨드)마다 다시 작성 | `App\Support\TrustedProxyDiagnostic` 단일 판정 — 면마다 조건을 복제하면 한 곳만 어긋나도 서로 다른 답을 내놓는다 |
|
||||
| 신뢰 프록시 값을 관리자 화면에서 편집 가능하게 제공 | 읽기 전용 진단만. ① 프록시 뒤에서는 그 화면 자체가 뜨지 않는 것이 이 결함이라 정작 필요한 순간에 도달 불가(잠금 역설) ② 웹 편집이 가능해지면 관리자 계정 탈취가 곧 XFF 위조 경로가 된다 |
|
||||
|
||||
이 결함군은 서버 로그에 흔적을 남기지 않는다. 브라우저 콘솔의 차단 로그와 "모든 방문자가 같은 IP" 라는 데이터 상태만이 증상이며, 설치 마법사는 `X-Forwarded-Proto` 를 읽어 "HTTPS 정상" 이라고 보고하므로 운영자에게는 원인 추적 단서가 없다.
|
||||
|
||||
`**` 는 Laravel 내장 미들웨어에서 `*` 과 같은 코드 경로를 타므로 "모든 프록시 신뢰" 가 아니다. 프록시가 여러 단인 구성에서는 체인의 모든 프록시 IP·CIDR 를 나열해야 최초 클라이언트 IP 가 해석된다.
|
||||
|
||||
> 상세: [reverse-proxy.md](docs/backend/reverse-proxy.md)
|
||||
> `config/trustedproxy.php` 의 키 계약, 신뢰/미신뢰 분기, 쿠키 Secure 자동 판정, `bootstrap/app.php` 로의 `env()` 함정 재유입 차단은 테스트가 잠근다. `url()`/`asset()`/`$request->ip()` 는 정상 사용례가 다수라 정적 금지 규칙을 두지 않는다.
|
||||
|
||||
### 목록 응답의 하위 컬렉션
|
||||
|
||||
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
|
||||
@@ -433,6 +628,26 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
|
||||
### 확장 캐시 버전은 트레이트 게터로만 읽고, 만료시키지 않는다
|
||||
|
||||
확장 캐시 버전(`ext.cache_version`)은 모든 자산 URL 의 `?v=`·정적 게시본(`public/build/ext/{v}/`)·병합 번들 파일명의 좌표다. 그 키를 올리는 단일 지점(`incrementExtensionCacheVersion()`)이 재게시까지 예약하므로, 게시본에 구워지는 입력이 바뀌는 경로는 전부 그 지점을 타야 하고, 키 자체는 만료로 재생성되어서는 안 된다 — 재생성은 정적 파일 전체 재생성이자 전 방문자의 자산 URL 변경이다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| `$cache->get('ext.cache_version', 0)` / `app(CacheInterface::class)->get(...)` / `->forget('ext.cache_version')` 원시 접근 | `ExtensionStaticCacheService::getExtensionCacheVersion()` 또는 트레이트를 조합한 클래스 안의 `self::getExtensionCacheVersion()` — 부재 시 재생성하므로 `cache:clear` 직후 0 이 새지 않고, 컨테이너 바인딩의 확장 네임스페이스 누수도 없다 |
|
||||
| 트레이트 정적 메서드를 트레이트 이름으로 직접 호출 (`ClearsTemplateCaches::getExtensionCacheVersion()`) | 트레이트를 조합한 코어 클래스 경유 — PHP 8.1+ E_DEPRECATED 이고, 트레이트 상수는 `self::` 로 읽을 수 없다 |
|
||||
| 게시본에 구워지는 설정값(`general.asset_url_mode`)을 바꾸는 경로에서 bump 누락 | 저장·단건 저장·복원·CLI 어느 경로든 `incrementExtensionCacheVersion()` — 번들 CSS 안의 `url()` 형태가 본문에 구워져 있다 |
|
||||
| 게시 입력이 바뀌는 수명주기 경로를 조건부 bump 에 위임 (템플릿 update 가 레이아웃 변경 건수 > 0 일 때만) | 모듈·플러그인과 동형으로 무조건 bump — 조건은 게시 입력 중 레이아웃 축만 본다 |
|
||||
| custom 변경 감지 서명과 게시 복사 집합을 서로 다른 코드가 정의 | 같은 열거자(`CustomAssets::publishableFiles()`) — 감지가 최상위 css/js 만 보면 하위 글꼴·이미지 교체가 영영 미게시다 |
|
||||
| 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget |
|
||||
| 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 |
|
||||
| `config:cache` / `route:cache` / `event:cache` / `optimize` 를 헬퍼 밖에서 `Artisan::call` | `ConfigCacheHelper::rebuild()` / `RouteCacheHelper::rebuild()` (내부가 `withPreservedContainer`) — 이 명령들은 새 Application 을 부팅하며 전역 `Container` 를 일회용 앱으로 바꿔 놓아, 그 뒤 등록되는 `app()->terminating()` 재게시 예약이 종료되지 않는 앱에 걸려 사라진다 |
|
||||
| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` |
|
||||
|
||||
이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다.
|
||||
|
||||
> 상세: [static-asset-publishing.md](docs/backend/static-asset-publishing.md) "버전은 만료되지 않는다" · "5-1. 관리자 화면에서의 수동 복구"
|
||||
|
||||
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
|
||||
|
||||
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
|
||||
@@ -449,6 +664,24 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
|
||||
|
||||
### 설정 주입과 `.env` 우선
|
||||
|
||||
관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| `SettingsServiceProvider` 에 settings → config 주입을 추가하면서 `EnvPriority::MAP`(또는 `EXEMPT`) 미등재 | 맵을 동반 갱신 — 등재를 잊으면 그 키는 영원히 settings 승으로 남고, 화면상 "그 키가 `.env` 에 없다"와 구분되지 않는다 |
|
||||
| env 명시 여부를 런타임 `env()` 로 판별 | config 빌드 시점 캡처(`config/env-priority.php`, `disk_explicit` 패턴) — `config:cache` 에서 `env()` 는 null 이라 판정이 영구 미발동한다 |
|
||||
| 잠긴 키 표시값에 sensitive 값(`.env` 비밀값) 노출 | 잠금 표시만 — 민감 키는 유효값 오버레이 대상에서 제외한다 |
|
||||
| 제거된 키의 **부재를 값으로 읽는 지점**을 그대로 둠 (게이트·기본값 주입·형제 폴백) | 그 자리는 유효값(런타임 config)으로 보정하거나 잠금일 때만 건너뛴다 — `array_key_exists`/`empty()` 로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이 구분되지 않는다 |
|
||||
|
||||
명시 판별은 strict 다 — `KEY=`(빈 값)·`KEY=null`·미설정만 미명시이고, `APP_DEBUG=false`·`REDIS_DB=0` 같은 falsy 명시는 명시로 취급한다(`?:` 를 쓰면 그 값들이 미명시로 오판된다).
|
||||
|
||||
잠금은 **그 키의 주입만** 건너뛰는 것이다. 그런데 제거된 키를 읽는 자리가 그 부재를 값으로 해석하면 잠금이 형제 설정까지 무너뜨린다 — 다섯 형태가 있고 전부 오류도 로그도 남기지 않는다: ① 마스터 토글의 OFF 강제(웹소켓) ② 게이트가 되는 키(디버그 모드 → 로그 레벨 강제·프록시 적용, 메일 드라이버 → mailgun/ses 하위 주입) ③ 저장값이 비어도 박히던 기본값(`services.{mailgun.endpoint,ses.region}`) ④ 형제 값으로의 폴백(웹소켓 server endpoint 가 관리자 소유 client 값을 `.env` 자리에 덮어씀) ⑤ 파생 판정(`storage_driver=s3` → `attachment.disk`). 판정에 쓰는 자리는 유효값으로 보정하고, 주입을 건너뛰어야 하는 자리는 `EnvPriority::isLocked()` 를 직접 묻는다.
|
||||
|
||||
> 상세: [admin-settings-access.md](docs/backend/admin-settings-access.md) "env 우선 모드(G7_ENV_PRIORITY)"
|
||||
> 자동 차단: 정적 검사가 주입 메서드의 필터 배선 실존을 확인하고, 맵 패리티·캡처 일치·`env()` 재유입과 표시값·저장 게이트는 계약 테스트가 잠근다. 부재를 값으로 읽는 자리는 의미 판정 영역이라 정적 검사가 덮지 못한다 — 게이트·기본값·폴백·파생 네 축을 각각 고정하는 회귀 테스트가 그 자리를 잠근다
|
||||
|
||||
### 목록 조회 컬럼 프루닝과 지연 조인
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -782,6 +1015,20 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
|
||||
필수: data_sources ID 고유성 유지, 조건부 렌더링은 if 속성만 사용 (type: "conditional" 미지원)
|
||||
```
|
||||
|
||||
### 독립 레이아웃(extends 없음)의 글로벌 호스트 컴포넌트
|
||||
|
||||
`toast`, `openModal` 등 글로벌 상태 기반 핸들러(`_global.toasts`, `_global.modal`)는 호스트 컴포넌트(`Toast`, `ModalRoot` 등)가 마운트되어야 화면에 렌더된다. 베이스 레이아웃(`_user_base`, `_admin_base`)은 일반적으로 이들을 마운트하므로 자식 레이아웃은 별도 작업이 필요 없지만, `extends` 없이 정의된 독립 레이아웃(예: `admin_login.json`)은 호스트 컴포넌트가 자동 주입되지 않는다.
|
||||
|
||||
```text
|
||||
필수: 독립 레이아웃에서 toast/modal 사용 시 components 최상단에 호스트 컴포넌트를 직접 추가
|
||||
- Toast: { type: "composite", name: "Toast", props: { toasts: "{{_global.toasts}}", ... } }
|
||||
- 누락 시 핸들러는 success 로 기록되나 화면에는 미노출 (조용한 실패)
|
||||
|
||||
필수: 의심 시 베이스 레이아웃의 호스트 컴포넌트 정의를 그대로 복사
|
||||
- sirsoft-admin_basic: layouts/_admin_base.json 의 #global_toast 블록
|
||||
- sirsoft-basic: layouts/_user_base.json 의 토스트 컴포넌트 블록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트 프로토콜
|
||||
@@ -1197,7 +1444,23 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
|
||||
|
||||
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
|
||||
|
||||
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production` (`--full` 은 앱 번들 전용 — `public/build` 를 비워 코어 3번들을 지운다).
|
||||
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production`.
|
||||
|
||||
### 빌드는 자기 산출물만 교체한다 (`emptyOutDir`)
|
||||
|
||||
모든 vite config 는 `build.emptyOutDir: false` 를 **명시**한다. 기본값 `true` 는 산출물 디렉토리를 통째로 비우는데, 그 디렉토리에는 vite 가 만들지 않는 서빙 자산이 함께 산다.
|
||||
|
||||
| 함께 지워지던 것 | 결과 |
|
||||
|---|---|
|
||||
| `public/build/core/` 3번들 | 폴백이 없다 — `template-engine.min.js` 는 동기 classic 스크립트라 소실 = **사이트 부팅 불가**, 안내 화면조차 렌더되지 않는다 |
|
||||
| `public/build/ext/{v}/` 게시본 | 이미 배달된 HTML 의 immutable URL 이 404. 재게시로 새 버전이 생겨도 **그 URL 은 복구되지 않는다** |
|
||||
| 확장 `dist/vendor/` | 확장이 동봉한 구동 제3자 자산 소실 (자체 제공 원칙 위반) |
|
||||
|
||||
소실은 예외도 서버 로그도 남기지 않는다 — 브라우저 404 로만 나타나므로 운영자에게는 흔적이 없다. 잔존하는 구 해시 산출물은 `manifest.json`(또는 고정 파일명)이 선택하므로 참조되지 않는 사표이고, 정리 책임은 빌드 커맨드가 진다.
|
||||
|
||||
빌드 커맨드의 산출물 정리는 **활성 디렉토리를 건너뛴다.** 정리는 빌드 *전에* 돌므로 웹이 서빙 중인 `dist/` 를 비우면 빌드 완료까지가 통째로 서빙 공백이 되고, 빌드가 실패하면 빈 채로 남는다. 정리 대상은 `_bundled` / `_pending` 소스 디렉토리뿐이다.
|
||||
|
||||
정적 검사가 모든 vite config 의 명시 선언을 강제한다 (기본값 의존 금지 — 규약이 코드에 남지 않으면 다음 편집자가 같은 결함을 재도입한다).
|
||||
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
|
||||
|
||||
---
|
||||
@@ -1284,6 +1547,7 @@ php artisan migrate:rollback
|
||||
|
||||
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
|
||||
| ------------------- | ------------------ |
|
||||
| `(modules\|plugins\|templates)/_bundled/{id}/**` (그 확장의 소스 전반) | 그 확장의 `AGENTS.md` · `docs/README.md` — 설계 의도·디렉토리 지도·확장점·**수정 시 동반 의무**·금지 패턴. 수정 후 표면이 바뀌었으면 `php artisan ext:docgen --scope={type}:{id}` ([extension-documentation.md](docs/extension/extension-documentation.md)) |
|
||||
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
|
||||
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
|
||||
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
|
||||
|
||||
@@ -4,6 +4,93 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.10] - 2026-09-06
|
||||
|
||||
### 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 님께서 건의해주셨습니다.)
|
||||
- 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. 알림의 「다시 만들기」 버튼으로 그 자리에서 바로 다시 만들 수 있습니다. 상태·게시 명령을 root 로 실행하면 캐시 폴더가 root 소유가 될 수 있음을 경고하고 웹 서버 계정으로 실행하는 명령 형태를 안내합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- AWS ALB·CloudFront·Cloudflare·nginx 처럼 HTTPS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성을 지원합니다. `.env` 에 `TRUSTED_PROXIES` 를 지정하면 사이트가 접속 주소를 올바르게 인식해 화면이 정상 표시되고, 게시글 작성 IP·로그인 시도 제한·결제 통보 수신도 실제 방문자 기준으로 동작합니다. 지정하지 않으면 종전과 동일하게 동작합니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 사이트가 쓰는 외부 라이브러리의 알려진 취약점을 한 번에 점검하는 명령이 추가되었습니다. `php artisan security:audit-dependencies` 로 코어와 설치된 모든 확장을 함께 확인할 수 있고, 개발 대시보드에서도 실행할 수 있습니다. 점검 도구가 원리상 볼 수 없는 동봉 라이브러리는 버전 목록으로 함께 표시해 운영자가 직접 확인할 수 있게 했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, **동봉된 확장 20개 전부에 문서가 채워졌습니다.** 새 확장을 만들면 스캐폴딩 단계에서 이 문서 골격이 함께 생성됩니다.
|
||||
- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목이 추가되었습니다. 그 확장이 레이아웃 편집기에 무엇을 선언했는지(추가 가능한 화면 요소, 스타일 조절 항목, 미리보기용 샘플 데이터, 화면 상태)와 화면 요소·데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담으며, 편집기 스펙을 두지 않은 확장에는 그것이 정상인지 아닌지를 적습니다.
|
||||
- 확장 문서 검사가 「설명 자리를 비워 둔 상태」도 미작성으로 셉니다. 종전에는 채워 넣으라는 표시만 지우고 내용을 쓰지 않으면 검사를 통과해, 빈 문서가 완비된 것으로 집계되었습니다.
|
||||
- 확장의 화면에 데이터를 붙였는데 레이아웃 편집기 미리보기에서 그 자리가 비는 경우를 문서가 실측해 알려 줍니다. 이 어긋남은 실제 화면이 정상 동작해 아무 오류도 남지 않으므로 종전에는 편집기를 열어 보기 전까지 드러나지 않았습니다.
|
||||
- `.env` 를 운영 기준값으로 삼는 설치를 지원합니다. `.env` 에 `G7_ENV_PRIORITY=true` 를 넣으면 `.env` 에 값이 적혀 있는 항목만 관리자 환경설정 저장값의 영향을 받지 않고, 그 항목은 관리자 화면에서 「`.env` 고정」 표시와 함께 편집 불가가 되며 실제로 적용 중인 값이 표시됩니다. 저장 요청에 그 항목이 섞여 있어도 서버가 걸러냅니다. 종전에는 설치 직후부터 관리자 화면 저장값이 항상 이겨 메일·드라이버·디버그 등 20여 개 항목의 `.env` 설정이 조용히 무시되었습니다. 스위치를 넣지 않으면 종전과 동일하게 동작합니다. (#37 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다.
|
||||
- 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다.
|
||||
- 동봉 확장의 문서 제목이 「그누보드7 {확장명} {유형}」(예: 「그누보드7 게시판 모듈」) 형식으로 통일되었습니다. 종전에는 확장명만 제목이라 그 문서만 연 사람이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없었습니다. 새 확장의 문서 골격도 같은 형식의 제목으로 생성되며, 직접 만든 확장에는 이 표기를 요구하지 않습니다.
|
||||
- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다.
|
||||
|
||||
### Security
|
||||
|
||||
- 화면이 새 스크립트를 불러오는 모든 경로에 같은 출처 확인을 적용했습니다. 종전에는 레이아웃에 적어 둔 스크립트만 확인 대상이어서, 화면 동작(액션)으로 스크립트를 불러오거나 확장을 활성화할 때 자산을 불러오는 경로, 레이아웃 편집기의 미리보기 화면은 확인 없이 외부 주소를 그대로 불러왔습니다. 이제 모든 경로가 사이트 자신의 주소이거나 확장이 미리 선언한 주소만 허용하며, 그 밖의 주소는 불러오지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃을 저장할 때 외부 주소를 검사하는 범위를 넓혔습니다. 종전에는 검사가 컴포넌트 속성·동작과 화면 진입 동작에만 미쳐, 모달·이름 붙인 동작·오류 처리·컴포넌트 생명주기·슬롯·반응형 설정에 적어 넣은 외부 주소는 그대로 저장되었습니다. 예시·안내용 주소를 담는 데이터 항목은 종전처럼 검사하지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 화면 동작으로 외부 라이브러리를 호출하는 기능에 안전장치를 더했습니다. 임의 코드 실행에 쓰일 수 있는 내장 함수 호출과, 호출 결과를 화면 값에 옮길 때 프로그램 내부 구조를 건드리는 이름은 거부됩니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
|
||||
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
|
||||
- 디버그 도구 엔드포인트 전체에 디버그 모드 확인을 적용했습니다. 종전에는 8개 중 3개에만 확인이 있어, 디버그 모드가 꺼진 운영 사이트에서도 로그인 없이 요청하면 저장된 디버그 데이터를 통째로 삭제할 수 있었습니다. 이제 확인은 개별 엔드포인트가 아니라 디버그 도구 전체에 한 번에 걸리므로, 앞으로 추가되는 엔드포인트도 자동으로 보호됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 본인인증을 마친 뒤 받은 확인값이 유효기간을 넘겨도 계속 통했던 문제를 수정했습니다. 그래서 오래전에 받아 둔 값 하나로 회원탈퇴처럼 본인인증이 필요한 작업을 언제든 다시 수행할 수 있었습니다. 이제 유효기간이 지난 확인값은 받아들이지 않으며, 그 경우 종전처럼 본인인증을 다시 요구합니다. 본인인증이 걸린 모든 지점(회원가입·비밀번호 재설정·정책이 지정한 화면 포함)에 함께 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2029)
|
||||
- 설치 마법사에 입력하는 사이트 이름·사이트 주소·업데이트 저장소 주소에 줄바꿈을 섞어 설치 설정 파일에 임의의 설정 항목을 끼워 넣을 수 있던 문제를 수정했습니다. 이제 설치 설정 파일에 기록되는 모든 입력이 같은 검사를 지나며, 줄바꿈이 섞이면 해당 항목에 오류를 표시하고 저장하지 않습니다. 주소 항목은 형식까지 확인합니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2042)
|
||||
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 네트워크 공유 경로(`\\서버\공유`)와 임의 이름의 `.phar` 파일을 지정할 수 있던 문제를 수정했습니다. 공격자가 지정한 원격 파일이나 미리 올려 둔 아카이브가 설치 과정에서 실행될 수 있었습니다. 이제 로컬 절대경로만 허용하고 Composer 자리는 composer 계열 이름만 받습니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2043)
|
||||
- 웹소켓 채널 인증 주소가 로그인 확인 없는 형태로 하나 더 등록되어 있던 문제를 수정했습니다. 화면에서는 쓰이지 않는 주소였지만 「웹소켓 사용 안 함」 설정을 우회할 수 있었습니다. 이제 로그인과 설정을 함께 확인하는 주소 하나만 남으며, 채널별 권한 확인은 종전과 동일하게 동작합니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 설정 캐시가 만들어져 있는 사이트(설치 마법사·환경설정 저장·확장 업데이트를 한 번이라도 거친 대부분의 사이트)에서 코어 업데이트가 업그레이드 스텝 단계에서 "수동 재개" 안내와 함께 멈추던 문제를 수정했습니다. 새 버전으로 실행되는 스텝 프로세스가 이전 버전의 설정 캐시를 읽어 자기 자신을 옛 버전으로 오판한 것이 원인이며, 실행할 스텝이 하나도 없는 버전으로 올릴 때도 같은 안내가 나왔습니다. 같은 원인으로 새 버전이 추가한 쓰기 폴더가 `sudo` 업데이트의 권한 정리에서 빠지던 문제도 함께 바로잡았고, 이제 업데이트는 스텝 실행 전에 이전 설정 캐시를 비웁니다.
|
||||
- 명령줄로 업그레이드 스텝을 직접 재실행하거나 관리자 「시스템 최적화」를 실행한 뒤, 그 실행이 예약한 초기 화면 정적 파일 재생성이 조용히 건너뛰어지던 문제를 수정했습니다.
|
||||
- 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다.
|
||||
- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
|
||||
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
|
||||
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다. 설정을 바꾸면(화면 저장·단건 저장·설정 복원·명령줄 어느 경로든) 스타일시트와 초기 화면 파일이 곧바로 새 주소 방식으로 다시 만들어집니다.
|
||||
- 스타일 안에서 글꼴·이미지를 상대 경로로 가리키는 확장이 있으면, 그 확장의 스타일이 화면에 하나도 적용되지 않던 문제를 수정했습니다. 여러 확장의 스타일을 하나로 합쳐 전달하는 과정에서 그런 확장을 통째로 빼고 있었고, 빠졌다는 사실은 어디에도 표시되지 않았습니다. 이제 빼는 대신 그 경로를 올바른 주소로 바꿔 함께 전달합니다.
|
||||
- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다.
|
||||
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
|
||||
- 상품 상세에서 옵션을 고른 뒤 「바로 구매」·「장바구니 담기」가 동작하지 않던 문제를 수정했습니다. 화면에는 고른 옵션이 목록에 담긴 것으로 보이는데 실제 요청에는 아무 옵션도 실리지 않아, 「바로 구매」는 오류로 끝나고 「장바구니 담기」는 「옵션을 선택해주세요」 안내만 반복됐습니다. 옵션 조합을 두 번 담으면 먼저 담은 조합이 사라지는 것도 같은 원인입니다. 상품 추가옵션(각인·포장 등) 선택도 함께 정상화됐습니다.
|
||||
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
|
||||
- 템플릿을 업데이트해도 레이아웃이 바뀌지 않은 릴리스(다국어·라우트·컴포넌트 정의·에셋만 바뀐 경우)에서는 다국어·라우트·에셋 캐시가 갱신되지 않아 이전 내용이 계속 표시되던 문제를 수정했습니다. 모듈·플러그인 업데이트와 같이 항상 갱신됩니다.
|
||||
- 자산 캐시 번호가 하루마다 자동으로 바뀌어 매일 모든 방문자가 자산을 다시 내려받고 서버가 초기 화면 파일 전체를 다시 만들던 문제를 수정했습니다. 이제 확장 설치·업데이트, 설정 변경, 운영자 파일 변경처럼 실제 변경이 있을 때만 바뀝니다.
|
||||
- `sudo` 로 코어를 업데이트한 뒤 모듈·플러그인 설정 저장이나 관리자 화면의 확장 업데이트가 폴더 권한 오류로 실패할 수 있던 문제를 수정했습니다. 업데이트 과정이 만드는 설정 폴더·임시 폴더·로그 파일의 소유권을 웹 서버 계정으로 맞춥니다. 설치 안내(INSTALL.md)에 명령줄·cron 을 웹 서버 계정으로 실행하는 규칙과 cron 예시를 추가했습니다.
|
||||
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
|
||||
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
|
||||
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
|
||||
- 프론트엔드를 다시 빌드하면 초기 화면 파일과 화면 구동에 필요한 코어 파일이 함께 지워져, 이미 열려 있던 페이지에서 파일을 찾지 못하던 문제를 수정했습니다. 빌드는 이제 자기 산출물만 교체합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 명령줄에서 초기 화면 파일을 처음 만들면 이후 웹에서 다시 만들지 못해, 확장을 설치·변경해도 초기 화면 파일이 갱신되지 않던 문제를 수정했습니다. 만들어진 파일이 웹 프로세스도 쓸 수 있는 권한을 갖도록 정리하고, 권한이 부족하면 화면이 느려질 뿐 멈추지 않도록 처리한 뒤 관리자 대시보드에 원인을 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 디스크가 가득 찬 상태에서 만들어진 손상된 초기 화면 파일이 그대로 사용되어 화면이 뜨지 않던 문제를 수정했습니다. 파일을 만들 때 기록된 내용이 온전한지 확인하고, 손상이 확인되면 종전 방식으로 자동 전환합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 초기 화면 파일을 새로 만드는 도중 짧은 시간 동안 기존 파일을 찾지 못할 수 있던 문제를 수정했습니다. 새 파일이 완성된 뒤에야 이전 파일을 정리합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 캐시를 비운 뒤 정리 작업이 먼저 실행되면 아직 사용 중인 직전 초기 화면 파일이 삭제될 수 있던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 확장 프로그램의 스크립트·스타일 묶음을 저장하지 못하는 환경에서 해당 요청이 서버 오류(500)가 되던 문제를 수정했습니다. 저장에 실패해도 화면에는 정상적으로 전달됩니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. 스타일이 비어 있는 확장만 설치된 사이트처럼 내용이 원래 없는 경우는 오류로 보지 않습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 설치 마법사 2단계(설치 환경 확인)가 Windows 사용자 계정 이름에 한글이 포함되어 있으면 빈 응답을 받아 `Unexpected end of JSON input` 오류로 더 진행되지 않던 문제를 수정했습니다. 계정 이름을 영문으로 바꾸지 않아도 설치할 수 있습니다. (sir.kr 커뮤니티의 FreeMax 님께서 제보해주셨습니다.)
|
||||
- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
|
||||
- 설치 완료·실패·중단 안내와 필수 파일 생성 안내가 나타날 때 화면이 그 위치로 부드럽게 이동합니다. 종전에는 설치 진행 로그를 보느라 화면이 아래쪽에 머물러 있으면 안내가 표시되어도 눈에 들어오지 않았습니다. 화면 움직임을 최소화하도록 설정한 사용자에게는 즉시 이동합니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
|
||||
- 코어 업데이트가 끝난 뒤 임시 작업 폴더의 껍데기(`storage/app/core_pending/core_*/extracted`)가 업데이트마다 남던 문제를 수정했습니다. `sudo` 로 실행한 경우 그 폴더가 root 소유로 남아 운영자 계정이나 웹서버 계정으로는 지울 수 없었습니다. 이제 임시 폴더를 통째로 정리하고, 이전 버전이 남긴 빈 껍데기도 업데이트 과정에서 함께 치웁니다. 소유권 복원 기준에서도 이번 실행이 만든 임시 폴더를 제외해, 남는 것이 있더라도 운영자가 지울 수 있는 소유권을 갖습니다.
|
||||
- 코어 업데이트 완료 안내문이 성공한 업데이트 뒤에는 동작하지 않는 정리 명령(`hotfix:rollback-stale-files --prune`)을 함께 안내하던 것을 바로잡았습니다. 성공한 업데이트는 백업을 지우므로 그 명령은 "사용 가능한 백업이 없습니다" 로 끝났습니다. 이제 신 버전에서 제거된 파일을 정리하려면 같은 업데이트를 `--prune` 옵션으로 다시 실행하도록만 안내합니다.
|
||||
|
||||
## [7.0.9] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
+97
-2
@@ -289,7 +289,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.9 g7
|
||||
# (필요 시) mv g7-7.0.10 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
@@ -368,6 +368,28 @@ http://도메인/install
|
||||
|
||||
---
|
||||
|
||||
## 설치 트러블슈팅
|
||||
|
||||
### 2단계에서 "Unexpected end of JSON input" 오류가 표시되는 경우
|
||||
|
||||
**증상**: 설치 마법사 2단계(설치 환경 확인)에서 요구사항 카드가 표시되지 않고
|
||||
`요구사항 검증 실패: ... Unexpected end of JSON input` 이 표시됩니다.
|
||||
서버 로그에는 아무 오류도 남지 않습니다.
|
||||
|
||||
**원인**: Windows 사용자 계정 이름에 한글(또는 서버가 쓰는 문자 인코딩 밖의 문자)이
|
||||
포함되어 있으면, 설치 마법사가 계정 이름을 조회하는 과정에서 응답을 만들지 못합니다.
|
||||
|
||||
**해결**:
|
||||
|
||||
- **7.0.10 이상**: 수정되었습니다. 별도 조치가 필요하지 않습니다.
|
||||
- **7.0.9 이하**: 계정 이름이 영문인 Windows 계정으로 웹 서버를 실행하거나,
|
||||
코어를 7.0.10 이상으로 올린 뒤 설치를 진행하세요.
|
||||
|
||||
설치 과정에서 문자 인코딩 관련 조치가 있었다면 `storage/logs/installation.log` 에
|
||||
`[env] whoami output was not UTF-8 — normalized` 형태로 기록됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 설치 후 확인
|
||||
|
||||
설치가 완료되면 아래 페이지에 접근할 수 있습니다.
|
||||
@@ -379,6 +401,29 @@ http://도메인/install
|
||||
|
||||
> 사용자 페이지는 사용자 템플릿이 설치되어 있어야 접근할 수 있습니다. 인스톨러에서 사용자 템플릿을 함께 설치하거나, 관리자 페이지에서 템플릿을 먼저 설치해 주세요.
|
||||
|
||||
### 파일 권한 (설치 후 확인)
|
||||
|
||||
POSIX 권한 모델 (Linux/macOS/BSD) 환경에서 웹 서버 실행 계정이 쓸 수 있어야 하는 위치는 다음과 같습니다. Windows 환경에서는 해당하지 않습니다.
|
||||
|
||||
| 위치 | 필요한 이유 |
|
||||
|------|------------|
|
||||
| `storage/` · `bootstrap/cache` | 애플리케이션 동작에 항상 필요 (로그·캐시·세션·업로드·런타임 캐시) |
|
||||
| `modules/` · `plugins/` · `templates/` · `public/build` | 관리자 화면에서 확장(모듈/플러그인/템플릿)을 설치·업데이트·삭제할 때 필요 |
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data storage bootstrap/cache modules plugins templates public/build
|
||||
```
|
||||
|
||||
**`vendor/` 에는 쓰기 권한이 필요하지 않습니다.** 확장의 `vendor/` 는 설치·업데이트 시점에만 기록되며, 애플리케이션이 동작하면서 만드는 런타임 캐시는 모두 `storage/` 아래에 기록됩니다. 명령줄로 설치·업데이트를 수행한다면 그 계정만 쓸 수 있으면 됩니다.
|
||||
|
||||
전체 권한 모델(그룹 공유 방식 A / 소유자 통일 방식 B / ACL 방식 C 와 umask 운영)은 [docs/requirements.md](docs/requirements.md) "파일 권한 및 umask 운영 방식" 절을 참고하세요.
|
||||
|
||||
### 명령줄·cron 실행 계정
|
||||
|
||||
`php artisan` 명령과 cron 은 **웹 서버 실행 계정으로** 실행합니다 (예: `sudo -u www-data php artisan …`, 또는 `crontab -u www-data -e` 로 등록). 대부분의 명령이 캐시·병합 번들·임시 파일을 만드는데, root 나 다른 계정으로 실행하면 그 산출물이 그 계정 소유로 남아 이후 웹 요청이 같은 자리에 쓰려는 순간 `Permission denied` 로 500 을 낼 수 있습니다. 이 문제는 특정 명령의 결함이 아니라 캐시를 건드리는 모든 명령에 공통이므로, 계정을 맞추는 것이 유일한 해법입니다.
|
||||
|
||||
예외는 코어 업데이트(`php artisan core:update`)입니다. 이 명령은 `.env` 등 웹 계정이 쓸 수 없는 파일까지 고치므로 `sudo` 를 권장하며, 실행이 끝날 때 자기가 만든 파일의 소유권을 되돌리는 절차가 내장되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 업그레이드
|
||||
@@ -540,6 +585,54 @@ sudo php artisan hotfix:rollback-stale-files --prune
|
||||
|
||||
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
|
||||
|
||||
`APP_URL` 은 명령줄 실행·큐 워커·메일 발송처럼 **요청이 없는 맥락**에서 절대 URL 을 만들 때의
|
||||
기준입니다. 웹 요청에서 만들어지는 절대 URL(화면 자산, 콜백 주소 등)은 `APP_URL` 이 아니라
|
||||
**요청 자체의 스킴과 호스트**를 따릅니다.
|
||||
|
||||
따라서 TLS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성(AWS ALB, CloudFront,
|
||||
Cloudflare, nginx 리버스 프록시, ngrok)에서는 `APP_URL` 만 `https://` 로 두어서는 부족합니다.
|
||||
사이트가 접속 주소와 방문자 IP 를 올바르게 인식하려면 `.env` 에 `TRUSTED_PROXIES` 를 함께
|
||||
지정해야 합니다.
|
||||
|
||||
```dotenv
|
||||
# 프록시 뒤에서 구동하는 경우에만 지정합니다 (미설정이 기본값).
|
||||
TRUSTED_PROXIES=*
|
||||
```
|
||||
|
||||
지정하지 않으면 화면이 표시되지 않거나(혼합 콘텐츠 차단), 결제 통보가 수신되지 않고, 모든
|
||||
방문자가 같은 IP 로 기록됩니다. 값 선택 기준과 도입 시 후속 조치는
|
||||
[docs/backend/reverse-proxy.md](docs/backend/reverse-proxy.md) 를 참고하세요.
|
||||
|
||||
### `.env` 를 운영 기준값으로 삼기 (선택)
|
||||
|
||||
기본 설계에서 **관리자 환경설정 화면이 운영 기준**입니다. 설치가 끝나면 메일·드라이버·디버그
|
||||
같은 항목은 화면에서 관리하며, 그 값이 `.env` 의 같은 항목보다 우선합니다. 설치 마법사가
|
||||
초기값을 저장하므로 **설치 직후부터** 그 우선순위가 적용됩니다.
|
||||
|
||||
컨테이너·구성 관리 도구·다중 서버처럼 `.env` 를 배포 기준값으로 관리하는 환경에서는 이
|
||||
우선순위를 뒤집을 수 있습니다. `.env` 에 다음 한 줄을 추가하세요.
|
||||
|
||||
```dotenv
|
||||
G7_ENV_PRIORITY=true
|
||||
```
|
||||
|
||||
켜면 **`.env` 에 값이 적혀 있는 항목만** 화면 저장값의 영향을 받지 않습니다. 그 항목들은
|
||||
관리자 화면에서 편집 불가로 표시되고, 화면에는 실제로 적용 중인 값이 나타납니다. 값이 비어
|
||||
있거나 줄이 없는 항목은 종전대로 화면에서 관리합니다.
|
||||
|
||||
주의할 점:
|
||||
|
||||
- `.env.example` 은 `APP_NAME`, `LOG_LEVEL`, `G7_UPDATE_GITHUB_URL` 등에 값을 채워 배포합니다.
|
||||
그 파일을 복사해 쓰고 있다면 스위치를 켜는 즉시 **그 항목들도 함께 잠깁니다.** 화면에서
|
||||
관리하고 싶은 항목은 값을 비우거나(`KEY=`) 줄을 지우세요.
|
||||
- `.env` 에 `BROADCAST_CONNECTION` 이나 `REVERB_*` 를 적으면 웹소켓 사용 여부까지 `.env` 가
|
||||
결정합니다. 관리자 화면의 웹소켓 토글은 그 설치에서 동작하지 않습니다.
|
||||
- `.env` 만 고친 경우에는 `php artisan config:cache` 를 다시 실행해야 반영됩니다. 관리자
|
||||
화면에서 저장하는 경우에는 자동으로 처리됩니다.
|
||||
- 비밀번호·시크릿·토큰 같은 값은 잠금 표시만 되고 화면에 나타나지 않습니다.
|
||||
|
||||
스위치를 넣지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
|
||||
|
||||
### `.env` 권한 강화 (선택)
|
||||
|
||||
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
|
||||
@@ -578,7 +671,9 @@ sudo php artisan hotfix:rollback-stale-files --prune
|
||||
cron에 아래 항목을 등록합니다.
|
||||
|
||||
```bash
|
||||
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
|
||||
* * * * * cd /path/to/g7 && sudo -u www-data php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
웹 서버 계정(`www-data` · `nginx` · `apache` 등)으로 실행해야 합니다 — root 의 crontab 에 그대로 등록하면 매분 캐시 파일이 root 소유로 만들어져 웹 요청이 실패할 수 있습니다. `crontab -u www-data -e` 로 그 계정의 crontab 에 등록하면 `sudo -u` 없이 같은 줄을 씁니다. 「명령줄·cron 실행 계정」 절을 참고하세요.
|
||||
|
||||
> 상세 내용은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
|
||||
|
||||
+8
-12
@@ -1,16 +1,12 @@
|
||||
<p align="center"><a href="README.md">English</a> | 한국어</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
|
||||
</p>
|
||||
# 그누보드7
|
||||
|
||||
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
|
||||
<p align="center">
|
||||
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -516,17 +512,17 @@ cp .env.example .env
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
|
||||
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<p align="center">English | <a href="README.ko.md">한국어</a></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Gnuboard7-그누보드7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="Gnuboard7 (그누보드7)">
|
||||
</p>
|
||||
# Gnuboard7
|
||||
|
||||
**A modern, extensible CMS platform built with Laravel + React**
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
|
||||
<p align="center">
|
||||
<strong>A modern, extensible CMS platform built with Laravel + React</strong><br>
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -530,17 +526,17 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
|
||||
<!-- community-contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
|
||||
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
|
||||
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
|
||||
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
|
||||
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
|
||||
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
|
||||
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
|
||||
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
|
||||
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
|
||||
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
|
||||
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
|
||||
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 부트스트랩 리소스 정적 게시 디렉토리를 정리하는 커맨드
|
||||
*
|
||||
* 게시 디렉토리(`public/build/ext/{version}/`)는 캐시 스토어 밖 파일시스템이라
|
||||
* version bump 가 구버전 디렉토리를 지우지 않는다. 게시 성공 직후 인라인 GC 가
|
||||
* 돌지만, 게시가 오래 없거나 실패한 환경의 잔존물을 이 커맨드가 회수한다.
|
||||
* (`CleanupExtensionBundlesCommand` 파일 산출물 GC 패턴 미러, #122)
|
||||
*/
|
||||
class CleanupExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '오래된 부트스트랩 리소스 정적 게시 디렉토리(구 version)를 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
$deleted = $service->cleanup();
|
||||
|
||||
$this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?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) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
|
||||
* 참조 중인 파일이 사라진다.
|
||||
*
|
||||
* **활성 디렉토리는 정리하지 않는다.** prune 은 빌드 *전에* 실행되므로, 웹이 서빙
|
||||
* 중인 `dist/` 를 비우면 prune~빌드 완료 구간 전체가 서빙 공백이 된다(빌드가 실패하면
|
||||
* 빈 채로 남는다). 확장 개발은 `_bundled` 에서 수행하고 활성 반영은 `{type}:update`
|
||||
* 가 담당하므로, 활성 경로 빌드는 예외적 경로다 — 그 경우 stale 산출물이 누적되는
|
||||
* 것을 감수하고 서빙 연속성을 택한다. 외부 확장처럼 `_bundled` 가 없어 활성 빌드가
|
||||
* 유일한 경로인 경우에도 사이트가 끊기지 않는다.
|
||||
*
|
||||
* @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 [];
|
||||
}
|
||||
|
||||
if (! $this->isBundledSourcePath($buildPath)) {
|
||||
$this->warn(
|
||||
' ⚠️ 활성 디렉토리 빌드 — 이전 산출물을 정리하지 않습니다 '
|
||||
.'(정리하면 빌드 완료까지 서빙이 끊깁니다). stale 산출물이 누적될 수 있으니 '
|
||||
.'개발은 _bundled 에서 하고 활성 반영은 update 커맨드로 하세요.'
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 경로가 소스 디렉토리(`_bundled` / `_pending`)인지 판정합니다.
|
||||
*
|
||||
* 이 둘은 웹이 서빙하지 않는 소스 보관소라 비워도 서빙 공백이 없다. 그 밖의 경로는
|
||||
* 활성 디렉토리(= 서빙 중)로 본다 — 판정을 뒤집어 두면(활성 목록을 열거하면) 새로운
|
||||
* 배치가 생길 때마다 조용히 활성 경로가 정리 대상이 된다.
|
||||
*
|
||||
* @param string $buildPath 확장 루트 경로
|
||||
* @return bool 소스 디렉토리 여부
|
||||
*/
|
||||
private function isBundledSourcePath(string $buildPath): bool
|
||||
{
|
||||
$normalized = str_replace('\\', '/', $buildPath);
|
||||
|
||||
return str_contains($normalized, '/_bundled/') || str_contains($normalized, '/_pending/');
|
||||
}
|
||||
}
|
||||
@@ -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,13 +125,63 @@ 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->pruneStaleSourceMaps($productionMode);
|
||||
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로덕션 빌드 후 `public/build/core/` 에 남은 소스맵을 제거합니다.
|
||||
*
|
||||
* 프로덕션 빌드는 `G7_BUILD_SOURCEMAP=0` 으로 맵을 **만들지 않을 뿐**, 이전 개발 빌드가
|
||||
* 남긴 맵을 지우지는 않는다. 그 디렉토리는 웹루트라 남아 있는 맵은 웹서버가 그대로
|
||||
* 서빙하고, 맵에는 원본 코드 전문(`sourcesContent`)이 담긴다 — 확장자 화이트리스트가
|
||||
* 막아 주는 확장 에셋과 달리 이 경로는 정적 서빙이라 통과한다.
|
||||
*
|
||||
* 종전에는 루트 `npm run build` 의 `emptyOutDir` 이 디렉토리를 통째로 비우면서 이 맵들을
|
||||
* 함께 지웠다. 그 동작은 서빙 중인 코어 번들·게시본까지 지우는 결함이라 껐으므로(#122),
|
||||
* 소스맵 정리 책임을 빌드 커맨드가 명시적으로 넘겨받는다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
*/
|
||||
private function pruneStaleSourceMaps(bool $productionMode): void
|
||||
{
|
||||
if (! $productionMode) {
|
||||
// 로컬 빌드는 디버깅을 위해 맵을 의도적으로 생성한다 — 지우면 그 목적이 사라진다.
|
||||
return;
|
||||
}
|
||||
|
||||
$removed = [];
|
||||
|
||||
foreach (glob(public_path('build/core').DIRECTORY_SEPARATOR.'*.map') ?: [] as $map) {
|
||||
if (@unlink($map)) {
|
||||
$removed[] = basename($map);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->warn(' ⚠️ 소스맵 삭제 실패 (수동 제거 필요): '.$map);
|
||||
}
|
||||
|
||||
if ($removed !== []) {
|
||||
$this->line(' 🧹 잔존 소스맵 제거: '.implode(', ', $removed));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
||||
*
|
||||
@@ -140,11 +190,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 */
|
||||
@@ -209,6 +262,7 @@ class BuildCoreCommand extends Command
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->pruneStaleSourceMaps($productionMode);
|
||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||
$this->showFullBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
@@ -301,12 +301,16 @@ class CoreUpdateCommand extends Command
|
||||
// Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
|
||||
// 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
|
||||
// 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
|
||||
//
|
||||
// 이번 실행이 방금 만든 격리 디렉토리(core_{ts})는 제외한다 — 스냅샷은 그 디렉토리가
|
||||
// 생긴 뒤에 찍히므로, 제외하지 않으면 sudo 가 root 로 만든 추출본이 "원본" 으로
|
||||
// 기록되고 복원이 잔존물을 다시 root 로 되돌린다(7.0.0~7.0.9 실사례).
|
||||
$detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
|
||||
'storage/logs',
|
||||
'storage/framework',
|
||||
'storage/app/core_pending',
|
||||
'bootstrap/cache',
|
||||
]);
|
||||
], excludes: [$service->resolveStagingRoot($pendingPath)]);
|
||||
if (! empty($detailedOwnershipSnapshot)) {
|
||||
$log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
|
||||
}
|
||||
@@ -593,6 +597,10 @@ class CoreUpdateCommand extends Command
|
||||
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
|
||||
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -677,6 +685,10 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -772,6 +784,10 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
|
||||
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
|
||||
// 전면 500 차단 (7.0.9→7.0.10 실사례)
|
||||
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
@@ -862,6 +878,15 @@ class CoreUpdateCommand extends Command
|
||||
'G7_UPDATE_IN_PROGRESS' => '1',
|
||||
]);
|
||||
|
||||
// spawn 직전 config 캐시 제거. 자식은 새 프로세스라 `bootstrap/cache/config.php` 가 있으면
|
||||
// 그 캐시로 부팅하는데, 그 캐시는 이전 버전 설치본이 만든 것이다(설치 마법사·설정 저장·
|
||||
// 확장 업데이트). 캐시 부팅에서는 `.env` 도 읽지 않고 위 `$env` 의 APP_VERSION 오버라이드도
|
||||
// config 에 반영되지 않으며, 신버전 `config/app.php` 가 추가한 update 목록(쓰기 권한
|
||||
// 디렉토리 등)도 자식에게 보이지 않는다 (7.0.9→7.0.10 실사례: stale 가드 오판 +
|
||||
// `public/build/ext` 권한 정상화 누락). 부모의 메모리 config 는 영향받지 않고, 캐시는
|
||||
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
|
||||
ConfigCacheHelper::clear();
|
||||
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
return $this->failSpawnWithMode(
|
||||
@@ -1359,44 +1384,17 @@ class CoreUpdateCommand extends Command
|
||||
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
|
||||
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
|
||||
*
|
||||
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
|
||||
* 쓰기 영역 소유자로 추정한다.
|
||||
* 판정은 `FilePermissionHelper::describeWebServerAccount()` 가 소유한다 — 정적 게시 상태·게시
|
||||
* 명령(`ext-static:*`)의 root 경고와 같은 4분기를 공유하기 위해 옮겼다(#651 C1). 이 메서드는
|
||||
* 그 결과를 종전 반환 형태(`[모드, 계정명]`)로 바꿔 주는 위임만 한다.
|
||||
*
|
||||
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
|
||||
*/
|
||||
private function classifyResumeExecutionContext(): array
|
||||
{
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return ['non_root', null];
|
||||
}
|
||||
$account = FilePermissionHelper::describeWebServerAccount();
|
||||
|
||||
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
|
||||
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
|
||||
if (posix_geteuid() !== 0) {
|
||||
return ['non_root', null];
|
||||
}
|
||||
|
||||
[$owner] = FilePermissionHelper::inferWebServerOwnership();
|
||||
|
||||
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
|
||||
if ($owner === false) {
|
||||
return ['root_web_unknown', null];
|
||||
}
|
||||
|
||||
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
|
||||
if ($owner === 0) {
|
||||
return ['root_web_symmetric', null];
|
||||
}
|
||||
|
||||
$entry = posix_getpwuid($owner);
|
||||
$name = $entry['name'] ?? null;
|
||||
|
||||
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
|
||||
if ($name === null) {
|
||||
return ['root_web_unknown', null];
|
||||
}
|
||||
|
||||
return ['root_web_known', $name];
|
||||
return [$account['mode'], $account['name']];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Search\SearchIndexMaintenanceManager;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Services\LanguagePackService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -183,6 +184,15 @@ class ExecuteBundledUpdatesCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 이전 버전 부모가 남긴 빈 격리 디렉토리(core_{ts}/extracted 껍데기) 청소 —
|
||||
// 부모의 정리 단계는 이 자식보다 먼저 끝나므로, 구버전 부모에서 올라오는
|
||||
// 업데이트도 이 자리에서 껍데기 없이 마무리된다. 실패해도 업데이트 결과와 무관.
|
||||
try {
|
||||
app(CoreUpdateService::class)->sweepEmptyStagingDirectories();
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
|
||||
$this->line(self::RESULT_PREFIX.json_encode([
|
||||
'success' => $success,
|
||||
|
||||
@@ -111,7 +111,17 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
|
||||
if (! $stepsOnly) {
|
||||
try {
|
||||
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
|
||||
// 구버전 부모(7.0.9 이하)는 spawn 전에 config 캐시를 비우지 않아 이 자식이 이전 버전
|
||||
// 캐시로 부팅할 수 있다. 그러면 `config()` 는 옛 목록이라 신버전이 추가한 디렉토리가
|
||||
// 빠진다 — 캐시 부팅이면 디스크 config/app.php 를 직접 읽는다 (7.0.9→7.0.10 실사례).
|
||||
// 판정은 캐시 파일의 실존으로 한다 — 부팅에 쓰였든 위 updateComposerAutoload() 가 방금
|
||||
// 재생성했든, 파일이 있으면 메모리 config 를 신뢰하지 않는다 (디스크 판독은 멱등).
|
||||
if (is_file($this->laravel->getCachedConfigPath())) {
|
||||
Log::channel('upgrade')->warning('[spawn] 이전 버전 config 캐시로 부팅됨 — update 목록은 디스크 config/app.php 에서 읽는다');
|
||||
$writablePaths = (array) $service->freshDiskUpdateConfig('restore_ownership_group_writable', []);
|
||||
} else {
|
||||
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
|
||||
}
|
||||
if (! empty($writablePaths)) {
|
||||
$service->ensureWritableDirectories(
|
||||
$writablePaths,
|
||||
@@ -206,6 +216,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
]);
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return UpgradeHandoffException::EXIT_CODE;
|
||||
} catch (\Throwable $e) {
|
||||
@@ -217,6 +228,7 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
$this->error($e->getMessage());
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
@@ -278,10 +290,38 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
}
|
||||
|
||||
$this->restoreUpgradeLogOwnership();
|
||||
$this->sweepEmptyStagingDirectories($service);
|
||||
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
|
||||
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
|
||||
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
|
||||
$service->normalizeRuntimeOwnershipAfterRootRun();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 버전 부모가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)를 청소합니다.
|
||||
*
|
||||
* 부모의 정리 단계는 소스 경로 안쪽만 지우던 결함(7.0.0~7.0.9)이 있어 업데이트마다
|
||||
* 껍데기가 남았고, sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없었다.
|
||||
* 부모는 구버전 클래스를 메모리에 들고 있어 고쳐도 다음 업데이트부터 효력이 있으므로,
|
||||
* 신버전 코드로 도는 이 자식이 치운다. 파일이 있는 디렉토리(부모가 쓰는 중인 격리
|
||||
* 디렉토리)는 술어상 건드리지 않는다. 실패는 업데이트 결과와 무관하므로 경고로 흡수한다.
|
||||
*
|
||||
* @param CoreUpdateService $service 코어 업데이트 서비스
|
||||
*/
|
||||
private function sweepEmptyStagingDirectories(CoreUpdateService $service): void
|
||||
{
|
||||
try {
|
||||
$swept = $service->sweepEmptyStagingDirectories();
|
||||
if ($swept > 0) {
|
||||
$this->info("[spawn] 빈 격리 디렉토리 청소: {$swept}개");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use App\Support\ExtensionDoc\ExtensionDocContext;
|
||||
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
||||
use App\Support\ExtensionDoc\ExtensionInventory;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 확장 개발자 문서 생성 커맨드
|
||||
*
|
||||
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
|
||||
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
|
||||
*
|
||||
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
|
||||
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
|
||||
*/
|
||||
class ExtDocgenCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'ext:docgen
|
||||
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
|
||||
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
|
||||
{--check : 생성하지 않고 누락·드리프트만 리포트}
|
||||
{--json : 기계 판독 출력}
|
||||
{--dry-run : 대상과 실측 집계만 출력}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
|
||||
|
||||
/**
|
||||
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
|
||||
*/
|
||||
private array $malformed = [];
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param ExtensionInventory $inventory 번들 확장 인벤토리
|
||||
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
ExtensionInventory $inventory,
|
||||
ExtensionDocContext $context,
|
||||
ExtensionDocScaffolder $scaffolder
|
||||
): int {
|
||||
$scope = (string) $this->option('scope');
|
||||
|
||||
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
|
||||
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
|
||||
// 함께 쓸 수 없다고 명시적으로 거부한다.
|
||||
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
|
||||
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
|
||||
$conflict = match (true) {
|
||||
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
|
||||
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
|
||||
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($conflict !== null) {
|
||||
$message = $conflict;
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $message],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$records = $inventory->collect($scope);
|
||||
$this->malformed = $inventory->malformed();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->warn($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$ctx = $context->build($record);
|
||||
|
||||
$results[] = $this->processExtension($ctx, $scaffolder);
|
||||
}
|
||||
|
||||
return $this->report($results, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 확장을 처리합니다.
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return array<string, mixed> 처리 결과
|
||||
*/
|
||||
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
$stats = ExtensionDocScaffolder::statsOf($ctx);
|
||||
|
||||
$result = [
|
||||
'type' => $record['type'],
|
||||
'id' => $record['id'],
|
||||
'relPath' => $record['relPath'],
|
||||
'version' => $record['version'],
|
||||
'stats' => $stats,
|
||||
'surfaceAvailable' => $ctx['surface']['available'],
|
||||
'surfaceReason' => $ctx['surface']['reason'],
|
||||
'surfaceErrors' => $ctx['surface']['errors'],
|
||||
'documents' => [],
|
||||
'created' => [],
|
||||
'updated' => [],
|
||||
'missingDocuments' => [],
|
||||
'missingSections' => [],
|
||||
'missingBlocks' => [],
|
||||
'driftedBlocks' => [],
|
||||
'orphanBlocks' => [],
|
||||
'unfilled' => [],
|
||||
];
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
|
||||
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
|
||||
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
|
||||
if ($this->option('init') && ! $this->option('check')) {
|
||||
$this->initSkeletons($ctx, $scaffolder, $result);
|
||||
}
|
||||
|
||||
$bodies = $scaffolder->renderBlocks($ctx);
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
|
||||
|
||||
if (! is_file($abs)) {
|
||||
$result['missingDocuments'][] = $doc;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) File::get($abs);
|
||||
|
||||
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
|
||||
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
|
||||
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
|
||||
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
|
||||
$result['missingSections'][] = $doc.' → '.$section;
|
||||
}
|
||||
}
|
||||
|
||||
// 미채움 마커 잔량
|
||||
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
|
||||
$count = substr_count($content, $marker);
|
||||
if ($count > 0) {
|
||||
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
|
||||
}
|
||||
}
|
||||
|
||||
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
|
||||
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
|
||||
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
|
||||
|
||||
if ($emptyIntents > 0) {
|
||||
$result['unfilled'][] = [
|
||||
'doc' => $doc,
|
||||
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
|
||||
'count' => $emptyIntents,
|
||||
];
|
||||
}
|
||||
|
||||
$docBodies = [];
|
||||
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
|
||||
if (array_key_exists($key, $bodies)) {
|
||||
$docBodies[$key] = $bodies[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
|
||||
|
||||
foreach ($merged['missing'] as $key) {
|
||||
$result['missingBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
|
||||
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
|
||||
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
|
||||
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
|
||||
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
|
||||
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
|
||||
$result['orphanBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('check')) {
|
||||
if (! $merged['unchanged']) {
|
||||
foreach ($merged['replaced'] as $key) {
|
||||
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
|
||||
$result['driftedBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $merged['unchanged']) {
|
||||
File::put($abs, $merged['content']);
|
||||
|
||||
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
|
||||
if (! in_array($doc, $result['created'], true)) {
|
||||
$result['updated'][] = $doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @param array<string, mixed> $result 처리 결과 (created 누적)
|
||||
*/
|
||||
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
|
||||
if (is_file($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($abs));
|
||||
File::put($abs, $scaffolder->skeleton($doc, $ctx));
|
||||
$result['created'][] = $doc;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
|
||||
*
|
||||
* @param string $content 문서 내용
|
||||
* @param string $key 블록 키
|
||||
* @param string $body 새 본문
|
||||
* @return bool 다르면 true
|
||||
*/
|
||||
private function blockDiffers(string $content, string $key, string $body): bool
|
||||
{
|
||||
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
|
||||
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
|
||||
|
||||
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$from = (int) $sm[0][1] + strlen($sm[0][0]);
|
||||
|
||||
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
|
||||
|
||||
return $existing !== trim($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 결과를 출력하고 종료 코드를 결정합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 확장별 결과
|
||||
* @param string $scope 범위
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function report(array $results, string $scope): int
|
||||
{
|
||||
$issues = 0;
|
||||
foreach ($results as $result) {
|
||||
$issues += count($result['missingDocuments'])
|
||||
+ count($result['missingSections'])
|
||||
+ count($result['missingBlocks'])
|
||||
+ count($result['driftedBlocks'])
|
||||
+ count($result['orphanBlocks']);
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'scope' => $scope,
|
||||
'mode' => $this->mode(),
|
||||
'extensions' => $results,
|
||||
'malformed' => $this->malformed,
|
||||
'issues' => $issues,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
|
||||
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info(count($results).'개 확장 (scope='.$scope.')');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($results as $result) {
|
||||
$parts = [];
|
||||
foreach ($result['stats'] as $label => $value) {
|
||||
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
|
||||
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
|
||||
$parts[] = $value === null
|
||||
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
|
||||
: "{$label} {$value}";
|
||||
}
|
||||
|
||||
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
|
||||
$this->line(' '.implode(' · ', $parts));
|
||||
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
|
||||
|
||||
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
|
||||
$this->line(' 선언형 표면: '.$result['surfaceReason']);
|
||||
}
|
||||
if ($result['surfaceErrors'] !== []) {
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->line(" ⚠ {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($results as $result) {
|
||||
$label = sprintf('[%s] %s', $result['type'], $result['id']);
|
||||
|
||||
if ($result['created'] !== []) {
|
||||
$this->info("{$label} 생성: ".implode(', ', $result['created']));
|
||||
}
|
||||
if ($result['updated'] !== []) {
|
||||
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
|
||||
}
|
||||
if ($result['missingDocuments'] !== []) {
|
||||
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
|
||||
}
|
||||
if ($result['missingBlocks'] !== []) {
|
||||
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
|
||||
}
|
||||
if ($result['missingSections'] !== []) {
|
||||
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
|
||||
}
|
||||
if ($result['driftedBlocks'] !== []) {
|
||||
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
|
||||
}
|
||||
if ($result['orphanBlocks'] !== []) {
|
||||
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
|
||||
}
|
||||
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
|
||||
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
|
||||
if ($result['unfilled'] !== []) {
|
||||
$total = array_sum(array_column($result['unfilled'], 'count'));
|
||||
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
|
||||
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
|
||||
$result['unfilled'],
|
||||
)));
|
||||
}
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->malformed as $bad) {
|
||||
$this->warn(sprintf(
|
||||
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
|
||||
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
|
||||
|
||||
if ($this->option('check') && $issues > 0) {
|
||||
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 실행 모드를 반환합니다.
|
||||
*
|
||||
* @return string 모드 문자열
|
||||
*/
|
||||
private function mode(): string
|
||||
{
|
||||
if ($this->option('dry-run')) {
|
||||
return 'dry-run';
|
||||
}
|
||||
if ($this->option('check')) {
|
||||
return 'check';
|
||||
}
|
||||
if ($this->option('init')) {
|
||||
return 'init';
|
||||
}
|
||||
|
||||
return 'update';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -56,6 +56,9 @@ class PlaywrightIssueToken extends Command
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
|
||||
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
|
||||
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
|
||||
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ class PlaywrightSeedLayout extends Command
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
|
||||
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
|
||||
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
|
||||
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시(bake)를 수동 수행하는 커맨드
|
||||
*
|
||||
* 수명주기 이벤트(terminating 트리거)와 blade 자가 치유가 정상 경로지만,
|
||||
* 배포 직후 워밍이나 수동 복구가 필요할 때 이 커맨드로 즉시 게시한다.
|
||||
* 설치기 완료 단계에서도 호출된다 (#122).
|
||||
*/
|
||||
class PublishExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:publish {--force : 게시 완료 상태여도 강제 재게시}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '부트스트랩 리소스(다국어·컴포넌트·라우트·번들·템플릿 에셋)를 정적 파일로 게시합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
if (! $service->isEnabled()) {
|
||||
$this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// root 로 실행 중이면 경고만 하고 **계속 진행**한다 — 명시적 명령은 운영자 책임이고, 중단 가드는
|
||||
// 두지 않는다(#651 D3). 게시가 만드는 캐시 락 샤드·병합 번들이 root 소유로 남으면 이후 웹 요청의
|
||||
// 캐시 쓰기가 Permission denied 로 죽으므로(전면 500 실사례) 웹 계정 실행 형태를 함께 적는다.
|
||||
if (StatusExtensionStaticCacheCommand::isRootProcess()) {
|
||||
$this->warn('root 로 실행 중입니다 — 이 계정으로 게시하면 캐시 폴더·병합 번들이 root 소유가 되어 이후 웹 요청이 실패할 수 있습니다.');
|
||||
$this->warn('웹 계정으로 실행하세요: '.StatusExtensionStaticCacheCommand::publishCommandHint());
|
||||
}
|
||||
|
||||
$published = $service->publishCurrent(force: (bool) $this->option('force'));
|
||||
|
||||
if (! $published) {
|
||||
$this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* 의존성 취약점 전수 점검 커맨드 (#126)
|
||||
*
|
||||
* 이 저장소는 잠금파일이 60개 가까이 흩어져 있다 — 코어 하나와 확장 수십 개가 각자
|
||||
* `package-lock.json` / `composer.lock` 을 갖는다. 루트만 감사하면 확장 전부가 사각이
|
||||
* 되는데, 그 사각은 오류도 경고도 남기지 않는다: 취약한 라이브러리가 정상 동작하는 것이
|
||||
* 유일한 증상이다.
|
||||
*
|
||||
* 동봉(vendored) 자산은 어떤 잠금파일에도 없어 `npm audit` 이 **원리상** 볼 수 없다.
|
||||
* 그 축은 판정하지 않고 목록으로 노출한다 — 사람이 봐야 하는 대상이기 때문이다.
|
||||
*
|
||||
* 종료 코드: 취약점이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||
* **조치 대상 발견 신호**다.
|
||||
*/
|
||||
class SecurityAuditDependenciesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'security:audit-dependencies
|
||||
{--json : 결과를 JSON 으로 출력합니다}
|
||||
{--npm-only : npm 잠금파일만 점검합니다}
|
||||
{--composer-only : composer 잠금파일만 점검합니다}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '저장소의 모든 잠금파일(npm·composer) 운영 의존성 취약점을 점검합니다 (취약점 발견 시 비-0 종료)';
|
||||
|
||||
/** 잠금파일 탐색에서 제외할 경로 조각 */
|
||||
private const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', '_pending', 'storage'];
|
||||
|
||||
/** 개별 감사 프로세스 제한 시간(초) */
|
||||
private const PROCESS_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int 취약점이 없으면 0, 있으면 1
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$npmOnly = (bool) $this->option('npm-only');
|
||||
$composerOnly = (bool) $this->option('composer-only');
|
||||
|
||||
$results = [];
|
||||
|
||||
if (! $composerOnly) {
|
||||
foreach ($this->lockFiles('package-lock.json') as $lock) {
|
||||
$results[] = $this->auditNpm($lock);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $npmOnly) {
|
||||
foreach ($this->lockFiles('composer.lock') as $lock) {
|
||||
$results[] = $this->auditComposer($lock);
|
||||
}
|
||||
}
|
||||
|
||||
$vendored = $this->vendoredAssets();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'results' => $results,
|
||||
'vendored_assets' => $vendored,
|
||||
'totals' => $this->totals($results),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $this->totals($results)['vulnerable'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->renderTable($results);
|
||||
$this->renderVendored($vendored);
|
||||
|
||||
$totals = $this->totals($results);
|
||||
|
||||
$this->line('');
|
||||
|
||||
if ($totals['unmeasurable'] > 0) {
|
||||
$this->warn(sprintf(
|
||||
'점검 불가 %d건 — 감사 도구가 실행되지 않았습니다. "취약점 없음" 과 다릅니다.',
|
||||
$totals['unmeasurable']
|
||||
));
|
||||
}
|
||||
|
||||
if ($totals['vulnerable'] === 0) {
|
||||
$this->info(sprintf(
|
||||
'운영 의존성 취약점 0건 (감사 %d개 / 대상 없음 %d개 / 발견 %d개 잠금파일).',
|
||||
$totals['audited'],
|
||||
$totals['empty'],
|
||||
$totals['checked']
|
||||
));
|
||||
|
||||
return $totals['unmeasurable'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error(sprintf(
|
||||
'%d개 잠금파일에서 운영 의존성 취약점이 발견되었습니다 (총 %d건).',
|
||||
$totals['vulnerable'],
|
||||
$totals['advisories']
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소에서 잠금파일을 전수 탐색합니다.
|
||||
*
|
||||
* @param string $fileName 잠금파일 이름
|
||||
* @return array<int, string> 잠금파일 절대 경로 목록
|
||||
*/
|
||||
private function lockFiles(string $fileName): array
|
||||
{
|
||||
$root = base_path();
|
||||
$found = [];
|
||||
|
||||
// 제외 디렉토리는 **내려가기 전에** 잘라낸다. 파일 단계에서 거르면 `node_modules`
|
||||
// 수만 개를 전부 훑고 나서 버리게 되어 한 번 실행에 수 분이 걸린다.
|
||||
$directories = new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS);
|
||||
$pruned = new \RecursiveCallbackFilterIterator(
|
||||
$directories,
|
||||
function (\SplFileInfo $entry): bool {
|
||||
if (! $entry->isDir()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array($entry->getFilename(), self::EXCLUDED_SEGMENTS, true);
|
||||
}
|
||||
);
|
||||
|
||||
foreach (new \RecursiveIteratorIterator($pruned) as $entry) {
|
||||
if (! $entry->isFile() || $entry->getFilename() !== $fileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found[] = $entry->getPathname();
|
||||
}
|
||||
|
||||
sort($found);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 잠금파일 하나를 감사합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function auditNpm(string $lockPath): array
|
||||
{
|
||||
if ($this->isEmptyLock($lockPath, ['packages', 'dependencies'])) {
|
||||
return $this->emptyLock($lockPath, 'npm');
|
||||
}
|
||||
|
||||
$dir = dirname($lockPath);
|
||||
$process = $this->runProcess(['npm', 'audit', '--omit=dev', '--json'], $dir);
|
||||
$decoded = json_decode($process['output'], true);
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['metadata']['vulnerabilities'])) {
|
||||
return $this->unmeasurable($lockPath, 'npm', trim($process['error']) ?: 'npm audit 출력을 해석할 수 없습니다.');
|
||||
}
|
||||
|
||||
$severities = array_filter(
|
||||
array_map('intval', $decoded['metadata']['vulnerabilities']),
|
||||
static fn (int $count, string $key): bool => $count > 0 && $key !== 'total',
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
$total = (int) ($decoded['metadata']['vulnerabilities']['total'] ?? 0);
|
||||
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => 'npm',
|
||||
'status' => $total > 0 ? 'vulnerable' : 'clean',
|
||||
'advisories' => $total,
|
||||
'severities' => $severities,
|
||||
'detail' => $total > 0 ? implode(', ', array_keys($decoded['vulnerabilities'] ?? [])) : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 잠금파일 하나를 감사합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function auditComposer(string $lockPath): array
|
||||
{
|
||||
if ($this->isEmptyLock($lockPath, ['packages', 'packages-dev'])) {
|
||||
return $this->emptyLock($lockPath, 'composer');
|
||||
}
|
||||
|
||||
$dir = dirname($lockPath);
|
||||
$process = $this->runProcess(['composer', 'audit', '--locked', '--no-dev', '--format=json'], $dir);
|
||||
$decoded = json_decode($this->firstJsonObject($process['output']), true);
|
||||
|
||||
if (! is_array($decoded) || ! array_key_exists('advisories', $decoded)) {
|
||||
return $this->unmeasurable($lockPath, 'composer', trim($process['error']) ?: 'composer audit 출력을 해석할 수 없습니다.');
|
||||
}
|
||||
|
||||
$severities = [];
|
||||
$total = 0;
|
||||
|
||||
foreach ($decoded['advisories'] as $advisories) {
|
||||
foreach ((array) $advisories as $advisory) {
|
||||
$total++;
|
||||
$severity = (string) ($advisory['severity'] ?? 'unknown');
|
||||
$severities[$severity] = ($severities[$severity] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => 'composer',
|
||||
'status' => $total > 0 ? 'vulnerable' : 'clean',
|
||||
'advisories' => $total,
|
||||
'severities' => $severities,
|
||||
'detail' => $total > 0 ? implode(', ', array_keys($decoded['advisories'])) : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠금파일이 의존성을 하나도 담고 있지 않은지 판정합니다.
|
||||
*
|
||||
* 확장 대부분은 composer 의존성이 없어 빈 잠금을 갖는다. 그 상태에서 감사 도구는
|
||||
* "설치본이 없다" 는 오류를 내는데, 그것을 점검 불가로 세면 조치할 것이 없는 대상이
|
||||
* 24건씩 경고로 쌓여 진짜 점검 불가를 가린다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param array<int, string> $keys 의존성이 담기는 최상위 키
|
||||
* @return bool
|
||||
*/
|
||||
private function isEmptyLock(string $lockPath, array $keys): bool
|
||||
{
|
||||
$decoded = json_decode((string) @file_get_contents($lockPath), true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$entries = $decoded[$key] ?? [];
|
||||
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// npm 잠금의 `packages` 는 루트 자신을 빈 문자열 키로 항상 담는다 — 그것만 있으면
|
||||
// 빈 것이다. composer 잠금의 `packages` 는 리스트라 키가 정수이므로 건드리지 않는다.
|
||||
$meaningful = array_filter(
|
||||
array_keys($entries),
|
||||
static fn ($name): bool => $name !== ''
|
||||
);
|
||||
|
||||
if ($meaningful !== []) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 의존성이 없는 잠금파일을 기록합니다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param string $kind npm|composer
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function emptyLock(string $lockPath, string $kind): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => $kind,
|
||||
'status' => 'empty',
|
||||
'advisories' => 0,
|
||||
'severities' => [],
|
||||
'detail' => '의존성 없음',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 도구를 실행할 수 없었던 대상을 기록합니다.
|
||||
*
|
||||
* "점검 불가" 는 "취약점 없음" 과 다르다 — 뭉뚱그리면 운영자가 정상으로 읽는다.
|
||||
*
|
||||
* @param string $lockPath 잠금파일 절대 경로
|
||||
* @param string $kind npm|composer
|
||||
* @param string $reason 사유
|
||||
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
|
||||
*/
|
||||
private function unmeasurable(string $lockPath, string $kind, string $reason): array
|
||||
{
|
||||
return [
|
||||
'target' => $this->relative($lockPath),
|
||||
'kind' => $kind,
|
||||
'status' => 'unmeasurable',
|
||||
'advisories' => 0,
|
||||
'severities' => [],
|
||||
'detail' => mb_substr($reason, 0, 200),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 명령을 실행합니다 ( 과 충돌하지 않도록 이름을 분리).
|
||||
*
|
||||
* @param array<int, string> $command 명령과 인자
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @return array{output: string, error: string, exit: int}
|
||||
*/
|
||||
private function runProcess(array $command, string $cwd): array
|
||||
{
|
||||
$process = new Process($command, $cwd, null, null, self::PROCESS_TIMEOUT);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable $e) {
|
||||
return ['output' => '', 'error' => $e->getMessage(), 'exit' => -1];
|
||||
}
|
||||
|
||||
return [
|
||||
'output' => $process->getOutput(),
|
||||
'error' => $process->getErrorOutput(),
|
||||
'exit' => (int) $process->getExitCode(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 앞쪽 잡음을 걷어내고 첫 JSON 객체만 남깁니다.
|
||||
*
|
||||
* composer 는 오토로드 경고를 표준출력에 함께 흘릴 수 있다.
|
||||
*
|
||||
* @param string $output 명령 출력
|
||||
* @return string JSON 문자열 (없으면 빈 문자열)
|
||||
*/
|
||||
private function firstJsonObject(string $output): string
|
||||
{
|
||||
$start = strpos($output, '{');
|
||||
|
||||
return $start === false ? '' : substr($output, $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동봉 제3자 자산의 버전 디렉토리를 나열합니다.
|
||||
*
|
||||
* 감사 도구가 원리상 볼 수 없는 축이므로 판정하지 않고 노출만 한다.
|
||||
*
|
||||
* @return array<int, array{extension: string, library: string, versions: array<int, string>}>
|
||||
*/
|
||||
private function vendoredAssets(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (['templates', 'modules', 'plugins'] as $kind) {
|
||||
foreach ([$kind.'/*/dist/vendor', $kind.'/_bundled/*/dist/vendor'] as $pattern) {
|
||||
foreach ((array) glob(base_path($pattern), GLOB_ONLYDIR) as $vendorRoot) {
|
||||
foreach ((array) glob($vendorRoot.'/*', GLOB_ONLYDIR) as $libDir) {
|
||||
$versions = array_map(
|
||||
'basename',
|
||||
(array) glob($libDir.'/*', GLOB_ONLYDIR)
|
||||
);
|
||||
|
||||
$rows[] = [
|
||||
'extension' => $this->relative(dirname($vendorRoot, 2)),
|
||||
'library' => basename($libDir),
|
||||
'versions' => array_values($versions),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 집계를 계산합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 감사 결과
|
||||
* @return array{checked: int, vulnerable: int, unmeasurable: int, advisories: int}
|
||||
*/
|
||||
private function totals(array $results): array
|
||||
{
|
||||
return [
|
||||
'checked' => count($results),
|
||||
'audited' => count(array_filter($results, static fn (array $r): bool => in_array($r['status'], ['clean', 'vulnerable'], true))),
|
||||
'empty' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'empty')),
|
||||
'vulnerable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'vulnerable')),
|
||||
'unmeasurable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'unmeasurable')),
|
||||
'advisories' => array_sum(array_column($results, 'advisories')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 표를 출력합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 감사 결과
|
||||
* @return void
|
||||
*/
|
||||
private function renderTable(array $results): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->line('<options=bold>의존성 취약점 점검 (운영 의존성 기준)</>');
|
||||
$this->line('');
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$severities = [];
|
||||
|
||||
foreach ($result['severities'] as $severity => $count) {
|
||||
$severities[] = $severity.' '.$count;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$result['kind'],
|
||||
$result['target'],
|
||||
match ($result['status']) {
|
||||
'clean' => '이상 없음',
|
||||
'empty' => '대상 없음',
|
||||
'vulnerable' => '취약',
|
||||
default => '점검 불가',
|
||||
},
|
||||
$result['advisories'] > 0 ? (string) $result['advisories'] : '-',
|
||||
$severities === [] ? ($result['detail'] !== '' ? mb_substr($result['detail'], 0, 60) : '-') : implode(', ', $severities),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['종류', '대상', '상태', '건수', '내역'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 동봉 자산 목록을 출력합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $vendored 동봉 자산 목록
|
||||
* @return void
|
||||
*/
|
||||
private function renderVendored(array $vendored): void
|
||||
{
|
||||
$this->line('');
|
||||
$this->line('<options=bold>동봉 제3자 자산 (감사 도구가 볼 수 없는 축 — 사람이 확인)</>');
|
||||
$this->line('');
|
||||
|
||||
if ($vendored === []) {
|
||||
$this->line(' (없음)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['확장', '라이브러리', '버전 디렉토리'],
|
||||
array_map(
|
||||
static fn (array $row): array => [
|
||||
$row['extension'],
|
||||
$row['library'],
|
||||
$row['versions'] === [] ? '(버전 디렉토리 없음)' : implode(', ', $row['versions']),
|
||||
],
|
||||
$vendored
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소 상대 경로로 바꿉니다.
|
||||
*
|
||||
* @param string $path 절대 경로
|
||||
* @return string
|
||||
*/
|
||||
private function relative(string $path): string
|
||||
{
|
||||
$base = base_path().DIRECTORY_SEPARATOR;
|
||||
|
||||
return str_replace('\\', '/', str_starts_with($path, $base) ? substr($path, strlen($base)) : $path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시 상태 점검 커맨드 (#122)
|
||||
*
|
||||
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이다. 그래서 정상
|
||||
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
|
||||
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
|
||||
*
|
||||
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
|
||||
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
|
||||
*
|
||||
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
|
||||
* 최근 실패 마커 / 잔존 버전 목록.
|
||||
*
|
||||
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
|
||||
* **이상 발견 신호**다 — 운영자가 조치할 대상이 있다는 뜻이다.
|
||||
*/
|
||||
class StatusExtensionStaticCacheCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-static:status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '부트스트랩 리소스 정적 게시 상태를 점검합니다 (이상 발견 시 비-0 종료)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionStaticCacheService $service 정적 게시 서비스
|
||||
* @return int 이상 없으면 0, 이상 발견 시 1
|
||||
*/
|
||||
public function handle(ExtensionStaticCacheService $service): int
|
||||
{
|
||||
$problems = [];
|
||||
|
||||
$report = $service->statusReport();
|
||||
$base = $service->baseDir();
|
||||
$publishHint = self::publishCommandHint();
|
||||
|
||||
$this->line('');
|
||||
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
|
||||
$this->line('');
|
||||
|
||||
$this->line(' 실행 계정 : '.$report['process_user']);
|
||||
|
||||
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
|
||||
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
|
||||
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
|
||||
if (self::isRootProcess()) {
|
||||
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
|
||||
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
|
||||
}
|
||||
|
||||
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
|
||||
$this->line(' 현재 캐시 버전 : '.$report['version']);
|
||||
$this->line(' 게시 루트 : '.$base);
|
||||
|
||||
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
|
||||
if (! $report['enabled']) {
|
||||
$this->line('');
|
||||
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
|
||||
if ($report['tree_writable']) {
|
||||
$this->line(' 트리 쓰기 : 가능');
|
||||
} else {
|
||||
$this->line(' 트리 쓰기 : <error>불가</error>');
|
||||
$problems[] = sprintf(
|
||||
'게시 트리에 쓸 수 없습니다 (%s, owner=%s, perms=%s). 웹 계정이 재게시할 수 없어 '
|
||||
.'모든 요청이 API 폴백으로 동작합니다.',
|
||||
$base,
|
||||
(string) (@fileowner($base) ?: 'unknown'),
|
||||
File::exists($base) ? substr(sprintf('%o', @fileperms($base)), -4) : 'absent'
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 현재 버전 게시 여부
|
||||
if ($report['published']) {
|
||||
$this->line(' 게시 상태 : 완료');
|
||||
$this->line(' manifest 파일 : '.$report['files'].'건');
|
||||
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
|
||||
|
||||
if ($report['files'] === 0) {
|
||||
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
|
||||
}
|
||||
} else {
|
||||
$this->line(' 게시 상태 : <comment>미게시</comment>');
|
||||
$problems[] = sprintf(
|
||||
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
|
||||
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
|
||||
.'「지금 다시 만들기」를 사용하세요.',
|
||||
$report['version'],
|
||||
$publishHint
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
|
||||
$marker = $report['failure'];
|
||||
|
||||
if ($marker !== null) {
|
||||
$this->line('');
|
||||
$this->line(' <error>최근 게시 실패</error>');
|
||||
$this->line(' 사유 : '.($marker['reason'] ?? '?').' — '.$this->reasonHint($marker['reason'] ?? ''));
|
||||
$this->line(' 버전 : '.($marker['version'] ?? '?'));
|
||||
$this->line(' 시각 : '.($marker['at'] ?? '?'));
|
||||
$this->line(' 연속 실패 : '.($marker['count'] ?? '?').'회');
|
||||
$this->line(' 상세 : '.($marker['message'] ?? ''));
|
||||
|
||||
$problems[] = '게시 실패 마커가 남아 있습니다 (사유: '.($marker['reason'] ?? '?').').';
|
||||
}
|
||||
|
||||
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
|
||||
$versions = $report['retained_versions'];
|
||||
|
||||
$this->line('');
|
||||
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
|
||||
|
||||
if (count($versions) > 3) {
|
||||
$problems[] = sprintf(
|
||||
'게시 버전이 %d개 누적됐습니다 (정상은 현재+직전 2개). 삭제가 실패하고 있을 수 '
|
||||
.'있습니다 — 소유권/권한을 확인하세요.',
|
||||
count($versions)
|
||||
);
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
if ($problems === []) {
|
||||
$this->info('이상 없음.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($problems as $problem) {
|
||||
$this->warn('• '.$problem);
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
|
||||
*
|
||||
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
|
||||
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
|
||||
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
|
||||
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
|
||||
*
|
||||
* @return string 안내 명령 문자열
|
||||
*/
|
||||
public static function publishCommandHint(): string
|
||||
{
|
||||
$command = 'php artisan ext-static:publish';
|
||||
$account = FilePermissionHelper::describeWebServerAccount();
|
||||
|
||||
return match ($account['mode']) {
|
||||
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
|
||||
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
|
||||
default => $command,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
|
||||
*
|
||||
* @return bool root 실행 여부
|
||||
*/
|
||||
public static function isRootProcess(): bool
|
||||
{
|
||||
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 사유 코드에 대한 조치 힌트를 반환합니다.
|
||||
*
|
||||
* 사유별로 볼 곳이 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
|
||||
*
|
||||
* @param string $reason 사유 코드
|
||||
* @return string 조치 힌트
|
||||
*/
|
||||
private function reasonHint(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
|
||||
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
|
||||
.self::publishCommandHint(),
|
||||
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
|
||||
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
|
||||
default => '상세 메시지를 확인하세요.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 설정 상태 점검 커맨드 (#124)
|
||||
*
|
||||
* 프록시 뒤에서 신뢰 프록시가 설정되지 않으면 관리자 화면 자체가 뜨지 않을 수 있다. 화면으로
|
||||
* 확인할 수 없는 바로 그 상태에서 쓰는 통로가 이 커맨드다.
|
||||
*
|
||||
* 콘솔에는 요청이 없으므로 `isSecure()`·`ip()` 같은 **실측 항목은 판정할 수 없다.** 그 항목은
|
||||
* `판정 불가` 로 구분해 표시하고, 값이 비어 있는 것과 뭉뚱그리지 않는다 — 둘을 같은 문구로
|
||||
* 내보내면 운영자가 "설정이 비었다" 로 오독한다.
|
||||
*
|
||||
* 종료 코드: 설정이 없으면 1. 비-0 은 "커맨드 실행 실패" 가 아니라 **점검 대상 신호**다.
|
||||
* 다만 프록시를 쓰지 않는 직접 노출 구성에서는 미설정이 정상이므로, 그 사실을 출력에 명시한다.
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class TrustedProxyStatusCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'trusted-proxy:status';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '리버스 프록시 신뢰 설정(TRUSTED_PROXIES) 상태를 점검합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int 설정되어 있으면 0, 미설정이면 1
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// 콘솔에는 요청이 없다 — null 을 넘겨 실측 축을 not_applicable 로 받는다.
|
||||
$diagnostic = TrustedProxyDiagnostic::forRequest(null);
|
||||
|
||||
$this->line('');
|
||||
$this->line('<options=bold>리버스 프록시 신뢰 설정 상태</>');
|
||||
$this->line('');
|
||||
|
||||
$this->line(' 설정 파일 : config/trustedproxy.php');
|
||||
$this->line(' 환경변수 : TRUSTED_PROXIES');
|
||||
|
||||
if ($diagnostic['trusted_configured']) {
|
||||
$this->line(' 현재 값 : '.$diagnostic['configured_proxies']);
|
||||
} else {
|
||||
$this->line(' 현재 값 : <comment>미설정 (아무 프록시도 신뢰하지 않음)</comment>');
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->line(' <options=bold>요청 기반 실측</>');
|
||||
$this->line(' 수신 전달 헤더 : <comment>판정 불가 (콘솔에는 요청이 없습니다)</comment>');
|
||||
$this->line(' HTTPS 인식 : <comment>판정 불가</comment>');
|
||||
$this->line(' 방문자 IP : <comment>판정 불가</comment>');
|
||||
$this->line('');
|
||||
$this->comment(' 실측 축은 웹 요청에서만 판정됩니다 — 관리자 대시보드 알림 또는');
|
||||
$this->comment(' 환경설정 > 고급 의 진단 블록에서 확인하세요.');
|
||||
|
||||
$this->line('');
|
||||
|
||||
if ($diagnostic['trusted_configured']) {
|
||||
$this->info('신뢰 프록시가 설정되어 있습니다. X-Forwarded-* 헤더를 신뢰합니다.');
|
||||
$this->line('');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->warn('• 신뢰 프록시가 설정되어 있지 않습니다.');
|
||||
$this->line('');
|
||||
$this->line(' 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 구동 중이라면');
|
||||
$this->line(' .env 에 TRUSTED_PROXIES 를 지정하세요. 미설정 시 접속 주소·방문자 IP 가');
|
||||
$this->line(' 프록시 기준으로 인식되어 화면 표시·IP 기록·결제 통보 수신이 어긋납니다.');
|
||||
$this->line(' 프록시를 쓰지 않는 직접 노출 구성이라면 미설정이 정상입니다.');
|
||||
$this->line(' 상세: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md');
|
||||
$this->line('');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시(bake) 실패 예외.
|
||||
*
|
||||
* 사용자 대면 예외가 아니다 — `ExtensionStaticCacheService::publishVersion()` 의
|
||||
* 자체 catch 가 즉시 삼켜 Log::warning 진단으로만 남기고, 사이트는 API 폴백으로
|
||||
* 정상 동작한다. 따라서 메시지는 다국어 키가 아니라 운영자 로그용 진단 문자열이다.
|
||||
*/
|
||||
class StaticCachePublishException extends \RuntimeException {}
|
||||
@@ -1304,6 +1304,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
|
||||
*
|
||||
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
|
||||
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
|
||||
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
|
||||
*
|
||||
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
|
||||
*/
|
||||
public function getDeclaredAssetAbsolutePaths(): array
|
||||
{
|
||||
$assets = $this->getAssets();
|
||||
$result = [];
|
||||
|
||||
foreach (['js', 'css'] as $kind) {
|
||||
if (! empty($assets[$kind]['output'])) {
|
||||
$result[$kind] = $this->getModulePath().'/'.$assets[$kind]['output'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 스토리지 드라이버 인스턴스 반환
|
||||
*
|
||||
|
||||
@@ -1126,6 +1126,29 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
|
||||
*
|
||||
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
|
||||
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
|
||||
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
|
||||
*
|
||||
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
|
||||
*/
|
||||
public function getDeclaredAssetAbsolutePaths(): array
|
||||
{
|
||||
$assets = $this->getAssets();
|
||||
$result = [];
|
||||
|
||||
foreach (['js', 'css'] as $kind) {
|
||||
if (! empty($assets[$kind]['output'])) {
|
||||
$result[$kind] = $this->getPluginPath().'/'.$assets[$kind]['output'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Extension\Cache;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -48,15 +49,15 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
public function resolveKey(string $key): string
|
||||
{
|
||||
return $this->getPrefix() . ':' . $key;
|
||||
return $this->getPrefix().':'.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel Cache 스토어 인스턴스를 반환합니다.
|
||||
*
|
||||
* @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스
|
||||
* @return Repository 캐시 스토어 인스턴스
|
||||
*/
|
||||
protected function store(): \Illuminate\Cache\Repository
|
||||
protected function store(): Repository
|
||||
{
|
||||
return Cache::store($this->store);
|
||||
}
|
||||
@@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
$resolvedKey = $this->resolveKey($key);
|
||||
$ttl = $ttl ?? $this->getDefaultTtl();
|
||||
|
||||
return $this->store()->put($resolvedKey, $value, $ttl);
|
||||
try {
|
||||
return $this->store()->put($resolvedKey, $value, $ttl);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('put', $resolvedKey, $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
public function forget(string $key): bool
|
||||
{
|
||||
return $this->store()->forget($this->resolveKey($key));
|
||||
try {
|
||||
return $this->store()->forget($this->resolveKey($key));
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('forget', $this->resolveKey($key), $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Remember 패턴 ===
|
||||
@@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
|
||||
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
|
||||
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
|
||||
$result = $this->store()->remember($resolvedKey, $ttl, $callback);
|
||||
$this->recordKeyTags($resolvedKey, $allTags);
|
||||
//
|
||||
// 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다.
|
||||
// Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유:
|
||||
// put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지).
|
||||
// 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든
|
||||
// remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10).
|
||||
$cached = $this->store()->get($resolvedKey);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$result = $callback();
|
||||
|
||||
try {
|
||||
$this->store()->put($resolvedKey, $result, $ttl);
|
||||
$this->recordKeyTags($resolvedKey, $allTags);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('remember', $resolvedKey, $e);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
||||
{
|
||||
return $this->remember('query:' . $queryHash, $callback, $ttl, $tags);
|
||||
return $this->remember('query:'.$queryHash, $callback, $ttl, $tags);
|
||||
}
|
||||
|
||||
// === 벌크 연산 ===
|
||||
@@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
$resolved[$this->resolveKey($key)] = $value;
|
||||
}
|
||||
|
||||
return $this->store()->putMany($resolved, $ttl);
|
||||
try {
|
||||
return $this->store()->putMany($resolved, $ttl);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === 무효화 ===
|
||||
@@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
|
||||
// === 키 인덱스 (태그 미지원 드라이버용) ===
|
||||
|
||||
/**
|
||||
* 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft).
|
||||
*
|
||||
* 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례:
|
||||
* sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든
|
||||
* 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다
|
||||
* (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다).
|
||||
*
|
||||
* 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다.
|
||||
*
|
||||
* @param string $operation 실패한 연산 (put/forget/putMany/remember/index)
|
||||
* @param string $key 대상 키 (진단용)
|
||||
* @param \Throwable $e 원인 예외
|
||||
*/
|
||||
protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void
|
||||
{
|
||||
static $warned = [];
|
||||
|
||||
$signature = $operation.'|'.$e->getMessage();
|
||||
if (isset($warned[$signature])) {
|
||||
return;
|
||||
}
|
||||
$warned[$signature] = true;
|
||||
|
||||
try {
|
||||
Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [
|
||||
'operation' => $operation,
|
||||
'key' => $key,
|
||||
'store' => $this->store,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키-태그 매핑을 인덱스에 기록합니다.
|
||||
*
|
||||
@@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
private function flushByIndex(): bool
|
||||
{
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
try {
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
|
||||
foreach (array_keys($index) as $key) {
|
||||
$this->store()->forget($key);
|
||||
foreach (array_keys($index) as $key) {
|
||||
$this->store()->forget($key);
|
||||
}
|
||||
|
||||
$this->store()->forget($indexKey);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('flush', $this->getIndexKey(), $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store()->forget($indexKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
private function flushTagsByIndex(array $tags): bool
|
||||
{
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
$tagsSet = array_flip($tags);
|
||||
try {
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
$tagsSet = array_flip($tags);
|
||||
|
||||
foreach ($index as $key => $keyTags) {
|
||||
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
|
||||
$this->store()->forget($key);
|
||||
unset($index[$key]);
|
||||
foreach ($index as $key => $keyTags) {
|
||||
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
|
||||
$this->store()->forget($key);
|
||||
unset($index[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->store()->put($indexKey, $index, 86400 * 30);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warnWriteFailure('flushTags', implode(',', $tags), $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store()->put($indexKey, $index, 86400 * 30);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface
|
||||
*/
|
||||
private function getIndexKey(): string
|
||||
{
|
||||
return 'g7:_idx:' . $this->getPrefix();
|
||||
return 'g7:_idx:'.$this->getPrefix();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -326,6 +344,11 @@ class ExtensionPendingHelper
|
||||
?\Closure $onProgress = null
|
||||
): void {
|
||||
File::ensureDirectoryExists($dest, 0775);
|
||||
// 디렉토리도 부모 소유권을 상속시킨다 — 파일만 `copyFile` 로 상속시키면 sudo 로
|
||||
// 실행된 설치/업데이트가 만든 **디렉토리**가 root 소유로 남아, 이후 웹 프로세스의
|
||||
// 쓰기가 그 디렉토리에서 막힌다. 형제 구현 `ExtensionBackupHelper::
|
||||
// copyDirectoryWithProgress` 는 이미 같은 방어를 갖고 있다 (계층 불균형 해소).
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
||||
|
||||
foreach ($items as $item) {
|
||||
@@ -366,6 +389,7 @@ class ExtensionPendingHelper
|
||||
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
|
||||
{
|
||||
File::ensureDirectoryExists($dest, 0775);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||
|
||||
$failed = [];
|
||||
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
|
||||
@@ -379,7 +403,7 @@ class ExtensionPendingHelper
|
||||
}
|
||||
|
||||
$staleFailures = [];
|
||||
self::removeStaleEntries($source, $dest, $staleFailures);
|
||||
self::removeStaleEntries($source, $dest, $staleFailures, true);
|
||||
|
||||
if (! empty($staleFailures)) {
|
||||
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
|
||||
@@ -389,6 +413,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
|
||||
*
|
||||
@@ -406,6 +464,9 @@ class ExtensionPendingHelper
|
||||
array &$failed
|
||||
): void {
|
||||
File::ensureDirectoryExists($dest, 0775);
|
||||
// 제자리 동기화 폴백 경로도 동일 방어 — rename 경로만 고치면 파일 잠금으로
|
||||
// 이 경로로 떨어진 교체에서만 소유권이 조용히 어긋난다.
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
||||
|
||||
foreach ($items as $item) {
|
||||
@@ -550,7 +611,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 +620,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 +693,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -685,6 +822,31 @@ class ExtensionPendingHelper
|
||||
return $basePath.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.$identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 업데이트용 임시 디렉토리(다운로드·추출)를 부모 소유권까지 정합화해 확보합니다.
|
||||
*
|
||||
* 세 매니저(모듈·플러그인·템플릿)가 같은 경로 규약(`storage/app/temp/{type}_update_{uid}`)을 쓰므로
|
||||
* 확보 절차도 한 곳에 둔다 — 복사본은 서로 다른 하드닝을 갖고 갈라진다.
|
||||
*
|
||||
* @param string $tempDir 임시 디렉토리 절대 경로
|
||||
*/
|
||||
public static function ensureUpdateTempDirectory(string $tempDir): void
|
||||
{
|
||||
// 부모(`storage/app/temp`)는 최초 1회만 만들어지고 자식만 삭제된다 — sudo 코어 업데이트의
|
||||
// 번들 확장 업데이트가 그 최초 생성자면 부모가 root 소유로 굳어, 이후 관리자 화면(웹 계정)의
|
||||
// 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14). 부모는 소유권 상속·그룹 쓰기까지
|
||||
// 정합화하는 프리미티브로 확보하고, 자식은 만든 뒤 부모 소유권을 상속시킨다.
|
||||
// 확보 실패는 종전 동작(`ensureDirectoryExists` 의 예외 흐름)으로 폴백해 계약을 바꾸지 않는다.
|
||||
if (! FilePermissionHelper::ensureWritableDirectory(dirname($tempDir))) {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 스테이징용 타임스탬프 디렉토리를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -387,6 +387,122 @@ class FilePermissionHelper
|
||||
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 존재하는 디렉토리의 퍼미션·소유권을 umask 와 무관하게 정합화합니다.
|
||||
*
|
||||
* `File::ensureDirectoryExists($dir, 0775)` 등 생성 API 의 mode 인자는 **umask 로 깎인다** —
|
||||
* umask 022 환경에서는 0775 요청이 0755 로 만들어져 그룹 공유(웹 계정)가 쓸 수 없다.
|
||||
* 명시 `chmod` 로 umask 를 무력화하고, POSIX 에서는 setgid 를 세워 그 아래에 만들어지는
|
||||
* 하위 디렉토리가 그룹을 상속하게 한다. setgid 가 없으면 CLI(스케줄러/큐)가 먼저 만든
|
||||
* 하위 디렉토리를 웹 프로세스가 쓰지 못한다. Windows 에는 setgid 개념이 없어 제외한다.
|
||||
*
|
||||
* 마지막으로 부모 소유권을 상속시켜 sudo/CLI 계정 고정을 막는다.
|
||||
*
|
||||
* @param string $path 대상 디렉토리 절대 경로
|
||||
* @param int $mode 적용할 퍼미션 (예: 0775)
|
||||
*/
|
||||
public static function hardenDirectory(string $path, int $mode = 0775): void
|
||||
{
|
||||
@chmod($path, $mode);
|
||||
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
@chmod($path, $mode | 02000);
|
||||
}
|
||||
|
||||
static::inheritOwnershipFromParent($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 가능한 디렉토리를 확보합니다 — 없으면 만들고, 권한을 정합화한 뒤 실제 쓰기 가능 여부를 판정합니다.
|
||||
*
|
||||
* 제3자 라이브러리에 넘길 캐시·임시 디렉토리처럼 "확보하지 못하면 그 기능만 끄면 되는"
|
||||
* 자리를 위한 프리미티브다. **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()`
|
||||
* 는 `mkdir()` 을 억제 없이 호출하므로 생성 실패가 `E_WARNING` 으로 나오고 Laravel
|
||||
* `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 쓰기 경로를
|
||||
* 지정하는 목적 자체가 그 500 을 막는 것이므로, 확보 실패가 다시 500 을 내면 무의미하다.
|
||||
*
|
||||
* 실패 정책은 호출부가 정한다 — 조용히 성능 저하로 이어갈지(정의 캐시), 시끄럽게 실패로
|
||||
* 처리할지(정적 게시)가 자리마다 다르기 때문이다. 사유는 `$failure` out 파라미터로 올린다.
|
||||
*
|
||||
* @param string $path 확보할 디렉토리 절대 경로
|
||||
* @param int $mode 생성 시 적용할 퍼미션
|
||||
* @param array{reason: string, path: string}|null $failure out — 실패 사유와 그 대상 경로.
|
||||
* reason 은 `occupied_by_file`(경로가 파일로 점유) /
|
||||
* `ancestor_not_writable`(실재하는 최근접 상위가 쓰기 불가) /
|
||||
* `create_failed`(생성 실패) / `not_writable`(존재하나 쓰기 불가)
|
||||
* @return bool 확보 성공 여부
|
||||
*/
|
||||
public static function ensureWritableDirectory(string $path, int $mode = 0775, ?array &$failure = null): bool
|
||||
{
|
||||
$failure = null;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
// 같은 이름의 파일이 자리를 차지하면 mkdir 이 경고를 낸다 — 먼저 걸러낸다.
|
||||
if (file_exists($path)) {
|
||||
$failure = ['reason' => 'occupied_by_file', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상위가 쓰기 불가라면 생성 자체가 불가능하다. 여기서 끊어야 호출부가 "무엇을
|
||||
// 고쳐야 하는지"(대상 디렉토리가 아니라 그 상위)를 운영자에게 지목할 수 있다.
|
||||
$ancestor = static::nearestExistingAncestor($path);
|
||||
|
||||
if ($ancestor === null || ! is_writable($ancestor)) {
|
||||
$failure = ['reason' => 'ancestor_not_writable', 'path' => $ancestor ?? dirname($path)];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// force 인자로 경고를 억제한다. 실패는 아래 판정이 흡수한다.
|
||||
File::makeDirectory($path, $mode, true, true);
|
||||
|
||||
if (is_dir($path)) {
|
||||
static::hardenDirectory($path, $mode);
|
||||
}
|
||||
}
|
||||
|
||||
// 방금 만든 경로의 stat 은 캐시돼 있을 수 있다 — 판정 전에 비운다.
|
||||
clearstatcache(true, $path);
|
||||
|
||||
if (! is_dir($path)) {
|
||||
$failure = ['reason' => 'create_failed', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! is_writable($path)) {
|
||||
$failure = ['reason' => 'not_writable', 'path' => $path];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
|
||||
*
|
||||
* @param string $path 기준 경로
|
||||
* @return string|null 실재하는 최근접 상위 (루트까지 없으면 null)
|
||||
*/
|
||||
public static function nearestExistingAncestor(string $path): ?string
|
||||
{
|
||||
$current = dirname($path);
|
||||
|
||||
while (! File::isDirectory($current)) {
|
||||
$parent = dirname($current);
|
||||
|
||||
if ($parent === $current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $parent;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
|
||||
*
|
||||
@@ -450,6 +566,73 @@ class FilePermissionHelper
|
||||
return [$baseOwner, $baseGroup, 'base_path (대칭 구성)'];
|
||||
}
|
||||
|
||||
/** 테스트 전용 — `describeWebServerAccount()` 판정 오버라이드 (null = 실판정) */
|
||||
private static ?array $webServerAccountForTesting = null;
|
||||
|
||||
/**
|
||||
* 테스트 전용 — 실행 환경 판정을 강제합니다 (posix 부재 환경에서 root 분기를 재현하기 위해).
|
||||
*
|
||||
* @param array{mode: string, name: string|null}|null $account 강제할 판정 (null 로 실판정 복귀)
|
||||
*/
|
||||
public static function fakeWebServerAccountForTesting(?array $account): void
|
||||
{
|
||||
self::$webServerAccountForTesting = $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스 기준으로 "웹서버 계정" 안내에 필요한 실행 환경을 분류합니다.
|
||||
*
|
||||
* root(sudo) 로 artisan 을 실행하면 그 명령이 만드는 캐시 샤드·번들·임시 파일이 root 소유로 남아
|
||||
* 이후 웹 프로세스의 쓰기가 실패한다. 그래서 root 실행을 감지한 명령은 "웹서버 계정으로 실행"
|
||||
* 을 안내해야 하는데, 그 판정을 명령마다 다시 쓰면 한 곳만 어긋나도 서로 다른 안내가 나간다.
|
||||
* `core:update` 의 재실행 안내가 처음 세운 4분기를 그대로 옮겨 공유한다 — 동작 불변.
|
||||
*
|
||||
* 반환 모드:
|
||||
* - `non_root` : posix 미지원(Windows 등) 또는 현재 유효 사용자가 root 가 아님
|
||||
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
|
||||
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
|
||||
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
|
||||
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
|
||||
*
|
||||
* 웹서버 계정은 `inferWebServerOwnership()` 이 storage/bootstrap 쓰기 영역 소유자로 추정한다.
|
||||
*
|
||||
* @return array{mode: string, name: string|null} 모드와 웹서버 계정명(`root_web_known` 일 때만)
|
||||
*/
|
||||
public static function describeWebServerAccount(): array
|
||||
{
|
||||
if (self::$webServerAccountForTesting !== null) {
|
||||
return self::$webServerAccountForTesting;
|
||||
}
|
||||
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return ['mode' => 'non_root', 'name' => null];
|
||||
}
|
||||
|
||||
if (posix_geteuid() !== 0) {
|
||||
return ['mode' => 'non_root', 'name' => null];
|
||||
}
|
||||
|
||||
[$owner] = static::inferWebServerOwnership();
|
||||
|
||||
if ($owner === false) {
|
||||
return ['mode' => 'root_web_unknown', 'name' => null];
|
||||
}
|
||||
|
||||
if ($owner === 0) {
|
||||
return ['mode' => 'root_web_symmetric', 'name' => null];
|
||||
}
|
||||
|
||||
$entry = posix_getpwuid($owner);
|
||||
$name = $entry['name'] ?? null;
|
||||
|
||||
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
|
||||
if ($name === null) {
|
||||
return ['mode' => 'root_web_unknown', 'name' => null];
|
||||
}
|
||||
|
||||
return ['mode' => 'root_web_known', 'name' => $name];
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -356,9 +357,13 @@ class SettingsMigrator
|
||||
return false;
|
||||
}
|
||||
|
||||
// 디렉토리 확인 및 생성
|
||||
// 디렉토리 확인 및 생성 — 파일(`writeJsonFile`)과 대칭으로 디렉토리도 부모 소유권을 상속한다.
|
||||
// sudo 코어 업데이트의 업그레이드 스텝이 이 디렉토리를 root 로 만들면 `storage/app/{modules,
|
||||
// plugins}` 는 restore_ownership 의도적 제외 경로라 코어 업데이트로도 되돌려지지 않고, 이후
|
||||
// 웹 프로세스의 그 모듈 설정 저장이 영구 실패한다 (#651 F13).
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 카테고리 파일 생성
|
||||
@@ -406,10 +411,10 @@ class SettingsMigrator
|
||||
private function getSettingsDir(): string
|
||||
{
|
||||
if ($this->type === 'module') {
|
||||
return storage_path('app/modules/'.$this->identifier.'/settings');
|
||||
return ExtensionStoragePath::module($this->identifier, 'settings');
|
||||
}
|
||||
|
||||
return storage_path('app/plugins/'.$this->identifier.'/settings');
|
||||
return ExtensionStoragePath::plugin($this->identifier, 'settings');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+181
-149
@@ -21,11 +21,13 @@ 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;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Extension\Helpers\IdentityMessageSyncHelper;
|
||||
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
||||
@@ -43,6 +45,7 @@ use App\Models\Template;
|
||||
use App\Providers\CoreServiceProvider;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -372,168 +375,186 @@ 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);
|
||||
|
||||
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
|
||||
$module = $this->getModule($moduleName);
|
||||
if (! $module) {
|
||||
// 복사 후 재로드 시도
|
||||
$this->reloadModule($moduleName);
|
||||
$module = $this->getModule($moduleName);
|
||||
}
|
||||
|
||||
if (! $module) {
|
||||
throw new \Exception(__('modules.not_found', ['module' => $moduleName]));
|
||||
}
|
||||
|
||||
// 그누보드7 코어 버전 호환성 검증
|
||||
CoreVersionChecker::validateExtension(
|
||||
$module->getRequiredCoreVersion(),
|
||||
$module->getIdentifier(),
|
||||
'module'
|
||||
);
|
||||
|
||||
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
$this->checkDependencies($module);
|
||||
|
||||
// 권한 구조 검증 (계층형 구조 필수)
|
||||
$this->validatePermissionStructure($module, 'module');
|
||||
|
||||
// 언어 파일 경로 검증 (src/lang 경로 필수)
|
||||
$this->validateTranslationPath($module, 'module');
|
||||
|
||||
// SEO 변수명 중복 검증
|
||||
$this->validateSeoVariables($module, 'module');
|
||||
|
||||
// 모듈 설치 실행
|
||||
$module->clearLifecycleFailureReason();
|
||||
$result = $module->install();
|
||||
|
||||
if (! $result) {
|
||||
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
||||
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
||||
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
||||
$this->runMigrations($module);
|
||||
|
||||
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
|
||||
$module = $this->getModule($moduleName);
|
||||
if (! $module) {
|
||||
// 복사 후 재로드 시도
|
||||
$this->reloadModule($moduleName);
|
||||
$module = $this->getModule($moduleName);
|
||||
}
|
||||
|
||||
// GitHub에서 최신 버전 정보 가져오기
|
||||
$latestVersion = $this->fetchLatestVersion($module);
|
||||
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
|
||||
if (! $module) {
|
||||
throw new \Exception(__('modules.not_found', ['module' => $moduleName]));
|
||||
}
|
||||
|
||||
// 다국어 name, description 처리 (역호환성 지원)
|
||||
$name = $this->convertToMultilingual($module->getName());
|
||||
$description = $this->convertToMultilingual($module->getDescription());
|
||||
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"module.{$module->getIdentifier()}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
|
||||
// 데이터베이스에 모듈 정보 저장
|
||||
$this->moduleRepository->updateOrCreate(
|
||||
['identifier' => $module->getIdentifier()],
|
||||
[
|
||||
'vendor' => $module->getVendor(),
|
||||
'name' => $name,
|
||||
'version' => $module->getVersion(),
|
||||
'latest_version' => $latestVersion,
|
||||
'description' => $description,
|
||||
'github_url' => $module->getGithubUrl(),
|
||||
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
|
||||
'update_available' => $updateAvailable,
|
||||
'metadata' => $module->getMetadata(),
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'vendor_mode' => $resolvedVendorMode->value,
|
||||
'config' => $module->getConfig(),
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
// 그누보드7 코어 버전 호환성 검증
|
||||
CoreVersionChecker::validateExtension(
|
||||
$module->getRequiredCoreVersion(),
|
||||
$module->getIdentifier(),
|
||||
'module'
|
||||
);
|
||||
|
||||
// Role 자동 생성
|
||||
$this->createModuleRoles($module);
|
||||
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
$this->checkDependencies($module);
|
||||
|
||||
// 권한 자동 생성
|
||||
$this->createModulePermissions($module);
|
||||
// 권한 구조 검증 (계층형 구조 필수)
|
||||
$this->validatePermissionStructure($module, 'module');
|
||||
|
||||
// 권한-Role 연결
|
||||
$this->assignPermissionsToRoles($module);
|
||||
// 언어 파일 경로 검증 (src/lang 경로 필수)
|
||||
$this->validateTranslationPath($module, 'module');
|
||||
|
||||
// 관리자 메뉴 자동 생성
|
||||
$this->createModuleMenus($module);
|
||||
// SEO 변수명 중복 검증
|
||||
$this->validateSeoVariables($module, 'module');
|
||||
|
||||
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
||||
$this->syncModuleIdentityPolicies($module);
|
||||
// 모듈 설치 실행
|
||||
$module->clearLifecycleFailureReason();
|
||||
$result = $module->install();
|
||||
|
||||
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
|
||||
$this->syncModuleIdentityMessages($module);
|
||||
if (! $result) {
|
||||
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
|
||||
|
||||
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
||||
$this->syncModuleNotificationDefinitions($module);
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
||||
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
||||
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
||||
$this->runMigrations($module);
|
||||
|
||||
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// GitHub에서 최신 버전 정보 가져오기
|
||||
$latestVersion = $this->fetchLatestVersion($module);
|
||||
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
|
||||
|
||||
// 다국어 name, description 처리 (역호환성 지원)
|
||||
$name = $this->convertToMultilingual($module->getName());
|
||||
$description = $this->convertToMultilingual($module->getDescription());
|
||||
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"module.{$module->getIdentifier()}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
|
||||
// 데이터베이스에 모듈 정보 저장
|
||||
$this->moduleRepository->updateOrCreate(
|
||||
['identifier' => $module->getIdentifier()],
|
||||
[
|
||||
'vendor' => $module->getVendor(),
|
||||
'name' => $name,
|
||||
'version' => $module->getVersion(),
|
||||
'latest_version' => $latestVersion,
|
||||
'description' => $description,
|
||||
'github_url' => $module->getGithubUrl(),
|
||||
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
|
||||
'update_available' => $updateAvailable,
|
||||
'metadata' => $module->getMetadata(),
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'vendor_mode' => $resolvedVendorMode->value,
|
||||
'config' => $module->getConfig(),
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// Role 자동 생성
|
||||
$this->createModuleRoles($module);
|
||||
|
||||
// 권한 자동 생성
|
||||
$this->createModulePermissions($module);
|
||||
|
||||
// 권한-Role 연결
|
||||
$this->assignPermissionsToRoles($module);
|
||||
|
||||
// 관리자 메뉴 자동 생성
|
||||
$this->createModuleMenus($module);
|
||||
|
||||
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
||||
$this->syncModuleIdentityPolicies($module);
|
||||
|
||||
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
|
||||
$this->syncModuleIdentityMessages($module);
|
||||
|
||||
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
||||
$this->syncModuleNotificationDefinitions($module);
|
||||
|
||||
DB::commit();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Phase 3: 시더 실행 (트랜잭션 외부)
|
||||
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
|
||||
$onProgress?->__invoke('seed', '시더 실행 중...');
|
||||
$this->runModuleSeeders($module);
|
||||
|
||||
// Phase 4: 기본 설정 파일 생성
|
||||
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
|
||||
$this->initializeModuleSettings($module);
|
||||
|
||||
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
|
||||
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
|
||||
if (! $composerDoneInPending) {
|
||||
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
|
||||
if (! app()->environment('testing')
|
||||
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
|
||||
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
|
||||
if (! $composerResult) {
|
||||
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
|
||||
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
||||
$this->extensionManager->updateComposerAutoload();
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 모듈 설치 완료
|
||||
HookManager::doAction('core.modules.installed', $moduleName);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
|
||||
$rollbackActivePath,
|
||||
$rollbackDirExisted,
|
||||
$moduleName,
|
||||
'module',
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Phase 3: 시더 실행 (트랜잭션 외부)
|
||||
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
|
||||
$onProgress?->__invoke('seed', '시더 실행 중...');
|
||||
$this->runModuleSeeders($module);
|
||||
|
||||
// Phase 4: 기본 설정 파일 생성
|
||||
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
|
||||
$this->initializeModuleSettings($module);
|
||||
|
||||
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
|
||||
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
|
||||
if (! $composerDoneInPending) {
|
||||
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
|
||||
if (! app()->environment('testing')
|
||||
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
|
||||
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
|
||||
if (! $composerResult) {
|
||||
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
|
||||
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
||||
$this->extensionManager->updateComposerAutoload();
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 모듈 설치 완료
|
||||
HookManager::doAction('core.modules.installed', $moduleName);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -905,6 +926,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 +938,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 +1071,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()]);
|
||||
@@ -1764,7 +1793,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
}
|
||||
|
||||
$identifier = $module->getIdentifier();
|
||||
$settingsDir = storage_path('app/modules/'.$identifier.'/settings');
|
||||
$settingsDir = ExtensionStoragePath::module($identifier, 'settings');
|
||||
|
||||
// 이미 환경설정 디렉토리가 있고 파일이 있으면 스킵 (재설치 시 덮어쓰기 방지)
|
||||
if (File::isDirectory($settingsDir) && count(File::files($settingsDir)) > 0) {
|
||||
@@ -1801,9 +1830,11 @@ class ModuleManager implements ModuleManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 디렉토리 생성
|
||||
// 디렉토리 생성 — sudo 코어 업데이트(번들 확장 업데이트 프롬프트) 경로에서 root 로 만들어지면
|
||||
// `storage/app/modules` 는 restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 카테고리별로 설정 파일 생성
|
||||
@@ -1818,6 +1849,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
$jsonContent = json_encode($categoryData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($filePath, $jsonContent);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($filePath);
|
||||
$createdFiles[] = $category.'.json';
|
||||
}
|
||||
|
||||
@@ -1888,7 +1920,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
*/
|
||||
protected function deleteModuleStorage(ModuleInterface $module): void
|
||||
{
|
||||
$moduleStoragePath = storage_path('app/modules/'.$module->getIdentifier());
|
||||
$moduleStoragePath = ExtensionStoragePath::module($module->getIdentifier());
|
||||
|
||||
if (! File::isDirectory($moduleStoragePath)) {
|
||||
Log::info('삭제할 모듈 스토리지 디렉토리가 없습니다.', [
|
||||
@@ -1973,7 +2005,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/modules/'.$identifier)
|
||||
ExtensionStoragePath::module($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
@@ -4112,7 +4144,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$tempDir = storage_path('app/temp/module_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
|
||||
+179
-148
@@ -21,11 +21,13 @@ 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;
|
||||
use App\Extension\Helpers\ExtensionStatusGuard;
|
||||
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use App\Extension\Helpers\IdentityMessageSyncHelper;
|
||||
use App\Extension\Helpers\IdentityPolicySyncHelper;
|
||||
@@ -44,6 +46,7 @@ use App\Providers\CoreServiceProvider;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use App\Support\AssetUrl;
|
||||
use App\Support\ExtensionStoragePath;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -357,167 +360,184 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 플러그인 설치 시)
|
||||
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
|
||||
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
|
||||
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
|
||||
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
|
||||
$rollbackDirExisted = File::isDirectory($activePath);
|
||||
|
||||
$onProgress?->__invoke('copy', '파일 복사 중...');
|
||||
$this->copyFromPendingOrBundled($pluginName, $onProgress, $force);
|
||||
|
||||
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
if (! $plugin) {
|
||||
// 복사 후 재로드 시도
|
||||
$this->reloadPlugin($pluginName);
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
}
|
||||
|
||||
if (! $plugin) {
|
||||
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
|
||||
}
|
||||
|
||||
// 그누보드7 코어 버전 호환성 검증
|
||||
CoreVersionChecker::validateExtension(
|
||||
$plugin->getRequiredCoreVersion(),
|
||||
$plugin->getIdentifier(),
|
||||
'plugin'
|
||||
);
|
||||
|
||||
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
|
||||
$this->checkDependencies($plugin);
|
||||
|
||||
// 언어 파일 경로 검증 (lang 경로 필수)
|
||||
$this->validateTranslationPath($plugin, 'plugin');
|
||||
|
||||
// SEO 변수명 중복 검증
|
||||
$this->validateSeoVariables($plugin, 'plugin');
|
||||
|
||||
// 플러그인 설치 실행
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
$result = $plugin->install();
|
||||
|
||||
if (! $result) {
|
||||
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
||||
}
|
||||
|
||||
if (! $result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
||||
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
||||
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
||||
$this->runMigrations($plugin);
|
||||
|
||||
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
if (! $plugin) {
|
||||
// 복사 후 재로드 시도
|
||||
$this->reloadPlugin($pluginName);
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
}
|
||||
|
||||
// GitHub에서 최신 버전 정보 가져오기
|
||||
$latestVersion = $this->fetchLatestVersion($plugin);
|
||||
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
|
||||
if (! $plugin) {
|
||||
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
|
||||
}
|
||||
|
||||
// 다국어 name, description 처리 (역호환성 지원)
|
||||
$name = $this->convertToMultilingual($plugin->getName());
|
||||
$description = $this->convertToMultilingual($plugin->getDescription());
|
||||
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"plugin.{$plugin->getIdentifier()}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
|
||||
// 데이터베이스에 플러그인 정보 저장
|
||||
$this->pluginRepository->updateOrCreate(
|
||||
['identifier' => $plugin->getIdentifier()],
|
||||
[
|
||||
'vendor' => $plugin->getVendor(),
|
||||
'name' => $name,
|
||||
'version' => $plugin->getVersion(),
|
||||
'latest_version' => $latestVersion,
|
||||
'description' => $description,
|
||||
'github_url' => $plugin->getGithubUrl(),
|
||||
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
|
||||
'update_available' => $updateAvailable,
|
||||
'metadata' => $plugin->getMetadata(),
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'vendor_mode' => $resolvedVendorMode->value,
|
||||
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
// 그누보드7 코어 버전 호환성 검증
|
||||
CoreVersionChecker::validateExtension(
|
||||
$plugin->getRequiredCoreVersion(),
|
||||
$plugin->getIdentifier(),
|
||||
'plugin'
|
||||
);
|
||||
|
||||
// Role 자동 생성
|
||||
$this->createPluginRoles($plugin);
|
||||
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
|
||||
$this->checkDependencies($plugin);
|
||||
|
||||
// 권한 자동 생성
|
||||
$this->createPluginPermissions($plugin);
|
||||
// 언어 파일 경로 검증 (lang 경로 필수)
|
||||
$this->validateTranslationPath($plugin, 'plugin');
|
||||
|
||||
// 권한-Role 연결
|
||||
$this->assignPermissionsToRoles($plugin);
|
||||
// SEO 변수명 중복 검증
|
||||
$this->validateSeoVariables($plugin, 'plugin');
|
||||
|
||||
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
|
||||
$this->createPluginMenus($plugin);
|
||||
// 플러그인 설치 실행
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
$result = $plugin->install();
|
||||
|
||||
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
||||
$this->syncPluginIdentityPolicies($plugin);
|
||||
if (! $result) {
|
||||
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
||||
}
|
||||
|
||||
// IDV 메시지 정의/템플릿 자동 동기화
|
||||
$this->syncPluginIdentityMessages($plugin);
|
||||
if (! $result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
||||
$this->syncPluginNotificationDefinitions($plugin);
|
||||
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
||||
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
||||
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
||||
$this->runMigrations($plugin);
|
||||
|
||||
DB::commit();
|
||||
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// GitHub에서 최신 버전 정보 가져오기
|
||||
$latestVersion = $this->fetchLatestVersion($plugin);
|
||||
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
|
||||
|
||||
// 다국어 name, description 처리 (역호환성 지원)
|
||||
$name = $this->convertToMultilingual($plugin->getName());
|
||||
$description = $this->convertToMultilingual($plugin->getDescription());
|
||||
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"plugin.{$plugin->getIdentifier()}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
|
||||
// 데이터베이스에 플러그인 정보 저장
|
||||
$this->pluginRepository->updateOrCreate(
|
||||
['identifier' => $plugin->getIdentifier()],
|
||||
[
|
||||
'vendor' => $plugin->getVendor(),
|
||||
'name' => $name,
|
||||
'version' => $plugin->getVersion(),
|
||||
'latest_version' => $latestVersion,
|
||||
'description' => $description,
|
||||
'github_url' => $plugin->getGithubUrl(),
|
||||
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
|
||||
'update_available' => $updateAvailable,
|
||||
'metadata' => $plugin->getMetadata(),
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'vendor_mode' => $resolvedVendorMode->value,
|
||||
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// Role 자동 생성
|
||||
$this->createPluginRoles($plugin);
|
||||
|
||||
// 권한 자동 생성
|
||||
$this->createPluginPermissions($plugin);
|
||||
|
||||
// 권한-Role 연결
|
||||
$this->assignPermissionsToRoles($plugin);
|
||||
|
||||
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
|
||||
$this->createPluginMenus($plugin);
|
||||
|
||||
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
||||
$this->syncPluginIdentityPolicies($plugin);
|
||||
|
||||
// IDV 메시지 정의/템플릿 자동 동기화
|
||||
$this->syncPluginIdentityMessages($plugin);
|
||||
|
||||
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
||||
$this->syncPluginNotificationDefinitions($plugin);
|
||||
|
||||
DB::commit();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Phase 3: 시더 실행 (트랜잭션 외부)
|
||||
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
|
||||
$onProgress?->__invoke('seed', '시더 실행 중...');
|
||||
$this->runPluginSeeders($plugin);
|
||||
|
||||
// Phase 4: 기본 설정 파일 생성
|
||||
$onProgress?->__invoke('settings', '설정 초기화 중...');
|
||||
$this->initializePluginSettings($plugin);
|
||||
|
||||
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
|
||||
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
|
||||
if (! $composerDoneInPending) {
|
||||
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
|
||||
if (! app()->environment('testing')
|
||||
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
|
||||
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
|
||||
if (! $composerResult) {
|
||||
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
|
||||
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
||||
$this->extensionManager->updateComposerAutoload();
|
||||
|
||||
// Phase 6: 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 플러그인 설치 완료
|
||||
HookManager::doAction('core.plugins.installed', $pluginName);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
|
||||
$activePath,
|
||||
$rollbackDirExisted,
|
||||
$pluginName,
|
||||
'plugin',
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Phase 3: 시더 실행 (트랜잭션 외부)
|
||||
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
|
||||
$onProgress?->__invoke('seed', '시더 실행 중...');
|
||||
$this->runPluginSeeders($plugin);
|
||||
|
||||
// Phase 4: 기본 설정 파일 생성
|
||||
$onProgress?->__invoke('settings', '설정 초기화 중...');
|
||||
$this->initializePluginSettings($plugin);
|
||||
|
||||
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
|
||||
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
|
||||
if (! $composerDoneInPending) {
|
||||
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
|
||||
if (! app()->environment('testing')
|
||||
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
|
||||
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
|
||||
if (! $composerResult) {
|
||||
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
|
||||
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
||||
$this->extensionManager->updateComposerAutoload();
|
||||
|
||||
// Phase 6: 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 플러그인 설치 완료
|
||||
HookManager::doAction('core.plugins.installed', $pluginName);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -932,6 +952,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 +964,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 +1100,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()]);
|
||||
@@ -2584,7 +2612,7 @@ class PluginManager implements PluginManagerInterface
|
||||
protected function initializePluginSettings(PluginInterface $plugin): void
|
||||
{
|
||||
$identifier = $plugin->getIdentifier();
|
||||
$settingsDir = storage_path("app/plugins/{$identifier}/settings");
|
||||
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
|
||||
$settingsPath = $settingsDir.'/setting.json';
|
||||
|
||||
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
|
||||
@@ -2610,14 +2638,17 @@ class PluginManager implements PluginManagerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// 디렉토리 생성
|
||||
// 디렉토리 생성 — sudo 코어 업데이트 경로에서 root 로 만들어지면 `storage/app/plugins` 는
|
||||
// restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
|
||||
if (! File::isDirectory($settingsDir)) {
|
||||
File::makeDirectory($settingsDir, 0755, true);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
|
||||
}
|
||||
|
||||
// 기본값 저장
|
||||
$content = json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
File::put($settingsPath, $content);
|
||||
FilePermissionHelper::inheritOwnershipFromParent($settingsPath);
|
||||
|
||||
Log::info('플러그인 기본 설정 파일 생성 완료', [
|
||||
'plugin' => $identifier,
|
||||
@@ -2665,7 +2696,7 @@ class PluginManager implements PluginManagerInterface
|
||||
protected function deletePluginSettingsDirectory(PluginInterface $plugin): void
|
||||
{
|
||||
$identifier = $plugin->getIdentifier();
|
||||
$pluginStorageDir = storage_path("app/plugins/{$identifier}");
|
||||
$pluginStorageDir = ExtensionStoragePath::plugin($identifier);
|
||||
|
||||
if (File::isDirectory($pluginStorageDir)) {
|
||||
File::deleteDirectory($pluginStorageDir);
|
||||
@@ -2735,7 +2766,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/plugins/'.$identifier)
|
||||
ExtensionStoragePath::plugin($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
@@ -4342,7 +4373,7 @@ class PluginManager implements PluginManagerInterface
|
||||
$tempDir = storage_path('app/temp/plugin_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
|
||||
@@ -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,79 +442,90 @@ class TemplateManager implements TemplateManagerInterface
|
||||
// 2. 검증
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
|
||||
return DB::transaction(function () use ($templateName, $onProgress) {
|
||||
$template = $this->getTemplate($templateName);
|
||||
if (! $template) {
|
||||
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
|
||||
}
|
||||
try {
|
||||
return DB::transaction(function () use ($templateName, $onProgress) {
|
||||
$template = $this->getTemplate($templateName);
|
||||
if (! $template) {
|
||||
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
|
||||
}
|
||||
|
||||
// 의존성 확인
|
||||
$this->checkDependencies($template);
|
||||
// 의존성 확인
|
||||
$this->checkDependencies($template);
|
||||
|
||||
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
|
||||
$this->validateSeoConfig($templateName);
|
||||
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
|
||||
$this->validateSeoConfig($templateName);
|
||||
|
||||
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
|
||||
$this->validateLayouts($templateName);
|
||||
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
|
||||
$this->validateLayouts($templateName);
|
||||
|
||||
// name과 description 다국어 변환 (역호환성)
|
||||
$name = $this->convertToMultilingual($template['name']);
|
||||
$description = $this->convertToMultilingual($template['description'] ?? '');
|
||||
// name과 description 다국어 변환 (역호환성)
|
||||
$name = $this->convertToMultilingual($template['name']);
|
||||
$description = $this->convertToMultilingual($template['description'] ?? '');
|
||||
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"template.{$templateName}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
|
||||
$manifest = HookManager::applyFilters(
|
||||
"template.{$templateName}.manifest.translations",
|
||||
['name' => $name, 'description' => $description]
|
||||
);
|
||||
$name = $manifest['name'] ?? $name;
|
||||
$description = $manifest['description'] ?? $description;
|
||||
|
||||
// 3. DB 등록
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
// 3. DB 등록
|
||||
$onProgress?->__invoke('db', 'DB 등록 중...');
|
||||
|
||||
// 템플릿 레코드 생성 또는 업데이트
|
||||
$templateRecord = $this->templateRepository->updateOrCreate(
|
||||
['identifier' => $templateName],
|
||||
[
|
||||
'vendor' => $template['vendor'],
|
||||
'name' => $name,
|
||||
'version' => $template['version'],
|
||||
'type' => $template['type'],
|
||||
'description' => $description,
|
||||
'github_url' => $template['github_url'] ?? null,
|
||||
'metadata' => $template['metadata'] ?? null,
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
]
|
||||
// 템플릿 레코드 생성 또는 업데이트
|
||||
$templateRecord = $this->templateRepository->updateOrCreate(
|
||||
['identifier' => $templateName],
|
||||
[
|
||||
'vendor' => $template['vendor'],
|
||||
'name' => $name,
|
||||
'version' => $template['version'],
|
||||
'type' => $template['type'],
|
||||
'description' => $description,
|
||||
'github_url' => $template['github_url'] ?? null,
|
||||
'metadata' => $template['metadata'] ?? null,
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
]
|
||||
);
|
||||
|
||||
// 4. 레이아웃 등록
|
||||
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
|
||||
|
||||
// 레이아웃 JSON 파일 일괄 등록
|
||||
$this->registerLayouts($templateName, $templateRecord->id);
|
||||
|
||||
// 모듈 레이아웃 오버라이드 등록
|
||||
$this->registerLayoutOverrides($templateName, $templateRecord->id);
|
||||
|
||||
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
|
||||
$this->registerExtensionOverrides($templateName, $templateRecord->id);
|
||||
|
||||
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
|
||||
$this->validateErrorLayouts($templateName, $template);
|
||||
|
||||
// 템플릿 상태 캐시 무효화
|
||||
self::invalidateTemplateStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 훅 발행: 템플릿 설치 완료
|
||||
HookManager::doAction('core.templates.installed', $templateName);
|
||||
|
||||
return true;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
|
||||
$rollbackActivePath,
|
||||
$rollbackDirExisted,
|
||||
$templateName,
|
||||
'template',
|
||||
);
|
||||
|
||||
// 4. 레이아웃 등록
|
||||
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
|
||||
|
||||
// 레이아웃 JSON 파일 일괄 등록
|
||||
$this->registerLayouts($templateName, $templateRecord->id);
|
||||
|
||||
// 모듈 레이아웃 오버라이드 등록
|
||||
$this->registerLayoutOverrides($templateName, $templateRecord->id);
|
||||
|
||||
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
|
||||
$this->registerExtensionOverrides($templateName, $templateRecord->id);
|
||||
|
||||
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
|
||||
$this->validateErrorLayouts($templateName, $template);
|
||||
|
||||
// 템플릿 상태 캐시 무효화
|
||||
self::invalidateTemplateStatusCache();
|
||||
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 훅 발행: 템플릿 설치 완료
|
||||
HookManager::doAction('core.templates.installed', $templateName);
|
||||
|
||||
return true;
|
||||
});
|
||||
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]);
|
||||
@@ -2825,7 +2854,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$tempDir = storage_path('app/temp/template_update_'.uniqid());
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
|
||||
|
||||
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
|
||||
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
|
||||
@@ -3154,11 +3183,14 @@ class TemplateManager implements TemplateManagerInterface
|
||||
|
||||
$this->clearAllTemplateLanguageCaches();
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
|
||||
// 비활성 템플릿이라 refreshTemplateLayouts()를 건너뛴 경우에만 여기서 증가
|
||||
if ($previousStatus !== ExtensionStatus::Active->value) {
|
||||
$this->incrementExtensionCacheVersion();
|
||||
}
|
||||
// 모듈(`updateModule`)·플러그인(`updatePlugin`)과 동형으로 **무조건** 올린다 —
|
||||
// lang/routes/components/dist 만 바뀐 릴리스도 정적 게시본(#122)을 갱신해야 한다.
|
||||
// 종전에는 활성 템플릿이면 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃
|
||||
// 변경 건수 > 0)에 위임했는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로
|
||||
// 레이아웃이 그대로인 릴리스는 게시본이 immutable 로 stale 하게 남았다(#651 F1).
|
||||
// `refreshTemplateLayouts()` 내부의 조건부 bump 는 단독 호출처(refresh-layout)가
|
||||
// 있어 그대로 두며, 이중 bump 는 put 1회 비용이고 terminating 게시는 1회로 병합된다.
|
||||
$this->incrementExtensionCacheVersion();
|
||||
self::invalidateTemplateStatusCache();
|
||||
|
||||
// 훅 발행: 템플릿 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
@@ -3208,6 +3240,15 @@ class TemplateManager implements TemplateManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 실패 경로도 bump 한다 — 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수
|
||||
// 있어, 게시본을 새 버전으로 다시 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다
|
||||
// 안전하다(#651 F2). 원래 예외를 가리지 않도록 bump 실패는 삼킨다.
|
||||
try {
|
||||
$this->incrementExtensionCacheVersion();
|
||||
} catch (\Throwable) {
|
||||
// 원래 예외(아래 throw)가 진짜 원인이다 — bump 실패는 로그(트레이트)로 충분
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
__('templates.errors.update_failed', [
|
||||
'template' => $identifier,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,27 @@ trait ClearsTemplateCaches
|
||||
*/
|
||||
private static string $extensionCacheVersionKey = 'ext.cache_version';
|
||||
|
||||
/**
|
||||
* 확장 캐시 버전·custom 서명 키의 저장 TTL (초) — 10년.
|
||||
*
|
||||
* 이 키들은 **만료로 재생성되어서는 안 된다.** 버전 키가 만료되면 다음 독자가
|
||||
* `regenerateExtensionCacheVersion()` 으로 새 `time()` 을 만들고, 그것은 정적 게시본
|
||||
* (`public/build/ext/{v}/`, 실측 715파일·40MB) **전체 재생성** + 병합 번들 재병합 + 전
|
||||
* 방문자의 자산 URL 변경이다. TTL 을 넘기지 않으면 `AbstractCacheDriver::put()` 이
|
||||
* `cache.default_ttl`(기본 86400) 을 적용해 그 재생성이 **매일** 우발적으로 일어났다(#651 F11).
|
||||
*
|
||||
* 영구 저장의 두 후보를 쓰지 않는 이유:
|
||||
* - `forever()` 는 `CacheInterface` 밖이다 — 인터페이스 확장은 확장 공개 표면 변경이라
|
||||
* 번들 확장 전수의 `g7_version` 동기화 의무를 낳는다.
|
||||
* - `put(…, 0)` 은 Laravel `Repository::put` 이 `seconds <= 0` 을 **forget** 으로 처리해
|
||||
* 키를 지운다. 파일 스토어는 만료를 `9999999999` 로 캡하므로 큰 TTL 은 안전하다.
|
||||
*
|
||||
* 트레이트 안에서는 `self::` 로 읽지 않는다 — 트레이트 정적 메서드를 트레이트 이름으로 직접
|
||||
* 부르는 호출(테스트·레거시)에서 `self` 가 트레이트 자신으로 해석되어 "Cannot access trait
|
||||
* constant directly" 가 난다. 트레이트를 조합한 코어 서비스 클래스 경유로 읽는다.
|
||||
*/
|
||||
public const PERSISTENT_TTL_SECONDS = 315360000;
|
||||
|
||||
/**
|
||||
* 프로세스 1회 메모이즈된 확장 좌표 캐시 스토어 이름 (write/read 스토어 일관성).
|
||||
* `extensionCacheStore()` 가 최초 1회 채우며, 테스트는 `resetExtensionCacheStoreMemo()`
|
||||
@@ -38,7 +60,7 @@ trait ClearsTemplateCaches
|
||||
{
|
||||
try {
|
||||
$newVersion = time();
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
Log::info('확장 기능 캐시 버전 증가', [
|
||||
'new_version' => $newVersion,
|
||||
@@ -48,6 +70,12 @@ trait ClearsTemplateCaches
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가
|
||||
// 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122).
|
||||
// terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다
|
||||
// (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상.
|
||||
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +112,9 @@ trait ClearsTemplateCaches
|
||||
* 대신 동일 로직을 직접 수행한다. 저장 실패 시에도 0 으로 붕괴하지 않도록
|
||||
* 생성한 `time()` 값을 반환한다(다음 요청이 다시 생성·저장 시도).
|
||||
*
|
||||
* 키는 `PERSISTENT_TTL_SECONDS` 로 저장되므로 만료로 이 경로에 오지 않는다 — 키 부재는
|
||||
* `php artisan cache:clear` 또는 캐시 스토어 소실 때만이다.
|
||||
*
|
||||
* @return int 새로 생성된 유효 캐시 버전 (타임스탬프)
|
||||
*/
|
||||
private static function regenerateExtensionCacheVersion(): int
|
||||
@@ -91,7 +122,7 @@ trait ClearsTemplateCaches
|
||||
$newVersion = time();
|
||||
|
||||
try {
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
Log::info('확장 기능 캐시 버전 재생성 (키 부재/무효)', [
|
||||
'new_version' => $newVersion,
|
||||
@@ -102,6 +133,17 @@ trait ClearsTemplateCaches
|
||||
]);
|
||||
}
|
||||
|
||||
// 재생성도 bump 다 — 게시를 예약한다. `incrementExtensionCacheVersion()` 과
|
||||
// 동형화해 "캐시 버전 갱신 → 게시" 가 **모든 경로**에서 성립하게 한다 (#122).
|
||||
//
|
||||
// 이 경로가 빠져 있던 동안 `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로
|
||||
// 점프하고 산출물은 옛 버전에 남았다. 스케줄 GC(`ext-static:cleanup`)가 바로 그
|
||||
// 첫 호출자라, cleanup 첫 줄의 `getExtensionCacheVersion()` 이 실존하지 않는 새
|
||||
// 버전을 만들어 내고 보존 대상이 `[없는 새 버전, 실존 최신 1개]` 가 되어 **진짜
|
||||
// 직전 버전이 삭제**됐다 (매일 04:28 재현). CLI 는 terminating 예약이 그대로
|
||||
// 유효하므로 커맨드 종료 시점에 게시가 수행된다.
|
||||
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||
|
||||
return $newVersion;
|
||||
}
|
||||
|
||||
@@ -164,6 +206,21 @@ trait ClearsTemplateCaches
|
||||
return new CoreCacheDriver(self::extensionCacheStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* custom 자산 변경 서명(`ext.custom_signature`)용 캐시 드라이버를 반환합니다.
|
||||
*
|
||||
* 버전 키와 **같은 고정 스토어·같은 코어 네임스페이스**를 쓴다. 서명이 버전 키와 다른
|
||||
* 스토어에 저장되면(`app(CacheInterface::class)` 재바인딩 등) "첫 관측은 기록만" 규칙이
|
||||
* 스토어마다 따로 성립해 실제 변경 1회를 조용히 삼킨다(#651 F8). 서명을 쓰는 뷰 컴포저와
|
||||
* 지우는 관리 API 가 같은 통로를 써야 하므로 트레이트가 공급한다.
|
||||
*
|
||||
* @return CacheInterface 코어 네임스페이스 캐시 드라이버
|
||||
*/
|
||||
protected static function customSignatureCache(): CacheInterface
|
||||
{
|
||||
return new CoreCacheDriver(self::extensionCacheStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 좌표 키(`ext.cache_version`)의 고정 캐시 스토어 이름을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -53,7 +53,11 @@ trait GeneratesComponentManifest
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
$written = $json !== false && file_put_contents($outputPath, $json.PHP_EOL) !== false;
|
||||
// 종결 개행은 "\n" 고정 — PHP_EOL 은 Windows 에서 "\r\n" 이라, 같은 소스를 빌드해도
|
||||
// 빌드한 OS 에 따라 산출물의 마지막 바이트가 달라진다. components.json 은 Git 추적
|
||||
// 대상이므로 그 차이가 매 빌드마다 변경으로 잡히는데, 줄 내용이 같아 diff 는 비어
|
||||
// 보인다 — 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
|
||||
$written = $json !== false && file_put_contents($outputPath, $json."\n") !== false;
|
||||
|
||||
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -120,7 +121,10 @@ trait InvalidatesLayoutCache
|
||||
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
|
||||
// 지우므로 정합을 맞춘다.
|
||||
if ($templateIdentifier) {
|
||||
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터 경유 — 이 트레이트만 조합한 클래스가 있을 수 있어 `self::` 가 아니라
|
||||
// `ClearsTemplateCaches` 를 조합한 서비스 클래스를 통해 부른다(트레이트 정적 직접 호출은
|
||||
// PHP 8.1+ E_DEPRECATED). 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 실제 키를 못 지운다.
|
||||
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
|
||||
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}.meta");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Helpers\EditorSpecAssembler;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Services\PermissionService;
|
||||
use App\Services\TemplateService;
|
||||
use App\Support\AssetUrl;
|
||||
@@ -88,8 +88,10 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
);
|
||||
}
|
||||
|
||||
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
|
||||
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
|
||||
// 확장 캐시 버전은 트레이트 게터로만 읽는다 — 원시 키 읽기는 `cache:clear` 직후 0 을
|
||||
// 돌려주어 버전 없는 URL 을 만들고, 컨테이너 바인딩은 확장 네임스페이스로 누수될 수 있다.
|
||||
// 게터는 키 부재 시 재생성하므로 항상 유효 버전이다 (`AssetUrl::staticExtBase` 와 동형).
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
|
||||
return $this->success(
|
||||
__('templates.messages.editor_assets_retrieved'),
|
||||
@@ -228,7 +230,7 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
|
||||
// 코어 캐시 store 를 빗나갈 수 있다.
|
||||
$cache = new CoreCacheDriver;
|
||||
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
|
||||
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
|
||||
$mtime = (int) @filemtime($cssPath);
|
||||
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
|
||||
|
||||
@@ -110,6 +110,19 @@ class AuthController extends AdminBaseController
|
||||
}
|
||||
|
||||
return $this->success('common.success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
|
||||
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.unauthenticated');
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -13,8 +13,11 @@ use App\Http\Requests\Settings\UpdateSettingRequest;
|
||||
use App\Http\Resources\SettingsResource;
|
||||
use App\Services\DriverConnectionTester;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Services\OutboundProxyTester;
|
||||
use App\Services\SettingsService;
|
||||
use App\Support\EnvPriority;
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -30,11 +33,33 @@ class SettingsController extends AdminBaseController
|
||||
private SettingsService $settingsService,
|
||||
private DriverConnectionTester $driverConnectionTester,
|
||||
private DriverRegistryService $driverRegistryService,
|
||||
private OutboundProxyTester $outboundProxyTester
|
||||
private OutboundProxyTester $outboundProxyTester,
|
||||
private ExtensionStaticCacheService $staticCacheService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 응답에 동봉할 `_meta` 를 만듭니다.
|
||||
*
|
||||
* 조회와 저장 두 응답이 같은 모양이어야 화면이 저장 직후에도 같은 판정을 이어갑니다 —
|
||||
* 한쪽만 필드가 늘면 저장 후 잠금 표시가 조용히 사라집니다.
|
||||
*
|
||||
* - `limits`: 입력 한계값 (core.settings_limits)
|
||||
* - `env_priority_enabled`: `.env` 우선 모드 활성 여부 (안내 배너 표시 판정)
|
||||
* - `env_locked`: `.env` 로 잠긴 필드 목록 (프론트엔드 키 기준)
|
||||
*
|
||||
* @return array<string, mixed> 응답 메타 배열
|
||||
*/
|
||||
private function buildSettingsMeta(): array
|
||||
{
|
||||
return [
|
||||
'limits' => config('core.settings_limits', []),
|
||||
'env_priority_enabled' => EnvPriority::enabled(),
|
||||
'env_locked' => $this->settingsService->envLockedMeta(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 시스템 설정을 조회합니다.
|
||||
*
|
||||
@@ -45,7 +70,7 @@ class SettingsController extends AdminBaseController
|
||||
try {
|
||||
$settings = $this->settingsService->getAllSettings();
|
||||
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
|
||||
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
|
||||
$settings['_meta'] = $this->buildSettingsMeta();
|
||||
|
||||
return $this->success('settings.fetch_success',
|
||||
(new SettingsResource($settings))->toArray(request())
|
||||
@@ -73,7 +98,7 @@ class SettingsController extends AdminBaseController
|
||||
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
|
||||
$allSettings = $this->settingsService->getAllSettings();
|
||||
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
|
||||
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
|
||||
$allSettings['_meta'] = $this->buildSettingsMeta();
|
||||
|
||||
return $this->success('settings.save_success', [
|
||||
'settings' => $allSettings,
|
||||
@@ -150,6 +175,56 @@ class SettingsController extends AdminBaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰 프록시(리버스 프록시) 설정 진단 결과를 조회합니다 (#124).
|
||||
*
|
||||
* 읽기 전용이다 — 값 편집 엔드포인트는 두지 않는다. 이 값은 "앱이 프록시 없이 도달
|
||||
* 가능한가" 라는 배포 구조 지식이 있어야 정할 수 있고, 웹에서 편집 가능해지면 관리자
|
||||
* 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. 편집은 `.env` 전용이다.
|
||||
*
|
||||
* 판정 대상은 관리자 브라우저의 **실제 요청**이므로 현재 요청을 그대로 쓴다.
|
||||
* 입력을 받지 않는 읽기 전용 조회라 FormRequest 를 두지 않는다.
|
||||
*
|
||||
* @return JsonResponse 진단 결과 JSON 응답
|
||||
*/
|
||||
public function trustedProxy(): JsonResponse
|
||||
{
|
||||
return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 화면 정적 파일(부트스트랩 리소스 정적 게시) 상태를 조회합니다 (#651).
|
||||
*
|
||||
* CLI `ext-static:status` 와 **같은 판정**(`ExtensionStaticCacheService::statusReport`)을 돌려준다.
|
||||
* 읽기 전용이며 입력이 없어 FormRequest 를 두지 않는다 (`trustedProxy` 와 동형).
|
||||
*
|
||||
* @return JsonResponse 상태 보고서 JSON 응답
|
||||
*/
|
||||
public function staticCacheStatus(): JsonResponse
|
||||
{
|
||||
return $this->success('settings.static_cache_status_loaded', $this->staticCacheService->statusReport());
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 화면 정적 파일을 지금 다시 만듭니다 (관리자 수동 복구, #651).
|
||||
*
|
||||
* 캐시 버전을 올리고 현재 버전을 강제 재게시한다. 게시 실패·게시가 쓰이지 않는 환경은
|
||||
* 요청 처리 실패가 아니라 **진단 결과**라 HTTP 200 으로 돌려주고 성공 여부는 페이로드
|
||||
* (`republished`)가 말한다 — 연결 테스트·드라이버 테스트와 같은 규약. 사이트는 어느 경우에도
|
||||
* API 폴백으로 정상이다.
|
||||
*
|
||||
* @return JsonResponse 재게시 결과 + 상태 보고서 JSON 응답
|
||||
*/
|
||||
public function republishStaticCache(): JsonResponse
|
||||
{
|
||||
$result = $this->staticCacheService->republish();
|
||||
|
||||
return $this->success(
|
||||
($result['republished'] ?? false) ? 'settings.static_cache_republished' : 'settings.static_cache_republish_failed',
|
||||
$result
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -60,17 +60,7 @@ class AuthController extends AuthBaseController
|
||||
|
||||
return $this->success('auth.login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
return $this->lockedResponse($e);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
@@ -98,11 +88,38 @@ class AuthController extends AuthBaseController
|
||||
$data['user'] = new UserResource($data['user']);
|
||||
|
||||
return $this->success('auth.login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
|
||||
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
|
||||
return $this->lockedResponse($e);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.two_factor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 잠금 응답(423)을 구성합니다.
|
||||
*
|
||||
* 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다.
|
||||
*
|
||||
* @param AccountLockedException $e 잠금 예외
|
||||
* @return JsonResponse 423 응답
|
||||
*/
|
||||
private function lockedResponse(AccountLockedException $e): JsonResponse
|
||||
{
|
||||
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
|
||||
return $this->error(
|
||||
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
|
||||
423,
|
||||
[
|
||||
'locked_until' => $e->lockedUntil?->toIso8601String(),
|
||||
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
|
||||
'permanent' => $e->isPermanent(),
|
||||
],
|
||||
['minutes' => $e->remainingMinutes]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 사용자를 등록시킵니다.
|
||||
*
|
||||
@@ -178,7 +195,11 @@ class AuthController extends AuthBaseController
|
||||
*/
|
||||
public function refresh(AuthenticatedRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->authService->refreshToken($request->user());
|
||||
try {
|
||||
$data = $this->authService->refreshToken($request->user());
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->lockedResponse($e);
|
||||
}
|
||||
|
||||
// 사용자 정보는 Resource로, 토큰은 그대로
|
||||
if (isset($data['user'])) {
|
||||
|
||||
@@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
|
||||
* JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함).
|
||||
*
|
||||
* ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를
|
||||
* 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정
|
||||
* 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상).
|
||||
*
|
||||
* @param mixed $data JSON으로 변환할 데이터
|
||||
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
|
||||
* @param int $status HTTP 상태 코드
|
||||
* @return JsonResponse JSON 응답
|
||||
* @return JsonResponse|Response JSON 응답 또는 304 응답
|
||||
*/
|
||||
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
|
||||
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response
|
||||
{
|
||||
$etag = $this->generateETag($data);
|
||||
|
||||
$cacheControl = app()->environment('production')
|
||||
? "public, max-age={$maxAge}"
|
||||
: 'no-cache';
|
||||
|
||||
if ($status === 200 && $this->isNotModified($etag)) {
|
||||
$response = $this->notModifiedResponse($etag, $maxAge);
|
||||
$response->headers->set('Cache-Control', $cacheControl);
|
||||
$response->headers->set('Vary', 'Accept-Encoding');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return response()->json($data, $status, [
|
||||
'Cache-Control' => "public, max-age={$maxAge}",
|
||||
'Cache-Control' => $cacheControl,
|
||||
'ETag' => $etag,
|
||||
'Vary' => 'Accept-Encoding',
|
||||
], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
|
||||
): JsonResponse|Response {
|
||||
$etag = $this->generateETag($data);
|
||||
|
||||
// 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
|
||||
// (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
|
||||
$cacheControl = app()->environment('production')
|
||||
? "public, max-age={$maxAge}"
|
||||
: 'no-cache';
|
||||
|
||||
// 304 Not Modified 처리
|
||||
if ($this->isNotModified($etag)) {
|
||||
return $this->notModifiedResponse($etag, $maxAge);
|
||||
$response = $this->notModifiedResponse($etag, $maxAge);
|
||||
$response->headers->set('Cache-Control', $cacheControl);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
|
||||
'data' => $data,
|
||||
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
|
||||
->header('ETag', $etag)
|
||||
->header('Cache-Control', "public, max-age={$maxAge}")
|
||||
->header('Cache-Control', $cacheControl)
|
||||
->header('Vary', 'Accept-Encoding, Accept-Language');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers\Api\Public;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Http\Controllers\Concerns\ServesExtensionBundles;
|
||||
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
|
||||
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use App\Services\ModuleService;
|
||||
@@ -17,6 +19,9 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
*/
|
||||
class PublicModuleController extends PublicBaseController
|
||||
{
|
||||
use ServesExtensionBundles;
|
||||
use ServesRewritableCssAssets;
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleService $moduleService,
|
||||
private readonly ExtensionBundleService $bundleService
|
||||
@@ -27,9 +32,10 @@ class PublicModuleController extends PublicBaseController
|
||||
/**
|
||||
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
||||
*
|
||||
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
|
||||
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
|
||||
* 서빙(ETag/304/환경별 Cache-Control 재사용).
|
||||
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
|
||||
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||
*
|
||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||
*/
|
||||
@@ -37,14 +43,7 @@ class PublicModuleController extends PublicBaseController
|
||||
{
|
||||
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
|
||||
|
||||
$version = $this->bundleService->getCurrentVersion();
|
||||
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
|
||||
|
||||
if ($path === '') {
|
||||
return response('', 200)->header('Content-Type', 'text/javascript');
|
||||
}
|
||||
|
||||
return $this->fileResponse($path, 'text/javascript', 31536000);
|
||||
return $this->bundleResponse($this->bundleService, 'module', 'js', 'text/javascript');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,14 +55,7 @@ class PublicModuleController extends PublicBaseController
|
||||
{
|
||||
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
|
||||
|
||||
$version = $this->bundleService->getCurrentVersion();
|
||||
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
|
||||
|
||||
if ($path === '') {
|
||||
return response('', 200)->header('Content-Type', 'text/css');
|
||||
}
|
||||
|
||||
return $this->fileResponse($path, 'text/css', 31536000);
|
||||
return $this->bundleResponse($this->bundleService, 'module', 'css', 'text/css');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +91,14 @@ class PublicModuleController extends PublicBaseController
|
||||
}
|
||||
|
||||
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
|
||||
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
|
||||
return $this->rewritableAssetResponse(
|
||||
$result['filePath'],
|
||||
$result['mimeType'],
|
||||
'modules',
|
||||
$identifier,
|
||||
$path,
|
||||
31536000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,9 +139,9 @@ class PublicModuleController extends PublicBaseController
|
||||
* 폴백한다(무손실 보존 디그레이드).
|
||||
*
|
||||
* @param string $identifier 모듈 식별자
|
||||
* @return JsonResponse 컴포넌트 정의 응답
|
||||
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
|
||||
*/
|
||||
public function serveComponents(string $identifier): JsonResponse
|
||||
public function serveComponents(string $identifier): JsonResponse|Response
|
||||
{
|
||||
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers\Api\Public;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Http\Controllers\Concerns\ServesExtensionBundles;
|
||||
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
|
||||
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use App\Services\PluginService;
|
||||
@@ -17,6 +19,9 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
*/
|
||||
class PublicPluginController extends PublicBaseController
|
||||
{
|
||||
use ServesExtensionBundles;
|
||||
use ServesRewritableCssAssets;
|
||||
|
||||
public function __construct(
|
||||
private readonly PluginService $pluginService,
|
||||
private readonly ExtensionBundleService $bundleService
|
||||
@@ -27,8 +32,10 @@ class PublicPluginController extends PublicBaseController
|
||||
/**
|
||||
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
|
||||
*
|
||||
* 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
|
||||
* 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
|
||||
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
|
||||
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
|
||||
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
|
||||
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
|
||||
*
|
||||
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
|
||||
*/
|
||||
@@ -36,14 +43,7 @@ class PublicPluginController extends PublicBaseController
|
||||
{
|
||||
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
|
||||
|
||||
$version = $this->bundleService->getCurrentVersion();
|
||||
$path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
|
||||
|
||||
if ($path === '') {
|
||||
return response('', 200)->header('Content-Type', 'text/javascript');
|
||||
}
|
||||
|
||||
return $this->fileResponse($path, 'text/javascript', 31536000);
|
||||
return $this->bundleResponse($this->bundleService, 'plugin', 'js', 'text/javascript');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,14 +55,7 @@ class PublicPluginController extends PublicBaseController
|
||||
{
|
||||
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
|
||||
|
||||
$version = $this->bundleService->getCurrentVersion();
|
||||
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
|
||||
|
||||
if ($path === '') {
|
||||
return response('', 200)->header('Content-Type', 'text/css');
|
||||
}
|
||||
|
||||
return $this->fileResponse($path, 'text/css', 31536000);
|
||||
return $this->bundleResponse($this->bundleService, 'plugin', 'css', 'text/css');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +91,14 @@ class PublicPluginController extends PublicBaseController
|
||||
}
|
||||
|
||||
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
|
||||
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
|
||||
return $this->rewritableAssetResponse(
|
||||
$result['filePath'],
|
||||
$result['mimeType'],
|
||||
'plugins',
|
||||
$identifier,
|
||||
$path,
|
||||
31536000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
|
||||
* 폴백한다(무손실 보존 디그레이드).
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @return JsonResponse 컴포넌트 정의 응답
|
||||
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
|
||||
*/
|
||||
public function serveComponents(string $identifier): JsonResponse
|
||||
public function serveComponents(string $identifier): JsonResponse|Response
|
||||
{
|
||||
$this->logApiUsage('plugins.components', ['identifier' => $identifier]);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\ExtensionStatus;
|
||||
use App\Extension\Helpers\EditorSpecAssembler;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
|
||||
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
|
||||
use App\Models\TemplateLayoutAttachment;
|
||||
use App\Services\TemplateLayoutAttachmentService;
|
||||
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
class PublicTemplateController extends PublicBaseController
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use ServesRewritableCssAssets;
|
||||
|
||||
public function __construct(
|
||||
private TemplateService $templateService,
|
||||
@@ -35,9 +37,9 @@ class PublicTemplateController extends PublicBaseController
|
||||
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
|
||||
* @return JsonResponse 라우트 정보 응답
|
||||
* @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304)
|
||||
*/
|
||||
public function getRoutes(string $identifier): JsonResponse
|
||||
public function getRoutes(string $identifier): JsonResponse|Response
|
||||
{
|
||||
// API 사용량 기록
|
||||
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
|
||||
@@ -101,6 +103,15 @@ class PublicTemplateController extends PublicBaseController
|
||||
return $this->error(__('templates.errors.invalid_cache_data'), 500);
|
||||
}
|
||||
|
||||
// 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다.
|
||||
// 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D).
|
||||
// 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v`
|
||||
// URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다
|
||||
// (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭).
|
||||
if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) {
|
||||
return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600);
|
||||
}
|
||||
|
||||
return $this->success(
|
||||
__('templates.messages.routes_retrieved'),
|
||||
$routesData['data']
|
||||
@@ -137,17 +148,26 @@ class PublicTemplateController extends PublicBaseController
|
||||
};
|
||||
}
|
||||
|
||||
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함)
|
||||
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
|
||||
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함).
|
||||
// CSS 는 안의 상대 참조를 절대 자산 URL 로 치환해 내보낸다 — 확장자 없는 모드에서
|
||||
// 상대 해석이 어긋나 글꼴·아이콘이 404 가 되기 때문이다.
|
||||
return $this->rewritableAssetResponse(
|
||||
$result['filePath'],
|
||||
$result['mimeType'],
|
||||
'templates',
|
||||
$identifier,
|
||||
$path,
|
||||
31536000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 정의 파일 서빙
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @return JsonResponse 컴포넌트 정의 응답
|
||||
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
|
||||
*/
|
||||
public function serveComponents(string $identifier): JsonResponse
|
||||
public function serveComponents(string $identifier): JsonResponse|Response
|
||||
{
|
||||
// API 사용량 기록
|
||||
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
|
||||
@@ -244,9 +264,9 @@ class PublicTemplateController extends PublicBaseController
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $locale 로케일 (ko, en 등)
|
||||
* @return JsonResponse 다국어 데이터 응답
|
||||
* @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304)
|
||||
*/
|
||||
public function serveLanguage(string $identifier, string $locale): JsonResponse
|
||||
public function serveLanguage(string $identifier, string $locale): JsonResponse|Response
|
||||
{
|
||||
// API 사용량 기록
|
||||
$this->logApiUsage('templates.language', [
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 확장(모듈/플러그인) 병합 번들 서빙의 공통 판정 (#122 E1/E2).
|
||||
*
|
||||
* 모듈 컨트롤러와 플러그인 컨트롤러가 **같은 판정**을 해야 한다. 각자 구현하면 한쪽만
|
||||
* 고쳐진 채 다른 쪽이 조용히 옛 동작으로 남는다 — 실제로 두 컨트롤러는 지금까지 동일
|
||||
* 코드를 복제하고 있었다.
|
||||
*
|
||||
* 판정은 셋으로 갈린다:
|
||||
*
|
||||
* 1. 병합 결과가 있고 디스크 캐시도 성공 → 파일 응답 (ETag/304/immutable 재사용)
|
||||
* 2. 병합 결과는 있는데 디스크 캐시가 실패 → **메모리 결과를 그대로 200** 으로 서빙.
|
||||
* 디스크 캐시는 최적화이므로 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
||||
* 3. 병합 결과가 비었음 →
|
||||
* - 선언한 산출물이 **소실·판독 불가**면 장애(배포 중 `dist` 가 잠깐 빔, 경로
|
||||
* 어긋남) → **503**. 빈 200 으로 내보내면 프론트는 404 도 오류도 받지 못한 채
|
||||
* 한참 뒤 "Unknown action handler" 로 죽어, 원인이 번들이라는 사실이 드러나지 않는다.
|
||||
* - 산출물이 **존재하되 비어 있으면** 정상 → 빈 200. 스타일 소스가 자리표시 주석뿐이라
|
||||
* 0바이트 CSS 를 내보내는 확장은 정당한 상태다. 선언 축만으로 판정하면 그런 확장만
|
||||
* 설치된 기본 구성이 통째로 503 이 되어 사용자 화면마다 실패 안내가 뜬다.
|
||||
* - 선언이 **0개**여도 정상 → 빈 200
|
||||
*/
|
||||
trait ServesExtensionBundles
|
||||
{
|
||||
/**
|
||||
* 확장 병합 번들을 서빙합니다.
|
||||
*
|
||||
* @param ExtensionBundleService $bundleService 번들 서비스
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @param string $kind 'js' | 'css'
|
||||
* @param string $mimeType 응답 Content-Type
|
||||
* @return BinaryFileResponse|Response 번들 응답
|
||||
*/
|
||||
protected function bundleResponse(
|
||||
ExtensionBundleService $bundleService,
|
||||
string $type,
|
||||
string $kind,
|
||||
string $mimeType
|
||||
): BinaryFileResponse|Response {
|
||||
$version = $bundleService->getCurrentVersion();
|
||||
$path = $bundleService->getBundleFilePath($type, $kind, $version);
|
||||
|
||||
if ($path !== '') {
|
||||
return $this->fileResponse($path, $mimeType, 31536000);
|
||||
}
|
||||
|
||||
// 파일 경로가 비었다 — 병합 결과 자체가 비었거나, 디스크 캐시가 실패했다.
|
||||
$content = $bundleService->buildBundleContent($type, $kind);
|
||||
|
||||
if ($content !== '') {
|
||||
// 디스크 캐시 실패 (2) — 메모리 결과로 서빙한다. 캐시 헤더는 붙이지 않는다:
|
||||
// 이 응답은 캐시에 실패한 상태의 산출물이라 다음 요청에서 정상 경로로
|
||||
// 돌아갈 수 있어야 한다.
|
||||
return response($content, 200)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'no-cache, private');
|
||||
}
|
||||
|
||||
$missing = $bundleService->findMissingDeclaredAssets($type, $kind);
|
||||
|
||||
if ($missing !== []) {
|
||||
// (3) 장애 — 선언한 산출물이 소실·판독 불가 (배포 중 dist 가 잠깐 빔, 경로 어긋남).
|
||||
// 어느 파일이 없는지를 함께 남긴다 — 선언 수만으로는 운영자가 조치 대상을 짚을 수 없다.
|
||||
Log::error('확장 번들 병합 결과가 비었습니다 — 선언된 산출물이 없거나 읽을 수 없습니다', [
|
||||
'type' => $type,
|
||||
'kind' => $kind,
|
||||
'version' => $version,
|
||||
'declared_extensions' => $bundleService->countAssetDeclaringExtensions($type, $kind),
|
||||
'missing' => $missing,
|
||||
]);
|
||||
|
||||
return response('', 503)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'no-cache, private');
|
||||
}
|
||||
|
||||
// (3) 정상 — 선언이 0 이거나, 선언된 산출물이 전부 존재하되 비어 있다
|
||||
// (스타일이 비어 있는 확장). 빈 번들이 맞다.
|
||||
return response('', 200)->header('Content-Type', $mimeType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Support\AssetCssUrlRewriter;
|
||||
use App\Support\AssetUrl;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 확장 자산 서빙에서 CSS 안의 상대 참조를 절대 자산 URL 로 바꿔 내보냅니다.
|
||||
*
|
||||
* 배경:
|
||||
* `general.asset_url_mode` 가 `extensionless` 면 자산 URL 이
|
||||
* `/api/templates/assets/{id}?file=vendor%2Fx%2Fa.css` 형태가 된다. 브라우저는 CSS 안의
|
||||
* 상대 `url()` 을 스타일시트 URL 의 **디렉토리** 기준으로 푸는데, 이 형태에서는 디렉토리가
|
||||
* `/api/templates/assets/` 라서 `./woff2/f.woff2` 가 엉뚱한 곳을 가리킨다. 확장자 모드나
|
||||
* 정적 게시본에서는 경로 형태라 정상 해석되므로, 어긋남은 이 조합에서만 나타난다.
|
||||
*
|
||||
* 증상은 404 하나뿐이다 — 글꼴은 기본 서체로 대체되고 아이콘은 빈칸이 되며, 서버 로그에는
|
||||
* 정상 요청으로 남는다. 그래서 운영자에게는 원인을 특정할 단서가 없다.
|
||||
*
|
||||
* 모드와 무관하게 항상 치환하는 이유:
|
||||
* 확장자 모드에서도 결과 URL 은 브라우저가 상대 해석으로 얻던 것과 같은 주소다. 모드에
|
||||
* 따라 치환 여부를 가르면 두 경로가 서로 다른 코드로 갈라져, 정작 깨지는 쪽만 검증에서
|
||||
* 빠지기 쉽다. 한 경로로 두고 두 모드를 같은 테스트로 잠근다.
|
||||
*
|
||||
* @see AssetCssUrlRewriter 치환 규칙(대상·비대상 판정)
|
||||
*/
|
||||
trait ServesRewritableCssAssets
|
||||
{
|
||||
/**
|
||||
* 확장 자산 응답을 만듭니다 — CSS 면 상대 참조를 치환해 내보냅니다.
|
||||
*
|
||||
* CSS 가 아니면 종전 `fileResponse()` 와 동일하게 동작합니다.
|
||||
*
|
||||
* @param string $filePath 실제 파일 절대 경로
|
||||
* @param string $mimeType MIME 타입
|
||||
* @param string $extensionType `templates` / `modules` / `plugins`
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $requestedPath 확장 기준 요청 경로 (CSS 상대 참조의 해석 기준)
|
||||
* @param int $maxAge 캐시 유지 시간 (초)
|
||||
* @return BinaryFileResponse|Response 자산 응답 (If-None-Match 일치 시 304)
|
||||
*/
|
||||
protected function rewritableAssetResponse(
|
||||
string $filePath,
|
||||
string $mimeType,
|
||||
string $extensionType,
|
||||
string $identifier,
|
||||
string $requestedPath,
|
||||
int $maxAge = 31536000
|
||||
): BinaryFileResponse|Response {
|
||||
if (! $this->isCssAsset($mimeType, $requestedPath)) {
|
||||
return $this->fileResponse($filePath, $mimeType, $maxAge);
|
||||
}
|
||||
|
||||
$css = @file_get_contents($filePath);
|
||||
|
||||
// 읽기에 실패하면 치환을 포기하고 원본을 그대로 서빙한다 — 치환은 편의 장치이므로
|
||||
// 그 실패가 자산 자체를 못 내보내는 사유가 되어서는 안 된다.
|
||||
if ($css === false) {
|
||||
return $this->fileResponse($filePath, $mimeType, $maxAge);
|
||||
}
|
||||
|
||||
// 서브리소스에는 CSS 자신이 받은 캐시 버전을 그대로 승계한다. 버전이 오르면 CSS URL
|
||||
// 이 바뀌어 재요청되고, 그 안의 서브리소스 URL 도 같은 버전을 달고 나가므로 두 계층의
|
||||
// 무효화 시점이 어긋나지 않는다.
|
||||
$version = request()->query('v');
|
||||
$version = is_string($version) && $version !== '' ? $version : null;
|
||||
|
||||
$rewritten = AssetCssUrlRewriter::rewrite(
|
||||
$css,
|
||||
$requestedPath,
|
||||
static fn (string $path): string => AssetUrl::extensionApiAsset($extensionType, $identifier, $path, $version)
|
||||
);
|
||||
|
||||
// ETag 는 **내보내는 본문** 기준이어야 한다. 파일 stat 기준으로 잡으면 URL 모드가
|
||||
// 바뀌어 본문이 달라져도 같은 ETag 가 나와 브라우저가 옛 본문을 계속 쓴다.
|
||||
$etag = md5($rewritten);
|
||||
|
||||
if (request()->header('If-None-Match') === $etag) {
|
||||
return response('', 304)->header('ETag', $etag);
|
||||
}
|
||||
|
||||
$cacheControl = app()->environment('production')
|
||||
? "public, max-age={$maxAge}, immutable"
|
||||
: 'no-cache';
|
||||
|
||||
return response($rewritten, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT',
|
||||
'ETag' => $etag,
|
||||
'Cache-Control' => $cacheControl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 자산이 CSS 인지 판정합니다.
|
||||
*
|
||||
* MIME 과 확장자를 함께 본다 — 서빙 계층이 돌려주는 MIME 은 환경에 따라
|
||||
* `text/plain` 으로 떨어질 수 있고, 그때 확장자가 유일한 단서다.
|
||||
*
|
||||
* @param string $mimeType MIME 타입
|
||||
* @param string $path 확장 기준 요청 경로
|
||||
* @return bool CSS 여부
|
||||
*/
|
||||
private function isCssAsset(string $mimeType, string $path): bool
|
||||
{
|
||||
if (str_contains(strtolower($mimeType), 'css')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'css';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\DevTools\DebugGate;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 디버그 모드가 꺼져 있으면 요청을 403 으로 차단합니다 (DevTools 엔드포인트 게이트).
|
||||
*
|
||||
* 왜 미들웨어인가:
|
||||
* 이 게이트는 원래 `routes/devtools.php` 의 **핸들러 안**에 `if (! DebugGate::isEnabled())`
|
||||
* 블록으로 들어 있었다. 그러다 보니 8개 라우트 중 POST 3종에만 붙고 GET 4종·DELETE 1종은
|
||||
* 빠졌다 — 라우트를 추가할 때 게이트를 함께 적는 것을 잊으면 그 라우트만 조용히 열린다.
|
||||
* 실제로 `DELETE /_boost/g7-debug/clear` 는 production·`APP_DEBUG=false` 에서도 미인증
|
||||
* 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128).
|
||||
*
|
||||
* 게이트가 하나라도 빠지면 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이
|
||||
* 유일한 증상이다. 그래서 판정을 그룹 미들웨어 **단일 지점**으로 올려 라우트 추가가 게이트
|
||||
* 부착과 분리될 수 없게 만든다. 부착 지점은 `bootstrap/app.php` 의 devtools 래퍼 하나다.
|
||||
*
|
||||
* 판정 SSoT 는 `DebugGate::isEnabled()` 를 그대로 쓴다 (`config('app.debug')` 또는 관리자
|
||||
* 환경설정 `debug.mode`). 응답 형태도 종전 핸들러 블록과 동일하게 유지한다 — 이미 배포된
|
||||
* 브라우저 인젝션 스크립트와 MCP 도구가 이 shape 을 읽는다.
|
||||
*/
|
||||
class EnsureDebugMode
|
||||
{
|
||||
/**
|
||||
* 요청을 처리합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 미들웨어
|
||||
* @return Response HTTP 응답 (디버그 모드 OFF 시 403 JSON)
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! DebugGate::isEnabled()) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => __('devtools.debug_disabled'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,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,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\View\Composers\Traits;
|
||||
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
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($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` 하는 것에 의존하므로(같은 키를 보는 두 소비자),
|
||||
* 키를 쪼개면 그 소비자가 일부만 지우게 되어 같은 결함군이 재생산된다.
|
||||
*
|
||||
* 서명 재료는 **게시와 같은 열거자**(`CustomAssets::publishableFiles`) — 활성 모듈·플러그인과
|
||||
* 이 렌더의 템플릿의 `custom/**` 전체(하위 디렉토리·글꼴·이미지 포함)의 경로·수정 시각·
|
||||
* 크기다. 종전에는 로드 목록(최상위 css/js)의 mtime 만 봤는데, 게시는 재귀 복사라
|
||||
* 글꼴만 교체한 변경이 영영 감지되지 않았다(#651 F7). 크기를 함께 넣는 이유는 mtime 해상도
|
||||
* (1초·일부 파일시스템 2초) 안의 재작성과 mtime 보존 복사(rsync -t)를 잡기 위해서다.
|
||||
*
|
||||
* 스코프 키에는 **호스트명**을 붙인다. 다중 웹서버가 캐시를 공유하면 같은 파일이라도 서버마다
|
||||
* 배포 시각(mtime)이 달라, 하나의 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로
|
||||
* 읽혀 전체 재게시가 왕복한다(#651 F9). 서버별로 기억하면 각 서버는 자기 파일만 대조한다.
|
||||
*
|
||||
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
|
||||
* @return bool 확장 캐시 버전을 올렸으면 true
|
||||
*/
|
||||
private function syncCustomAssetCacheVersion(?string $scope = null): bool
|
||||
{
|
||||
try {
|
||||
$signature = $this->customAssetSignature($scope);
|
||||
|
||||
$scopeKey = ($scope ?? '').'@'.(gethostname() ?: 'host');
|
||||
|
||||
// 버전 키와 같은 고정 스토어·코어 네임스페이스 — 컨테이너 바인딩을 거치면 재바인딩
|
||||
// 컨텍스트에서 다른 네임스페이스에 저장되어 첫 관측 규칙이 실제 변경을 삼킨다.
|
||||
$cache = self::customSignatureCache();
|
||||
|
||||
// 구 배포본이 남긴 스칼라 값은 맵이 아니다 — 어느 스코프의 것인지 알 수 없으므로
|
||||
// 서명으로 쓰지 않고 미관측으로 취급한다(첫 관측은 기록만 하니 헛된 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;
|
||||
// 버전 키와 같은 영구 TTL — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라
|
||||
// 실제 변경 1회를 놓친다.
|
||||
$cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 렌더 스코프의 custom 파일 서명을 만듭니다.
|
||||
*
|
||||
* 재료 = 활성 모듈 → 활성 플러그인 → 렌더 템플릿 순으로 `publishableFiles()` 전체의
|
||||
* `{type}/{id}/{relative}@{mtime}@{size}`. 정렬 뒤 md5. 파일이 하나도 없으면 `empty`.
|
||||
*
|
||||
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
|
||||
* @return string 서명
|
||||
*/
|
||||
private function customAssetSignature(?string $scope): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$targets = [];
|
||||
|
||||
foreach ($this->activeExtensionIdentifiers('modules') as $identifier) {
|
||||
$targets[] = ['modules', $identifier];
|
||||
}
|
||||
|
||||
foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) {
|
||||
$targets[] = ['plugins', $identifier];
|
||||
}
|
||||
|
||||
if (! empty($scope)) {
|
||||
$targets[] = ['templates', $scope];
|
||||
}
|
||||
|
||||
foreach ($targets as [$type, $identifier]) {
|
||||
foreach (CustomAssets::publishableFiles($type, $identifier) as $file) {
|
||||
$parts[] = $type.'/'.$identifier.'/'.$file['relative'].'@'.$file['mtime'].'@'.$file['size'];
|
||||
}
|
||||
}
|
||||
|
||||
sort($parts, SORT_STRING);
|
||||
|
||||
return $parts === [] ? 'empty' : md5(implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 요청이 사용자 추가 에셋을 끄도록 요구했는지 판정합니다.
|
||||
*
|
||||
* 판정을 수집 **맨 앞**에 두는 것이 중요하다. 뒤에 두면 빈 목록이 변경 감지
|
||||
* (`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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 핸들러
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Helpers\TimezoneHelper;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시 실패 알림 리스너 (#122)
|
||||
*
|
||||
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이고, 서버 로그에만
|
||||
* warning 이 쌓인다. 그래서 쓰기 불가 환경에서 정적 fast path 가 **영구히 꺼진 채로**
|
||||
* 운영되는 상태를 아무도 눈치채지 못했다(제보 본건).
|
||||
*
|
||||
* 관리자 대시보드가 그 사실이 운영자에게 도달하는 통로다. 대시보드 레이아웃·컨트롤러·
|
||||
* API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts` 필터 훅에 항목을 얹기만 한다.
|
||||
*
|
||||
* 표시 조건: 실패 마커가 존재하고 **연속 실패가 2회 이상**. 1회는 배포 중 일시적 경합일 수
|
||||
* 있고, 그 경우 다음 렌더의 자가 치유가 해소한다 — 매번 알리면 알림이 소음이 된다.
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class StaticPublishFailureAlertListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 알림을 띄우기 시작하는 연속 실패 횟수.
|
||||
*/
|
||||
private const ALERT_THRESHOLD = 2;
|
||||
|
||||
/**
|
||||
* 구독할 훅과 메서드 매핑 반환.
|
||||
*
|
||||
* @return array 훅 매핑 배열
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.dashboard.alerts' => [
|
||||
'method' => 'addStaticPublishAlert',
|
||||
'priority' => 15,
|
||||
'type' => 'filter',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 이벤트 처리 (기본 핸들러).
|
||||
*
|
||||
* @param mixed ...$args 훅에서 전달된 인수들
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 기본 핸들러는 사용하지 않음
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 게시 실패 알림을 대시보드에 추가합니다.
|
||||
*
|
||||
* @param array $alerts 기존 알림 배열
|
||||
* @return array 알림이 추가된 배열
|
||||
*/
|
||||
public function addStaticPublishAlert(array $alerts): array
|
||||
{
|
||||
$marker = ExtensionStaticCacheService::failureMarker();
|
||||
|
||||
if ($marker === null) {
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
$count = (int) ($marker['count'] ?? 0);
|
||||
|
||||
if ($count < self::ALERT_THRESHOLD) {
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
$reason = (string) ($marker['reason'] ?? 'write_failed');
|
||||
|
||||
$alerts[] = [
|
||||
'id' => 'static_publish_failure',
|
||||
'type' => 'warning',
|
||||
// 원인별로 조치가 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
|
||||
'subtype' => 'static_publish_'.$reason,
|
||||
'icon' => 'exclamation-triangle',
|
||||
'title' => __('extensions.alerts.static_publish_failed_title'),
|
||||
'message' => __('extensions.alerts.static_publish_failed_'.$this->messageKeySuffix($reason), [
|
||||
'count' => $count,
|
||||
]),
|
||||
'time' => isset($marker['at'])
|
||||
? TimezoneHelper::toUserCarbon(Carbon::parse($marker['at']))?->diffForHumans()
|
||||
: null,
|
||||
'read' => false,
|
||||
// 알림에서 바로 복구한다 (#651 D11) — 운영자가 결함을 처음 만나는 곳이 알림이므로 그 자리에서
|
||||
// 끝나야 한다. 대시보드 렌더러는 `recover_endpoint` 가 있는 알림에 버튼을 그리고 POST 한다
|
||||
// (기존 재호환 알림과 같은 배선). 성공 문구는 재호환 알림의 기본 문구가 이 알림에는 맞지
|
||||
// 않으므로 리스너가 함께 싣는다.
|
||||
'recover_endpoint' => '/api/admin/settings/static-cache/republish',
|
||||
'recover_label' => __('extensions.alerts.static_publish_recover_label'),
|
||||
'recover_success_message' => __('extensions.alerts.static_publish_recovered'),
|
||||
];
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사유 코드를 다국어 키 접미사로 변환합니다.
|
||||
*
|
||||
* 알 수 없는 사유는 일반 문구로 떨어뜨린다 — 키가 없으면 번역기가 키 문자열을 그대로
|
||||
* 화면에 내보낸다.
|
||||
*
|
||||
* @param string $reason 사유 코드
|
||||
* @return string 다국어 키 접미사
|
||||
*/
|
||||
private function messageKeySuffix(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'parent_not_writable', 'write_failed', 'lock_unavailable' => $reason,
|
||||
default => 'write_failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Support\TrustedProxyDiagnostic;
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 미설정 경고 리스너 (#124)
|
||||
*
|
||||
* 프록시 뒤에서 신뢰 프록시가 설정되지 않은 상태는 HTTPS 종단 구성에서는 화면 백지로 즉시
|
||||
* 드러나지만, HTTP 전용 사이트가 프록시 뒤에 있으면 **화면이 완전히 정상이다.** 그 상태에서도
|
||||
* webhook 403 · IP 기록 왜곡 · 로그인 제한 붕괴는 그대로 발생한다. 이 알림이 그 조용한 구성을
|
||||
* 덮는 유일한 통로다.
|
||||
*
|
||||
* 대시보드 레이아웃·컨트롤러·API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts`
|
||||
* 필터 훅에 항목을 얹기만 한다(선례: StaticPublishFailureAlertListener).
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
class TrustedProxyAlertListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅과 메서드 매핑 반환.
|
||||
*
|
||||
* @return array 훅 매핑 배열
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.dashboard.alerts' => [
|
||||
'method' => 'addTrustedProxyAlert',
|
||||
'priority' => 15,
|
||||
'type' => 'filter',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 이벤트 처리 (기본 핸들러).
|
||||
*
|
||||
* @param mixed ...$args 훅에서 전달된 인수들
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 기본 핸들러는 사용하지 않음
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰 프록시 미설정 경고를 대시보드에 추가합니다.
|
||||
*
|
||||
* @param array $alerts 기존 알림 배열
|
||||
* @return array 알림이 추가된 배열
|
||||
*/
|
||||
public function addTrustedProxyAlert(array $alerts): array
|
||||
{
|
||||
$diagnostic = TrustedProxyDiagnostic::forRequest(request());
|
||||
|
||||
if ($diagnostic['status'] !== TrustedProxyDiagnostic::STATUS_WARNING) {
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
$alerts[] = [
|
||||
'id' => 'trusted_proxy_missing',
|
||||
'type' => 'warning',
|
||||
'subtype' => 'trusted_proxy_missing',
|
||||
'icon' => 'exclamation-triangle',
|
||||
'title' => __('settings.trusted_proxy.alert_title'),
|
||||
// 원인과 조치를 함께 담는다 — 원인만 알려 주면 운영자가 어디를 고쳐야 할지 모른다.
|
||||
'message' => __('settings.trusted_proxy.alert_message', [
|
||||
'headers' => implode(', ', $diagnostic['forwarded_headers']),
|
||||
'ip' => (string) ($diagnostic['client_ip'] ?? '-'),
|
||||
]),
|
||||
'time' => null,
|
||||
'read' => false,
|
||||
];
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use App\Extension\PluginManager;
|
||||
use App\Http\View\Composers\TemplateComposer;
|
||||
use App\Http\View\Composers\UserTemplateComposer;
|
||||
use App\Listeners\ExtensionCompatibilityAlertListener;
|
||||
use App\Listeners\StaticPublishFailureAlertListener;
|
||||
use App\Listeners\TrustedProxyAlertListener;
|
||||
use App\Notifications\NotificationChannelManager;
|
||||
use App\Services\ChannelReadinessService;
|
||||
use App\Services\GeoIpService;
|
||||
@@ -99,8 +101,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
View::composer('admin', TemplateComposer::class);
|
||||
View::composer('app', UserTemplateComposer::class);
|
||||
|
||||
// 확장 호환성 알림 리스너 등록
|
||||
$this->registerExtensionCompatibilityAlertListener();
|
||||
// 코어 훅 리스너 등록 (대시보드 알림 등)
|
||||
$this->registerCoreHookListeners();
|
||||
|
||||
// SQL 쿼리 로그 설정
|
||||
$this->configureSqlQueryLogging();
|
||||
@@ -135,25 +137,38 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 호환성 알림 리스너를 등록합니다.
|
||||
* 코어가 소유한 훅 리스너를 등록합니다.
|
||||
*
|
||||
* 코어 버전 호환성 문제로 자동 비활성화된 확장에 대한
|
||||
* 알림을 관리자 대시보드에 표시하기 위한 훅 리스너입니다.
|
||||
* 확장 리스너와 달리 코어 리스너는 자동 발견 대상이 아니므로 여기서 등록한다. 목록만
|
||||
* 늘리면 되도록 루프로 둔다 — 리스너마다 등록 메서드를 복제하면 한 곳만 빠져도 그
|
||||
* 리스너가 조용히 동작하지 않는다(등록 실패는 예외도 로그도 남기지 않는다).
|
||||
*
|
||||
* 등록 대상:
|
||||
* - `ExtensionCompatibilityAlertListener` — 코어 호환성으로 자동 비활성화/재호환된 확장
|
||||
* - `StaticPublishFailureAlertListener` — 부트스트랩 리소스 정적 게시 실패 (#122)
|
||||
* - `TrustedProxyAlertListener` — 프록시 헤더 수신 중 신뢰 프록시 미설정 (#124)
|
||||
*/
|
||||
private function registerExtensionCompatibilityAlertListener(): void
|
||||
private function registerCoreHookListeners(): void
|
||||
{
|
||||
$listener = new ExtensionCompatibilityAlertListener;
|
||||
$subscribedHooks = ExtensionCompatibilityAlertListener::getSubscribedHooks();
|
||||
$listenerClasses = [
|
||||
ExtensionCompatibilityAlertListener::class,
|
||||
StaticPublishFailureAlertListener::class,
|
||||
TrustedProxyAlertListener::class,
|
||||
];
|
||||
|
||||
foreach ($subscribedHooks as $hookName => $config) {
|
||||
$method = $config['method'] ?? 'handle';
|
||||
$priority = $config['priority'] ?? 10;
|
||||
$type = $config['type'] ?? 'action';
|
||||
foreach ($listenerClasses as $listenerClass) {
|
||||
$listener = new $listenerClass;
|
||||
|
||||
if ($type === 'filter') {
|
||||
HookManager::addFilter($hookName, [$listener, $method], $priority);
|
||||
} else {
|
||||
HookManager::addAction($hookName, [$listener, $method], $priority);
|
||||
foreach ($listenerClass::getSubscribedHooks() as $hookName => $config) {
|
||||
$method = $config['method'] ?? 'handle';
|
||||
$priority = $config['priority'] ?? 10;
|
||||
$type = $config['type'] ?? 'action';
|
||||
|
||||
if ($type === 'filter') {
|
||||
HookManager::addFilter($hookName, [$listener, $method], $priority);
|
||||
} else {
|
||||
HookManager::addAction($hookName, [$listener, $method], $priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* 브로드캐스트 채널 인가 정의를 로드합니다 (`routes/channels.php`).
|
||||
*
|
||||
* 왜 프레임워크 기본 경로를 쓰지 않는가:
|
||||
* `bootstrap/app.php` 의 `withRouting(channels: ...)` 인자는 두 가지를 한꺼번에 한다 —
|
||||
* ① `routes/channels.php` 로드(원하는 것) ② `Broadcast::routes()` 자동 호출(원치 않는 것).
|
||||
* ②가 등록하는 `GET|POST /broadcasting/auth` 에는 어떤 게이트도 없어서, 웹소켓 사용 OFF
|
||||
* (`broadcasting.default === 'null'`, 공개#50) 킬스위치를 통째로 우회하는 경로가 된다.
|
||||
* 프론트(`WebSocketManager`)는 게이트된 `/api/broadcasting/auth` 만 호출하므로 그 라우트는
|
||||
* 死라우트이면서 우회로이기만 했다 — production 에서 미인증 POST 가 200 을 받았다(공개#128).
|
||||
*
|
||||
* 그래서 `channels:` 인자를 떼어 ②의 자동 등록을 끊고, ①만 이 프로바이더가 담당한다.
|
||||
* 인증 엔드포인트의 SSoT 는 `routes/api.php` 의 `api.broadcasting.auth` 하나다
|
||||
* (`auth:sanctum` + 킬스위치 가드).
|
||||
*
|
||||
* 라우트 캐시 안전:
|
||||
* `Broadcast::channel()` 은 라우트가 아니라 브로드캐스터의 인가 콜백 등록이라 라우트 캐시와
|
||||
* 무관하다. 매 부팅마다 실행되어야 하며, 누락되면 예외 없이 **모든 private 채널 구독이 403**
|
||||
* 이 된다(콜백이 없는 채널은 거부되므로).
|
||||
*/
|
||||
class BroadcastServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* 채널 인가 정의를 등록합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$channels = base_path('routes/channels.php');
|
||||
|
||||
if (is_file($channels)) {
|
||||
require $channels;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Providers;
|
||||
|
||||
use App\Repositories\JsonConfigRepository;
|
||||
use App\Support\AllowedExtensions;
|
||||
use App\Support\EnvPriority;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use App\Support\OutboundProxy;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
@@ -82,6 +83,10 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드에서 `.env` 가 소유권을 가져간 키를 제거한다 — 아래 가드들이
|
||||
// 제거된 키를 자연히 건너뛰므로 지점마다 조건을 심지 않는다 (스위치 OFF 면 무동작).
|
||||
$mailSettings = EnvPriority::filterLocked('mail', $mailSettings);
|
||||
|
||||
// 메일러 설정
|
||||
if (! empty($mailSettings['mailer'])) {
|
||||
Config::set('mail.default', $mailSettings['mailer']);
|
||||
@@ -108,8 +113,13 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
Config::set('mail.mailers.smtp.encryption', $mailSettings['encryption'] ?: null);
|
||||
}
|
||||
|
||||
// 드라이버별 설정
|
||||
$mailer = $mailSettings['mailer'] ?? '';
|
||||
// 드라이버별 설정 — 마스터 드라이버가 `.env` 로 잠기면 저장값이 이 배열에서 제거되어
|
||||
// 게이트가 빈 문자열이 된다. 그대로 두면 어느 분기에도 들어가지 않아 mailgun/ses 하위
|
||||
// 저장값(도메인·자격증명 등, 각자 잠기지 않았다)이 조용히 주입되지 않는다.
|
||||
// 잠긴 경우에는 유효값(= `.env` 유래 config)으로 게이트를 보정한다.
|
||||
$mailer = EnvPriority::isLocked('mail.mailer')
|
||||
? (string) config('mail.default')
|
||||
: ($mailSettings['mailer'] ?? '');
|
||||
|
||||
if ($mailer === 'mailgun') {
|
||||
if (! empty($mailSettings['mailgun_domain'])) {
|
||||
@@ -118,9 +128,15 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
if (! empty($mailSettings['mailgun_secret'])) {
|
||||
Config::set('services.mailgun.secret', $mailSettings['mailgun_secret']);
|
||||
}
|
||||
Config::set('services.mailgun.endpoint',
|
||||
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
|
||||
);
|
||||
// 저장값이 비어도 기본값을 박는다 — 종전 동작이다. 주입을 건너뛰는 경우는
|
||||
// `.env` 우선 모드가 이 키를 가져간 때뿐이므로 잠금 여부를 직접 묻는다.
|
||||
// 키 존재 여부(`array_key_exists`)로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이
|
||||
// 구분되지 않아, 전자에서도 기본값 주입이 사라진다.
|
||||
if (! EnvPriority::isLocked('mail.mailgun_endpoint')) {
|
||||
Config::set('services.mailgun.endpoint',
|
||||
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($mailer === 'ses') {
|
||||
@@ -130,9 +146,12 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
if (! empty($mailSettings['ses_secret'])) {
|
||||
Config::set('services.ses.secret', $mailSettings['ses_secret']);
|
||||
}
|
||||
Config::set('services.ses.region',
|
||||
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
|
||||
);
|
||||
// mailgun_endpoint 와 같은 사유의 가드 (위 주석 참조).
|
||||
if (! EnvPriority::isLocked('mail.ses_region')) {
|
||||
Config::set('services.ses.region',
|
||||
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 발신자 설정
|
||||
@@ -156,6 +175,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
$generalSettings = EnvPriority::filterLocked('general', $generalSettings);
|
||||
|
||||
if (! empty($generalSettings['site_name'])) {
|
||||
// site_name 이 다국어 JSON array 일 수 있으므로 현재/폴백 로케일 string 으로 정규화한다
|
||||
// (공개#49). raw array 를 config('app.name') 에 넣으면 app.blade.php 의
|
||||
@@ -240,6 +262,10 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function applyDebugConfig(JsonConfigRepository $configRepository): void
|
||||
{
|
||||
// G7_PLAYWRIGHT_BYPASS 는 호출자가 그 프로세스에만 부여하는 프로세스 환경변수다 —
|
||||
// `.env` 파일에 적히지 않으므로 config:cache 로 박제될 대상이 아니고, env() 가
|
||||
// 프로세스 환경을 그대로 읽으므로 config:cache 환경에서도 정상 판별된다.
|
||||
// (`.env` 유래 값의 런타임 env() 판별이 무력해지는 함정과는 다른 축이다.)
|
||||
if (app()->environment('testing') || env('G7_PLAYWRIGHT_BYPASS') === '1') {
|
||||
return;
|
||||
}
|
||||
@@ -250,14 +276,29 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
$isDebugMode = isset($debugSettings['mode']) && (bool) $debugSettings['mode'];
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
$debugSettings = EnvPriority::filterLocked('debug', $debugSettings);
|
||||
|
||||
$modeLocked = EnvPriority::isLocked('debug.mode');
|
||||
$logLevelLocked = EnvPriority::isLocked('debug.log_level');
|
||||
|
||||
// 디버그 모드가 `.env` 로 잠기면 그 유효값은 config('app.debug')(= APP_DEBUG)다.
|
||||
// 저장값을 읽으면 잠금으로 키가 사라져 항상 false 가 되고, 아래 두 2차 효과
|
||||
// (로그 레벨 강제·프록시 게이트)가 운영자 의도와 반대로 동작한다.
|
||||
$isDebugMode = $modeLocked
|
||||
? (bool) config('app.debug')
|
||||
: (isset($debugSettings['mode']) && (bool) $debugSettings['mode']);
|
||||
|
||||
if (isset($debugSettings['mode'])) {
|
||||
Config::set('app.debug', $isDebugMode);
|
||||
}
|
||||
|
||||
// debug 모드가 true이면 log_level을 debug로 강제 설정
|
||||
if ($isDebugMode) {
|
||||
// debug 모드가 true이면 log_level을 debug로 강제 설정.
|
||||
// 단 로그 레벨이 `.env` 로 잠긴 설치에서는 강제 자체를 하지 않는다 — 그 강제는
|
||||
// 저장값 경로의 편의 규칙이고, 잠긴 키의 권위는 `.env` 에 있다.
|
||||
if ($logLevelLocked) {
|
||||
$logLevel = null;
|
||||
} elseif ($isDebugMode) {
|
||||
$logLevel = 'debug';
|
||||
} elseif (! empty($debugSettings['log_level'])) {
|
||||
$logLevel = $debugSettings['log_level'];
|
||||
@@ -279,7 +320,10 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
// 아웃바운드 HTTP 프록시 설정.
|
||||
// 적용 여부 판정은 OutboundProxy 가 단독으로 소유한다 — 디버그 모드가 꺼져 있으면
|
||||
// 저장값이 남아 있어도 null 이 되어 주입되지 않는다.
|
||||
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings));
|
||||
// 게이트 값(mode)은 위에서 구한 유효값으로 보정해 넘긴다 — `+` 는 존재하는 키를
|
||||
// 덮지 않으므로 잠기지 않은 설치에서는 종전과 동일하다. 보정이 없으면 디버그 모드가
|
||||
// `.env` 로 잠긴 설치에서 프록시가 영구 미적용된다 (오류·로그 없이).
|
||||
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings + ['mode' => $isDebugMode]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,10 +345,17 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
// 아래 apply*Config 들은 이 배열을 인자로 받으므로 redis/reverb 처럼 한 설정이
|
||||
// 여러 config 키를 파생시키는 경우도 한 번에 처리된다.
|
||||
$driverSettings = EnvPriority::filterLocked('drivers', $driverSettings);
|
||||
|
||||
// testing 환경에서는 drivers.json의 cache/session/queue 오버라이드 차단 — 테스트 격리 보호
|
||||
// (storage/app/settings/drivers.json은 shared 파일이므로 dev의 Redis/DB 드라이버가
|
||||
// testing으로 흘러들어가면 dev 캐시를 오염시킴)
|
||||
$isTestingEnv = env('APP_ENV') === 'testing';
|
||||
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해지므로
|
||||
// 해석된 환경(app()->environment)으로 판정한다 — applyDebugConfig 와 동일 규약.
|
||||
$isTestingEnv = app()->environment('testing');
|
||||
|
||||
// 캐시 드라이버 설정
|
||||
if (! $isTestingEnv && ! empty($driverSettings['cache_driver'])) {
|
||||
@@ -346,7 +397,14 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
// 빈 문자열도 미명시로 취급한다 — `ATTACHMENT_DISK=` 가 복사된 .env 에서
|
||||
// 빈 값을 명시로 읽으면 전환이 영구 미발동한다 (config 정규화의 2차 방어).
|
||||
// 기존 행은 행 disk 로 서빙되므로 신구 디스크 혼재는 안전하다.
|
||||
if (($driverSettings['storage_driver'] ?? null) === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
|
||||
// storage_driver 가 `.env` 로 잠긴 설치에서는 저장값이 제거되어 있으므로 유효값으로
|
||||
// 판정한다. 보정하지 않으면 `FILESYSTEM_DISK=s3` 를 잠근 운영자의 첨부 업로드만
|
||||
// 로컬 디스크로 되돌아간다 (기존 행은 행 disk 로 서빙되므로 오류 없이 갈라진다).
|
||||
$storageDriver = EnvPriority::isLocked('drivers.storage_driver')
|
||||
? config('filesystems.default')
|
||||
: ($driverSettings['storage_driver'] ?? null);
|
||||
|
||||
if ($storageDriver === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
|
||||
Config::set('attachment.disk', 's3');
|
||||
}
|
||||
|
||||
@@ -372,7 +430,8 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function applyPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
|
||||
{
|
||||
if (env('APP_ENV') === 'testing') {
|
||||
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해진다.
|
||||
if (app()->environment('testing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -389,6 +448,10 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function injectPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
|
||||
{
|
||||
// audit:allow env-priority-filter-wiring 이 지점이 drivers 에서 읽는 키는
|
||||
// public_asset_disk 하나뿐이고 그 키는 EnvPriority::EXEMPT 다 (env 대응 없음).
|
||||
// 여기서 다른 drivers 키를 추가로 읽게 되면 그 키는 잠금을 우회하므로,
|
||||
// 그때는 이 면제를 걷어내고 filterLocked('drivers', …) 를 배선해야 한다.
|
||||
$driverSettings = $configRepository->getCategory('drivers');
|
||||
|
||||
$disk = (string) ($driverSettings['public_asset_disk'] ?? '');
|
||||
@@ -535,6 +598,14 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function applyWebsocketConfig(array $driverSettings): void
|
||||
{
|
||||
// 마스터 토글이 `.env` 로 잠긴 설치에서는 관리자 저장값이 이 배열에서 제거되어
|
||||
// 있으므로 `empty()` 가 참이 된다 — 그대로 두면 OFF 강제 3종이 오발동해
|
||||
// `.env`(BROADCAST_CONNECTION=reverb)로 웹소켓을 켠 운영자에게 강제 OFF 가 걸린다.
|
||||
// 잠긴 경우에는 OFF 강제도 ON 주입도 하지 않고 config 기본값(= `.env` 유래)을 서빙한다.
|
||||
if (EnvPriority::isLocked('drivers.websocket_enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($driverSettings['websocket_enabled'])) {
|
||||
Config::set('broadcasting.default', 'null');
|
||||
// 프론트(admin/app.blade.php)가 @if(broadcasting.connections.reverb.key)로 연결을
|
||||
@@ -562,13 +633,16 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
$serverHost = $driverSettings['websocket_server_host'] ?? '';
|
||||
$serverPort = (int) ($driverSettings['websocket_server_port'] ?? 0);
|
||||
$serverScheme = $driverSettings['websocket_server_scheme'] ?? '';
|
||||
if (empty($serverHost)) {
|
||||
// 잠긴 server 키에는 폴백을 태우지 않는다 — 클라이언트 endpoint 는 env 대응이 없는
|
||||
// 관리자 소유 값이라, 폴백을 그대로 두면 그 값이 `.env` 가 소유한 REVERB_HOST/PORT/SCHEME
|
||||
// 자리에 덮여 잠금이 무력해진다. 잠긴 키는 빈 값으로 남아 아래 주입에서 건너뛰어진다.
|
||||
if (empty($serverHost) && ! EnvPriority::isLocked('drivers.websocket_server_host')) {
|
||||
$serverHost = $clientHost;
|
||||
}
|
||||
if ($serverPort <= 0) {
|
||||
if ($serverPort <= 0 && ! EnvPriority::isLocked('drivers.websocket_server_port')) {
|
||||
$serverPort = $clientPort;
|
||||
}
|
||||
if (empty($serverScheme)) {
|
||||
if (empty($serverScheme) && ! EnvPriority::isLocked('drivers.websocket_server_scheme')) {
|
||||
$serverScheme = $clientScheme;
|
||||
}
|
||||
$serverUseTLS = $serverScheme === 'https';
|
||||
@@ -632,6 +706,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
$settings = EnvPriority::filterLocked('core_update', $settings);
|
||||
|
||||
if (! empty($settings['github_url'])) {
|
||||
Config::set('app.update.github_url', $settings['github_url']);
|
||||
}
|
||||
@@ -658,6 +735,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
$settings = EnvPriority::filterLocked('geoip', $settings);
|
||||
|
||||
if (isset($settings['feature_enabled'])) {
|
||||
Config::set('geoip.enabled', (bool) $settings['feature_enabled']);
|
||||
}
|
||||
@@ -691,6 +771,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
*/
|
||||
private function applyIdentityConfig(JsonConfigRepository $configRepository): void
|
||||
{
|
||||
// audit:allow env-priority-filter-wiring identity 카테고리는 전 키가 EnvPriority::EXEMPT 다
|
||||
// (env 대응이 없어 잠글 대상이 하나도 없다) — filterLocked 를 걸어도 no-op 이다.
|
||||
// 이 카테고리에 env 대응 키가 생기면 EnvPriorityContractTest 의 맵 패리티가 먼저 red 가 된다.
|
||||
$settings = $configRepository->getCategory('identity');
|
||||
|
||||
if (empty($settings)) {
|
||||
@@ -711,6 +794,9 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return;
|
||||
}
|
||||
|
||||
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
|
||||
$uploadSettings = EnvPriority::filterLocked('upload', $uploadSettings);
|
||||
|
||||
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
|
||||
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
|
||||
if (! empty($uploadSettings['max_file_size'])) {
|
||||
|
||||
@@ -89,7 +89,13 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
|
||||
}
|
||||
|
||||
/**
|
||||
* 미소비된 검증 토큰으로 로그를 조회합니다.
|
||||
* 미소비·미만료 검증 토큰으로 로그를 조회합니다.
|
||||
*
|
||||
* 만료 술어(expires_at > now)는 이 지점이 단일 관문이다. 정책 미들웨어·정책 서비스·
|
||||
* 회원가입/비밀번호재설정 리스너·IdvTokenRule 이 모두 이 메서드를 경유하므로
|
||||
* 호출부마다 만료 검사를 중복해 두지 않는다(한쪽 누락 시 그 경로가 우회로가 된다).
|
||||
* expires_at 이 비어 있는 로그도 반환하지 않는다 — 모든 provider 가 challenge 생성 시
|
||||
* expires_at 을 세팅하므로 NULL 은 정상 발급 산물이 아니다.
|
||||
*
|
||||
* @param string $token 검증 토큰
|
||||
* @param string $purpose 본인인증 목적
|
||||
@@ -102,6 +108,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
|
||||
->where('purpose', $purpose)
|
||||
->where('status', IdentityVerificationStatus::Verified->value)
|
||||
->whereNull('consumed_at')
|
||||
->where('expires_at', '>', Carbon::now())
|
||||
->first();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,20 @@ class AllowedModuleFileType implements ValidationRule
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
];
|
||||
|
||||
/**
|
||||
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||
*
|
||||
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||
*
|
||||
* @return array<string> 기본 허용 확장자 목록
|
||||
*/
|
||||
public static function allowedExtensions(): array
|
||||
{
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 파일 타입인지 검증
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,20 @@ class AllowedPluginFileType implements ValidationRule
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
];
|
||||
|
||||
/**
|
||||
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||
*
|
||||
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||
*
|
||||
* @return array<string> 기본 허용 확장자 목록
|
||||
*/
|
||||
public static function allowedExtensions(): array
|
||||
{
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 파일 타입인지 검증
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,20 @@ class AllowedTemplateFileType implements ValidationRule
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
];
|
||||
|
||||
/**
|
||||
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
|
||||
*
|
||||
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
|
||||
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
|
||||
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
|
||||
*
|
||||
* @return array<string> 기본 허용 확장자 목록
|
||||
*/
|
||||
public static function allowedExtensions(): array
|
||||
{
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용된 파일 타입인지 검증
|
||||
*/
|
||||
|
||||
@@ -8,8 +8,12 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
||||
/**
|
||||
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
|
||||
*
|
||||
* 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:,
|
||||
* javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
||||
* 컴포넌트 props·actions·lifecycle·onComponentEvent·slots·component_layout·responsive 와
|
||||
* 최상위 init_actions/initActions·modals·named_actions·errorHandling 내의 http://, https://,
|
||||
* data:, javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
|
||||
*
|
||||
* 순회 대상은 "액션이 실행되거나 값이 sink(컴포넌트 prop)로 흘러 들어가는 자리" 다.
|
||||
* 한 자리만 빠져도 그 키가 그대로 저장 우회로가 되며, 우회는 오류를 남기지 않는다.
|
||||
*
|
||||
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
|
||||
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
|
||||
@@ -55,55 +59,128 @@ class NoExternalUrls implements ValidationRule
|
||||
$this->validateComponents($value['components'], $fail);
|
||||
}
|
||||
|
||||
// init_actions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 로드 시점
|
||||
// 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 검사한다.
|
||||
if (isset($value['init_actions']) && is_array($value['init_actions'])) {
|
||||
foreach ($value['init_actions'] as $i => $action) {
|
||||
if (is_array($action)) {
|
||||
$this->validateObject($action, "init_actions[$i]", $fail);
|
||||
// init_actions / initActions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은
|
||||
// 로드 시점 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게
|
||||
// 검사한다. 엔진(LayoutLoader)이 두 철자를 모두 소비하므로 두 철자 모두 검사한다 —
|
||||
// 한쪽만 보면 다른 철자가 그대로 우회로가 된다.
|
||||
foreach (['init_actions', 'initActions'] as $initKey) {
|
||||
if (isset($value[$initKey]) && is_array($value[$initKey])) {
|
||||
foreach ($value[$initKey] as $i => $action) {
|
||||
if (is_array($action)) {
|
||||
$this->validateObject($action, "{$initKey}[$i]", $fail);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modals: 각 항목이 컴포넌트 정의다 — 모달 안의 props/actions 도 같은 sink 이므로
|
||||
// 컴포넌트와 동일하게 재귀 검사한다.
|
||||
if (isset($value['modals']) && is_array($value['modals'])) {
|
||||
foreach ($value['modals'] as $modalKey => $modal) {
|
||||
if (! is_array($modal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->validateComponents([$modal], $fail, "modals.$modalKey");
|
||||
|
||||
if (isset($modal['components']) && is_array($modal['components'])) {
|
||||
$this->validateComponents($modal['components'], $fail, "modals.$modalKey.components");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// named_actions: 이름으로 호출되는 액션 정의 — 실행 시 컴포넌트 actions 와 동일한 sink.
|
||||
if (isset($value['named_actions']) && is_array($value['named_actions'])) {
|
||||
foreach ($value['named_actions'] as $name => $named) {
|
||||
if (is_array($named)) {
|
||||
$this->validateObject($named, "named_actions.$name", $fail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errorHandling: 오류 시 실행되는 액션(navigate/apiCall)을 담는다.
|
||||
if (isset($value['errorHandling']) && is_array($value['errorHandling'])) {
|
||||
$this->validateObject($value['errorHandling'], 'errorHandling', $fail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* components 배열을 재귀적으로 검증
|
||||
*
|
||||
* @param array $components 컴포넌트 정의 배열
|
||||
* @param Closure $fail 실패 콜백
|
||||
* @param string $basePath 오류 메시지에 실을 경로 접두 (modals/slots 경로 보존)
|
||||
*/
|
||||
private function validateComponents(array $components, Closure $fail): void
|
||||
private function validateComponents(array $components, Closure $fail, string $basePath = 'components'): void
|
||||
{
|
||||
foreach ($components as $index => $component) {
|
||||
if (! is_array($component)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = "{$basePath}[$index]";
|
||||
|
||||
// props 검사
|
||||
if (isset($component['props']) && is_array($component['props'])) {
|
||||
$this->validateObject($component['props'], "components[$index].props", $fail);
|
||||
$this->validateObject($component['props'], "$path.props", $fail);
|
||||
}
|
||||
|
||||
// actions 검사
|
||||
if (isset($component['actions']) && is_array($component['actions'])) {
|
||||
$this->validateActions($component['actions'], $index, $fail);
|
||||
$this->validateActions($component['actions'], $path, $fail);
|
||||
}
|
||||
|
||||
// lifecycle: 마운트/언마운트 시 자동 실행되는 액션 — init_actions 와 같은 성격이다.
|
||||
if (isset($component['lifecycle']) && is_array($component['lifecycle'])) {
|
||||
$this->validateObject($component['lifecycle'], "$path.lifecycle", $fail);
|
||||
}
|
||||
|
||||
// onComponentEvent: 컴포넌트 이벤트로 발화되는 액션 배열.
|
||||
if (isset($component['onComponentEvent']) && is_array($component['onComponentEvent'])) {
|
||||
$this->validateObject($component['onComponentEvent'], "$path.onComponentEvent", $fail);
|
||||
}
|
||||
|
||||
// slots: 슬롯 이름별 컴포넌트 배열 — 슬롯 안의 컴포넌트도 같은 sink 다.
|
||||
if (isset($component['slots']) && is_array($component['slots'])) {
|
||||
foreach ($component['slots'] as $slotName => $slotComponents) {
|
||||
if (is_array($slotComponents)) {
|
||||
$this->validateComponents($slotComponents, $fail, "$path.slots.$slotName");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// component_layout: 컴포넌트가 품는 하위 레이아웃 정의.
|
||||
if (isset($component['component_layout']) && is_array($component['component_layout'])) {
|
||||
$this->validateObject($component['component_layout'], "$path.component_layout", $fail);
|
||||
}
|
||||
|
||||
// responsive: breakpoint 별 props/children 오버라이드 — 그 안의 값도 같은 sink 다.
|
||||
if (isset($component['responsive']) && is_array($component['responsive'])) {
|
||||
$this->validateObject($component['responsive'], "$path.responsive", $fail);
|
||||
}
|
||||
|
||||
// children 재귀 검사
|
||||
if (isset($component['children']) && is_array($component['children'])) {
|
||||
$this->validateComponents($component['children'], $fail);
|
||||
$this->validateComponents($component['children'], $fail, "$path.children");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* actions 배열 검증
|
||||
*
|
||||
* @param array $actions 액션 정의 배열
|
||||
* @param string $componentPath 컴포넌트 경로 (오류 메시지용)
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
private function validateActions(array $actions, int $componentIndex, Closure $fail): void
|
||||
private function validateActions(array $actions, string $componentPath, Closure $fail): void
|
||||
{
|
||||
foreach ($actions as $actionIndex => $action) {
|
||||
if (! is_array($action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->validateObject($action, "components[$componentIndex].actions[$actionIndex]", $fail);
|
||||
$this->validateObject($action, "{$componentPath}.actions[$actionIndex]", $fail);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,9 @@ class SafeLayoutExpressions implements ValidationRule
|
||||
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
|
||||
* 않으므로 그대로 통과합니다(과차단 없음).
|
||||
*
|
||||
* 클라이언트(`TemplateApp.isAllowedScriptSrc`)·정적 검사
|
||||
* 클라이언트(`resources/js/core/support/scriptSrcPolicy.ts::isAllowedScriptSrc` — 레이아웃
|
||||
* `scripts[]` 뿐 아니라 loadScript 액션·확장 핸들러 재로드·편집기 프리뷰·
|
||||
* `G7Core.asset.loadScript` 가 공유하는 런타임 SSoT)·정적 검사
|
||||
* (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다.
|
||||
*
|
||||
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측
|
||||
|
||||
@@ -1457,6 +1457,9 @@ class ComponentHtmlMapper
|
||||
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
|
||||
$resolved = $evaluator->evaluateRaw($value, $context);
|
||||
$data[$key] = $resolved;
|
||||
} elseif ($this->isSwitchDefinition($value)) {
|
||||
// $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
|
||||
$data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
|
||||
} elseif (is_array($value)) {
|
||||
// 중첩 객체 (예: socialLinks): 재귀적으로 해석
|
||||
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
|
||||
@@ -1482,6 +1485,8 @@ class ComponentHtmlMapper
|
||||
foreach ($values as $k => $v) {
|
||||
if (is_string($v) && str_contains($v, '{{')) {
|
||||
$result[$k] = $evaluator->evaluateRaw($v, $context);
|
||||
} elseif ($this->isSwitchDefinition($v)) {
|
||||
$result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
|
||||
} elseif (is_array($v)) {
|
||||
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
|
||||
} else {
|
||||
@@ -1492,6 +1497,71 @@ class ComponentHtmlMapper
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값이 `$switch` 선언적 분기 객체인지 판정합니다.
|
||||
*
|
||||
* React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases`
|
||||
* 키를 모두 가진 객체(연관 배열)만 해당한다.
|
||||
*
|
||||
* @param mixed $value 판정 대상 값
|
||||
* @return bool $switch 정의 여부
|
||||
*/
|
||||
private function isSwitchDefinition(mixed $value): bool
|
||||
{
|
||||
return is_array($value)
|
||||
&& array_key_exists('$switch', $value)
|
||||
&& array_key_exists('$cases', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `$switch` 선언적 분기 객체를 해석합니다.
|
||||
*
|
||||
* React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티):
|
||||
* ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열)
|
||||
* ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined)
|
||||
* ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함)
|
||||
*
|
||||
* 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가
|
||||
* 별도 처리한다 — 이 메서드는 노드 props 값 축 담당.
|
||||
*
|
||||
* @param array $definition $switch 정의 { "$switch", "$cases", "$default"? }
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return mixed 해석된 값 (매칭·기본값 모두 없으면 null)
|
||||
*/
|
||||
private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed
|
||||
{
|
||||
try {
|
||||
$keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context));
|
||||
} catch (\Throwable) {
|
||||
$keyValue = '';
|
||||
}
|
||||
|
||||
$cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : [];
|
||||
|
||||
if ($keyValue !== '' && array_key_exists($keyValue, $cases)) {
|
||||
$result = $cases[$keyValue];
|
||||
} elseif (array_key_exists('$default', $definition)) {
|
||||
$result = $definition['$default'];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($result)) {
|
||||
return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result;
|
||||
}
|
||||
|
||||
if ($this->isSwitchDefinition($result)) {
|
||||
return $this->resolveSwitchValue($result, $context, $evaluator);
|
||||
}
|
||||
|
||||
if (is_array($result)) {
|
||||
return $this->resolveAllPropsRecursive($result, $context, $evaluator);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {field|alt_field} 패턴에서 아이템 값을 해석합니다.
|
||||
*
|
||||
@@ -1688,6 +1758,12 @@ class ComponentHtmlMapper
|
||||
continue;
|
||||
}
|
||||
|
||||
// $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
|
||||
// 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
|
||||
if ($this->isSwitchDefinition($value)) {
|
||||
$value = $this->resolveSwitchValue($value, $context, $evaluator);
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$evaluated = $evaluator->evaluate($value, $context);
|
||||
if ($evaluated !== '') {
|
||||
|
||||
+76
-3
@@ -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,18 +703,72 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리의 절대 경로를 반환합니다.
|
||||
*
|
||||
* 테스트가 임시 디렉토리를 템플릿 루트로 쓸 수 있도록 분리한 seam 이다.
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @return string 템플릿 루트 절대 경로
|
||||
*/
|
||||
protected function templateRootPath(string $identifier): string
|
||||
{
|
||||
return base_path("templates/{$identifier}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿의 CSS 에셋 URL 목록을 반환합니다.
|
||||
*
|
||||
* template.json의 assets.css 경로를 서빙 URL로 변환합니다.
|
||||
* 예: "dist/css/components.css" → "/api/templates/assets/{id}/css/components.css"
|
||||
*
|
||||
* `assets.css` 는 **선언**일 뿐이라 산출물이 없을 수 있다. 없는 경로를 그대로 링크하면
|
||||
* 봇 화면에서만 404 가 나고 일반 화면에는 흔적이 없다 — 서버 로그에도 남지 않아
|
||||
* 운영자가 알 방법이 없다. 그래서 파일이 실재하는 경로만 싣는다(0바이트는 정상).
|
||||
*
|
||||
* @param string $templateIdentifier 템플릿 식별자
|
||||
* @return array CSS URL 배열
|
||||
*/
|
||||
private function getTemplateCssUrls(string $templateIdentifier): array
|
||||
{
|
||||
$templateJsonPath = base_path("templates/{$templateIdentifier}/template.json");
|
||||
$templateRoot = $this->templateRootPath($templateIdentifier);
|
||||
$templateJsonPath = $templateRoot.'/template.json';
|
||||
if (! file_exists($templateJsonPath)) {
|
||||
return [];
|
||||
}
|
||||
@@ -728,9 +785,20 @@ class SeoRenderer implements SeoRendererInterface
|
||||
|
||||
$urls = [];
|
||||
foreach ($cssPaths as $cssPath) {
|
||||
// 선언한 파일이 실재할 때만 링크한다 — 없는 경로의 <link> 는 봇 화면에서만
|
||||
// 404 가 되고 어디에도 흔적을 남기지 않는다
|
||||
if (! is_file($templateRoot.'/'.$cssPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
|
||||
$servePath = preg_replace('#^dist/#', '', $cssPath);
|
||||
$urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath);
|
||||
|
||||
// 정적 게시본(bake) 경로 금지 — 이 URL 은 SeoCacheManager(`seo.page.*`,
|
||||
// 키에 cache_version 미포함)에 캐시된 HTML 에 박제되는데, 정적 디렉토리는
|
||||
// GC 가 현재+직전 1개만 보존해 캐시 수명 안에 404 가 될 수 있다. SEO HTML 은
|
||||
// asset-url-recovery 파샬도 없어 자가 복구가 불가하므로 무버전 API URL 고정.
|
||||
$urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath, allowStatic: false);
|
||||
}
|
||||
|
||||
return $urls;
|
||||
@@ -954,6 +1022,11 @@ class SeoRenderer implements SeoRendererInterface
|
||||
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
|
||||
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
|
||||
*
|
||||
* **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) —
|
||||
* 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션
|
||||
* 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위
|
||||
* `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조).
|
||||
*
|
||||
* @param mixed $block 초기 상태 블록 (키 → 값)
|
||||
* @param array $context 현재 컨텍스트 (route, query 등 포함)
|
||||
* @return array 평가된 초기 상태
|
||||
|
||||
@@ -100,20 +100,7 @@ class AuthService
|
||||
// 사전 잠금 체크 — 잠긴 계정은 Auth::attempt 자체를 시도하지 않는다.
|
||||
// (실패 카운트가 0 으로 리셋된 잠금 상태에서 Failed 이벤트가 다시
|
||||
// 카운트를 올려 재잠금 시각을 갱신하는 부작용 방지)
|
||||
if ((bool) g7_core_settings('security.login_attempt_enabled', true)) {
|
||||
$candidate = $this->userRepository->findByEmail($email);
|
||||
if ($candidate !== null && $this->userRepository->isLocked($candidate)) {
|
||||
// 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기.
|
||||
$remaining = $candidate->locked_until === null
|
||||
? null
|
||||
: max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60));
|
||||
|
||||
throw new AccountLockedException(
|
||||
lockedUntil: $candidate->locked_until,
|
||||
remainingMinutes: $remaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->assertNotLocked($this->userRepository->findByEmail($email));
|
||||
|
||||
if (! Auth::attempt(['email' => $email, 'password' => $password])) {
|
||||
// 실패 카운트 증가/잠금 처리는 HandleFailedLoginListener 에서 담당
|
||||
@@ -158,6 +145,40 @@ class AuthService
|
||||
return $this->issueLoginSession($user, $email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정이 잠겨 있으면 예외를 던집니다.
|
||||
*
|
||||
* 세션(토큰)을 발급하는 지점은 전부 이 검사를 거쳐야 합니다. 2단계 인증이 켜져 있으면
|
||||
* 비밀번호 확인(`login`)은 challenge 만 돌려주고 실제 세션은 `completeTwoFactor()` 가
|
||||
* 발급하므로, 한쪽에만 검사가 있으면 잠기기 전에 받아 둔 challenge 를 잠긴 뒤 완료하는
|
||||
* 것만으로 잠금이 통째로 우회됩니다. 그 뒤 로그인 완료 훅이 실패 횟수·잠금 시각까지
|
||||
* 초기화해 흔적도 남지 않습니다.
|
||||
*
|
||||
* @param User|null $user 검사 대상 사용자 (없으면 검사 대상 아님)
|
||||
*
|
||||
* @throws AccountLockedException 계정이 잠겨 있을 때
|
||||
*/
|
||||
private function assertNotLocked(?User $user): void
|
||||
{
|
||||
if (! (bool) g7_core_settings('security.login_attempt_enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user === null || ! $this->userRepository->isLocked($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기.
|
||||
$remaining = $user->locked_until === null
|
||||
? null
|
||||
: max(1, (int) ceil(now()->diffInSeconds($user->locked_until, false) / 60));
|
||||
|
||||
throw new AccountLockedException(
|
||||
lockedUntil: $user->locked_until,
|
||||
remainingMinutes: $remaining,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다.
|
||||
*
|
||||
@@ -268,6 +289,10 @@ class AuthService
|
||||
]);
|
||||
}
|
||||
|
||||
// 세션을 여는 것은 이 지점이다 — challenge 발급 이후에 잠겼을 수 있으므로 재검사한다.
|
||||
// Auth::login() 앞에 두어야 로그인 완료 훅이 잠금 필드를 초기화하지 못한다.
|
||||
$this->assertNotLocked($user);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return $this->issueLoginSession($user, (string) $user->email);
|
||||
@@ -446,9 +471,15 @@ class AuthService
|
||||
*
|
||||
* @param User $user 토큰을 갱신할 사용자
|
||||
* @return array 새로운 토큰 정보
|
||||
*
|
||||
* @throws AccountLockedException 계정이 잠겨 있을 때
|
||||
*/
|
||||
public function refreshToken(User $user): array
|
||||
{
|
||||
// 재발급도 세션을 여는 지점이다. 유효한 기존 세션이 전제라 신규 로그인 우회는
|
||||
// 아니지만, 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다.
|
||||
$this->assertNotLocked($user);
|
||||
|
||||
// 현재 토큰 삭제 (다른 디바이스는 유지)
|
||||
$currentToken = $user->currentAccessToken();
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ use App\Extension\Vendor\VendorResolver;
|
||||
use Database\Seeders\IdentityMessageDefinitionSeeder;
|
||||
use Database\Seeders\IdentityPolicySeeder;
|
||||
use Database\Seeders\NotificationDefinitionSeeder;
|
||||
use Dotenv\Dotenv;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Env;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -1626,6 +1628,47 @@ class CoreUpdateService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 `config/app.php` 에서 `update.{$key}` 를 직접 읽습니다.
|
||||
*
|
||||
* spawn 자식(`core:execute-upgrade-steps`)은 부모가 spawn 전에 config 캐시를 비우지 않은 경우
|
||||
* (7.0.9 이하 부모) 이전 버전 설치본의 `bootstrap/cache/config.php` 로 부팅한다. 그 상태의
|
||||
* `config('app.update.*')` 는 캐시에 박힌 옛 목록이라, 신버전이 추가한 항목(7.0.10 의
|
||||
* `public/build/ext` 쓰기 권한 디렉토리)이 자식의 권한 정상화에서 빠진다 (2026-09-06 서버 실측).
|
||||
* 본 메서드는 메모리 config 를 건드리지 않고 디스크 파일을 평가해 신버전 값을 돌려준다.
|
||||
*
|
||||
* 캐시 부팅에서는 `.env` 도 로드되지 않으므로(`LoadEnvironmentVariables` 가 건너뜀), 운영자의
|
||||
* `G7_UPDATE_*` 재정의가 `config/app.php` 의 `env()` 에 보이도록 `.env` 를 먼저 불변 로드한다 —
|
||||
* 이미 프로세스 env 에 있는 값(부모가 넘긴 `APP_VERSION` 등)은 덮어쓰지 않는다.
|
||||
*
|
||||
* @param string $key `config/app.php` 의 `update` 배열 키
|
||||
* @param mixed $default 파일에 키가 없을 때 돌려줄 값
|
||||
* @return mixed 디스크 config 의 값
|
||||
*/
|
||||
public function freshDiskUpdateConfig(string $key, mixed $default = []): mixed
|
||||
{
|
||||
$path = config_path('app.php');
|
||||
if (! File::exists($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (app()->configurationIsCached() && File::exists(base_path('.env'))) {
|
||||
try {
|
||||
// audit:allow service-direct-data-access reason: Dotenv 는 모델이 아니라 .env 파서 — 캐시 부팅에서 로드되지 않은 .env 를 불변 로드한다 (프로세스 env 우선)
|
||||
Dotenv::create(Env::getRepository(), base_path(), '.env')->safeLoad();
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('upgrade')->warning('freshDiskUpdateConfig: .env 로드 실패 — 프로세스 env 만으로 평가', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$fresh = require $path;
|
||||
if (! is_array($fresh)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $fresh['update'][$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 업그레이드 스텝을 실행합니다.
|
||||
* 각 스텝에서 환경설정 파일 생성, 데이터 마이그레이션 등을 수행합니다.
|
||||
@@ -1650,9 +1693,22 @@ class CoreUpdateService
|
||||
// 보유한 채 step 을 실행 중. upgrade step 안에서 신규 메서드 호출 시 fatal 위험.
|
||||
// `spawn_failure_mode` 와 연동하여 abort/fallback 분기.
|
||||
//
|
||||
// spawn 자식 (ExecuteUpgradeStepsCommand) 의 경우 spawn env 의 APP_VERSION=toVersion
|
||||
// 이 적용된 채 새 프로세스에서 부팅되므로 memoryVersion === toVersion → 가드 미발동.
|
||||
$memoryVersion = (string) config('app.version', $fromVersion);
|
||||
// spawn 자식 (ExecuteUpgradeStepsCommand) 은 spawn env 의 APP_VERSION=toVersion 을 받아
|
||||
// 부팅되므로 memoryVersion === toVersion → 가드 미발동이어야 한다.
|
||||
//
|
||||
// 판독은 `CoreVersionChecker::getCoreVersion()` (env 우선, config 폴백) 으로 한다.
|
||||
// `config('app.version')` 만 읽으면 안 된다 — 부모는 spawn 전(Step 10)에 config 캐시를
|
||||
// 비우지 않으므로, 이전 버전 설치본의 `bootstrap/cache/config.php` 가 있으면 자식은
|
||||
// 그 캐시로 부팅해 config 에는 fromVersion 이 박혀 있고 env 오버라이드는 무시된다.
|
||||
// 그 상태에서 config 만 보면 정상 spawn 자식을 stale 부모로 오판해 abort 한다
|
||||
// (7.0.9→7.0.10 실사례, 2026-09-06 — 스텝 0건 릴리즈에서도 중단). 이전 릴리즈에서는
|
||||
// 자식 진입부의 `config:cache` 가 전역 Container 를 일회용 앱으로 바꿔 놓는 부수효과로
|
||||
// `config()` 가 우연히 env 기반 값을 읽어 가드가 침묵했을 뿐이며, 그 부수효과는
|
||||
// `ConfigCacheHelper::withPreservedContainer` 가 제거했다.
|
||||
//
|
||||
// 부모 in-process fallback 에서는 env 가 .env 의 APP_VERSION(= 아직 fromVersion, Step 11 전)
|
||||
// 이므로 가드가 그대로 발동한다.
|
||||
$memoryVersion = CoreVersionChecker::getCoreVersion() ?: (string) config('app.version', $fromVersion);
|
||||
if (version_compare($memoryVersion, $toVersion, '<')) {
|
||||
$mode = config('app.update.spawn_failure_mode', 'fallback');
|
||||
$message = sprintf(
|
||||
@@ -1850,13 +1906,126 @@ class CoreUpdateService
|
||||
/**
|
||||
* _pending 하위 디렉토리를 정리합니다.
|
||||
*
|
||||
* 타임스탬프 기반 격리 디렉토리를 통째로 삭제합니다.
|
||||
* 타임스탬프 기반 격리 디렉토리(`core_{Ymd_His}/`)를 통째로 삭제합니다.
|
||||
*
|
||||
* @param string $pendingPath 삭제할 pending 디렉토리 경로
|
||||
* 호출자가 넘기는 경로는 격리 디렉토리 자체가 아니라 그 안쪽의 소스 경로일 수 있다 —
|
||||
* ZIP·GitHub 경로는 `core_{ts}/extracted/{루트}/` 를, `--local` 은 `core_{ts}/local_source/`
|
||||
* 를 소스로 돌려준다. 그 안쪽만 지우면 `core_{ts}/extracted/` 껍데기가 업데이트마다 남고,
|
||||
* sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없다(7.0.0 부터 누적된 실사례).
|
||||
* 그래서 격리 디렉토리 루트로 올라가서 지운다.
|
||||
*
|
||||
* @param string $pendingPath 삭제할 pending 경로 (격리 디렉토리 또는 그 하위 소스 경로)
|
||||
*/
|
||||
public function cleanupPending(string $pendingPath): void
|
||||
{
|
||||
ExtensionPendingHelper::cleanupStaging($pendingPath);
|
||||
ExtensionPendingHelper::cleanupStaging($this->resolveStagingRoot($pendingPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로가 속한 격리 디렉토리(`{pending_path}/core_*`) 루트를 돌려줍니다.
|
||||
*
|
||||
* 경로가 pending 기준 디렉토리 아래가 아니면 그대로 돌려준다 (`--source` 로 넘어온
|
||||
* 외부 디렉토리처럼 우리가 만들지 않은 경로를 위로 올라가 지우는 일이 없도록).
|
||||
*
|
||||
* @param string $path 격리 디렉토리 또는 그 하위 경로
|
||||
* @return string 격리 디렉토리 루트 또는 입력 경로 그대로
|
||||
*/
|
||||
public function resolveStagingRoot(string $path): string
|
||||
{
|
||||
$rawBase = rtrim((string) config('app.update.pending_path'), '/\\');
|
||||
$base = str_replace('\\', '/', $rawBase);
|
||||
$normalized = rtrim(str_replace('\\', '/', $path), '/');
|
||||
|
||||
if ($base === '' || $normalized === $base || ! str_starts_with($normalized, $base.'/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$relative = substr($normalized, strlen($base) + 1);
|
||||
$first = explode('/', $relative, 2)[0];
|
||||
|
||||
if ($first === '' || $first === '.' || $first === '..') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $rawBase.DIRECTORY_SEPARATOR.$first;
|
||||
}
|
||||
|
||||
/**
|
||||
* pending 기준 디렉토리에 남은 **빈** 격리 디렉토리(`core_*`)를 청소합니다.
|
||||
*
|
||||
* 이전 버전의 정리 단계가 소스 경로 안쪽만 지워 남긴 `core_{ts}/extracted/` 껍데기가
|
||||
* 대상이다. 부모(구버전 코드)가 남긴 것을 새 코드가 도는 자식 프로세스가 치우므로,
|
||||
* 이 결함을 가진 버전에서 올라오는 업데이트도 껍데기 없이 끝난다.
|
||||
*
|
||||
* 파일이 하나라도 있는 디렉토리는 건드리지 않는다 — 부모가 아직 쓰고 있는 격리
|
||||
* 디렉토리(추출본·vendor)는 파일을 갖고 있으므로 이 술어만으로 안전하게 구분된다.
|
||||
*
|
||||
* @return int 삭제한 격리 디렉토리 수
|
||||
*/
|
||||
public function sweepEmptyStagingDirectories(): int
|
||||
{
|
||||
$base = (string) config('app.update.pending_path');
|
||||
|
||||
if ($base === '' || ! File::isDirectory($base)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$swept = 0;
|
||||
|
||||
foreach (File::directories($base) as $dir) {
|
||||
if (! str_starts_with(basename($dir), 'core_') || is_link($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isDirectoryTreeEmpty($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ExtensionPendingHelper::cleanupStaging($dir);
|
||||
|
||||
if (File::isDirectory($dir)) {
|
||||
Log::channel('upgrade')->warning('코어 업데이트: 빈 격리 디렉토리 청소 실패 (권한)', ['path' => $dir]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$swept++;
|
||||
}
|
||||
|
||||
if ($swept > 0) {
|
||||
Log::channel('upgrade')->info('코어 업데이트: 빈 격리 디렉토리 청소', ['swept' => $swept]);
|
||||
}
|
||||
|
||||
return $swept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리 트리에 파일(또는 링크)이 하나도 없는지 판정합니다.
|
||||
*
|
||||
* 읽을 수 없는 하위 디렉토리가 있으면 "비어 있지 않다" 로 본다 — 내용을 모르는
|
||||
* 디렉토리를 지우지 않기 위해서다.
|
||||
*
|
||||
* @param string $dir 판정할 디렉토리
|
||||
*/
|
||||
private function isDirectoryTreeEmpty(string $dir): bool
|
||||
{
|
||||
try {
|
||||
$items = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item->isLink() || ! $item->isDir()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isDirectoryTreeEmpty($item->getPathname())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2084,10 +2253,16 @@ class CoreUpdateService
|
||||
* - chown 미지원 환경(Windows 등) 은 빈 배열 반환
|
||||
* - symbolic link 는 lstat 으로 처리하여 대상 따라가지 않음 (은닉 cycle 방어)
|
||||
*
|
||||
* 제외 경로(`$excludes`)는 이번 실행이 스스로 만든 격리 디렉토리를 넘기는 자리다.
|
||||
* 스냅샷은 격리 디렉토리가 만들어진 **뒤에** 수집되므로, 제외하지 않으면 sudo 실행이
|
||||
* root 로 만든 추출본이 "원본 소유권" 으로 기록되고 복원 단계가 그 항목을 다시 root
|
||||
* 로 되돌린다 — 정리가 어떤 이유로든 실패하면 잔존물은 언제나 root 소유가 된다.
|
||||
*
|
||||
* @param array<int, string> $paths base_path 상대 또는 절대 경로 목록
|
||||
* @param array<int, string> $excludes 스냅샷에서 제외할 경로 (그 하위 전체 포함)
|
||||
* @return array<string, array{owner:int|false, group:int|false, perms:int|null, is_dir:bool, is_link:bool}>
|
||||
*/
|
||||
public function snapshotOwnershipDetailed(array $paths): array
|
||||
public function snapshotOwnershipDetailed(array $paths, array $excludes = []): array
|
||||
{
|
||||
if (! function_exists('chown')) {
|
||||
return [];
|
||||
@@ -2096,6 +2271,14 @@ class CoreUpdateService
|
||||
$snapshot = [];
|
||||
$maxItems = 50000;
|
||||
$truncated = false;
|
||||
$excludePrefixes = [];
|
||||
|
||||
foreach ($excludes as $exclude) {
|
||||
$exclude = rtrim(str_replace('\\', '/', trim((string) $exclude)), '/');
|
||||
if ($exclude !== '') {
|
||||
$excludePrefixes[] = $exclude;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($paths as $rawPath) {
|
||||
$rawPath = trim((string) $rawPath);
|
||||
@@ -2108,7 +2291,7 @@ class CoreUpdateService
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->collectStatRecursively($absolute, $snapshot, $maxItems, $truncated);
|
||||
$this->collectStatRecursively($absolute, $snapshot, $maxItems, $truncated, $excludePrefixes);
|
||||
|
||||
if ($truncated) {
|
||||
break;
|
||||
@@ -2150,8 +2333,9 @@ class CoreUpdateService
|
||||
* 트리를 재귀 stat 하여 snapshot 배열에 누적합니다.
|
||||
*
|
||||
* @param array<string, array{owner:int|false, group:int|false, perms:int|null, is_dir:bool, is_link:bool}> $snapshot
|
||||
* @param array<int, string> $excludePrefixes 제외 경로(슬래시 정규화, 끝 슬래시 없음) — 일치하거나 그 하위면 건너뛴다
|
||||
*/
|
||||
private function collectStatRecursively(string $path, array &$snapshot, int $maxItems, bool &$truncated): void
|
||||
private function collectStatRecursively(string $path, array &$snapshot, int $maxItems, bool &$truncated, array $excludePrefixes = []): void
|
||||
{
|
||||
if ($truncated || count($snapshot) >= $maxItems) {
|
||||
$truncated = true;
|
||||
@@ -2159,6 +2343,15 @@ class CoreUpdateService
|
||||
return;
|
||||
}
|
||||
|
||||
if ($excludePrefixes !== []) {
|
||||
$normalized = rtrim(str_replace('\\', '/', $path), '/');
|
||||
foreach ($excludePrefixes as $prefix) {
|
||||
if ($normalized === $prefix || str_starts_with($normalized, $prefix.'/')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$isLink = is_link($path);
|
||||
// symbolic link 는 lstat — 대상 추적 금지
|
||||
$stat = $isLink ? @lstat($path) : @stat($path);
|
||||
@@ -2181,13 +2374,69 @@ class CoreUpdateService
|
||||
|
||||
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
|
||||
foreach ($items as $item) {
|
||||
$this->collectStatRecursively($item->getPathname(), $snapshot, $maxItems, $truncated);
|
||||
$this->collectStatRecursively($item->getPathname(), $snapshot, $maxItems, $truncated, $excludePrefixes);
|
||||
if ($truncated) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* root 로 실행된 업데이트가 종료된 뒤, 런타임 쓰기 디렉토리의 소유권을 정상화합니다.
|
||||
*
|
||||
* `restoreOwnership()` 은 흐름 **중간**의 한 단계라, 그 이후에 일어나는 캐시
|
||||
* 쓰기(버전 bump·상태/훅 캐시 재생성·키 인덱스 갱신·번들 빌드)가 root 소유
|
||||
* 파일을 새로 만든다. 그 파일들이 남으면 웹 프로세스의 캐시 쓰기가 Permission
|
||||
* denied 로 죽어 전면 500 이 된다 (실사례: 7.0.9→7.0.10 sudo 업데이트 —
|
||||
* 치명점은 모든 remember 가 갱신하는 캐시 키 인덱스 파일).
|
||||
*
|
||||
* 따라서 이 메서드는 **흐름의 마지막**(restoreUpgradeLogOwnership 과 같은
|
||||
* 지점)에서 호출되어, 대상 디렉토리 자신의 소유자(웹 쓰기 소유)를 기준으로
|
||||
* 내용물을 재귀 정상화하고 그룹 쓰기를 동기화한다. 과거 업데이트가 남긴
|
||||
* root 잔재도 함께 정리된다 (재귀 전체 대상).
|
||||
*
|
||||
* 비-root 프로세스는 chown 자체가 불가능하고 필요도 없으므로 즉시 no-op.
|
||||
* 기준 디렉토리 자체가 root 소유(비정상 배포)면 상속 근거가 없어 스킵한다.
|
||||
*/
|
||||
public function normalizeRuntimeOwnershipAfterRootRun(): void
|
||||
{
|
||||
if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targets = [
|
||||
storage_path('framework/cache'),
|
||||
base_path('bootstrap/cache'),
|
||||
storage_path('app/ext-bundles'),
|
||||
// 확장 업데이트의 다운로드·추출 임시 폴더. 부모 `storage/app/temp` 가 sudo 업데이트에서 root 로
|
||||
// 최초 생성되면 이후 관리자 화면의 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14).
|
||||
storage_path('app/temp'),
|
||||
// `restore_ownership` 은 `storage/logs` 를 포함하지만 그 복원은 흐름 **중간**(Step 11)이라,
|
||||
// 그 뒤에 만들어지는 daily 롤오버·신규 로그 파일은 root 로 남는다 (#651 F15).
|
||||
storage_path('logs'),
|
||||
];
|
||||
|
||||
foreach ($targets as $dir) {
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owner = @fileowner($dir);
|
||||
$group = @filegroup($dir);
|
||||
|
||||
if ($owner === false || $owner === 0) {
|
||||
Log::channel('upgrade')->warning('런타임 소유권 정상화 스킵 — 기준 디렉토리가 root/판독불가 소유', [
|
||||
'dir' => $dir,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
FilePermissionHelper::chownRecursive($dir, $owner, $group);
|
||||
FilePermissionHelper::syncGroupWritability($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\CustomAssetOperationException;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
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 {
|
||||
// 서명을 쓰는 뷰 컴포저와 **같은 통로**(고정 스토어·코어 네임스페이스)로 지운다 —
|
||||
// 다른 통로로 지우면 그쪽 서명이 남아 다음 렌더가 같은 변경을 한 번 더 bump 한다.
|
||||
self::customSignatureCache()->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;
|
||||
}
|
||||
|
||||
// 확보는 코어 공통 프리미티브가 맡는다 — 소유권 상속·그룹 쓰기까지 정합화하고, 실패는 예외가 아니라
|
||||
// 사유로 올라온다. `custom/` 은 지연 생성이라 `deploy:deploy 0755` 로 배포된 확장 디렉토리에서
|
||||
// 첫 저장이 여기서 실패하는데, 경로만 적으면 운영자가 무엇을 고쳐야 하는지 알 수 없다(#651 D6).
|
||||
// 사유·소유자·권한·실행 계정·조치 예시를 함께 싣는다 (정적 게시 프리플라이트와 같은 식).
|
||||
if (FilePermissionHelper::ensureWritableDirectory($directory, 0775, $failure)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failedPath = (string) ($failure['path'] ?? $directory);
|
||||
$reason = (string) ($failure['reason'] ?? 'create_failed');
|
||||
|
||||
throw new CustomAssetOperationException('custom_assets.errors.directory_failed', [
|
||||
'path' => $directory,
|
||||
'reason' => __('custom_assets.errors.reason.'.$reason),
|
||||
'owner' => (string) (@fileowner($failedPath) ?: 'unknown'),
|
||||
'perms' => file_exists($failedPath) ? substr(sprintf('%o', @fileperms($failedPath)), -4) : 'absent',
|
||||
'process_user' => ExtensionStaticCacheService::currentProcessUser(),
|
||||
'hint' => __('custom_assets.errors.directory_failed_hint', ['path' => $failedPath]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Extension\PluginManager;
|
||||
use App\Extension\Storage\CoreStorageDriver;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Http\View\Composers\TemplateComposer;
|
||||
use App\Support\AssetCssUrlRewriter;
|
||||
use App\Support\AssetUrl;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -25,6 +26,8 @@ use Illuminate\Support\Facades\Log;
|
||||
* 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 —
|
||||
* `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")`
|
||||
* 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4).
|
||||
* 소실 판정만 선언 축 게터(`getDeclaredAssetAbsolutePaths()`)를 쓴다 — 그 게터는
|
||||
* `file_exists()` 게이트를 타지 않아 "선언은 있는데 파일이 없다" 를 셀 수 있다.
|
||||
*
|
||||
* @see TemplateComposer
|
||||
*/
|
||||
@@ -39,6 +42,13 @@ class ExtensionBundleService
|
||||
*/
|
||||
private const BUNDLE_DISK = 'ext-bundles';
|
||||
|
||||
/**
|
||||
* 원자적 쓰기 임시 파일(`*.tmp.{pid}`)을 잔존물로 보는 나이 (초).
|
||||
*
|
||||
* pid 는 재사용되므로 "그 pid 가 살아 있는가" 로는 진행 중 여부를 판정할 수 없다.
|
||||
*/
|
||||
private const TEMP_BUNDLE_STALE_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* 서비스 주입
|
||||
*
|
||||
@@ -57,9 +67,12 @@ class ExtensionBundleService
|
||||
* 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest
|
||||
* `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1).
|
||||
*
|
||||
* `cssRelPath` 는 확장 루트 기준 상대 경로다 — 병합 시 CSS 안의 상대 참조를 그 CSS 가
|
||||
* 놓인 위치 기준으로 풀어야 하는데, 절대 경로만으로는 확장 루트를 되짚을 수 없다.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, priority: int}>
|
||||
* identifier => 절대경로/우선순위 (priority 오름차순 정렬)
|
||||
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, cssRelPath: ?string, priority: int}>
|
||||
* identifier => 절대경로/상대경로/우선순위 (priority 오름차순 정렬)
|
||||
*/
|
||||
public function getOrderedGlobalAssetPaths(string $type): array
|
||||
{
|
||||
@@ -91,9 +104,14 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS 안의 상대 참조를 풀려면 그 CSS 가 확장 안에서 **어디에 놓였는지**가 필요하다.
|
||||
// 절대 경로만으로는 확장 루트를 되짚을 수 없으므로 선언된 상대 경로를 함께 싣는다.
|
||||
$cssRelPath = $extension->getBuiltAssetPaths()['css'] ?? null;
|
||||
|
||||
$ordered[$extension->getIdentifier()] = [
|
||||
'jsAbsPath' => $jsAbsPath,
|
||||
'cssAbsPath' => $cssAbsPath,
|
||||
'cssRelPath' => $cssRelPath,
|
||||
'priority' => (int) ($loadingConfig['priority'] ?? 100),
|
||||
];
|
||||
}
|
||||
@@ -157,9 +175,12 @@ class ExtensionBundleService
|
||||
/**
|
||||
* 확장 타입의 CSS 번들 문자열을 생성합니다.
|
||||
*
|
||||
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. 상대경로
|
||||
* `url(...)` 참조가 있는 CSS 는 병합 시 경로가 깨지므로 번들에서 제외하고
|
||||
* 경고 로그를 남긴다(안전장치 — 현재 번들 CSS 는 url() 0건).
|
||||
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. CSS 안의 상대
|
||||
* `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 — 병합본의 주소는
|
||||
* 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
|
||||
*
|
||||
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을
|
||||
* 쓴다. 두 경로가 서로 다른 코드로 갈라지면 한쪽만 고쳐진 채 남는다.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
|
||||
@@ -168,6 +189,8 @@ class ExtensionBundleService
|
||||
{
|
||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||
$isProduction = app()->environment('production');
|
||||
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
|
||||
$version = $this->getCurrentVersion();
|
||||
$segments = [];
|
||||
|
||||
foreach ($ordered as $identifier => $paths) {
|
||||
@@ -188,16 +211,24 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 상대경로 url() 참조가 있으면 병합 시 폰트/이미지 경로가 깨진다.
|
||||
// 절대/data URI 는 안전하므로 상대경로만 검출해 해당 CSS 제외.
|
||||
if ($this->hasRelativeUrl($content)) {
|
||||
Log::warning('확장 CSS 에 상대경로 url() 존재 — 번들에서 제외(개별 폴백 유지)', [
|
||||
'type' => $type,
|
||||
'identifier' => $identifier,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
// 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적
|
||||
// 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데,
|
||||
// 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다.
|
||||
//
|
||||
// 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이
|
||||
// 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets)
|
||||
// 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던
|
||||
// "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다.
|
||||
$content = AssetCssUrlRewriter::rewrite(
|
||||
$content,
|
||||
(string) ($paths['cssRelPath'] ?? ''),
|
||||
fn (string $path): string => AssetUrl::extensionApiAsset(
|
||||
$typeSegment,
|
||||
$identifier,
|
||||
$path,
|
||||
$version
|
||||
)
|
||||
);
|
||||
|
||||
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -236,20 +267,158 @@ class ExtensionBundleService
|
||||
return '';
|
||||
}
|
||||
|
||||
$storage = $this->bundleStorage();
|
||||
$relativeName = $this->bundleFileName($type, $kind, $version);
|
||||
|
||||
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
|
||||
if (! app()->environment('production')) {
|
||||
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
|
||||
}
|
||||
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
||||
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
|
||||
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
|
||||
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
|
||||
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
|
||||
try {
|
||||
$storage = $this->bundleStorage();
|
||||
|
||||
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
|
||||
if ($storage->exists('', $relativeName)) {
|
||||
return $storage->getBasePath('').'/'.$relativeName;
|
||||
}
|
||||
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
|
||||
if (! app()->environment('production')) {
|
||||
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
|
||||
}
|
||||
|
||||
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
|
||||
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
|
||||
if ($storage->exists('', $relativeName)) {
|
||||
return $storage->getBasePath('').'/'.$relativeName;
|
||||
}
|
||||
|
||||
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
|
||||
'type' => $type,
|
||||
'kind' => $kind,
|
||||
'version' => $version,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @param string $kind 'js' | 'css'
|
||||
* @return string 병합 결과 (없으면 빈 문자열)
|
||||
*/
|
||||
public function buildBundleContent(string $type, string $kind): string
|
||||
{
|
||||
return $kind === 'css'
|
||||
? $this->buildCssBundle($type)
|
||||
: $this->buildJsBundle($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 타입에서 프론트엔드 에셋을 **선언한** 활성 확장 수를 반환합니다.
|
||||
*
|
||||
* 이 값은 **선언 축**이다 — 503 의 판정은 소실 축(`findMissingDeclaredAssets()`)이
|
||||
* 한다. 선언 축만으로 "선언 > 0 && 병합 결과 0 = 장애" 로 등치하면, 산출물이 존재하되
|
||||
* 비어 있는 정당한 상태(스타일이 비어 있는 확장)까지 배포 장애로 잡혀 그 확장만
|
||||
* 설치된 기본 구성이 통째로 503 이 된다.
|
||||
*
|
||||
* 선언 축은 로그 컨텍스트(운영자가 보는 "선언한 확장이 몇 개인가")와 화면 진단이
|
||||
* 근거로 삼는다.
|
||||
*
|
||||
* 판정은 **kind 별**이다 — js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은
|
||||
* 정상이므로, 그 경우까지 장애로 보면 정상 구성이 503 이 된다.
|
||||
*
|
||||
* 근거는 manifest 의 `assets.{kind}.output` **선언**이며 산출물 파일의 존재를 보지
|
||||
* 않는다. `getOrderedGlobalAssetPaths()` / `hasAssets()` / `getBuiltAssetPaths()` 는
|
||||
* 전부 `file_exists()` 게이트를 타므로, 그 경로로 세면 "dist 가 잠깐 빔" 이 곧
|
||||
* "선언 0" 이 되어 **막으려던 바로 그 상태가 정상(빈 200)으로 판정된다.** 선언과
|
||||
* 산출은 다른 축이고, 이 메서드가 재는 것은 선언 축이다.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @param string $kind 'js' | 'css'
|
||||
* @return int 해당 kind 의 에셋을 선언한 활성 확장 수
|
||||
*/
|
||||
public function countAssetDeclaringExtensions(string $type, string $kind): int
|
||||
{
|
||||
try {
|
||||
$extensions = $type === 'plugin'
|
||||
? $this->pluginManager->getActivePlugins()
|
||||
: $this->moduleManager->getActiveModules();
|
||||
|
||||
$declared = 0;
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
||||
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! empty($extension->getAssets()[$kind]['output'] ?? null)) {
|
||||
$declared++;
|
||||
}
|
||||
}
|
||||
|
||||
return $declared;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('확장 에셋 선언 수 집계 실패', [
|
||||
'type' => $type,
|
||||
'kind' => $kind,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 산출물 중 **소실·판독 불가**한 것의 절대 경로 목록을 반환합니다.
|
||||
*
|
||||
* 503 의 근거는 선언이 아니라 이 소실 축이다. 선언만으로 판정하면 "선언됨 + 산출물이 존재하지만
|
||||
* 0바이트"(스타일이 비어 있는 확장의 정당한 상태) 와 "선언됨 + 산출물 소실"(배포 중 dist 가 잠깐 빔)
|
||||
* 이 구분되지 않아 정상 구성이 503 이 된다 — 번들 확장만 설치한 기본 구성 전부가 그랬다.
|
||||
*
|
||||
* 모집단은 countAssetDeclaringExtensions() 와 같다. 경로는 확장의 선언 축 게터로만 얻는다 —
|
||||
* getBuiltAssetAbsolutePaths() 는 file_exists() 게이트라 부재를 셀 수 없고, base_path("modules"…)
|
||||
* 직접 조립은 _bundled 확장에서 어긋난다.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @param string $kind 'js' | 'css'
|
||||
* @return list<string> 소실·판독 불가 산출물의 절대 경로 (없으면 빈 배열)
|
||||
*/
|
||||
public function findMissingDeclaredAssets(string $type, string $kind): array
|
||||
{
|
||||
try {
|
||||
$extensions = $type === 'plugin'
|
||||
? $this->pluginManager->getActivePlugins()
|
||||
: $this->moduleManager->getActiveModules();
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($extensions as $extension) {
|
||||
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
||||
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $extension->getDeclaredAssetAbsolutePaths()[$kind] ?? null;
|
||||
|
||||
if ($path !== null && (! is_file($path) || ! is_readable($path))) {
|
||||
$missing[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
} catch (\Throwable $e) {
|
||||
// 판정 자체가 실패하면 장애로 단정하지 않는다(선언 축 카운트와 같은 fail-open). 흔적은 error 로 —
|
||||
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다.
|
||||
Log::error('확장 에셋 소실 판정 실패', [
|
||||
'type' => $type,
|
||||
'kind' => $kind,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +440,17 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 원자적 쓰기의 임시 파일(`{type}.{v}.{kind}.tmp.{pid}`)은 번들 파일 패턴에
|
||||
// 맞지 않아 GC 대상에서 통째로 빠져 있었다 — rename 이 실패한 만큼 영구
|
||||
// 잔존한다(실측 560개). 나이 가드를 붙여 진행 중인 쓰기는 건드리지 않는다.
|
||||
if ($this->isStaleTempBundleFile($name, $storage)) {
|
||||
if ($storage->delete('', $name)) {
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isBundleFile($name) && $storage->delete('', $name)) {
|
||||
$deleted++;
|
||||
}
|
||||
@@ -279,15 +459,43 @@ class ExtensionBundleService
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명이 **오래된** 원자적 쓰기 임시 파일인지 판정합니다.
|
||||
*
|
||||
* 진행 중인 쓰기를 파괴하지 않도록 나이 가드를 둔다 — pid 는 재사용되므로 "그 pid 가
|
||||
* 살아 있는가" 로는 판정할 수 없다.
|
||||
*
|
||||
* @param string $name 파일명
|
||||
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
||||
* @return bool 삭제 대상 여부
|
||||
*/
|
||||
private function isStaleTempBundleFile(string $name, CoreStorageDriver $storage): bool
|
||||
{
|
||||
if (! preg_match('/^(module|plugin)\.\d+\.(js|css)\.tmp\.\d+$/', $name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mtime = @filemtime($storage->getBasePath('').'/'.$name);
|
||||
|
||||
// 나이를 읽지 못하면 남긴다 — 진행 중인 쓰기를 지우는 쪽이 더 나쁘다.
|
||||
return $mtime !== false && (time() - $mtime) > self::TEMP_BUNDLE_STALE_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용).
|
||||
*
|
||||
* **현재 버전은 보존한다** — `cleanupStaleBundles()` 와 같은 정책이다. 현재 버전까지
|
||||
* 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후 `filemtime()` 에서 500 을
|
||||
* 낸다(bump 직후 TOCTOU). 캐시 파일은 없으면 다음 요청이 다시 만들므로, 지우는 것의
|
||||
* 이득은 없고 그 창의 500 만 남는다.
|
||||
*
|
||||
* @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체
|
||||
* @return int 삭제된 파일 수
|
||||
*/
|
||||
public function clearBundles(?string $type = null): int
|
||||
{
|
||||
$storage = $this->bundleStorage();
|
||||
$currentVersion = $this->getCurrentVersion();
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($storage->files('', '') as $file) {
|
||||
@@ -297,6 +505,11 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 현재 버전 보존 (cleanupStaleBundles 와 동형 — 정책이 갈라지면 한쪽이 창을 연다)
|
||||
if ($this->matchesVersion($name, $currentVersion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 타입 필터 (파일명 접두사 `{type}.`)
|
||||
if ($type !== null && ! str_starts_with($name, $type.'.')) {
|
||||
continue;
|
||||
@@ -446,42 +659,6 @@ class ExtensionBundleService
|
||||
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
|
||||
*
|
||||
* 절대 URL(http/https), 루트 절대경로(/), data URI 는 병합에 안전하므로
|
||||
* 그 외의 url() 참조만 상대경로로 간주한다.
|
||||
*
|
||||
* @param string $css CSS 내용
|
||||
* @return bool 상대경로 url() 이 하나라도 있으면 true
|
||||
*/
|
||||
private function hasRelativeUrl(string $css): bool
|
||||
{
|
||||
if (! preg_match_all('/url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($matches[1] as $url) {
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isAbsolute = str_starts_with($url, 'http://')
|
||||
|| str_starts_with($url, 'https://')
|
||||
|| str_starts_with($url, '//')
|
||||
|| str_starts_with($url, '/')
|
||||
|| str_starts_with($url, 'data:');
|
||||
|
||||
if (! $isAbsolute) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 디스크용 스토리지 드라이버를 반환합니다(StorageInterface 경유).
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1135,7 +1135,9 @@ class LayoutService
|
||||
}
|
||||
|
||||
$identifier = $template->identifier;
|
||||
$cacheVersion = (int) $this->cache->get('ext.cache_version', 0);
|
||||
// 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 `.v0` 키를 지우고 실제 키는 남긴다 —
|
||||
// 트레이트 게터는 부재 시 재생성하므로 실제 서빙 키와 같은 버전을 본다.
|
||||
$cacheVersion = self::getExtensionCacheVersion();
|
||||
|
||||
// PublicLayoutController::serve() 가 일반 응답과 편집 모드(`with_source_meta=1`) 응답을
|
||||
// 별도 캐시 키로 저장한다 (`.meta` 접미사). 본 PR Phase 3 S5a-1 에서 편집 모드 응답 캐시
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Http\Resources\AttachmentResource;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\EnvPriority;
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use App\Support\OpcacheStatus;
|
||||
use App\Support\ProcessOutputEncoding;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -26,6 +29,10 @@ use Illuminate\Validation\ValidationException;
|
||||
*/
|
||||
class SettingsService
|
||||
{
|
||||
// 자산 URL 방식 변경 시 확장 캐시 버전 bump 용. 주입된 `$this->cache` 로 직접 put 하지
|
||||
// 않는다 — 트레이트가 고정 `CoreCacheDriver` + 메모이즈 스토어를 쓰는 이유가 그 안에 있다.
|
||||
use ClearsTemplateCaches;
|
||||
|
||||
public function __construct(
|
||||
private ConfigRepositoryInterface $configRepository,
|
||||
private AttachmentRepositoryInterface $attachmentRepository,
|
||||
@@ -150,9 +157,102 @@ class SettingsService
|
||||
// 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형)
|
||||
$settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default');
|
||||
|
||||
return $this->overlayEnvLockedValues($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* `.env` 로 잠긴 키의 표시값을 유효값(런타임 config)으로 덮어씁니다.
|
||||
*
|
||||
* 잠긴 필드에 사문화된 저장값이 남아 있으면 운영자는 적용되지 않는 값을 읽게 됩니다.
|
||||
* 화면이 보여줄 진실은 실제로 적용 중인 값이므로, 그 값으로 대체합니다.
|
||||
*
|
||||
* 민감 키는 제외합니다 — `.env` 의 비밀값을 관리자 화면 응답에 실어 보내지 않기
|
||||
* 위해서입니다(잠금 표시만 하고 값은 저장값 그대로 둡니다).
|
||||
*
|
||||
* 스위치가 꺼져 있으면 아무 것도 하지 않습니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings frontend 키 변환이 끝난 설정 배열
|
||||
* @return array<string, mixed> 유효값이 덮인 설정 배열
|
||||
*/
|
||||
private function overlayEnvLockedValues(array $settings): array
|
||||
{
|
||||
if (! EnvPriority::enabled()) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
foreach (array_keys(EnvPriority::lockedKeys()) as $storageKey) {
|
||||
if (EnvPriority::isSensitive($storageKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$category, $key] = explode('.', $storageKey, 2);
|
||||
$value = EnvPriority::effectiveValue($storageKey);
|
||||
|
||||
foreach ($this->resolveFrontendKeyTargets($category, $key) as [$targetCategory, $outputKey]) {
|
||||
if (isset($settings[$targetCategory]) && is_array($settings[$targetCategory])) {
|
||||
$settings[$targetCategory][$outputKey] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* `.env` 로 잠긴 키 목록을 프론트엔드 키 형태로 반환합니다.
|
||||
*
|
||||
* 화면(레이아웃 JSON)이 참조하는 키는 `frontend_key`/`merge_into` 변환을 거친 이름
|
||||
* (예: `advanced.debug_mode`)이므로, 저장소 키(`debug.mode`)를 그대로 내보내면 화면의
|
||||
* 잠금 표시가 조용히 미발동합니다.
|
||||
*
|
||||
* `getAllSettings()` 가 값을 두 위치(병합 대상 + 원본 카테고리)에 싣는 것과 동일하게
|
||||
* 잠금 표시도 두 위치를 모두 담습니다.
|
||||
*
|
||||
* @return array<string, bool> 프론트엔드 키(`카테고리.키`) => true
|
||||
*/
|
||||
public function envLockedMeta(): array
|
||||
{
|
||||
$meta = [];
|
||||
|
||||
foreach (array_keys(EnvPriority::lockedKeys()) as $storageKey) {
|
||||
[$category, $key] = explode('.', $storageKey, 2);
|
||||
|
||||
foreach ($this->resolveFrontendKeyTargets($category, $key) as [$targetCategory, $outputKey]) {
|
||||
$meta[$targetCategory.'.'.$outputKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소 키가 응답에서 실리는 (카테고리, 키) 쌍 목록을 반환합니다.
|
||||
*
|
||||
* `getAllSettings()` 의 변환 규칙과 같은 규칙을 씁니다 — 갈라지면 잠금 표시가 값과
|
||||
* 다른 자리를 가리키게 되고, 그 어긋남은 화면에서 "잠금이 안 걸린 것"과 구분되지 않습니다.
|
||||
*
|
||||
* @param string $category 저장소 카테고리명
|
||||
* @param string $key 저장소 키
|
||||
* @return array<int, array{0: string, 1: string}> (카테고리, 출력 키) 쌍 목록
|
||||
*/
|
||||
private function resolveFrontendKeyTargets(string $category, string $key): array
|
||||
{
|
||||
$schema = $this->configRepository->getFrontendSchema();
|
||||
$categorySchema = $schema[$category] ?? [];
|
||||
$fields = $categorySchema['fields'] ?? [];
|
||||
|
||||
$outputKey = $fields[$key]['frontend_key'] ?? $key;
|
||||
$targetCategory = $categorySchema['frontend_name'] ?? $categorySchema['merge_into'] ?? $category;
|
||||
|
||||
$targets = [[$targetCategory, $outputKey]];
|
||||
|
||||
if ($targetCategory !== $category) {
|
||||
$targets[] = [$category, $outputKey];
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 카테고리의 설정을 조회합니다.
|
||||
*
|
||||
@@ -538,6 +638,13 @@ class SettingsService
|
||||
// frontend_key를 원본 키로 역변환
|
||||
$tabSettings = $this->reverseFrontendKeys($tabSettings);
|
||||
|
||||
// `.env` 가 소유권을 가져간 키는 저장 대상에서 제거한다. 화면의 disabled 는
|
||||
// 게이트가 아니다 — 저장 API 를 직접 호출하는 경로가 남으므로 실질 차단은 여기다.
|
||||
// (advanced 탭은 아직 카테고리가 갈리지 않았으므로 saveAdvancedSettings 가 분리 후 적용한다.)
|
||||
if ($tab !== 'advanced') {
|
||||
$tabSettings = EnvPriority::rejectLockedForSave($tab, $tabSettings);
|
||||
}
|
||||
|
||||
// advanced 탭은 cache와 debug 두 카테고리로 분리
|
||||
if ($tab === 'advanced') {
|
||||
$result = $this->saveAdvancedSettings($tabSettings);
|
||||
@@ -604,6 +711,7 @@ class SettingsService
|
||||
// CLI(`g7:asset-url-mode`)와 동일한 처리 (계획서 §알려진 한계).
|
||||
if ($assetUrlModeChanged) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
|
||||
// drivers 탭은 queue/broadcasting/cache 등 long-running worker에 영향
|
||||
@@ -729,6 +837,10 @@ class SettingsService
|
||||
|
||||
// 각 카테고리별 병합 저장
|
||||
foreach ($categorized as $category => $categorySettings) {
|
||||
// 고급 탭은 여러 카테고리가 한 폼에 섞여 오므로 잠금 필터를 분리 후에 적용한다
|
||||
// (탭 이름 'advanced' 는 저장소 카테고리가 아니라 매핑이 걸리지 않는다).
|
||||
$categorySettings = EnvPriority::rejectLockedForSave($category, $categorySettings);
|
||||
|
||||
if (empty($categorySettings)) {
|
||||
continue;
|
||||
}
|
||||
@@ -984,6 +1096,7 @@ class SettingsService
|
||||
|
||||
if ($assetUrlModeChanged) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
|
||||
if (str_starts_with($key, 'drivers.') || $key === 'drivers') {
|
||||
@@ -1020,17 +1133,42 @@ class SettingsService
|
||||
*/
|
||||
public function restoreSettings(string $backupPath): bool
|
||||
{
|
||||
// 자산 URL 방식은 복원 **전**에 읽어 둔다 — 복원 뒤에는 이전 값을 알 수 없어
|
||||
// 변경 여부를 판별할 수 없다 (saveSettings/setSetting 과 같은 사유).
|
||||
$previousAssetUrlMode = $this->configRepository->get('general.asset_url_mode');
|
||||
|
||||
$result = $this->configRepository->restore($backupPath);
|
||||
|
||||
if ($result) {
|
||||
// 복원도 설정 전체를 갈아엎는 쓰기다 — 캐시만 비우고 미러를 두면
|
||||
// 같은 프로세스가 복원 전 값을 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
|
||||
$this->invalidateSettingsCache();
|
||||
|
||||
// 복원으로 자산 URL 방식이 바뀌었으면 두 저장 경로와 동일하게 처리한다 —
|
||||
// SEO 프리렌더와 병합 번들 CSS 양쪽에 구워진 URL 형태가 어긋난다.
|
||||
if ($this->configRepository->get('general.asset_url_mode') !== $previousAssetUrlMode) {
|
||||
$this->clearSeoCacheForAssetUrlMode();
|
||||
$this->bumpExtensionCacheForAssetUrlMode();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 URL 방식이 바뀌었을 때 확장 캐시 버전을 올립니다 (정적 게시본 재생성).
|
||||
*
|
||||
* 병합 번들 CSS 는 내부 `url()` 참조가 그 시점의 자산 URL **형태**(확장자 / `?file=`)로
|
||||
* 본문에 구워진다(`ExtensionBundleService` 의 `AssetCssUrlRewriter`). 디스크 번들과 정적
|
||||
* 게시본은 캐시 버전으로 키드되어 있어, 버전이 오르지 않으면 모드를 바꿔도 옛 형태의
|
||||
* URL 이 남고 그 참조(글꼴·이미지)가 서버에서 404 가 된다(#651 F5). bump 단일 지점이
|
||||
* 번들 재병합과 정적 재게시를 함께 예약한다.
|
||||
*/
|
||||
private function bumpExtensionCacheForAssetUrlMode(): void
|
||||
{
|
||||
$this->incrementExtensionCacheVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 메일을 발송합니다.
|
||||
*
|
||||
@@ -1295,8 +1433,12 @@ class SettingsService
|
||||
public function optimizeSystem(): bool
|
||||
{
|
||||
try {
|
||||
Artisan::call('config:cache');
|
||||
Artisan::call('route:cache');
|
||||
// config:cache / route:cache 는 새 Application 을 부팅하며 전역 Container 를 바꿔 놓는다.
|
||||
// 보존 래퍼 없이 부르면 이 요청의 후속 `app()->terminating()` 예약이 사라진다.
|
||||
ConfigCacheHelper::withPreservedContainer(static function (): void {
|
||||
Artisan::call('config:cache');
|
||||
Artisan::call('route:cache');
|
||||
});
|
||||
Artisan::call('view:cache');
|
||||
|
||||
return true;
|
||||
@@ -1496,7 +1638,10 @@ class SettingsService
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
|
||||
if ($output) {
|
||||
$name = trim($output);
|
||||
// 2>&1 로 합쳐진 오류 문장은 시스템 코드페이지(한국어 Windows = CP949)로 출력된다.
|
||||
// 정규화하지 않으면 이 값이 시스템 정보 API 응답에 실려 JsonResponse 직렬화가
|
||||
// Malformed UTF-8 로 500 을 낸다 (gnuboard/g7#62 와 동형).
|
||||
$name = trim(ProcessOutputEncoding::normalize($output));
|
||||
if ($name !== '' && ! str_contains(strtolower($name), 'error')) {
|
||||
return $name;
|
||||
}
|
||||
@@ -1504,7 +1649,7 @@ class SettingsService
|
||||
|
||||
$output = @shell_exec('wmic cpu get name 2>&1');
|
||||
if ($output) {
|
||||
$lines = explode("\n", trim($output));
|
||||
$lines = explode("\n", trim(ProcessOutputEncoding::normalize($output)));
|
||||
if (isset($lines[1]) && trim($lines[1]) !== '') {
|
||||
return trim($lines[1]);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -40,14 +40,15 @@ class ApiEndpointProbe
|
||||
private ?User $user = null;
|
||||
|
||||
/**
|
||||
* @param string|null $baseUrl 기준 URL (null 이면 .env 의 APP_URL 직접 사용)
|
||||
* @param string|null $baseUrl 기준 URL (null 이면 config('app.url') 사용)
|
||||
*/
|
||||
public function __construct(?string $baseUrl = null)
|
||||
{
|
||||
// config('app.url') 은 테스트 환경에서 override 될 수 있으므로(test.example.com 등),
|
||||
// 실측은 .env 의 APP_URL 을 우선 신뢰한다. 명시 인자가 있으면 그것을 최우선한다.
|
||||
// 기준 URL 은 config('app.url') 에서 해석한다. 이전에는 `.env` 를 우선 신뢰하려고
|
||||
// env('APP_URL') 을 먼저 읽었는데, config:cache 환경에서 env() 는 null 로 고정되므로
|
||||
// 그 우선순위는 실제로 성립한 적이 없었다(그대로 config 폴백으로 떨어졌다).
|
||||
// 실측 대상을 지정해야 하면 명시 인자를 넘긴다 — 그쪽이 최우선이다.
|
||||
$resolved = $baseUrl
|
||||
?: (string) env('APP_URL')
|
||||
?: (string) config('app.url');
|
||||
|
||||
$this->baseUrl = rtrim($resolved, '/');
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 확장 자산으로 서빙되는 CSS 안의 **상대 경로 참조**를 절대 자산 URL 로 바꿉니다.
|
||||
*
|
||||
* 왜 필요한가:
|
||||
* 자산 URL 은 두 모드로 나간다 (`general.asset_url_mode`).
|
||||
* - `extension` → `/api/templates/assets/{id}/vendor/x/1.0/a.css`
|
||||
* - `extensionless` → `/api/templates/assets/{id}?file=vendor%2Fx%2F1.0%2Fa.css`
|
||||
*
|
||||
* 브라우저는 CSS 안의 상대 `url()` 을 **그 스타일시트 URL의 디렉토리** 기준으로 푼다.
|
||||
* 확장자 모드에서는 디렉토리가 `.../vendor/x/1.0/` 이라 `./woff2/f.woff2` 가 제대로 풀리지만,
|
||||
* 확장자 없는 모드에서는 경로의 마지막 세그먼트가 식별자(`{id}`)이고 파일명은 쿼리에 있으므로
|
||||
* 디렉토리가 `/api/templates/assets/` 로 잡힌다 — `./woff2/f.woff2` 가
|
||||
* `/api/templates/assets/woff2/f.woff2` 라는 존재하지 않는 주소가 된다.
|
||||
*
|
||||
* 이 실패는 서버 로그에 아무 흔적을 남기지 않는다. 요청은 정상 404 이고, 화면은 글꼴이
|
||||
* 기본 서체로 대체되거나 아이콘이 빈칸으로 보일 뿐이라 운영자가 원인을 특정할 단서가 없다.
|
||||
* 실제로 사용자 템플릿의 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다.
|
||||
*
|
||||
* 왜 서빙 시점인가:
|
||||
* 최종 URL 은 런타임 모드와 캐시 버전이 정한다 — 빌드 시점에는 알 수 없다. 그리고 동봉
|
||||
* 자산은 제3자 산출물이라 원본을 손대면 상류 갱신 때마다 재작업이 된다. 그래서 원본은
|
||||
* 상대 경로 그대로 두고, 내보내는 순간에만 해석한다.
|
||||
*
|
||||
* 대상이 아닌 것:
|
||||
* 절대 URL(`https://`, `//`), 루트 상대(`/`), `data:`/`about:` 등 스킴 참조, 빈 참조.
|
||||
* 정적 게시본(`/build/ext/{v}/...`)은 웹서버가 직접 서빙하고 경로 형태라 상대 해석이
|
||||
* 정상이므로 이 경로를 타지 않는다.
|
||||
*/
|
||||
class AssetCssUrlRewriter
|
||||
{
|
||||
/** `url(...)` 참조 — 따옴표 3종(없음/홑/겹)을 모두 받는다 */
|
||||
private const URL_RE = '/\burl\(\s*(["\']?)(.*?)\1\s*\)/s';
|
||||
|
||||
/** `@import "..."` / `@import \'...\'` (url() 없이 문자열만 오는 형태) */
|
||||
private const IMPORT_RE = '/@import\s+(["\'])(.*?)\1/s';
|
||||
|
||||
/**
|
||||
* CSS 안의 상대 참조를 절대 자산 URL 로 치환합니다.
|
||||
*
|
||||
* @param string $css 원본 CSS
|
||||
* @param string $cssPath 확장 기준 CSS 경로 (서빙 요청에 쓰인 것과 같은 좌표계)
|
||||
* @param callable(string): string $urlFor 확장 기준 경로 → 절대 자산 URL 변환기
|
||||
* @return string 치환된 CSS
|
||||
*/
|
||||
public static function rewrite(string $css, string $cssPath, callable $urlFor): string
|
||||
{
|
||||
$baseDir = self::baseDirectory($cssPath);
|
||||
|
||||
$replace = function (array $m) use ($baseDir, $urlFor): string {
|
||||
$quote = $m[1];
|
||||
$ref = trim($m[2]);
|
||||
|
||||
$resolved = self::resolve($ref, $baseDir);
|
||||
|
||||
if ($resolved === null) {
|
||||
return $m[0];
|
||||
}
|
||||
|
||||
[$path, $fragment] = $resolved;
|
||||
|
||||
$url = $urlFor($path).$fragment;
|
||||
|
||||
// 따옴표가 없던 참조도 겹따옴표로 감싼다 — 생성된 URL 은 `?`·`&` 를 포함할 수
|
||||
// 있는데, 따옴표 없는 url() 토큰에서 그 문자들은 CSS 문법상 허용되지 않는다.
|
||||
$quote = $quote !== '' ? $quote : '"';
|
||||
|
||||
return str_starts_with($m[0], '@import')
|
||||
? '@import '.$quote.$url.$quote
|
||||
: 'url('.$quote.$url.$quote.')';
|
||||
};
|
||||
|
||||
$css = preg_replace_callback(self::URL_RE, $replace, $css) ?? $css;
|
||||
|
||||
return preg_replace_callback(self::IMPORT_RE, $replace, $css) ?? $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조가 상대 경로인지 판정하고, 확장 기준 절대 경로로 해석합니다.
|
||||
*
|
||||
* @param string $ref CSS 안의 원본 참조
|
||||
* @param array<int, string> $baseDir CSS 가 놓인 디렉토리 세그먼트
|
||||
* @return array{0: string, 1: string}|null `[확장 기준 경로, 프래그먼트]` 또는 대상 아님이면 null
|
||||
*/
|
||||
private static function resolve(string $ref, array $baseDir): ?array
|
||||
{
|
||||
if ($ref === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 루트 상대(`/x`) · 프로토콜 상대(`//host/x`) · 스킴 참조(`https:`, `data:`, `#`) 는 그대로 둔다.
|
||||
if ($ref[0] === '/' || $ref[0] === '#' || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $ref) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 프래그먼트는 보존하고(레거시 `#iefix` 등), 참조 자신의 쿼리는 버린다 —
|
||||
// 생성되는 자산 URL 이 자기 캐시 버전 쿼리를 갖는다.
|
||||
$fragment = '';
|
||||
if (($hash = strpos($ref, '#')) !== false) {
|
||||
$fragment = substr($ref, $hash);
|
||||
$ref = substr($ref, 0, $hash);
|
||||
}
|
||||
|
||||
if (($q = strpos($ref, '?')) !== false) {
|
||||
$ref = substr($ref, 0, $q);
|
||||
}
|
||||
|
||||
if ($ref === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$segments = $baseDir;
|
||||
|
||||
foreach (explode('/', $ref) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
if ($segments === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [implode('/', $segments), $fragment];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 경로가 놓인 디렉토리 세그먼트를 구합니다.
|
||||
*
|
||||
* @param string $cssPath 확장 기준 CSS 경로
|
||||
* @return array<int, string> 디렉토리 세그먼트 (루트면 빈 배열)
|
||||
*/
|
||||
private static function baseDirectory(string $cssPath): array
|
||||
{
|
||||
$parts = explode('/', trim(str_replace('\\', '/', $cssPath), '/'));
|
||||
|
||||
array_pop($parts);
|
||||
|
||||
return array_values(array_filter($parts, static fn (string $p): bool => $p !== '' && $p !== '.'));
|
||||
}
|
||||
}
|
||||
+219
-8
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use App\Support\Routing\DualExtensionRoute;
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,23 @@ class AssetUrl
|
||||
*/
|
||||
private static ?string $modeOverride = null;
|
||||
|
||||
/**
|
||||
* 정적 게시(bake) 베이스 경로 메모 (요청당 1회 판정).
|
||||
*/
|
||||
private static ?string $staticExtBaseMemo = null;
|
||||
|
||||
/**
|
||||
* 정적 게시 베이스 판정 완료 여부.
|
||||
*/
|
||||
private static bool $staticExtBaseResolved = false;
|
||||
|
||||
/**
|
||||
* 태그 계층 파일 단위 게이트 메모 (상대 경로 => 존재 여부).
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $staticFileMemo = [];
|
||||
|
||||
/**
|
||||
* 현재 자산 URL 모드를 반환합니다.
|
||||
*
|
||||
@@ -103,6 +121,105 @@ class AssetUrl
|
||||
self::$modeOverride = $mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 게시(bake) 베이스 경로를 반환합니다 (#122).
|
||||
*
|
||||
* 게이트 3조건 — ① 프로덕션 ② `core.static_cache.enabled` ③ 현재 버전 게시
|
||||
* 완료(manifest 존재) — 을 전부 통과할 때만 `/build/ext/{v}` 를 반환한다.
|
||||
* 아니면 null (종전 API URL 방출). 요청당 1회 판정 후 메모이즈한다.
|
||||
*
|
||||
* 자가 치유 — 프로덕션인데 현재 버전이 미게시면 terminating 게시를 예약한다.
|
||||
* 이번 응답은 종전 API URL 로 나가고(첫 방문자 1회만 종전 속도), 다음
|
||||
* 렌더부터 정적 fast path 가 적용된다.
|
||||
*
|
||||
* blade 렌더 경로에서 호출되므로 어떤 실패도 예외로 새 나가면 안 된다.
|
||||
*
|
||||
* @return string|null 정적 베이스 경로 또는 null
|
||||
*/
|
||||
public static function staticExtBase(): ?string
|
||||
{
|
||||
if (self::$staticExtBaseResolved) {
|
||||
return self::$staticExtBaseMemo;
|
||||
}
|
||||
|
||||
self::$staticExtBaseResolved = true;
|
||||
self::$staticExtBaseMemo = null;
|
||||
|
||||
try {
|
||||
if (! app()->environment('production')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! (bool) config('core.static_cache.enabled', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 트레이트 정적 메서드 직접 호출(ClearsTemplateCaches::)은 PHP 8.1+ E_DEPRECATED
|
||||
// — 트레이트를 사용하는 클래스 경유로 호출한다 (게이트·게시자가 같은 static
|
||||
// 스토어 메모 슬롯을 공유하게 되는 부수 이점도 있다)
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
|
||||
if (! app(ExtensionStaticCacheService::class)->isPublished($version)) {
|
||||
// 자가 치유 — 응답 종료 후 게시 시도 (실패해도 API 폴백으로 정상)
|
||||
ExtensionStaticCacheService::schedulePublishOnTerminate();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$staticExtBaseMemo = '/build/ext/'.$version;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$staticExtBaseMemo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 게시 베이스 메모를 초기화합니다 (테스트 전용).
|
||||
*/
|
||||
public static function resetStaticExtBaseMemo(): void
|
||||
{
|
||||
self::$staticExtBaseResolved = false;
|
||||
self::$staticExtBaseMemo = null;
|
||||
self::$staticFileMemo = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 게시본 내 파일 존재 여부를 확인합니다 (태그 계층 파일 단위 게이트).
|
||||
*
|
||||
* `<link>`/`<script>` 태그는 404 를 받아도 스스로 재시도하지 못하므로,
|
||||
* manifest 게이트에 더해 그 자산의 실파일 존재까지 확인한 뒤에만 정적 URL 을
|
||||
* 방출한다 (요청당 태그 대상 ~6개 파일, 메모이즈).
|
||||
*
|
||||
* @param string $relative 게시 트리 상대 경로
|
||||
* @return bool 파일 존재 여부
|
||||
*/
|
||||
private static function staticFileExists(string $relative): bool
|
||||
{
|
||||
$version = substr((string) self::$staticExtBaseMemo, strlen('/build/ext/'));
|
||||
|
||||
return self::$staticFileMemo[$relative] ??= is_file(
|
||||
public_path('build/ext/'.$version.'/'.$relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 계층 자산의 정적 게시 URL 을 반환합니다 (파일 존재 확인 포함).
|
||||
*
|
||||
* @param string $relative 게시 트리 상대 경로
|
||||
* @return string|null 정적 URL 또는 null (게이트 미통과)
|
||||
*/
|
||||
private static function staticTagUrl(string $relative): ?string
|
||||
{
|
||||
$base = self::staticExtBase();
|
||||
|
||||
if ($base === null || ! self::staticFileExists($relative)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $base.'/'.$relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 자산 URL 을 생성합니다.
|
||||
*
|
||||
@@ -112,11 +229,28 @@ class AssetUrl
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
|
||||
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
|
||||
* @param bool $allowStatic 정적 게시본(bake) 경로 허용 여부. 생성한 URL 이 정적
|
||||
* 게시 GC(현재+직전 1개 보존)보다 오래 사는 저장소에 박제되는
|
||||
* 호출부(SEO 페이지 캐시 등)는 false 로 종전 API URL 을 받는다
|
||||
* @return string 생성된 URL
|
||||
*/
|
||||
public static function templateAsset(string $identifier, string $path, int|string|null $version = null): string
|
||||
public static function templateAsset(string $identifier, string $path, int|string|null $version = null, bool $allowStatic = true): string
|
||||
{
|
||||
return self::asset('templates', $identifier, $path, $version);
|
||||
// 정적 게시본(bake) 우선 (#122) — 버전 디렉토리 경로라 `?v` 쿼리가 불필요하다.
|
||||
// 게이트(프로덕션·kill-switch·게시 완료·개별 파일 존재) 미통과 시 종전 URL 그대로.
|
||||
// `$version` 이 현재 게시 버전과 다르면 정적 분기를 건너뛴다 — 정적 경로는 항상
|
||||
// 현재 게시본이므로, 다른 버전을 명시한 호출에 현재본을 주면 "요청 버전이 URL 에
|
||||
// 반영된다" 는 시그니처 계약이 조용히 깨진다 (현 호출부는 전부 현재 버전 전달).
|
||||
$static = null;
|
||||
if ($allowStatic) {
|
||||
$base = self::staticExtBase();
|
||||
|
||||
if ($base !== null && ($version === null || $base === '/build/ext/'.$version)) {
|
||||
$static = self::staticTagUrl('templates/'.$identifier.'/assets/'.ltrim($path, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return $static ?? self::asset('templates', $identifier, $path, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +359,17 @@ class AssetUrl
|
||||
*/
|
||||
public static function extensionBundle(string $type, string $kind, int|string|null $version = null): string
|
||||
{
|
||||
// 정적 게시본(bake) 우선 (#122) — `bundles/{modules|plugins}.{js|css}` 사본.
|
||||
// `$version` 명시 호출이 현재 게시 버전과 다르면 건너뛴다 (templateAsset 과 동일 계약)
|
||||
$base = self::staticExtBase();
|
||||
$static = $base !== null && ($version === null || $base === '/build/ext/'.$version)
|
||||
? self::staticTagUrl("bundles/{$type}.{$kind}")
|
||||
: null;
|
||||
|
||||
if ($static !== null) {
|
||||
return $static;
|
||||
}
|
||||
|
||||
$base = self::isExtensionless()
|
||||
? "/api/{$type}/bundle/{$kind}"
|
||||
: "/api/{$type}/bundle.{$kind}";
|
||||
@@ -205,6 +397,25 @@ class AssetUrl
|
||||
return $url.self::versionQuery($version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 자산의 **API 서빙 URL** 을 생성합니다 (정적 게시본 분기 없음).
|
||||
*
|
||||
* 정적 게시본을 건너뛰는 이유: 이 메서드의 호출자는 CSS 서빙 컨트롤러다. 게시본이
|
||||
* 활성이면 그 CSS 자체가 웹서버에서 경로 형태로 나가 컨트롤러에 도달하지 않으므로,
|
||||
* 여기 도달했다는 것은 이 요청에 게시본이 적용되지 않았다는 뜻이다. 한 스타일시트
|
||||
* 안에서 서빙 경로가 갈리지 않도록 API 형태로 통일한다.
|
||||
*
|
||||
* @param string $type `templates` / `modules` / `plugins`
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $path 확장 기준 파일 경로
|
||||
* @param int|string|null $version 캐시 무효화 버전
|
||||
* @return string 생성된 URL (현재 모드 반영)
|
||||
*/
|
||||
public static function extensionApiAsset(string $type, string $identifier, string $path, int|string|null $version = null): string
|
||||
{
|
||||
return self::asset($type, $identifier, $path, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 자산 URL 을 생성하는 공통 구현.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -52,7 +53,7 @@ class ConfigCacheHelper
|
||||
}
|
||||
|
||||
try {
|
||||
Artisan::call('config:cache');
|
||||
self::withPreservedContainer(static fn () => Artisan::call('config:cache'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('config 캐시 재생성 실패 (config:clear 로 stale 은 제거됨 — 다음 요청은 비캐시 부팅)', [
|
||||
'error' => $e->getMessage(),
|
||||
@@ -60,6 +61,30 @@ class ConfigCacheHelper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콜백 실행 뒤 전역 컨테이너 인스턴스를 원래 앱으로 되돌립니다.
|
||||
*
|
||||
* `config:cache` 는 신선한 설정을 얻기 위해 **새 Application 을 부팅**하는데, `Application` 생성자가
|
||||
* `Container::setInstance()` 를 호출하므로 그 순간부터 `app()` 헬퍼가 실행 중인 앱이 아니라 그
|
||||
* 일회용 앱을 가리킨다(파사드는 별도 참조라 그대로다). 같은 프로세스에서 그 뒤에 등록되는
|
||||
* `app()->terminating()` 콜백은 종료되지 않는 앱에 걸려 **영원히 실행되지 않는다** — 설정 저장
|
||||
* 뒤의 확장 캐시 버전 bump 가 예약한 정적 재게시가 그렇게 조용히 사라졌다(#651 F5 실측: 버전은
|
||||
* 올랐는데 게시는 다음 렌더의 자가 치유까지 미뤄짐). 예외도 로그도 없고, 자가 치유가 한 렌더
|
||||
* 뒤에 덮어 주므로 "한 박자 늦게 반영" 으로만 나타난다.
|
||||
*
|
||||
* @param callable $callback 전역 인스턴스를 바꿔 놓을 수 있는 작업
|
||||
*/
|
||||
public static function withPreservedContainer(callable $callback): void
|
||||
{
|
||||
$app = Container::getInstance();
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
Container::setInstance($app);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* config 캐시만 제거합니다 (재생성 없음).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\AllowedTemplateFileType;
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* 게시 대상 파일 열거 결과의 요청 스코프 메모이즈 (키 형식은 `$cache` 와 같다 — `{type}|{id}`).
|
||||
*
|
||||
* 서술자 메모(`$cache`)와 자리를 나눈다 — 서술자는 **로드 목록**(최상위 css/js), 이것은
|
||||
* **게시·변경 감지 집합**(하위 디렉토리 포함 전 허용 확장자)이라 의미가 다르다.
|
||||
*/
|
||||
private static array $fileCache = [];
|
||||
|
||||
/**
|
||||
* 확장 하나의 사용자 추가 에셋 목록을 돌려줍니다.
|
||||
*
|
||||
* @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 = [];
|
||||
self::$fileCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 하나의 `custom/` 아래 **게시 대상 파일 전체**를 열거합니다.
|
||||
*
|
||||
* 정적 게시(`ExtensionStaticCacheService`)와 변경 감지(`CollectsCustomAssets`)가 **같은
|
||||
* 열거자**를 쓴다. 종전에는 게시가 `custom/**` 를 재귀로 복사하고 감지는 최상위 css/js 의
|
||||
* mtime 만 서명해 범위가 어긋났다 — 문서가 권장하는 `url('./fonts/x.woff2')` 의 글꼴만
|
||||
* 교체하면 게시본이 영영 갱신되지 않았다(#651 F7). 감지 범위와 게시 범위를 한 코드가
|
||||
* 정의해야 다시 어긋나지 않는다.
|
||||
*
|
||||
* 규약 스캔의 **로드 목록**(`forExtension()` — 최상위 css/js) 과는 별개다. 글꼴·이미지는
|
||||
* CSS 가 상대 경로로 참조하는 대상이지 그 자체로 로드할 것이 아니므로 로드 목록은 그대로다.
|
||||
*
|
||||
* 허용 확장자는 종전 게시와 동일하게 `AllowedTemplateFileType::allowedExtensions()`(환경
|
||||
* 무관 게터)에서 소스맵(`map`)을 뺀 목록이다 — 배포 금지 정책(`*.map` gitignore)과 같다.
|
||||
*
|
||||
* @param string $extensionType `templates` | `modules` | `plugins`
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return array<int, array{relative: string, absolute: string, mtime: int, size: int}> 상대 경로 오름차순
|
||||
*/
|
||||
public static function publishableFiles(string $extensionType, string $identifier): array
|
||||
{
|
||||
$cacheKey = $extensionType.'|'.$identifier;
|
||||
|
||||
if (array_key_exists($cacheKey, self::$fileCache)) {
|
||||
return self::$fileCache[$cacheKey];
|
||||
}
|
||||
|
||||
return self::$fileCache[$cacheKey] = self::enumeratePublishableFiles($extensionType, $identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* `publishableFiles()` 의 실제 열거 (메모 없음).
|
||||
*
|
||||
* @param string $extensionType 확장 타입
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return array<int, array{relative: string, absolute: string, mtime: int, size: int}> 상대 경로 오름차순
|
||||
*/
|
||||
private static function enumeratePublishableFiles(string $extensionType, string $identifier): array
|
||||
{
|
||||
$directory = self::directory($extensionType, $identifier);
|
||||
|
||||
if ($directory === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$realRoot = realpath($directory);
|
||||
|
||||
if ($realRoot === false || ! is_dir($realRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowed = array_diff(AllowedTemplateFileType::allowedExtensions(), ['map']);
|
||||
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($realRoot, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
Log::warning('사용자 추가 에셋 디렉토리를 열 수 없습니다.', [
|
||||
'identifier' => $identifier,
|
||||
'directory' => $realRoot,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getExtension());
|
||||
|
||||
if (! in_array($extension, $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 컨테인먼트 검증 — 심볼릭 링크 등으로 custom 밖을 가리키는 실경로 차단 (게시와 동일)
|
||||
$realFile = $file->getRealPath();
|
||||
|
||||
if ($realFile === false || ! str_starts_with($realFile, $realRoot.DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', substr($realFile, strlen($realRoot) + 1));
|
||||
|
||||
$files[$relative] = [
|
||||
'relative' => $relative,
|
||||
'absolute' => $realFile,
|
||||
'mtime' => (int) (@$file->getMTime() ?: 0),
|
||||
'size' => (int) (@$file->getSize() ?: 0),
|
||||
];
|
||||
}
|
||||
|
||||
ksort($files, SORT_STRING);
|
||||
|
||||
return array_values($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장의 `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, '..');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* `.env` 키 단위 우선 규약 (G7_ENV_PRIORITY 옵트인).
|
||||
*
|
||||
* G7 은 관리자 환경설정(`storage/app/settings/*.json`)을 운영 SSoT 로 삼고,
|
||||
* `SettingsServiceProvider` 가 그 값을 `config()` 에 주입해 `.env` 유래 값을 덮는다.
|
||||
* 공유호스팅 1차 대상 설계에서는 그것이 옳지만, `.env` 를 배포 기준값으로 관리하는
|
||||
* 설치(컨테이너·IaC·다중 서버)에서는 `.env` 에 적은 값이 조용히 사문화된다.
|
||||
*
|
||||
* 이 클래스는 그 소유권을 **키 단위**로 되돌리는 판정을 단독으로 소유한다:
|
||||
*
|
||||
* - 스위치(`G7_ENV_PRIORITY`)가 꺼져 있으면 전 경로가 조기 return 이라 현행 동작과 100% 동일하다.
|
||||
* - 켜진 설치에서는 `.env` 에 값이 **명시된** 키만 잠긴다 — settings 주입을 건너뛰고(`.env` 권위),
|
||||
* 관리자 화면은 그 필드를 편집 불가로 표시하며, 저장 API 는 그 키를 서버측에서 필터한다.
|
||||
*
|
||||
* 명시 여부 판별은 반드시 `config('env-priority.explicit')`(빌드 시점 캡처)를 읽는다.
|
||||
* `env()` 직접 호출은 `config:cache` 환경에서 null 로 고정되므로 판별이 영구 미발동한다
|
||||
* (`config/attachment.php` 의 `disk_explicit` 와 동형 함정).
|
||||
*
|
||||
* `SettingsServiceProvider::register()` 단계(DI 컨테이너 사용 전)에서 호출되므로 정적 클래스다.
|
||||
*
|
||||
* @since 7.0.10
|
||||
*/
|
||||
final class EnvPriority
|
||||
{
|
||||
/**
|
||||
* settings 저장소 키 ↔ env 변수 ↔ config 키 매핑 (단일 SSoT).
|
||||
*
|
||||
* 소비자 3곳: ① Provider 주입 스킵 ② 설정 API `_meta.env_locked` + 표시값 ③ 계약 테스트.
|
||||
*
|
||||
* 각 항목의 형태:
|
||||
* - `env`: 그 설정을 결정하는 env 변수 목록. **하나라도** 명시되면 잠긴다(any-of) —
|
||||
* env 두 개가 한 설정을 나누는 `drivers.redis_database` 는 하나만 명시돼도 주입이
|
||||
* 그것을 덮으므로 any 가 안전측이다.
|
||||
* - `config`: 그 설정이 파생시키는 config 키 전부(문서·표시값 근거). 주입 스킵은
|
||||
* 배열 필터 방식이라 이 목록을 순회하지 않는다 — 첫 키가 표시값 조회 대상이다.
|
||||
* - `display`: 표시값 변환 지시자 (아래 `effectiveValue()` 참조). 없으면 config 값 그대로.
|
||||
* - `sensitive`: true 면 잠금 표시만 하고 유효값을 화면·응답에 싣지 않는다
|
||||
* (`.env` 비밀값이 admin UI 로 유출되는 것을 차단).
|
||||
*
|
||||
* @var array<string, array{env: array<int, string>, config: array<int, string>, display?: string, sensitive?: bool}>
|
||||
*/
|
||||
public const MAP = [
|
||||
// --- general ---
|
||||
'general.site_name' => ['env' => ['APP_NAME'], 'config' => ['app.name']],
|
||||
'general.site_url' => ['env' => ['APP_URL'], 'config' => ['app.url']],
|
||||
'general.timezone' => ['env' => ['APP_DEFAULT_USER_TIMEZONE'], 'config' => ['app.default_user_timezone', 'app.schedule_timezone']],
|
||||
'general.language' => ['env' => ['APP_LOCALE'], 'config' => ['app.locale']],
|
||||
|
||||
// --- mail ---
|
||||
'mail.mailer' => ['env' => ['MAIL_MAILER'], 'config' => ['mail.default']],
|
||||
'mail.host' => ['env' => ['MAIL_HOST'], 'config' => ['mail.mailers.smtp.host']],
|
||||
'mail.port' => ['env' => ['MAIL_PORT'], 'config' => ['mail.mailers.smtp.port']],
|
||||
'mail.username' => ['env' => ['MAIL_USERNAME'], 'config' => ['mail.mailers.smtp.username']],
|
||||
'mail.password' => ['env' => ['MAIL_PASSWORD'], 'config' => ['mail.mailers.smtp.password'], 'sensitive' => true],
|
||||
'mail.mailgun_domain' => ['env' => ['MAILGUN_DOMAIN'], 'config' => ['services.mailgun.domain']],
|
||||
'mail.mailgun_secret' => ['env' => ['MAILGUN_SECRET'], 'config' => ['services.mailgun.secret'], 'sensitive' => true],
|
||||
'mail.mailgun_endpoint' => ['env' => ['MAILGUN_ENDPOINT'], 'config' => ['services.mailgun.endpoint']],
|
||||
'mail.ses_key' => ['env' => ['AWS_ACCESS_KEY_ID'], 'config' => ['services.ses.key'], 'sensitive' => true],
|
||||
'mail.ses_secret' => ['env' => ['AWS_SECRET_ACCESS_KEY'], 'config' => ['services.ses.secret'], 'sensitive' => true],
|
||||
'mail.ses_region' => ['env' => ['AWS_DEFAULT_REGION'], 'config' => ['services.ses.region']],
|
||||
'mail.from_address' => ['env' => ['MAIL_FROM_ADDRESS'], 'config' => ['mail.from.address']],
|
||||
'mail.from_name' => ['env' => ['MAIL_FROM_NAME'], 'config' => ['mail.from.name']],
|
||||
|
||||
// --- debug ---
|
||||
'debug.mode' => ['env' => ['APP_DEBUG'], 'config' => ['app.debug']],
|
||||
'debug.log_level' => ['env' => ['LOG_LEVEL'], 'config' => ['logging.channels.single.level', 'logging.channels.daily.level', 'logging.level']],
|
||||
|
||||
// --- drivers ---
|
||||
'drivers.cache_driver' => ['env' => ['CACHE_STORE'], 'config' => ['cache.default']],
|
||||
'drivers.session_driver' => ['env' => ['SESSION_DRIVER'], 'config' => ['session.driver']],
|
||||
'drivers.session_lifetime' => ['env' => ['SESSION_LIFETIME'], 'config' => ['session.lifetime']],
|
||||
'drivers.queue_driver' => ['env' => ['QUEUE_CONNECTION'], 'config' => ['queue.default']],
|
||||
'drivers.storage_driver' => ['env' => ['FILESYSTEM_DISK'], 'config' => ['filesystems.default']],
|
||||
'drivers.redis_host' => ['env' => ['REDIS_HOST'], 'config' => ['database.redis.default.host', 'database.redis.cache.host']],
|
||||
'drivers.redis_port' => ['env' => ['REDIS_PORT'], 'config' => ['database.redis.default.port', 'database.redis.cache.port']],
|
||||
'drivers.redis_password' => ['env' => ['REDIS_PASSWORD'], 'config' => ['database.redis.default.password', 'database.redis.cache.password'], 'sensitive' => true],
|
||||
'drivers.redis_database' => ['env' => ['REDIS_DB', 'REDIS_CACHE_DB'], 'config' => ['database.redis.default.database', 'database.redis.cache.database']],
|
||||
'drivers.memcached_host' => ['env' => ['MEMCACHED_HOST'], 'config' => ['cache.stores.memcached.servers.0.host']],
|
||||
'drivers.memcached_port' => ['env' => ['MEMCACHED_PORT'], 'config' => ['cache.stores.memcached.servers.0.port']],
|
||||
'drivers.s3_bucket' => ['env' => ['AWS_BUCKET'], 'config' => ['filesystems.disks.s3.bucket']],
|
||||
'drivers.s3_region' => ['env' => ['AWS_DEFAULT_REGION'], 'config' => ['filesystems.disks.s3.region']],
|
||||
'drivers.s3_access_key' => ['env' => ['AWS_ACCESS_KEY_ID'], 'config' => ['filesystems.disks.s3.key'], 'sensitive' => true],
|
||||
'drivers.s3_secret_key' => ['env' => ['AWS_SECRET_ACCESS_KEY'], 'config' => ['filesystems.disks.s3.secret'], 'sensitive' => true],
|
||||
'drivers.s3_url' => ['env' => ['AWS_URL'], 'config' => ['filesystems.disks.s3.url']],
|
||||
'drivers.s3_endpoint' => ['env' => ['AWS_ENDPOINT'], 'config' => ['filesystems.disks.s3.endpoint']],
|
||||
'drivers.s3_use_path_style' => ['env' => ['AWS_USE_PATH_STYLE_ENDPOINT'], 'config' => ['filesystems.disks.s3.use_path_style_endpoint']],
|
||||
'drivers.log_driver' => ['env' => ['LOG_STACK'], 'config' => ['logging.channels.stack.channels'], 'display' => 'log_stack_first'],
|
||||
'drivers.log_level' => ['env' => ['LOG_LEVEL'], 'config' => ['logging.channels.single.level', 'logging.channels.daily.level']],
|
||||
'drivers.log_days' => ['env' => ['LOG_DAILY_DAYS'], 'config' => ['logging.channels.daily.days']],
|
||||
'drivers.search_engine_driver' => ['env' => ['SCOUT_DRIVER'], 'config' => ['scout.driver']],
|
||||
'drivers.websocket_enabled' => ['env' => ['BROADCAST_CONNECTION'], 'config' => ['broadcasting.default'], 'display' => 'broadcast_is_reverb'],
|
||||
'drivers.websocket_app_id' => ['env' => ['REVERB_APP_ID'], 'config' => ['broadcasting.connections.reverb.app_id', 'reverb.apps.apps.0.app_id']],
|
||||
'drivers.websocket_app_key' => ['env' => ['REVERB_APP_KEY'], 'config' => ['broadcasting.connections.reverb.key', 'reverb.apps.apps.0.key']],
|
||||
'drivers.websocket_app_secret' => ['env' => ['REVERB_APP_SECRET'], 'config' => ['broadcasting.connections.reverb.secret', 'reverb.apps.apps.0.secret'], 'sensitive' => true],
|
||||
'drivers.websocket_server_host' => ['env' => ['REVERB_HOST'], 'config' => ['broadcasting.connections.reverb.options.host', 'reverb.apps.apps.0.options.host']],
|
||||
'drivers.websocket_server_port' => ['env' => ['REVERB_PORT'], 'config' => ['broadcasting.connections.reverb.options.port', 'reverb.apps.apps.0.options.port']],
|
||||
'drivers.websocket_server_scheme' => ['env' => ['REVERB_SCHEME'], 'config' => ['broadcasting.connections.reverb.options.scheme', 'reverb.apps.apps.0.options.scheme']],
|
||||
'drivers.websocket_verify_ssl' => ['env' => ['REVERB_VERIFY_SSL'], 'config' => ['broadcasting.connections.reverb.client_options.verify']],
|
||||
|
||||
// --- core_update ---
|
||||
'core_update.github_url' => ['env' => ['G7_UPDATE_GITHUB_URL'], 'config' => ['app.update.github_url']],
|
||||
'core_update.github_token' => ['env' => ['G7_UPDATE_GITHUB_TOKEN'], 'config' => ['app.update.github_token'], 'sensitive' => true],
|
||||
|
||||
// --- geoip ---
|
||||
'geoip.feature_enabled' => ['env' => ['GEOIP_ENABLED'], 'config' => ['geoip.enabled']],
|
||||
'geoip.license_key' => ['env' => ['GEOIP_LICENSE_KEY'], 'config' => ['geoip.license_key'], 'sensitive' => true],
|
||||
'geoip.auto_update_enabled' => ['env' => ['GEOIP_AUTO_UPDATE_ENABLED'], 'config' => ['geoip.auto_update_enabled']],
|
||||
|
||||
// --- upload ---
|
||||
'upload.max_file_size' => ['env' => ['ATTACHMENT_MAX_FILE_SIZE'], 'config' => ['attachment.max_file_size'], 'display' => 'kb_to_mb'],
|
||||
'upload.image_max_width' => ['env' => ['ATTACHMENT_IMAGE_MAX_WIDTH'], 'config' => ['attachment.image_max_width']],
|
||||
'upload.image_max_height' => ['env' => ['ATTACHMENT_IMAGE_MAX_HEIGHT'], 'config' => ['attachment.image_max_height']],
|
||||
'upload.image_quality' => ['env' => ['ATTACHMENT_IMAGE_QUALITY'], 'config' => ['attachment.image_quality']],
|
||||
];
|
||||
|
||||
/**
|
||||
* env 대응이 존재하지 않아 매핑에서 의도적으로 제외한 settings 키와 그 사유.
|
||||
*
|
||||
* 계약 테스트가 "맵 누락"과 "의도적 제외"를 구분하는 근거다 — 이 목록이 없으면
|
||||
* `SettingsServiceProvider` 에 새 주입이 추가될 때 그것이 빠뜨린 것인지 대응이 없는
|
||||
* 것인지 판정할 수 없고, 결국 아무도 알아채지 못한 채 `.env` 가 다시 사문화된다.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const EXEMPT = [
|
||||
'general.site_description' => 'env 대응 없음 (설정 전용 값)',
|
||||
'general.admin_email' => 'env 대응 없음 (설정 전용 값)',
|
||||
'general.currency' => 'env 대응 없음 (설정 전용 값)',
|
||||
'general.maintenance_mode' => 'env 대응 없음 — APP_MAINTENANCE_DRIVER 는 저장소 종류이고 점검 모드 on/off 가 아니다',
|
||||
'general.site_logo' => 'env 대응 없음 (첨부 id 배열)',
|
||||
'general.asset_url_mode' => 'env 대응 없음 (설정 전용 값)',
|
||||
'mail.encryption' => 'config/mail.php 에 대응 키 없음 — Provider 가 쓰는 mail.mailers.smtp.encryption 은 env 유래가 아니다 (Laravel 12 의 env 대응 키는 MAIL_SCHEME → smtp.scheme 로 별개 축)',
|
||||
'debug.sql_query_log' => 'env 대응 없음 (g7.sql_query_log 는 설정 전용)',
|
||||
'debug.outbound_proxy' => 'env 대응 없음 (g7.outbound_proxy 는 설정 전용)',
|
||||
'debug.outbound_proxy_bypass' => 'env 대응 없음 (g7.outbound_proxy 는 설정 전용)',
|
||||
'drivers.public_asset_disk' => 'env 대응 없음 (core.storage.public_asset_disk 는 설정 전용)',
|
||||
'drivers.websocket_host' => '브라우저 클라이언트 endpoint (g7.websocket.client.host) — REVERB_HOST 는 서버 endpoint 축이라 대응이 아니다',
|
||||
'drivers.websocket_port' => '브라우저 클라이언트 endpoint (g7.websocket.client.port)',
|
||||
'drivers.websocket_scheme' => '브라우저 클라이언트 endpoint (g7.websocket.client.scheme)',
|
||||
'geoip.last_updated_at' => 'geoip:update 커맨드가 기록하는 런타임 상태값',
|
||||
'upload.allowed_extensions' => 'env 대응 없음 (AllowedExtensions 정규화를 거치는 설정 전용 값)',
|
||||
'upload.orphan_cleanup_enabled' => 'env 대응 없음 (설정 전용 값)',
|
||||
'upload.orphan_retention_days' => 'env 대응 없음 (설정 전용 값)',
|
||||
'identity.default_provider' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
|
||||
'identity.purpose_providers' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
|
||||
'identity.challenge_ttl_minutes' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
|
||||
'identity.max_attempts' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
|
||||
];
|
||||
|
||||
/**
|
||||
* 선언 파일(`config/*.php`)에 존재하지 않고 런타임에 생성되는 config 키.
|
||||
*
|
||||
* `logging.level` 은 G7 이 도입한 통합 로그 레벨 키로, `SettingsServiceProvider` 가
|
||||
* 만들고 `BrowserLogWriter` 가 기본값과 함께 읽는다 — Laravel 의 `config/logging.php`
|
||||
* 에는 선언이 없다. 계약 테스트가 "존재하지 않는 config 키" 로 오탐하지 않도록
|
||||
* 그 사유를 코드에 남긴다.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const RUNTIME_CREATED_CONFIG_KEYS = [
|
||||
'logging.level',
|
||||
];
|
||||
|
||||
/**
|
||||
* `SettingsServiceProvider` 가 settings → config 주입을 수행하는 카테고리 목록.
|
||||
*
|
||||
* 계약 테스트가 "이 카테고리의 defaults 키는 MAP 이나 EXEMPT 중 하나에 반드시 등재"
|
||||
* 라는 전수 패리티를 강제하는 모집단입니다. 주입 대상이 아닌 카테고리(security·seo·
|
||||
* cache·pagination·notifications)는 `.env` 를 덮는 일이 없으므로 대상이 아닙니다.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const INJECTED_CATEGORIES = [
|
||||
'general',
|
||||
'mail',
|
||||
'debug',
|
||||
'drivers',
|
||||
'core_update',
|
||||
'geoip',
|
||||
'upload',
|
||||
'identity',
|
||||
];
|
||||
|
||||
/**
|
||||
* 매핑에 등장하는 env 변수 전체를 중복 없이 반환합니다.
|
||||
*
|
||||
* `config/env-priority.php` 가 명시 여부를 캡처할 대상 목록입니다.
|
||||
*
|
||||
* @return array<int, string> env 변수명 목록
|
||||
*/
|
||||
public static function envVars(): array
|
||||
{
|
||||
$vars = [];
|
||||
|
||||
foreach (self::MAP as $entry) {
|
||||
foreach ($entry['env'] as $var) {
|
||||
$vars[$var] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* `.env` 우선 모드가 켜져 있는지 반환합니다.
|
||||
*
|
||||
* @return bool 켜져 있으면 true
|
||||
*/
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return config('env-priority.enabled') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 settings 키가 `.env` 로 잠겼는지 판정합니다.
|
||||
*
|
||||
* 스위치가 켜져 있고, 그 키를 결정하는 env 변수 중 **하나라도** 명시되어 있으면 잠깁니다.
|
||||
*
|
||||
* @param string $categoryDotKey settings 저장소 키 (예: `mail.port`)
|
||||
* @return bool 잠겼으면 true
|
||||
*/
|
||||
public static function isLocked(string $categoryDotKey): bool
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$entry = self::MAP[$categoryDotKey] ?? null;
|
||||
|
||||
if ($entry === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$explicit = config('env-priority.explicit', []);
|
||||
|
||||
foreach ($entry['env'] as $var) {
|
||||
if (($explicit[$var] ?? false) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 배열에서 잠긴 키를 제거합니다.
|
||||
*
|
||||
* 주입 지점(`SettingsServiceProvider`)이 이 배열을 받으므로, 기존 `!empty`/`isset`
|
||||
* 가드가 제거된 키를 자연히 건너뜁니다 — 한 설정이 파생시키는 config 키가 여러 개인
|
||||
* 경우(redis 2, reverb 2)도 지점마다 조건을 심지 않고 한 번에 처리됩니다.
|
||||
*
|
||||
* 스위치가 꺼져 있으면 입력을 그대로 반환합니다 (현행 동작 보존).
|
||||
*
|
||||
* @param string $category settings 카테고리명 (예: `mail`)
|
||||
* @param array<string, mixed> $settings 카테고리 설정 배열
|
||||
* @return array<string, mixed> 잠긴 키가 제거된 배열
|
||||
*/
|
||||
public static function filterLocked(string $category, array $settings): array
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
foreach (array_keys($settings) as $key) {
|
||||
if (self::isLocked($category.'.'.$key)) {
|
||||
unset($settings[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 입력에서 잠긴 키를 제거합니다 (서버측 게이트).
|
||||
*
|
||||
* 화면의 `disabled` 는 게이트가 아닙니다 — 저장 API 를 직접 호출하는 경로가 남으므로
|
||||
* 실질 차단은 이 지점입니다. abilities 필터와 동형으로 조용히 제거합니다(422 아님):
|
||||
* 운영자가 `.env` 로 소유권을 가져간 키는 "거부"가 아니라 "설정 대상이 아님"입니다.
|
||||
*
|
||||
* @param string $category settings 카테고리명 (예: `mail`)
|
||||
* @param array<string, mixed> $input 저장 입력 (원본 저장소 키 기준)
|
||||
* @return array<string, mixed> 잠긴 키가 제거된 입력
|
||||
*/
|
||||
public static function rejectLockedForSave(string $category, array $input): array
|
||||
{
|
||||
return self::filterLocked($category, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠긴 settings 키 목록을 반환합니다.
|
||||
*
|
||||
* @return array<string, bool> 저장소 키 => true (잠긴 키만)
|
||||
*/
|
||||
public static function lockedKeys(): array
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$locked = [];
|
||||
|
||||
foreach (array_keys(self::MAP) as $key) {
|
||||
if (self::isLocked($key)) {
|
||||
$locked[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 settings 키가 민감 정보인지 반환합니다.
|
||||
*
|
||||
* 민감 키는 잠금 표시만 하고 유효값을 화면·응답에 싣지 않습니다.
|
||||
*
|
||||
* @param string $categoryDotKey settings 저장소 키
|
||||
* @return bool 민감 정보면 true
|
||||
*/
|
||||
public static function isSensitive(string $categoryDotKey): bool
|
||||
{
|
||||
return (self::MAP[$categoryDotKey]['sensitive'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠긴 키의 유효값(런타임 config 값)을 반환합니다.
|
||||
*
|
||||
* 화면이 저장값 대신 이 값을 보여주어야 "진실"과 일치합니다 — 잠긴 필드에 사문화된
|
||||
* 저장값이 남아 있으면 운영자는 적용되지 않는 값을 읽게 됩니다.
|
||||
*
|
||||
* 민감 키는 호출자가 걸러야 합니다 (이 메서드는 값 자체를 판단하지 않습니다).
|
||||
*
|
||||
* @param string $categoryDotKey settings 저장소 키
|
||||
* @return mixed 유효값 (매핑에 없으면 null)
|
||||
*/
|
||||
public static function effectiveValue(string $categoryDotKey): mixed
|
||||
{
|
||||
$entry = self::MAP[$categoryDotKey] ?? null;
|
||||
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = config($entry['config'][0]);
|
||||
|
||||
return match ($entry['display'] ?? null) {
|
||||
// config/attachment.* 는 KB, 관리자 화면은 MB — Provider 의 변환과 역방향.
|
||||
'kb_to_mb' => is_numeric($value) ? (int) ((int) $value / 1024) : $value,
|
||||
// 화면의 웹소켓 마스터 토글은 broadcasting 드라이버 선택으로 표현된다.
|
||||
'broadcast_is_reverb' => $value === 'reverb',
|
||||
// stack 채널 목록의 첫 항목이 화면의 단일 로그 드라이버 선택값이다.
|
||||
'log_stack_first' => is_array($value) ? ($value[0] ?? null) : $value,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 확장 데이터 모델 수집기
|
||||
*
|
||||
* 모델·Enum·마이그레이션·Repository 계약을 `_bundled` 소스에서 수집합니다.
|
||||
* 모델 클래스를 로드하지 않고 소스를 파싱하므로 DB 연결이나 확장 활성화 상태와 무관하게
|
||||
* 동작합니다 (문서 생성은 설치되지 않은 확장에도 수행되어야 합니다).
|
||||
*/
|
||||
class DataModelCollector
|
||||
{
|
||||
/**
|
||||
* Eloquent 관계 정의 메서드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const RELATION_METHODS = [
|
||||
'hasOne', 'hasMany', 'belongsTo', 'belongsToMany',
|
||||
'hasOneThrough', 'hasManyThrough',
|
||||
'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany',
|
||||
];
|
||||
|
||||
/**
|
||||
* 확장의 데이터 모델 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{models: array<int, array<string, mixed>>, enums: array<int, array<string, mixed>>, migrations: array<int, array<string, mixed>>, tables: array<int, string>, repositories: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$models = $this->collectModels($record);
|
||||
$migrations = $this->collectMigrations($record);
|
||||
|
||||
$tables = [];
|
||||
foreach ($migrations as $migration) {
|
||||
foreach ($migration['creates'] as $table) {
|
||||
$tables[$table] = true;
|
||||
}
|
||||
}
|
||||
foreach ($models as $model) {
|
||||
if ($model['table'] !== null) {
|
||||
$tables[$model['table']] = true;
|
||||
}
|
||||
}
|
||||
$tables = array_keys($tables);
|
||||
sort($tables);
|
||||
|
||||
return [
|
||||
'models' => $models,
|
||||
'enums' => $this->collectEnums($record),
|
||||
'migrations' => $migrations,
|
||||
'tables' => $tables,
|
||||
'repositories' => $this->collectRepositories($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `src/Models/**` 의 모델을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 모델 목록
|
||||
*/
|
||||
private function collectModels(array $record): array
|
||||
{
|
||||
$models = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'src/Models') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
$short = basename($file, '.php');
|
||||
|
||||
$models[] = [
|
||||
'class' => $short,
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'table' => $this->stringProperty($content, 'table'),
|
||||
'fillable' => $this->arrayPropertyCount($content, 'fillable'),
|
||||
'softDeletes' => (bool) preg_match('/\buse\s+[^;]*\bSoftDeletes\b/', $content),
|
||||
'userOverrides' => str_contains($content, 'HasUserOverrides'),
|
||||
'searchable' => str_contains($content, 'FulltextSearchable') || str_contains($content, 'Laravel\Scout\Searchable'),
|
||||
'relations' => $this->collectRelations($content),
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($models, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 소스에서 관계 정의를 수집합니다.
|
||||
*
|
||||
* @param string $content 모델 소스
|
||||
* @return array<int, array{method: string, type: string, target: string|null}> 관계 목록
|
||||
*/
|
||||
private function collectRelations(string $content): array
|
||||
{
|
||||
$relations = [];
|
||||
$alternation = implode('|', self::RELATION_METHODS);
|
||||
|
||||
$pattern = '/public\s+function\s+(\w+)\s*\([^)]*\)[^{]*\{(?:[^{}]|\{[^{}]*\})*?\$this->('
|
||||
.$alternation
|
||||
.')\s*\(\s*(?:([A-Za-z_\\\\]+)::class)?/s';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $m) {
|
||||
$relations[] = [
|
||||
'method' => $m[1],
|
||||
'type' => $m[2],
|
||||
'target' => ($m[3] ?? '') !== '' ? $this->shortName($m[3]) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* `src/Enums/**` 의 Enum 을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> Enum 목록
|
||||
*/
|
||||
private function collectEnums(array $record): array
|
||||
{
|
||||
$enums = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'src/Enums') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$backing = null;
|
||||
if (preg_match('/^\s*enum\s+\w+\s*:\s*(\w+)/m', $content, $bm)) {
|
||||
$backing = $bm[1];
|
||||
}
|
||||
|
||||
$cases = [];
|
||||
if (preg_match_all("/^\s*case\s+(\w+)\s*(?:=\s*'([^']*)')?/m", $content, $cm, PREG_SET_ORDER)) {
|
||||
foreach ($cm as $c) {
|
||||
$cases[] = ['name' => $c[1], 'value' => $c[2] ?? null];
|
||||
}
|
||||
}
|
||||
|
||||
$enums[] = [
|
||||
'class' => basename($file, '.php'),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'backing' => $backing,
|
||||
'cases' => $cases,
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($enums, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $enums;
|
||||
}
|
||||
|
||||
/**
|
||||
* `database/migrations/**` 을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 마이그레이션 목록 (파일명 정렬)
|
||||
*/
|
||||
private function collectMigrations(array $record): array
|
||||
{
|
||||
$migrations = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'database/migrations') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$creates = [];
|
||||
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?create\s*\(\s*'([^']+)'/", $content, $m)) {
|
||||
$creates = array_values(array_unique($m[1]));
|
||||
}
|
||||
|
||||
$alters = [];
|
||||
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?table\s*\(\s*'([^']+)'/", $content, $m)) {
|
||||
$alters = array_values(array_unique($m[1]));
|
||||
}
|
||||
|
||||
$migrations[] = [
|
||||
'file' => basename($file),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'creates' => $creates,
|
||||
'alters' => $alters,
|
||||
'hasDown' => (bool) preg_match('/function\s+down\s*\(/', $content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($migrations, static fn (array $a, array $b): int => $a['file'] <=> $b['file']);
|
||||
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository 계약과 구현을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> Repository 목록
|
||||
*/
|
||||
private function collectRepositories(array $record): array
|
||||
{
|
||||
$repositories = [];
|
||||
|
||||
foreach (['src/Repositories', 'src/Contracts/Repositories'] as $sub) {
|
||||
foreach ($this->filesIn($record, $sub) as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$repositories[] = [
|
||||
'class' => basename($file, '.php'),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'isInterface' => (bool) preg_match('/^\s*interface\s+\w+/m', $content),
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($repositories, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $repositories;
|
||||
}
|
||||
|
||||
/**
|
||||
* `protected $x = '...'` 형태의 문자열 프로퍼티 값을 읽습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @param string $name 프로퍼티명
|
||||
* @return string|null 값 (없으면 null)
|
||||
*/
|
||||
private function stringProperty(string $content, string $name): ?string
|
||||
{
|
||||
if (preg_match('/\$'.preg_quote($name, '/')."\s*=\s*'([^']*)'/", $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `protected $x = [...]` 형태의 배열 프로퍼티 원소 수를 셉니다.
|
||||
*
|
||||
* 근사치입니다 — 문서의 규모 감을 주기 위한 값이며 계약 판정에 쓰지 않습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @param string $name 프로퍼티명
|
||||
* @return int|null 원소 수 (프로퍼티 없으면 null)
|
||||
*/
|
||||
private function arrayPropertyCount(string $content, string $name): ?int
|
||||
{
|
||||
if (! preg_match('/\$'.preg_quote($name, '/').'\s*=\s*\[(.*?)\];/s', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match_all("/'[^']*'/", $m[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스 docblock 의 첫 문장을 요약으로 뽑습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @return string|null 요약 (없으면 null)
|
||||
*/
|
||||
private function classDocSummary(string $content): ?string
|
||||
{
|
||||
if (! preg_match('#/\*\*(.*?)\*/\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|enum|interface|trait)\s+\w+#s', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/^\s*\*\s?/', '', $line) ?? '');
|
||||
if ($line !== '' && ! str_starts_with($line, '@')) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FQCN 에서 클래스 짧은 이름을 뽑습니다.
|
||||
*
|
||||
* @param string $fqcn 클래스명
|
||||
* @return string 짧은 이름
|
||||
*/
|
||||
private function shortName(string $fqcn): string
|
||||
{
|
||||
$parts = explode('\\', trim($fqcn, '\\'));
|
||||
|
||||
return end($parts) ?: $fqcn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 하위 디렉토리의 PHP 파일을 열거합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $sub 확장 루트 기준 하위 경로
|
||||
* @return array<int, string> PHP 파일 절대 경로
|
||||
*/
|
||||
private function filesIn(array $record, string $sub): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 루트 기준 상대 경로로 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $absolute 절대 경로
|
||||
* @return string 상대 경로 (POSIX 구분자)
|
||||
*/
|
||||
private function relative(array $record, string $absolute): string
|
||||
{
|
||||
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
|
||||
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
|
||||
|
||||
return str_replace('\\', '/', $rel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use App\Extension\AbstractModule;
|
||||
use App\Extension\AbstractPlugin;
|
||||
use ReflectionClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 확장 선언형 표면 수집기
|
||||
*
|
||||
* `AbstractModule` / `AbstractPlugin` 이 이미 갖고 있는 선언형 getter 를 실제로 호출해
|
||||
* 라우트·권한·메뉴·훅·설정·스케줄 등의 표면을 읽습니다. 정규식으로 소스를 긁는 방식과 달리
|
||||
* 상속 기본값(`getRoutes()` 가 파일 존재 여부로 계산하는 값 등)까지 정확히 반영됩니다.
|
||||
*
|
||||
* 읽기 대상은 항상 `_bundled` 소스입니다. 활성 디렉토리에 같은 FQCN 이 이미 로드되어 있으면
|
||||
* PHP 는 클래스를 재정의할 수 없으므로, `ModuleManager::evalFreshModule()` 과 같은 방식으로
|
||||
* 진입 클래스명만 바꿔 메모리에 다시 로드합니다 (namespace 유지 → use/extends 정상 동작).
|
||||
*
|
||||
* 확장 getter 는 DB·파일시스템·다른 확장에 의존할 수 있으므로 개별 호출을 각각 격리합니다.
|
||||
* 한 getter 의 실패가 나머지 수집을 중단시키지 않으며, 실패 사유는 `errors` 로 올라가
|
||||
* 문서에 "수집 실패" 로 드러납니다 (조용한 누락 금지).
|
||||
*/
|
||||
class DeclarativeSurfaceCollector
|
||||
{
|
||||
/**
|
||||
* 수집 대상 getter 와 문서상 라벨.
|
||||
*
|
||||
* 확장 유형에 없는 getter 는 `method_exists` 로 건너뜁니다 (모듈 전용 · 플러그인 전용 혼재).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const GETTERS = [
|
||||
// 라우트·마이그레이션·뷰
|
||||
'getRoutes' => '라우트 파일',
|
||||
'getMigrations' => '마이그레이션 경로',
|
||||
'getViews' => '뷰 경로',
|
||||
'getSeeders' => '시더',
|
||||
'getDynamicTables' => '동적 테이블',
|
||||
// 권한·역할·메뉴
|
||||
'getPermissions' => '권한 정의',
|
||||
'getDynamicPermissionIdentifiers' => '동적 권한 식별자',
|
||||
'getRoles' => '역할 정의',
|
||||
'getDynamicRoleIdentifiers' => '동적 역할 식별자',
|
||||
'getAdminMenus' => '관리자 메뉴',
|
||||
'getCustomMenus' => '사용자 메뉴',
|
||||
'getDynamicMenuSlugs' => '동적 메뉴 slug',
|
||||
// 확장점
|
||||
'getHooks' => '발행 훅 선언',
|
||||
'getHookListeners' => '훅 리스너',
|
||||
'getChannels' => '브로드캐스트 채널',
|
||||
'getSchedules' => '스케줄',
|
||||
'getMiddleware' => '미들웨어',
|
||||
'getLayoutExtensions' => '레이아웃 확장',
|
||||
'getNotificationDefinitions' => '알림 정의',
|
||||
'getBenchmarkProfiles' => '성능 계측 프로파일',
|
||||
// 본인인증
|
||||
'getIdentityPolicies' => 'IDV 정책',
|
||||
'getIdentityPurposes' => 'IDV 목적',
|
||||
'getIdentityMessages' => 'IDV 메시지',
|
||||
// 설정
|
||||
'getConfig' => 'config 파일',
|
||||
'getConfigValues' => 'config 값',
|
||||
'getSettingsSchema' => '설정 스키마',
|
||||
'getSettingsDefaultsPath' => '설정 기본값 경로',
|
||||
'getSettingsLayout' => '설정 레이아웃',
|
||||
'getSettingsRoute' => '설정 라우트',
|
||||
'getSeoConfigPath' => 'SEO 설정 경로',
|
||||
// 에셋
|
||||
'getAssets' => '프론트 에셋',
|
||||
'getAssetLoadingConfig' => '에셋 로딩 설정',
|
||||
'getBuiltAssetPaths' => '빌드 산출물 경로',
|
||||
'getTrustedScriptHosts' => '신뢰 스크립트 호스트',
|
||||
'getStorageDisk' => '스토리지 디스크',
|
||||
'getCacheStore' => '캐시 스토어',
|
||||
// 메타
|
||||
'getDependencies' => '의존 확장',
|
||||
'getRequiredCoreVersion' => '코어 최소 버전',
|
||||
'getLicense' => '라이선스',
|
||||
'getGithubUrl' => 'GitHub URL',
|
||||
];
|
||||
|
||||
/**
|
||||
* 확장의 선언형 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{available: bool, reason: string|null, values: array<string, mixed>, errors: array<string, string>, endpoints: int}
|
||||
* available=false 이면 values 는 비고 reason 에 사유가 담깁니다.
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$empty = ['available' => false, 'reason' => null, 'values' => [], 'errors' => [], 'endpoints' => 0];
|
||||
// 확장마다 초기화한다 — 남겨 두면 앞 확장의 실패가 다음 확장의 사유로 새어 나간다.
|
||||
$this->pathInjectionError = null;
|
||||
|
||||
if (($record['entryFile'] ?? null) === null || ($record['entryClass'] ?? null) === null) {
|
||||
$empty['reason'] = '진입 클래스 없음 (템플릿은 선언형 표면을 갖지 않습니다)';
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$restore = $this->registerBundledAutoloader($record);
|
||||
|
||||
try {
|
||||
$instance = $this->instantiate($record);
|
||||
} catch (Throwable $e) {
|
||||
$restore();
|
||||
$empty['reason'] = '진입 클래스 로드 실패: '.$e->getMessage();
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
if ($instance === null) {
|
||||
$restore();
|
||||
$empty['reason'] = '진입 클래스 인스턴스화 실패: '.$record['entryClass'];
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
$errors = [];
|
||||
|
||||
if ($this->pathInjectionError !== null) {
|
||||
$errors['__path_injection'] = $this->pathInjectionError;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach (array_keys(self::GETTERS) as $getter) {
|
||||
if (! method_exists($instance, $getter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$values[$getter] = $instance->{$getter}();
|
||||
} catch (Throwable $e) {
|
||||
$errors[$getter] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$restore();
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'reason' => null,
|
||||
'values' => $values,
|
||||
'errors' => $errors,
|
||||
'endpoints' => $this->countEndpoints($values['getRoutes'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 라우트 파일에 등록된 엔드포인트 수를 셉니다.
|
||||
*
|
||||
* 집계 배지가 말하는 "라우트 수" 는 **주소(엔드포인트) 개수**입니다 — 라우트 파일 개수가
|
||||
* 아닙니다. 확장 대부분이 파일 1~2개에 수십 개의 주소를 담으므로 파일 수는 규모를 전혀
|
||||
* 알려주지 않습니다. 이 값은 `docs/api/README.md` 목차의 엔드포인트 수와 같은 것을 세므로
|
||||
* 두 표가 서로 다른 숫자를 말하지 않습니다.
|
||||
*
|
||||
* `Route::match(['GET','POST'], ...)` 는 한 번 등록되지만 주소는 메서드 수만큼이므로
|
||||
* 배열 길이로 셉니다 (API 문서 생성기와 같은 기준).
|
||||
*
|
||||
* @param mixed $routes `getRoutes()` 반환값 (종류 => 파일 경로)
|
||||
* @return int 엔드포인트 수 (셀 수 없으면 0)
|
||||
*/
|
||||
private function countEndpoints(mixed $routes): int
|
||||
{
|
||||
if (! is_array($routes)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$verbs = 'get|post|put|patch|delete|options|any|dualSuffix|dualSuffixSegment|dualAsset';
|
||||
$total = 0;
|
||||
|
||||
foreach ($routes as $path) {
|
||||
if (! is_string($path) || ! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) file_get_contents($path);
|
||||
|
||||
$total += preg_match_all('/Route::(?:'.$verbs.')\s*\(/', $source);
|
||||
|
||||
// match 는 메서드 배열의 길이만큼 주소를 만든다.
|
||||
if (preg_match_all('/Route::match\s*\(\s*\[([^\]]*)\]/', $source, $m) > 0) {
|
||||
foreach ($m[1] as $methods) {
|
||||
$total += max(1, preg_match_all('/[\'"]/', $methods) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (int) $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진입 클래스를 인스턴스화합니다.
|
||||
*
|
||||
* 같은 FQCN 이 이미 로드되어 있으면(활성 디렉토리 확장이 부팅된 경우) 클래스명을 바꿔
|
||||
* eval 로 다시 로드합니다. 그렇지 않으면 `_bundled` 파일을 직접 include 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return object|null 확장 인스턴스 (실패 시 null)
|
||||
*/
|
||||
/**
|
||||
* 직전 인스턴스화에서 발생한 경로 주입 실패 사유 (없으면 null).
|
||||
*/
|
||||
private ?string $pathInjectionError = null;
|
||||
|
||||
private function instantiate(array $record): ?object
|
||||
{
|
||||
$fqcn = (string) $record['entryClass'];
|
||||
$entryFile = (string) $record['entryFile'];
|
||||
|
||||
if (! class_exists($fqcn, false)) {
|
||||
require_once $entryFile;
|
||||
|
||||
return class_exists($fqcn, false) ? new $fqcn : null;
|
||||
}
|
||||
|
||||
return $this->evalFreshEntry($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 로드된 FQCN 을 피해 `_bundled` 진입 클래스를 새 이름으로 다시 로드합니다.
|
||||
*
|
||||
* PHP 는 동일 프로세스에서 클래스를 재정의할 수 없으므로 클래스명만 치환합니다.
|
||||
* namespace 는 유지하므로 use/extends/implements 가 그대로 동작합니다.
|
||||
* eval 로 만든 클래스는 `ReflectionClass::getFileName()` 이 비정상이라
|
||||
* 경로 프로퍼티를 리플렉션으로 직접 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return object|null 확장 인스턴스 (실패 시 null)
|
||||
*/
|
||||
private function evalFreshEntry(array $record): ?object
|
||||
{
|
||||
$content = @file_get_contents((string) $record['entryFile']);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$short = (string) $record['entryClassShort'];
|
||||
$uid = '_extdoc_'.bin2hex(random_bytes(6));
|
||||
|
||||
$renamed = preg_replace('/\bclass\s+'.preg_quote($short, '/').'\b/', 'class '.$short.$uid, $content, 1);
|
||||
if (! is_string($renamed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$renamed = preg_replace('/^<\?php\s*/', '', $renamed);
|
||||
if (! is_string($renamed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
eval($renamed);
|
||||
|
||||
$freshClass = $record['namespace'].'\\'.$short.$uid;
|
||||
if (! class_exists($freshClass, false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = new $freshClass;
|
||||
$this->pathInjectionError = $this->injectExtensionPath($instance, (string) $record['path']);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* eval 로 로드한 인스턴스에 확장 디렉토리 경로를 주입합니다.
|
||||
*
|
||||
* @param object $instance 확장 인스턴스
|
||||
* @param string $path 확장 디렉토리 절대 경로
|
||||
*/
|
||||
private function injectExtensionPath(object $instance, string $path): ?string
|
||||
{
|
||||
$targets = [
|
||||
AbstractModule::class => 'modulePath',
|
||||
AbstractPlugin::class => 'pluginPath',
|
||||
];
|
||||
|
||||
foreach ($targets as $class => $property) {
|
||||
if (! $instance instanceof $class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ref = new ReflectionClass($class);
|
||||
if (! $ref->hasProperty($property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prop = $ref->getProperty($property);
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($instance, $path);
|
||||
} catch (Throwable $e) {
|
||||
// 경로 주입 실패는 예외를 던지지 않는다. 그런데 경로 기반 getter(`getRoutes`
|
||||
// `getMigrations` 등)는 그 상태에서 **예외 없이 빈 값**을 돌려주므로 getter 별
|
||||
// try/catch 에도 걸리지 않는다 — "없음" 으로 굳는 침묵 경로다. 사유를 올린다.
|
||||
return $property.' 주입 실패: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_bundled` composer.json 의 PSR-4 매핑을 임시 오토로더로 등록합니다.
|
||||
*
|
||||
* 아직 로드되지 않은 확장 내부 클래스(Listener·Model 등)가 활성 디렉토리가 아니라
|
||||
* `_bundled` 소스에서 해석되도록 합니다. 수집이 끝나면 반드시 해제해야 하므로
|
||||
* 해제 클로저를 돌려줍니다 (프로세스 잔류 시 이후 코드가 `_bundled` 를 보게 됨).
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return \Closure 해제 클로저
|
||||
*/
|
||||
private function registerBundledAutoloader(array $record): \Closure
|
||||
{
|
||||
$composerPath = $record['path'].DIRECTORY_SEPARATOR.'composer.json';
|
||||
$psr4 = [];
|
||||
|
||||
if (is_file($composerPath)) {
|
||||
$composer = json_decode((string) file_get_contents($composerPath), true);
|
||||
$declared = $composer['autoload']['psr-4'] ?? null;
|
||||
|
||||
if (is_array($declared)) {
|
||||
foreach ($declared as $prefix => $dir) {
|
||||
$dirs = is_array($dir) ? $dir : [$dir];
|
||||
foreach ($dirs as $one) {
|
||||
$psr4[(string) $prefix][] = rtrim($record['path'].DIRECTORY_SEPARATOR.trim((string) $one, '/\\'), '/\\');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($psr4 === []) {
|
||||
return static function (): void {};
|
||||
}
|
||||
|
||||
$loader = static function (string $class) use ($psr4): void {
|
||||
foreach ($psr4 as $prefix => $dirs) {
|
||||
if (! str_starts_with($class, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix))).'.php';
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$file = $dir.DIRECTORY_SEPARATOR.$relative;
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
spl_autoload_register($loader, true, true);
|
||||
|
||||
return static function () use ($loader): void {
|
||||
spl_autoload_unregister($loader);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
/**
|
||||
* 확장 의존 관계 수집기
|
||||
*
|
||||
* manifest 의 `dependencies` 선언을 정방향(내가 의존하는 확장)과 역방향(나에게 의존하는
|
||||
* 확장) 양쪽으로 해석합니다.
|
||||
*
|
||||
* 역방향이 이 수집기의 존재 이유입니다 — 운영자가 "이 확장을 끄면 무엇이 같이 죽는가" 를
|
||||
* 알아야 하는데, 그 정보는 어느 한 manifest 에도 없고 번들 전수를 교차 스캔해야만 나옵니다.
|
||||
* 확장명을 하드코딩하지 않고 인벤토리 스캔 결과에서 도출하므로 신규 확장이 자동 편입됩니다.
|
||||
*/
|
||||
class DependencyGraphCollector
|
||||
{
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>|null 전수 인벤토리 캐시
|
||||
*/
|
||||
private ?array $universe = null;
|
||||
|
||||
/**
|
||||
* @param ExtensionInventory $inventory 번들 확장 인벤토리
|
||||
*/
|
||||
public function __construct(private readonly ExtensionInventory $inventory) {}
|
||||
|
||||
/**
|
||||
* 확장의 의존 관계를 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{requires: array<int, array{type: string, id: string, constraint: string, bundled: bool}>, requiredBy: array<int, array{type: string, id: string, constraint: string}>, coreVersion: string|null}
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
return [
|
||||
'requires' => $this->requires($record),
|
||||
'requiredBy' => $this->requiredBy($record),
|
||||
'coreVersion' => $this->coreConstraint($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 확장이 의존하는 확장 목록을 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array{type: string, id: string, constraint: string, bundled: bool}>
|
||||
*/
|
||||
private function requires(array $record): array
|
||||
{
|
||||
$requires = [];
|
||||
|
||||
foreach ($this->declaredDependencies($record) as $type => $entries) {
|
||||
foreach ($entries as $id => $constraint) {
|
||||
$requires[] = [
|
||||
'type' => $type,
|
||||
'id' => (string) $id,
|
||||
'constraint' => (string) $constraint,
|
||||
'bundled' => $this->isBundled($type, (string) $id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($requires, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
|
||||
|
||||
return $requires;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 확장에 의존하는 확장 목록을 반환합니다 (번들 전수 교차 스캔).
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array{type: string, id: string, constraint: string}>
|
||||
*/
|
||||
private function requiredBy(array $record): array
|
||||
{
|
||||
$selfType = $this->pluralize((string) $record['type']);
|
||||
$selfId = (string) $record['id'];
|
||||
$dependents = [];
|
||||
|
||||
foreach ($this->allExtensions() as $other) {
|
||||
if ($other['id'] === $selfId && $other['type'] === $record['type']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$declared = $this->declaredDependencies($other);
|
||||
$constraint = $declared[$selfType][$selfId] ?? null;
|
||||
|
||||
if ($constraint === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dependents[] = [
|
||||
'type' => (string) $other['type'],
|
||||
'id' => (string) $other['id'],
|
||||
'constraint' => (string) $constraint,
|
||||
];
|
||||
}
|
||||
|
||||
usort($dependents, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
|
||||
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 코어 버전 제약을 읽습니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return string|null 코어 버전 제약 (없으면 null)
|
||||
*/
|
||||
private function coreConstraint(array $record): ?string
|
||||
{
|
||||
$value = $record['manifest']['g7_version'] ?? ($record['manifest']['requires']['g7_version'] ?? null);
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 dependencies 선언을 정규화합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{modules: array<string, string>, plugins: array<string, string>}
|
||||
*/
|
||||
private function declaredDependencies(array $record): array
|
||||
{
|
||||
$declared = $record['manifest']['dependencies'] ?? [];
|
||||
// templates 도 정규화한다 — pluralize() 가 'templates' 를 만드는데 여기에 그 키가
|
||||
// 없으면 템플릿의 역방향 의존(`requiredBy`)이 구조적으로 항상 빈 배열이 된다.
|
||||
$normalized = ['modules' => [], 'plugins' => [], 'templates' => []];
|
||||
|
||||
if (! is_array($declared)) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
foreach (['modules', 'plugins', 'templates'] as $key) {
|
||||
$entries = $declared[$key] ?? [];
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($entries as $id => $constraint) {
|
||||
if (is_string($constraint)) {
|
||||
$normalized[$key][(string) $id] = $constraint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형 단수형을 manifest dependencies 의 복수 키로 바꿉니다.
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @return string 복수 키 (`modules` | `plugins` | `templates`)
|
||||
*/
|
||||
private function pluralize(string $type): string
|
||||
{
|
||||
return $type.'s';
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상이 번들 확장인지 확인합니다.
|
||||
*
|
||||
* @param string $pluralType 복수 키
|
||||
* @param string $id 확장 식별자
|
||||
* @return bool 번들 여부
|
||||
*/
|
||||
private function isBundled(string $pluralType, string $id): bool
|
||||
{
|
||||
$singular = rtrim($pluralType, 's');
|
||||
|
||||
foreach ($this->allExtensions() as $ext) {
|
||||
if ($ext['type'] === $singular && $ext['id'] === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 확장 전수를 반환합니다 (1회 스캔 후 캐시).
|
||||
*
|
||||
* @return array<int, array<string, mixed>> 확장 레코드 목록
|
||||
*/
|
||||
private function allExtensions(): array
|
||||
{
|
||||
if ($this->universe === null) {
|
||||
$this->universe = $this->inventory->collect('all');
|
||||
}
|
||||
|
||||
return $this->universe;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user