Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00b8d04a1 | ||
|
|
0b6f329249 | ||
|
|
72f46a6468 | ||
|
|
a8a98f6ed0 | ||
|
|
455d009a83 | ||
|
|
3b0a8c03cf | ||
|
|
dd8f9a49c4 | ||
|
|
9ca7b42e80 | ||
|
|
5c2e42c133 | ||
|
|
e160a86f0d | ||
|
|
0c4f52fc96 | ||
|
|
ffab451b4a | ||
|
|
21177c8e17 | ||
|
|
f32729e20e | ||
|
|
f506c040c1 | ||
|
|
893c63c605 | ||
|
|
50007d5cc6 | ||
|
|
72fb12f267 | ||
|
|
b46396b079 | ||
|
|
bc0b47b12b | ||
|
|
e3ca585afd | ||
|
|
f41915124a | ||
|
|
7946410ba6 | ||
|
|
d8426c5341 | ||
|
|
98bce431f8 | ||
|
|
033d2e6d9b | ||
|
|
24f9df9de4 | ||
|
|
67cae00dd8 | ||
|
|
b79e082ca3 | ||
|
|
de18159986 | ||
|
|
d263f74cae | ||
|
|
0be081c997 | ||
|
|
9cf9c3ff8c | ||
|
|
8f072895ff | ||
|
|
3e41dc6075 | ||
|
|
9d3b15e300 | ||
|
|
f417488816 | ||
|
|
0833bd1f6a | ||
|
|
482faf00be | ||
|
|
c305b56248 | ||
|
|
381fcc4e0b | ||
|
|
b97deee43a | ||
|
|
1124fad027 | ||
|
|
8cf829f953 | ||
|
|
92923a5011 | ||
|
|
5d14d01d50 | ||
|
|
db8640df86 | ||
|
|
1e89335258 | ||
|
|
621cd28376 | ||
|
|
21fc114f37 | ||
|
|
d4439ea30a | ||
|
|
151a7ae945 | ||
|
|
de584ea93f | ||
|
|
cd189190c2 | ||
|
|
da5f76a9d8 | ||
|
|
3cb598ce5f | ||
|
|
3a0aba85bc | ||
|
|
eb7ff4ec6a | ||
|
|
6c63536f81 | ||
|
|
8328b1db77 | ||
|
|
11175d35d6 | ||
|
|
266b723b9d | ||
|
|
01d319e74b | ||
|
|
f094a5f3fc | ||
|
|
4d60b6cefb | ||
|
|
f9ef15f287 | ||
|
|
b6c5e1f323 | ||
|
|
46ada4dddb | ||
|
|
85e266d361 | ||
|
|
fc27a6edf6 | ||
|
|
b4ed33aa44 | ||
|
|
fe973f40af | ||
|
|
3945b6f1b3 | ||
|
|
72f2e94c1a | ||
|
|
0253c1aa68 | ||
|
|
0bb33e3baa | ||
|
|
5ba7a83597 | ||
|
|
56836a2b66 | ||
|
|
78bb192bb0 | ||
|
|
9ff363bd14 | ||
|
|
7b26c4beec | ||
|
|
28d6a7752c | ||
|
|
0bccd867a7 | ||
|
|
e7fa1b5535 | ||
|
|
40f62e79aa | ||
|
|
e17731d8cc | ||
|
|
6a8a537f10 | ||
|
|
acdff53792 | ||
|
|
1ba67ca7cf | ||
|
|
14eb2e39e4 | ||
|
|
ca4834bbb9 | ||
|
|
df918de2a6 | ||
|
|
494e581973 | ||
|
|
510e498fec | ||
|
|
0db8f9c5fb | ||
|
|
80d16c80bc | ||
|
|
03cbb99196 | ||
|
|
565f6c0117 | ||
|
|
d8ca281464 | ||
|
|
6d2677e7bd | ||
|
|
a7b7c64573 | ||
|
|
0e8b625436 | ||
|
|
8fa97bc8bd | ||
|
|
f0562a8553 | ||
|
|
5d58fb5c5b | ||
|
|
8bf40465df | ||
|
|
6a756bdec1 | ||
|
|
07c459f0ff | ||
|
|
932866da8f | ||
|
|
04f1066e66 | ||
|
|
4b986be0b2 | ||
|
|
a1e1706ddc | ||
|
|
0bb2ab9b49 | ||
|
|
b3d0da9355 | ||
|
|
ed985dd175 | ||
|
|
3ee37e8399 | ||
|
|
2fb452aa44 | ||
|
|
19424877e8 | ||
|
|
3ebbda5d86 | ||
|
|
60652fdc07 | ||
|
|
cf1d8ab52c | ||
|
|
2b5972437b | ||
|
|
febffb173e | ||
|
|
db49e8b9b8 | ||
|
|
e819db9d42 | ||
|
|
09a700e4c7 | ||
|
|
01526faac2 | ||
|
|
7f457a05b3 | ||
|
|
3ccfb24417 | ||
|
|
cfc2caba1b | ||
|
|
1d895a6428 | ||
|
|
71abdeaf60 | ||
|
|
cd84b63d2b | ||
|
|
b16c5c38a5 | ||
|
|
b33559e462 | ||
|
|
d970117149 | ||
|
|
d7ef20668a | ||
|
|
7e6f41cc53 | ||
|
|
071719e570 | ||
|
|
4e1ec6e001 | ||
|
|
49c155509e | ||
|
|
c341fe28a4 | ||
|
|
a5a7caf8ba | ||
|
|
057e002ad1 | ||
|
|
37fad55333 | ||
|
|
ea58c606a2 | ||
|
|
c128a6ea3a | ||
|
|
55bc9399e6 | ||
|
|
9131ee6bb7 | ||
|
|
d675c8ac9f | ||
|
|
7c10f45aaf | ||
|
|
b1b5bfa417 | ||
|
|
709fbff278 | ||
|
|
0bcd42ac04 | ||
|
|
2de257491f | ||
|
|
b44f2befd7 | ||
|
|
7765695339 | ||
|
|
95957bb167 | ||
|
|
c77ddee03b | ||
|
|
c09711f609 | ||
|
|
5ae34002f3 | ||
|
|
22175d42b0 | ||
|
|
284b38b16c | ||
|
|
5ca8f636e6 |
+50
-1
@@ -3,7 +3,17 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.6
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(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
|
||||
@@ -100,9 +110,25 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
|
||||
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
|
||||
# AWS_URL=
|
||||
# AWS_ENDPOINT=
|
||||
# ATTACHMENT_DISK=
|
||||
|
||||
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=
|
||||
@@ -112,3 +138,26 @@ 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
|
||||
|
||||
# SEO 봇 캐시 상한 (config/core.php seo_cache_limits). 봇 판정은 User-Agent 뿐이라 위장이
|
||||
# 가능하고, 캐시 키에 쿼리가 들어가므로 값만 바꾼 반복 요청이 매번 렌더·저장을 유발한다.
|
||||
# 렌더 예산을 넘긴 요청은 오류가 아니라 일반 SPA 응답(X-SEO-Cache: BYPASS)을 받는다.
|
||||
# G7_SEO_CACHE_MAX_QUERY_PARAMS=10
|
||||
# G7_SEO_CACHE_MAX_QUERY_LENGTH=512
|
||||
# G7_SEO_CACHE_MAX_VARIANTS_PER_PATH=50
|
||||
# G7_SEO_CACHE_MAX_ENTRIES=20000
|
||||
# G7_SEO_RENDER_MISSES_PER_MINUTE=60
|
||||
# G7_SEO_STATS_RECORDS_PER_MINUTE=300
|
||||
|
||||
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
|
||||
# 통째로 대체한다 (예: 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
|
||||
|
||||
+20
-1
@@ -3,7 +3,17 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.6
|
||||
APP_VERSION=7.0.11
|
||||
|
||||
# 리버스 프록시(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
|
||||
@@ -77,6 +87,11 @@ AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
|
||||
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
|
||||
# AWS_URL=
|
||||
# AWS_ENDPOINT=
|
||||
# ATTACHMENT_DISK=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -92,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}/ — 수명주기 이벤트·자산 URL ... |
|
||||
| [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,10 +149,10 @@
|
||||
|
||||
| 대상 | 진입점 | 문서/엔드포인트 |
|
||||
|------|--------|----------------|
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
|
||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 328 |
|
||||
|
||||
|
||||
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
|
||||
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
|
||||
|
||||
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
@@ -165,9 +162,10 @@
|
||||
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
|
||||
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
|
||||
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||
@@ -176,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 · 레이아웃 146 |
|
||||
| `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 -->
|
||||
|
||||
---
|
||||
@@ -252,6 +278,57 @@
|
||||
| `"item"`, `"index"` | `"item_var"`, `"index_var"` |
|
||||
| iteration 내 if 순서 무시 | if가 iteration보다 먼저 평가됨 |
|
||||
|
||||
### 위젯 값 형태 ↔ apply 경로
|
||||
|
||||
편집기 위젯 중 **값이 스칼라가 아닌 것**(`image` → `{url,size,repeat,position}` 객체)을 값 슬롯이 하나뿐인 apply 경로에 연결하면, 객체가 그대로 `props[key]` 에 저장되어 소비 컴포넌트가 `[object Object]` 를 URL 로 받는다. 이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 **200(HTML)** 이고, 편집기 위젯의 미리보기는 정상이라 조작 중에는 이상이 보이지 않는다. 화면의 엑박이 유일한 증상이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| `image` 위젯을 `classToken`·`cssVar` 에 연결하거나 `apply` 를 생략 | 값이 스칼라로 축약되는 경로만 — `propValue`(맨 url 문자열) 또는 `backgroundImage` 를 포함한 `styleProp` 묶음(4속성 분해) |
|
||||
| 축약 판정을 **값 형태 sniffing** 으로 게이트 | `widget === 'image'` 게이트 — `isImageValueObject` 는 4키 중 **하나만** 있어도 참이라 `props.tooltip = {position:'left'}` 같은 정당한 객체 prop 을 이미지로 오인해 삭제한다 |
|
||||
| 축약 분기를 writer 마다 복붙 | 공용 헬퍼 `scalarizeImageValue` 단일 지점 — 이번 결함의 원인이 정확히 "방어가 `applyStyleProp` 안에만 있었다" 이다 |
|
||||
| 쓰기만 축약하고 읽기는 그대로 | `propValue` 역해석이 저장 문자열을 `{url}` 로 되감는다. **표현식 문자열도 감싼다** — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이 소리 없이 소실된다 |
|
||||
| 저장되지 않는 컨트롤을 `disabled` 로 남김 | 단일 값 슬롯이면 표시모드 버튼을 **컨테이너째 미렌더** — `disabled` 는 *일시적* 비활성의 시각 언어라 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다 |
|
||||
| 저장되지 않는 `size` 를 미리보기에 반영 | 단일 슬롯 미리보기는 `contain` 고정 — 실제 표시 방식은 소비 컴포넌트의 클래스가 정하므로 편집기가 흉내내면 거짓 미리보기다 |
|
||||
| 코어 엔진의 느슨한 판정식을 **백필**에 이식 | 백필·런타임 방어는 **엄격 판정식**(키 집합 ⊆ 4키 **AND** `url` 키 존재). 느슨한 판정식은 레이아웃 전수에서 2,219건을 매치하고 그 대부분이 정상 props 다(`{className,name,size}` 674건 · `{name,size}` 552건) — 엄격 판정식의 매치는 0건이었다 |
|
||||
| 백필 순회 범위를 **노드 키 allowlist** 로 정의 | `props` 키 진입 시 모드 ON / `style` 키 진입 시 OFF 인 **모드 플래그 전역 재귀** — 실측상 `props` 안에 컴포넌트 노드가 1,150건 살아 allowlist 는 원리상 완결 불가다 |
|
||||
| 두 방어선(런타임 `Img` / 백필)의 판정 강도를 따로 정함 | 완전히 같은 엄격도 — 어긋나면 한쪽만 통과하는 값이 생긴다 |
|
||||
|
||||
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "controls — 재사용 스타일 컨트롤"
|
||||
> 정적 검사가 두 축을 함께 본다 — editor-spec 선언과 코어 엔진의 축약 분기 실존. 선언 축만 보면 코어 분기가 삭제돼도 통과하는데 결함은 부활한다. 기설치본 보정은 DB 데이터 상태라 정적 검사 대상이 아니며, 업그레이드 스텝의 회귀 테스트가 그 축을 잠근다
|
||||
|
||||
### 편집기 컨트롤의 데이터 연결 값 보호
|
||||
|
||||
레이아웃의 prop 자리에는 `{{_global.settings?.general?.site_logo_url}}` 같은 **표현식 문자열**이 저장돼 있을 수 있다. 위젯은 그 값을 해석하지 못해 **빈 컨트롤**로 보이고, 조작하는 순간 그 연결이 사라진다 — 값 하나가 아니라 **환경설정과의 연결**이 끊기고, 원문이 화면 어디에도 남지 않아 되돌릴 수단조차 없다. 예외도 콘솔 오류도 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 바인딩 판정·배지·잠금을 위젯마다 구현 | `ControlRenderer` 의 **공용 게이트 한 곳** — 새 위젯을 등록해도 자동 적용된다 |
|
||||
| 해제 경로 없이 잠그기만 | 「직접 지정으로 바꾸기」로 **명시적으로만** 연다 |
|
||||
| 「직접 지정으로 바꾸기」를 편도로 두기 | 「되돌리기」 동반 — 해제 직후엔 취소로, 값을 이미 넣은 뒤엔 원문 복구로 동작한다 |
|
||||
| 파괴적 조작 표면 중 일부만 잠그기 | 그 위젯의 **전 표면** — 업로드·제거·목록 선택뿐 아니라 **관리 모달 진입**까지. 같은 동작이 두 곳에 렌더되면 하나만 잠근 것은 판단이 아니라 누락이다 |
|
||||
| 위젯이 자체 처리를 가지면서 공용 게이트도 통과 | 둘 중 하나 — 자체 처리 위젯은 제외 목록에 등재하고, 그 위젯이 **원문 표시·해제·복구 셋을 모두** 제공하는지 확인한다 |
|
||||
| 자체 분기가 공용 해제 경로를 막음 | 해제된 뒤에는 위젯이 평소대로 편집 가능해야 한다 |
|
||||
| 해석 못 하는 값을 위젯이 흉내내 표시 | 원문 배지로 대체 — 흉내내면 거짓 컨트롤이다 |
|
||||
|
||||
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "데이터 연결 값 보호"
|
||||
|
||||
### 상속·주입 노드의 편집 표면
|
||||
|
||||
저장 마스킹(`stripInheritedFromLayoutContent`)은 상속(base)·주입(extension) 출처 노드를 **정상 저장에서도 항상 폐기**한다. 그래서 그 노드를 편집할 수 있게 열어 두면 편집분이 오류도 경고도 없이 사라진다 — 저장은 200 으로 성공하고 `history.clear()` 로 undo 도 불가능하다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 잠금 판정에 편집 모드 예외를 둠 (`editMode !== 'route' && isNodeLocked(...)`) | 출처 잠금이 `data_bound` 보다 **항상 우선** — "route 는 종전 동작을 한 줄도 바꾸지 않는다" 는 계약이 아니라 보수성 선언이었고, 그 보수성이 곧 결함이었다 |
|
||||
| `data_bound` 의 **의미**를 좁혀 해결 | 의미는 그대로 두고(= 편집 가능, 텍스트만 잠금) **어느 노드가 그렇게 분류되는가**만 좁힌다 — 형제 경로(DnD·오버레이)가 그 계약에 실제로 의존한다 |
|
||||
| 게이트 조건을 표면마다 복사 | 단일 판정 헬퍼 `isEditableLockKind` / `resolveDndDenial` 만 호출 — 한 곳만 빠져도 같은 소실 결함이 재발한다 |
|
||||
| ⓘ 메뉴와 드래그 핸들만 막고 끝냄 | 인라인 편집(더블클릭)·복제·키보드 `Delete`·잘라내기까지 전 표면 — 키보드 경로는 ⓘ 메뉴를 거치지 않아 무방비였다 |
|
||||
| 드래그 거부를 `zone === null` 에 기댄 간접 방어로 | commit 직전 최종 가드 — stale 슬롯이나 유효 zone 이 들어오면 그대로 이동 commit 된다 |
|
||||
| 거부된 드래그가 `activeDragPath` 를 남김 | 거부 시 즉시 비운다 — 남으면 DragOverlay 가 잡힌 노드를 따라다녀 "옮길 수 있다" 는 거짓 어포던스를 준다 |
|
||||
| 조상 산출 구현을 파일마다 복제 | `collectAncestors` 단일 출처 — 잠금 판정의 입력이 갈리면 "핸들은 있는데 드래그는 거부" 같은 어긋남이 조용히 생긴다 |
|
||||
|
||||
차단된 노드에는 「🔒 공통 레이아웃 편집」·「🔒 확장 편집」 진입 어포던스가 대신 뜬다. 상속 노드의 「데이터 영역」 라벨은 사라지지만 후자가 행동 가능한 정보이므로 순증이다.
|
||||
|
||||
### 컴포넌트 Props
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -260,6 +337,9 @@
|
||||
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
|
||||
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
|
||||
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
|
||||
| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) |
|
||||
| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) |
|
||||
| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) |
|
||||
|
||||
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
|
||||
|
||||
@@ -284,6 +364,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 를 검사한다.
|
||||
|
||||
### 핸들러 정의
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -331,6 +431,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`)를 손실 없이 보존해야 한다.
|
||||
@@ -355,6 +471,21 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
|
||||
|
||||
### 일괄 처리 목록의 선택 범위
|
||||
|
||||
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 누르면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다. 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 실행 후에도 오류가 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 선택을 화면 밖 상태에 저장하는 DataGrid 에 `selectionScope` 미선언 | `"selectionScope": "page"`(일괄 처리 목록) 또는 `"free"`(선택 자체가 저장 대상인 폼)를 **명시** |
|
||||
| 일괄 처리 버튼이 달린 목록에 `"free"` | `"page"` — 대상은 언제나 "화면에 보이고 체크된 행" |
|
||||
| 여러 페이지에 걸쳐 고르는 폼 선택기에 `"page"` | `"free"` — 페이지를 넘기면 앞 페이지 선택이 사라져 기능이 깨진다 |
|
||||
| `selectable` 이 꺼진 화면이라 안전하다고 간주 | 체크박스가 없으면 남은 선택이 **더** 안 보인다 — 범위 판정은 `selectable` 과 무관 |
|
||||
| `"selectionScope": "{{조건}}"` 표현식 분기 | 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 대상 밖 행을 싣는다 |
|
||||
| 화면마다 검색·필터·페이지 액션에 선택 초기화 액션을 복제 | 컴포넌트가 단일 지점에서 정리 — 액션 복제는 한 곳만 빠져도 같은 결함이 남는다 |
|
||||
|
||||
정적 검사가 `onSelectionChange` 가 배선된 DataGrid 를 전수 검사해 미선언을 차단한다. 상세: [component-props.md DataGrid](docs/frontend/component-props.md)
|
||||
|
||||
### 중첩 리소스 스코프 / 계층 무결성
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -369,6 +500,204 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
|
||||
|
||||
#### 보안 게이트 대칭성 (KVE-2026-1914/1915/1919)
|
||||
|
||||
접근 게이트와 권한 등급 상한은 한 경로에만 있으면 다른 경로가 조용한 우회로가 된다. 게이트는 생산 지점(부모 비밀 판정 · 소유권 판정 · 등급 판정) 한 곳을 SSoT 로 두고, 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유해야 한다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| 비밀/비공개 부모(게시글)의 비밀 게이트를 하위 리소스(댓글·첨부·문의) 독립 엔드포인트에서 재적용하지 않음 | 부모 비밀 판정을 하위 전 경로(훅·서비스·첨부 서빙·댓글 목록)에 재적용 — PostResource 한 곳만으로는 부족하다 (KVE-2026-1914) |
|
||||
| hash 기반 file-serving(preview/download)이 소유권·비밀·발행 상태 검사 없이 서빙 | preview 와 download 가 동일 게이트 공유 — 미발행·비소유·비밀 첨부는 404 (KVE-2026-1914 A-3/S-1/S-2) |
|
||||
| User/Role 의 쓰기·상태변경·권한부여 경로가 삭제 경로보다 약한 등급 가드 | 전 경로에 동일 등급-상한(rank ceiling)을 대칭 적용 — 정적 라우트(bulk)는 스코프 미들웨어가 우회되므로 서비스 계층에서 강제한다 (KVE-2026-1919) |
|
||||
| 저장측 레이아웃 표현식 검증(SafeLayoutExpressions)을 문자열 endpoint 필드에만 부착 | 표현식이 실릴 수 있는 배열 트리 전체(`content`)에 부착 — 문자열 한정 부착은 `is_array` 가드로 무력화되어 no-op 이 된다 (KVE-2026-1915) |
|
||||
| 배열 트리 순회용 규칙(`NoExternalUrls`)이 문자열 필드에도 부착돼 `is_array` 로 조용히 통과 | 규칙이 문자열 스칼라도 처리하거나, 그 자리에서 떼어낸다 — 부착만 해두고 통과시키는 상태가 최악이다 |
|
||||
| 같은 저장 대상의 FormRequest 마다 부착 규칙이 다름 (편집기 경로만 누락) | Store·Update·Content·ExtensionContent 4경로 동일 강도 — 편집기 저장 경로가 가장 약하면 그 경로가 우회로다 |
|
||||
| same-origin 을 `//` 접두·scheme·`/` 시작 **문자열 검사**로만 판정 | 브라우저 URL 파서와 동일 정규화(tab·LF·CR 제거 → 백슬래시를 슬래시로 → 선행 슬래시 런 접기) 후 판정 — `/\/evil.com/x.js` 는 문자열상 path 지만 브라우저는 외부 origin 으로 해석한다. 런타임·저장측·정적검사 3층이 같은 정규화를 공유한다 (KVE-2026-1915 B-2) |
|
||||
| same-origin 판정만 정규화하고 **신뢰 호스트 추출(`hostOf`)은 원문**으로 판정 | 두 판정이 같은 `if` 안에서 이어지므로 정규화도 공유 — 어긋나면 `https://evil.com\@cdn.신뢰.com/x.js` 가 저장측에서만 신뢰 호스트로 보여 통과한다 |
|
||||
| 정적 일괄 라우트(`bulk-*`)에 등급 상한만 적용하고 **스코프 축은 비움** | 라우트 모델이 없으면 미들웨어 스코프 검사가 스킵되므로 서비스가 상세 경로와 **같은 스코프 판정**(`PermissionHelper::filterByScope`)을 재적용 — 등급 축만 막으면 스코프 축이 우회로다 (KVE-2026-1919) |
|
||||
| 권한 상한(ceiling) 검사를 DB 쓰기 **뒤**에 배치 | 가드 → 쓰기 순서 — 쓰기 뒤에 검사하면 거부된 요청이 고아 행·반영된 속성 변경을 남긴다. 회귀 테스트는 403 뿐 아니라 **상태 불변**까지 단언한다 |
|
||||
| 같은 리소스를 쓰는 public 서비스 메서드 중 일부만 보호 가드 보유 | 형제 public 메서드 전부 동일 가드 — 서비스는 확장에 열려 있으므로 "현재 호출부가 없다" 는 방어가 아니다 |
|
||||
| 라우트 파라미터가 Model 로 resolve 되지 않는 쓰기 경로를 미들웨어 스코프 검사에 맡김 | 서비스 계층에서 재적용 — 스킵 조건은 정적 경로(`bulk-*`·`reorder`)뿐 아니라 **파라미터명 불일치**(`{id}` + `int` 타입힌트)도 있고, 후자는 상세 경로까지 무가드다 |
|
||||
| 순서 변경·일괄 작업의 스코프 거부를 "대상 일부 제외" 로 처리 | 순서·트리처럼 집합 전체가 하나의 값인 작업은 **전량 거부** — 일부만 반영하면 나머지와 어긋난 상태가 저장된다 |
|
||||
| 가시성 판정을 호출부가 넘기는 옵트인 플래그(`$filters['is_public'] ?? false`)에 의존 | 열람자 신원 기반 fail-closed — 옵트인은 호출부가 빠뜨리면 조용히 열린다(읽기만 하고 쓰는 곳이 없는 사문 플래그가 실재했다) |
|
||||
| 부모 상태로 판정하는 게이트를 `$x->parent && …` 로 작성 | 부모를 못 읽으면 차단 — 부모가 soft-delete 되면 조건이 성립하지 않아 통과한다 |
|
||||
| 리소스 `abilityMap can_*` 을 연관/타 리소스 권한으로 게이팅 | 그 엔드포인트의 라우트 권한(SSoT)과 **같은 리소스 prefix** — 상승 방지는 게이트 이중화가 아니라 rank ceiling 이 담당한다 |
|
||||
|
||||
이 결함군은 예외도 오류도 남기지 않는다 — 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이다. secret 게이트 재적용·hash 서빙 게이트·rank 대칭·URL 판정 3층 동형·정적 bulk 스코프 재적용·가드 선행·형제 메서드 가드 패리티·abilityMap prefix 정합은 의미 판정 영역이라 정적 검사가 일부만 덮으므로, 부모 변경·하위 서빙·등급 경로·URL 검증 지점을 건드릴 때 코드 리뷰에서 대칭성을 확인한다.
|
||||
|
||||
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
|
||||
|
||||
### 서버가 조건에 따라 다른 형태의 200 을 돌려주는 엔드포인트
|
||||
|
||||
같은 엔드포인트가 설정·상태에 따라 **다른 형태의 2xx** 를 낸다면, 프론트는 형태를 판별한 뒤에 읽어야 한다. 한 형태만 가정하면 다른 형태에서 필드 접근이 그 자리에서 던지고, 그 원문이 오류 박스에 영문으로 노출된다. 서버는 정상 응답했으므로 **서버 로그에는 흔적이 없다** — 깨진 것은 클라이언트뿐이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 응답 타입을 한 형태로 고정 선언하고 `response.data.user.*` 를 바로 읽기 | 판별 유니온으로 두 형태를 표현 (`LoginResult` = `{status:'authenticated', user}` \| `{status:'two_factor_required', challenge}`) |
|
||||
| 저장 지점에 형태 가드 없이 `setToken(response.data.token)` | 비어 있지 않은 문자열만 저장 — `localStorage` 는 무엇을 넣든 문자열로 바꾸므로 `undefined` 가 `"undefined"`(truthy)로 남아 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 된다 |
|
||||
| 대체 형태 분기를 사용자 경로에만 두고 관리자 경로는 그대로 | 관리자 경로가 먼저 500 이 되면 설정을 되돌릴 수단까지 사라진다 — 두 경로 동시 적용 |
|
||||
| `onSuccess` 후속 액션에 조건 없이 성공 처리를 나열 | 대체 형태에서 실행되면 안 되는 액션마다 `if:"{{!response.대체형태플래그}}"` |
|
||||
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 시점 컨텍스트는 아직 갱신 전이다 — `{{response.*}}` 만 읽는다 (`onSuccess` 결과는 `handleSequence` 의 상태 동기화 대상이 아니다) |
|
||||
| 서버가 제공하는 기능의 프론트 화면 부재를 "미사용" 으로 간주 | 토글을 켠 사이트에서만 드러나는 미구현이다 — 서버 토글 ↔ 화면 존재를 전수 대조 |
|
||||
|
||||
착수 전 전수조사 축은 **"서버가 대체 형태 2xx 를 내는 엔드포인트 ↔ 프론트 처리 여부"** 다. 그리고 **"서버 토글 ON 시 프론트 화면 존재 여부"** 를 함께 본다 — 2단계 인증은 도입 후 여러 버전 동안 입력 화면이 없었고, 그 토글을 켠 사이트에서만 전원 로그인 불가로 나타났다(공개 #133).
|
||||
|
||||
> 상세: [auth-system.md "2단계 인증 로그인"](docs/frontend/auth-system.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자 라이브러리를 붙일 때"
|
||||
|
||||
### 공개 자산 직접 URL 은 운영자 선언으로만 열린다
|
||||
|
||||
저장된 파일의 주소를 직접 URL(CDN)로 내보내면 서버 스트리밍 경로가 통째로 건너뛰어진다. 그 경로에 게이트가 있으면 게이트가 사라지고, 대상 저장소가 실제로는 비공개면 발급된 주소가 403 이 된다. 둘 다 예외도 로그도 남기지 않는다 — 그 이미지들만 조용히 깨지거나, 막아야 할 파일이 조용히 열린다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| `filesystems.disks.{disk}.url` 이 설정돼 있다는 이유로 그 디스크를 공개로 간주해 직접 URL 발급 | 운영자가 명시 선언한 `core.storage.public_asset_disk` 와 **행 disk 가 일치할 때만** 직접 URL. `url` 설정 유무는 "URL 을 만들 수 있는가" 이지 "익명 읽기가 되는가" 가 아니다 |
|
||||
| 권한·게이트(비밀글·소유권·발행 상태·본인인증·다운로드 카운트)가 걸린 첨부 경로를 직접 URL 로 전환 | 게이트가 없는 완전 공개 자산 카테고리에만 배선. 게이트가 있는 경로의 응답 `url` 칸은 그 게이트를 통과하는 서빙 URL 로 채운다 |
|
||||
| 공개 판정을 `PublicAssetDisk::resolve()` 밖에서 사본으로 재작성 | 판정은 그 단일 지점 경유. `''`·`'none'`·config 에 없는 고아 디스크가 등가 비교만으로 통과하는 것을 막는다 |
|
||||
| 행 disk 로 `withDisk()` 를 무검증 호출 | 존재 확인 후 폴백 — 공개 자산 디스크가 플러그인 등록 디스크일 수 있고, 그 플러그인 비활성화 시 무인증 공개 서빙 라우트가 **500** 이 된다 |
|
||||
| 직접 URL 로 전환하면서 "행 삭제 = 접근 차단" 전제를 그대로 둠 | 직접 URL 은 행과 무관하게 파일을 가리킨다 — 회수 수단(파일 삭제)과 그 한계를 문서에 남긴다 |
|
||||
| 서버가 스스로 발급하는 자산 주소(프록시 서빙 URL)를 절대 URL 로 만듦 | 사이트 상대 경로 — 절대면 저장 규칙(외부 URL 차단)이 자기 주소를 외부로 차단해 image 위젯의 업로드 → 저장이 422 가 되고(배경은 `style` 이라 스캔되지 않아 로고에서만 드러났다), 저장된 레이아웃이 발급 시점 도메인·스킴에 묶인다 |
|
||||
| 저장 규칙의 "외부" 판정을 스킴 접두 문자열로만 두고 자기 주소를 예외 없이 차단 | 사이트 자기 host(`app.url`)·선언된 공개 자산 디스크 host 는 외부가 아니다 — `SiteAssetHosts` 단일 판정(정규화 후 host 등가 비교). 접두 비교는 `host.evil.com`·`host@evil.com` 을 통과시키고, 요청 `Host` 헤더는 위조 가능해 근거가 아니다 |
|
||||
|
||||
이 결함군의 유일한 증상은 화면이다: 직접 URL 이 403 을 돌려주면 그 이미지들만 깨지고, 게이트가 우회되면 정상 200 이 나간다. 서버 로그에는 어느 쪽도 흔적이 없다.
|
||||
|
||||
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "공개 자산 전용 디스크 분리"
|
||||
> 이 규칙은 정적 검사로 판정할 수 없다 — "이 경로에 게이트가 있는가"·"행 disk 와 설정이 같은 축인가" 는 의미 판정이므로, 회귀 테스트(특히 비공개 S3 행 + 공개 URL 설정 조합에서 서빙 라우트가 나오는 케이스)가 잠근다
|
||||
|
||||
### 직접 전송로는 자격증명을 스스로 싣는다
|
||||
|
||||
레이아웃의 `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건).
|
||||
@@ -387,6 +716,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()` + `PackageManifestCacheHelper::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` 는 참 그대로 남는다.
|
||||
@@ -403,6 +752,45 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
|
||||
|
||||
### vendor 를 교체한 뒤 새 PHP 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다
|
||||
|
||||
Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 provider 를 `new` 한다. 그래서 vendor 를 바꾼 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가 새 vendor 에 없는 클래스를 찾다 부팅 단계에서 죽고, 예외는 부팅 전이라 앱 로그에 남지 않는다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| vendor 교체 뒤 `proc_open`·`config:cache`·`route:cache` 등 새 부팅을 `bootstrap/cache/{packages,services}.php` 정리 없이 실행 | 부팅 직전 `PackageManifestCacheHelper::clear()`(재생성까지 필요하면 `rebuild()`) — `ConfigCacheHelper::clear()` 와 짝으로 |
|
||||
| 정리 로직을 호출부마다 `@unlink` 로 복제 | 헬퍼 단일 지점 — `clearAllCaches()` 도 같은 헬퍼를 쓴다 |
|
||||
| 자식 프로세스 보호를 부모 코드에만 두기 | 부모는 이미 배포된 옛 코드일 수 있다 — 새 버전의 `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS` 를 보고 스스로 비운다(App\ 클래스 미참조·실패 무시) |
|
||||
| 코어 버전 판정에서 프로세스 env `APP_VERSION` 을 무조건 우선 | env 우선은 `CoreUpdateContext::isInProgress()` 인 프로세스 트리 안에서만 — 업데이트 전에 뜬 `artisan serve`·큐 워커는 옛 값을 물고 있다 |
|
||||
| 업데이트 커맨드 argv 판정을 SAPI 게이트 없이 두기 | argv 는 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다 — CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링에서 채워(`?x+core:update`) 비인증 웹 요청이 업데이트 트리로 판정되고, 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로 둔다 |
|
||||
| 매니페스트 삭제 실패를 `@unlink` 로 삼키기 | `clear()` 가 지우지 못한 경로를 돌려주고 spawn 직전 호출부가 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식도 같은 이유로 실패해 증상은 제보와 같고, 이 경고가 원인을 가리키는 유일한 흔적이다 |
|
||||
| 업데이트 트리 판정을 지점마다 다시 작성 | `App\Support\CoreUpdateContext` 단일 SSoT — `CoreServiceProvider::isCoreUpdateInProgress()` 도 위임이다. `bootstrap/app.php` 의 복제본은 부팅 전이라 불가피한 예외이며 주석으로 상호 참조한다 |
|
||||
| 자동 비활성화 로그의 `core_version` 을 `config('app.version')` 으로 적기 | 판정과 같은 `CoreVersionChecker::getCoreVersion()` — 로그와 판정 근거가 갈리면 운영자가 원인을 특정할 수 없다 |
|
||||
| "이미 있으니 건너뛴다" 분기(인스톨러 vendor 재사용)가 산출물의 출처를 보지 않음 | `installed.json` 의 `dev`/`dev-package-names` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 |
|
||||
| 코어 업데이트를 마치고 상주 워커에 신호를 보내지 않음 | Step 11·핸드오프·단독 재개 사후 단계에서 `signalQueueRestart()` |
|
||||
|
||||
이 결함군은 예외도 로그도 남기지 않는다 — 자식 프로세스가 부팅 단계에서 죽어 부모가 핸드오프로 멈추는 것, 또는 업데이트 직후 확장이 `incompatible_core` 로 꺼지는 것이 유일한 증상이다. 후자는 관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없어 자가 회복 경로가 없다.
|
||||
|
||||
> 상세: [core-update-system.md](docs/backend/core-update-system.md) "spawn 전 캐시 정리 계약(3계층)" · [extension-update-system.md](docs/extension/extension-update-system.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()` 재유입과 표시값·저장 게이트는 계약 테스트가 잠근다. 부재를 값으로 읽는 자리는 의미 판정 영역이라 정적 검사가 덮지 못한다 — 게이트·기본값·폴백·파생 네 축을 각각 고정하는 회귀 테스트가 그 자리를 잠근다
|
||||
|
||||
### 목록 조회 컬럼 프루닝과 지연 조인
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -448,6 +836,19 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [pagination.md](docs/backend/pagination.md)
|
||||
|
||||
### 입력 크기에 비례해 커지는 메모리는 PHP 기본 한계 안에서 잰다
|
||||
|
||||
브라우저에서 잘 돌던 알고리즘·상수를 PHP 로 옮길 때 시간 상한(O(n·m) 가드)만 함께 오고 **메모리 상한은 오지 않는다.** PHP 배열은 원소당 수십 바이트라 (줄 수)² 표는 2,350줄에서 약 150MB 이고, 운영 서버의 기본 `memory_limit` 은 128M 이다. 개발 머신(512M)에서는 통과하고 서버에서만 500 이 되며, 예외는 `FatalError` 한 줄뿐이라 어느 요청의 어떤 입력이었는지 로그에 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 카운트·길이만 쓰는 LCS/DP 에 전체 표 + backtrack | 두 행(또는 한 행) DP 로 길이만 구한다 — 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 라 숫자가 같다 |
|
||||
| 브라우저 구현의 임계값(`DIFF_MAX_LINES` 등)을 그대로 이식하고 "가드가 있다" 고 간주 | 그 임계에서의 PHP 메모리를 실측하고 128M 아래인지 확인 — 임계가 시간 축만 막는 경우가 있다 |
|
||||
| "인접 버전 비교는 변경 영역이 작다" 는 가정으로 최악 경로를 비워 둠 | 변경 영역은 양끝이 동시에 바뀌면 파일 전체다 — 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태 |
|
||||
| 메모리 회귀 테스트를 "통과했다" 로만 잠금 | `memory_get_peak_usage()` 증가량 상한을 단언하고, 수정 전 값(146MB)을 테스트 메시지에 남긴다 |
|
||||
|
||||
> 상세: [service-repository.md "입력 크기에 비례하는 메모리"](docs/backend/service-repository.md)
|
||||
|
||||
### 검색 인덱스 재생성(리인덱싱)
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
@@ -500,6 +901,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
|
||||
|
||||
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
|
||||
|
||||
동결 대상은 환율만이 아니다 — **소수 자릿수·절사 규칙·환산 분모(base_unit)까지 스냅샷이 SSoT** 다. 이 값들을 현재 설정에서 조회하면, 운영자가 그 통화를 삭제하는 순간 설정에서 사라져 폴백(자릿수 2)이 적용된다. 소수 0자리 통화의 과거 주문 표기가 `¥14,835` → `¥14,835.00` 으로 바뀌고, 3자리 이상으로 설정했던 통화는 표시 금액이 절사된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따라가면 같은 화면 안에서 근거가 갈린다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| --- | --- |
|
||||
| 주문·환불 표시에서 `getDecimalPlaces($code)` 를 스냅샷 없이 호출 | `getDecimalPlaces($code, $currencySnapshot)` — 스냅샷이 있으면 그것이 우선 |
|
||||
| 리소스가 주문 스냅샷을 자식에게 전파하지 않음 | `withOrderCurrency()` 를 전파하는 지점마다 `withCurrencySnapshot()` 도 함께 전파 |
|
||||
| 상품·카탈로그 표시까지 스냅샷으로 고정 | 현재 판매가는 **현재 설정**이 정답 — 스냅샷 없이 호출한다 |
|
||||
| 자릿수를 박제하지 않은 구형 스냅샷에서 예외/0 자리 강제 | 박제값이 없으면 현재 설정 폴백을 그대로 탄다 (하위호환) |
|
||||
|
||||
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
|
||||
### 확장 결제수단은 자기 능력을 선언한다
|
||||
@@ -515,6 +925,31 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
|
||||
|
||||
레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다.
|
||||
|
||||
### 예외를 응답으로 바꾸는 자리
|
||||
|
||||
`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애·코드 결함이 "입력 오류" 로 위장되어 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다. 그리고 이미 번역된 `$e->getMessage()` 를 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문(SQL 상태코드·경로 포함 가능)이 그대로 화면에 나간다. 둘 다 예외도 로그도 남기지 않는다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| generic catch 가 4xx 반환 | 5xx — 의도적 4xx 는 사유와 함께 판정 테스트의 상수에 선언 |
|
||||
| generic catch 에서 상태코드 인자 생략 (`moduleError($mod,'key')`) | 상태코드 명시 — 생략 시 `ResponseHelper` 기본값 **400** 이 조용히 적용된다 |
|
||||
| 도메인 예외를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 유지 | 승격한 예외로 좁힌다 — 도메인 예외의 **부모**를 잡으면 남는 것은 인프라 예외뿐인데 그것까지 4xx 가 된다 |
|
||||
| `error($e->getMessage(), 422)` | `error($e->getMessageKey(), 422, null, $e->getMessageParams())` |
|
||||
| 도메인 예외가 번역문만 들고 다님 | 생성자에 키+치환 파라미터 보관 → `getMessageKey()` / `getMessageParams()` |
|
||||
| 서비스를 typed 예외로 승격하고 컨트롤러는 generic 만 유지 | 그 서비스 메서드를 호출하는 **컨트롤러 메서드 전수**에 typed catch 추가 (없으면 도메인 사유가 500 이 된다) |
|
||||
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
|
||||
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
|
||||
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
|
||||
| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
|
||||
| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
|
||||
| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
|
||||
| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
|
||||
| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
|
||||
|
||||
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
|
||||
|
||||
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
|
||||
|
||||
### Listener 데이터 접근
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
@@ -525,6 +960,8 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
|
||||
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
|
||||
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
|
||||
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
|
||||
| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) |
|
||||
| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 |
|
||||
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
|
||||
|
||||
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
@@ -667,6 +1104,7 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
|
||||
- `## [버전] - YYYY-MM-DD` 헤더 필수
|
||||
- `### Added` / `### Changed` / `### Fixed` / `### Removed` 카테고리 사용
|
||||
- 최신 버전이 파일 상단
|
||||
- 한 버전 섹션 안에 같은 카테고리 헤딩은 한 번만 — 브랜치마다 섹션 머리에 자기 `### Fixed` 블록을 얹고 병합이 양쪽을 이어 붙이면 같은 제목이 두 번 남는다. 항목은 기존 카테고리 블록 끝에 추가하고, 병합 뒤 두 번째 블록이 보이면 그 항목을 첫 블록에 합치고 헤딩만 지운다(항목 삭제 금지). 같은 버전 헤더 중복·허용 밖 카테고리도 같은 결함군이며 정적 검사가 working 버전 섹션에서 차단한다. 공개 배포된 과거 섹션은 소급 수정하지 않는다
|
||||
|
||||
---
|
||||
|
||||
@@ -700,6 +1138,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 의 토스트 컴포넌트 블록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트 프로토콜
|
||||
@@ -977,6 +1429,8 @@ BaseApiController (최상위)
|
||||
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
|
||||
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
|
||||
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
|
||||
필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
|
||||
필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
|
||||
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
```
|
||||
@@ -1045,6 +1499,21 @@ php artisan language-pack:update g7-core-ja --force
|
||||
|
||||
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
|
||||
|
||||
### 배포 산출물의 브라우저 하한
|
||||
|
||||
선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
|
||||
|
||||
정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
|
||||
| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(?<!` `(?<=`, `v` 플래그) — 하한과 같은 버전이어도 | 그 두 파일만은 하한 미만 브라우저에서도 **파싱**돼야 한다 |
|
||||
| 빌드 타깃을 낮춰 해결 시도 | 소스에서 그 문법을 쓰지 않는다 |
|
||||
| 바이트 길이 비교식 번들러 검출기로 판정 | 문법·API 표 기반 검사 (프린터 표기 차이가 오탐을 낸다) |
|
||||
|
||||
부팅 임계 번들 둘은 `async`/`defer` 없는 동기 classic 스크립트다. 파싱에 실패하면 **폴백 안내 화면조차 렌더되지 않아** 사용자에게는 백지 또는 거짓 진단만 남는다. 하한 미만 브라우저에 "지원 범위 밖" 안내를 띄우려면 이 두 파일은 파싱에 성공해야 하므로, 하한과 **정확히 같은** 버전을 요구하는 문법(lookbehind = Safari 16.4)도 금지한다 — 하한 초과만 보는 검사로는 영원히 잡히지 않는 지점이다. 정적 검사가 이 규칙을 강제한다.
|
||||
|
||||
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
|
||||
|
||||
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
|
||||
@@ -1068,6 +1537,8 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱
|
||||
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
|
||||
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
|
||||
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
|
||||
- 프로덕션은 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 키는 `(type, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다.
|
||||
- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다.
|
||||
|
||||
### 빌드 명령어 (Artisan)
|
||||
|
||||
@@ -1097,6 +1568,24 @@ 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`.
|
||||
|
||||
### 빌드는 자기 산출물만 교체한다 (`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` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
|
||||
|
||||
---
|
||||
@@ -1183,6 +1672,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) |
|
||||
|
||||
+258
@@ -4,6 +4,264 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.11] - 2026-09-09
|
||||
|
||||
### Added
|
||||
|
||||
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 관리자 로그인 화면도 같은 흐름으로 동작합니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
- 설치 마법사가 이미 준비된 `vendor/` 에 개발용 패키지가 섞여 있으면 설치 환경 확인 단계와 설치 로그에서 이를 알리고 정리 명령을 안내합니다. 설치는 그대로 진행되며, 종전에는 아무 표시 없이 그대로 사용해 이후 코어 업데이트 중단의 원인이 되었습니다.
|
||||
- 설치 안내서의 Composer 설치 명령이 운영용 구성(`--no-dev`)으로 바뀌었고, `vendor/` 가 없으면 설치 마법사가 자동 설치하므로 이 단계를 생략해도 된다는 안내가 추가되었습니다.
|
||||
- 설치 중 Composer 단계가 실패했을 때 안내하는 수동 명령도 운영용 구성(`--no-dev`)으로 바뀌었습니다. 종전 안내를 그대로 따르면 개발용 패키지가 섞인 상태로 설치되어 이후 코어 업데이트가 중단될 수 있었습니다.
|
||||
- 코어 업데이트가 운영 `vendor/` 에 개발용 패키지가 있는지 확인해 로그에 남깁니다. 의존성 변경이 없어 재설치를 건너뛰는 경우에는 개발용 패키지가 그대로 남는다는 경고와 정리 명령을 함께 안내합니다.
|
||||
- 코어 업데이트가 끝나면 실행 중인 큐 워커에 재시작 신호를 보내, 워커를 손수 재시작하지 않아도 새 코드가 반영됩니다.
|
||||
- 「공개 자산 스토리지」를 지정하면 레이아웃 배경 이미지가 그 저장소에 저장됩니다. 이미 올라간 파일은 옮기지 않으며 각자 저장된 위치에서 그대로 서비스됩니다. 기존 첨부 저장소와 공개 자산 저장소를 같은 곳으로 지정한 경우에는 설정을 켜는 순간 이전에 올린 이미지도 저장소 주소로 바뀝니다. 자세한 사항은 관리자 > 환경설정 > 드라이버의 안내를 참고하세요.
|
||||
- PHP 의존성 패키지를 최신 안정 버전으로 갱신했습니다 (Laravel 12.69, Reverb 1.11, Scout 11.6, Monolog 3.11, Symfony 7.4.18 등). 각 패키지의 보안 수정과 오류 수정이 함께 반영되며, Composer 를 실행할 수 없는 환경을 위한 코어와 이커머스 모듈의 `vendor-bundle.zip` 도 같은 구성으로 다시 만들었습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 2단계 인증을 켠 사이트에서 로그인이 되지 않던 문제를 수정했습니다. 로그인 화면에 영문 오류(`Cannot read properties of undefined`)가 표시되고 그 뒤로는 진행할 방법이 없었으며, 관리자 로그인은 서버 오류(500)가 되어 설정을 되돌릴 수단까지 사라졌습니다. 이제 인증번호 입력 단계가 표시되고 관리자도 같은 흐름으로 로그인할 수 있습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
|
||||
- 로그인 응답을 잘못 해석해 사용할 수 없는 인증 정보가 브라우저에 저장되던 문제를 수정했습니다. 그 뒤로는 화면을 새로 열 때마다 「세션이 만료되었습니다」 안내와 함께 로그인 화면으로 되돌아갔습니다.
|
||||
- 본인인증 화면에서 로그인용 인증 요청을 처리할 수 있어, 그 요청으로는 다시 로그인할 수 없게 되던 문제를 수정했습니다. 로그인용 인증 요청은 이제 로그인 화면에서만 처리됩니다.
|
||||
- 로그인 시도가 많아 잠시 차단될 때 영문 안내(`Too Many Attempts.`)가 그대로 표시되던 문제를 수정했습니다. 이제 다시 시도할 수 있는 시각과 함께 사이트 언어로 안내합니다.
|
||||
- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다.
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다.
|
||||
- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
|
||||
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
|
||||
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
|
||||
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
|
||||
- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. 비우지 못한 파일이 있으면 업데이트 로그에 남겨 권한 문제를 바로 확인할 수 있습니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.)
|
||||
- 이미 배포된 이전 버전(7.0.9·7.0.10)에서 업데이트를 시작하는 경우에도 같은 중단이 나지 않도록, 새 버전의 스텝 프로세스가 시작할 때 이전 패키지 목록 캐시를 스스로 정리합니다.
|
||||
- `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다.
|
||||
- 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다.
|
||||
- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다.
|
||||
- 레이아웃 편집기에서 올린 배경 이미지의 주소가 「공개 자산 스토리지」 설정과 무관하게 항상 사이트 서버를 거쳐 전달되던 문제를 수정했습니다. 파일은 설정한 저장소에 정상 저장되지만 방문자 요청은 매번 사이트 서버가 저장소에서 받아 다시 내보내고 있어, CDN 을 쓰도록 설정해도 그 효과를 볼 수 없었습니다. 이제 「공개 자산 스토리지」를 지정하면 그 저장소에 올라간 배경 이미지가 저장소 주소로 직접 전달됩니다. 설정하지 않은 경우와 설정 이전에 올린 이미지는 종전과 똑같이 동작합니다. (#134 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 확장이 카테고리별로 다른 저장소를 쓰도록 설정했을 때, 확장 개발용 파일 주소·경로 조회가 그 설정을 반영하지 않고 언제나 기본 저장소를 보던 문제를 수정했습니다.
|
||||
- 화면 편집기에서 로고처럼 이미지 주소를 직접 받는 항목에 이미지를 지정하면 그림이 깨져 보이던 문제를 수정했습니다. 지정한 주소 대신 알 수 없는 값이 저장된 것이 원인이며, 편집기 화면에서는 미리보기가 정상으로 보여 저장 전에는 알아챌 수 없었습니다. 이제 주소가 그대로 저장되고, 이미 잘못 저장된 사이트도 업데이트하면 자동으로 정정됩니다. 업데이트 전에도 화면은 정상 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 화면 편집기의 「탭 표시 게시판 수」처럼 숫자를 받는 항목이 「지원하지 않는 컨트롤」로 표시되어 편집할 수 없던 문제를 수정했습니다. 이제 숫자 입력칸이 나타납니다.
|
||||
- 화면 편집기에서 공통 레이아웃이나 확장이 넣은 요소를 고쳐도 저장되지 않던 문제를 수정했습니다. 편집은 되는데 저장 후 되돌아가 있었고 실행 취소로도 되살릴 수 없었습니다. 이제 그런 요소는 편집 대신 「공통 레이아웃 편집」·「확장 편집」 진입 안내가 표시되며, 옮기기·복제·삭제도 같은 기준으로 막힙니다.
|
||||
- 화면 편집기가 열리자마자 빈 화면이 되는 경우가 있던 문제를 수정했습니다. 편집기가 잠깐 보였다가 사라졌고, 새로고침에 따라 나타났다 사라졌다 했습니다.
|
||||
- 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다.
|
||||
- `vendor-bundle:build-all --check` 가 설치되지 않은 번들 확장을 「소스 경로 없음」으로 건너뛰어, 그 확장의 vendor 번들이 갱신되지 않은 상태가 점검을 통과하던 문제를 수정했습니다. 이제 설치 여부와 무관하게 배포 원본 기준으로 판정합니다.
|
||||
- 레이아웃 편집기의 헤더 「로고 이미지」에서 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」로 거부되던 문제를 수정했습니다. 업로드한 이미지의 주소가 사이트 전체 주소 형태로 발급되어 레이아웃 저장의 외부 주소 차단에 걸리고 있었으며, 배경 이미지는 검사 대상이 아니라 증상이 없었습니다. 이제 업로드 주소는 사이트 상대 경로로 발급되고, 사이트 자신의 주소와 「공개 자산 스토리지」로 지정한 저장소의 주소는 외부로 취급하지 않습니다.
|
||||
- 큰 공통 레이아웃(예: 사용자 화면 공통 레이아웃)을 레이아웃 편집기에서 처음 저장할 때 「저장 중 네트워크 오류가 발생했습니다 / Server Error」로 끝나던 문제를 수정했습니다. 버전 이력의 변경량(+N/-N 줄)을 계산하는 과정이 레이아웃 크기의 제곱에 비례하는 메모리를 써서 PHP 기본 메모리 한도(128MB)인 서버에서 실패했습니다. 이제 메모리가 레이아웃 크기에 비례하며 표시되는 변경량 숫자는 그대로입니다.
|
||||
- 레이아웃 편집기의 「확장 편집」 모드에서 저장하면 그 확장이 화면에 끼워 넣던 요소(예: 헤더의 통화 선택기)가 사라지던 문제를 수정했습니다. 내용을 바꾸지 않고 저장해도 일어났고 오류나 안내가 없어 사이트에서 그 요소가 없어진 뒤에야 알 수 있었습니다. 이제 끼워 넣은 요소가 그대로 보존되며, 되돌릴 자리를 확인할 수 없는 요소가 있으면 저장을 막고 그 사실을 안내합니다. 이미 사라진 사이트는 「버전 기록」에서 직전 버전을 복원하면 됩니다.
|
||||
- 레이아웃 편집기에서 저장이나 복원을 두 번 이상 한 뒤 「초기화」나 충돌 안내의 「최신 불러오기」를 누르면 최신이 아닌 옛 내용이 올라오던 문제를 수정했습니다. 그 화면을 다시 저장하면 최신 내용을 옛 내용이 덮을 수 있었고, 충돌 안내 뒤에는 새로고침 전까지 저장이 계속 거부됐습니다.
|
||||
- 레이아웃 편집기의 충돌 안내가 「최신 버전: -1」 로 표시되던 문제를 수정했습니다. 이제 실제 최신 버전 번호가 표시됩니다.
|
||||
- 레이아웃·레이아웃 확장의 버전 기록에 저장자가 항상 「알 수 없음」으로 표시되던 문제를 수정했습니다. 이제 저장한 관리자가 기록됩니다.
|
||||
- 버전 기록에서 복원한 직후, 복원 전 화면을 열어 둔 다른 관리자가 저장하면 복원 결과가 충돌 안내 없이 덮이던 문제를 수정했습니다. 이제 복원도 저장과 같이 충돌 검사의 기준 번호를 올립니다.
|
||||
|
||||
## [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
|
||||
|
||||
- 설치 마법사의 디렉토리 권한 검사 단계(`storage`·`bootstrap/cache` 와 같은 단계)에서 `public/build` 가 검사 목록에 빠져 있던 문제를 수정했습니다. 웹 서버 계정이 그 폴더에 쓸 수 없어도 마법사가 막거나 실행할 명령을 보여 주지 않은 채 설치를 진행했고, 설치 마지막 단계의 초기 화면 파일 생성이 실패한 뒤 대시보드에 「초기 화면 파일 생성 실패」 알림이 떴습니다. 이제 다른 폴더와 같은 방식으로 통과할 때까지 진행을 막고 권한 부여 명령을 안내합니다.
|
||||
- 관리자 대시보드의 시스템 알림(초기 화면 파일 생성 실패·확장 호환성·신뢰 프록시)이 같은 내용으로 두 장씩 표시되던 문제를 수정했습니다. 알림 리스너가 자동 발견과 별도 등록으로 두 번 등록된 것이 원인입니다.
|
||||
- 새로 설치할 때 일부 확장의 설치가 "Class not found" 오류로 중단되던 문제를 수정했습니다. 확장의 설정 기본값을 읽는 단계가 그 확장의 클래스 경로 등록보다 먼저 실행된 것이 원인이며, 이제 설치는 확장 파일을 복사한 직후 클래스 경로를 먼저 등록합니다. 이미 설치된 사이트의 업데이트에는 영향이 없습니다.
|
||||
- 설정 캐시가 만들어져 있는 사이트(설치 마법사·환경설정 저장·확장 업데이트를 한 번이라도 거친 대부분의 사이트)에서 코어 업데이트가 업그레이드 스텝 단계에서 "수동 재개" 안내와 함께 멈추던 문제를 수정했습니다. 새 버전으로 실행되는 스텝 프로세스가 이전 버전의 설정 캐시를 읽어 자기 자신을 옛 버전으로 오판한 것이 원인이며, 실행할 스텝이 하나도 없는 버전으로 올릴 때도 같은 안내가 나왔습니다. 같은 원인으로 새 버전이 추가한 쓰기 폴더가 `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
|
||||
|
||||
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
|
||||
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
|
||||
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
|
||||
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
|
||||
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
|
||||
|
||||
## [7.0.8] - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
|
||||
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
|
||||
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
|
||||
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
|
||||
- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
|
||||
- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
|
||||
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
|
||||
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
|
||||
|
||||
## [7.0.7] - 2026-08-19
|
||||
|
||||
### Security
|
||||
|
||||
- 부관리자(위임 관리자)가 슈퍼 관리자 계정을 함부로 손대지 못하도록 막았습니다. 슈퍼 관리자 보호는 삭제·탈퇴 경로에만 있었고 비밀번호 변경·상태 변경(차단/탈퇴)·계정 잠금 해제·일괄 상태 변경 경로에는 없어, 회원 관리 권한만 위임받은 계정이 슈퍼 관리자 계정을 무력화할 수 있었습니다. 이제 이 경로 전부에 같은 기준을 적용해, 슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다. 슈퍼 관리자 본인의 작업은 종전처럼 정상 동작합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
|
||||
- 부관리자가 역할 권한을 통해 자신보다 높은 권한을 획득하지 못하도록 막았습니다. 이전에는 역할에 권한을 부여할 때 부여하는 사람이 그 권한을 가졌는지 확인하지 않아, 권한 관리 권한만 위임받은 계정이 자신에게 없는 권한이나 더 넓은 범위의 권한을 역할에 실어 우회 상승할 수 있었습니다. 이제 자신이 보유한 권한을 자신의 범위 이내로만 부여할 수 있으며, `admin` 같은 시스템·확장 소유 역할의 권한·활성 상태 변경도 슈퍼 관리자로 제한됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
|
||||
- 위 상한을 사용자에게 역할을 붙이는 경로에도 동일하게 적용했습니다. 이전에는 사용자 생성·수정 화면에서 역할을 배정할 때 그 역할이 담은 권한을 배정자가 모두 가졌는지 확인하지 않아, `admin` 같은 고권한 역할을 통째로 붙여 우회 상승할 수 있었습니다. 이제 배정자가 자신의 권한 범위 안에서 전부 부여할 수 있는 역할만 붙일 수 있습니다. 같은 기준이 역할을 **떼는** 방향에도 적용되어, 자신이 부여할 수 없는 상위 역할을 다른 관리자에게서 박탈하는 것도 차단됩니다. 사용자가 이미 가진 역할을 유지하는 수정은 그대로 허용되고, 슈퍼 관리자의 역할 배정은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||
- 레이아웃 편집기에 저장하는 표현식이 서버나 다른 사용자 브라우저에서 임의 코드로 실행될 수 없도록 표현식 평가 방식을 근본적으로 바꿨습니다. 이전에는 표현식을 실제 코드로 만들어 실행했기 때문에 특정한 우회 기법으로 편집 권한을 넘어선 동작이 가능했습니다. 이제 정해진 문법·함수만 해석하는 안전한 방식으로 평가하며, 위험한 표현식과 외부 주소의 스크립트 로드는 저장 단계에서도 거부합니다. 기존 레이아웃의 정상 표현식(조건·계산·목록 가공·경로 조립 등)은 그대로 동작하고, 위지윅 에디터·주소 검색처럼 정해진 외부 스크립트를 쓰는 확장은 각자 신뢰 출처를 선언해 정책 강화 이후에도 정상 동작합니다. 신뢰 출처는 모듈·플러그인·템플릿이 모두 자기 설정 파일에 선언할 수 있으며, 활성 상태인 확장의 선언만 반영됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1915)
|
||||
- 레이아웃에 적는 주소가 "내 사이트 경로"인지 판정하는 방식을 브라우저의 실제 해석과 일치시켰습니다. 이전에는 주소 앞부분의 글자만 보고 판정했기 때문에, 슬래시 사이에 역슬래시나 보이지 않는 공백 문자를 끼워 넣은 주소가 내 사이트 경로처럼 통과한 뒤 브라우저에서는 외부 사이트 주소로 해석되어, 선언하지 않은 외부 스크립트가 실제로 불려 올 수 있었습니다. 이제 브라우저와 같은 기준으로 정규화한 뒤 판정하며, 저장 단계·화면 로드·정비 검사 세 곳이 같은 기준을 씁니다. 경로 중간에 역슬래시가 들어간 정상 주소는 종전처럼 그대로 동작합니다.
|
||||
- 레이아웃 편집기로 저장할 때 외부 주소 차단이 적용되지 않던 문제를 수정했습니다. 레이아웃을 새로 만들거나 정보를 수정하는 경로에는 이 검사가 걸려 있었지만, 편집기가 실제로 사용하는 콘텐츠 저장 경로에는 빠져 있어 같은 내용도 어느 화면에서 저장하느냐에 따라 통과 여부가 달랐습니다. 함께, 주소 입력 칸 하나만 검사하도록 걸어 둔 설정이 실제로는 아무것도 검사하지 않고 지나가던 것도 바로잡았습니다. 이제 네 저장 경로가 모두 같은 강도로 검사합니다.
|
||||
- 회원 일괄 상태 변경에서 담당 범위 제한이 적용되지 않던 문제를 수정했습니다. 회원 수정 권한은 "본인 계정만" 또는 "같은 역할 범위만" 으로 범위를 좁혀 위임할 수 있는데, 이 제한은 회원을 하나씩 여는 화면에서만 적용되고 목록에서 여러 명을 한 번에 처리하는 일괄 변경에는 적용되지 않았습니다. 그래서 범위를 좁혀 위임받은 관리자가 일괄 변경으로는 담당 밖 회원까지 차단·탈퇴 처리하고 그 회원들의 로그인 세션까지 끊을 수 있었습니다. 기본 제공 역할인 "매니저" 가 이 구성에 해당합니다. 이제 일괄 변경도 회원 상세와 같은 기준으로 대상마다 범위를 확인하며, 범위 밖 회원은 처리 대상에서 제외되고 처리 건수로 확인할 수 있습니다. 범위 제한 없이 위임받은 관리자의 일괄 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||
- 레이아웃에 적는 스크립트 주소가 확장이 선언한 신뢰 출처인지 판정할 때, 주소를 브라우저와 같은 기준으로 정규화하지 않던 문제를 수정했습니다. 신뢰 출처 이름을 주소 뒷부분에 끼워 넣고 그 앞에 역슬래시를 둔 주소가 저장 단계에서만 신뢰 출처로 보여 통과했습니다(화면 로드 단계는 차단하고 있었으므로 실제로 불려 오지는 않았습니다). 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 경우도 있었습니다. 이제 주소가 "내 사이트 경로인가" 와 "신뢰 출처인가" 를 같은 기준으로 판정합니다.
|
||||
- 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다.
|
||||
- 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919)
|
||||
- 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915)
|
||||
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
|
||||
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
|
||||
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
|
||||
|
||||
### Added
|
||||
|
||||
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
|
||||
- S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다.
|
||||
- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
|
||||
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
|
||||
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
|
||||
- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다.
|
||||
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
|
||||
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
|
||||
- 예약 작업의 실행 시각이 환경설정의 시간대를 기준으로 해석되도록 바꿨습니다. 종전에는 세계표준시 기준이어서 '새벽 4시'로 등록해도 국내 기준 낮에 실행됐습니다. 이번 변경으로 새로 추가된 자동 정리 작업은 물론, 기존 사이트맵 자동 생성·GeoIP 갱신·확장이 제공하는 예약 작업, 관리자가 직접 등록한 예약 작업까지 모두 같은 기준을 따릅니다. **시각을 지정해 둔 예약은 실제 실행 시각이 이동하므로 확인이 필요합니다.**
|
||||
- 시스템 요구사항 문서를 실제 검증 기준에 맞게 정비했습니다. 지원 브라우저 항목에 검증 방식을 명시하고(자동 테스트는 Chromium 기준이며, Firefox·Safari 는 프론트엔드 의존성의 호환 범위를 기준으로 지원합니다), PHP 확장을 필수와 기능별 선택으로 구분했으며, 설치 프로그램이 자동으로 검사하는 항목과 운영자가 직접 확인해야 하는 항목을 구분하는 절을 새로 두었습니다. "요구사항" 이라고 적힌 항목이 모두 설치 단계에서 검사되는 것은 아니라는 점이 이제 문서에 드러납니다. (#95 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 문서 사이에 어긋나 있던 수치와 표기를 정정했습니다 — 디스크 용량 기준(설치 검사 기준 500MB), PHP 확장 개수, 지원 운영체제에 Windows 개발 환경 추가, 데이터베이스 표기에 MariaDB 병기, 한국어 README 의 시스템 요구사항 항목 보완, 그리고 데이터베이스 접두어 길이 제한·collation 변경 가능 여부처럼 실제로는 동작하지만 문서에 없던 조건의 추가.
|
||||
- 확장 개발자용: 배포본에 함께 들어가는 브라우저 테스트 안내 문서가 실제 디렉토리 구성 및 실행 스크립트와 어긋나 있던 부분을 정정했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 데이터베이스 테이블 접두어를 기본값(`g7_`)이 아닌 값으로 설치한 사이트에서, 7.0.6 업그레이드의 활동 로그 색인 교체가 옛 색인을 지우지 못하고 남겨 두던 문제를 수정했습니다. 남은 옛 색인은 조회 이득 없이 기록 쓰기 비용만 늘리므로, 이번 업그레이드에서 자동으로 정리됩니다.
|
||||
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
|
||||
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
|
||||
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
|
||||
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
|
||||
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
|
||||
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
|
||||
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
|
||||
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
|
||||
- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다.
|
||||
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
|
||||
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
|
||||
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 회원 일괄 상태 변경의 결과 안내 문구에 처리 인원수가 채워지지 않고 자리표시 원문이 그대로 표시되던 문제를 수정했습니다.
|
||||
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
|
||||
- 웹소켓 연결 테스트가 브라우저 접속 주소만 검사해, 테스트는 성공해도 서버 발송(알림 등)은 실패할 수 있던 문제를 수정했습니다 — 이제 서버 발송용 주소도 함께 검사하고, 실패 시 어느 쪽 주소가 문제인지 구분해 알려줍니다.
|
||||
- 드라이버 설정의 S3 URL 이 너무 길 때 표시되는 영어 안내 문구가 실제 허용 길이(500자)와 다르게 255자로 안내되던 문제를 수정했습니다.
|
||||
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
|
||||
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
|
||||
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
|
||||
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
|
||||
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
|
||||
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
|
||||
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
|
||||
- 예약 작업 저장 화면에서 설치된 확장이 제공하는 Artisan 명령이 "예약 실행이 허용된 명령이 아닙니다" 로 거부되던 문제를 수정했습니다. 터미널에서의 검증은 통과하는 명령이 관리자 화면 저장에서만 거부되었습니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다.
|
||||
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
|
||||
|
||||
## [7.0.6] - 2026-08-10
|
||||
|
||||
### Security
|
||||
|
||||
+121
-10
@@ -8,7 +8,7 @@
|
||||
|
||||
| 항목 | 요구사항 |
|
||||
|------|---------|
|
||||
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
|
||||
| **PHP** | 8.2 이상 (필수 확장 16개 포함 — 기능별 선택 확장은 별도) |
|
||||
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
|
||||
| **Composer** | 2.x |
|
||||
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
|
||||
@@ -148,12 +148,16 @@ git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
```
|
||||
|
||||
### 3단계: Composer 의존성 설치
|
||||
### 3단계: Composer 의존성 설치 (선택)
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
> 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||
>
|
||||
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||
|
||||
### 4단계: 환경 설정 파일 생성
|
||||
|
||||
```bash
|
||||
@@ -200,15 +204,17 @@ https://github.com/gnuboard/g7
|
||||
|
||||
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
|
||||
|
||||
### 4단계: Composer 의존성 설치
|
||||
### 4단계: Composer 의존성 설치 (선택)
|
||||
|
||||
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다.
|
||||
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
|
||||
|
||||
```bash
|
||||
cd g7-버전명
|
||||
composer install
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
|
||||
|
||||
### 5단계: 환경 설정 파일 생성
|
||||
|
||||
```bash
|
||||
@@ -289,7 +295,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.6 g7
|
||||
# (필요 시) mv g7-7.0.11 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
@@ -343,12 +349,22 @@ http://도메인/install
|
||||
|------|------|
|
||||
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
|
||||
| 1. 라이선스 | 동의 |
|
||||
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 |
|
||||
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 + 권장 항목(OPcache · Composer 의존성 구성) 안내 |
|
||||
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
|
||||
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
|
||||
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
|
||||
|
||||
`.env` 파일 생성과 `storage` / `bootstrap/cache` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
||||
`.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
||||
|
||||
**Step 2 — Composer 의존성 구성 (선택 항목):**
|
||||
|
||||
이미 `vendor/` 가 준비되어 있으면 설치 마법사는 그것을 그대로 씁니다. 이때 그 `vendor/` 가 개발용 패키지까지 포함한 설치(`composer install`, 옵션 없음)인지 확인해 알려 줍니다.
|
||||
|
||||
- **운영용 구성 (개발용 패키지 없음)**: 권장 상태입니다. 조치가 필요 없습니다.
|
||||
- **개발용 패키지 N개 포함**: 설치는 그대로 진행됩니다. 운영에 사용할 서버라면 설치를 마친 뒤 프로젝트 루트에서 `composer install --no-dev --optimize-autoloader` 를 한 번 실행하세요. 그대로 두면 이후 코어 업데이트가 `vendor/` 를 교체할 때 이전 패키지 목록 캐시와 어긋나 업데이트가 중단될 수 있습니다.
|
||||
- **vendor 없음**: 설치 마법사가 운영용 구성으로 자동 설치하므로 조치가 필요 없습니다.
|
||||
|
||||
이 항목은 설치를 차단하지 않습니다.
|
||||
|
||||
**Step 3 — Vendor 설치 방식 선택:**
|
||||
|
||||
@@ -368,6 +384,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 +417,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 +601,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 +687,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)를 참조하세요.
|
||||
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
<p align="center"><a href="README.md">English</a> | 한국어</p>
|
||||
|
||||
# 그누보드7
|
||||
|
||||
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-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>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 그누보드7 소개
|
||||
|
||||
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
|
||||
|
||||
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
|
||||
|
||||
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
|
||||
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
|
||||
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
|
||||
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
|
||||
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
|
||||
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
|
||||
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
|
||||
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
|
||||
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
|
||||
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
|
||||
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
|
||||
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
|
||||
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
|
||||
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
|
||||
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
|
||||
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
|
||||
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
|
||||
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
|
||||
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|------|------|
|
||||
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
|
||||
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
|
||||
| **인증** | Laravel Sanctum (Bearer 토큰) |
|
||||
| **테스트** | PHPUnit 11.x, Vitest |
|
||||
| **코드 품질** | Laravel Pint (PSR-12) |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
Gnuboard7
|
||||
├── Core (Laravel 12)
|
||||
│ ├── Controller → FormRequest → Service → Repository → Model
|
||||
│ ├── Hook System (Action / Filter)
|
||||
│ ├── Permission (Role → Permission → Scope)
|
||||
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
|
||||
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
|
||||
│ ├── Notification (Definition × Template × Recipients)
|
||||
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
|
||||
│
|
||||
├── Extensions
|
||||
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
|
||||
│ ├── Plugins — 결제, 인증, 마케팅 ...
|
||||
│ ├── Templates — 관리자 UI, 사용자 UI
|
||||
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
|
||||
│
|
||||
└── Template Engine
|
||||
├── JSON Layout → React Components
|
||||
└── Dynamic Rendering + Data Binding
|
||||
```
|
||||
|
||||
### 템플릿 엔진 동작 흐름
|
||||
|
||||
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
|
||||
|
||||
#### 제공 기능
|
||||
|
||||
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
|
||||
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
|
||||
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Backend ["🔧 Backend — Laravel"]
|
||||
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
|
||||
B --> |"상속 해석<br/>extends / partial"| B
|
||||
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
|
||||
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
|
||||
end
|
||||
|
||||
subgraph Frontend ["⚛️ Frontend — React"]
|
||||
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
|
||||
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
|
||||
F & G --> H["🎨 DynamicRenderer"]
|
||||
|
||||
H --> I{"❓ 조건 평가<br/>if 표현식"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
|
||||
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
|
||||
|
||||
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
|
||||
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React 렌더링"]
|
||||
end
|
||||
|
||||
subgraph Actions ["👆 사용자 인터랙션"]
|
||||
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — 페이지 이동"]
|
||||
Q --> S["📡 apiCall — API 호출"]
|
||||
Q --> T["🔄 setState — 상태 변경"]
|
||||
Q --> U["📋 openModal — 모달 열기"]
|
||||
S --> |"onSuccess · onError"| Q
|
||||
T --> |"상태 변경 → 리렌더링"| H
|
||||
end
|
||||
|
||||
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||
style Frontend fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#064e3b
|
||||
style Actions fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#831843
|
||||
|
||||
style A fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style B fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style M fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style C fill:#dc2626,stroke:#b91c1c,color:#fff
|
||||
style D fill:#059669,stroke:#047857,color:#fff
|
||||
|
||||
style E fill:#059669,stroke:#047857,color:#fff
|
||||
style F fill:#0891b2,stroke:#0e7490,color:#fff
|
||||
style G fill:#0891b2,stroke:#0e7490,color:#fff
|
||||
style H fill:#d97706,stroke:#b45309,color:#fff
|
||||
style I fill:#d97706,stroke:#b45309,color:#fff
|
||||
style J fill:#2563eb,stroke:#1d4ed8,color:#fff
|
||||
style K fill:#6b7280,stroke:#4b5563,color:#fff
|
||||
style L fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style N fill:#7c3aed,stroke:#6d28d9,color:#fff
|
||||
style O fill:#059669,stroke:#047857,color:#fff
|
||||
|
||||
style Q fill:#e11d48,stroke:#be123c,color:#fff
|
||||
style R fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style S fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style T fill:#be185d,stroke:#9d174d,color:#fff
|
||||
style U fill:#be185d,stroke:#9d174d,color:#fff
|
||||
|
||||
linkStyle default stroke:#374151,stroke-width:2px
|
||||
```
|
||||
|
||||
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"data_sources": [
|
||||
{ "id": "products", "endpoint": "/api/products", "method": "GET" }
|
||||
],
|
||||
"layout": {
|
||||
"type": "basic", "name": "Div",
|
||||
"children": [
|
||||
{ "type": "basic", "name": "H1", "text": "$t:product_list" },
|
||||
{
|
||||
"type": "basic", "name": "Div",
|
||||
"iteration": { "source": "{{products?.data?.data}}", "item_var": "$item" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "{{$item.name}}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic", "name": "Button", "text": "$t:add",
|
||||
"if": "{{products?.data?.abilities?.can_create}}",
|
||||
"actions": [{
|
||||
"event": "onClick",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/products/create" }
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
|
||||
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
|
||||
|
||||
### 핵심 시스템
|
||||
|
||||
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
|
||||
|
||||
#### 1. 확장 시스템 — 3원칙
|
||||
|
||||
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
|
||||
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
|
||||
|
||||
#### 2. 훅 시스템 (Action / Filter)
|
||||
|
||||
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
|
||||
|
||||
```php
|
||||
// Service 계층에서 훅 발행
|
||||
HookManager::doAction('core.user.after_create', $user, $data);
|
||||
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
|
||||
|
||||
// 모듈 Listener 가 훅 구독 (자동 발견)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
|
||||
|
||||
#### 3. 공통 캐시 시스템
|
||||
|
||||
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
|
||||
|
||||
| 드라이버 | 접두사 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
|
||||
|
||||
```php
|
||||
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
|
||||
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $repository,
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
|
||||
) {}
|
||||
```
|
||||
|
||||
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
|
||||
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
|
||||
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
|
||||
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
|
||||
|
||||
#### 4. 알림 시스템
|
||||
|
||||
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
|
||||
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
|
||||
│ variables=[...] │ │ subject, body, │ │ - role │
|
||||
│ │ │ click_url │ │ - specific_users │
|
||||
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
|
||||
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
|
||||
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
|
||||
|
||||
```php
|
||||
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
||||
|
||||
// ↓ NotificationHookListener → NotificationDispatcher:
|
||||
// 1. order.confirmed 정의 조회
|
||||
// 2. 활성 템플릿 순회 (mail/database)
|
||||
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
|
||||
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
|
||||
// 5. notification_logs 에 발송 이력 기록
|
||||
```
|
||||
|
||||
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
|
||||
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
|
||||
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
|
||||
|
||||
#### 5. 언어팩 시스템
|
||||
|
||||
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
|
||||
|
||||
| 영역 | 동작 |
|
||||
| --- | --- |
|
||||
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
|
||||
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
|
||||
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
|
||||
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
|
||||
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
|
||||
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
|
||||
|
||||
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
|
||||
|
||||
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
|
||||
|
||||
#### 6. 본인인증 (Identity Verification)
|
||||
|
||||
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
|
||||
|
||||
```text
|
||||
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Policy │ │ Purpose │ │ Provider │
|
||||
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
|
||||
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
|
||||
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
|
||||
│
|
||||
GenericNotification
|
||||
```
|
||||
|
||||
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
|
||||
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
|
||||
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
|
||||
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
|
||||
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
|
||||
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
|
||||
|
||||
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 시스템 요구사항
|
||||
|
||||
- PHP 8.2+ (필수 확장 16개 포함 — `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, `zip` 등. `gd`/`imagick`, `intl`, `redis`, `bcmath` 등은 해당 기능을 쓸 때만 필요하며 전체 목록은 [docs/requirements.md](docs/requirements.md) 참조)
|
||||
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
|
||||
- Composer 2.x
|
||||
- Node.js 20+ (프론트엔드 에셋을 직접 빌드할 때만 필요)
|
||||
- 웹 서버(Apache 또는 Nginx) — 문서 루트를 `public/` 으로 지정
|
||||
- Redis 6.0+ (선택 — 프로덕션의 캐시·큐에 권장)
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
# 1. 프로젝트 클론
|
||||
git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
|
||||
# 2. 환경 설정 파일 복사
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
||||
```
|
||||
|
||||
> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
|
||||
|
||||
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 기본 제공 확장
|
||||
|
||||
### 모듈
|
||||
|
||||
| 모듈 | 설명 |
|
||||
|------|------|
|
||||
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
|
||||
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
|
||||
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
|
||||
|
||||
### 플러그인
|
||||
|
||||
| 플러그인 | 설명 |
|
||||
|---------|------|
|
||||
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
|
||||
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
|
||||
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
|
||||
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
|
||||
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
|
||||
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
|
||||
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
|
||||
|
||||
### 템플릿
|
||||
|
||||
| 템플릿 | 설명 |
|
||||
|--------|------|
|
||||
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
|
||||
| **sirsoft-basic** | 사용자 기본 템플릿 |
|
||||
|
||||
### 번들 언어팩
|
||||
|
||||
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
|
||||
|
||||
| 식별자 | 설명 |
|
||||
| ------ | ---- |
|
||||
| **g7-core-ja** | 코어 일본어 |
|
||||
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
|
||||
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
|
||||
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
|
||||
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | 비즈뿌리오 메시지 발송 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
|
||||
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
|
||||
|
||||
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
|
||||
|
||||
### 학습용 샘플 확장
|
||||
|
||||
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
|
||||
|
||||
| 식별자 | 종류 | 설명 |
|
||||
| ------ | ---- | ---- |
|
||||
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
|
||||
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
|
||||
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
|
||||
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
|
||||
|
||||
---
|
||||
|
||||
## 비즈니스 모델
|
||||
|
||||
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
|
||||
|
||||
| 모델 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
|
||||
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
|
||||
|
||||
---
|
||||
|
||||
## 기존 그누보드 사용자
|
||||
|
||||
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 링크 |
|
||||
|------|------|
|
||||
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
|
||||
| 전체 문서 | [docs/README.md](docs/README.md) |
|
||||
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
|
||||
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
|
||||
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
|
||||
|
||||
---
|
||||
|
||||
## 기여하기
|
||||
|
||||
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
|
||||
|
||||
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- 코드 스타일: Laravel Pint (PSR-12)
|
||||
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
|
||||
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
|
||||
|
||||
---
|
||||
|
||||
## 만든 사람들
|
||||
|
||||
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
|
||||
|
||||
### Core Team
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>
|
||||
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
|
||||
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
|
||||
</p>
|
||||
|
||||
### 커뮤니티 기여자
|
||||
|
||||
제보와 건의로 그누보드7을 함께 만들어 주신 분들입니다. 아래 목록은 변경 기록(CHANGELOG)에 남은 제보자 표기에서 모았습니다.
|
||||
|
||||
<!-- 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/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/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/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></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>
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
<!-- community-contributors:end -->
|
||||
|
||||
코드 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
| 채널 | 링크 |
|
||||
|------|------|
|
||||
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
|
||||
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
|
||||
| 문의 | minsup@sir.kr |
|
||||
|
||||
---
|
||||
|
||||
## 변경 기록
|
||||
|
||||
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 보안 취약점
|
||||
|
||||
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
|
||||
|
||||
Copyright (c) 2026 SIRSOFT
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made by <a href="https://sir.kr">SIRSOFT</a>
|
||||
</p>
|
||||
@@ -1,14 +1,12 @@
|
||||
<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>
|
||||
<p align="center">English | <a href="README.ko.md">한국어</a></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>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 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.6-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-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>
|
||||
@@ -17,67 +15,71 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
|
||||
<a href="https://g7.gnuboard.com"><strong>Live Demo</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>Admin Demo</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>The demo UI language follows your browser (Korean/English); you can switch it in the UI.</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
|
||||
[About](#about-gnuboard7) · [Key Features](#key-features) · [Tech Stack](#tech-stack) · [Architecture](#architecture) · [Quick Start](#quick-start) · [Bundled Extensions](#bundled-extensions) · [Business Models](#business-models) · [Migrating from Gnuboard 5](#migrating-from-gnuboard-5) · [Documentation](#documentation) · [Contributing](#contributing) · [Team](#team) · [Community](#community) · [Changelog](#changelog) · [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## 그누보드7 소개
|
||||
## About Gnuboard7
|
||||
|
||||
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
|
||||
**Gnuboard7** is a complete, ground-up redesign of Gnuboard — Korea's most widely used open-source CMS for 23 years — rebuilt on a modern stack.
|
||||
|
||||
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
|
||||
Everything from security to architecture was rewritten from scratch on Laravel and React.
|
||||
|
||||
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
|
||||
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
|
||||
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
|
||||
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
|
||||
- **JSON layout engine**: define React-based UI declaratively with JSON alone, no React knowledge required. Modules and plugins inject or extend UI dynamically through JSON without a frontend build. When you need something more advanced, you can develop and register custom React components
|
||||
- **One platform, many business models**: community, storefront, subscription, booking — extend it to fit your business
|
||||
- **Fine-grained access control**: Role + Permission + Scope, a three-tier model that keeps control as your service grows
|
||||
- **Global-ready**: native i18n support, locale-driven UI, multi-currency handling
|
||||
- **Extension system**: modules + plugins + templates, a three-layer structure that adds functionality without touching the core
|
||||
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
## Key Features
|
||||
|
||||
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
|
||||
Everything a modern web platform needs, built in.
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
|
||||
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
|
||||
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
|
||||
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
|
||||
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
|
||||
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
|
||||
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
|
||||
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
|
||||
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
|
||||
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
|
||||
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
|
||||
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
|
||||
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
|
||||
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
|
||||
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
|
||||
| Area | Description |
|
||||
|------|-------------|
|
||||
| **Modular architecture** | Modules + plugins + templates, a three-layer extension structure. Independent modules (boards, commerce, and more) can be developed without modifying the core. Hook-based injection preserves the clear layering of the Service-Repository pattern |
|
||||
| **Language pack system** | Install a new language from a ZIP file or GitHub URL without touching the core. Official bundled language packs (Japanese and others) are ready to use immediately, and labels an operator has edited are preserved per sub-key so a language pack never overwrites them. Packs apply independently to modules, plugins, and templates |
|
||||
| **Localization** | A consistent multilingual development experience from backend to frontend. Active language packs automatically enrich notification channel labels, provider/registry payloads, and settings catalogs (payment methods, currencies, shippable countries). Activity log and message surfaces are separated so modules and plugins describe their own domain labels in their own territory |
|
||||
| **Payment gateways** | A foundation for growing beyond a local business into global commerce. Payment integrations attach through the same extension-point pattern, and international gateways ship as separate plugins |
|
||||
| **Access control** | Control menus, features, and even data scope per role. Role + Permission + Scope three-tier access control provides flexible management that mirrors your organization |
|
||||
| **Identity verification (IDV)** | Every verification point — signup, password reset, sensitive operations — is managed centrally through declarative route/hook-level policies. The core ships a mail provider built in, and external providers (Korean identity-verification services such as KG Inicis and NHN KCP, as well as SMS, PortOne, and Stripe Identity-style services) attach through the same provider contract. When the server returns HTTP 428, the frontend interceptor opens a verification modal automatically and replays the original request on success |
|
||||
| **Security** | Automatic input validation and token-based authentication. Layered defense designed in from the start (CSRF/XSS/SQL injection), a real implementation of login throttling and account lockout (HTTP 423), and automatic blocking of installer endpoints once setup completes (HTTP 410) |
|
||||
| **Flexible screen composition** | Define a screen structure and see it applied immediately. Web-app-grade dynamic screens are achievable with JSON declarations alone, no frontend infrastructure required |
|
||||
| **Layout editor** | A WYSIWYG layout editor lets you place screen blocks directly and see the result right away |
|
||||
| **Proven foundation** | Built on Laravel + React — a stack adopted by companies worldwide, offering high extensibility and flexible UI implementation |
|
||||
| **Shared cache system** | `CacheInterface` plus three drivers (core/module/plugin) isolate key prefixes automatically (`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`). Tag-based automatic invalidation and central TTL management via `g7_core_settings('cache.*_ttl')` keep operations free of hardcoded values |
|
||||
| **Notification system** | A three-tier model — Definition × Template × Recipients — supports independent multi-channel delivery over mail, database, and real-time broadcast (Reverb). Targeting by author, role, specific users, or permission holders, plus hook-based dispatch, lets modules register their own notifications freely |
|
||||
| **SEO** | Powered by `jaybizzle/crawler-detect`, roughly 1,000 bot types (search engines, social unfurlers, AI search) are detected automatically: bots receive static HTML while regular users get the SPA. OG/Twitter card metadata, domain schemas declared by modules (Article/Product/Offer/AggregateRating), automatic and manual sitemap generation, and generator meta tags are all provided by the core |
|
||||
| **Activity log** | Administrator and user activity is recorded and searchable automatically. The Monolog-based structure is easy to extend, and action labels resolve from a module's or plugin's own translation files first, so each domain describes itself |
|
||||
| **Search** | Full-text search powered by Laravel Scout, covering key content such as products and posts |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
## Tech Stack
|
||||
|
||||
| 구분 | 기술 |
|
||||
|------|------|
|
||||
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
|
||||
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
|
||||
| **인증** | Laravel Sanctum (Bearer 토큰) |
|
||||
| **테스트** | PHPUnit 11.x, Vitest |
|
||||
| **코드 품질** | Laravel Pint (PSR-12) |
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
|
||||
| **Frontend** | React 19, Vite, Tailwind CSS 4 (dark mode supported) |
|
||||
| **Authentication** | Laravel Sanctum (Bearer tokens) |
|
||||
| **Testing** | PHPUnit 11.x, Vitest |
|
||||
| **Code quality** | Laravel Pint (PSR-12) |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Gnuboard7
|
||||
@@ -86,66 +88,66 @@ Gnuboard7
|
||||
│ ├── Hook System (Action / Filter)
|
||||
│ ├── Permission (Role → Permission → Scope)
|
||||
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
|
||||
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
|
||||
│ ├── Language Pack (virtual protected rows + ZIP/GitHub install + sub-key preservation)
|
||||
│ ├── Notification (Definition × Template × Recipients)
|
||||
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
|
||||
│
|
||||
├── Extensions
|
||||
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
|
||||
│ ├── Plugins — 결제, 인증, 마케팅 ...
|
||||
│ ├── Templates — 관리자 UI, 사용자 UI
|
||||
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
|
||||
│ ├── Modules — board, commerce, page ...
|
||||
│ ├── Plugins — payment, verification, marketing ...
|
||||
│ ├── Templates — admin UI, user UI
|
||||
│ └── LanguagePacks — official and third-party packs (Japanese and more)
|
||||
│
|
||||
└── Template Engine
|
||||
├── JSON Layout → React Components
|
||||
└── Dynamic Rendering + Data Binding
|
||||
```
|
||||
|
||||
### 템플릿 엔진 동작 흐름
|
||||
### How the template engine works
|
||||
|
||||
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
|
||||
In Gnuboard7 you **declare the UI structure in JSON**, and the engine interprets it and renders React components.
|
||||
|
||||
#### 제공 기능
|
||||
#### What it gives you
|
||||
|
||||
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
|
||||
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
|
||||
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
|
||||
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
|
||||
- Build React-based UI from JSON declarations alone — screen development without React expertise
|
||||
- Modules and plugins inject or extend UI dynamically through JSON, with no frontend build
|
||||
- Develop and register custom React components when a screen needs something more advanced
|
||||
- Because the UI is defined as data (JSON) rather than code, a **WYSIWYG layout editor** lets non-developers place and edit screen blocks and see the result immediately
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Backend ["🔧 Backend — Laravel"]
|
||||
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
|
||||
B --> |"상속 해석<br/>extends / partial"| B
|
||||
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
|
||||
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
|
||||
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
|
||||
A["📄 JSON layout file"] --> B["⚙️ LayoutService"]
|
||||
B --> |"inheritance<br/>extends / partial"| B
|
||||
M["📦 Module layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
|
||||
P["🔌 Plugin layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
|
||||
B --> C["🔒 Permission filtering<br/>drop components per user"]
|
||||
C --> D["📨 Merged JSON response<br/>cached · 1 hour TTL"]
|
||||
end
|
||||
|
||||
subgraph Frontend ["⚛️ Frontend — React"]
|
||||
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
|
||||
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
|
||||
D --> E["📥 LayoutLoader<br/>receive layout JSON"]
|
||||
E --> F["💾 State init<br/>_global · _local · _computed"]
|
||||
E --> G["🌐 Data source loading<br/>parallel API calls"]
|
||||
F & G --> H["🎨 DynamicRenderer"]
|
||||
|
||||
H --> I{"❓ 조건 평가<br/>if 표현식"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
|
||||
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
|
||||
H --> I{"❓ Condition eval<br/>if expression"}
|
||||
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React component"]
|
||||
I --> |"❌ false"| K["⏭️ Skip rendering"]
|
||||
|
||||
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
|
||||
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React 렌더링"]
|
||||
J --> L["🔗 Data binding<br/>expression → value"]
|
||||
L --> N["🖱️ Event binding<br/>onClick → ActionDispatcher"]
|
||||
N --> O["✨ React render"]
|
||||
end
|
||||
|
||||
subgraph Actions ["👆 사용자 인터랙션"]
|
||||
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — 페이지 이동"]
|
||||
Q --> S["📡 apiCall — API 호출"]
|
||||
Q --> T["🔄 setState — 상태 변경"]
|
||||
Q --> U["📋 openModal — 모달 열기"]
|
||||
subgraph Actions ["👆 User interaction"]
|
||||
O --> |"click · input"| Q["🎯 ActionDispatcher"]
|
||||
Q --> R["🧭 navigate — page transition"]
|
||||
Q --> S["📡 apiCall — API request"]
|
||||
Q --> T["🔄 setState — state change"]
|
||||
Q --> U["📋 openModal — open a modal"]
|
||||
S --> |"onSuccess · onError"| Q
|
||||
T --> |"상태 변경 → 리렌더링"| H
|
||||
T --> |"state change → re-render"| H
|
||||
end
|
||||
|
||||
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
|
||||
@@ -179,7 +181,7 @@ flowchart TB
|
||||
linkStyle default stroke:#374151,stroke-width:2px
|
||||
```
|
||||
|
||||
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
|
||||
**JSON layout example** — the JSON below renders as a real React UI:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -211,29 +213,29 @@ flowchart TB
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
|
||||
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
|
||||
Activating a module or plugin injects its UI and components automatically.
|
||||
Developers add or change UI with JSON alone — no separate frontend build — and UI elements are shown or hidden automatically according to permissions (abilities).
|
||||
|
||||
### 핵심 시스템
|
||||
### Core systems
|
||||
|
||||
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
|
||||
Four systems work together to hold the platform up.
|
||||
|
||||
#### 1. 확장 시스템 — 3원칙
|
||||
#### 1. Extension system — three principles
|
||||
|
||||
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
|
||||
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
|
||||
1. **Minimal core modification** — all business logic lives in modules and plugins
|
||||
2. **Dynamic loading** — discovered automatically by directory scan, with no `composer.json` hardcoding
|
||||
3. **Hook-based extension** — functionality is injected at the service layer through action and filter hooks
|
||||
|
||||
#### 2. 훅 시스템 (Action / Filter)
|
||||
#### 2. Hook system (Action / Filter)
|
||||
|
||||
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
|
||||
A lightweight hook system that operates separately from Laravel events. Actions handle side effects (logging, notifications); filters transform values (injecting defaults, extending permissions).
|
||||
|
||||
```php
|
||||
// Service 계층에서 훅 발행
|
||||
// Publish hooks from the service layer
|
||||
HookManager::doAction('core.user.after_create', $user, $data);
|
||||
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
|
||||
|
||||
// 모듈 Listener 가 훅 구독 (자동 발견)
|
||||
// A module listener subscribes to the hook (auto-discovered)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
@@ -242,258 +244,275 @@ public static function getSubscribedHooks(): array
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
|
||||
Modules and plugins only need to place a class in their `Listeners/` directory and `HookListenerRegistrar` subscribes it automatically. Asynchronous execution through queue serialization is supported as well, and context such as `Auth::user()`, `request()->ip()`, and `App::getLocale()` is restored automatically inside the worker.
|
||||
|
||||
#### 3. 공통 캐시 시스템
|
||||
#### 3. Shared cache system
|
||||
|
||||
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
|
||||
Built on `CacheInterface`, the **core, modules, and plugins** each manage their own cache without key collisions.
|
||||
|
||||
| 드라이버 | 접두사 | 용도 |
|
||||
| Driver | Prefix | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | Core services (layouts, SEO, notifications, settings) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | Per-module isolated cache (board and product lists, cooldowns) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | Per-plugin isolated cache |
|
||||
|
||||
```php
|
||||
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
|
||||
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
|
||||
// Register a module service in the BaseModuleServiceProvider::$cacheServices array
|
||||
// and it is injected from the constructor type hint alone (same as the storage pattern)
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $repository,
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: prefix applied automatically
|
||||
) {}
|
||||
```
|
||||
|
||||
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
|
||||
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
|
||||
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
|
||||
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
|
||||
- **Central TTL management** — every cache TTL follows `g7_core_settings('cache.*_ttl')`. No hardcoding
|
||||
- **Automatic invalidation** — apply the `CacheInvalidatable` trait to a model and related caches are dropped by tag on `saved` / `deleted`
|
||||
- **Lifecycle integration** — when a module is deactivated or removed, `ModuleManager` flushes that module's isolated cache in bulk
|
||||
- **Frontend cache busting** — incrementing `ext.cache_version` propagates through the response `config.json` and invalidates browser caches via a `?v=` query parameter
|
||||
|
||||
#### 4. 알림 시스템
|
||||
#### 4. Notification system
|
||||
|
||||
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
|
||||
Multi-channel notifications are managed through a three-tier model: **Definition × Template × Recipients**.
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
|
||||
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
|
||||
│ variables=[...] │ │ subject, body, │ │ - role │
|
||||
│ │ │ click_url │ │ - specific_users │
|
||||
│ tion ├──────┤ (independent per ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel) │ │ - related_user │
|
||||
│ variables=[...] │ │ channel=mail|db|... │ │ - role │
|
||||
│ │ │ subject, body, │ │ - specific_users │
|
||||
│ │ │ click_url │ │ │
|
||||
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
|
||||
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
|
||||
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
|
||||
- **Definition** — declares the notification type, supported channels, and variable metadata
|
||||
- **Template** — an independent subject, body, and click URL per channel (`mail` / `database` / `broadcast`). Administrators can customize them in multiple languages
|
||||
- **Recipients** — recipient rules declared as JSON per template. Because they are per-template, you can branch freely: "mail to the buyer, database notification to role holders"
|
||||
|
||||
```php
|
||||
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
|
||||
// A module service only publishes a hook; the delivery pipeline runs automatically
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
||||
|
||||
// ↓ NotificationHookListener → NotificationDispatcher:
|
||||
// 1. order.confirmed 정의 조회
|
||||
// 2. 활성 템플릿 순회 (mail/database)
|
||||
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
|
||||
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
|
||||
// 5. notification_logs 에 발송 이력 기록
|
||||
// 1. Look up the order.confirmed definition
|
||||
// 2. Iterate active templates (mail/database)
|
||||
// 3. Resolve each template's recipients JSON → recipient collection
|
||||
// 4. Deliver per channel to each recipient (GenericNotification)
|
||||
// 5. Record the delivery in notification_logs
|
||||
```
|
||||
|
||||
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
|
||||
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
|
||||
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
|
||||
- Three core notifications: `welcome`, `reset_password`, `password_changed`
|
||||
- Seven e-commerce module notifications: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- Real-time broadcasting runs on Laravel Reverb (WebSocket). Where Reverb is not configured, it skips gracefully without errors
|
||||
- A single `GenericNotification` class handles every notification — adding a new notification type requires no new notification class
|
||||
|
||||
#### 5. 언어팩 시스템
|
||||
#### 5. Language pack system
|
||||
|
||||
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
|
||||
An operations tool for adding a new language without touching the core, with the same lifecycle as module, plugin, and template management (install → activate → update → remove, with automatic backup and rollback).
|
||||
|
||||
| 영역 | 동작 |
|
||||
| Area | Behavior |
|
||||
| --- | --- |
|
||||
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
|
||||
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
|
||||
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
|
||||
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
|
||||
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
|
||||
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
|
||||
| Install paths | ZIP upload / GitHub URL / the `lang-packs/_bundled` bundled directory (synchronized in bulk on core updates) |
|
||||
| Scope | Applied separately to the core, modules, plugins, and templates — a module pack activates only while the matching core pack is active |
|
||||
| Preserving operator edits | Multilingual JSON columns record user overrides per sub-key (`name.ko` / `name.ja`), so editing one language's label preserves only that language while new languages sync automatically |
|
||||
| Activation effects | On activation or deactivation, the entity seeders of affected modules and plugins re-run, so menus, permissions, roles, manifests, and notification labels reach the database immediately |
|
||||
| Virtual protected rows | Korean and English are built into the core and bundled extensions and are always exposed as active and protected (editing and removal are blocked) |
|
||||
| Security | Installation is blocked if a pack contains executable PHP beyond language translations |
|
||||
|
||||
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
|
||||
Sixteen official Japanese (ja) bundled packs — the core plus the main modules, plugins, and templates — are ready to use, and step 4 of the installer links language pack cards to your module, plugin, and template selection so they can be installed together.
|
||||
|
||||
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
|
||||
> Details: [docs/extension/language-packs.md](docs/extension/language-packs.md) (Korean)
|
||||
|
||||
#### 6. 본인인증 (Identity Verification)
|
||||
#### 6. Identity verification
|
||||
|
||||
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
|
||||
Every verification point — signup, password reset, sensitive operations, the moment before payment — is managed centrally through declarative route/hook-level policies.
|
||||
|
||||
```text
|
||||
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Policy │ │ Purpose │ │ Provider │
|
||||
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
|
||||
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
|
||||
│ (enforcement point │ │ (verification goal, │ │ (mail, KCP, Inicis, │
|
||||
│ failure mode, │ ◀▶ │ allowed channels, │ ◀▶ │ SMS, external IDV) │
|
||||
│ step, conditions) │ │ source tracking) │ │ │
|
||||
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
|
||||
└──────────▶ Message Template (policy × purpose) ◀──┘
|
||||
│
|
||||
GenericNotification
|
||||
```
|
||||
|
||||
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
|
||||
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
|
||||
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
|
||||
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
|
||||
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
|
||||
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
|
||||
- **Policy as the single source of truth** — toggling a policy takes effect immediately without editing route code. Every API route matches against the policy database automatically
|
||||
- **428 interceptor** — when the server returns HTTP 428, the frontend opens a verification modal automatically and replays the original request once verification succeeds
|
||||
- **Declarative registration** — modules and plugins declare `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()`, and those are registered automatically on activation or update while preserving operator edits
|
||||
- **Message templates** — define multilingual subjects and bodies per provider and per purpose/policy, falling back in the order policy → purpose → provider default
|
||||
- **External provider slots** — plugins can inject their own SDK UI for services such as KCP, PortOne, Toss verification, or Stripe Identity through the standard G7 extension-point pattern
|
||||
- **History management** — the admin screen offers tabs per verification method, unified search, multi-filters for status/purpose/channel/IP, and bulk destruction by retention period (180 days)
|
||||
|
||||
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
|
||||
> Details: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md) (Korean)
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
## Quick Start
|
||||
|
||||
### 시스템 요구사항
|
||||
### System requirements
|
||||
|
||||
- PHP 8.2+ (필수 확장 30개 포함)
|
||||
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
|
||||
- Node.js 20+ (빌드 시에만 필요)
|
||||
- PHP 8.2+ with the required extensions (16 in total), including `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`. Additional extensions (`gd`/`imagick`, `intl`, `redis`, `bcmath`, and others) are optional and only needed for the features that use them — see [docs/requirements.md](docs/requirements.md)
|
||||
- MySQL 8.0+ or MariaDB 10.3+ (utf8mb4)
|
||||
- Composer 2.x
|
||||
- Node.js 20+ (only needed when building frontend assets)
|
||||
- A web server (Apache or Nginx) with the document root pointed at `public/`
|
||||
- Redis 6.0+ (optional — recommended for cache and queue in production)
|
||||
|
||||
### 설치
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# 1. 프로젝트 클론
|
||||
# 1. Clone the project
|
||||
git clone https://github.com/gnuboard/g7.git
|
||||
cd g7
|
||||
|
||||
# 2. 환경 설정 파일 복사
|
||||
# 2. (Optional) Install PHP dependencies — you can skip this step:
|
||||
# the setup wizard installs them automatically with the production
|
||||
# configuration when vendor/ is absent. Do not use a plain composer
|
||||
# install on a production site; it pulls in development packages.
|
||||
composer install --no-dev --optimize-autoloader
|
||||
|
||||
# 3. Copy the environment file
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
|
||||
# 4. Point your web server's document root at the public/ directory,
|
||||
# then open /install in a browser and follow the setup wizard
|
||||
```
|
||||
|
||||
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
|
||||
The setup wizard creates the application key, configures the database connection, runs the migrations, and lets you choose which modules, plugins, templates, and language packs to install. Once setup completes, the installer endpoints are blocked automatically (HTTP 410).
|
||||
|
||||
> Detailed installation guide (Korean): [INSTALL.md](INSTALL.md)
|
||||
|
||||
---
|
||||
|
||||
## 기본 제공 확장
|
||||
## Bundled Extensions
|
||||
|
||||
### 모듈
|
||||
### Modules
|
||||
|
||||
| 모듈 | 설명 |
|
||||
|------|------|
|
||||
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
|
||||
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
|
||||
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| **sirsoft-board** | Boards — multiple boards, comments, file attachments |
|
||||
| **sirsoft-ecommerce** | Storefront — products, orders, payments, shipping, coupons, product inquiries |
|
||||
| **sirsoft-page** | Pages — static content management |
|
||||
|
||||
### 플러그인
|
||||
### Plugins
|
||||
|
||||
| 플러그인 | 설명 |
|
||||
|---------|------|
|
||||
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
|
||||
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
|
||||
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
|
||||
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
|
||||
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
|
||||
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| **sirsoft-pay_kginicis** | KG Inicis payment integration (Korean payment gateway) |
|
||||
| **sirsoft-pay_nicepayments** | NICE Payments integration (Korean payment gateway, unified checkout) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP payment integration (Korean payment gateway, Standard Pay) |
|
||||
| **sirsoft-tosspayments** | Toss Payments integration (Korean payment gateway) |
|
||||
| **sirsoft-verification_kginicis** | KG Inicis identity verification (Korean identity verification) |
|
||||
| **sirsoft-verification_nhnkcp** | NHN KCP mobile identity verification (Korean identity verification) |
|
||||
| **sirsoft-daum_postcode** | Daum postcode lookup (Korean address search) |
|
||||
| **sirsoft-marketing** | Marketing tools |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 editor |
|
||||
| **sirsoft-gdpr** | Privacy and GDPR support |
|
||||
| **sirsoft-message_bizppurio** | Bizppurio messaging (SMS/LMS and KakaoTalk alimtalk delivery) |
|
||||
|
||||
### 템플릿
|
||||
### Templates
|
||||
|
||||
| 템플릿 | 설명 |
|
||||
|--------|------|
|
||||
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
|
||||
| **sirsoft-basic** | 사용자 기본 템플릿 |
|
||||
| Template | Description |
|
||||
|----------|-------------|
|
||||
| **sirsoft-admin_basic** | Default admin template |
|
||||
| **sirsoft-basic** | Default user template |
|
||||
|
||||
### 번들 언어팩
|
||||
### Bundled language packs
|
||||
|
||||
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
|
||||
Official language packs you can install alongside the initial setup, so the core and the main modules, plugins, and templates share one consistent translation from the start.
|
||||
|
||||
| 식별자 | 설명 |
|
||||
| ------ | ---- |
|
||||
| **g7-core-ja** | 코어 일본어 |
|
||||
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
|
||||
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
|
||||
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
|
||||
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
|
||||
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
|
||||
| Identifier | Description |
|
||||
| ---------- | ----------- |
|
||||
| **g7-core-ja** | Core, Japanese |
|
||||
| **g7-module-sirsoft-board-ja** | Board module, Japanese |
|
||||
| **g7-module-sirsoft-ecommerce-ja** | E-commerce module, Japanese |
|
||||
| **g7-module-sirsoft-page-ja** | Page module, Japanese |
|
||||
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor 5 plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-daum_postcode-ja** | Daum postcode plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-gdpr-ja** | Privacy/GDPR plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-marketing-ja** | Marketing plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-message_bizppurio-ja** | Bizppurio messaging plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG Inicis payment plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | NICE Payments plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP payment plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | Toss Payments plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG Inicis identity verification plugin, Japanese |
|
||||
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP mobile identity verification plugin, Japanese |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | Default admin template, Japanese |
|
||||
| **g7-template-sirsoft-basic-ja** | Default user template, Japanese |
|
||||
|
||||
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
|
||||
> Korean and English are built into the core and bundled extensions and are always active without installation. Any other language can be added freely from a ZIP file or a GitHub URL.
|
||||
|
||||
### 학습용 샘플 확장
|
||||
### Sample extensions for learning
|
||||
|
||||
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
|
||||
Minimal implementations for learning the extension system. They appear in the admin UI when the "include hidden" toggle is on, and are always visible from the CLI.
|
||||
|
||||
| 식별자 | 종류 | 설명 |
|
||||
| ------ | ---- | ---- |
|
||||
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
|
||||
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
|
||||
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
|
||||
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
|
||||
| Identifier | Type | Description |
|
||||
| ---------- | ---- | ----------- |
|
||||
| **gnuboard7-hello_module** | Module | Memo CRUD plus a hook publishing demo |
|
||||
| **gnuboard7-hello_plugin** | Plugin | Action/filter hook subscription demo |
|
||||
| **gnuboard7-hello_admin_template** | Admin template | A minimal set of basic components |
|
||||
| **gnuboard7-hello_user_template** | User template | Home page plus a memo list integration |
|
||||
|
||||
---
|
||||
|
||||
## 비즈니스 모델
|
||||
## Business Models
|
||||
|
||||
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
|
||||
One Gnuboard7 installation can run a range of businesses.
|
||||
|
||||
| 모델 | 설명 | 상태 |
|
||||
|------|------|------|
|
||||
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
|
||||
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
|
||||
| Model | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| **Community** | Boards, comments, member management | Stable |
|
||||
| **Commerce** | Product registration, orders, payments, shipping management | Stable |
|
||||
|
||||
---
|
||||
|
||||
## 기존 그누보드 사용자
|
||||
## Migrating from Gnuboard 5
|
||||
|
||||
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
|
||||
A migration tool from Gnuboard 5 is planned.
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
## Documentation
|
||||
|
||||
| 문서 | 링크 |
|
||||
|------|------|
|
||||
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
|
||||
| 전체 문서 | [docs/README.md](docs/README.md) |
|
||||
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
|
||||
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
|
||||
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
|
||||
> Documentation is currently available in Korean only. English documentation is planned.
|
||||
|
||||
| Document | Link |
|
||||
|----------|------|
|
||||
| Installation guide | [INSTALL.md](INSTALL.md) |
|
||||
| Full documentation | [docs/README.md](docs/README.md) |
|
||||
| System requirements | [docs/requirements.md](docs/requirements.md) |
|
||||
| Backend development | [docs/backend/README.md](docs/backend/README.md) |
|
||||
| Frontend development | [docs/frontend/README.md](docs/frontend/README.md) |
|
||||
| Database | [docs/database-guide.md](docs/database-guide.md) |
|
||||
| Extension system | [docs/extension/README.md](docs/extension/README.md) |
|
||||
| Module development | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
|
||||
| Plugin development | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
|
||||
| Template development | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
|
||||
| Testing | [docs/testing-guide.md](docs/testing-guide.md) |
|
||||
| API reference | [docs/backend/api/README.md](docs/backend/api/README.md) |
|
||||
| API documentation policy | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
|
||||
|
||||
---
|
||||
|
||||
## 기여하기
|
||||
## Contributing
|
||||
|
||||
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
|
||||
Gnuboard7 is an open-source project, and contributions of every kind are welcome.
|
||||
|
||||
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- 코드 스타일: Laravel Pint (PSR-12)
|
||||
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
|
||||
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
|
||||
- Bug reports and feature proposals: [GitHub Issues](https://github.com/gnuboard/g7/issues)
|
||||
- Code style: Laravel Pint (PSR-12)
|
||||
- Testing: PHPUnit (backend) + Vitest (frontend)
|
||||
- AI collaboration: the repository ships a development rule specification for AI agents (AGENTS.md) along with MCP debugging tools, so AI tooling fits naturally into the workflow
|
||||
|
||||
---
|
||||
|
||||
## 만든 사람들
|
||||
## Team
|
||||
|
||||
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
|
||||
Developed by **[SIRSOFT](https://sir.kr)**.
|
||||
|
||||
### Core Team
|
||||
|
||||
@@ -503,37 +522,65 @@ cp .env.example .env
|
||||
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
|
||||
</p>
|
||||
|
||||
### Contributors
|
||||
### Community Contributors
|
||||
|
||||
커뮤니티 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
|
||||
Thanks to everyone who reported an issue or suggested a feature that shipped — the list below is compiled from the attributions in our changelogs.
|
||||
|
||||
<!-- 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/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/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/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></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>
|
||||
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
|
||||
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
|
||||
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
|
||||
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
|
||||
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
|
||||
</p>
|
||||
<!-- community-contributors:end -->
|
||||
|
||||
The list of code contributors is available on [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors).
|
||||
|
||||
---
|
||||
|
||||
## 커뮤니티
|
||||
## Community
|
||||
|
||||
| 채널 | 링크 |
|
||||
|------|------|
|
||||
| Channel | Link |
|
||||
|---------|------|
|
||||
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
|
||||
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
|
||||
| 문의 | minsup@sir.kr |
|
||||
| SIR community (Korean) | [sir.kr](https://sir.kr) |
|
||||
| Contact | minsup@sir.kr |
|
||||
|
||||
---
|
||||
|
||||
## 변경 기록
|
||||
## Changelog
|
||||
|
||||
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
|
||||
For details on recent changes, see the [CHANGELOG](CHANGELOG.md) (Korean).
|
||||
|
||||
---
|
||||
|
||||
## 보안 취약점
|
||||
## Security Vulnerabilities
|
||||
|
||||
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
|
||||
If you discover a security vulnerability, please report it as a private post on the [SIR inquiry board](https://sir.kr/boards/co_qa) (Korean board), or email minsup@sir.kr.
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
## License
|
||||
|
||||
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
|
||||
Gnuboard7 is open-source software distributed under the [MIT License](LICENSE).
|
||||
|
||||
Copyright (c) 2026 SIRSOFT
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -330,6 +330,10 @@ trait BundledExtensionUpdatePrompt
|
||||
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
|
||||
$env = array_merge(getenv(), $_ENV);
|
||||
|
||||
// audit:allow spawn-after-vendor-swap-clears-package-manifest 이 spawn 은 vendor 를 바꾸지 않고,
|
||||
// 두 호출처가 모두 clearAllCaches() 뒤다 — CoreUpdateCommand Step 11(패키지 매니페스트 재생성)
|
||||
// 다음의 Step 12, ExecuteUpgradeStepsCommand 단독 실행의 캐시 정리 다음 단계. 따라서 자식이
|
||||
// 읽는 매니페스트는 이미 현재 vendor 기준으로 다시 만들어진 것이다.
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
return null;
|
||||
|
||||
@@ -16,7 +16,9 @@ use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\CoreUpdateService;
|
||||
use App\Support\ComposerInstallInfo;
|
||||
use App\Support\ConfigCacheHelper;
|
||||
use App\Support\PackageManifestCacheHelper;
|
||||
use App\Support\RouteCacheHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -301,12 +303,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 쓰기 영역)');
|
||||
}
|
||||
@@ -316,6 +322,22 @@ class CoreUpdateCommand extends Command
|
||||
$composerSkipped = $vendorMode !== VendorMode::Bundled
|
||||
&& $service->isComposerUnchangedForCore($pendingPath);
|
||||
|
||||
// 운영 vendor 에 개발용(require-dev) 패키지가 섞여 있는지 알린다. 그대로 두면
|
||||
// 이전 설치본이 만든 패키지 매니페스트가 새 vendor 와 어긋나 이후 부팅이 깨지고,
|
||||
// 그 사실은 오류 메시지 어디에도 "dev 설치본" 으로 드러나지 않는다.
|
||||
$devPackages = ComposerInstallInfo::devPackageNames(base_path('vendor'));
|
||||
if ($devPackages !== []) {
|
||||
$devCount = count($devPackages);
|
||||
$devSample = implode(', ', array_slice($devPackages, 0, 5)).($devCount > 5 ? ' …' : '');
|
||||
if ($composerSkipped) {
|
||||
$this->warn("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — composer.json/lock 변경이 없어 vendor 를 재설치하지 않으므로 그대로 남습니다. 업데이트 후 'composer install --no-dev --optimize-autoloader' 실행을 권장합니다.");
|
||||
$log("운영 vendor 개발용 패키지 {$devCount}개 잔존 (composer 스킵): {$devSample}");
|
||||
} else {
|
||||
$this->line("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — 이번 업데이트가 --no-dev vendor 로 교체합니다.");
|
||||
$log("운영 vendor 개발용 패키지 {$devCount}개 감지 — --no-dev vendor 로 교체: {$devSample}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($composerSkipped) {
|
||||
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
|
||||
$bar->advance();
|
||||
@@ -507,6 +529,11 @@ class CoreUpdateCommand extends Command
|
||||
$service->updateVersionInEnv($toVersion);
|
||||
$service->clearAllCaches();
|
||||
|
||||
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시가 새 코드 기준으로
|
||||
// 정리된 직후 재시작 신호를 보낸다.
|
||||
$service->signalQueueRestart();
|
||||
$log('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||
|
||||
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
|
||||
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
|
||||
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
|
||||
@@ -593,6 +620,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;
|
||||
|
||||
@@ -628,6 +659,9 @@ class CoreUpdateCommand extends Command
|
||||
try {
|
||||
$service->updateVersionInEnv($toVersion);
|
||||
$service->clearAllCaches();
|
||||
// 핸드오프로 멈춰도 파일은 이미 toVersion 이므로 워커는 새 코드로 재기동해야 한다.
|
||||
$service->signalQueueRestart();
|
||||
$log('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
|
||||
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
|
||||
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
|
||||
@@ -677,6 +711,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 +810,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 +904,33 @@ 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();
|
||||
|
||||
// 같은 이유로 패키지 매니페스트(bootstrap/cache/packages.php · services.php)도 비운다.
|
||||
// vendor 는 Step 6/8 에서 이미 교체됐지만 두 파일은 Step 11 clearAllCaches 까지 이전
|
||||
// 설치본의 것이 남고, Laravel 은 packages.php 가 "없을 때만" 다시 만든다. 이전 설치본이
|
||||
// dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
|
||||
// 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
|
||||
// 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
|
||||
//
|
||||
// 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
|
||||
// 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이
|
||||
// 권한이 원인이라는 유일한 흔적이다.
|
||||
$remainingManifests = PackageManifestCacheHelper::clear();
|
||||
if ($remainingManifests !== []) {
|
||||
$manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): '
|
||||
.implode(', ', $remainingManifests);
|
||||
$log($manifestWarning);
|
||||
$this->warn($manifestWarning);
|
||||
}
|
||||
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
return $this->failSpawnWithMode(
|
||||
@@ -1359,44 +1428,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;
|
||||
}
|
||||
@@ -249,6 +261,10 @@ class ExecuteUpgradeStepsCommand extends Command
|
||||
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
|
||||
$this->info('캐시 정리 (config/route/view/services/packages)');
|
||||
$service->clearAllCaches();
|
||||
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시 정리 직후 재시작 신호.
|
||||
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모가 보낸다.
|
||||
$service->signalQueueRestart();
|
||||
$this->info('큐 워커 재시작 신호 전송 (queue:restart)');
|
||||
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
|
||||
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
|
||||
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
|
||||
@@ -278,10 +294,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,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
|
||||
*
|
||||
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
|
||||
*/
|
||||
class ExpireIdentityChallengesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:expire-challenges';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$expired = $service->expirePastDue();
|
||||
|
||||
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command
|
||||
'debug_mode' => 'debug',
|
||||
'sql_query_log' => 'debug',
|
||||
'log_level' => 'debug',
|
||||
'outbound_proxy' => 'debug',
|
||||
'outbound_proxy_bypass' => 'debug',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use App\Services\LayoutResolverService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClearModuleCacheCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use InvalidatesLayoutCache;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -27,15 +34,19 @@ class ClearModuleCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private ModuleManager $moduleManager,
|
||||
private ModuleRepositoryInterface $moduleRepository,
|
||||
private CacheInterface $cache,
|
||||
private ExtensionBundleService $bundleService
|
||||
protected LayoutRepositoryInterface $layoutRepository,
|
||||
private ExtensionBundleService $bundleService,
|
||||
private LayoutResolverService $layoutResolver
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
|
||||
* + 버전 포함 키)를 무효화한다.
|
||||
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -61,6 +72,11 @@ class ClearModuleCacheCommand extends Command
|
||||
|
||||
$clearedCount = $this->clearModuleCache($identifier);
|
||||
|
||||
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
|
||||
ModuleManager::invalidateModuleStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
|
||||
'module' => $identifier,
|
||||
'count' => $clearedCount,
|
||||
@@ -69,25 +85,16 @@ class ClearModuleCacheCommand extends Command
|
||||
// 모든 모듈 캐시 삭제
|
||||
$this->info(__('modules.commands.cache_clear.clearing_all'));
|
||||
|
||||
// 전체 모듈 캐시 키 삭제
|
||||
$cacheKeys = [
|
||||
'modules.all',
|
||||
'modules.active',
|
||||
'modules.installed',
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 각 모듈별 캐시 삭제
|
||||
$allModules = $this->moduleManager->getAllModules();
|
||||
foreach ($allModules as $moduleName => $module) {
|
||||
foreach ($this->moduleManager->getAllModules() as $module) {
|
||||
$clearedCount += $this->clearModuleCache($module->getIdentifier());
|
||||
}
|
||||
|
||||
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
|
||||
ModuleManager::invalidateModuleStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
|
||||
$clearedCount += $this->bundleService->clearBundles('module');
|
||||
|
||||
@@ -113,26 +120,23 @@ class ClearModuleCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* 특정 모듈의 캐시를 삭제합니다.
|
||||
*
|
||||
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
|
||||
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
|
||||
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
|
||||
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
|
||||
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
|
||||
*
|
||||
* @param string $identifier 모듈 식별자
|
||||
* @return int 캐시를 무효화한 레이아웃 수
|
||||
*/
|
||||
private function clearModuleCache(string $identifier): int
|
||||
{
|
||||
$clearedCount = 0;
|
||||
$this->invalidateExtensionLayoutCache($identifier, 'module');
|
||||
$this->layoutResolver->clearResolutionCacheByModule($identifier);
|
||||
|
||||
// 모듈별 캐시 키
|
||||
$cacheKeys = [
|
||||
"module.config.{$identifier}",
|
||||
"module.info.{$identifier}",
|
||||
"module.routes.{$identifier}",
|
||||
"module.permissions.{$identifier}",
|
||||
"module.menus.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $clearedCount;
|
||||
return $this->layoutRepository
|
||||
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\')');
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\IdentityVerificationLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Playwright E2E 용 2단계 인증 픽스처 커맨드.
|
||||
*
|
||||
* 두 가지 일을 한다.
|
||||
* - `--ensure-user` : 알려진 비밀번호를 가진 Active 사용자를 만들거나 갱신한다.
|
||||
* (`--admin` 이면 admin 역할을 함께 부여)
|
||||
* - `--plant` : 지정한 challenge 에 알려진 인증 코드의 해시를 심는다.
|
||||
*
|
||||
* 인증번호는 해시로만 저장되므로 브라우저 테스트가 되읽을 수 없다. 메일을 실제로 열어
|
||||
* 보는 대신 알려진 값을 심어 "코드가 맞을 때" 를 재현한다 — 검증 대상은 코드 생성이
|
||||
* 아니라 로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
|
||||
* 심는 방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 동일하다.
|
||||
*
|
||||
* 보안 가드 (3중, `playwright:issue-token` 과 동형):
|
||||
* ① CLI 한정 — `php_sapi_name() === 'cli'`. production 웹 요청에서 도달 불가
|
||||
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 필수
|
||||
* ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작이 가능하도록
|
||||
*
|
||||
* 호출 예시 (PowerShell):
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --ensure-user=user --password='Passw0rd!2fa'
|
||||
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790
|
||||
*/
|
||||
class PlaywrightSeedTwoFactor extends Command
|
||||
{
|
||||
protected $signature = 'playwright:seed-two-factor
|
||||
{--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
|
||||
{--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
|
||||
{--admin : --ensure-user 계정에 admin 역할을 부여한다}
|
||||
{--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
|
||||
{--code=135790 : --plant 가 심을 인증 코드}
|
||||
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
|
||||
{--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
|
||||
|
||||
protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
|
||||
|
||||
/** 테스트 전용 계정 이메일 접두사 */
|
||||
private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
|
||||
// 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
|
||||
// 환경변수이므로 config:cache 의 영향을 받지 않는다.
|
||||
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
|
||||
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
|
||||
Config::set('app.debug', true);
|
||||
|
||||
$gcHours = (int) $this->option('gc-hours');
|
||||
if ($gcHours > 0) {
|
||||
$this->pruneStaleTestUsers($gcHours);
|
||||
}
|
||||
|
||||
$did = false;
|
||||
|
||||
// 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
|
||||
// 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
|
||||
if ($this->option('purge-users')) {
|
||||
$this->purgeTestUsers();
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($suffix = $this->option('ensure-user')) {
|
||||
$this->ensureUser((string) $suffix);
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if ($challengeId = $this->option('plant')) {
|
||||
if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
$did = true;
|
||||
}
|
||||
|
||||
if (! $did) {
|
||||
$this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
|
||||
*
|
||||
* @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
|
||||
*/
|
||||
private function ensureUser(string $suffix): void
|
||||
{
|
||||
$email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
|
||||
$password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if ($user === null) {
|
||||
$user = User::factory()->create([
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
]);
|
||||
} else {
|
||||
// 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
|
||||
// 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
|
||||
// 잠금 상태가 다음 실행으로 새지 않게 한다.
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($password),
|
||||
'status' => UserStatus::Active->value,
|
||||
'locked_until' => null,
|
||||
'failed_login_attempts' => 0,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($this->option('admin')) {
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
[
|
||||
'name' => ['ko' => '관리자', 'en' => 'Admin'],
|
||||
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
|
||||
'extension_type' => ExtensionOwnerType::Core,
|
||||
'extension_identifier' => 'core',
|
||||
'type' => 'admin',
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
}
|
||||
} else {
|
||||
// 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
|
||||
// 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
|
||||
$user->roles()->detach();
|
||||
}
|
||||
|
||||
$this->line($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* challenge 에 알려진 인증 코드의 해시를 심습니다.
|
||||
*
|
||||
* @param string $challengeId challenge UUID
|
||||
* @param string $code 심을 인증 코드
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
private function plantCode(string $challengeId, string $code): bool
|
||||
{
|
||||
$log = IdentityVerificationLog::find($challengeId);
|
||||
|
||||
if ($log === null) {
|
||||
$this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $log->metadata ?? [];
|
||||
$metadata['code_hash'] = Hash::make($code);
|
||||
|
||||
$log->metadata = $metadata;
|
||||
$log->save();
|
||||
|
||||
$this->line($code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
|
||||
*
|
||||
* 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
|
||||
* 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
|
||||
*/
|
||||
private function purgeTestUsers(): void
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
|
||||
}
|
||||
|
||||
/**
|
||||
* 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
|
||||
*
|
||||
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
|
||||
* chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
|
||||
*
|
||||
* @param int $hours 이 시간보다 오래된 계정만 정리
|
||||
*/
|
||||
private function pruneStaleTestUsers(int $hours): void
|
||||
{
|
||||
$threshold = now()->subHours($hours);
|
||||
$removed = 0;
|
||||
|
||||
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
|
||||
->where('created_at', '<', $threshold)
|
||||
->chunkById(100, function ($chunk) use (&$removed) {
|
||||
foreach ($chunk as $user) {
|
||||
$user->tokens()->delete();
|
||||
$user->roles()->detach();
|
||||
$user->delete();
|
||||
$removed++;
|
||||
}
|
||||
});
|
||||
|
||||
if ($removed > 0) {
|
||||
$this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClearPluginCacheCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
use InvalidatesLayoutCache;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -27,8 +33,7 @@ class ClearPluginCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginManager $pluginManager,
|
||||
private PluginRepositoryInterface $pluginRepository,
|
||||
private CacheInterface $cache,
|
||||
protected LayoutRepositoryInterface $layoutRepository,
|
||||
private ExtensionBundleService $bundleService
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -36,6 +41,12 @@ class ClearPluginCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
|
||||
* + 버전 포함 키)를 무효화한다.
|
||||
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
|
||||
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
|
||||
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -61,6 +72,11 @@ class ClearPluginCacheCommand extends Command
|
||||
|
||||
$clearedCount = $this->clearPluginCache($identifier);
|
||||
|
||||
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
|
||||
PluginManager::invalidatePluginStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
|
||||
'plugin' => $identifier,
|
||||
'count' => $clearedCount,
|
||||
@@ -69,25 +85,16 @@ class ClearPluginCacheCommand extends Command
|
||||
// 모든 플러그인 캐시 삭제
|
||||
$this->info(__('plugins.commands.cache_clear.clearing_all'));
|
||||
|
||||
// 전체 플러그인 캐시 키 삭제
|
||||
$cacheKeys = [
|
||||
'plugins.all',
|
||||
'plugins.active',
|
||||
'plugins.installed',
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 각 플러그인별 캐시 삭제
|
||||
$allPlugins = $this->pluginManager->getAllPlugins();
|
||||
foreach ($allPlugins as $pluginName => $plugin) {
|
||||
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
|
||||
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
|
||||
}
|
||||
|
||||
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
|
||||
PluginManager::invalidatePluginStatusCache();
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
|
||||
$clearedCount += $this->bundleService->clearBundles('plugin');
|
||||
|
||||
@@ -113,25 +120,18 @@ class ClearPluginCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* 특정 플러그인의 캐시를 삭제합니다.
|
||||
*
|
||||
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
|
||||
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
|
||||
*
|
||||
* @return int 캐시를 무효화한 레이아웃 수
|
||||
*/
|
||||
private function clearPluginCache(string $identifier): int
|
||||
{
|
||||
$clearedCount = 0;
|
||||
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
|
||||
|
||||
// 플러그인별 캐시 키 (메뉴 캐시 없음)
|
||||
$cacheKeys = [
|
||||
"plugin.config.{$identifier}",
|
||||
"plugin.info.{$identifier}",
|
||||
"plugin.routes.{$identifier}",
|
||||
"plugin.permissions.{$identifier}",
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $clearedCount;
|
||||
return $this->layoutRepository
|
||||
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
|
||||
*/
|
||||
class PruneActivityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ActivityLogService $service 활동 로그 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ActivityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\IdentityLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
|
||||
*
|
||||
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
|
||||
*/
|
||||
class PruneIdentityLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param IdentityLogService $service 본인인증 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(IdentityLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$purged = $service->purge($days);
|
||||
|
||||
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NotificationLogService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
|
||||
*/
|
||||
class PruneNotificationLogsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param NotificationLogService $service 알림 발송 이력 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(NotificationLogService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->prune($days);
|
||||
|
||||
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AttachmentService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 소유자 없는 고아 첨부파일 정리 커맨드
|
||||
*
|
||||
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
|
||||
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
|
||||
*
|
||||
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
|
||||
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
|
||||
*
|
||||
* @example php artisan attachments:prune-orphans --dry-run
|
||||
* @example php artisan attachments:prune-orphans --days=7 --limit=100
|
||||
*/
|
||||
class PruneOrphanAttachmentsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* 커맨드 이름 및 시그니처
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'attachments:prune-orphans
|
||||
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
|
||||
{--limit=500 : 한 번에 처리할 최대 건수}
|
||||
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
|
||||
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
|
||||
|
||||
/**
|
||||
* 커맨드 설명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
|
||||
|
||||
/**
|
||||
* @param AttachmentService $attachmentService 첨부파일 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커맨드 실행
|
||||
*
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
|
||||
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$days = $this->resolveRetentionDays();
|
||||
|
||||
if ($days < 1) {
|
||||
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
|
||||
$days,
|
||||
$result['scanned'],
|
||||
$result['deleted'],
|
||||
$result['failed'],
|
||||
));
|
||||
|
||||
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
|
||||
'days' => $days,
|
||||
'limit' => $limit,
|
||||
] + $result);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 정리 토글을 false 폴백으로 조회합니다.
|
||||
*
|
||||
* @return bool 자동 정리 활성 여부
|
||||
*/
|
||||
private function isCleanupEnabled(): bool
|
||||
{
|
||||
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
|
||||
*
|
||||
* @return int 보존기간(일)
|
||||
*/
|
||||
private function resolveRetentionDays(): int
|
||||
{
|
||||
$option = $this->option('days');
|
||||
|
||||
if ($option !== null && $option !== '') {
|
||||
return (int) $option;
|
||||
}
|
||||
|
||||
return (int) g7_core_settings('upload.orphan_retention_days', 30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ScheduleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
|
||||
*
|
||||
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneScheduleHistoryCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
|
||||
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param ScheduleService $service 스케줄 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ScheduleService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->pruneHistory($days);
|
||||
|
||||
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Seo\SeoCacheStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
|
||||
*
|
||||
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
|
||||
*/
|
||||
class PruneSeoCacheStatsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
|
||||
* 보존 기간 하한은 그 계층이 소유합니다.
|
||||
*
|
||||
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(SeoCacheStatsService $service): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$deleted = $service->cleanup($days);
|
||||
|
||||
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\StorageLeftoverPruneService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 스토리지 잔존물 정리 커맨드
|
||||
*
|
||||
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
|
||||
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
|
||||
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
|
||||
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
|
||||
*/
|
||||
class PruneStorageLeftoversCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'storage:prune-leftovers
|
||||
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
|
||||
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
|
||||
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
|
||||
|
||||
/**
|
||||
* 대상군 키 → 출력 라벨.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const GROUP_LABELS = [
|
||||
'staging' => '업데이트 스테이징 잔존',
|
||||
'temp' => '코어 임시 산출물',
|
||||
'vendor_bundle_staging' => 'vendor 번들 스테이징',
|
||||
'extension_backups' => '확장 백업',
|
||||
'core_backups' => '코어 백업',
|
||||
'legacy_browser_log' => '레거시 브라우저 로그',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(StorageLeftoverPruneService $service): int
|
||||
{
|
||||
$days = max(0, (int) $this->option('days'));
|
||||
$backupDays = max(0, (int) $this->option('backup-days'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$result = $service->prune($days, $backupDays, $dryRun);
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach (self::GROUP_LABELS as $key => $label) {
|
||||
$paths = $result[$key] ?? [];
|
||||
$count = count($paths);
|
||||
$total += $count;
|
||||
|
||||
$this->line("{$label}: {$count}건");
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($paths as $path) {
|
||||
$this->line(" - {$path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
|
||||
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
|
||||
|
||||
return self::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,
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
namespace App\Console\Commands\Template;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ClearTemplateCacheCommand extends Command
|
||||
{
|
||||
use ClearsTemplateCaches;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
@@ -28,7 +29,6 @@ class ClearTemplateCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private TemplateManager $templateManager,
|
||||
private CacheInterface $cache,
|
||||
private ExtensionBundleService $bundleService
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -67,6 +67,10 @@ class ClearTemplateCacheCommand extends Command
|
||||
|
||||
/**
|
||||
* 특정 템플릿의 캐시 삭제
|
||||
*
|
||||
* 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점
|
||||
* (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을
|
||||
* 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119).
|
||||
*/
|
||||
private function clearSingleTemplateCache(string $identifier): void
|
||||
{
|
||||
@@ -78,34 +82,12 @@ class ClearTemplateCacheCommand extends Command
|
||||
|
||||
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
|
||||
|
||||
$clearedCount = 0;
|
||||
// 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시
|
||||
$clearedCount = $this->templateManager->clearTemplateCache($identifier);
|
||||
|
||||
// 1. 레이아웃 캐시 삭제
|
||||
$templateRecord = Template::where('identifier', $identifier)->first();
|
||||
if ($templateRecord) {
|
||||
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
|
||||
foreach ($layouts as $layout) {
|
||||
$this->cache->forget("layout.{$identifier}.{$layout->name}");
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Routes 캐시 삭제
|
||||
$this->cache->forget("template.routes.{$identifier}");
|
||||
$clearedCount++;
|
||||
|
||||
// 3. 다국어 파일 캐시 삭제
|
||||
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
|
||||
foreach ($supportedLocales as $locale) {
|
||||
$this->cache->forget("template.language.{$identifier}.{$locale}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
// 4. 활성 템플릿 타입 캐시 삭제
|
||||
if ($templateRecord) {
|
||||
$this->cache->forget("templates.active.{$templateRecord->type}");
|
||||
$clearedCount++;
|
||||
}
|
||||
// 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump)
|
||||
TemplateManager::invalidateTemplateStatusCache();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
|
||||
'template' => $identifier,
|
||||
@@ -126,36 +108,17 @@ class ClearTemplateCacheCommand extends Command
|
||||
$this->info(__('templates.commands.cache_clear.clearing_all'));
|
||||
|
||||
$clearedCount = 0;
|
||||
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
|
||||
|
||||
// 모든 설치된 템플릿의 캐시 삭제
|
||||
$templates = Template::all();
|
||||
|
||||
foreach ($templates as $templateRecord) {
|
||||
// 1. 레이아웃 캐시 삭제
|
||||
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
|
||||
foreach ($layouts as $layout) {
|
||||
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
// 2. Routes 캐시 삭제
|
||||
$this->cache->forget("template.routes.{$templateRecord->identifier}");
|
||||
$clearedCount++;
|
||||
|
||||
// 3. 다국어 파일 캐시 삭제
|
||||
foreach ($supportedLocales as $locale) {
|
||||
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
|
||||
$clearedCount++;
|
||||
}
|
||||
// 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점)
|
||||
foreach (Template::all() as $templateRecord) {
|
||||
$clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier);
|
||||
}
|
||||
|
||||
// 4. 활성 템플릿 타입 캐시 삭제
|
||||
$this->cache->forget('templates.active.admin');
|
||||
$this->cache->forget('templates.active.user');
|
||||
$clearedCount += 2;
|
||||
// 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분
|
||||
TemplateManager::invalidateTemplateStatusCache();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
|
||||
// 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
|
||||
$clearedCount += $this->bundleService->clearBundles();
|
||||
|
||||
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Console\Commands\Traits;
|
||||
|
||||
use App\Console\Helpers\ConsoleConfirm;
|
||||
use Symfony\Component\Console\Input\StreamableInputInterface;
|
||||
|
||||
/**
|
||||
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
|
||||
@@ -12,6 +13,7 @@ use App\Console\Helpers\ConsoleConfirm;
|
||||
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
|
||||
*
|
||||
* - `--no-interaction` 시: $default 즉시 반환
|
||||
* - 응답을 받을 수 없는 실행(콘솔 없는 STDIN) 시: $default 즉시 반환
|
||||
* - empty 입력 시: $default 반환
|
||||
* - yes/y → true, no/n → false (대소문자 무시)
|
||||
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
|
||||
@@ -26,7 +28,7 @@ trait HasUnifiedConfirm
|
||||
*/
|
||||
protected function unifiedConfirm(string $question, bool $default = false): bool
|
||||
{
|
||||
if (! $this->input->isInteractive()) {
|
||||
if (! $this->input->isInteractive() || ! $this->canPromptForAnswer()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
@@ -47,4 +49,38 @@ trait HasUnifiedConfirm
|
||||
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 실행이 프롬프트 응답을 실제로 받을 수 있는지 판정합니다.
|
||||
*
|
||||
* Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로
|
||||
* 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
|
||||
* `--no-interaction` 없이 CI·스케줄러·에이전트처럼 콘솔이 없는 곳에서 부르면
|
||||
* `isInteractive()` 가 true 로 남고 `ask()` 가 오지 않을 응답을 무한히 기다린다.
|
||||
* 프롬프트 출력마저 버퍼에 갇혀 있어 겉으로는 "커맨드가 느리다" 로만 보인다.
|
||||
*
|
||||
* `stream_isatty()` 는 posix 확장 없이도 Windows 를 포함해 동작하므로 이를 쓴다.
|
||||
* 판정 재료가 없으면 true 를 돌려 기존 동작(질문)을 유지한다 — 물어보지 못해 멈추는
|
||||
* 것보다 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
|
||||
*
|
||||
* @return bool 응답을 받을 수 있으면 true
|
||||
*/
|
||||
protected function canPromptForAnswer(): bool
|
||||
{
|
||||
// 테스트는 QuestionHelper 를 대체하거나 입력을 주입하므로 STDIN 을 쓰지 않는다
|
||||
if ($this->laravel !== null && $this->laravel->runningUnitTests()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 입력 스트림이 주입된 실행(CommandTester::setInputs 등)도 STDIN 과 무관하다
|
||||
if ($this->input instanceof StreamableInputInterface && is_resource($this->input->getStream())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! \defined('STDIN') || ! is_resource(STDIN) || ! \function_exists('stream_isatty')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return stream_isatty(STDIN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Console\Commands\Vendor\Concerns;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundleResult;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorBundleResult;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
@@ -210,14 +210,17 @@ trait RunsVendorBundleAction
|
||||
$sourcePath = $this->resolveSourcePath($target);
|
||||
$outputPath = $this->resolveOutputPath($target);
|
||||
|
||||
if (! is_dir($sourcePath)) {
|
||||
$this->warn("- $label: 소스 경로 없음 ($sourcePath)");
|
||||
// 판정 기준 파일은 빌드(VendorBundler::build / isStale)와 같은 규칙 — 출력(_bundled) 우선,
|
||||
// 없으면 활성 디렉토리 폴백. 활성 디렉토리만 보면 미설치 확장을 "소스 경로 없음" 으로
|
||||
// 보고해 _bundled 의 stale 번들이 --check 를 통과한다.
|
||||
$composerJsonPath = $this->resolveCheckFile($sourcePath, $outputPath, 'composer.json');
|
||||
if ($composerJsonPath === null) {
|
||||
if (! is_dir($sourcePath) && ! is_dir($outputPath)) {
|
||||
$this->warn("- $label: 경로 없음 ($outputPath)");
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (! is_file($composerJsonPath)) {
|
||||
$this->line("- $label: SKIPPED (composer.json 없음)");
|
||||
|
||||
return false;
|
||||
@@ -240,6 +243,24 @@ trait RunsVendorBundleAction
|
||||
return $stale;
|
||||
}
|
||||
|
||||
/**
|
||||
* --check 가 읽을 composer 파일 경로 — 출력(_bundled) 우선, 없으면 활성 디렉토리 폴백.
|
||||
*
|
||||
* VendorBundler::resolveHashTarget() 과 같은 규칙. 판정 파일이 빌드·해시 기준과
|
||||
* 다르면 --check 결과가 실제 stale 여부와 어긋난다.
|
||||
*/
|
||||
private function resolveCheckFile(string $sourcePath, string $outputPath, string $filename): ?string
|
||||
{
|
||||
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
|
||||
if (is_file($outputFile)) {
|
||||
return $outputFile;
|
||||
}
|
||||
|
||||
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
|
||||
|
||||
return is_file($sourceFile) ? $sourceFile : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer.json 에 외부 패키지 의존성이 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,17 @@ interface CacheableExtensionInterface
|
||||
*/
|
||||
public function getStorage(): StorageInterface;
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 드라이버를 반환합니다.
|
||||
*
|
||||
* getStorageDiskFor($category) 가 결정한 디스크의 인스턴스를 반환하며,
|
||||
* 기본 구현은 getStorage() 와 동일한 디스크를 사용합니다.
|
||||
*
|
||||
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
|
||||
* @return StorageInterface 카테고리 디스크의 스토리지 인스턴스
|
||||
*/
|
||||
public function getStorageFor(string $category): StorageInterface;
|
||||
|
||||
/**
|
||||
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Contracts\Extension;
|
||||
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 확장(모듈/플러그인) 스토리지 인터페이스
|
||||
*
|
||||
@@ -50,12 +52,14 @@ interface StorageInterface
|
||||
/**
|
||||
* 파일의 공개 URL을 반환합니다.
|
||||
*
|
||||
* public disk인 경우 직접 URL을 반환하고,
|
||||
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
|
||||
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
|
||||
* 직접 URL 을 반환하고, 그 외에는 null 을 반환합니다 (별도 API 엔드포인트 사용).
|
||||
* 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 항상 통과하므로,
|
||||
* 확장이 URL 을 공급/수정/차단할 수 있습니다 (null 반환도 훅 발화 대상).
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @param string $path 파일 경로
|
||||
* @return string|null 파일 URL (private disk인 경우 null)
|
||||
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
|
||||
*/
|
||||
public function url(string $category, string $path): ?string;
|
||||
|
||||
@@ -110,9 +114,9 @@ interface StorageInterface
|
||||
* @param string $path 파일 경로
|
||||
* @param string $filename 다운로드 시 표시될 파일명
|
||||
* @param array $headers 추가 HTTP 헤더
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null)
|
||||
* @return StreamedResponse|null 파일 스트림 (파일이 없으면 null)
|
||||
*/
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
|
||||
|
||||
/**
|
||||
* 사용할 디스크를 변경한 새 인스턴스를 반환합니다.
|
||||
@@ -132,7 +136,7 @@ interface StorageInterface
|
||||
* @param string $path 파일 경로
|
||||
* @param string $filename 다운로드 시 표시될 파일명
|
||||
* @param array $headers 추가 HTTP 헤더
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
|
||||
* @return StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
|
||||
*/
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
|
||||
interface TemplateManagerInterface
|
||||
{
|
||||
/**
|
||||
* 모든 템플릿을 로드하고 초기화합니다.
|
||||
* 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
|
||||
*/
|
||||
public function loadTemplates(): void;
|
||||
|
||||
/**
|
||||
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
|
||||
*
|
||||
* "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
|
||||
* (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
|
||||
*/
|
||||
public function ensureLoaded(): void;
|
||||
|
||||
/**
|
||||
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
|
||||
*
|
||||
|
||||
@@ -65,4 +65,12 @@ interface ActivityLogRepositoryInterface
|
||||
* @return int 익명화된 row 수
|
||||
*/
|
||||
public function anonymizeUserId(int $userId): int;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 활동 로그를 삭제합니다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* 첨부파일 Repository 인터페이스
|
||||
@@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface
|
||||
*/
|
||||
public function findById(int $id): ?Attachment;
|
||||
|
||||
/**
|
||||
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
|
||||
*
|
||||
* 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로,
|
||||
* 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다.
|
||||
*
|
||||
* @param int $id 첨부파일 ID
|
||||
* @return Attachment|null 첨부파일 또는 null
|
||||
*/
|
||||
public function findByIdWithTrashed(int $id): ?Attachment;
|
||||
|
||||
/**
|
||||
* 여러 ID로 첨부파일 조회 (order 정렬)
|
||||
*
|
||||
@@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface
|
||||
* @param string $collection 컬렉션명
|
||||
*/
|
||||
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
|
||||
|
||||
/**
|
||||
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
|
||||
*
|
||||
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로,
|
||||
* 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다.
|
||||
*
|
||||
* 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고,
|
||||
* 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다.
|
||||
*
|
||||
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
|
||||
* @param int $limit 최대 조회 건수
|
||||
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
|
||||
* @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
|
||||
*/
|
||||
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
|
||||
}
|
||||
|
||||
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
|
||||
public function getActiveCoreLocales(): array;
|
||||
|
||||
/**
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
|
||||
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
|
||||
*
|
||||
* 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
|
||||
* 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
|
||||
*
|
||||
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
|
||||
* @return LengthAwarePaginator 페이지네이션 결과
|
||||
*/
|
||||
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
|
||||
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
|
||||
*
|
||||
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
|
||||
*/
|
||||
public function allForUpdateCheck(): Collection;
|
||||
|
||||
/**
|
||||
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
|
||||
|
||||
@@ -14,9 +14,10 @@ interface LayoutExtensionVersionRepositoryInterface
|
||||
* @param int $extensionId 레이아웃 확장 ID
|
||||
* @param array $oldContent 이전 콘텐츠
|
||||
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
|
||||
*/
|
||||
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
|
||||
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion;
|
||||
|
||||
/**
|
||||
* 특정 확장의 모든 버전 조회 (최신순)
|
||||
|
||||
@@ -14,9 +14,10 @@ interface LayoutVersionRepositoryInterface
|
||||
* @param int $layoutId 레이아웃 ID
|
||||
* @param array $oldContent 이전 콘텐츠
|
||||
* @param array|null $newContent 새 콘텐츠 (null이면 현재 레이아웃 content 사용)
|
||||
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
|
||||
* @return TemplateLayoutVersion 생성된 버전 모델
|
||||
*/
|
||||
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null): TemplateLayoutVersion;
|
||||
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutVersion;
|
||||
|
||||
/**
|
||||
* 특정 레이아웃의 모든 버전 조회 (최신순)
|
||||
|
||||
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
|
||||
*/
|
||||
public function findById(int $id): ?Menu;
|
||||
|
||||
/**
|
||||
* 여러 ID로 메뉴를 한 번에 조회합니다.
|
||||
*
|
||||
* 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
|
||||
* 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
|
||||
*
|
||||
* @param array<int, int> $ids 메뉴 ID 목록
|
||||
* @return Collection 메뉴 컬렉션
|
||||
*/
|
||||
public function findByIds(array $ids): Collection;
|
||||
|
||||
/**
|
||||
* 슬러그로 메뉴를 찾습니다.
|
||||
*
|
||||
@@ -48,6 +59,14 @@ interface MenuRepositoryInterface
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Menu;
|
||||
|
||||
/**
|
||||
* URL 로 메뉴를 찾습니다.
|
||||
*
|
||||
* @param string $url 메뉴 URL
|
||||
* @return Menu|null 찾은 메뉴 모델 또는 null
|
||||
*/
|
||||
public function findByUrl(string $url): ?Menu;
|
||||
|
||||
/**
|
||||
* 새로운 메뉴를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -40,6 +40,14 @@ interface NotificationLogRepositoryInterface
|
||||
*/
|
||||
public function bulkDelete(array $ids): int;
|
||||
|
||||
/**
|
||||
* 보존 기간이 지난 발송 이력을 삭제합니다.
|
||||
*
|
||||
* @param int $days 보존 기간 (일)
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function deleteOlderThan(int $days): int;
|
||||
|
||||
/**
|
||||
* 페이지네이션 목록 조회.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
|
||||
*/
|
||||
public function findByIdentifier(string $identifier): ?Permission;
|
||||
|
||||
/**
|
||||
* 여러 ID로 권한을 일괄 조회합니다.
|
||||
*
|
||||
* @param array<int> $ids 권한 ID 배열
|
||||
* @return Collection 권한 컬렉션 (ID 기준)
|
||||
*/
|
||||
public function getByIds(array $ids): Collection;
|
||||
|
||||
/**
|
||||
* 새로운 권한을 생성합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Auth;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드 발송 실패 예외 — 비밀번호 확인은 통과했으나 인증번호를 보낼 수 없어
|
||||
* 로그인을 완료할 수단이 없을 때 발생합니다.
|
||||
*
|
||||
* HTTP 503 Service Unavailable 로 매핑됩니다. 자격 증명은 올바르므로 401 로 뭉뚱그리면
|
||||
* 사용자는 비밀번호를 의심하며 같은 실패를 반복하게 되고, 운영자는 메일 설정이 깨진 사실을
|
||||
* 알 방법이 없습니다.
|
||||
*
|
||||
* 메시지 자리에는 번역문이 아니라 다국어 **키**를 보관합니다 (선례: AccountLockedException).
|
||||
*
|
||||
* @since 7.0.11
|
||||
*/
|
||||
class TwoFactorDeliveryFailedException extends HttpException
|
||||
{
|
||||
public function __construct(?string $message = null)
|
||||
{
|
||||
parent::__construct(503, $message ?? 'auth.two_factor_delivery_failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 보호된 역할(코어/확장 소유) 수정 시도 시 발생하는 예외
|
||||
*
|
||||
* 삭제 경로에만 있던 코어/확장 소유 역할 보호를 수정·상태변경 경로까지 대칭
|
||||
* 적용하기 위한 예외입니다. 비-슈퍼관리자 액터는 `admin` 등 코어/확장 소유
|
||||
* 역할의 권한·활성상태를 변경할 수 없습니다(KVE-2026-1919).
|
||||
*/
|
||||
class CannotModifyProtectedRoleException extends Exception
|
||||
{
|
||||
/**
|
||||
* 보호된 역할 수정 시도 시 예외를 생성합니다.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('exceptions.cannot_modify_protected_role'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 비-슈퍼관리자 액터가 슈퍼 관리자 계정/역할을 수정하려 할 때 발생하는 예외
|
||||
*
|
||||
* 삭제 경로에만 있던 슈퍼 관리자 보호 가드를 수정·상태변경·권한부여 경로까지
|
||||
* 대칭 적용하기 위한 등급 상한(rank ceiling) 위반 예외입니다.
|
||||
*/
|
||||
class CannotModifySuperAdminException extends Exception
|
||||
{
|
||||
/**
|
||||
* 슈퍼 관리자 수정 시도 시 예외를 생성합니다.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('exceptions.cannot_modify_super_admin'));
|
||||
}
|
||||
}
|
||||
@@ -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,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 권한 상승(escalation) 시도 시 발생하는 예외
|
||||
*
|
||||
* 비-슈퍼관리자 액터가 역할에 자신이 보유하지 않은 권한, 또는 자신의 범위(scope)보다
|
||||
* 넓은 범위의 권한을 부여하려 할 때 발생합니다(KVE-2026-1919 권한 상승 차단).
|
||||
*/
|
||||
class PermissionEscalationException extends Exception
|
||||
{
|
||||
/**
|
||||
* 권한 상승 시도 시 예외를 생성합니다.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('exceptions.cannot_grant_unheld_permission'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
/**
|
||||
* 부트스트랩 리소스 정적 게시(bake) 실패 예외.
|
||||
*
|
||||
* 사용자 대면 예외가 아니다 — `ExtensionStaticCacheService::publishVersion()` 의
|
||||
* 자체 catch 가 즉시 삼켜 Log::warning 진단으로만 남기고, 사이트는 API 폴백으로
|
||||
* 정상 동작한다. 따라서 메시지는 다국어 키가 아니라 운영자 로그용 진단 문자열이다.
|
||||
*/
|
||||
class StaticCachePublishException extends \RuntimeException {}
|
||||
@@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected array $storageServices = [];
|
||||
|
||||
/**
|
||||
* 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리).
|
||||
*
|
||||
* 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이
|
||||
* getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가
|
||||
* 서비스 코드 무수정으로 카테고리 디스크를 따릅니다.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
protected array $storageCategoryServices = [];
|
||||
|
||||
/**
|
||||
* CacheInterface 가 필요한 서비스 클래스 목록.
|
||||
*
|
||||
@@ -111,13 +122,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function registerStorageBindings(): void
|
||||
{
|
||||
if (empty($this->storageServices)) {
|
||||
return;
|
||||
if (! empty($this->storageServices)) {
|
||||
$this->app->when($this->storageServices)
|
||||
->needs(StorageInterface::class)
|
||||
->give(fn () => $this->resolveExtension()->getStorage());
|
||||
}
|
||||
|
||||
$this->app->when($this->storageServices)
|
||||
->needs(StorageInterface::class)
|
||||
->give(fn () => $this->resolveExtension()->getStorage());
|
||||
foreach ($this->storageCategoryServices as $service => $category) {
|
||||
$this->app->when($service)
|
||||
->needs(StorageInterface::class)
|
||||
->give(fn () => $this->resolveExtension()->getStorageFor($category));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Cache\ModuleCacheDriver;
|
||||
use App\Extension\Storage\ModuleStorageDriver;
|
||||
use App\Extension\Traits\ReportsLifecycleFailure;
|
||||
use App\Support\PublicAssetDisk;
|
||||
use Illuminate\Database\Seeder;
|
||||
use ReflectionClass;
|
||||
|
||||
@@ -21,6 +23,8 @@ use ReflectionClass;
|
||||
*/
|
||||
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
|
||||
{
|
||||
use ReportsLifecycleFailure;
|
||||
|
||||
/**
|
||||
* 모듈 디렉토리 경로 (캐시)
|
||||
*/
|
||||
@@ -927,6 +931,31 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
|
||||
*
|
||||
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
|
||||
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
|
||||
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
|
||||
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
|
||||
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
|
||||
*
|
||||
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
|
||||
*/
|
||||
public function getTrustedScriptHosts(): array
|
||||
{
|
||||
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
|
||||
|
||||
if (! is_array($hosts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
|
||||
fn ($host) => $host !== ''
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 확장 파일 경로 반환
|
||||
*
|
||||
@@ -1276,6 +1305,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 스토리지 드라이버 인스턴스 반환
|
||||
*
|
||||
@@ -1309,6 +1361,67 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return 'modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
|
||||
*
|
||||
* @var array<string, StorageInterface>
|
||||
*/
|
||||
private array $storageByDisk = [];
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 디스크 이름 반환
|
||||
*
|
||||
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
|
||||
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다.
|
||||
*
|
||||
* 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 —
|
||||
* 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
|
||||
* 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
|
||||
*
|
||||
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
|
||||
* @return string 디스크 이름
|
||||
*/
|
||||
public function getStorageDiskFor(string $category): string
|
||||
{
|
||||
return $this->getStorageDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 드라이버 인스턴스 반환
|
||||
*
|
||||
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
|
||||
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @return StorageInterface 스토리지 드라이버 인스턴스
|
||||
*/
|
||||
public function getStorageFor(string $category): StorageInterface
|
||||
{
|
||||
$disk = $this->getStorageDiskFor($category);
|
||||
|
||||
if (! isset($this->storageByDisk[$disk])) {
|
||||
$base = $this->getStorage();
|
||||
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
|
||||
}
|
||||
|
||||
return $this->storageByDisk[$disk];
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 자산 디스크 설정값을 해석합니다.
|
||||
*
|
||||
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
|
||||
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
|
||||
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
|
||||
*
|
||||
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
|
||||
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
|
||||
*/
|
||||
protected function resolvePublicAssetDisk(?string $override = null): ?string
|
||||
{
|
||||
return PublicAssetDisk::resolve($override);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 캐시 드라이버 인스턴스 반환
|
||||
*
|
||||
@@ -1344,26 +1457,30 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
/**
|
||||
* 카테고리별 스토리지 기본 경로 반환
|
||||
*
|
||||
* 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
|
||||
* 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
|
||||
*
|
||||
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
|
||||
* @return string 전체 파일 시스템 경로
|
||||
*/
|
||||
public function getStorageBasePath(string $category): string
|
||||
{
|
||||
return $this->getStorage()->getBasePath($category);
|
||||
return $this->getStorageFor($category)->getBasePath($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일의 공개 URL 반환
|
||||
*
|
||||
* public disk인 경우 직접 URL을 반환하고,
|
||||
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
|
||||
* 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
|
||||
* 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
|
||||
* 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @param string $path 파일 경로
|
||||
* @return string|null 파일 URL (private disk인 경우 null)
|
||||
* @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
|
||||
*/
|
||||
public function getStorageUrl(string $category, string $path): ?string
|
||||
{
|
||||
return $this->getStorage()->url($category, $path);
|
||||
return $this->getStorageFor($category)->url($category, $path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Cache\PluginCacheDriver;
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use App\Extension\Traits\ReportsLifecycleFailure;
|
||||
use App\Support\PublicAssetDisk;
|
||||
use Illuminate\Database\Seeder;
|
||||
use ReflectionClass;
|
||||
|
||||
@@ -19,10 +21,13 @@ use ReflectionClass;
|
||||
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
|
||||
* getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱됩니다.
|
||||
*
|
||||
* 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다.
|
||||
* 참고: 플러그인도 관리자 메뉴(getAdminMenus)를 선언할 수 있습니다. 설치·업데이트·활성화가
|
||||
* 공통으로 지나는 선언형 산출물 동기화에서 PluginManager 가 자동으로 반영합니다.
|
||||
*/
|
||||
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
|
||||
{
|
||||
use ReportsLifecycleFailure;
|
||||
|
||||
/**
|
||||
* 플러그인 디렉토리 경로 (캐시)
|
||||
*/
|
||||
@@ -771,6 +776,31 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
|
||||
*
|
||||
* plugin.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 플러그인이 레이아웃
|
||||
* `scripts[].src` 로 로드하는 외부 CDN 호스트(예: `cdn.ckeditor.com`)를 선언합니다.
|
||||
* 코어는 이 목록을 집계(AbstractPlugin/AbstractModule → TrustedScriptHosts)해 런타임
|
||||
* 스크립트 로더·저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된
|
||||
* 호스트만** 허용하도록 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
|
||||
*
|
||||
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.ckeditor.com'])
|
||||
*/
|
||||
public function getTrustedScriptHosts(): array
|
||||
{
|
||||
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
|
||||
|
||||
if (! is_array($hosts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
|
||||
fn ($host) => $host !== ''
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 확장 파일 경로 반환
|
||||
*
|
||||
@@ -1097,6 +1127,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
|
||||
*
|
||||
@@ -1203,6 +1256,67 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return 'plugins';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
|
||||
*
|
||||
* @var array<string, StorageInterface>
|
||||
*/
|
||||
private array $storageByDisk = [];
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 디스크 이름 반환
|
||||
*
|
||||
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
|
||||
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 플러그인이 오버라이드합니다.
|
||||
*
|
||||
* 주의: 오버라이드 구현은 'settings' 카테고리에서 플러그인 설정을 조회하면 안 됩니다 —
|
||||
* 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
|
||||
* 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
|
||||
*
|
||||
* @param string $category 카테고리 (settings, data, images, temp)
|
||||
* @return string 디스크 이름
|
||||
*/
|
||||
public function getStorageDiskFor(string $category): string
|
||||
{
|
||||
return $this->getStorageDisk();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 드라이버 인스턴스 반환
|
||||
*
|
||||
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
|
||||
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @return StorageInterface 스토리지 드라이버 인스턴스
|
||||
*/
|
||||
public function getStorageFor(string $category): StorageInterface
|
||||
{
|
||||
$disk = $this->getStorageDiskFor($category);
|
||||
|
||||
if (! isset($this->storageByDisk[$disk])) {
|
||||
$base = $this->getStorage();
|
||||
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
|
||||
}
|
||||
|
||||
return $this->storageByDisk[$disk];
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 자산 디스크 설정값을 해석합니다.
|
||||
*
|
||||
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
|
||||
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
|
||||
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
|
||||
*
|
||||
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
|
||||
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
|
||||
*/
|
||||
protected function resolvePublicAssetDisk(?string $override = null): ?string
|
||||
{
|
||||
return PublicAssetDisk::resolve($override);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 캐시 드라이버 인스턴스 반환
|
||||
*
|
||||
@@ -1238,26 +1352,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
/**
|
||||
* 카테고리별 스토리지 기본 경로 반환
|
||||
*
|
||||
* 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
|
||||
* 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
|
||||
*
|
||||
* @param string $category 카테고리 (settings, data, temp)
|
||||
* @return string 전체 파일 시스템 경로
|
||||
*/
|
||||
public function getStorageBasePath(string $category): string
|
||||
{
|
||||
return $this->getStorage()->getBasePath($category);
|
||||
return $this->getStorageFor($category)->getBasePath($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일의 공개 URL 반환
|
||||
*
|
||||
* public disk인 경우 직접 URL을 반환하고,
|
||||
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
|
||||
* 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
|
||||
* 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
|
||||
* 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @param string $path 파일 경로
|
||||
* @return string|null 파일 URL (private disk인 경우 null)
|
||||
* @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
|
||||
*/
|
||||
public function getStorageUrl(string $category, string $path): ?string
|
||||
{
|
||||
return $this->getStorage()->url($category, $path);
|
||||
return $this->getStorageFor($category)->url($category, $path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,4 +139,17 @@ final class InlineModuleExtensionAdapter implements CacheableExtensionInterface
|
||||
{
|
||||
return $this->storage ??= new ModuleStorageDriver($this->identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 드라이버를 반환합니다.
|
||||
*
|
||||
* fallback 어댑터는 모듈 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
|
||||
*/
|
||||
public function getStorageFor(string $category): StorageInterface
|
||||
{
|
||||
return $this->getStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,4 +130,17 @@ final class InlinePluginExtensionAdapter implements CacheableExtensionInterface
|
||||
{
|
||||
return $this->storage ??= new PluginStorageDriver($this->identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 드라이버를 반환합니다.
|
||||
*
|
||||
* fallback 어댑터는 플러그인 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
|
||||
*/
|
||||
public function getStorageFor(string $category): StorageInterface
|
||||
{
|
||||
return $this->getStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Extension;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Exceptions\CoreVersionMismatchException;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Support\CoreUpdateContext;
|
||||
use Composer\Semver\Semver;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -31,10 +32,11 @@ class CoreVersionChecker
|
||||
* 현재 설치된 그누보드7 코어 버전 반환
|
||||
*
|
||||
* 반환 우선순위:
|
||||
* 1. 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
|
||||
* 1. 코어 업데이트 프로세스 트리 안(`CoreUpdateContext::isInProgress()`)이면
|
||||
* 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
|
||||
* 2. `config('app.version')`
|
||||
*
|
||||
* 왜 env 를 우선 읽는가:
|
||||
* 왜 업데이트 트리 안에서만 env 를 우선 읽는가:
|
||||
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
|
||||
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
|
||||
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
|
||||
@@ -44,23 +46,28 @@ class CoreVersionChecker
|
||||
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
|
||||
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
|
||||
*
|
||||
* env 를 우선 읽는 본 구현은 config cache 유무와 무관하게 spawn 이 전달한 버전을 그대로
|
||||
* 신뢰한다. 일반 요청 경로에서는 `APP_VERSION` 이 `.env` 에 기록된 값 그대로이므로
|
||||
* 동작에 차이가 없다.
|
||||
* 왜 트리 밖에서는 env 를 읽지 않는가:
|
||||
* `php artisan serve` · 큐 워커 · Horizon 처럼 오래 사는 프로세스는 기동 시점의
|
||||
* `APP_VERSION` 을 프로세스 환경 테이블에 물고 있다. 업데이트가 끝나 `.env` 와 config 가
|
||||
* 새 버전이 되어도 그 프로세스만 옛 버전으로 판정해, 새 코어를 요구하는 확장을
|
||||
* `incompatible_core` 로 자동 비활성화한다 (관리자 템플릿이 꺼지면 복구 UI 에도 도달할 수
|
||||
* 없다 — 2026-09-07 실측). 업데이트 트리 밖에서는 config 가 유일한 근거다.
|
||||
*
|
||||
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
|
||||
* config cache 우회를 요구하기 때문이다.
|
||||
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 업데이트 중
|
||||
* 버전 판정이 config cache 우회를 요구하기 때문이다.
|
||||
*
|
||||
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
|
||||
*/
|
||||
public static function getCoreVersion(): string
|
||||
{
|
||||
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
|
||||
if (is_string($envVersion) && $envVersion !== '') {
|
||||
return $envVersion;
|
||||
if (CoreUpdateContext::isInProgress()) {
|
||||
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
|
||||
if (is_string($envVersion) && $envVersion !== '') {
|
||||
return $envVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return config('app.version');
|
||||
return (string) config('app.version');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,8 +74,12 @@ class ExtensionManager
|
||||
*
|
||||
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
|
||||
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
|
||||
*
|
||||
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
|
||||
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
|
||||
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
|
||||
*/
|
||||
protected function regenerateHookCache(): void
|
||||
public function regenerateHookCache(): void
|
||||
{
|
||||
try {
|
||||
$moduleManager = app(ModuleManager::class);
|
||||
|
||||
@@ -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 디렉토리에서 확장 메타데이터를 로드합니다.
|
||||
*
|
||||
@@ -141,16 +156,30 @@ class ExtensionPendingHelper
|
||||
// Windows에서 deleteDirectory 직후 같은 이름으로 rename이 실패하는
|
||||
// 타이밍 이슈를 회피하기 위해 rename→rename→delete 패턴을 사용합니다.
|
||||
// 임시 디렉토리를 _pending/ 하위에 생성하여 오토로드 오염 방지
|
||||
//
|
||||
// Windows 잠금 대응: 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의
|
||||
// 디렉토리 핸들, IDE/Node 프로세스가 열어 둔 파일 등)이 하나라도 있으면
|
||||
// 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없으므로(디렉토리 핸들은
|
||||
// Restart Manager 로 감지 불가), rename 이 차단되면 파일 단위 연산으로
|
||||
// 폴백한다 — 파일 생성/덮어쓰기/읽기는 디렉토리 핸들 잠금의 영향을 받지
|
||||
// 않아 어떤 프로세스도 종료하지 않고 교체를 완료할 수 있다.
|
||||
$basePath = dirname($targetPath);
|
||||
$pendingPath = $basePath.DIRECTORY_SEPARATOR.'_pending';
|
||||
File::ensureDirectoryExists($pendingPath, 0775);
|
||||
|
||||
$identifier = basename($targetPath);
|
||||
|
||||
// 이전 실패 실행이 남긴 교체용 임시 디렉토리 정리 (best-effort)
|
||||
self::cleanupSwapLeftovers($pendingPath, $identifier);
|
||||
|
||||
$tempPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_updating_'.uniqid();
|
||||
$oldPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_old_'.uniqid();
|
||||
|
||||
try {
|
||||
self::copyDirectoryWithProgress($sourcePath, $tempPath, $sourcePath, $onProgress);
|
||||
|
||||
// 운영자 소유 디렉토리를 새 트리로 옮겨 심는다 (교체 전에 해 둬야 원본이 살아 있다)
|
||||
self::carryOverPreservedDirectories($targetPath, $tempPath, $onProgress);
|
||||
} catch (\Exception $e) {
|
||||
// 복사 실패 시 임시 디렉토리 정리 후 예외 전파
|
||||
if (File::isDirectory($tempPath)) {
|
||||
@@ -161,40 +190,77 @@ class ExtensionPendingHelper
|
||||
|
||||
// 기존 → _old 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
|
||||
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
|
||||
// 파일 잠금 감지 및 해제 시도
|
||||
if (self::tryReleaseLocks($targetPath, $onProgress)) {
|
||||
// 잠금 해제 후 재시도
|
||||
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
|
||||
File::deleteDirectory($tempPath);
|
||||
throw new \RuntimeException(
|
||||
"Failed to move existing directory: {$targetPath} → {$oldPath}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
File::deleteDirectory($tempPath);
|
||||
throw new \RuntimeException(
|
||||
"Failed to move existing directory: {$targetPath} → {$oldPath}"
|
||||
);
|
||||
// 활성 디렉토리 rename 차단 (하위 트리에 열린 핸들 존재)
|
||||
// → 파일 단위 제자리 동기화로 폴백. 덮어쓰기는 잠금의 영향을 받지 않는다.
|
||||
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 제자리 교체로 전환합니다...');
|
||||
Log::info('확장 교체: 활성 디렉토리 rename 차단 — 제자리 동기화 폴백', [
|
||||
'target' => $targetPath,
|
||||
]);
|
||||
|
||||
try {
|
||||
self::syncDirectoryContents($tempPath, $targetPath, $onProgress);
|
||||
} finally {
|
||||
self::bestEffortDeleteDirectory($tempPath);
|
||||
}
|
||||
|
||||
self::refreshRuntimeCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 임시 → 활성 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
|
||||
if (! self::retryMoveDirectory($tempPath, $targetPath)) {
|
||||
// 롤백: _old를 원래 위치로 복원
|
||||
File::moveDirectory($oldPath, $targetPath);
|
||||
throw new \RuntimeException(
|
||||
"Failed to move directory: {$tempPath} → {$targetPath}"
|
||||
);
|
||||
// 방금 복사한 스테이징 트리를 파일 워처가 이미 열어 rename 이 차단된 경우
|
||||
// → 파일 단위 복사로 폴백. 소스에는 읽기 접근만 필요해 잠금과 무관하게 성공한다.
|
||||
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 복사로 전환합니다...');
|
||||
Log::info('확장 교체: 스테이징 rename 차단 — 파일 단위 복사 폴백', [
|
||||
'staging' => $tempPath,
|
||||
'target' => $targetPath,
|
||||
]);
|
||||
|
||||
try {
|
||||
self::copyDirectoryWithProgress($tempPath, $targetPath, $tempPath, $onProgress);
|
||||
} catch (\Exception $e) {
|
||||
// 복사 실패: 부분 복사본 제거 후 _old 를 원래 위치로 복원
|
||||
self::bestEffortDeleteDirectory($targetPath);
|
||||
if (! self::retryMoveDirectory($oldPath, $targetPath)) {
|
||||
try {
|
||||
self::syncDirectoryContents($oldPath, $targetPath, $onProgress);
|
||||
} catch (\Throwable $restoreError) {
|
||||
Log::error('확장 교체 롤백 실패 — 백업 복원이 필요합니다', [
|
||||
'target' => $targetPath,
|
||||
'old' => $oldPath,
|
||||
'error' => $restoreError->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException(
|
||||
"Failed to move directory: {$tempPath} → {$targetPath}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
|
||||
self::bestEffortDeleteDirectory($tempPath);
|
||||
}
|
||||
|
||||
// 교체 완료 후 _old 삭제 (실패해도 무해)
|
||||
File::deleteDirectory($oldPath);
|
||||
// 교체 완료 후 _old 삭제 (실패해도 무해 — 다음 교체 시작 시 잔존물 정리가 재시도)
|
||||
self::bestEffortDeleteDirectory($oldPath);
|
||||
|
||||
// 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
|
||||
// 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
|
||||
// PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
|
||||
// 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
|
||||
// clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
|
||||
self::refreshRuntimeCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 교체 완료 후 PHP 런타임 캐시를 갱신합니다.
|
||||
*
|
||||
* 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
|
||||
* 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
|
||||
* PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
|
||||
* 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
|
||||
* clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
|
||||
*/
|
||||
private static function refreshRuntimeCaches(): void
|
||||
{
|
||||
clearstatcache(true);
|
||||
|
||||
// opcache 가 활성화된 프로덕션에서는 활성 디렉토리 하위의 이전 컴파일 바이트코드가
|
||||
@@ -237,41 +303,6 @@ class ExtensionPendingHelper
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 잠금을 감지하고 해제를 시도합니다.
|
||||
*
|
||||
* Windows에서 다른 프로세스(IDE 등)가 파일 핸들을 보유하고 있을 때,
|
||||
* 해당 프로세스를 감지하고 종료하여 디렉토리 이동이 가능하도록 합니다.
|
||||
* 프로그레스바와 별개로 STDERR에 직접 출력하여 메시지가 덮어씌워지지 않습니다.
|
||||
*
|
||||
* @param string $directoryPath 잠금 해제할 디렉토리 경로
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @return bool 잠금 해제 성공 여부
|
||||
*/
|
||||
private static function tryReleaseLocks(string $directoryPath, ?\Closure $onProgress = null): bool
|
||||
{
|
||||
if (! FileHandleHelper::isWindows()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 프로그레스바에 의해 메시지가 덮어씌워지지 않도록 STDERR에 직접 출력
|
||||
$stderr = fopen('php://stderr', 'w');
|
||||
$outputCallback = function (string $message) use ($stderr) {
|
||||
if ($stderr) {
|
||||
fwrite($stderr, $message.PHP_EOL);
|
||||
}
|
||||
Log::info($message, ['context' => 'file_lock_release']);
|
||||
};
|
||||
|
||||
$result = FileHandleHelper::releaseLocks($directoryPath, $outputCallback);
|
||||
|
||||
if ($stderr) {
|
||||
fclose($stderr);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리 이동을 재시도합니다 (Windows NTFS 타이밍 이슈 대응).
|
||||
*
|
||||
@@ -313,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) {
|
||||
@@ -333,19 +369,409 @@ class ExtensionPendingHelper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 콘텐츠를 활성 디렉토리에 파일 단위로 제자리 동기화합니다.
|
||||
*
|
||||
* 디렉토리 rename 이 외부 프로세스의 핸들 잠금으로 차단될 때 사용하는 폴백입니다.
|
||||
* 디렉토리 inode 를 건드리지 않고 파일 생성/덮어쓰기/삭제만 수행하므로
|
||||
* 디렉토리 핸들 잠금(파일 워처 등)의 영향을 받지 않습니다.
|
||||
*
|
||||
* (1) 새 버전 파일 전체를 덮어쓰기 → (2) 새 버전에 없는 잔존 파일 제거.
|
||||
* (1) 실패는 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하게 하고,
|
||||
* (2) 실패는 로그만 남기고 진행합니다 (업데이트 전체 실패보다 잔존이 낫다).
|
||||
*
|
||||
* @param string $source 새 버전 콘텐츠 디렉토리
|
||||
* @param string $dest 활성 디렉토리
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
*
|
||||
* @throws \RuntimeException 새 버전 파일을 심지 못했을 때
|
||||
*/
|
||||
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);
|
||||
|
||||
if (! empty($failed)) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to replace files locked by another process: '
|
||||
.implode(', ', array_slice($failed, 0, 5))
|
||||
.(count($failed) > 5 ? ' (+'.(count($failed) - 5).' more)' : '')
|
||||
);
|
||||
}
|
||||
|
||||
$staleFailures = [];
|
||||
self::removeStaleEntries($source, $dest, $staleFailures, true);
|
||||
|
||||
if (! empty($staleFailures)) {
|
||||
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
|
||||
'dest' => $dest,
|
||||
'failed' => $staleFailures,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자 소유 디렉토리를 기존 활성 디렉토리에서 새 트리로 옮겨 심습니다.
|
||||
*
|
||||
* 새 배포본에 같은 이름의 디렉토리가 있으면 **그것을 치우고** 기존 것을 심는다 —
|
||||
* `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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
|
||||
*
|
||||
* @param string $source 소스 디렉토리
|
||||
* @param string $dest 대상 디렉토리
|
||||
* @param string $basePath 상대 경로 계산 기준
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @param array $failed 교체 실패한 상대 경로 수집 (참조)
|
||||
*/
|
||||
private static function overlayDirectory(
|
||||
string $source,
|
||||
string $dest,
|
||||
string $basePath,
|
||||
?\Closure $onProgress,
|
||||
array &$failed
|
||||
): void {
|
||||
File::ensureDirectoryExists($dest, 0775);
|
||||
// 제자리 동기화 폴백 경로도 동일 방어 — rename 경로만 고치면 파일 잠금으로
|
||||
// 이 경로로 떨어진 교체에서만 소유권이 조용히 어긋난다.
|
||||
FilePermissionHelper::inheritOwnershipFromParent($dest);
|
||||
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir() && in_array($item->getBasename(), self::EXCLUDED_DIRECTORIES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $dest.DIRECTORY_SEPARATOR.$item->getBasename();
|
||||
$relativePath = ltrim(str_replace($basePath, '', $item->getPathname()), '/\\');
|
||||
|
||||
if ($item->isDir()) {
|
||||
if (is_file($target) && ! self::deleteFileWithFallback($target)) {
|
||||
// 파일 → 디렉토리로 바뀐 경로인데 기존 파일을 치우지 못함
|
||||
$failed[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
self::overlayDirectory($item->getPathname(), $target, $basePath, $onProgress, $failed);
|
||||
} else {
|
||||
if (is_dir($target)) {
|
||||
// 디렉토리 → 파일로 바뀐 경로
|
||||
File::deleteDirectory($target);
|
||||
}
|
||||
$onProgress?->__invoke(null, $relativePath);
|
||||
if (! self::replaceFile($item->getPathname(), $target)) {
|
||||
$failed[] = $relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 하나를 덮어씁니다 (잠금 대응 단계적 폴백).
|
||||
*
|
||||
* Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용하므로,
|
||||
* 다른 프로세스가 열어 둔 파일이라도 내용 덮어쓰기는 대부분 성공합니다.
|
||||
* 덮어쓰기가 막힌 경우에만 삭제 후 재생성 → 옆으로 치우기(rename) 순으로
|
||||
* 시도합니다. FilePermissionHelper::copyFile() 은 복사 실패를 보고하지 않으므로
|
||||
* (File::copy 반환값 무시) 이 경로에서는 네이티브 copy 반환값으로 판정합니다.
|
||||
*
|
||||
* @param string $source 소스 파일
|
||||
* @param string $destination 대상 파일
|
||||
* @return bool 교체 성공 여부
|
||||
*/
|
||||
private static function replaceFile(string $source, string $destination): bool
|
||||
{
|
||||
File::ensureDirectoryExists(dirname($destination));
|
||||
|
||||
$isExisting = is_file($destination);
|
||||
$existingPerms = $isExisting ? @fileperms($destination) : null;
|
||||
$existingOwner = $isExisting ? @fileowner($destination) : null;
|
||||
$existingGroup = $isExisting ? @filegroup($destination) : null;
|
||||
|
||||
$restoreMeta = function () use ($destination, $isExisting, $existingPerms, $existingOwner, $existingGroup) {
|
||||
if (! $isExisting) {
|
||||
// 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (sudo 실행 시 root 소유 방지)
|
||||
FilePermissionHelper::inheritOwnershipFromParent($destination);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($existingPerms !== false && $existingPerms !== null) {
|
||||
@chmod($destination, $existingPerms);
|
||||
}
|
||||
if ($existingOwner !== false && $existingOwner !== null && function_exists('chown')) {
|
||||
@chown($destination, $existingOwner);
|
||||
}
|
||||
if ($existingGroup !== false && $existingGroup !== null && function_exists('chgrp')) {
|
||||
@chgrp($destination, $existingGroup);
|
||||
}
|
||||
};
|
||||
|
||||
// 1차: 그대로 덮어쓰기
|
||||
if (@copy($source, $destination)) {
|
||||
$restoreMeta();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2차: 읽기 전용 속성 해제 후 재시도
|
||||
@chmod($destination, 0666);
|
||||
if (@copy($source, $destination)) {
|
||||
$restoreMeta();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3차: 삭제 후 재생성
|
||||
if (@unlink($destination) && @copy($source, $destination)) {
|
||||
$restoreMeta();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4차: 잠긴 파일을 옆으로 치우고(rename 은 덮어쓰기와 별개 권한) 새 파일 생성.
|
||||
// 옆으로 치운 파일은 즉시 삭제를 시도하고, 실패해도 새 버전에 없는 파일이므로
|
||||
// 다음 제자리 동기화의 잔존 파일 제거가 다시 삭제를 시도한다.
|
||||
$aside = $destination.'.g7stale_'.uniqid();
|
||||
if (@rename($destination, $aside)) {
|
||||
@unlink($aside);
|
||||
if (@copy($source, $destination)) {
|
||||
$restoreMeta();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 하나를 삭제합니다 (잠금 대응 단계적 폴백).
|
||||
*
|
||||
* @param string $path 삭제할 파일 경로
|
||||
* @return bool 삭제(또는 옆으로 치우기) 성공 여부
|
||||
*/
|
||||
private static function deleteFileWithFallback(string $path): bool
|
||||
{
|
||||
if (@unlink($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@chmod($path, 0666);
|
||||
if (@unlink($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 삭제가 막힌 경우 옆으로 치워 원래 이름을 비운다
|
||||
$aside = $path.'.g7stale_'.uniqid();
|
||||
if (@rename($path, $aside)) {
|
||||
@unlink($aside);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 디렉토리에서 소스에 존재하지 않는 파일/디렉토리를 제거합니다.
|
||||
*
|
||||
* @param string $source 새 버전 콘텐츠 디렉토리
|
||||
* @param string $dest 활성 디렉토리
|
||||
* @param array $failures 삭제 실패 경로 수집 (참조)
|
||||
*/
|
||||
private static function removeStaleEntries(string $source, string $dest, array &$failures, bool $isRoot = false): void
|
||||
{
|
||||
if (! is_dir($dest)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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()) {
|
||||
if (is_dir($counterpart)) {
|
||||
self::removeStaleEntries($counterpart, $item->getPathname(), $failures);
|
||||
} else {
|
||||
File::deleteDirectory($item->getPathname());
|
||||
if (is_dir($item->getPathname())) {
|
||||
$failures[] = $item->getPathname();
|
||||
}
|
||||
}
|
||||
} elseif (! is_file($counterpart)) {
|
||||
if (! self::deleteFileWithFallback($item->getPathname())) {
|
||||
$failures[] = $item->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 실패 실행이 남긴 교체용 임시 디렉토리를 정리합니다 (best-effort).
|
||||
*
|
||||
* 잠금으로 인해 삭제하지 못한 `_updating_`/`_old_` 디렉토리는 다음 교체 시작
|
||||
* 시점(잠금이 풀린 뒤일 가능성이 높음)에 다시 삭제를 시도합니다.
|
||||
* 호출자가 소유한 스테이징 디렉토리(`{identifier}_{Ymd_His}`)는 건드리지 않습니다.
|
||||
*
|
||||
* @param string $pendingPath _pending 디렉토리 경로
|
||||
* @param string $identifier 확장 식별자
|
||||
*/
|
||||
private static function cleanupSwapLeftovers(string $pendingPath, string $identifier): void
|
||||
{
|
||||
foreach (['_updating_', '_old_'] as $marker) {
|
||||
$pattern = $pendingPath.DIRECTORY_SEPARATOR.$identifier.$marker.'*';
|
||||
foreach (glob($pattern) ?: [] as $leftover) {
|
||||
if (is_dir($leftover)) {
|
||||
self::bestEffortDeleteDirectory($leftover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리를 삭제하되, 실패해도 예외를 던지지 않습니다.
|
||||
*
|
||||
* 잠금으로 삭제하지 못한 디렉토리는 다음 교체 시작 시 잔존물 정리가 재시도합니다.
|
||||
*
|
||||
* @param string $path 삭제할 디렉토리 경로
|
||||
*/
|
||||
private static function bestEffortDeleteDirectory(string $path): void
|
||||
{
|
||||
if (! File::isDirectory($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File::deleteDirectory($path);
|
||||
|
||||
if (File::isDirectory($path)) {
|
||||
Log::info('디렉토리를 완전히 삭제하지 못했습니다 (다음 교체 시 잔존물 정리에서 재시도)', [
|
||||
'path' => $path,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 디렉토리를 삭제합니다.
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,6 +779,7 @@ class ExtensionPendingHelper
|
||||
*
|
||||
* @param string $basePath 확장 타입의 기본 경로
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return bool _pending 존재 여부
|
||||
*/
|
||||
public static function isPending(string $basePath, string $identifier): bool
|
||||
{
|
||||
@@ -364,6 +791,7 @@ class ExtensionPendingHelper
|
||||
*
|
||||
* @param string $basePath 확장 타입의 기본 경로
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return bool _bundled 존재 여부
|
||||
*/
|
||||
public static function isBundled(string $basePath, string $identifier): bool
|
||||
{
|
||||
@@ -375,6 +803,7 @@ class ExtensionPendingHelper
|
||||
*
|
||||
* @param string $basePath 확장 타입의 기본 경로
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return string _pending 절대 경로
|
||||
*/
|
||||
public static function getPendingPath(string $basePath, string $identifier): string
|
||||
{
|
||||
@@ -386,12 +815,38 @@ class ExtensionPendingHelper
|
||||
*
|
||||
* @param string $basePath 확장 타입의 기본 경로
|
||||
* @param string $identifier 확장 식별자
|
||||
* @return string _bundled 절대 경로
|
||||
*/
|
||||
public static function getBundledPath(string $basePath, string $identifier): string
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+273
-161
@@ -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;
|
||||
@@ -311,6 +314,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
||||
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
|
||||
* @param bool $force 강제 설치 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return bool 설치 성공 여부
|
||||
*
|
||||
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
|
||||
@@ -320,7 +324,10 @@ class ModuleManager implements ModuleManagerInterface
|
||||
?\Closure $onProgress = null,
|
||||
VendorMode $vendorMode = VendorMode::Auto,
|
||||
bool $force = false,
|
||||
?string &$failureReason = null,
|
||||
): bool {
|
||||
$failureReason = null;
|
||||
|
||||
// identifier 형식 검증 (내부 호출 방어)
|
||||
ExtensionManager::validateIdentifierFormat($moduleName);
|
||||
|
||||
@@ -368,165 +375,193 @@ 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);
|
||||
}
|
||||
// 설치 시점에는 autoload-extensions.php 가 아직 갱신되지 않았다. module.php 의
|
||||
// getConfigValues()/getSettingsSchema() 등이 자기 src/ 클래스를 호출하면 그 클래스가
|
||||
// 해석되지 않아 "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본의
|
||||
// 매핑이 이미 있어 재현되지 않는다). 시더 실행 직전이 아니라 진입 파일을 로드하기
|
||||
// 전에 그 확장의 PSR-4 매핑을 등록한다.
|
||||
ExtensionManager::registerExtensionAutoloadPaths('modules', $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');
|
||||
|
||||
// 모듈 설치 실행
|
||||
$result = $module->install();
|
||||
|
||||
if (! $result) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,9 +575,15 @@ class ModuleManager implements ModuleManagerInterface
|
||||
{
|
||||
$module = $this->getModule($moduleName);
|
||||
if (! $module) {
|
||||
return ['success' => false, 'layouts_registered' => 0];
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_registered' => 0,
|
||||
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
|
||||
];
|
||||
}
|
||||
|
||||
$module->clearLifecycleFailureReason();
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
|
||||
if ($record) {
|
||||
@@ -628,6 +669,13 @@ class ModuleManager implements ModuleManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
||||
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
|
||||
// 새 애플리케이션을 부팅해 "캐시된" 활성 모듈 목록을 읽는다. 여기서 비우지 않으면
|
||||
// 방금 활성으로 바뀐 이 모듈이 목록에서 빠진 채 라우트가 박제되고,
|
||||
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// soft deleted된 모듈 레이아웃 복원 (재활성화 시)
|
||||
$this->restoreModuleLayouts($module->getIdentifier());
|
||||
|
||||
@@ -650,9 +698,6 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
|
||||
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
@@ -667,7 +712,18 @@ class ModuleManager implements ModuleManagerInterface
|
||||
HookManager::doAction('core.modules.activated', $moduleName);
|
||||
}
|
||||
|
||||
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
|
||||
if (! $result) {
|
||||
// 모듈이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
|
||||
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
|
||||
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_registered' => $layoutsRegistered,
|
||||
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -714,9 +770,15 @@ class ModuleManager implements ModuleManagerInterface
|
||||
): array {
|
||||
$module = $this->getModule($moduleName);
|
||||
if (! $module) {
|
||||
return ['success' => false, 'layouts_deleted' => 0];
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_deleted' => 0,
|
||||
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
|
||||
];
|
||||
}
|
||||
|
||||
$module->clearLifecycleFailureReason();
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
|
||||
if ($record) {
|
||||
@@ -777,6 +839,12 @@ class ModuleManager implements ModuleManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
||||
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 모듈 목록을 읽으므로,
|
||||
// 여기서 비우지 않으면 방금 비활성으로 바꾼 모듈의 라우트가 그대로 박제되어
|
||||
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 모듈 레이아웃 soft delete
|
||||
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
|
||||
|
||||
@@ -796,9 +864,6 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 모듈 자체 캐시 전체 정리
|
||||
$this->flushModuleCache($module);
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서
|
||||
// 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존)
|
||||
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
|
||||
@@ -811,7 +876,15 @@ class ModuleManager implements ModuleManagerInterface
|
||||
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
|
||||
}
|
||||
|
||||
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
|
||||
if (! $result) {
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_deleted' => $layoutsDeleted,
|
||||
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'layouts_deleted' => $layoutsDeleted];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -859,12 +932,24 @@ class ModuleManager implements ModuleManagerInterface
|
||||
* @param string $moduleName 제거할 모듈명
|
||||
* @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 모듈을 찾을 수 없을 때
|
||||
*/
|
||||
public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool
|
||||
{
|
||||
public function uninstallModule(
|
||||
string $moduleName,
|
||||
bool $deleteData = false,
|
||||
?\Closure $onProgress = null,
|
||||
?string &$failureReason = null,
|
||||
?array &$preservedBackups = null,
|
||||
): bool {
|
||||
$failureReason = null;
|
||||
$preservedBackups = [];
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
|
||||
if ($existingRecord) {
|
||||
@@ -897,8 +982,13 @@ class ModuleManager implements ModuleManagerInterface
|
||||
DB::beginTransaction();
|
||||
|
||||
// 모듈 제거 실행
|
||||
$module->clearLifecycleFailureReason();
|
||||
$result = $module->uninstall();
|
||||
|
||||
if (! $result) {
|
||||
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
|
||||
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
|
||||
@@ -960,6 +1050,12 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 오토로드 병합 실행 (트랜잭션 외부에서 실행)
|
||||
if ($result) {
|
||||
// 모듈 상태 캐시 무효화 — DB 에서 모듈 행을 지운 직후(커밋 직후)에 둔다.
|
||||
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
|
||||
// 캐시된 활성 모듈 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 모듈이
|
||||
// 목록에 남은 채로 라우트·훅이 박제된다.
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
|
||||
$this->extensionManager->updateComposerAutoload();
|
||||
|
||||
@@ -977,15 +1073,15 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 모듈 자체 캐시 전체 정리
|
||||
$this->flushModuleCache($module);
|
||||
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
|
||||
$onProgress?->__invoke('files', '파일 삭제 중...');
|
||||
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier());
|
||||
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
|
||||
$this->modulesPath,
|
||||
$module->getIdentifier()
|
||||
);
|
||||
|
||||
// 메모리에서 모듈 제거
|
||||
unset($this->modules[$module->getIdentifier()]);
|
||||
@@ -1704,7 +1800,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) {
|
||||
@@ -1741,9 +1837,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);
|
||||
}
|
||||
|
||||
// 카테고리별로 설정 파일 생성
|
||||
@@ -1758,6 +1856,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';
|
||||
}
|
||||
|
||||
@@ -1828,7 +1927,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('삭제할 모듈 스토리지 디렉토리가 없습니다.', [
|
||||
@@ -1913,7 +2012,7 @@ class ModuleManager implements ModuleManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/modules/'.$identifier)
|
||||
ExtensionStoragePath::module($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
@@ -4052,7 +4151,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(
|
||||
@@ -4495,6 +4594,13 @@ class ModuleManager implements ModuleManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 모듈 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
|
||||
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
|
||||
// updateComposerAutoload() 가 DB 를 재조회해 이 모듈을 비활성으로 판정하고
|
||||
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
|
||||
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
|
||||
// refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
|
||||
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
|
||||
@@ -4518,7 +4624,13 @@ class ModuleManager implements ModuleManagerInterface
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
|
||||
// 그 시점 이 모듈이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
|
||||
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
|
||||
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
|
||||
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
|
||||
$this->extensionManager->regenerateHookCache();
|
||||
|
||||
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
HookManager::doAction('core.modules.updated', $identifier);
|
||||
|
||||
+320
-161
@@ -21,10 +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;
|
||||
@@ -43,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;
|
||||
@@ -295,6 +299,7 @@ class PluginManager implements PluginManagerInterface
|
||||
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
||||
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
|
||||
* @param bool $force 강제 설치 여부
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return bool 설치 성공 여부
|
||||
*
|
||||
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
|
||||
@@ -304,7 +309,10 @@ class PluginManager implements PluginManagerInterface
|
||||
?\Closure $onProgress = null,
|
||||
VendorMode $vendorMode = VendorMode::Auto,
|
||||
bool $force = false,
|
||||
?string &$failureReason = null,
|
||||
): bool {
|
||||
$failureReason = null;
|
||||
|
||||
// identifier 형식 검증 (내부 호출 방어)
|
||||
ExtensionManager::validateIdentifierFormat($pluginName);
|
||||
|
||||
@@ -352,159 +360,191 @@ 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);
|
||||
}
|
||||
// 설치 시점에는 autoload-extensions.php 가 아직 갱신되지 않았다. plugin.php 의
|
||||
// getConfigValues()/getSettingsSchema() 등이 자기 src/ 클래스를 호출하면 그 클래스가
|
||||
// 해석되지 않아 "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본의
|
||||
// 매핑이 이미 있어 재현되지 않는다). 시더 실행 직전이 아니라 진입 파일을 로드하기
|
||||
// 전에 그 확장의 PSR-4 매핑을 등록한다.
|
||||
ExtensionManager::registerExtensionAutoloadPaths('plugins', $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', '검증 중...');
|
||||
$result = $plugin->install();
|
||||
|
||||
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');
|
||||
|
||||
// IDV 정책 자동 동기화 (identity_policies 테이블)
|
||||
$this->syncPluginIdentityPolicies($plugin);
|
||||
// 플러그인 설치 실행
|
||||
$onProgress?->__invoke('validate', '검증 중...');
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
$result = $plugin->install();
|
||||
|
||||
// IDV 메시지 정의/템플릿 자동 동기화
|
||||
$this->syncPluginIdentityMessages($plugin);
|
||||
if (! $result) {
|
||||
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
||||
}
|
||||
|
||||
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
|
||||
$this->syncPluginNotificationDefinitions($plugin);
|
||||
if (! $result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
|
||||
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
|
||||
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
|
||||
$this->runMigrations($plugin);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,9 +558,15 @@ class PluginManager implements PluginManagerInterface
|
||||
{
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
if (! $plugin) {
|
||||
return ['success' => false, 'layouts_registered' => 0];
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_registered' => 0,
|
||||
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
|
||||
];
|
||||
}
|
||||
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
|
||||
if ($record) {
|
||||
@@ -609,6 +655,13 @@ class PluginManager implements PluginManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
||||
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
|
||||
// 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면
|
||||
// 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고,
|
||||
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// soft deleted된 플러그인 레이아웃 복원 (재활성화 시)
|
||||
$this->restorePluginLayouts($plugin->getIdentifier());
|
||||
|
||||
@@ -631,9 +684,6 @@ class PluginManager implements PluginManagerInterface
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
|
||||
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
@@ -648,7 +698,18 @@ class PluginManager implements PluginManagerInterface
|
||||
HookManager::doAction('core.plugins.activated', $pluginName);
|
||||
}
|
||||
|
||||
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
|
||||
if (! $result) {
|
||||
// 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
|
||||
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
|
||||
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_registered' => $layoutsRegistered,
|
||||
'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -695,9 +756,15 @@ class PluginManager implements PluginManagerInterface
|
||||
): array {
|
||||
$plugin = $this->getPlugin($pluginName);
|
||||
if (! $plugin) {
|
||||
return ['success' => false, 'layouts_deleted' => 0];
|
||||
return [
|
||||
'success' => false,
|
||||
'layouts_deleted' => 0,
|
||||
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
|
||||
];
|
||||
}
|
||||
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
|
||||
if ($record) {
|
||||
@@ -761,6 +828,12 @@ class PluginManager implements PluginManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
|
||||
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로,
|
||||
// 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어
|
||||
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 플러그인 레이아웃 soft delete
|
||||
$layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier());
|
||||
|
||||
@@ -780,9 +853,6 @@ class PluginManager implements PluginManagerInterface
|
||||
// 플러그인 자체 캐시 전체 정리
|
||||
$this->flushPluginCache($plugin);
|
||||
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
|
||||
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
|
||||
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
|
||||
@@ -797,6 +867,10 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
|
||||
|
||||
if (! $result) {
|
||||
$response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
||||
}
|
||||
|
||||
if (! empty($driverWarnings)) {
|
||||
$response['driver_warnings'] = $driverWarnings;
|
||||
}
|
||||
@@ -884,12 +958,24 @@ class PluginManager implements PluginManagerInterface
|
||||
* @param string $pluginName 제거할 플러그인명
|
||||
* @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 플러그인을 찾을 수 없을 때
|
||||
*/
|
||||
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool
|
||||
{
|
||||
public function uninstallPlugin(
|
||||
string $pluginName,
|
||||
bool $deleteData = false,
|
||||
?\Closure $onProgress = null,
|
||||
?string &$failureReason = null,
|
||||
?array &$preservedBackups = null,
|
||||
): bool {
|
||||
$failureReason = null;
|
||||
$preservedBackups = [];
|
||||
|
||||
// 상태 가드: 진행 중 상태 체크
|
||||
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
|
||||
if ($existingRecord) {
|
||||
@@ -918,8 +1004,13 @@ class PluginManager implements PluginManagerInterface
|
||||
DB::beginTransaction();
|
||||
|
||||
// 플러그인 제거 실행
|
||||
$plugin->clearLifecycleFailureReason();
|
||||
$result = $plugin->uninstall();
|
||||
|
||||
if (! $result) {
|
||||
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
// 권한/역할은 $deleteData=true 시에만 삭제.
|
||||
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
|
||||
@@ -980,6 +1071,12 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 트랜잭션 외부에서 실행
|
||||
if ($result) {
|
||||
// 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다.
|
||||
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
|
||||
// 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된
|
||||
// 플러그인이 목록에 남은 채로 라우트·훅이 박제된다.
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우)
|
||||
if ($deleteData) {
|
||||
$this->deletePluginSettingsDirectory($plugin);
|
||||
@@ -1005,15 +1102,15 @@ class PluginManager implements PluginManagerInterface
|
||||
// 플러그인 자체 캐시 전체 정리
|
||||
$this->flushPluginCache($plugin);
|
||||
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
|
||||
$onProgress?->__invoke('files', '파일 삭제 중...');
|
||||
ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier());
|
||||
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
|
||||
$this->pluginsPath,
|
||||
$plugin->getIdentifier()
|
||||
);
|
||||
|
||||
// 메모리에서 플러그인 제거
|
||||
unset($this->plugins[$plugin->getIdentifier()]);
|
||||
@@ -2003,10 +2100,54 @@ class PluginManager implements PluginManagerInterface
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 선언한 관리자 메뉴를 동기화합니다.
|
||||
*
|
||||
* 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다.
|
||||
* 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
|
||||
* 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만
|
||||
* 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다.
|
||||
*
|
||||
* helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징
|
||||
* (user_overrides) 도 보존됩니다.
|
||||
*
|
||||
* @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스
|
||||
*/
|
||||
protected function createPluginMenus(PluginInterface $plugin): void
|
||||
{
|
||||
if (! method_exists($plugin, 'getAdminMenus')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$menus = $plugin->getAdminMenus();
|
||||
|
||||
if (empty($menus)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록
|
||||
// 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선).
|
||||
$menus = HookManager::applyFilters(
|
||||
"plugin.{$plugin->getIdentifier()}.admin_menus.translations",
|
||||
$menus,
|
||||
);
|
||||
|
||||
$helper = app(ExtensionMenuSyncHelper::class);
|
||||
|
||||
foreach ($menus as $menuData) {
|
||||
$helper->syncMenuRecursive(
|
||||
$menuData,
|
||||
ExtensionOwnerType::Plugin,
|
||||
$plugin->getIdentifier(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙).
|
||||
*
|
||||
* 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상.
|
||||
* 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로
|
||||
* 여기서는 권한·역할만 대상으로 한다.
|
||||
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
|
||||
*/
|
||||
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
|
||||
@@ -2065,14 +2206,15 @@ class PluginManager implements PluginManagerInterface
|
||||
* 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
|
||||
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
|
||||
*
|
||||
* 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재):
|
||||
* 동기화 대상:
|
||||
* 1. 역할 (`getRoles`)
|
||||
* 2. 권한 (`getPermissions`)
|
||||
* 3. 역할-권한 매핑 (`getRolePermissions`)
|
||||
* 4. stale cleanup (현재 선언에 없는 기존 레코드 제거)
|
||||
* 5. IDV 정책 (`getIdentityPolicies`)
|
||||
* 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
|
||||
* 7. 알림 정의/템플릿 (`getNotificationDefinitions`)
|
||||
* 4. 관리자 메뉴 (`getAdminMenus`)
|
||||
* 5. stale cleanup (현재 선언에 없는 기존 레코드 제거)
|
||||
* 6. IDV 정책 (`getIdentityPolicies`)
|
||||
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
|
||||
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
|
||||
*
|
||||
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
|
||||
* 무해 (멱등).
|
||||
@@ -2084,6 +2226,7 @@ class PluginManager implements PluginManagerInterface
|
||||
$this->createPluginRoles($plugin);
|
||||
$this->createPluginPermissions($plugin);
|
||||
$this->assignPermissionsToRoles($plugin);
|
||||
$this->createPluginMenus($plugin);
|
||||
$this->cleanupStalePluginEntries($plugin);
|
||||
$this->syncPluginIdentityPolicies($plugin);
|
||||
$this->syncPluginIdentityMessages($plugin);
|
||||
@@ -2476,7 +2619,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';
|
||||
|
||||
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
|
||||
@@ -2502,14 +2645,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,
|
||||
@@ -2557,7 +2703,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);
|
||||
@@ -2627,7 +2773,7 @@ class PluginManager implements PluginManagerInterface
|
||||
|
||||
// 5. 스토리지 디렉토리 1-depth 용량 조회
|
||||
$storageInfo = $this->getStorageDirectoriesInfo(
|
||||
storage_path('app/plugins/'.$identifier)
|
||||
ExtensionStoragePath::plugin($identifier)
|
||||
);
|
||||
|
||||
// 6. Composer vendor 디렉토리 정보 조회
|
||||
@@ -4234,7 +4380,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(
|
||||
@@ -4675,6 +4821,13 @@ class PluginManager implements PluginManagerInterface
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
|
||||
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
|
||||
// updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고
|
||||
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
|
||||
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
|
||||
// refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
|
||||
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
|
||||
@@ -4698,7 +4851,13 @@ class PluginManager implements PluginManagerInterface
|
||||
$this->clearAllTemplateRoutesCaches();
|
||||
$this->incrementExtensionCacheVersion();
|
||||
RouteCacheHelper::rebuild();
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
|
||||
// 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
|
||||
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
|
||||
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
|
||||
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
|
||||
$this->extensionManager->regenerateHookCache();
|
||||
|
||||
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
|
||||
HookManager::doAction('core.plugins.updated', $identifier);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Storage\Concerns;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 스토리지 드라이버 공용 공개 URL 해석 trait
|
||||
*
|
||||
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
|
||||
* 직접 URL 을 생성하고, 그 외에는 null 을 생성합니다. 생성 결과는 디스크 종류와 무관하게
|
||||
* `core.storage.filter_url` 필터 훅을 항상 통과하므로, 확장이 URL 을 공급/수정/차단할 수 있습니다.
|
||||
*/
|
||||
trait ResolvesPublicUrl
|
||||
{
|
||||
/**
|
||||
* 파일의 공개 URL을 반환합니다.
|
||||
*
|
||||
* @param string $category 카테고리
|
||||
* @param string $path 파일 경로 (카테고리 하위 상대 경로)
|
||||
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
|
||||
*/
|
||||
public function url(string $category, string $path): ?string
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
$url = $this->diskSupportsDirectUrl()
|
||||
? Storage::disk($this->disk)->url($fullPath)
|
||||
: null;
|
||||
|
||||
$filtered = HookManager::applyFilters('core.storage.filter_url', $url, array_merge(
|
||||
$this->urlHookContext(),
|
||||
[
|
||||
'disk' => $this->disk,
|
||||
'category' => $category,
|
||||
'path' => $path,
|
||||
'full_path' => $fullPath,
|
||||
]
|
||||
));
|
||||
|
||||
return (is_string($filtered) && trim($filtered) !== '') ? $filtered : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 디스크가 직접 URL 생성을 지원하는지 판정합니다.
|
||||
*
|
||||
* public 디스크는 항상 지원, 그 외 디스크는 `filesystems.disks.{disk}.url` 이
|
||||
* 비어 있지 않은 문자열로 설정된 경우에만 지원합니다 (AWS_URL 미설정/빈값 방어).
|
||||
*
|
||||
* @return bool 직접 URL 지원 여부
|
||||
*/
|
||||
private function diskSupportsDirectUrl(): bool
|
||||
{
|
||||
if ($this->disk === 'public') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$base = config("filesystems.disks.{$this->disk}.url");
|
||||
|
||||
return is_string($base) && trim($base) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
|
||||
*
|
||||
* @return array{scope: string, identifier: ?string} scope(core|module|plugin) + 확장 식별자(코어는 null)
|
||||
*/
|
||||
abstract protected function urlHookContext(): array;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Extension\Storage;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 코어 스토리지 드라이버
|
||||
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class CoreStorageDriver implements StorageInterface
|
||||
{
|
||||
use ResolvesPublicUrl;
|
||||
|
||||
/**
|
||||
* 사용할 디스크 이름
|
||||
*/
|
||||
@@ -93,17 +97,16 @@ class CoreStorageDriver implements StorageInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
|
||||
*
|
||||
* @return array{scope: string, identifier: ?string} scope + 식별자 (코어는 null)
|
||||
*/
|
||||
public function url(string $category, string $path): ?string
|
||||
protected function urlHookContext(): array
|
||||
{
|
||||
if ($this->disk !== 'public') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
return Storage::disk($this->disk)->url($fullPath);
|
||||
return [
|
||||
'scope' => 'core',
|
||||
'identifier' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +158,7 @@ class CoreStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
@@ -180,7 +183,7 @@ class CoreStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Extension\Storage;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 모듈 스토리지 드라이버
|
||||
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class ModuleStorageDriver implements StorageInterface
|
||||
{
|
||||
use ResolvesPublicUrl;
|
||||
|
||||
/**
|
||||
* 모듈 식별자
|
||||
*/
|
||||
@@ -119,18 +123,16 @@ class ModuleStorageDriver implements StorageInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
|
||||
*
|
||||
* @return array{scope: string, identifier: ?string} scope + 모듈 식별자
|
||||
*/
|
||||
public function url(string $category, string $path): ?string
|
||||
protected function urlHookContext(): array
|
||||
{
|
||||
// public disk인 경우에만 직접 URL 반환
|
||||
if ($this->disk !== 'public') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
return Storage::disk($this->disk)->url($fullPath);
|
||||
return [
|
||||
'scope' => 'module',
|
||||
'identifier' => $this->identifier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +184,7 @@ class ModuleStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
@@ -207,7 +209,7 @@ class ModuleStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Extension\Storage;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 플러그인 스토리지 드라이버
|
||||
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class PluginStorageDriver implements StorageInterface
|
||||
{
|
||||
use ResolvesPublicUrl;
|
||||
|
||||
/**
|
||||
* 플러그인 식별자
|
||||
*/
|
||||
@@ -119,18 +123,16 @@ class PluginStorageDriver implements StorageInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
|
||||
*
|
||||
* @return array{scope: string, identifier: ?string} scope + 플러그인 식별자
|
||||
*/
|
||||
public function url(string $category, string $path): ?string
|
||||
protected function urlHookContext(): array
|
||||
{
|
||||
// public disk인 경우에만 직접 URL 반환
|
||||
if ($this->disk !== 'public') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
return Storage::disk($this->disk)->url($fullPath);
|
||||
return [
|
||||
'scope' => 'plugin',
|
||||
'identifier' => $this->identifier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +184,7 @@ class PluginStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
@@ -207,7 +209,7 @@ class PluginStorageDriver implements StorageInterface
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
|
||||
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
|
||||
{
|
||||
$fullPath = $this->resolvePath($category, $path);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -51,6 +52,14 @@ class TemplateManager implements TemplateManagerInterface
|
||||
|
||||
protected array $templates = [];
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리 스캔이 1회 이상 수행되었는지 여부
|
||||
*
|
||||
* `ensureLoaded()` 의 멱등 판정에만 쓴다 — 명시적 `loadTemplates()` 는 이 값과 무관하게
|
||||
* 항상 재스캔한다(설치/삭제 직후 갱신 계약).
|
||||
*/
|
||||
protected bool $templatesLoaded = false;
|
||||
|
||||
/**
|
||||
* _pending 디렉토리의 템플릿 메타데이터 배열
|
||||
*
|
||||
@@ -98,13 +107,33 @@ class TemplateManager implements TemplateManagerInterface
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
|
||||
*
|
||||
* 소비자가 "템플릿 맵이 채워져 있음" 만 필요로 할 때 쓴다. `loadTemplates()` 는 맵을
|
||||
* 리셋하고 디렉토리를 통째로 재스캔하므로, 그것을 무조건 호출하면 공유 인스턴스의
|
||||
* 상태를 매번 갈아엎으면서 풀스캔 비용까지 반복된다.
|
||||
*/
|
||||
public function ensureLoaded(): void
|
||||
{
|
||||
if ($this->templatesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 템플릿을 로드하고 초기화합니다.
|
||||
*
|
||||
* 항상 재스캔한다 — 설치/삭제/업데이트 직후 갱신을 보장하는 계약이다.
|
||||
* 단순히 "채워져 있으면 됨" 인 호출자는 `ensureLoaded()` 를 쓴다.
|
||||
*/
|
||||
public function loadTemplates(): void
|
||||
{
|
||||
// 기존 템플릿 캐시 초기화 (테스트 환경에서 재로드 지원)
|
||||
$this->templates = [];
|
||||
$this->templatesLoaded = true;
|
||||
|
||||
if (! File::exists($this->templatesPath)) {
|
||||
return;
|
||||
@@ -397,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', '파일 복사 중...');
|
||||
@@ -407,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,15 +690,21 @@ class TemplateManager implements TemplateManagerInterface
|
||||
* @param string $templateName 비활성화할 템플릿명 (identifier)
|
||||
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
|
||||
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
|
||||
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
|
||||
* @return bool 비활성화 성공 여부
|
||||
*/
|
||||
public function deactivateTemplate(
|
||||
string $templateName,
|
||||
string $reason = DeactivationReason::Manual->value,
|
||||
?string $incompatibleRequiredVersion = null,
|
||||
?string &$failureReason = null,
|
||||
): bool {
|
||||
$failureReason = null;
|
||||
|
||||
$template = $this->getTemplate($templateName);
|
||||
if (! $template) {
|
||||
$failureReason = __('templates.errors.not_found', ['template' => $templateName]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -697,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', '캐시 삭제 중...');
|
||||
|
||||
@@ -748,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]);
|
||||
@@ -1659,13 +1722,9 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
|
||||
if (! in_array($override->name, $registeredLayoutNames)) {
|
||||
// 캐시 삭제 (레코드 삭제 전에 수행)
|
||||
$cacheKey = "template.{$templateId}.layout.{$override->name}";
|
||||
$this->cache()->forget($cacheKey);
|
||||
|
||||
$sourceHash = md5($override->source_type?->value.$override->source_identifier);
|
||||
$cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}";
|
||||
$this->cache()->forget($cacheKeyWithHash);
|
||||
// 캐시 삭제 (레코드 삭제 전에 수행) — 키 조립은 트레이트 단일 지점에 위임
|
||||
// (.with_source_meta 변종 포함, 수작업 키 목록의 규약 드리프트 방지)
|
||||
$this->forgetLayoutCacheKeys($override, $templateName);
|
||||
|
||||
$override->forceDelete();
|
||||
$deletedCount++;
|
||||
@@ -1712,14 +1771,10 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
|
||||
foreach ($overrideLayouts as $layout) {
|
||||
// 기본 캐시 키 패턴으로 삭제
|
||||
$cacheKey = "template.{$templateId}.layout.{$layout->name}";
|
||||
$this->cache()->forget($cacheKey);
|
||||
|
||||
// sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴)
|
||||
$sourceHash = md5($layout->source_type?->value.$layout->source_identifier);
|
||||
$cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}";
|
||||
$this->cache()->forget($cacheKeyWithHash);
|
||||
// 키 조립은 트레이트 단일 지점에 위임 (.with_source_meta 변종 포함).
|
||||
// 이 경로는 identifier 를 보유하지 않으므로 '' 전달 — 버전 포함 공개
|
||||
// 서빙 키는 종전과 동일하게 버전 bump 로 무효화된다.
|
||||
$this->forgetLayoutCacheKeys($layout, '');
|
||||
}
|
||||
|
||||
Log::info(__('templates.info.override_layouts_cache_invalidated'), [
|
||||
@@ -1766,41 +1821,79 @@ class TemplateManager implements TemplateManagerInterface
|
||||
/**
|
||||
* 템플릿 관련 모든 캐시를 삭제합니다.
|
||||
*
|
||||
* 템플릿 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는
|
||||
* 무효화 단일 지점이다. 버전 접미사 없는 고정 키(`template.config.{identifier}`,
|
||||
* `template.{id|identifier}.components_manifest`)는 캐시 버전 bump 로 무효화되지
|
||||
* 않으므로 여기서 능동 forget 한다 — 누락 시 공개 config.json 이 TTL(1시간) 동안
|
||||
* 이전 manifest 로 응답된다 (#588, 공개 #119).
|
||||
*
|
||||
* template:cache-clear 커맨드가 호출할 수 있도록 public 이다.
|
||||
*
|
||||
* @param string $templateIdentifier 템플릿 식별자
|
||||
* @return int 능동 삭제를 시도한 캐시 키 수
|
||||
*/
|
||||
protected function clearTemplateCache(string $templateIdentifier): void
|
||||
public function clearTemplateCache(string $templateIdentifier): int
|
||||
{
|
||||
$clearedCount = 0;
|
||||
|
||||
// identifier 만으로 가능한 고정 키 forget — uninstall 등 DB 레코드가 이미
|
||||
// 없는 상황에서도 반드시 수행되어야 하므로 레코드 조회보다 먼저 둔다.
|
||||
$this->cache()->forget("template.config.{$templateIdentifier}");
|
||||
$clearedCount++;
|
||||
|
||||
// components_manifest identifier 변종 (ComponentExists 는 int|string 수용)
|
||||
$this->cache()->forget("template.{$templateIdentifier}.components_manifest");
|
||||
$clearedCount++;
|
||||
|
||||
// 활성 템플릿 + 레이아웃 무변경 업데이트 경로는 캐시 버전 bump 가 없어
|
||||
// 현재 버전 routes/language 키가 stale 로 남는다 — warmTemplateCache() 와
|
||||
// 대칭으로 현재 버전 키를 능동 삭제한다 (이전 버전 키는 TTL 자연 만료).
|
||||
$cacheVersion = self::getExtensionCacheVersion();
|
||||
$this->cache()->forget("template.routes.{$templateIdentifier}.v{$cacheVersion}");
|
||||
$clearedCount++;
|
||||
foreach (config('app.supported_locales', ['ko', 'en']) as $locale) {
|
||||
$this->cache()->forget("template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
// DB 기반 조회 — 파일 시스템 상태와 무관하게 캐시 삭제 보장
|
||||
// (파일 교체 중이거나 reloadTemplate() 전에도 캐시를 확실히 삭제)
|
||||
$templateRecord = $this->templateRepository->findByIdentifier($templateIdentifier);
|
||||
if (! $templateRecord) {
|
||||
return;
|
||||
return $clearedCount;
|
||||
}
|
||||
|
||||
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
|
||||
$this->clearLayoutCaches($templateIdentifier);
|
||||
// components_manifest 숫자 id 변종 (StoreLayoutRequest 가 integer 검증 — 실운영 키)
|
||||
$this->cache()->forget("template.{$templateRecord->id}.components_manifest");
|
||||
$clearedCount++;
|
||||
|
||||
// Routes/다국어 캐시는 버전 포함 키이므로 incrementExtensionCacheVersion() + TTL로 무효화됨
|
||||
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
|
||||
$clearedCount += $this->clearLayoutCaches($templateIdentifier);
|
||||
|
||||
Log::info(__('templates.info.cache_cleared'), [
|
||||
'template' => $templateIdentifier,
|
||||
]);
|
||||
|
||||
return $clearedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿의 모든 레이아웃 캐시를 삭제합니다.
|
||||
*
|
||||
* @param string $templateIdentifier 템플릿 식별자
|
||||
* @return int 캐시를 무효화한 레이아웃 수
|
||||
*/
|
||||
protected function clearLayoutCaches(string $templateIdentifier): void
|
||||
protected function clearLayoutCaches(string $templateIdentifier): int
|
||||
{
|
||||
// 템플릿의 모든 레이아웃 조회
|
||||
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
|
||||
if (! $template) {
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->invalidateTemplateLayoutCache($template->id, $templateIdentifier);
|
||||
|
||||
return $this->layoutRepository->getByTemplateId($template->id)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2761,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(
|
||||
@@ -3082,13 +3175,22 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$onProgress?->__invoke('cleanup', '정리 중...');
|
||||
ExtensionBackupHelper::deleteBackup($backupPath);
|
||||
|
||||
// 고정 키(config/components_manifest) + 현재 버전 routes/language 키 능동 삭제.
|
||||
// 비활성 업데이트·레이아웃 무변경 업데이트 경로는 버전 bump 가 없어 이 호출이
|
||||
// 없으면 공개 config.json 이 TTL 동안 이전 manifest 로 응답된다 (#588, 공개 #119).
|
||||
// 활성 경로의 refreshTemplateLayouts() 경유 중복 삭제는 forget 멱등이라 무해.
|
||||
$this->clearTemplateCache($identifier);
|
||||
|
||||
$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 직접 호출 시에도 리스너 트리거)
|
||||
@@ -3138,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;
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
|
||||
* 버전 없는 내부 캐시 키를 능동 삭제합니다:
|
||||
* 1. template.{templateId}.layout.{layoutName} - LayoutService에서 사용
|
||||
* 2. template.{templateId}.layout.{layoutName}.{sourceHash} - 모듈/플러그인 레이아웃
|
||||
* 3. 위 두 형태의 `.with_source_meta` 접미사 변종 - 편집기용 병합 응답 캐시
|
||||
*
|
||||
* 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는
|
||||
* incrementExtensionCacheVersion() + TTL로 무효화됩니다.
|
||||
@@ -98,22 +100,31 @@ trait InvalidatesLayoutCache
|
||||
$cache = $this->resolveLayoutCache();
|
||||
|
||||
// 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제)
|
||||
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
|
||||
// 편집기용 병합 응답은 `.with_source_meta` 접미사 키로 별도 캐싱되므로
|
||||
// (LayoutService::getMergedLayoutCacheKey) 두 변종을 함께 지운다 —
|
||||
// 접미사 키를 남기면 activate/deactivate/uninstall/refresh 후에도
|
||||
// 편집기가 stale 병합 캐시를 받는다 (#588).
|
||||
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name));
|
||||
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, withSourceMeta: true));
|
||||
|
||||
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제)
|
||||
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) — 접미사는 소스 해시 뒤
|
||||
if ($layout->source_type && $layout->source_identifier) {
|
||||
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
|
||||
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
|
||||
$sourceType = $layout->source_type->value;
|
||||
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier));
|
||||
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier, true));
|
||||
}
|
||||
|
||||
// 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두.
|
||||
// PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta`
|
||||
// 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화
|
||||
// (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를
|
||||
// 받는다. 레이아웃
|
||||
// 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다.
|
||||
// 받는다. 레이아웃 저장 경로(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");
|
||||
}
|
||||
@@ -137,26 +148,32 @@ trait InvalidatesLayoutCache
|
||||
/**
|
||||
* 레이아웃 캐시 키를 생성합니다.
|
||||
*
|
||||
* `$withSourceMeta` 가 true 면 `.with_source_meta` 접미사를 붙인다 — 편집기용
|
||||
* 병합 응답 키(LayoutService::getMergedLayoutCacheKey 와 동형, 접미사는 소스 해시 뒤).
|
||||
*
|
||||
* @param int $templateId 템플릿 ID
|
||||
* @param string $layoutName 레이아웃 이름
|
||||
* @param string|null $sourceType 소스 타입 (선택)
|
||||
* @param string|null $sourceIdentifier 소스 식별자 (선택)
|
||||
* @param bool $withSourceMeta 편집기용 병합 응답 키 여부 (선택)
|
||||
* @return string 캐시 키
|
||||
*/
|
||||
protected function buildLayoutCacheKey(
|
||||
int $templateId,
|
||||
string $layoutName,
|
||||
?string $sourceType = null,
|
||||
?string $sourceIdentifier = null
|
||||
?string $sourceIdentifier = null,
|
||||
bool $withSourceMeta = false
|
||||
): string {
|
||||
$baseKey = "template.{$templateId}.layout.{$layoutName}";
|
||||
$metaSuffix = $withSourceMeta ? '.with_source_meta' : '';
|
||||
|
||||
if ($sourceType && $sourceIdentifier) {
|
||||
$sourceHash = md5($sourceType.$sourceIdentifier);
|
||||
|
||||
return "{$baseKey}.{$sourceHash}";
|
||||
return "{$baseKey}.{$sourceHash}{$metaSuffix}";
|
||||
}
|
||||
|
||||
return $baseKey;
|
||||
return "{$baseKey}{$metaSuffix}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
/**
|
||||
* 확장이 수명주기 훅에서 실패 사유를 알리는 통로.
|
||||
*
|
||||
* `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로,
|
||||
* 확장이 "왜" 거부했는지가 호출자에게 전달되지 않는다. 그 결과 관리자 화면에는
|
||||
* 원인 자리가 빈 실패 문구만 남는다.
|
||||
*
|
||||
* 확장은 `failWith()` 로 사유를 남기며 false 를 돌려주고, 코어(Manager)는
|
||||
* `getLifecycleFailureReason()` 으로 그 사유를 읽어 응답에 싣는다.
|
||||
* 사유를 남기지 않은 확장은 null 이며, 이 경우 코어가 일반 문구로 대체한다.
|
||||
*
|
||||
* 사유는 이미 번역된 문장이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
|
||||
*/
|
||||
trait ReportsLifecycleFailure
|
||||
{
|
||||
/** @var string|null 마지막 수명주기 실패 사유 (번역된 문장) */
|
||||
protected ?string $lifecycleFailureReason = null;
|
||||
|
||||
/**
|
||||
* 마지막 수명주기 훅이 남긴 실패 사유를 반환합니다.
|
||||
*
|
||||
* @return string|null 실패 사유. 남기지 않았으면 null
|
||||
*/
|
||||
public function getLifecycleFailureReason(): ?string
|
||||
{
|
||||
return $this->lifecycleFailureReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 사유를 남기고 false 를 반환합니다.
|
||||
*
|
||||
* 수명주기 훅에서 `return $this->failWith(__('...'));` 형태로 사용합니다.
|
||||
*
|
||||
* @param string $reason 운영자에게 보일 실패 사유 (번역된 문장)
|
||||
* @return false 언제나 false
|
||||
*/
|
||||
protected function failWith(string $reason): bool
|
||||
{
|
||||
$this->lifecycleFailureReason = $reason;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 실패 사유를 지웁니다.
|
||||
*
|
||||
* 같은 확장 인스턴스로 수명주기 훅을 다시 부르기 전에 코어가 호출합니다.
|
||||
* 지우지 않으면 이전 실패의 사유가 다음 성공/실패에 그대로 따라붙는다.
|
||||
*/
|
||||
public function clearLifecycleFailureReason(): void
|
||||
{
|
||||
$this->lifecycleFailureReason = null;
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,38 @@ class PermissionHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프 접근이 허용되는 모델만 남긴 배열을 반환합니다 (정적 일괄 라우트용).
|
||||
*
|
||||
* `PermissionMiddleware` 의 스코프 검사는 라우트에서 모델이 resolve 될 때만
|
||||
* 동작합니다 — 모델이 없으면 목록 엔드포인트로 보아 건너뜁니다. 따라서
|
||||
* `{user}` 같은 파라미터가 없는 **정적 일괄 라우트**(예: `PATCH users/bulk-status`)
|
||||
* 에서는 스코프 검사가 통째로 우회됩니다. 상세 경로가 403 으로 막는 대상을
|
||||
* 일괄 경로로는 바꿀 수 있으면 그 경로가 우회로이므로, 서비스 계층에서
|
||||
* 같은 판정(`checkScopeAccess`)을 재적용해야 합니다.
|
||||
*
|
||||
* 판정은 대상별로 이뤄집니다 — 액터의 유효 스코프가 self 면 자기 소유만,
|
||||
* role 이면 같은 역할 범위까지, 미지정(글로벌)이면 전체가 통과합니다.
|
||||
*
|
||||
* @param iterable<Model> $models 검사 대상 모델 목록
|
||||
* @param string $permission 권한 식별자
|
||||
* @param User|null $user 사용자 (null이면 현재 인증 사용자)
|
||||
* @return array<Model> 스코프 접근이 허용된 모델 목록
|
||||
*/
|
||||
public static function filterByScope(iterable $models, string $permission, ?User $user = null): array
|
||||
{
|
||||
$user = $user ?? Auth::user();
|
||||
|
||||
$allowed = [];
|
||||
foreach ($models as $model) {
|
||||
if (self::checkScopeAccess($model, $permission, $user)) {
|
||||
$allowed[] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission 스코프 데이터를 static 캐시와 함께 조회합니다.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ExtensionSettingsMirror;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
if (! function_exists('g7_settings')) {
|
||||
@@ -16,15 +17,12 @@ if (! function_exists('g7_settings')) {
|
||||
* @example
|
||||
* // 코어 메일 설정 조회
|
||||
* $mailHost = g7_settings('core.mail.host');
|
||||
*
|
||||
* @example
|
||||
* // 모듈 설정 조회
|
||||
* $shopName = g7_settings('modules.sirsoft-ecommerce.basic_info.shop_name');
|
||||
*
|
||||
* @example
|
||||
* // 플러그인 설정 조회
|
||||
* $displayMode = g7_settings('plugins.sirsoft-daum_postcode.display_mode');
|
||||
*
|
||||
* @example
|
||||
* // 전체 설정 조회
|
||||
* $allSettings = g7_settings();
|
||||
@@ -50,7 +48,6 @@ if (! function_exists('g7_core_settings')) {
|
||||
* @example
|
||||
* // 메일 호스트 조회
|
||||
* $mailHost = g7_core_settings('mail.host');
|
||||
*
|
||||
* @example
|
||||
* // 사이트명 조회
|
||||
* $siteName = g7_core_settings('general.site_name');
|
||||
@@ -77,7 +74,6 @@ if (! function_exists('g7_module_settings')) {
|
||||
* @example
|
||||
* // 이커머스 모듈의 쇼핑몰명 조회
|
||||
* $shopName = g7_module_settings('sirsoft-ecommerce', 'basic_info.shop_name');
|
||||
*
|
||||
* @example
|
||||
* // 모듈 전체 설정 조회
|
||||
* $allSettings = g7_module_settings('sirsoft-ecommerce');
|
||||
@@ -92,6 +88,23 @@ if (! function_exists('g7_module_settings')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('g7_refresh_module_settings_config')) {
|
||||
/**
|
||||
* 모듈 환경설정 config 미러를 다시 채웁니다.
|
||||
*
|
||||
* 모듈 설정 저장에는 코어 공통 지점이 없어 각 모듈의 SettingsService 가 직접 저장합니다.
|
||||
* 그 서비스가 자기 캐시를 비우는 자리에서 이 헬퍼를 호출하면, 상주 프로세스에서도
|
||||
* `g7_module_settings()` 가 저장 직후의 값을 읽습니다 (공개이슈 #109).
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @return void
|
||||
*/
|
||||
function g7_refresh_module_settings_config(string $identifier): void
|
||||
{
|
||||
app(ExtensionSettingsMirror::class)->refreshModule($identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('g7_plugin_settings')) {
|
||||
/**
|
||||
* 그누보드7 플러그인 환경설정 조회 헬퍼 함수
|
||||
@@ -104,7 +117,6 @@ if (! function_exists('g7_plugin_settings')) {
|
||||
* @example
|
||||
* // 다음 우편번호 플러그인의 표시 모드 조회
|
||||
* $displayMode = g7_plugin_settings('sirsoft-daum_postcode', 'display_mode');
|
||||
*
|
||||
* @example
|
||||
* // 플러그인 전체 설정 조회
|
||||
* $allSettings = g7_plugin_settings('sirsoft-daum_postcode');
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Resources\AttachmentResource;
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentService;
|
||||
use Exception;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ class AttachmentController extends AdminBaseController
|
||||
/**
|
||||
* AttachmentController 생성자
|
||||
*
|
||||
* @param AttachmentService $attachmentService 첨부파일 서비스
|
||||
* @param AttachmentService $attachmentService 첨부파일 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private AttachmentService $attachmentService
|
||||
@@ -32,7 +33,7 @@ class AttachmentController extends AdminBaseController
|
||||
/**
|
||||
* 단일 파일 업로드
|
||||
*
|
||||
* @param UploadAttachmentRequest $request 업로드 요청
|
||||
* @param UploadAttachmentRequest $request 업로드 요청
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function upload(UploadAttachmentRequest $request): JsonResponse
|
||||
@@ -64,7 +65,7 @@ class AttachmentController extends AdminBaseController
|
||||
/**
|
||||
* 여러 파일 일괄 업로드
|
||||
*
|
||||
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
|
||||
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
|
||||
@@ -117,7 +118,7 @@ class AttachmentController extends AdminBaseController
|
||||
/**
|
||||
* 순서 변경
|
||||
*
|
||||
* @param ReorderAttachmentsRequest $request 순서 변경 요청
|
||||
* @param ReorderAttachmentsRequest $request 순서 변경 요청
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
|
||||
@@ -126,9 +127,12 @@ class AttachmentController extends AdminBaseController
|
||||
$this->attachmentService->reorder($request->input('order'));
|
||||
|
||||
return $this->success('attachment.reorder_success');
|
||||
} catch (AuthorizationException $e) {
|
||||
// 스코프 밖 첨부가 포함된 경우. 아래 제네릭 catch 보다 앞에 둬야 한다 —
|
||||
// 뒤에 두면 인가 거부가 500 으로 뭉개져 상세 경로(403)와 응답이 갈린다.
|
||||
return $this->error('auth.scope_denied', 403, $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\Auth\AccountLockedException;
|
||||
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
|
||||
use App\Http\Requests\Auth\AuthenticatedRequest;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
|
||||
use App\Http\Requests\Auth\TwoFactorResendRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\AuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -13,10 +17,21 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends AdminBaseController
|
||||
{
|
||||
use BuildsAuthFailureResponses;
|
||||
|
||||
public function __construct(
|
||||
private AuthService $authService
|
||||
) {
|
||||
parent::__construct();
|
||||
// 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
|
||||
// parent::__construct();
|
||||
|
||||
// 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가
|
||||
// 식별하며, 관리자 여부는 코드 확인에 성공한 뒤 서비스가 돌려준 사용자로 판정한다.
|
||||
$this->middleware(['auth:sanctum', 'admin'])->except([
|
||||
'login',
|
||||
'verifyTwoFactor',
|
||||
'resendTwoFactor',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,10 +48,21 @@ class AuthController extends AdminBaseController
|
||||
$request->validated()['password']
|
||||
);
|
||||
|
||||
// 2단계 인증이 켜져 있으면 아직 토큰도 사용자도 없다 — 관리자 판정은 코드 확인
|
||||
// 뒤로 미룬다. 이 분기가 없으면 $data['user'] 가 없어 500 이 되고, 관리자까지
|
||||
// 로그인할 수 없어 설정을 되돌릴 수단이 사라진다.
|
||||
if ($data['two_factor_required'] ?? false) {
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
}
|
||||
|
||||
$user = $data['user'];
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (! $user->isAdmin()) {
|
||||
// 이 시점에는 이미 토큰과 web 세션이 발급되어 있다 — 거절하면서 남겨 두면
|
||||
// 관리자가 아닌 사용자가 응답만 403 을 받을 뿐 세션은 그대로 유효해진다.
|
||||
$this->authService->revokeIssuedSession($user, $data['token']);
|
||||
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
@@ -45,22 +71,92 @@ class AuthController extends AdminBaseController
|
||||
|
||||
return $this->success('auth.admin_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 (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자의 2단계 인증 코드를 확인하고 로그인을 완료합니다.
|
||||
*
|
||||
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
|
||||
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
|
||||
*
|
||||
* @param TwoFactorChallengeRequest $request challenge 확인 요청
|
||||
* @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
|
||||
*/
|
||||
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
$data = $this->authService->completeTwoFactor(
|
||||
$validated['challenge_id'],
|
||||
['code' => $validated['code']]
|
||||
);
|
||||
|
||||
$user = $data['user'];
|
||||
|
||||
if (! $user->isAdmin()) {
|
||||
// completeTwoFactor() 는 코드 확인에 성공한 시점에 토큰을 발급한다.
|
||||
// 관리자 판정으로 거절하면서 그 발급분을 회수하지 않으면 관리자가 아닌
|
||||
// 사용자가 유효한 세션을 손에 쥔 채 응답만 403 을 받는다.
|
||||
$this->authService->revokeIssuedSession($user, $data['token']);
|
||||
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
$data['user'] = new UserResource($user);
|
||||
|
||||
return $this->success('auth.admin_login_success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다.
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.two_factor_failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자의 2단계 인증 코드를 재발송합니다.
|
||||
*
|
||||
* 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
|
||||
* 더 이상 통하지 않습니다.
|
||||
*
|
||||
* @param TwoFactorResendRequest $request challenge 재발송 요청
|
||||
* @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$resolvedUser = null;
|
||||
|
||||
$data = $this->authService->resendTwoFactorChallenge(
|
||||
$request->validated()['challenge_id'],
|
||||
$resolvedUser
|
||||
);
|
||||
|
||||
// 이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다 — 다만 완료할 수 없는
|
||||
// 상대에게 새 인증번호를 계속 보내지는 않는다.
|
||||
if ($resolvedUser === null || ! $resolvedUser->isAdmin()) {
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
return $this->success('auth.two_factor_required', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
return $this->lockedResponse($e);
|
||||
} catch (TwoFactorDeliveryFailedException $e) {
|
||||
return $this->deliveryFailedResponse();
|
||||
} catch (ValidationException $e) {
|
||||
return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자를 로그아웃시킵니다.
|
||||
*
|
||||
@@ -110,6 +206,10 @@ class AuthController extends AdminBaseController
|
||||
}
|
||||
|
||||
return $this->success('common.success', $data);
|
||||
} catch (AccountLockedException $e) {
|
||||
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
|
||||
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
|
||||
return $this->lockedResponse($e);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->unauthorized('auth.unauthenticated');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: 모델 직접 조회를 Service 경유로 바꾼 내부 리팩토링 — 요청/응답 계약 불변
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Identity;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
@@ -8,6 +10,7 @@ use App\Http\Requests\Admin\Identity\UpdateIdentityMessageTemplateRequest;
|
||||
use App\Http\Resources\Admin\Identity\IdentityMessageTemplateResource;
|
||||
use App\Models\IdentityMessageTemplate;
|
||||
use App\Services\IdentityMessageTemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -29,7 +32,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
|
||||
*
|
||||
* @param UpdateIdentityMessageTemplateRequest $request
|
||||
* @param IdentityMessageTemplate $template
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function update(UpdateIdentityMessageTemplateRequest $request, IdentityMessageTemplate $template)
|
||||
{
|
||||
@@ -54,7 +57,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
|
||||
* 활성/비활성 토글.
|
||||
*
|
||||
* @param IdentityMessageTemplate $template
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function toggleActive(IdentityMessageTemplate $template)
|
||||
{
|
||||
@@ -79,13 +82,13 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
|
||||
* 변수 치환 미리보기.
|
||||
*
|
||||
* @param PreviewIdentityMessageTemplateRequest $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function preview(PreviewIdentityMessageTemplateRequest $request)
|
||||
{
|
||||
try {
|
||||
$payload = $request->validated();
|
||||
$template = IdentityMessageTemplate::findOrFail($payload['template_id']);
|
||||
$template = $this->templateService->findOrFailById((int) $payload['template_id']);
|
||||
$rendered = $this->templateService->getPreview(
|
||||
$template,
|
||||
$payload['data'] ?? [],
|
||||
@@ -104,7 +107,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
|
||||
* 템플릿을 시더 기본값으로 복원.
|
||||
*
|
||||
* @param IdentityMessageTemplate $template
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function reset(IdentityMessageTemplate $template)
|
||||
{
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\LanguagePackSlotConflictException;
|
||||
use App\Extension\Helpers\ChangelogParser;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
|
||||
use App\Http\Requests\LanguagePack\BulkActivateRequest;
|
||||
use App\Http\Requests\LanguagePack\IndexLanguagePackRequest;
|
||||
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
|
||||
use App\Http\Requests\LanguagePack\InstallFromFileRequest;
|
||||
use App\Http\Requests\LanguagePack\InstallFromGithubRequest;
|
||||
use App\Http\Requests\LanguagePack\InstallFromUrlRequest;
|
||||
use App\Http\Requests\LanguagePack\ManifestPreviewRequest;
|
||||
use App\Http\Requests\LanguagePack\UninstallLanguagePackRequest;
|
||||
use App\Extension\Helpers\ChangelogParser;
|
||||
use App\Http\Resources\LanguagePackCollection;
|
||||
use App\Http\Resources\LanguagePackResource;
|
||||
use App\Services\LanguagePackService;
|
||||
@@ -66,7 +67,7 @@ class LanguagePackController extends AdminBaseController
|
||||
|
||||
return $this->success('language_packs.fetch_success', $payload);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.fetch_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ class LanguagePackController extends AdminBaseController
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.install_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ class LanguagePackController extends AdminBaseController
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.install_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +174,7 @@ class LanguagePackController extends AdminBaseController
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.install_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +203,7 @@ class LanguagePackController extends AdminBaseController
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.install_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,17 +238,17 @@ class LanguagePackController extends AdminBaseController
|
||||
'target' => (new LanguagePackResource($e->target))->toArray($request),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.activate_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
|
||||
*
|
||||
* @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
|
||||
* @param BulkActivateRequest $request 요청
|
||||
* @return JsonResponse 결과 (succeeded/failed 분리)
|
||||
*/
|
||||
public function bulkActivate(\App\Http\Requests\LanguagePack\BulkActivateRequest $request): JsonResponse
|
||||
public function bulkActivate(BulkActivateRequest $request): JsonResponse
|
||||
{
|
||||
$ids = $request->validated('ids');
|
||||
$result = $this->service->bulkActivate($ids);
|
||||
@@ -278,7 +279,7 @@ class LanguagePackController extends AdminBaseController
|
||||
(new LanguagePackResource($pack))->toArray($request)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.deactivate_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +303,7 @@ class LanguagePackController extends AdminBaseController
|
||||
|
||||
return $this->success('language_packs.uninstall_success');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.uninstall_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ class LanguagePackController extends AdminBaseController
|
||||
|
||||
return $this->success('language_packs.check_updates_success', $result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.check_updates_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +346,7 @@ class LanguagePackController extends AdminBaseController
|
||||
(new LanguagePackResource($updated))->toArray($request)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.update_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.update_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +362,9 @@ class LanguagePackController extends AdminBaseController
|
||||
|
||||
return $this->success('language_packs.refresh_cache_success', $result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.refresh_cache_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
// 예외 원문을 응답에 싣지 않는다 — 디버그 노출은 ResponseHelper 가
|
||||
// app.debug 에서만 Throwable 을 펼치는 기존 메커니즘에 위임한다.
|
||||
return $this->error('language_packs.refresh_cache_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +381,9 @@ class LanguagePackController extends AdminBaseController
|
||||
|
||||
return $this->success('language_packs.preview_success', $result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.preview_failed', 422, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
// 업로드 ZIP 검증 실패는 의도적 422 로 존치하되, 예외 원문은 싣지 않는다
|
||||
// (다른 manifestPreview 컨트롤러 3종과 동형).
|
||||
return $this->error('language_packs.preview_failed', 422, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +415,7 @@ class LanguagePackController extends AdminBaseController
|
||||
'has_changelog' => $entries !== [] || $rawContent !== '',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
return $this->error('language_packs.fetch_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Http\Resources\MenuCollection;
|
||||
use App\Http\Resources\MenuResource;
|
||||
use App\Models\Menu;
|
||||
use App\Services\MenuService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -222,6 +223,10 @@ class MenuController extends AdminBaseController
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('menu.order_update_failed', 422, $e->errors());
|
||||
} catch (AuthorizationException $e) {
|
||||
// 스코프 밖 메뉴가 포함된 경우. 제네릭 catch 보다 앞에 둬야 인가 거부가 500 으로
|
||||
// 뭉개지지 않고 상세 경로(403)와 같은 응답이 된다.
|
||||
return $this->error('auth.scope_denied', 403, $e->getMessage());
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('menu.update_error', 500, $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Exceptions\ModuleOperationException;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
|
||||
@@ -199,7 +200,7 @@ class ModuleController extends AdminBaseController
|
||||
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
|
||||
$this->installSelectedDependencies($validated['dependencies'] ?? []);
|
||||
|
||||
$module = $this->moduleService->installModule($moduleName, $vendorMode);
|
||||
$module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason);
|
||||
|
||||
if ($module) {
|
||||
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
|
||||
@@ -210,7 +211,9 @@ class ModuleController extends AdminBaseController
|
||||
|
||||
return $this->success('module.install_success', $payload, 201);
|
||||
} else {
|
||||
return $this->error('module.install_failed');
|
||||
return $this->error('module.install_failed', 400, null, [
|
||||
'error' => $installFailureReason ?? __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
// Service에서 이미 번역된 메시지를 errors에 포함하므로
|
||||
@@ -271,10 +274,12 @@ class ModuleController extends AdminBaseController
|
||||
'pending_language_packs' => $pendingLanguagePacks,
|
||||
]));
|
||||
} else {
|
||||
return $this->error('module.activate_failed');
|
||||
return $this->error('module.activate_failed', 400, null, [
|
||||
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('module.activate_failed', 422, $e->errors());
|
||||
return $this->error('module.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -320,10 +325,12 @@ class ModuleController extends AdminBaseController
|
||||
|
||||
return $this->success('module.deactivate_success', $result);
|
||||
} else {
|
||||
return $this->error('module.deactivate_failed');
|
||||
return $this->error('module.deactivate_failed', 400, null, [
|
||||
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('module.deactivate_failed', 422, $e->errors());
|
||||
return $this->error('module.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -383,15 +390,26 @@ class ModuleController extends AdminBaseController
|
||||
$moduleName = $validated['module_name'];
|
||||
$deleteData = $validated['delete_data'] ?? false;
|
||||
|
||||
$result = $this->moduleService->uninstallModule($moduleName, $deleteData);
|
||||
$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');
|
||||
return $this->error('module.uninstall_failed', 400, null, [
|
||||
'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('module.uninstall_failed', 422, $e->errors());
|
||||
return $this->error('module.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -433,8 +451,10 @@ class ModuleController extends AdminBaseController
|
||||
new ModuleResource($module),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (ModuleOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -457,8 +477,10 @@ class ModuleController extends AdminBaseController
|
||||
new ModuleResource($module),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (ModuleOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -476,7 +498,7 @@ class ModuleController extends AdminBaseController
|
||||
|
||||
return $this->success('modules.check_updates_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('modules.check_updates_failed', 422, $e->errors());
|
||||
return $this->error('modules.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -505,7 +527,7 @@ class ModuleController extends AdminBaseController
|
||||
|
||||
return $this->success('modules.check_modified_layouts_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
|
||||
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -596,10 +618,12 @@ class ModuleController extends AdminBaseController
|
||||
new ModuleResource($module)
|
||||
);
|
||||
} else {
|
||||
return $this->error('module.refresh_layouts_failed');
|
||||
return $this->error('module.refresh_layouts_failed', 400, null, [
|
||||
'error' => __('modules.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('module.refresh_layouts_failed', 422, $e->errors());
|
||||
return $this->error('module.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
@@ -27,6 +29,9 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 알림 목록을 조회합니다.
|
||||
*
|
||||
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
|
||||
* @return JsonResponse 알림 목록 응답
|
||||
*/
|
||||
public function index(NotificationIndexRequest $request): JsonResponse
|
||||
{
|
||||
@@ -51,7 +56,11 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 미읽음 알림 수를 반환합니다.
|
||||
*
|
||||
* @param Request $request 인증 사용자 요청
|
||||
* @return JsonResponse 미읽음 알림 수 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
|
||||
public function unreadCount(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -70,7 +79,12 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 알림을 읽음 처리합니다.
|
||||
*
|
||||
* @param Request $request 인증 사용자 요청
|
||||
* @param string $id 알림 ID
|
||||
* @return JsonResponse 읽음 처리 결과 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -93,6 +107,9 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 지정된 알림들을 일괄 읽음 처리합니다.
|
||||
*
|
||||
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
|
||||
* @return JsonResponse 일괄 읽음 처리 결과 응답
|
||||
*/
|
||||
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
|
||||
{
|
||||
@@ -114,7 +131,11 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 모든 알림을 읽음 처리합니다.
|
||||
*
|
||||
* @param Request $request 인증 사용자 요청
|
||||
* @return JsonResponse 전체 읽음 처리 결과 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -133,7 +154,11 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 사용자의 모든 알림을 삭제합니다.
|
||||
*
|
||||
* @param Request $request 인증 사용자 요청
|
||||
* @return JsonResponse 전체 삭제 결과 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
|
||||
public function destroyAll(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -152,7 +177,12 @@ class NotificationController extends AdminBaseController
|
||||
|
||||
/**
|
||||
* 알림을 삭제합니다.
|
||||
*
|
||||
* @param Request $request 인증 사용자 요청
|
||||
* @param string $id 알림 ID
|
||||
* @return JsonResponse 삭제 결과 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
|
||||
public function destroy(Request $request, string $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Exceptions\PluginOperationException;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
@@ -188,7 +189,7 @@ class PluginController extends AdminBaseController
|
||||
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
|
||||
$this->installSelectedDependencies($validated['dependencies'] ?? []);
|
||||
|
||||
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode);
|
||||
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode, false, $installFailureReason);
|
||||
|
||||
if ($pluginInfo) {
|
||||
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
|
||||
@@ -199,7 +200,9 @@ class PluginController extends AdminBaseController
|
||||
|
||||
return $this->success('plugins.install_success', $payload);
|
||||
} else {
|
||||
return $this->error('plugins.install_failed');
|
||||
return $this->error('plugins.install_failed', 400, null, [
|
||||
'error' => $installFailureReason ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
// Service에서 이미 번역된 메시지를 errors에 포함하므로
|
||||
@@ -260,13 +263,16 @@ class PluginController extends AdminBaseController
|
||||
'pending_language_packs' => $pendingLanguagePacks,
|
||||
]));
|
||||
} else {
|
||||
return $this->error('plugins.activate_failed');
|
||||
return $this->error('plugins.activate_failed', 400, null, [
|
||||
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error(
|
||||
'plugins.activate_validation_failed',
|
||||
422,
|
||||
$e->errors()
|
||||
$e->errors(),
|
||||
['error' => $e->getMessage()]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error(
|
||||
@@ -317,13 +323,16 @@ class PluginController extends AdminBaseController
|
||||
|
||||
return $this->success('plugins.deactivate_success', $result);
|
||||
} else {
|
||||
return $this->error('plugins.deactivate_failed');
|
||||
return $this->error('plugins.deactivate_failed', 400, null, [
|
||||
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error(
|
||||
'plugins.deactivate_validation_failed',
|
||||
422,
|
||||
$e->errors()
|
||||
$e->errors(),
|
||||
['error' => $e->getMessage()]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error(
|
||||
@@ -388,18 +397,30 @@ class PluginController extends AdminBaseController
|
||||
$pluginName = $validated['plugin_name'];
|
||||
$deleteData = $validated['delete_data'] ?? false;
|
||||
|
||||
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData);
|
||||
$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');
|
||||
return $this->error('plugins.uninstall_failed', 400, null, [
|
||||
'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error(
|
||||
'plugins.uninstall_validation_failed',
|
||||
422,
|
||||
$e->errors()
|
||||
$e->errors(),
|
||||
['error' => $e->getMessage()]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error(
|
||||
@@ -444,8 +465,10 @@ class PluginController extends AdminBaseController
|
||||
new PluginResource($plugin),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (PluginOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -468,8 +491,10 @@ class PluginController extends AdminBaseController
|
||||
new PluginResource($plugin),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (PluginOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -487,7 +512,7 @@ class PluginController extends AdminBaseController
|
||||
|
||||
return $this->success('plugins.check_updates_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('plugins.check_updates_failed', 422, $e->errors());
|
||||
return $this->error('plugins.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -512,7 +537,7 @@ class PluginController extends AdminBaseController
|
||||
|
||||
return $this->success('plugins.check_modified_layouts_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors());
|
||||
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -608,13 +633,16 @@ class PluginController extends AdminBaseController
|
||||
'unchanged' => $result['unchanged'],
|
||||
]);
|
||||
} else {
|
||||
return $this->error('plugins.refresh_layouts_failed');
|
||||
return $this->error('plugins.refresh_layouts_failed', 400, null, [
|
||||
'error' => __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error(
|
||||
'plugins.refresh_layouts_validation_failed',
|
||||
422,
|
||||
$e->errors()
|
||||
$e->errors(),
|
||||
['error' => $e->getMessage()]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
|
||||
use App\Services\DriverRegistryService;
|
||||
use App\Services\PluginSettingsService;
|
||||
use App\Support\SensitiveSettingMask;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -49,6 +50,34 @@ class PluginSettingsController extends AdminBaseController
|
||||
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 스키마가 public_asset_disk 키를 선언한 플러그인의 응답에
|
||||
* 공개 자산 디스크 카탈로그(available_public_asset_disks)를 부착합니다.
|
||||
*
|
||||
* 화면이 /api/admin/settings(core.settings.read)를 따로 조회하게 두면 이
|
||||
* 화면의 권한(core.plugins.read)과 표면이 갈려, 커스텀 역할에서 카탈로그만
|
||||
* 조용히 비는 비대칭이 생깁니다. 설정 응답 단일 표면에서 함께 내려 권한 축을
|
||||
* 일치시킵니다 (ecommerce 모듈 설정 응답의 동명 키와 동형 계약).
|
||||
* 저장 시에는 스키마 기반 검증 whitelist 가 이 키를 걸러 설정에 남지 않습니다.
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 응답에 실을 설정
|
||||
* @return array<string, mixed> 카탈로그가 부착된 설정
|
||||
*/
|
||||
private function withPublicAssetCatalog(string $identifier, array $settings): array
|
||||
{
|
||||
$plugin = $this->pluginManager->getPlugin($identifier);
|
||||
|
||||
if ($plugin === null || ! array_key_exists('public_asset_disk', $plugin->getSettingsSchema())) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
$settings['available_public_asset_disks'] = app(DriverRegistryService::class)
|
||||
->getAvailableDrivers('public_asset');
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 조회합니다.
|
||||
*
|
||||
@@ -63,7 +92,10 @@ class PluginSettingsController extends AdminBaseController
|
||||
return $this->notFound('plugins.not_found');
|
||||
}
|
||||
|
||||
return $this->success('common.success', $this->maskSensitive($identifier, $settings));
|
||||
return $this->success(
|
||||
'common.success',
|
||||
$this->withPublicAssetCatalog($identifier, $this->maskSensitive($identifier, $settings))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,15 +113,22 @@ class PluginSettingsController extends AdminBaseController
|
||||
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
|
||||
$settings = $request->validated();
|
||||
|
||||
$result = $this->pluginSettingsService->save($identifier, $settings);
|
||||
$result = $this->pluginSettingsService->save($identifier, $settings, $failureReason);
|
||||
|
||||
if (! $result) {
|
||||
return $this->error('plugins.settings.update_failed', 500);
|
||||
return $this->error('plugins.settings.update_failed', 500, null, [
|
||||
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
|
||||
// 누락 시 저장 직후 선택지가 비어 보인다 (ecommerce 저장 응답과 동형)
|
||||
return $this->success(
|
||||
'plugins.settings.updated',
|
||||
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
|
||||
$this->withPublicAssetCatalog(
|
||||
$identifier,
|
||||
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\CannotModifyProtectedRoleException;
|
||||
use App\Exceptions\ExtensionOwnedRoleDeleteException;
|
||||
use App\Exceptions\PermissionEscalationException;
|
||||
use App\Exceptions\SystemRoleDeleteException;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Role\ActiveRolesRequest;
|
||||
use App\Http\Requests\Role\RoleListRequest;
|
||||
use App\Http\Requests\Role\StoreRoleRequest;
|
||||
use App\Http\Requests\Role\UpdateRoleRequest;
|
||||
use App\Http\Resources\RoleCollection;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\RoleService;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
@@ -60,13 +63,13 @@ class RoleController extends AdminBaseController
|
||||
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
|
||||
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청 객체
|
||||
* @param ActiveRolesRequest $request 활성 역할 조회 요청
|
||||
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
|
||||
*/
|
||||
public function active(Request $request): JsonResponse
|
||||
public function active(ActiveRolesRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
/** @var \App\Models\User $user */
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
|
||||
@@ -78,7 +81,10 @@ class RoleController extends AdminBaseController
|
||||
return $this->success('role.fetch_success', [
|
||||
'data' => RoleResource::collection($roles),
|
||||
'abilities' => [
|
||||
'can_assign_roles' => PermissionHelper::check('core.permissions.update'),
|
||||
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
|
||||
// (core.permissions.update)이 아니다. 부여 가능한 개별 역할의 범위는 서버
|
||||
// 상한(PermissionEscalationGuard)이 역할별로 강제한다.
|
||||
'can_assign_roles' => PermissionHelper::check('core.users.update'),
|
||||
],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
@@ -122,6 +128,8 @@ class RoleController extends AdminBaseController
|
||||
new RoleResource($role),
|
||||
201
|
||||
);
|
||||
} catch (PermissionEscalationException $e) {
|
||||
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('role.create_failed', 422, $e->errors());
|
||||
} catch (Exception $e) {
|
||||
@@ -145,6 +153,10 @@ class RoleController extends AdminBaseController
|
||||
'role.update_success',
|
||||
new RoleResource($updatedRole)
|
||||
);
|
||||
} catch (CannotModifyProtectedRoleException $e) {
|
||||
return $this->error('exceptions.cannot_modify_protected_role', 403);
|
||||
} catch (PermissionEscalationException $e) {
|
||||
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('role.update_failed', 422, $e->errors());
|
||||
} catch (Exception $e) {
|
||||
@@ -174,6 +186,8 @@ class RoleController extends AdminBaseController
|
||||
} else {
|
||||
return $this->error('role.update_failed');
|
||||
}
|
||||
} catch (CannotModifyProtectedRoleException $e) {
|
||||
return $this->error('exceptions.cannot_modify_protected_role', 403);
|
||||
} catch (Exception $e) {
|
||||
return $this->error('role.update_failed', 500, $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -8,11 +8,16 @@ use App\Http\Requests\Settings\RestoreSettingsRequest;
|
||||
use App\Http\Requests\Settings\SaveSettingsRequest;
|
||||
use App\Http\Requests\Settings\TestDriverConnectionRequest;
|
||||
use App\Http\Requests\Settings\TestMailRequest;
|
||||
use App\Http\Requests\Settings\TestOutboundProxyRequest;
|
||||
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;
|
||||
@@ -27,11 +32,34 @@ class SettingsController extends AdminBaseController
|
||||
public function __construct(
|
||||
private SettingsService $settingsService,
|
||||
private DriverConnectionTester $driverConnectionTester,
|
||||
private DriverRegistryService $driverRegistryService
|
||||
private DriverRegistryService $driverRegistryService,
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 시스템 설정을 조회합니다.
|
||||
*
|
||||
@@ -42,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())
|
||||
@@ -70,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,
|
||||
@@ -147,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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 캐시를 정리합니다.
|
||||
*
|
||||
@@ -188,23 +266,21 @@ class SettingsController extends AdminBaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스를 백업합니다.
|
||||
* 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다.
|
||||
*
|
||||
* @return JsonResponse 백업 결과 JSON 응답
|
||||
* 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다.
|
||||
* 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고,
|
||||
* PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서
|
||||
* 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3).
|
||||
*
|
||||
* 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다.
|
||||
* 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다.
|
||||
*
|
||||
* @return JsonResponse 미제공 안내 JSON 응답 (501)
|
||||
*/
|
||||
public function backupDatabase(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->settingsService->backupDatabase();
|
||||
|
||||
if ($result) {
|
||||
return $this->success('settings.backup_success');
|
||||
} else {
|
||||
return $this->error('settings.backup_failed');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('settings.backup_error', 500, $e->getMessage());
|
||||
}
|
||||
return $this->error('settings.database_backup_unavailable', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,4 +418,30 @@ class SettingsController extends AdminBaseController
|
||||
return $this->error('settings.driver_test_error', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 아웃바운드 프록시 연결을 테스트합니다.
|
||||
*
|
||||
* 저장하기 전에 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
|
||||
* 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야
|
||||
* 하는 값이라 결과의 핵심입니다.
|
||||
*
|
||||
* 검사 대상은 저장된 설정이 아니라 이번 요청이 제출한 값입니다.
|
||||
*
|
||||
* @param TestOutboundProxyRequest $request 검증된 요청
|
||||
* @return JsonResponse 검사 결과
|
||||
*/
|
||||
public function testOutboundProxy(TestOutboundProxyRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$result = $this->outboundProxyTester->test(
|
||||
(string) $validated['outbound_proxy'],
|
||||
(array) ($validated['outbound_proxy_bypass'] ?? [])
|
||||
);
|
||||
|
||||
// 연결 실패는 요청 처리 실패가 아니라 진단 결과다 — 200 으로 결과를 돌려주고
|
||||
// 성공 여부는 페이로드가 말한다 (드라이버 연결 테스트와 같은 규약).
|
||||
return $this->success($result['message_key'], $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Exceptions\TemplateOperationException;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
|
||||
@@ -169,7 +170,9 @@ class TemplateController extends AdminBaseController
|
||||
|
||||
return $this->success('templates.install_success', $payload, 201);
|
||||
} else {
|
||||
return $this->error('templates.install_failed');
|
||||
return $this->error('templates.install_failed', 400, null, [
|
||||
'error' => __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
// Service에서 이미 번역된 메시지를 errors에 포함하므로
|
||||
@@ -230,10 +233,12 @@ class TemplateController extends AdminBaseController
|
||||
'pending_language_packs' => $pendingLanguagePacks,
|
||||
]));
|
||||
} else {
|
||||
return $this->error('templates.activate_failed');
|
||||
return $this->error('templates.activate_failed', 400, null, [
|
||||
'error' => $result['reason'] ?? __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.activate_failed', 422, $e->errors());
|
||||
return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -249,7 +254,7 @@ class TemplateController extends AdminBaseController
|
||||
{
|
||||
try {
|
||||
$templateName = $request->validated()['template_name'];
|
||||
$template = $this->templateService->deactivateTemplate($templateName);
|
||||
$template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason);
|
||||
|
||||
if ($template) {
|
||||
return $this->successWithResource(
|
||||
@@ -257,10 +262,12 @@ class TemplateController extends AdminBaseController
|
||||
new TemplateResource($template)
|
||||
);
|
||||
} else {
|
||||
return $this->error('templates.deactivate_failed');
|
||||
return $this->error('templates.deactivate_failed', 400, null, [
|
||||
'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.deactivate_failed', 422, $e->errors());
|
||||
return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -279,15 +286,21 @@ 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');
|
||||
return $this->error('templates.uninstall_failed', 400, null, [
|
||||
'error' => __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.uninstall_failed', 422, $e->errors());
|
||||
return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -348,8 +361,10 @@ class TemplateController extends AdminBaseController
|
||||
new TemplateResource($template),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (TemplateOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -372,8 +387,10 @@ class TemplateController extends AdminBaseController
|
||||
new TemplateResource($template),
|
||||
201
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), 422);
|
||||
} catch (TemplateOperationException $e) {
|
||||
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
|
||||
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
|
||||
return $this->error($e->errorKey, 422, null, $e->params);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -397,10 +414,12 @@ class TemplateController extends AdminBaseController
|
||||
new TemplateResource($template)
|
||||
);
|
||||
} else {
|
||||
return $this->error('templates.refresh_layouts_failed');
|
||||
return $this->error('templates.refresh_layouts_failed', 400, null, [
|
||||
'error' => __('templates.errors.unknown_error'),
|
||||
]);
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.refresh_layouts_failed', 422, $e->errors());
|
||||
return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -418,7 +437,7 @@ class TemplateController extends AdminBaseController
|
||||
|
||||
return $this->success('templates.check_updates_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.check_updates_failed', 422, $e->errors());
|
||||
return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -446,7 +465,7 @@ class TemplateController extends AdminBaseController
|
||||
|
||||
return $this->success('templates.check_modified_layouts_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors());
|
||||
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<?php
|
||||
|
||||
// audit:allow api-doc-coverage reason: 응답 계약 불변 — lang 키에서 :error 플레이스홀더가 제거되어 사문화된 messageParams 인자만 정리 (키·상태코드·payload 형태 무변경)
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Exceptions\CannotDeleteSuperAdminException;
|
||||
use App\Exceptions\CannotModifySuperAdminException;
|
||||
use App\Exceptions\PermissionEscalationException;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
|
||||
use App\Http\Requests\User\CheckEmailRequest;
|
||||
@@ -74,6 +78,8 @@ class UserController extends AdminBaseController
|
||||
new UserResource($user),
|
||||
201
|
||||
);
|
||||
} catch (PermissionEscalationException $e) {
|
||||
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('user.create_failed', 422, $e->errors());
|
||||
} catch (Exception $e) {
|
||||
@@ -122,10 +128,14 @@ class UserController extends AdminBaseController
|
||||
'user.update_success',
|
||||
new UserResource($updatedUser)
|
||||
);
|
||||
} catch (CannotModifySuperAdminException $e) {
|
||||
return $this->error('exceptions.cannot_modify_super_admin', 403);
|
||||
} catch (PermissionEscalationException $e) {
|
||||
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('user.update_failed', 422, $e->errors());
|
||||
} catch (Exception $e) {
|
||||
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
||||
return $this->error('user.update_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +158,10 @@ class UserController extends AdminBaseController
|
||||
'auth.account_unlocked',
|
||||
new UserResource($unlocked)
|
||||
);
|
||||
} catch (CannotModifySuperAdminException $e) {
|
||||
return $this->error('exceptions.cannot_modify_super_admin', 403);
|
||||
} catch (Exception $e) {
|
||||
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
|
||||
return $this->error('user.update_failed', 500, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,9 +329,13 @@ class UserController extends AdminBaseController
|
||||
$validated = $request->validated();
|
||||
$result = $this->userService->bulkUpdateStatus($validated['ids'], $validated['status']);
|
||||
|
||||
// 메시지 키(user.bulk_status_updated)가 :count 플레이스홀더를 가지므로
|
||||
// 치환 파라미터를 함께 전달한다 (생략 시 원문 ":count명의 …" 이 그대로 노출)
|
||||
return $this->success(
|
||||
'user.bulk_status_updated',
|
||||
$result
|
||||
$result,
|
||||
200,
|
||||
['count' => $result['updated_count'] ?? 0]
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('user.bulk_update_status_failed', 422, $e->errors());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user