v7.0.0 release

This commit is contained in:
HeuJung
2026-07-01 10:30:32 +09:00
parent 0e7fa04b03
commit c4ea9a6cd1
3582 changed files with 626189 additions and 160151 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.7
APP_VERSION=7.0.0
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.7
APP_VERSION=7.0.0
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+11
View File
@@ -104,6 +104,7 @@ boost.json
cypress/
cypress.config.js
.env.testing
.claude/settings.local.json
# ===== 보안: 민감 파일 방어 패턴 =====
*.pem
@@ -121,4 +122,14 @@ id_ed25519
*.sqlite
*.db
# NHN KCP 결제플러그인 PG 공개키는 예외 처리
!plugins/_bundled/*/bin/pub.key
.claude/tmp/
# ===== Playwright E2E =====
tests/Playwright/.auth/
playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
+20 -2
View File
@@ -97,12 +97,13 @@
| [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/) (29개)
### 확장 시스템 [extension/](docs/extension/) (30개)
| 문서 | 설명 | 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-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() - 부가 작업 (로그, 알림, 캐시) |
@@ -131,7 +132,7 @@
| [upgrade-step-guide.md](docs/extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) | upgrade step 이 실행되는 환경은 경로에 따라 다르다 — 섹션 9 "업그레이드 경로" 먼저 읽기 |
| [vendor-bundle.md](docs/extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) | - |
### 공통 (4개)
### 공통 (5개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -139,6 +140,7 @@
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
@@ -566,6 +568,20 @@ powershell -Command "npm run test:run"
---
## npm install 규칙
기본 `npm install`은 `package-lock.json`을 자동 수정할 수 있으므로, lock 파일 변경 의도가 없는 의존성 복구나 작업 환경 재구성에는 `npm install --package-lock=false`를 사용합니다.
| 상황 | 권장 명령어 | 비고 |
| ---- | ----------- | ---- |
| 누락 의존성 복구 / 작업 환경 재구성 | `npm install --package-lock=false` | lock 파일 변경 없이 설치 |
| clean install | `npm ci` | `package.json`과 `package-lock.json`이 동기화된 경우 |
| 의존성 신규 추가/업데이트 | `npm install <pkg>` | lock 변경이 작업 범위에 포함된 경우만 |
lock 파일 변경 의도가 없는 상황에서 `npm install` 단독 실행을 피합니다. `module.json`, `plugin.json`, `template.json`의 `version`을 바꾸면 해당 확장의 `package.json`, `package-lock.json`, `composer.json` 버전도 함께 동기화합니다. 의존성 재설치 없이 lock 파일의 version 필드만 갱신할 때는 `npm install --package-lock-only`를 사용합니다.
---
## 핵심 원칙
### 1. 동적 로딩
@@ -887,6 +903,8 @@ php artisan migrate:rollback
| `templates/**/src/components/**/*.tsx` | [components.md](docs/frontend/components.md) |
| `modules/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `plugins/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `lang/{ko,en}/**/*.php` | [database-guide.md](docs/database-guide.md) (다국어 섹션) — 코어 백엔드 다국어 |
| `lang/{ko,en}.json`, `lang/partial/{ko,en}/**` | [data-binding-i18n.md](docs/frontend/data-binding-i18n.md) — 코어 프론트엔드 다국어 (`$t:core.*`) |
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
+104
View File
@@ -4,6 +4,110 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.0] - 2026-07-01
### Added
- 모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.
- 관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.
- 관리자 대시보드에 최근 발송된 알림 이력을 보여주는 "최근 알림" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.
- 본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.
- 플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.
- 모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.
#### 위지윅 레이아웃 편집기 (신규 도입)
화면을 직접 보며 편집하는 위지윅 방식의 레이아웃 편집기를 이번 버전에서 새로 도입했습니다. 아래는 도입된 기능 영역별 상세입니다.
- 좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.
- 편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.
- 요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 "→ 이 화면 편집" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.
- 저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.
- 속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.
- 이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.
- 동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 "다른 페이지로 이동"·"안내 메시지 보여주기"·"화면 상태 바꾸기"·"목록 새로고침"·"서버에 보내고 결과 처리"·"결제 진입" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 "그리고/또는"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. "결제 진입" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.
- 색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 "기본값으로 초기화"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.
- 화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)
- 모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 "요소 ID" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.
- 모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 "이 화면의 모달" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 "주문 취소"·"비회원 조회 비밀번호 재설정"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.
- 버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.
- 공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.
- 화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.
- 일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 "데이터를 표시할 수 없습니다"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.
- 회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다.
### Changed
- 모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.
- 템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 "찾을 수 없음" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.
- 템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용
- 템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화
- 모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.
- 코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.
- 코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다.
### Fixed
#### 본인인증(IDV)
- 정책 관리에서 코어·모듈·플러그인이 제공한 정책의 "인증 목적"·"적용 대상"·"우선순위"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.
- 한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.
- 성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 "본인 확인이 필요합니다" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.
- 환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.
- 환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 "설정 저장에 실패했습니다" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.
- 정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.
- 코드 입력 모달의 "남은 시도 횟수" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.
- 인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 "최대 시도 초과 — 다시 요청해주세요" 안내 대신 일반 오류 문구("인증 코드가 올바르지 않습니다")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.
- 본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.
#### 확장 동기화·캐시·레이아웃 주입
- 확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.
- 확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.
- 모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.
- 모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 "물려받은 출처" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.
- 확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.
- 확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.
- 코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
- 관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 "백업 생성 중" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.
#### 알림
- 회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.
- 신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.
- 알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.
#### 설치·인스톨러
- 설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.
- 인스톨러에서 "기존 테이블 삭제 후 설치"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.
#### 관리자 화면·검색·시스템·보안
- 관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.
- 한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)
- 환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 "참/거짓 값이어야 합니다" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.
- 관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 "전체"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.
- 검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.
- IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)
- 관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 "알 수 없음"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)
- 시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 "알 수 없음"으로 표시되도록 보강했습니다.
- 웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)
- 사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)
- 존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)
- 인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 "인증 필요" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)
### Security
- 사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)
- 설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)
### Removed
- 기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다.
## [7.0.0-beta.7] - 2026-05-15
### Fixed
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.7 g7
# (필요 시) mv g7-7.0.0 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+17 -15
View File
@@ -8,12 +8,12 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.7-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0-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-Open%20Beta-orange" alt="Status"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
---
@@ -45,12 +45,12 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 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) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
@@ -100,15 +100,12 @@ Gnuboard7
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 현재 지원
#### 제공 기능
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
#### 지원 예정
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용하여, **드래그 앤 드롭 방식의 비주얼 에디터**를 통해 비개발자도 화면을 직접 구성할 수 있도록 지원할 계획입니다
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
```mermaid
flowchart TB
@@ -385,11 +382,12 @@ cp .env.example .env
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification** | 본인인증 |
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
### 템플릿
@@ -409,8 +407,11 @@ cp .env.example .env
| **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-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
@@ -435,8 +436,8 @@ cp .env.example .env
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | Beta |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | Beta |
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
---
@@ -484,7 +485,8 @@ cp .env.example .env
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>&nbsp;&nbsp;
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### Contributors
+5 -11
View File
@@ -2,12 +2,15 @@
namespace App\Console\Commands\Core;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildCoreCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -99,6 +102,7 @@ class BuildCoreCommand extends Command
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
}
return $result;
@@ -130,6 +134,7 @@ class BuildCoreCommand extends Command
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
}
return $result;
@@ -156,17 +161,6 @@ class BuildCoreCommand extends Command
$fileSize = number_format(filesize($engineFile) / 1024, 2);
$this->line(" - template-engine.min.js ({$fileSize} KB)");
}
// lang 파일 확인
$langPath = $corePath.'/lang';
if (is_dir($langPath)) {
$langFiles = glob($langPath.'/*.json');
foreach ($langFiles as $langFile) {
$fileName = 'lang/'.basename($langFile);
$fileSize = number_format(filesize($langFile) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
}
}
/**
@@ -3,13 +3,15 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Exceptions\UpgradeHandoffException;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
@@ -19,6 +21,7 @@ use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:update
@@ -457,6 +460,11 @@ class CoreUpdateCommand extends Command
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
$this->incrementExtensionCacheVersion();
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지).
// detailedSnapshot 동시 전달 — PHP-FPM 쓰기 영역의 owner/group/perms 를 항목별
@@ -514,6 +522,10 @@ class CoreUpdateCommand extends Command
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (UpgradeHandoffException $e) {
@@ -548,6 +560,10 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
$this->incrementExtensionCacheVersion();
// Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
@@ -587,6 +603,8 @@ class CoreUpdateCommand extends Command
$this->newLine();
}
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (\Throwable $e) {
@@ -680,6 +698,8 @@ class CoreUpdateCommand extends Command
}
}
$this->restoreUpgradeLogOwnership();
return Command::FAILURE;
}
}
@@ -703,9 +723,9 @@ class CoreUpdateCommand extends Command
* @param string $toVersion 대상 버전
* @param bool $force 동일 버전 강제 실행 여부
* @param \Closure $log 로그 엔트리 수집 콜백
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
*
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
*/
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
{
@@ -893,9 +913,9 @@ class CoreUpdateCommand extends Command
* @param \Closure $log 로그 엔트리 수집 콜백
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
*
* @throws UpgradeHandoffException mode=abort 일 때
* @throws UpgradeHandoffException mode=abort 일 때
*/
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
{
@@ -1000,10 +1020,44 @@ class CoreUpdateCommand extends Command
$content = $header."\n".implode("\n", $entries)."\n";
file_put_contents($logPath, $content);
// sudo 업데이트가 로그 파일을 root 소유로 만들면 이후 www-data 의 tinker/로그 쓰기가
// 거부된다. 부모(storage/logs) 소유권을 상속한다(멱등, sudo 없으면 silent no-op).
FilePermissionHelper::inheritOwnershipFromParent($logPath);
Log::info("코어 업데이트 로그 저장: {$logPath}");
}
/**
* 코어 업데이트(부모 프로세스)가 생성한 upgrade 로그 파일의 소유권을 부모(storage/logs)
* 로 정합합니다 — **spawn 실패로 부모가 upgrade step 을 in-process 로 직접 실행한 경우** 대비.
*
* upgrade 로그(`upgrade-YYYY-MM-DD.log`)를 만드는 주체는 두 갈래다:
* - spawn 성공: 자식(ExecuteUpgradeStepsCommand)이 로그 생성 → 자식이 자기 종료 직전 정합
* - spawn 실패(fallback): 부모가 runUpgradeSteps + reloadCoreConfigAndResync 를 직접 실행
* 하여 부모 프로세스가 upgrade 로그를 root 로 생성 → **부모가** 정합해야 한다.
*
* sudo 업데이트는 root 로 실행되어 로그를 root 소유로 만들고, 이후 www-data(php-fpm) 의
* module:update upgrade step 이 같은 날짜 로그에 append 하지 못해 Permission denied 로
* 실패한다. 본 메서드를 코어 업데이트의 모든 로그 쓰기가 끝난 종료 시점(각 return 직전)에
* 호출한다. **본 메서드 자체는 로그를 쓰지 않는다**(쓰면 다시 root 가 됨). silent no-op 멱등.
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
/**
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
*
@@ -1059,9 +1113,7 @@ class CoreUpdateCommand extends Command
* 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
* 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
*
* @param CoreUpdateService $service
* @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
* @return void
*/
private function surfacePermissionWarnings(CoreUpdateService $service, callable $log): void
{
@@ -5,9 +5,12 @@ namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\ExtensionManager;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -28,23 +31,30 @@ use Illuminate\Support\Facades\Log;
* 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
* 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
* CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
*
* `--steps-only`: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 ·
* 오토로드 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를
* 생략한다. 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다. 보조 단계가
* 이미 완료된 환경에서 무거운 권한 재귀 순회 등을 건너뛴다.
*/
class ExecuteUpgradeStepsCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:execute-upgrade-steps
{--from= : 시작 버전}
{--to= : 대상 버전}
{--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
{--steps-only : 업그레이드 스텝만 실행 — 권한 정상화·오토로드 재생성·마이그레이션·resync·버전 갱신·캐시 정리·번들 업데이트 등 모든 사전/사후 보조 단계 생략}
{--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
{--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
{--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
{--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
{--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행합니다.';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행하며, --steps-only 로 스텝만 실행할 수 있습니다.';
/**
* 커맨드를 실행합니다.
@@ -58,6 +68,11 @@ class ExecuteUpgradeStepsCommand extends Command
$to = (string) $this->option('to');
$force = (bool) $this->option('force');
// --steps-only: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 · 오토로드
// 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를 생략한다.
// 운영자가 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다.
$stepsOnly = (bool) $this->option('steps-only');
if ($from === '' || $to === '') {
$this->error('--from 과 --to 는 필수 옵션입니다.');
@@ -75,12 +90,14 @@ class ExecuteUpgradeStepsCommand extends Command
// `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성
// 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
if (! $stepsOnly) {
try {
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
}
}
// spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
@@ -90,23 +107,25 @@ class ExecuteUpgradeStepsCommand extends Command
//
// 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
\Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg);
},
);
if (! $stepsOnly) {
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
Log::channel('upgrade')->$level('[spawn] '.$msg);
},
);
}
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
'error' => $e->getMessage(),
]);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
'error' => $e->getMessage(),
]);
}
// 코어 업데이트 spawn 자식 감지.
@@ -130,18 +149,18 @@ class ExecuteUpgradeStepsCommand extends Command
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
// migration / permission / menu / seeder 누락 차단.
if (! $this->option('skip-migrations') && ! $isSpawnChild) {
if (! $this->option('skip-migrations') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('마이그레이션 실행');
$service->runMigrations();
} else {
$this->info('[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
$this->info($stepsOnly ? '[steps-only] 마이그레이션 스킵' : '[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
}
if (! $this->option('skip-resync') && ! $isSpawnChild) {
if (! $this->option('skip-resync') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
$service->reloadCoreConfigAndResync();
} else {
$this->info('[spawn] resync 스킵 — 부모가 이미 실행');
$this->info($stepsOnly ? '[steps-only] resync 스킵' : '[spawn] resync 스킵 — 부모가 이미 실행');
}
$stepsExecuted = 0;
@@ -180,6 +199,8 @@ class ExecuteUpgradeStepsCommand extends Command
'reason' => $e->reason,
]);
$this->restoreUpgradeLogOwnership();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
Log::error('core:execute-upgrade-steps 실패', [
@@ -189,6 +210,8 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
return self::FAILURE;
}
@@ -209,21 +232,25 @@ class ExecuteUpgradeStepsCommand extends Command
// --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
// 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
// (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
if (! $this->option('skip-version-env') && ! $isSpawnChild) {
if (! $this->option('skip-version-env') && ! $isSpawnChild && ! $stepsOnly) {
$this->info(".env APP_VERSION={$to} 갱신");
$service->updateVersionInEnv($to);
} else {
$this->info('[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
$this->info($stepsOnly ? '[steps-only] .env APP_VERSION 갱신 스킵' : '[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
}
if (! $this->option('skip-cache-clear') && ! $isSpawnChild) {
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
$this->incrementExtensionCacheVersion();
} else {
$this->info('[spawn] 캐시 정리 스킵 — 부모가 처리');
$this->info($stepsOnly ? '[steps-only] 캐시 정리 스킵' : '[spawn] 캐시 정리 스킵 — 부모가 처리');
}
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild) {
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
$this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
@@ -232,9 +259,42 @@ class ExecuteUpgradeStepsCommand extends Command
$force,
);
} else {
$this->info('[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
$this->info($stepsOnly ? '[steps-only] 번들 확장 일괄 업데이트 스킵' : '[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
}
$this->restoreUpgradeLogOwnership();
return self::SUCCESS;
}
/**
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
*
* upgrade step 은 sudo 코어 업데이트에서 별도 PHP 프로세스로 spawn 되며(이 커맨드),
* root 로 실행되어 `upgrade-YYYY-MM-DD.log`(Log::channel('upgrade')) 를 root 소유로
* 만든다. 부모(CoreUpdateCommand) 프로세스는 업데이트 시작 시점의 *이전 버전* 클래스를
* 메모리에 들고 있어, 부모 측 로그 정상화 로직이 추가돼 있어도 그 버전엔 없을 수 있다
* (클래스 캐싱). 반면 본 spawn 자식은 항상 *신버전* 코드로 실행되므로, upgrade 로그를
* 만든 주체인 자식이 자기 종료 직전에 직접 소유권을 정합하면 부모 버전과 무관하게 동작한다.
*
* 그 결과 이후 www-data(php-fpm) 의 module:update upgrade step 이 같은 날짜 upgrade
* 로그에 append 할 수 있다. 본 메서드는 어떤 로그도 쓰지 않는다(쓰면 다시 root 가 됨).
* sudo 아닌 환경에서는 silent no-op(멱등).
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -196,6 +198,14 @@ class BuildModuleCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -5,8 +5,10 @@ namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -36,7 +38,8 @@ class UpdateModuleCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
private ModuleRepositoryInterface $moduleRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -120,6 +123,30 @@ class UpdateModuleCommand extends Command
$this->info('업데이트 버전: (module.json 추출 후 판별)');
}
$this->info(__('modules.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Module,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('modules.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('modules.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
/**
* Playwright E2E 용 Sanctum 토큰 발급 커맨드.
*
* 임의 권한 식별자(코어/모듈/플러그인) 를 받아 권한 보유 관리자 유저를 즉시 생성하고
* Sanctum 개인 액세스 토큰을 발급한다. 발급된 토큰은 stdout 마지막 줄에 출력되어
* Playwright fixture 가 stdin 캡처로 사용한다.
*
* 보안 가드 (3중):
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
* ③ APP_DEBUG 강제 — bypass flag 인지 후 `config('app.debug')` 를 true 로 inline override.
* SettingsServiceProvider 의 testing/bypass 분기가 이미 settings JSON 덮어쓰기를 건너뛰므로
* production + debug=false 환경에서도 토큰 발급이 가능하다
*
* 환경 매트릭스:
* - local + bypass=1 : 로컬 개발자 PC 에서 직접 spec 작성/실행
* - testing + bypass=1 : CI / PHPUnit 환경에서 .env.testing 로 동작하는 E2E 통합 (testing 환경은 이미 testing 가드로 통과)
* - production + bypass=1 : production DB 가 활성 호스트를 가리키는 환경(예: g7.dev) 에서 E2E
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:issue-token --permissions=core.templates.layouts.edit
*
* 로직 출처: tests/Feature/Api/Admin/LayoutAccessCheckEndpointTest::makeAdminUser (44~87행)
*/
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
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 환경에서도 sanctum 토큰 발급 + 디버그 정보 누락 방지.
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
return self::SUCCESS;
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
* 절차:
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
*/
private function makeAdminUser(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -196,6 +198,14 @@ class BuildPluginCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -5,8 +5,10 @@ namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -36,7 +38,8 @@ class UpdatePluginCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
private PluginRepositoryInterface $pluginRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -119,6 +122,30 @@ class UpdatePluginCommand extends Command
$this->info('업데이트 버전: (plugin.json 추출 후 판별)');
}
$this->info(__('plugins.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Plugin,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('plugins.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('plugins.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use Illuminate\Console\Command;
/**
* 레이아웃 버전의 변경 요약(changes_summary)을 현재 알고리즘으로 재계산하는 커맨드
*
* 버전 변경량 측정이 키 경로 단위에서 라인 단위(버전 비교 diff 뷰와 동일 SSoT)로
* 바뀌기 전에 저장된 버전들은 옛 기준의 changes_summary 를 담고 있어, 버전 목록의
* 변경량이 상세 diff 와 불일치한다. 본 커맨드로 기존 버전을 일괄 재계산해 정합시킨다.
*/
class RecalculateLayoutVersionDiffsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'layout-versions:recalculate-diffs';
/**
* The console command description.
*/
protected $description = '레이아웃 버전의 변경 요약을 현재 라인 단위 알고리즘으로 재계산합니다';
/**
* Execute the console command.
*
* @param LayoutVersionRepositoryInterface $repository 버전 저장소
* @return int 명령 실행 결과 코드
*/
public function handle(LayoutVersionRepositoryInterface $repository): int
{
$this->info('레이아웃 버전 변경 요약 재계산을 시작합니다...');
$updated = $repository->recalculateAllChangeSummaries();
$this->info("버전 {$updated}건의 변경 요약을 재계산했습니다.");
return self::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Contracts\Extension;
/**
* 캐시/스토리지 도메인 분리를 제공하는 확장의 좁은 contract.
*
* AbstractModule / AbstractPlugin 이 공통으로 노출하는 캐시·스토리지
* 접근자를 한 인터페이스로 묶어, AbstractExtensionServiceProvider 가
* 모듈·플러그인 양쪽을 균등하게 다룰 수 있도록 합니다.
*/
interface CacheableExtensionInterface
{
/**
* 확장 식별자를 반환합니다 (vendor-extension 형식).
*
* @return string 확장 식별자
*/
public function getIdentifier(): string;
/**
* 확장 도메인에 격리된 스토리지 드라이버를 반환합니다.
*
* @return StorageInterface 확장 전용 스토리지 인스턴스
*/
public function getStorage(): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
* @return CacheInterface 확장 전용 캐시 인스턴스
*/
public function getCache(): CacheInterface;
}
@@ -35,6 +35,19 @@ interface IdentityVerificationInterface
*/
public function getChannels(): array;
/**
* 채널 키 → 다국어 표시 라벨 맵.
*
* 각 프로바이더는 자신이 지원하는 모든 채널의 표시 라벨을 제공해야 합니다.
* 관리자 이력 화면 등에서 채널 식별자(`email`, `ipin`)를 사람이 읽는
* 이름(`이메일`, `아이핀`)으로 표시하는 데 사용됩니다.
* 라벨은 `getLabel()` 과 동일하게 `__()` 로 다국어 처리하여 언어팩 활성화 시
* 번역이 적용되도록 합니다.
*
* @return array<string, string> 채널 키 → 라벨 맵 (예: ['email' => '이메일'])
*/
public function getChannelLabels(): array;
/**
* 프론트가 challenge 를 렌더하는 방법 힌트.
*
+1 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface ModuleInterface
interface ModuleInterface extends CacheableExtensionInterface
{
/**
* 모듈명 반환 (표시용)
+1 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface PluginInterface
interface PluginInterface extends CacheableExtensionInterface
{
/**
* 플러그인의 고유 식별자를 반환합니다 (vendor-plugin 형식).
@@ -0,0 +1,23 @@
<?php
namespace App\Contracts\Notifications;
/**
* 비회원(게스트) 알림 수신자 계약
*
* user_id 없이 이메일/이름/로케일만 가진 익명 수신자를 1급 수신자로 표현합니다.
* 알림 채널 게이트(GenericNotification::via)는 구체 타입(User 등) 검사 대신
* 이 계약으로 게스트 여부를 판별하여, 채널별 게스트 발송 허용 정책을 적용합니다.
*
* 구현체는 Laravel Notifiable 트레잇을 함께 사용해 회원과 동일한
* `$notifiable->notify()` 발송 경로를 공유합니다.
*/
interface GuestRecipientInterface
{
/**
* 게스트(비회원) 수신자 여부를 반환합니다.
*
* @return bool true = 비회원 수신자
*/
public function isGuest(): bool;
}
@@ -3,6 +3,7 @@
namespace App\Contracts\Repositories;
use App\Models\IdentityPolicy;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
@@ -19,15 +20,23 @@ interface IdentityPolicyRepositoryInterface
* key 로 조회합니다.
*
* @param string $key 정책 키 (예: core.auth.signup_before_submit)
* @return IdentityPolicy|null
*/
public function findByKey(string $key): ?IdentityPolicy;
/**
* key + source_type 조합으로 정책 존재 여부를 확인합니다.
*
* 운영자 UI 의 scope_value 매핑 검증 등 "특정 출처 정책" 만 허용하는 검증 경로에서 사용.
*
* @param string $key 정책 키
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
*/
public function existsByKeyAndSourceType(string $key, string $sourceType): bool;
/**
* id 로 조회합니다.
*
* @param int $id 정책 PK
* @return IdentityPolicy|null
*/
public function findById(int $id): ?IdentityPolicy;
@@ -87,6 +96,19 @@ interface IdentityPolicyRepositoryInterface
*/
public function cleanupStale(string $sourceType, string $sourceIdentifier, array $currentKeys): int;
/**
* source_type+source_identifier 에 속하면서 currentKeys 에 없는 stale 정책을 조회합니다.
*
* bulk delete(cleanupStale) 와 달리 모델 인스턴스를 반환하므로, 호출 측이 per-model
* delete()(deleted 이벤트 발화 — 라우트 스코프 캐시 flush)와 로깅을 수행할 수 있다.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier vendor 식별자
* @param array<int, string> $currentKeys 현재 선언된 key 목록
* @return Collection<int, IdentityPolicy> stale 정책 목록
*/
public function findStale(string $sourceType, string $sourceIdentifier, array $currentKeys): Collection;
/**
* 특정 source(확장) 가 등록한 정책 개수를 반환합니다.
*
@@ -94,7 +116,6 @@ interface IdentityPolicyRepositoryInterface
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier 확장 식별자
* @return int
*/
public function countBySource(string $sourceType, string $sourceIdentifier): int;
@@ -103,7 +124,7 @@ interface IdentityPolicyRepositoryInterface
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지 크기
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
* @return LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20);
@@ -14,8 +14,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 확장점에 등록된 확장 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getByExtensionPoint(int $templateId, string $extensionPointName): Collection;
@@ -23,8 +23,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 레이아웃을 타겟으로 하는 오버레이 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getOverlaysByLayout(int $templateId, string $layoutName): Collection;
@@ -32,8 +32,7 @@ interface LayoutExtensionRepositoryInterface
/**
* 확장 등록
*
* @param array $data 확장 데이터
* @return LayoutExtension
* @param array $data 확장 데이터
*/
public function create(array $data): LayoutExtension;
@@ -42,17 +41,16 @@ interface LayoutExtensionRepositoryInterface
*
* 동일한 조건의 확장이 존재하면 업데이트하고, 없으면 생성합니다.
*
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
* @return LayoutExtension
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
*/
public function updateOrCreate(array $attributes, array $values): LayoutExtension;
/**
* 출처별 확장 삭제 (soft delete)
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function softDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -60,8 +58,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 복원
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 복원된 레코드 수
*/
public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -69,8 +67,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 영구 삭제
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -80,9 +78,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 extension_point에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForExtensionPoint(
@@ -96,9 +94,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 target_layout에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForOverlay(
@@ -113,8 +111,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedExtensionPoints(int $templateId, string $extensionPointName): Collection;
@@ -125,8 +123,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedOverlays(int $templateId, string $layoutName): Collection;
@@ -134,15 +132,92 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 템플릿의 모든 확장 조회
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿의 확장을 오버라이드 해석을 적용해 조회
*
* 템플릿 오버라이드에 가려져 화면에 적용되지 않는 모듈/플러그인 확장을
* 제외합니다. 레이아웃 편집 화면 트리가 실제 화면에 반영되는 확장만
* 보여주도록 사용합니다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getResolvedByTemplateId(int $templateId): Collection;
/**
* 템플릿 오버라이드 해석을 적용해 가려진 확장을 제거
*
* 동일한 (extension_type, target_name) 범위의 확장만 전달해야 합니다.
*
* @param Collection<int, LayoutExtension> $extensions 동일 범위 확장 컬렉션
* @return Collection<int, LayoutExtension>
*/
public function resolveOverrides(Collection $extensions): Collection;
/**
* ID로 단일 확장 조회
*
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null
*/
public function findById(int $extensionId): ?LayoutExtension;
/**
* 특정 출처(모듈/플러그인)의 모든 확장 조회
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return Collection<int, LayoutExtension>
*/
public function getBySource(LayoutSourceType $sourceType, string $identifier): Collection;
/**
* 조회 조건에 일치하는 확장을 조회
*
* @param array $attributes 조회 조건
* @return LayoutExtension|null 일치하는 확장 또는 null
*/
public function findByAttributes(array $attributes): ?LayoutExtension;
/**
* 조회 조건에 일치하는 모든 LIVE 확장을 템플릿 구분 없이 조회 (cross-template 판정용)
*
* @param array $attributes 조회 조건 (template_id 제외)
* @return \Illuminate\Database\Eloquent\Collection<int, LayoutExtension>
*/
public function getAllByAttributesAcrossTemplates(array $attributes): \Illuminate\Database\Eloquent\Collection;
/**
* 확장 업데이트
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트할 데이터
* @return LayoutExtension 업데이트된 확장 모델
*/
public function update(int $extensionId, array $data): LayoutExtension;
/**
* 확장 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 update 데이터에 lock_version 증가를 함께 반영한다.
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트 데이터 (content, priority 등)
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return LayoutExtension 업데이트된 확장 모델
*/
public function updateWithLock(int $extensionId, array $data, int $newLockVersion): LayoutExtension;
/**
* 특정 템플릿의 모든 확장 삭제
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return int 삭제된 레코드 수
*/
public function deleteByTemplateId(int $templateId): int;
@@ -0,0 +1,75 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutExtensionVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutExtensionVersionRepositoryInterface
{
/**
* 버전 저장 (자동 증가)
*
* @param int $extensionId 레이아웃 확장 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
*/
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
/**
* 특정 확장의 모든 버전 조회 (최신순)
*
* @param int $extensionId 레이아웃 확장 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $extensionId): Collection;
/**
* 확장 ID 목록의 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 라우트 트리의 확장 노드 버전 배지 데이터
* 소스로, 버전 이력이 1건 이상인 확장만 포함된다(미저장 확장 = 원본 → 맵 제외).
*
* @param array<int> $extensionIds 확장 ID 목록
* @return array<int, int> 확장 ID → 최신 버전 번호
*/
public function getCurrentVersionsByExtensionIds(array $extensionIds): array;
/**
* 특정 버전 조회
*
* @param int $versionId 버전 ID
* @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null
*/
public function getVersion(int $versionId): ?TemplateLayoutExtensionVersion;
/**
* 다음 버전 번호 계산
*
* @param int $extensionId 레이아웃 확장 ID
* @return int 다음 버전 번호
*/
public function getNextVersion(int $extensionId): int;
/**
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
/**
* 버전 복원
*
* @param int $extensionId 레이아웃 확장 ID
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutExtensionVersion 복원 후 생성된 새 버전 모델
*
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $extensionId, int $versionId): TemplateLayoutExtensionVersion;
}
@@ -43,6 +43,44 @@ interface LayoutRepositoryInterface
*/
public function exists(int $templateId, string $name): bool;
/**
* 특정 템플릿의 레이아웃 중 지정한 확장점(extension_point)을 정의한 것이 있는지 확인
*
* 레이아웃 content JSON 트리를 재귀 순회하여 `type: extension_point` 노드의
* `name` 이 일치하는지 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return bool 정의 존재 여부
*/
public function hasExtensionPoint(int $templateId, string $extensionPointName): bool;
/**
* 지정 extension_point 를 포함하는 레이아웃 이름 목록을 반환합니다.
*
* 확장 편집 모드에서 extension_point 확장의 대표 호스트 레이아웃 선택(picker)용. 여러
* 레이아웃에 같은 확장점이 있으면 모두 반환한다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return array<int, string> 호스트 레이아웃 이름 목록
*/
public function findLayoutNamesWithExtensionPoint(int $templateId, string $extensionPointName): array;
/**
* 레이아웃 content 트리에 지정 노드 id 가 존재하는지 확인합니다.
*
* overlay 확장의 호스트 유효성 판정용 — overlay 는 `injections[].target_id` 위치에 주입되므로,
* 호스트 레이아웃에 그 id 노드가 없으면 실제로 주입되지 않는다(`applyExtensions` no-op).
* 노드 식별자는 `id` 또는 `props.id` 두 형태를 모두 검사한다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $nodeId 찾을 노드 id
* @return bool 노드 id 존재 여부
*/
public function layoutContainsNodeId(int $templateId, string $layoutName, string $nodeId): bool;
/**
* extends를 가진 자식 레이아웃 조회
*
@@ -61,6 +99,19 @@ interface LayoutRepositoryInterface
*/
public function update(int $id, array $data): TemplateLayout;
/**
* 레이아웃 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 content 교체와 lock_version 증가를 한 번의 UPDATE 로 수행한다.
*
* @param int $id 레이아웃 ID
* @param array $content 전체 레이아웃 JSON content
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return TemplateLayout 업데이트된 레이아웃 모델
*/
public function updateContent(int $id, array $content, int $newLockVersion): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
*
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutVersionRepositoryInterface
{
@@ -42,11 +43,11 @@ interface LayoutVersionRepositoryInterface
public function getNextVersion(int $layoutId): int;
/**
* JSON content 변경사항 계산
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array 변경사항 (added, removed, modified)
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
@@ -57,7 +58,30 @@ interface LayoutVersionRepositoryInterface
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutVersion 복원 후 생성된 새 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException 버전을 찾을 수 없는 경우
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $layoutId, int $versionId): TemplateLayoutVersion;
}
/**
* 템플릿의 레이아웃별 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 좌측 라우트 트리의 버전 배지 데이터 소스로,
* 해당 템플릿에 속한 레이아웃 중 버전 이력이 1건 이상인 레이아웃만 포함된다
* (한 번도 저장된 적 없는 레이아웃 = 원본 상태 → 맵에 없음 → 배지 미표시).
*
* @param int $templateId 대상 템플릿 ID
* @return array<string, int> 레이아웃 이름 → 최신 버전 번호
*/
public function getCurrentVersionsByTemplateId(int $templateId): array;
/**
* 모든 버전의 changes_summary 를 현재 알고리즘으로 재계산하여 갱신합니다.
*
* 측정 알고리즘 변경(키 경로 단위 → 라인 단위) 이전에 저장된 버전들은 옛 기준의
* changes_summary 를 담고 있다. 각 레이아웃의 버전을 버전 순으로 정렬해 인접 쌍으로
* 재계산하여 갱신한다(첫 버전은 baseline 으로 빈 요약).
*
* @return int 갱신된 버전 수
*/
public function recalculateAllChangeSummaries(): int;
}
@@ -4,10 +4,19 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
{
/**
* 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림).
*
* @param int $limit 조회 건수
* @return Collection<int, NotificationLog> 최근 알림 로그 컬렉션
*/
public function getRecent(int $limit): Collection;
/**
* ID로 알림 로그 조회.
*/
@@ -39,6 +39,14 @@ interface RoleRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Role;
/**
* 식별자로 역할에 소속된 사용자들을 조회합니다.
*
* @param string $identifier 역할 식별자
* @return Collection 역할 소속 사용자 컬렉션
*/
public function getUsersByIdentifier(string $identifier): Collection;
/**
* 새로운 역할을 생성합니다.
*
@@ -0,0 +1,104 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateCustomTranslation;
use Illuminate\Database\Eloquent\Collection;
interface TemplateCustomTranslationRepositoryInterface
{
/**
* 특정 템플릿의 커스텀 다국어 키 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 필터 (null 이면 전체)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateId(int $templateId, ?string $layoutName = null, ?string $status = null): Collection;
/**
* 특정 템플릿의 활성 커스텀 다국어 키 조회 (런타임 병합용)
*
* @param int $templateId 템플릿 ID
* @return Collection<int, TemplateCustomTranslation> 활성 커스텀 키 컬렉션
*/
public function getActiveByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿 + 레이아웃의 커스텀 키 조회 (좀비 감지용)
*
* `layout_name` 으로 격리해, 해당 레이아웃 저장 시 그 레이아웃 출처 키만
* 좀비 후보로 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름 (원본, 예: board/list)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateIdAndLayout(int $templateId, string $layoutName, ?string $status = null): Collection;
/**
* 주어진 ID 목록의 상태를 일괄 변경합니다 (active ↔ orphaned 전이).
*
* @param array<int, int> $ids 대상 커스텀 키 ID 목록
* @param string $status 새 상태 (active|orphaned)
* @return int 변경된 행 수
*/
public function updateStatus(array $ids, string $status): int;
/**
* ID로 커스텀 다국어 키 조회
*
* @param int $id 커스텀 키 ID
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findById(int $id): ?TemplateCustomTranslation;
/**
* 템플릿 + 키로 조회
*
* @param int $templateId 템플릿 ID
* @param string $translationKey 다국어 키
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findByKey(int $templateId, string $translationKey): ?TemplateCustomTranslation;
/**
* 특정 템플릿 + 레이아웃의 최대 seq 조회
*
* `custom.{layout}.{seq}` 형식 키에서 seq 의 현재 최대값을 반환합니다.
* 신규 키 생성 시 다음 seq 계산에 사용됩니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutKey 키 네임스페이스 (레이아웃명 정규화)
* @return int 현재 최대 seq (없으면 0)
*/
public function getMaxSeq(int $templateId, string $layoutKey): int;
/**
* 커스텀 다국어 키 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateCustomTranslation 생성된 모델
*/
public function create(array $data): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 값 수정 (lock_version 갱신 포함)
*
* @param int $id 커스텀 키 ID
* @param array<string, mixed> $data 수정 데이터
* @param int $newLockVersion 새 잠금 버전
* @return TemplateCustomTranslation 수정된 모델
*/
public function update(int $id, array $data, int $newLockVersion): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 삭제
*
* @param int $id 커스텀 키 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
}
@@ -0,0 +1,45 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutAttachment;
use Illuminate\Database\Eloquent\Collection;
/**
* 템플릿 레이아웃 첨부 파일 Repository 인터페이스
*/
interface TemplateLayoutAttachmentRepositoryInterface
{
/**
* ID로 첨부 파일 조회
*
* @param int $id 첨부 파일 ID
* @return TemplateLayoutAttachment|null 첨부 파일 또는 null
*/
public function findById(int $id): ?TemplateLayoutAttachment;
/**
* 템플릿(+선택적 레이아웃)별 첨부 파일 목록 조회 (최신순)
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 (null 이면 템플릿 전체)
* @return Collection<int, TemplateLayoutAttachment>
*/
public function listForTemplate(int $templateId, ?string $layoutName = null): Collection;
/**
* 첨부 파일 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateLayoutAttachment 생성된 첨부 파일
*/
public function create(array $data): TemplateLayoutAttachment;
/**
* 첨부 파일 삭제 (DB 행만 — 스토리지 파일 삭제는 Service 책임)
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
*/
public function delete(TemplateLayoutAttachment $attachment): bool;
}
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
interface UserRepositoryInterface
{
@@ -104,6 +105,29 @@ interface UserRepositoryInterface
*/
public function findManyByUuidsKeyed(array $uuids): Collection;
/**
* UUID 목록으로 사용자들을 조회합니다.
*
* @param array<int, string> $uuids 사용자 UUID 목록
* @return Collection<int, User> 조회된 사용자 컬렉션
*/
public function findManyByUuids(array $uuids): Collection;
/**
* 슈퍼관리자 1명을 조회합니다.
*
* @return User|null 슈퍼관리자 또는 없으면 null
*/
public function findSuperAdmin(): ?User;
/**
* 특정 권한 identifier 를 가진 역할에 소속된 모든 사용자를 조회합니다.
*
* @param string $permissionIdentifier 권한 identifier
* @return Collection<int, User> 권한 보유 사용자 컬렉션
*/
public function findManyByPermissionIdentifier(string $permissionIdentifier): Collection;
/**
* 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
*
@@ -122,9 +146,9 @@ interface UserRepositoryInterface
*
* @param User $user 잠글 사용자
* @param int $minutes 잠금 유지 시간(분)
* @return \Illuminate\Support\Carbon 잠금 해제 시각
* @return Carbon 잠금 해제 시각
*/
public function lockAccount(User $user, int $minutes): \Illuminate\Support\Carbon;
public function lockAccount(User $user, int $minutes): Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
@@ -133,7 +157,6 @@ interface UserRepositoryInterface
* `locked_until=null`, `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
* @return void
*/
public function resetLoginAttempts(User $user): void;
@@ -146,4 +169,4 @@ interface UserRepositoryInterface
* @return bool 잠금 여부
*/
public function isLocked(User $user): bool;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* 낙관적 잠금 충돌 예외 — 동일 행에 대해 두 클라이언트가 동시에 저장을 시도해
* `lock_version` 이 기대값과 일치하지 않을 때 발생합니다. 컨트롤러는 이 예외를
* 잡아 409 Conflict 응답으로 변환하고, 프론트엔드는 "다른 사용자가 먼저
* 저장했습니다" 안내 모달을 표시합니다.
*/
class ConcurrentModificationException extends RuntimeException
{
public function __construct(
public readonly int $currentVersion,
public readonly int $expectedVersion,
public readonly string $resource,
) {
parent::__construct(__('exceptions.concurrent_modification', [
'resource' => $resource,
'current' => $currentVersion,
'expected' => $expectedVersion,
]));
}
}
@@ -0,0 +1,177 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\CacheableExtensionInterface;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\StorageInterface;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;
/**
* 확장(모듈/플러그인) 서비스 프로바이더 공통 베이스.
*
* 모듈·플러그인이 공유하는 자동 바인딩 표면(Repository, Storage, Cache)과
* 다국어/마이그레이션 로드 hook 을 단일 클래스에 집약합니다. 자식 베이스
* (BaseModuleServiceProvider / BasePluginServiceProvider) 는 확장 인스턴스
* 해석 방식만 결정합니다.
*
* 글로벌 컨테이너 바인딩(`$this->app->singleton(CacheInterface::class, ...)`)
* 으로 코어 도메인을 덮어쓰는 패턴은 금지됩니다. cacheServices 배열에 클래스만
* 등록하면 Laravel contextual binding 으로 해당 확장 도메인의 캐시 인스턴스가
* 자동 주입됩니다.
*/
abstract class AbstractExtensionServiceProvider extends ServiceProvider
{
/**
* 확장 식별자 (vendor-extension). 자식 클래스에서 반드시 정의.
*
* @var string
*/
protected string $extensionIdentifier;
/**
* StorageInterface 가 필요한 서비스 클래스 목록.
*
* 등록된 클래스의 생성자에서 StorageInterface 를 의존하면, 해당 확장
* 도메인의 Storage 인스턴스가 contextual binding 으로 자동 주입됩니다.
*
* @var array<int, class-string>
*/
protected array $storageServices = [];
/**
* CacheInterface 가 필요한 서비스 클래스 목록.
*
* @var array<int, class-string>
*/
protected array $cacheServices = [];
/**
* Repository 인터페이스 ↔ 구현체 매핑.
*
* @var array<class-string, class-string>
*/
protected array $repositories = [];
/**
* ServiceProvider 파일이 위치한 디렉토리 경로 (캐시).
*/
private ?string $providerPath = null;
/**
* 확장 인스턴스를 해석합니다.
*
* BaseModuleServiceProvider 는 ModuleManager, BasePluginServiceProvider 는
* PluginManager 를 통해 해당 식별자의 확장을 가져옵니다.
*
* @return CacheableExtensionInterface 캐시/스토리지 도메인을 보유한 확장
*/
abstract protected function resolveExtension(): CacheableExtensionInterface;
/**
* 다국어 도메인 이름. 기본값은 확장 식별자이며 자식이 필요 시 오버라이드.
*/
protected function translationNamespace(): string
{
return $this->extensionIdentifier;
}
/**
* Register services.
*/
public function register(): void
{
$this->registerRepositories();
$this->registerStorageBindings();
$this->registerCacheBindings();
}
/**
* Bootstrap services.
*/
public function boot(): void
{
$this->loadExtensionMigrations();
$this->loadExtensionTranslations();
}
/**
* Repository 인터페이스를 구현체에 바인딩합니다.
*/
protected function registerRepositories(): void
{
foreach ($this->repositories as $interface => $implementation) {
$this->app->bind($interface, $implementation);
}
}
/**
* StorageInterface 를 필요로 하는 서비스에 contextual binding 으로 주입합니다.
*/
protected function registerStorageBindings(): void
{
if (empty($this->storageServices)) {
return;
}
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
}
/**
* CacheInterface 를 필요로 하는 서비스에 contextual binding 으로 주입합니다.
*/
protected function registerCacheBindings(): void
{
if (empty($this->cacheServices)) {
return;
}
$this->app->when($this->cacheServices)
->needs(CacheInterface::class)
->give(fn () => $this->resolveExtension()->getCache());
}
/**
* ServiceProvider 파일의 디렉토리 경로를 반환합니다.
*
* ReflectionClass 로 자식 클래스의 실제 경로를 가져옵니다 (__DIR__ 은 베이스
* 경로를 반환하므로 사용 불가).
*/
protected function getProviderPath(): string
{
if ($this->providerPath === null) {
$reflection = new ReflectionClass($this);
$this->providerPath = dirname($reflection->getFileName());
}
return $this->providerPath;
}
/**
* 확장 마이그레이션 로드 hook (기본 no-op).
*
* 확장 마이그레이션은 ModuleManager/PluginManager::runMigrations() 가 별도로
* 실행하므로 여기서는 loadMigrationsFrom() 을 호출하지 않습니다.
*/
protected function loadExtensionMigrations(): void
{
// no-op: ModuleManager/PluginManager::runMigrations() 에서 처리됨
}
/**
* 확장의 다국어 파일을 로드합니다.
*
* 기본 경로: ServiceProvider 디렉토리에서 상위로 한 단계 올라간 lang/ 디렉토리.
*/
protected function loadExtensionTranslations(): void
{
$langPath = $this->getProviderPath().'/../lang';
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->translationNamespace());
}
}
}
+65 -1
View File
@@ -2,6 +2,7 @@
namespace App\Extension;
use App\Contracts\Extension\CacheableExtensionInterface;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\StorageInterface;
@@ -18,7 +19,7 @@ use ReflectionClass;
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
* getName(), getVersion(), getDescription()은 module.json에서 자동 파싱됩니다.
*/
abstract class AbstractModule implements ModuleInterface
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
{
/**
* 모듈 디렉토리 경로 (캐시)
@@ -1001,6 +1002,69 @@ abstract class AbstractModule implements ModuleInterface
return [];
}
/**
* OG 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* `seoOgDefaults()` 가 반환하는 평문값은 운영 렌더링에 그대로 쓰이지만, 어느 데이터에서
* 왔는지(`{{product.data.name}}`)는 평문으로 resolve 되며 정보가 소실됩니다. 편집기
* [검색엔진] 탭은 자동값을 "상품 이름" 같은 **연결 칩**으로 보여주고 사용자가 다른
* 데이터로 교체할 수 있어야 하므로, 본 메서드가 키별 데이터 경로(표현식)와
* 사용자용 라벨을 함께 제공합니다.
*
* 운영 렌더링(`SeoRenderer`)은 본 메서드를 호출하지 않습니다 — 편집기 미리보기
* (`SeoOgPreviewService`)만 소비합니다. 미오버라이드(빈 배열)면 편집기는 종전대로
* resolve 된 평문을 보여줍니다(하위호환·평문 폴백).
*
* `label` 은 **번역 키 문자열**(`'sirsoft-ecommerce::seo.auto_value.product_name'`)로 선언하는 것을
* 권장합니다 — 편집기가 `__()` 로 해석하므로 모듈 lang 파일(+번들 언어팩)이 그 키를 번역하면
* 추가 언어(ja 등)에 자동 대응합니다. 인라인 다국어 맵(`['ko' => ..., 'en' => ...]`)도 허용하나
* 그 외 로케일은 en 폴백이라 언어팩에 대응하지 못합니다(하위호환용).
*
* @param string $pageType 레이아웃 meta.seo.page_type
* @return array<string, array{expr: string, label: string|array<string, string>}>
* 키별 데이터 경로 메타 — 예:
* [
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* ]
*/
public function seoOgDefaultMeta(string $pageType): array
{
return [];
}
/**
* Twitter 카드 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
*/
public function seoTwitterDefaultMeta(string $pageType): array
{
return [];
}
/**
* 구조화 데이터 속성별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* `seoStructuredData()` 의 중첩 객체를 점 경로 키로 평탄화한 기준으로 선언합니다
* (예: `offers.price`). 편집기가 자동 블록을 평탄 행으로 보여줄 때 각 값을 연결 칩으로
* 표시하는 근거입니다.
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}>
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
* [
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
* ]
*/
public function seoStructuredDataMeta(string $pageType): array
{
return [];
}
/**
* 그누보드7 코어 요구 버전 제약 반환
*
+42 -1
View File
@@ -2,6 +2,7 @@
namespace App\Extension;
use App\Contracts\Extension\CacheableExtensionInterface;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\PluginInterface;
use App\Contracts\Extension\StorageInterface;
@@ -20,7 +21,7 @@ use ReflectionClass;
*
* 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다.
*/
abstract class AbstractPlugin implements PluginInterface
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{
/**
* 플러그인 디렉토리 경로 (캐시)
@@ -845,6 +846,46 @@ abstract class AbstractPlugin implements PluginInterface
return [];
}
/**
* OG 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* `seoOgDefaults()` 의 평문값이 어느 데이터에서 왔는지(`{{...}}`)를 편집기 [검색엔진] 탭이
* 연결 칩으로 보여주고 교체할 수 있도록, 키별 데이터 경로(표현식)와 사용자용 라벨을 제공합니다.
* 운영 렌더링은 본 메서드를 호출하지 않습니다 — 편집기 미리보기만 소비. 미오버라이드면
* 종전대로 resolve 된 평문을 보여줍니다(하위호환·평문 폴백). 상세: AbstractModule 동명 메서드.
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타 (label = 번역 키 권장)
*/
public function seoOgDefaultMeta(string $pageType): array
{
return [];
}
/**
* Twitter 카드 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타 (label = 번역 키 권장)
*/
public function seoTwitterDefaultMeta(string $pageType): array
{
return [];
}
/**
* 구조화 데이터 속성별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* `seoStructuredData()` 중첩 객체를 점 경로 키로 평탄화한 기준으로 선언합니다(예: `offers.price`).
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: array<string, string>|string}> 점 경로 키별 데이터 경로 메타
*/
public function seoStructuredDataMeta(string $pageType): array
{
return [];
}
/**
* 그누보드7 코어 요구 버전 제약 반환
*
+86 -127
View File
@@ -2,71 +2,57 @@
namespace App\Extension;
use App\Contracts\Extension\CacheableExtensionInterface;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\StorageInterface;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
/**
* 모듈 서비스 프로바이더 베이스 클래스
* 모듈 서비스 프로바이더 베이스 클래스.
*
* 모든 모듈의 ServiceProvider가 상속받는 추상 클래스입니다.
* 공통 기능을 자동화하여 코드 중복을 제거하고 일관성을 확보합니다.
* 공통 자동 바인딩 로직은 AbstractExtensionServiceProvider 가 보유하며,
* 본 클래스는 ModuleManager 를 통한 확장 해석만 담당합니다. 기존 자식
* 클래스는 `$moduleIdentifier` 속성을 그대로 사용할 수 있으며, 부모의
* `$extensionIdentifier` 에 자동 미러링됩니다.
*/
abstract class BaseModuleServiceProvider extends ServiceProvider
abstract class BaseModuleServiceProvider extends AbstractExtensionServiceProvider
{
/**
* 모듈 식별자 (자식 클래스에서 반드시 정의)
* 모듈 식별자 (하위 호환 alias).
*
* @var string
* 기존 자식 클래스가 이 속성으로 지정한 값을 부모의
* `$extensionIdentifier` 로 미러링합니다.
*/
protected string $moduleIdentifier;
/**
* StorageInterface가 필요한 서비스 클래스 목록
* 모듈 인스턴스를 해석합니다.
*
* 이 배열에 정의된 서비스들은 자동으로 StorageInterface가 주입됩니다.
* 정상 경로: ModuleManager 가 활성 모듈 인스턴스를 반환합니다.
*
* @var array<int, class-string>
* Fallback: 테스트 격리 / 모듈 디스커버리 이전 시점에서는 ModuleCacheDriver /
* ModuleStorageDriver 를 식별자만으로 직접 생성한 어댑터를 반환합니다.
*/
protected array $storageServices = [];
protected function resolveExtension(): CacheableExtensionInterface
{
$module = $this->app->make(ModuleManager::class)
->getModule($this->extensionIdentifier);
/**
* CacheInterface가 필요한 서비스 클래스 목록
*
* 이 배열에 정의된 서비스들은 자동으로 CacheInterface가 주입됩니다.
*
* @var array<int, class-string>
*/
protected array $cacheServices = [];
if ($module !== null) {
return $module;
}
/**
* Repository 인터페이스와 구현체 매핑
*
* @var array<class-string, class-string>
*/
protected array $repositories = [];
/**
* ServiceProvider 파일이 위치한 디렉토리 경로 (캐시)
*
* @var string|null
*/
private ?string $providerPath = null;
return new InlineModuleExtensionAdapter($this->extensionIdentifier);
}
/**
* Register services.
*/
public function register(): void
{
// Repository 바인딩
$this->registerRepositories();
// StorageInterface 바인딩
$this->registerStorageBindings();
// CacheInterface 바인딩
$this->registerCacheBindings();
$this->ensureIdentifierAlias();
parent::register();
}
/**
@@ -74,110 +60,83 @@ abstract class BaseModuleServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// 마이그레이션 자동 로드
$this->loadModuleMigrations();
// 다국어 자동 로드
$this->loadModuleTranslations();
$this->ensureIdentifierAlias();
parent::boot();
}
/**
* Repository 인터페이스를 구현체에 바인딩합니다.
* `$moduleIdentifier` 값을 부모의 `$extensionIdentifier` 로 미러링합니다.
*/
protected function registerRepositories(): void
private function ensureIdentifierAlias(): void
{
foreach ($this->repositories as $interface => $implementation) {
$this->app->bind($interface, $implementation);
if (isset($this->moduleIdentifier) && ! isset($this->extensionIdentifier)) {
$this->extensionIdentifier = $this->moduleIdentifier;
}
}
/**
* StorageInterface를 필요로 하는 서비스에 자동 바인딩합니다.
*
* 각 서비스의 생성자에서 StorageInterface를 주입받으면,
* 해당 모듈의 Storage 인스턴스가 자동으로 주입됩니다.
*/
protected function registerStorageBindings(): void
{
if (empty($this->storageServices)) {
return;
}
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(function () {
return $this->app->make(ModuleManager::class)
->getModule($this->moduleIdentifier)
->getStorage();
});
}
/**
* CacheInterface를 필요로 하는 서비스에 자동 바인딩합니다.
*
* 각 서비스의 생성자에서 CacheInterface를 주입받으면,
* 해당 모듈의 Cache 인스턴스가 자동으로 주입됩니다.
*/
protected function registerCacheBindings(): void
{
if (empty($this->cacheServices)) {
return;
}
$this->app->when($this->cacheServices)
->needs(CacheInterface::class)
->give(function () {
return $this->app->make(ModuleManager::class)
->getModule($this->moduleIdentifier)
->getCache();
});
}
/**
* ServiceProvider 파일의 디렉토리 경로 반환
*
* ReflectionClass를 사용하여 자식 클래스의 실제 경로를 반환합니다.
* __DIR__은 베이스 클래스 경로를 반환하므로 사용할 수 없습니다.
*
* @return string ServiceProvider 디렉토리 경로 (예: modules/vendor-module/src/Providers)
*/
protected function getProviderPath(): string
{
if ($this->providerPath === null) {
$reflection = new ReflectionClass($this);
$this->providerPath = dirname($reflection->getFileName());
}
return $this->providerPath;
}
/**
* 모듈의 마이그레이션 파일을 로드합니다.
*
* 기본 경로: {module}/database/migrations
* 자식 클래스는 {module}/src/Providers에 위치해야 합니다.
*
* 참고: 모듈 마이그레이션은 php artisan migrate와 분리됩니다.
* module:install, module:activate 명령어에서 ModuleManager::runMigrations()로 실행됩니다.
* @deprecated 7.0.0-beta.7 부모 `loadExtensionMigrations()` 사용 권장.
*/
protected function loadModuleMigrations(): void
{
// 모듈 마이그레이션은 loadMigrationsFrom()으로 등록하지 않음
// 대신 ModuleManager::runMigrations()에서 별도로 실행됨
$this->loadExtensionMigrations();
}
/**
* 모듈의 다국어 파일을 로드합니다.
*
* 기본 경로: {module}/src/lang
* 자식 클래스는 {module}/src/Providers에 위치해야 합니다.
* @deprecated 7.0.0-beta.7 부모 `loadExtensionTranslations()` 사용 권장.
*/
protected function loadModuleTranslations(): void
{
$langPath = $this->getProviderPath().'/../lang';
$this->loadExtensionTranslations();
}
}
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->moduleIdentifier);
}
/**
* ModuleManager 가 식별자 매핑을 보유하지 않는 컨텍스트에서 사용되는 어댑터.
*
* AbstractModule::getCache() / getStorage() 와 동일한 키 prefix·디스크 정책을
* 따르도록 ModuleCacheDriver / ModuleStorageDriver 를 직접 생성합니다.
*
* @internal BaseModuleServiceProvider 의 fallback 전용.
*/
final class InlineModuleExtensionAdapter implements CacheableExtensionInterface
{
private ?CacheInterface $cache = null;
private ?StorageInterface $storage = null;
/**
* @param string $identifier 모듈 식별자 (vendor-module)
*/
public function __construct(private readonly string $identifier) {}
/**
* 모듈 식별자를 반환합니다.
*
* @return string 모듈 식별자
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* 모듈 도메인 캐시 드라이버를 반환합니다.
*
* @return CacheInterface ModuleCacheDriver 인스턴스
*/
public function getCache(): CacheInterface
{
return $this->cache ??= new ModuleCacheDriver($this->identifier);
}
/**
* 모듈 도메인 스토리지 드라이버를 반환합니다.
*
* @return StorageInterface ModuleStorageDriver 인스턴스
*/
public function getStorage(): StorageInterface
{
return $this->storage ??= new ModuleStorageDriver($this->identifier);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\CacheableExtensionInterface;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver;
/**
* 플러그인 서비스 프로바이더 베이스 클래스.
*
* 공통 자동 바인딩 로직은 AbstractExtensionServiceProvider 가 보유하며,
* 본 클래스는 PluginManager 를 통한 확장 해석만 담당합니다. 자식 클래스는
* `$pluginIdentifier` 속성으로 플러그인 식별자를 지정합니다.
*
* 글로벌 컨테이너 바인딩으로 코어 `CacheInterface` 를 덮어쓰는 패턴은
* 금지됩니다. 캐시·스토리지가 필요한 서비스 클래스명을 `$cacheServices`
* / `$storageServices` 배열에 등록하면 Laravel contextual binding 으로
* 해당 플러그인 도메인의 인스턴스가 자동 주입됩니다.
*/
abstract class BasePluginServiceProvider extends AbstractExtensionServiceProvider
{
/**
* 플러그인 식별자 (vendor-plugin). 자식 클래스에서 반드시 정의.
*/
protected string $pluginIdentifier;
/**
* 플러그인 인스턴스를 해석합니다.
*
* 정상 경로: PluginManager 가 활성 플러그인 인스턴스를 반환합니다.
*
* Fallback: 테스트 격리 환경 / 플러그인이 PluginManager 에 등록되기 전 시점
* (ServiceProvider 의 register 단계가 PluginManager 의 디스커버리보다 앞서 호출되는
* 컨테이너 해석 등) 에는 PluginCacheDriver / PluginStorageDriver 를 식별자만으로
* 직접 생성한 in-memory 어댑터를 반환합니다. AbstractPlugin::getCache() /
* getStorage() 의 기본 구현과 동일한 키 prefix 와 디스크를 사용하므로 운영
* 동작은 보존됩니다.
*/
protected function resolveExtension(): CacheableExtensionInterface
{
$plugin = $this->app->make(PluginManager::class)
->getPlugin($this->extensionIdentifier);
if ($plugin !== null) {
return $plugin;
}
return new InlinePluginExtensionAdapter($this->extensionIdentifier);
}
/**
* Register services.
*/
public function register(): void
{
$this->ensureIdentifierAlias();
parent::register();
}
/**
* Bootstrap services.
*/
public function boot(): void
{
$this->ensureIdentifierAlias();
parent::boot();
}
/**
* `$pluginIdentifier` 값을 부모의 `$extensionIdentifier` 로 미러링합니다.
*/
private function ensureIdentifierAlias(): void
{
if (isset($this->pluginIdentifier) && ! isset($this->extensionIdentifier)) {
$this->extensionIdentifier = $this->pluginIdentifier;
}
}
}
/**
* PluginManager 가 식별자에 해당하는 플러그인 인스턴스를 보유하지 않는 컨텍스트
* (테스트 격리 / 플러그인 디스커버리 이전 컨테이너 해석 등) 에서 사용되는 어댑터.
*
* AbstractPlugin::getCache() / getStorage() 의 기본 구현과 동일한 키 prefix 와
* 디스크 정책을 따르도록 PluginCacheDriver / PluginStorageDriver 를 식별자만으로
* 직접 생성합니다.
*
* @internal BasePluginServiceProvider 의 fallback 전용. 외부에서 직접 참조하지 마세요.
*/
final class InlinePluginExtensionAdapter implements CacheableExtensionInterface
{
private ?CacheInterface $cache = null;
private ?StorageInterface $storage = null;
/**
* @param string $identifier 플러그인 식별자 (vendor-plugin)
*/
public function __construct(private readonly string $identifier) {}
/**
* 플러그인 식별자를 반환합니다.
*
* @return string 플러그인 식별자
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* 플러그인 도메인 캐시 드라이버를 반환합니다.
*
* @return CacheInterface PluginCacheDriver 인스턴스
*/
public function getCache(): CacheInterface
{
return $this->cache ??= new PluginCacheDriver($this->identifier);
}
/**
* 플러그인 도메인 스토리지 드라이버를 반환합니다.
*
* @return StorageInterface PluginStorageDriver 인스턴스
*/
public function getStorage(): StorageInterface
{
return $this->storage ??= new PluginStorageDriver($this->identifier);
}
}
+13 -2
View File
@@ -33,9 +33,20 @@ class CoreBackupHelper
public static function createBackup(array $targets, ?\Closure $onProgress = null, array $excludes = []): string
{
$timestamp = date('Ymd_His');
$backupPath = storage_path("app/core_backups/core_{$timestamp}");
$backupRoot = storage_path('app/core_backups');
$backupPath = $backupRoot.DIRECTORY_SEPARATOR."core_{$timestamp}";
File::ensureDirectoryExists($backupPath, 0770, true);
// 백업 루트(core_backups)를 php-fpm 그룹이 쓸 수 있도록 보장한 뒤 타임스탬프
// 디렉토리를 만든다. sudo 업데이트가 core_backups 를 root/운영자 소유 + g-w(0755)
// 로 만들어 두면 이후 www-data 가 그 안에 mkdir 하지 못해 백업 생성이 실패한다
// (mkdir(): Permission denied). 루트를 g+w(0775) 로 정상화하고 소유권을 부모에서
// 상속한다(소유자 아님 → chmod 불가 환경은 silent no-op, 멱등).
File::ensureDirectoryExists($backupRoot, 0775);
FilePermissionHelper::inheritOwnershipFromParent($backupRoot);
FilePermissionHelper::syncGroupWritability($backupRoot);
File::ensureDirectoryExists($backupPath, 0775, true);
FilePermissionHelper::inheritOwnershipFromParent($backupPath);
foreach ($targets as $target) {
$sourcePath = base_path($target);
@@ -0,0 +1,106 @@
<?php
namespace App\Extension\Helpers;
use Illuminate\Support\Facades\Log;
/**
* editor-spec.json 합본 헬퍼.
*
* editor-spec.json 이 과대해져(admin 18k 줄) 상위 블록 단위로 분할되었다. 분할
* 형식은 manifest(`editor-spec.json`)에 메타 + 소형 블록을 인라인으로 두고, 대형
* 블록은 `$include` 맵으로 `editor-spec/{block}.json` 을 참조하는 구조다.
*
* 서빙 4개 사이트는 활성 디렉토리만 기준으로 합본한다 — `_bundled` 폴백은 없다
* `_bundled` 작업분은 `{module,plugin,template}:update` 로 활성 디렉토리에
* 반영된 뒤에만 런타임에 보인다. 다음 형태의 manifest 를 받는다:
*
* {
* "templateId": "...", "version": "...", "darkMode": { ... },
* "$include": {
* "componentPalette": "editor-spec/componentPalette.json",
* "controls": "editor-spec/controls.json"
* }
* }
*
* 본 헬퍼는 manifest 의 `$include` 를 manifest 디렉토리 기준으로 해석해 단일
* 병합 spec(top-level merge)으로 합본한다. 런타임 API 응답은 분할 전 단일 파일과
* 동일한 형태(`array<string,mixed>`)를 유지하므로 프론트엔드 로더는 무영향이다.
*
* `$include` 가 없는 구버전/미분할 editor-spec.json 은 manifest 원본을 그대로
* 반환한다(하위 호환). include 파일 미존재/파싱 실패 시 해당 키만 누락하고 경고를
* 남긴다(무손실 디그레이드).
*
*/
class EditorSpecAssembler
{
/**
* manifest 파일을 디코드하고 `$include` 블록을 합본한 단일 spec 을 반환합니다.
*
* `$include` 부재 시 manifest 원본을 그대로 반환합니다(하위 호환). include 경로는
* manifest 가 위치한 디렉토리 기준 상대 경로로 해석합니다.
*
* @param string $manifestPath editor-spec.json manifest 의 절대 경로
* @return array<string, mixed>|null 합본 spec, 디코드 실패 시 null
*/
public static function assemble(string $manifestPath): ?array
{
$manifest = self::decodeJsonFile($manifestPath);
if ($manifest === null) {
return null;
}
$includes = $manifest['$include'] ?? null;
unset($manifest['$include']);
if (! is_array($includes) || $includes === []) {
// 미분할(구버전) editor-spec.json — manifest 원본 그대로 반환.
return $manifest;
}
$baseDir = \dirname($manifestPath);
foreach ($includes as $key => $relative) {
if (! is_string($key) || ! is_string($relative) || $relative === '') {
continue;
}
$blockPath = $baseDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
$block = self::decodeJsonFile($blockPath);
if ($block === null) {
// include 파일 미존재/파싱 실패 — 해당 키 누락(무손실 디그레이드) + 경고.
Log::warning('EditorSpecAssembler: include 블록 로드 실패', [
'manifest' => $manifestPath,
'block' => $key,
'path' => $blockPath,
]);
continue;
}
// top-level merge — include 결과가 해당 key 의 값이 된다.
// manifest 인라인 값이 있으면 include 가 덮어쓴다(분할본이 정본).
$manifest[$key] = $block;
}
return $manifest;
}
/**
* JSON 파일을 읽어 배열로 디코드합니다. 미존재/비배열/파싱 실패 시 null.
*
* @param string $path 절대 경로
* @return array<string, mixed>|null 디코드된 배열, 실패 시 null
*/
private static function decodeJsonFile(string $path): ?array
{
if (! file_exists($path) || ! is_file($path)) {
return null;
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
}
}
@@ -33,9 +33,22 @@ class ExtensionBackupHelper
}
$timestamp = date('Ymd_His');
$backupRoot = storage_path('app/extension_backups');
$backupTypeDir = storage_path("app/extension_backups/{$type}");
$backupPath = storage_path("app/extension_backups/{$type}/{$identifier}_{$timestamp}");
File::ensureDirectoryExists(dirname($backupPath));
// 백업 부모 체인(extension_backups → {type})을 php-fpm 그룹이 쓸 수 있도록 보장한다.
//
// sudo 업데이트가 이 디렉토리들을 root/운영자 소유 + g-w(0755) 로 만들어 두면, 이후
// www-data(php-fpm) 가 그 안에 백업 디렉토리를 mkdir 하지 못해 "mkdir(): Permission
// denied" 로 실패한다(백업 생성 단계 중단). 디렉토리를 g+w(0775) 로 생성하고, 이미
// 존재하면 부모 소유권 상속 + g+w 승격으로 정상화한다. 소유자가 아니라 chmod 가
// 불가한 환경에서는 silent no-op(멱등) — 운영자가 디렉토리 권한을 직접 부여한다.
foreach ([$backupRoot, $backupTypeDir] as $dir) {
File::ensureDirectoryExists($dir, 0775);
FilePermissionHelper::inheritOwnershipFromParent($dir);
FilePermissionHelper::syncGroupWritability($dir);
}
// 파일별 복사로 진행 상세 보고
self::copyDirectoryWithProgress($sourcePath, $backupPath, $sourcePath, $onProgress);
@@ -58,6 +71,8 @@ class ExtensionBackupHelper
?\Closure $onProgress = null
): void {
File::ensureDirectoryExists($dest, 0775);
// 백업 디렉토리 트리도 부모 소유권을 상속해 sudo update 후 root 소유 잔존을 차단한다.
FilePermissionHelper::inheritOwnershipFromParent($dest);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
@@ -126,7 +126,17 @@ class ExtensionMenuSyncHelper
$updateData['url'] = $newAttributes['url'] ?? null;
}
$this->menuRepository->update($existing, $updateData);
// user_overrides 자동 마킹 비활성화 — 시스템 sync 컨텍스트는 사용자 변경이 아님.
// HasUserOverrides::bootHasUserOverrides 의 updating 이벤트 hook 이 'seeding' 플래그를
// 보고 자동 마킹을 건너뛴다. 미설정 시 동일한 module.php 정의값을 적용해도 icon/name/order
// 등 trackable 필드가 dirty 로 잡혀 user_overrides 에 자동 추가되어, 이후 sync 가 차단되는
// 결함이 발생한다.
app()->instance('user_overrides.seeding', true);
try {
$this->menuRepository->update($existing, $updateData);
} finally {
app()->forgetInstance('user_overrides.seeding');
}
return $existing->fresh();
}
@@ -87,7 +87,17 @@ class ExtensionRoleSyncHelper
// 기타 속성 병합
$updateData = array_merge($updateData, $otherAttributes);
$this->roleRepository->update($existing, $updateData);
// user_overrides 자동 마킹 비활성화 — 시스템 sync 컨텍스트는 사용자 변경이 아님.
// ExtensionMenuSyncHelper 와 동일 패턴 (HasUserOverrides::bootHasUserOverrides 의
// updating 이벤트 hook 이 'seeding' 플래그를 보고 자동 마킹을 건너뛴다).
// 미설정 시 동일한 정의값을 적용해도 name/description 등 trackable 필드가 dirty 로
// 잡혀 user_overrides 에 자동 추가되어 이후 sync 가 차단되는 결함이 발생한다.
app()->instance('user_overrides.seeding', true);
try {
$this->roleRepository->update($existing, $updateData);
} finally {
app()->forgetInstance('user_overrides.seeding');
}
return $existing->fresh();
}
+43 -35
View File
@@ -16,20 +16,24 @@ class FilePermissionHelper
* - 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (퍼미션은 PHP 기본 umask)
* - removeOrphans=false: 소스에 없고 대상에만 있는 파일 유지 (사용자 추가 파일 보호)
* - removeOrphans=true: 소스에 없고 대상에만 있는 파일/디렉토리 삭제 (excludes 제외)
* - preserveTopLevelOrphans=true: removeOrphans=true 라도 *최상위 한 레벨* 에서 소스에
* 없는 항목(디렉토리/파일)은 삭제하지 않는다. 소스에 존재하는 디렉토리 내부의
* orphan 정리는 그대로 수행된다. 코어 업데이트가 `{domain}/_bundled` 를 sync 할 때
* 사용자가 `_bundled/` 바로 아래에 직접 만든 커스텀 확장 디렉토리를 보존하기 위함.
*
* 신규 항목의 소유권 상속은 sudo 로 실행된 업데이트 프로세스가 root 소유로 파일을
* 생성하는 것을 방지한다. vendor/ 처럼 cleanDirectory 후 재생성되는 디렉토리 구조
* 전체가 기존 부모(= vendor/) 의 소유권을 승계하도록 보장한다.
*
* @param string $source 소스 디렉토리 경로
* @param string $destination 대상 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
* @param array $excludes 제외할 이름 또는 경로 목록 (예: ['node_modules', '.git', 'node_modules/test_dir'])
* @param string $relativePath 현재 상대 경로 (내부 재귀용)
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
* @return void
* @param string $source 소스 디렉토리 경로
* @param string $destination 대상 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
* @param array $excludes 제외할 이름 또는 경로 목록 (예: ['node_modules', '.git', 'node_modules/test_dir'])
* @param string $relativePath 현재 상대 경로 (내부 재귀용)
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
*/
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false): void
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false): void
{
if (! File::isDirectory($destination)) {
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
@@ -61,7 +65,8 @@ class FilePermissionHelper
}
if ($item->isDir()) {
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans);
// preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달.
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false);
} else {
static::copyFile($item->getPathname(), $destPath);
}
@@ -69,7 +74,7 @@ class FilePermissionHelper
// 소스에 없는 대상 파일/디렉토리 삭제
if ($removeOrphans && File::isDirectory($destination)) {
static::removeOrphanItems($source, $destination, $excludes, $relativePath);
static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans);
}
}
@@ -86,7 +91,7 @@ class FilePermissionHelper
*
* @param string $source 소스 symlink 경로
* @param string $destination 대상 symlink 경로
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
*/
protected static function copySymlink(string $source, string $destination): bool
{
@@ -122,14 +127,21 @@ class FilePermissionHelper
*
* excludes 목록에 해당하는 항목은 삭제하지 않습니다.
*
* @param string $source 소스 디렉토리 경로
* @param string $destination 대상 디렉토리 경로
* @param array $excludes 제외할 이름 또는 경로 목록
* @param string $relativePath 현재 상대 경로
* @return void
* @param string $source 소스 디렉토리 경로
* @param string $destination 대상 디렉토리 경로
* @param array $excludes 제외할 이름 또는 경로 목록
* @param string $relativePath 현재 상대 경로
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
*/
protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath): void
protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false): void
{
// 최상위 한 레벨에서 소스에 없는 항목(사용자 추가) 보존 — `_bundled/my-project` 등.
// 호출자(copyDirectory)는 최상위 진입 시에만 relativePath='' + 플래그 true 로 들어오며,
// 자식 재귀에는 항상 false 가 전달되므로 본 분기는 최상위에서만 활성화된다.
if ($preserveTopLevelOrphans && $relativePath === '') {
return;
}
$destItems = new \FilesystemIterator($destination, \FilesystemIterator::SKIP_DOTS);
foreach ($destItems as $destItem) {
@@ -165,9 +177,9 @@ class FilePermissionHelper
* - 단순 이름 (슬래시 미포함): 모든 레벨에서 해당 이름과 매칭
* - 경로 패턴 (슬래시 포함): 상대 경로와 정확히 매칭
*
* @param string $itemName 현재 항목의 파일/디렉토리 이름
* @param string $itemRelativePath 루트로부터의 상대 경로
* @param array $excludes 제외 목록
* @param string $itemName 현재 항목의 파일/디렉토리 이름
* @param string $itemRelativePath 루트로부터의 상대 경로
* @param array $excludes 제외 목록
* @return bool 제외 대상 여부
*/
public static function isExcluded(string $itemName, string $itemRelativePath, array $excludes): bool
@@ -199,9 +211,8 @@ class FilePermissionHelper
* 파일을 생성하는 문제를 방지하기 위함이다. vendor/ 내부처럼 cleanDirectory 후
* 전량 재생성되는 경로에서 필요하다.
*
* @param string $source 소스 파일
* @param string $destination 대상 파일
* @return void
* @param string $source 소스 파일
* @param string $destination 대상 파일
*/
public static function copyFile(string $source, string $destination): void
{
@@ -239,8 +250,7 @@ class FilePermissionHelper
/**
* 부모 디렉토리의 퍼미션·소유자·그룹을 상속하여 신규 디렉토리를 생성합니다.
*
* @param string $path 생성할 디렉토리 경로
* @return void
* @param string $path 생성할 디렉토리 경로
*/
protected static function createDirectoryInheritingParent(string $path): void
{
@@ -262,7 +272,6 @@ class FilePermissionHelper
* 외부 호출처(예: `SettingsMigrator::writeJsonFile`) 가 직접 호출 가능하도록 public.
*
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
* @return void
*/
public static function inheritOwnershipFromParent(string $path): void
{
@@ -277,10 +286,9 @@ class FilePermissionHelper
/**
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
*
* @param string $path 대상 경로
* @param int|false $owner fileowner() 반환값 (false 허용)
* @param int|false $group filegroup() 반환값 (false 허용)
* @return void
* @param string $path 대상 경로
* @param int|false $owner fileowner() 반환값 (false 허용)
* @param int|false $group filegroup() 반환값 (false 허용)
*/
protected static function applyOwnership(string $path, int|false $owner, int|false $group): void
{
@@ -303,7 +311,7 @@ class FilePermissionHelper
* - sudo 실행된 업데이트가 원본 스냅샷을 수집하지 못한 경우의 fallback
* - 외부 프로세스(composer 등) 가 root 로 오염시킨 경로의 원본 추정
*
* @return array{0: int|false, 1: int|false, 2: string} [owner, group, source]
* @return array{0: int|false, 1: int|false, 2: string} [owner, group, source]
*/
public static function inferWebServerOwnership(): array
{
@@ -345,9 +353,9 @@ class FilePermissionHelper
* 처리하고 대상은 따라가지 않는다. @chown/@chgrp suppress 로 권한 부족 / chown 미지원
* 환경에서도 silent fail.
*
* @param string $path 대상 경로 (파일 또는 디렉토리)
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID (false = 그룹 유지)
* @param string $path 대상 경로 (파일 또는 디렉토리)
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID (false = 그룹 유지)
* @return int 실제 소유권을 변경한 항목 수
*/
public static function chownRecursive(string $path, int $owner, int|false $group): int
@@ -421,7 +429,7 @@ class FilePermissionHelper
* - silent fail — 권한 부족·chmod 미지원 환경에서도 예외 미발생
*
* @param string $root 대상 루트 (재귀 순회)
* @return int 실제 chmod 한 항목 수
* @return int 실제 chmod 한 항목 수
*/
public static function syncGroupWritability(string $root): int
{
@@ -2,6 +2,8 @@
namespace App\Extension\Helpers;
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
use App\Listeners\Identity\EnforceIdentityPolicyListener;
use App\Models\IdentityPolicy;
use Illuminate\Support\Facades\Log;
@@ -18,6 +20,13 @@ use Illuminate\Support\Facades\Log;
*/
class IdentityPolicySyncHelper
{
/**
* @param IdentityPolicyRepositoryInterface $repository 정책 데이터 접근 Repository
*/
public function __construct(
protected IdentityPolicyRepositoryInterface $repository,
) {}
/**
* 정책을 동기화합니다 (user_overrides 보존 upsert).
*
@@ -29,7 +38,7 @@ class IdentityPolicySyncHelper
*/
public function syncPolicy(array $data): IdentityPolicy
{
return IdentityPolicy::syncOrCreateFromUpgrade(
$policy = IdentityPolicy::syncOrCreateFromUpgrade(
['key' => $data['key']],
[
'scope' => $data['scope'] ?? 'route',
@@ -46,6 +55,15 @@ class IdentityPolicySyncHelper
'fail_mode' => $data['fail_mode'] ?? 'block',
]
);
// scope=hook 정책이 새로 적재되면 그 target 에 enforce 구독을 멱등 (재)바인딩한다.
// 코어 리스너 자동발견은 부팅 전반부에 1회만 일어나 그 시점에 없던 모듈 hook target 을
// 놓치므로, 정책이 DB 에 적재되는 이 시점에 보충한다(이미 바인딩된 target 은 멱등 스킵).
if (($policy->scope instanceof \BackedEnum ? $policy->scope->value : $policy->scope) === 'hook') {
EnforceIdentityPolicyListener::syncDynamicHookSubscriptions();
}
return $policy;
}
/**
@@ -63,16 +81,9 @@ class IdentityPolicySyncHelper
string $sourceIdentifier,
array $currentKeys,
): int {
$query = IdentityPolicy::query()
->where('source_type', $sourceType)
->where('source_identifier', $sourceIdentifier);
if (! empty($currentKeys)) {
$query->whereNotIn('key', $currentKeys);
}
$targets = $query->get(['id', 'key']);
$targets = $this->repository->findStale($sourceType, $sourceIdentifier, $currentKeys);
foreach ($targets as $policy) {
// per-model delete — deleted 이벤트로 라우트 스코프 캐시가 flush 된다(bulk delete 와 차이).
$policy->delete();
}
+71 -16
View File
@@ -27,7 +27,7 @@ class HookListenerRegistrar
*
* 모듈 install/uninstall 시나리오에서 재등록이 필요하면 clear() 사용.
*
* @var array<string, true> key: "{source}::{listenerClass}"
* @var array<string, true> key: "{source}::{listenerClass}"
*/
private static array $registered = [];
@@ -38,7 +38,6 @@ class HookListenerRegistrar
*
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
* @return void
*/
public static function register(string $listenerClass, ?string $source = null): void
{
@@ -80,18 +79,8 @@ class HookListenerRegistrar
app($listenerClass)->{$method}(...$args);
}, $priority);
} else {
// Action 기본: 큐 디스패치
// 큐 드라이버가 sync이면 Laravel이 즉시 실행 → 하위호환 보장
// HookContextCapture::capture()로 Auth/Request/Locale 스냅샷을 함께 전달하여
// 큐 워커에서 리스너가 평소처럼 사용자 컨텍스트를 사용할 수 있도록 한다.
HookManager::addAction($hookName, function (...$args) use ($listenerClass, $method) {
dispatch(new DispatchHookListenerJob(
$listenerClass,
$method,
HookArgumentSerializer::serialize($args),
HookContextCapture::capture(),
));
}, $priority);
// Action 기본: 큐 디스패치 (큐 드라이버가 sync 면 Laravel 이 즉시 실행 → 하위호환)
self::addQueuedAction($hookName, $listenerClass, $method, $priority);
}
Log::info('훅 리스너 등록 완료', [
@@ -106,13 +95,79 @@ class HookListenerRegistrar
}
}
/**
* DB 기반 동적 훅을 큐 디스패치 정책에 맞춰 등록합니다.
*
* 정적 getSubscribedHooks() 으로 표현할 수 없는 동적 구독(DB 정의에 따라 훅 대상이
* 런타임에 결정되는 경우, 예: NotificationHookListener::registerDynamicHooks)을 위한 진입점.
* register() 의 Action 기본 동작과 동일하게 DispatchHookListenerJob 으로 큐 디스패치한다 —
* 리스너가 직접 dispatch(new DispatchHookListenerJob(...)) 를 작성하지 않고 이 헬퍼에 위임하여,
* 큐 래핑 로직의 소유권을 Registrar 한 곳에 유지한다(정적/동적 일관).
*
* $boundArgs 로 훅 발화 인자 앞에 고정 인자(예: NotificationDefinition)를 붙일 수 있다.
* 워커는 listenerClass::method(...$boundArgs, ...$hookArgs) 형태로 복원 호출한다.
*
* @param string $hookName 구독할 훅 이름
* @param string $listenerClass 리스너 FQCN
* @param string $method 큐 워커에서 호출할 public 메서드명
* @param array<int, mixed> $boundArgs 훅 발화 인자 앞에 고정으로 붙일 인자 (직렬화 가능해야 함)
* @param int $priority 실행 우선순위
* @param bool $sync true 면 큐 래핑 없이 즉시 동기 실행 (큐 드라이버 무관)
*/
public static function registerDynamicAction(
string $hookName,
string $listenerClass,
string $method,
array $boundArgs = [],
int $priority = 10,
bool $sync = false,
): void {
if ($sync) {
HookManager::addAction($hookName, function (...$args) use ($listenerClass, $method, $boundArgs) {
app($listenerClass)->{$method}(...$boundArgs, ...$args);
}, $priority);
return;
}
self::addQueuedAction($hookName, $listenerClass, $method, $priority, $boundArgs);
}
/**
* 훅에 큐 디스패치(DispatchHookListenerJob) 콜백을 등록합니다 (정적/동적 공통).
*
* 큐 드라이버가 sync 면 Laravel 이 즉시 실행하므로 하위호환은 그대로 유지된다.
* HookContextCapture::capture() 로 Auth/Request/Locale 스냅샷을 함께 전달하여
* 큐 워커에서 리스너가 평소처럼 사용자 컨텍스트를 사용할 수 있도록 한다.
*
* @param string $hookName 구독할 훅 이름
* @param string $listenerClass 리스너 FQCN
* @param string $method 큐 워커에서 호출할 메서드명
* @param int $priority 실행 우선순위
* @param array<int, mixed> $boundArgs 훅 발화 인자 앞에 고정으로 붙일 인자
*/
private static function addQueuedAction(
string $hookName,
string $listenerClass,
string $method,
int $priority,
array $boundArgs = [],
): void {
HookManager::addAction($hookName, function (...$args) use ($listenerClass, $method, $boundArgs) {
dispatch(new DispatchHookListenerJob(
$listenerClass,
$method,
HookArgumentSerializer::serialize([...$boundArgs, ...$args]),
HookContextCapture::capture(),
));
}, $priority);
}
/**
* 등록 이력 캐시를 비웁니다.
*
* 모듈 install/uninstall 시나리오 또는 테스트 격리가 필요할 때 호출.
* 캐시 비운 후 register() 호출하면 listener 가 다시 HookManager 에 추가됨.
*
* @return void
*/
public static function clear(): void
{
@@ -23,6 +23,7 @@ final class VerificationChallenge
* @param string|null $redirectUrl external_redirect 일 때 이동할 외부 URL
* @param array $publicPayload 프론트에 내려줄 공개 페이로드 (민감정보 제외)
* @param array $metadata 서버 내부 참조용 데이터
* @param int $maxAttempts 허용 최대 시도 횟수 (0 = 무제한, popup/SDK 형 provider 가 사용)
*/
public function __construct(
public readonly string $id,
@@ -35,6 +36,7 @@ final class VerificationChallenge
public readonly ?string $redirectUrl = null,
public readonly array $publicPayload = [],
public readonly array $metadata = [],
public readonly int $maxAttempts = 0,
) {}
/**
@@ -53,6 +55,7 @@ final class VerificationChallenge
'redirect_url' => $this->redirectUrl,
'expires_at' => $this->expiresAt->toIso8601String(),
'public_payload' => $this->publicPayload,
'max_attempts' => $this->maxAttempts,
];
}
}
@@ -69,6 +69,18 @@ class MailIdentityProvider implements IdentityVerificationInterface
return ['email'];
}
/**
* 채널 키 → 다국어 표시 라벨 맵을 반환합니다.
*
* @return array<string, string> 채널 키 → 라벨 맵
*/
public function getChannelLabels(): array
{
return [
'email' => __('identity.channels.email'),
];
}
/**
* 기본 렌더 힌트를 반환합니다.
*
@@ -193,6 +205,7 @@ class MailIdentityProvider implements IdentityVerificationInterface
renderHint: $renderHint,
publicPayload: $publicPayload,
metadata: [],
maxAttempts: $maxAttempts,
);
}
@@ -244,10 +257,16 @@ class MailIdentityProvider implements IdentityVerificationInterface
]);
if ($storedHash === null || ! Hash::check($provided, $storedHash)) {
// 이번 오답으로 max_attempts 에 도달하면 잠금(Failed)으로 전환하고, 사용자에게
// 일반 오답 안내 대신 "최대 시도 초과 + 재요청" 안내(MAX_ATTEMPTS)를 반환한다.
// 프론트 모달은 도달 즉시 확인 버튼을 비활성화하므로 추가 시도를 기대할 수 없어,
// 막 소진된 이 응답에서 안내하지 않으면 max_attempts 문구가 사용자에게 영영 도달하지 못한다.
if (($log->attempts + 1) >= $log->max_attempts) {
$this->logRepository->updateById($log->id, [
'status' => IdentityVerificationStatus::Failed->value,
]);
return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts');
}
return VerificationResult::failure($challengeId, self::ID, 'INVALID_CODE', 'identity.errors.invalid_code');
@@ -349,15 +368,9 @@ class MailIdentityProvider implements IdentityVerificationInterface
/**
* IDV 전용 메시지 디스패처를 통해 메일을 발송합니다.
*
* @param string $email
* @param string $purpose
* @param string $renderHint text_code | link
* @param string $challengeId
* @param string|null $policyKey
* @param string|null $code text_code 흐름 시 평문 인증 코드
* @param string|null $linkToken link 흐름 시 서명 링크용 raw 토큰
* @param int $ttlMinutes
* @param Carbon $expiresAt
* @return bool 발송 성공 여부
*/
protected function dispatchMessage(
@@ -411,11 +424,6 @@ class MailIdentityProvider implements IdentityVerificationInterface
/**
* link 흐름용 서명 링크를 생성합니다.
*
* @param string $challengeId
* @param string $linkToken
* @param Carbon $expiresAt
* @return string
*/
protected function buildSignedLink(string $challengeId, string $linkToken, Carbon $expiresAt): string
{
@@ -433,9 +441,6 @@ class MailIdentityProvider implements IdentityVerificationInterface
/**
* purpose 라벨(다국어)을 현재 로케일 문자열로 해석합니다.
*
* @param string $purpose
* @return string
*/
protected function resolvePurposeLabel(string $purpose): string
{
+44 -20
View File
@@ -18,6 +18,7 @@ use App\Enums\ExtensionOwnerType;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Enums\PermissionType;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
@@ -26,8 +27,6 @@ use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\GithubHelper;
use App\Providers\CoreServiceProvider;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
@@ -36,9 +35,11 @@ use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorMode;
use App\Extension\Vendor\VendorResolver;
use App\Models\IdentityPolicy;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
@@ -424,6 +425,14 @@ class ModuleManager implements ModuleManagerInterface
$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()],
@@ -536,7 +545,7 @@ class ModuleManager implements ModuleManagerInterface
);
}
// 코어 버전 호환성 사전 검증 (#306 sync 훅보다 앞쪽)
// 코어 버전 호환성 사전 검증
if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
CoreVersionChecker::validateExtension(
$module->getRequiredCoreVersion(),
@@ -635,6 +644,10 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
}
// 훅 발행: 모듈 활성화 완료
@@ -773,6 +786,11 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서
// 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
IdentityPolicy::flushRouteScopeCache();
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
@@ -1498,7 +1516,7 @@ class ModuleManager implements ModuleManagerInterface
* 중첩 구조 의존성 배열을 순회하며 (identifier => declaredType) 를 yield 합니다.
*
* @param array $dependencies ['modules' => [...], 'plugins' => [...]] 형식
* @return \Generator<string, string> identifier => 'module'|'plugin'
* @return \Generator<string, string> identifier => 'module'|'plugin'
*/
private function iterateNestedDependencies(array $dependencies): \Generator
{
@@ -1892,7 +1910,6 @@ class ModuleManager implements ModuleManagerInterface
];
}
/**
* 단일 마이그레이션 파일을 롤백합니다.
*
@@ -2520,10 +2537,6 @@ class ModuleManager implements ModuleManagerInterface
/**
* 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
*
* @param string $sourceType
* @param string $sourceIdentifier
* @return bool
*/
protected function hasExistingIdentityPolicies(string $sourceType, string $sourceIdentifier): bool
{
@@ -2587,9 +2600,6 @@ class ModuleManager implements ModuleManagerInterface
*
* `$module` 에서 현재 정의된 식별자/slug 를 수집하여 helper 의 `cleanupStale*` 호출.
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
*
* @param ModuleInterface $module
* @return void
*/
protected function cleanupStaleModuleEntries(ModuleInterface $module): void
{
@@ -3274,7 +3284,7 @@ class ModuleManager implements ModuleManagerInterface
*
* @param string $moduleName 모듈명
* @param bool $preserveModified true 시 사용자가 UI에서 수정한 레이아웃은 덮어쓰지 않음
* (original_content_hash 와 현재 content hash 비교)
* (original_content_hash 와 현재 content hash 비교)
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
*
* @throws \Exception 모듈을 찾을 수 없거나 레이아웃 갱신 실패 시
@@ -3446,7 +3456,7 @@ class ModuleManager implements ModuleManagerInterface
// 레이아웃 확장(extension)은 모든 활성 템플릿에 적용될 수 있으므로
// admin 템플릿뿐만 아니라 모든 활성 템플릿에 대해 갱신
$allActiveTemplates = $this->templateRepository->getActive();
$extensionStats = $this->refreshLayoutExtensions($module, $allActiveTemplates);
$extensionStats = $this->refreshLayoutExtensions($module, $allActiveTemplates, $preserveModified);
// 레이아웃 또는 레이아웃 확장이 실제로 변경된 경우에만 캐시 버전 증가
$extensionChanged = ($extensionStats['created'] ?? 0) > 0 || ($extensionStats['updated'] ?? 0) > 0;
@@ -3474,11 +3484,12 @@ class ModuleManager implements ModuleManagerInterface
*
* @param ModuleInterface $module 모듈 인스턴스
* @param Collection $adminTemplates admin 템플릿 컬렉션
* @return array{refreshed: int, created: int, updated: int, deleted: int} 갱신 통계
* @param bool $preserveModified 관리자가 편집한 확장을 보존할지 여부 (--layout-strategy=keep)
* @return array{refreshed: int, created: int, updated: int, deleted: int, skipped: int} 갱신 통계
*/
protected function refreshLayoutExtensions(ModuleInterface $module, $adminTemplates): array
protected function refreshLayoutExtensions(ModuleInterface $module, $adminTemplates, bool $preserveModified = false): array
{
return $this->refreshExtensionLayoutExtensions($module, $adminTemplates, LayoutSourceType::Module);
return $this->refreshExtensionLayoutExtensions($module, $adminTemplates, LayoutSourceType::Module, $preserveModified);
}
/**
@@ -4032,9 +4043,13 @@ class ModuleManager implements ModuleManagerInterface
// 버전순 정렬
uksort($filteredSteps, 'version_compare');
// 확장 업그레이드는 코어(sudo/root)와 다른 실행 주체(php-fpm/www-data)로 실행되므로
// 코어 'upgrade' 채널과 로그 파일을 분리한다. 같은 파일 공유 시 root 소유 파일에
// www-data 가 append 하지 못해 Permission denied 로 실패하던 결함을 원천 차단.
$context = new UpgradeContext(
fromVersion: $fromVersion,
toVersion: $toVersion,
logChannel: 'extension-upgrade',
);
foreach ($filteredSteps as $stepVersion => $step) {
@@ -4083,7 +4098,7 @@ class ModuleManager implements ModuleManagerInterface
{
$layouts = $this->layoutRepository->getBySourceIdentifier(
$identifier,
\App\Enums\LayoutSourceType::Module,
LayoutSourceType::Module,
);
$modifiedLayouts = $layouts->filter(function ($layout) {
@@ -4146,8 +4161,7 @@ class ModuleManager implements ModuleManagerInterface
?\Closure $onUpgradeStep = null,
?string $sourceOverride = null,
?string $zipPath = null,
): array
{
): array {
$record = $this->moduleRepository->findByIdentifier($identifier);
if (! $record) {
throw new \RuntimeException(__('modules.not_found', ['module' => $identifier]));
@@ -4351,6 +4365,16 @@ class ModuleManager implements ModuleManagerInterface
$name = $module ? $this->convertToMultilingual($module->getName()) : $record->name;
$description = $module ? $this->convertToMultilingual($module->getDescription()) : $record->description;
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입 (install 경로와 동일)
if ($module) {
$manifest = HookManager::applyFilters(
"module.{$identifier}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
}
$this->moduleRepository->updateByIdentifier($identifier, [
'version' => $toVersion,
'latest_version' => $toVersion,
+56 -31
View File
@@ -18,26 +18,27 @@ use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Enums\PermissionType;
use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\GithubHelper;
use App\Providers\CoreServiceProvider;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorMode;
use App\Extension\Vendor\VendorResolver;
use App\Models\IdentityPolicy;
use App\Models\Module;
use App\Models\Plugin;
use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use Illuminate\Support\Collection;
@@ -405,6 +406,14 @@ class PluginManager implements PluginManagerInterface
$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()],
@@ -514,7 +523,7 @@ class PluginManager implements PluginManagerInterface
);
}
// 코어 버전 호환성 사전 검증 (#306 sync 훅보다 앞쪽)
// 코어 버전 호환성 사전 검증
// - force=true 시 우회 (CLI/웹 모두)
// - 코어 업데이트 spawn 컨텍스트에서는 매니페스트와 코어 버전이 일시적으로
// 어긋날 수 있어 자동 비활성화 가드와 동일 정책으로 스킵
@@ -616,6 +625,10 @@ class PluginManager implements PluginManagerInterface
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
}
// 훅 발행: 플러그인 활성화 완료
@@ -757,6 +770,11 @@ class PluginManager implements PluginManagerInterface
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
IdentityPolicy::flushRouteScopeCache();
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
}
@@ -893,7 +911,7 @@ class PluginManager implements PluginManagerInterface
$this->removePluginPermissions($plugin);
// IDV 정책도 data 옵션 선택 시 제거 (user_overrides 손실 허용)
if (\Illuminate\Support\Facades\Schema::hasTable('identity_policies')) {
if (Schema::hasTable('identity_policies')) {
try {
app(IdentityPolicySyncHelper::class)
->cleanupStalePolicies('plugin', $plugin->getIdentifier(), []);
@@ -906,7 +924,7 @@ class PluginManager implements PluginManagerInterface
}
// IDV 메시지 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
if (\Illuminate\Support\Facades\Schema::hasTable('identity_message_definitions')) {
if (Schema::hasTable('identity_message_definitions')) {
try {
app(IdentityMessageSyncHelper::class)
->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
@@ -919,7 +937,7 @@ class PluginManager implements PluginManagerInterface
}
// 알림 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
if (\Illuminate\Support\Facades\Schema::hasTable('notification_definitions')) {
if (Schema::hasTable('notification_definitions')) {
try {
app(NotificationSyncHelper::class)
->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
@@ -1499,7 +1517,7 @@ class PluginManager implements PluginManagerInterface
* 중첩 구조 의존성 배열을 순회하며 (identifier => declaredType) 를 yield 합니다.
*
* @param array $dependencies ['modules' => [...], 'plugins' => [...]] 형식
* @return \Generator<string, string> identifier => 'module'|'plugin'
* @return \Generator<string, string> identifier => 'module'|'plugin'
*/
private function iterateNestedDependencies(array $dependencies): \Generator
{
@@ -1960,9 +1978,6 @@ class PluginManager implements PluginManagerInterface
*
* 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상.
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
*
* @param PluginInterface $plugin
* @return void
*/
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
{
@@ -2132,7 +2147,7 @@ class PluginManager implements PluginManagerInterface
return;
}
if (! \Illuminate\Support\Facades\Schema::hasTable('identity_policies')) {
if (! Schema::hasTable('identity_policies')) {
return; // 마이그레이션 미실행 환경 보호
}
@@ -2178,7 +2193,7 @@ class PluginManager implements PluginManagerInterface
return;
}
if (! \Illuminate\Support\Facades\Schema::hasTable('identity_message_definitions')) {
if (! Schema::hasTable('identity_message_definitions')) {
return; // 마이그레이션 미실행 환경 보호
}
@@ -2244,7 +2259,7 @@ class PluginManager implements PluginManagerInterface
return;
}
if (! \Illuminate\Support\Facades\Schema::hasTable('notification_definitions')) {
if (! Schema::hasTable('notification_definitions')) {
return; // 마이그레이션 미실행 환경 보호
}
@@ -2290,15 +2305,11 @@ class PluginManager implements PluginManagerInterface
/**
* 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
*
* @param string $sourceType
* @param string $sourceIdentifier
* @return bool
*/
protected function hasExistingIdentityPolicies(string $sourceType, string $sourceIdentifier): bool
{
try {
return \Illuminate\Support\Facades\DB::table('identity_policies')
return DB::table('identity_policies')
->where('source_type', $sourceType)
->where('source_identifier', $sourceIdentifier)
->exists();
@@ -2313,7 +2324,7 @@ class PluginManager implements PluginManagerInterface
protected function hasExistingIdentityMessageDefinitions(string $extensionType, string $extensionIdentifier): bool
{
try {
return \Illuminate\Support\Facades\DB::table('identity_message_definitions')
return DB::table('identity_message_definitions')
->where('extension_type', $extensionType)
->where('extension_identifier', $extensionIdentifier)
->exists();
@@ -2328,7 +2339,7 @@ class PluginManager implements PluginManagerInterface
protected function hasExistingNotificationDefinitions(string $extensionType, string $extensionIdentifier): bool
{
try {
return \Illuminate\Support\Facades\DB::table('notification_definitions')
return DB::table('notification_definitions')
->where('extension_type', $extensionType)
->where('extension_identifier', $extensionIdentifier)
->exists();
@@ -3758,7 +3769,7 @@ class PluginManager implements PluginManagerInterface
// 레이아웃 확장(extension)은 모든 활성 템플릿에 적용될 수 있으므로
// admin 템플릿뿐만 아니라 모든 활성 템플릿에 대해 갱신
$allActiveTemplates = $this->templateRepository->getActive();
$extensionStats = $this->refreshLayoutExtensions($plugin, $allActiveTemplates);
$extensionStats = $this->refreshLayoutExtensions($plugin, $allActiveTemplates, $preserveModified);
// 레이아웃 또는 레이아웃 확장이 실제로 변경된 경우에만 캐시 버전 증가
$extensionChanged = ($extensionStats['created'] ?? 0) > 0 || ($extensionStats['updated'] ?? 0) > 0;
@@ -3786,11 +3797,12 @@ class PluginManager implements PluginManagerInterface
*
* @param PluginInterface $plugin 플러그인 인스턴스
* @param Collection $adminTemplates admin 템플릿 컬렉션
* @return array{refreshed: int, created: int, updated: int, deleted: int} 갱신 통계
* @param bool $preserveModified 관리자가 편집한 확장을 보존할지 여부 (--layout-strategy=keep)
* @return array{refreshed: int, created: int, updated: int, deleted: int, skipped: int} 갱신 통계
*/
protected function refreshLayoutExtensions(PluginInterface $plugin, $adminTemplates): array
protected function refreshLayoutExtensions(PluginInterface $plugin, $adminTemplates, bool $preserveModified = false): array
{
return $this->refreshExtensionLayoutExtensions($plugin, $adminTemplates, LayoutSourceType::Plugin);
return $this->refreshExtensionLayoutExtensions($plugin, $adminTemplates, LayoutSourceType::Plugin, $preserveModified);
}
/**
@@ -4209,14 +4221,18 @@ class PluginManager implements PluginManagerInterface
if (empty($filteredSteps)) {
return;
}
}
// 버전순 정렬
uksort($filteredSteps, 'version_compare');
// 확장 업그레이드는 코어(sudo/root)와 다른 실행 주체(php-fpm/www-data)로 실행되므로
// 코어 'upgrade' 채널과 로그 파일을 분리한다. 같은 파일 공유 시 root 소유 파일에
// www-data 가 append 하지 못해 Permission denied 로 실패하던 결함을 원천 차단.
$context = new UpgradeContext(
fromVersion: $fromVersion,
toVersion: $toVersion,
logChannel: 'extension-upgrade',
);
foreach ($filteredSteps as $stepVersion => $step) {
@@ -4262,7 +4278,7 @@ class PluginManager implements PluginManagerInterface
{
$layouts = $this->layoutRepository->getBySourceIdentifier(
$identifier,
\App\Enums\LayoutSourceType::Plugin,
LayoutSourceType::Plugin,
);
$modifiedLayouts = $layouts->filter(function ($layout) {
@@ -4325,8 +4341,7 @@ class PluginManager implements PluginManagerInterface
?\Closure $onUpgradeStep = null,
?string $sourceOverride = null,
?string $zipPath = null,
): array
{
): array {
$record = $this->pluginRepository->findByIdentifier($identifier);
if (! $record) {
throw new \RuntimeException(__('plugins.not_found', ['plugin' => $identifier]));
@@ -4530,6 +4545,16 @@ class PluginManager implements PluginManagerInterface
$name = $plugin ? $this->convertToMultilingual($plugin->getName()) : $record->name;
$description = $plugin ? $this->convertToMultilingual($plugin->getDescription()) : $record->description;
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입 (install 경로와 동일)
if ($plugin) {
$manifest = HookManager::applyFilters(
"plugin.{$identifier}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
}
$this->pluginRepository->updateByIdentifier($identifier, [
'version' => $toVersion,
'latest_version' => $toVersion,
+35 -11
View File
@@ -2,6 +2,7 @@
namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
@@ -10,6 +11,7 @@ use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
@@ -20,8 +22,6 @@ use App\Services\LayoutService;
use App\Services\TemplateService;
use Composer\Semver\Semver;
use Illuminate\Support\Facades\Auth;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -85,15 +85,17 @@ class TemplateManager implements TemplateManagerInterface
}
/**
* 코어 캐시 드라이버를 lazy 조회합니다.
* 코어 캐시 드라이버를 반환합니다.
*
* 이 메서드가 다루는 키(`template.*`, `layout.*`)는 모두 코어 소유이므로
* 항상 `g7:core:` 네임스페이스를 써야 한다. 컨테이너의 `CacheInterface`
* 바인딩(모듈/플러그인 테스트가 일시적으로 `PluginCacheDriver` 등으로
* 재바인딩할 수 있음)에 의존하면 누수된 바인딩 때문에 forget/remember 가
* `g7:plugin.*` 네임스페이스로 빗나가므로, 항상 CoreCacheDriver 를 직접 생성한다.
*/
private function cache(): CacheInterface
{
try {
return app(CacheInterface::class);
} catch (\Throwable $e) {
return new CoreCacheDriver(config('cache.default', 'array'));
}
return new CoreCacheDriver(config('cache.default', 'array'));
}
/**
@@ -424,6 +426,14 @@ class TemplateManager implements TemplateManagerInterface
$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;
// 3. DB 등록
$onProgress?->__invoke('db', 'DB 등록 중...');
@@ -921,6 +931,7 @@ class TemplateManager implements TemplateManagerInterface
return $installedTemplates;
}
/**
* 템플릿 식별자에 대한 메타데이터(다국어 치환 + 활성 상태 포함)를 조회합니다.
*
@@ -968,6 +979,7 @@ class TemplateManager implements TemplateManagerInterface
'github_changelog_url' => $template['github_changelog_url'] ?? null,
'requires_core' => $template['g7_version'] ?? null,
'dependencies' => $template['dependencies'] ?? [],
'externals' => $template['externals'] ?? [],
'locales' => $template['locales'] ?? [],
'layouts_count' => $layoutsCount,
'components' => $components,
@@ -1015,6 +1027,7 @@ class TemplateManager implements TemplateManagerInterface
'github_changelog_url' => $metadata['github_changelog_url'] ?? null,
'requires_core' => $metadata['g7_version'] ?? null,
'dependencies' => $metadata['dependencies'] ?? [],
'externals' => $metadata['externals'] ?? [],
'locales' => $metadata['locales'] ?? [],
'layouts_count' => 0,
'components' => $metadata['components'] ?? [],
@@ -1187,11 +1200,12 @@ class TemplateManager implements TemplateManagerInterface
);
}
// 레이아웃 이름 (숫자 또는 문자열 키)
// 레이아웃 이름 (숫자 또는 문자열 키) — 다른 모든 카테고리(auth/, board/) 와
// 동일하게 디렉토리 접두사 포함된 식별자를 그대로 사용한다 (예: "errors/404").
$layoutName = $errorLayouts[$code] ?? $errorLayouts[(string) $code];
// 3. 레이아웃 파일 실제 존재 확인 (errors/ 디렉토리 내)
$layoutFilePath = $templatePath.'/layouts/errors/'.$layoutName.'.json';
// 3. 레이아웃 파일 실제 존재 확인 — layout_name 그대로 layouts/ 하위 파일 경로로 사용
$layoutFilePath = $templatePath.'/layouts/'.$layoutName.'.json';
if (! File::exists($layoutFilePath)) {
throw new \Exception(
__('templates.errors.error_layout_not_found', [
@@ -3007,6 +3021,16 @@ class TemplateManager implements TemplateManagerInterface
$name = $template ? $this->convertToMultilingual($template['name']) : $record->name;
$description = $template ? $this->convertToMultilingual($template['description'] ?? '') : $record->description;
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입 (install 경로와 동일)
if ($template) {
$manifest = HookManager::applyFilters(
"template.{$identifier}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
}
$this->templateRepository->updateByIdentifier($identifier, [
'version' => $toVersion,
'latest_version' => $toVersion,
@@ -0,0 +1,108 @@
<?php
namespace App\Extension\Testing;
/**
* 테스트 환경 확장 로딩 allowlist
*
* PHPUnit 환경에서 테스트 클래스가 명시한 확장만 ServiceProvider / route /
* hook listener 등록 대상으로 허용합니다. allowlist 밖 확장은 테스트 앱에
* 영향을 주지 않습니다 (예: GDPR 플러그인의 전역 미들웨어).
*
* provider register() 는 앱 부팅 단계라 테스트 인스턴스 메서드보다 먼저
* 실행되므로, 인스턴스 프로퍼티 대신 static 컨텍스트로 allowlist 를 공유합니다.
* 테스트 클래스의 setUp() 최상단(앱 생성 전)에서 set() 을 호출합니다.
*
* isActive() 가 false 인 환경(비-testing, 또는 allowlist 미설정)에서는
* 가드가 동작하지 않으므로 운영/개발 환경의 확장 로딩은 영향을 받지 않습니다.
*/
class ExtensionTestAllowlist
{
/**
* 허용된 플러그인 디렉토리명 목록 (예: 'sirsoft-gdpr')
*
* @var array<string>
*/
private static array $plugins = [];
/**
* 허용된 모듈 디렉토리명 목록 (예: 'sirsoft-ecommerce')
*
* @var array<string>
*/
private static array $modules = [];
/**
* allowlist 가 명시적으로 설정되었는지 여부
*
* 빈 배열로 set() 된 경우(= core-only 테스트)와
* 한 번도 set() 되지 않은 경우(= 비-테스트 부팅)를 구분합니다.
*/
private static bool $configured = false;
/**
* allowlist 를 설정합니다.
*
* requiredExtensions 형식의 상대 경로 문자열을 받아
* 'plugins/' / 'modules/' 프리픽스로 분류하고 디렉토리명만 추출합니다.
*
* @param array<string> $extensions 'plugins/sirsoft-gdpr' 형식의 확장 경로 배열
*/
public static function set(array $extensions): void
{
self::$plugins = [];
self::$modules = [];
self::$configured = true;
foreach ($extensions as $extension) {
$extension = trim($extension, '/');
if (str_starts_with($extension, 'plugins/')) {
self::$plugins[] = basename($extension);
} elseif (str_starts_with($extension, 'modules/')) {
self::$modules[] = basename($extension);
}
}
}
/**
* allowlist 를 초기화합니다.
*
* 테스트 종료 시 호출하여 프로세스 내 테스트 클래스 간 누수를 방지합니다.
*/
public static function reset(): void
{
self::$plugins = [];
self::$modules = [];
self::$configured = false;
}
/**
* 가드 활성 여부를 반환합니다.
*
* testing 환경이면서 allowlist 가 명시적으로 설정된 경우에만 true.
* false 이면 provider 는 기존과 동일하게 전수 등록합니다.
*
* @return bool 가드 활성 여부
*/
public static function isActive(): bool
{
return self::$configured && app()->environment('testing');
}
/**
* 해당 확장이 allowlist 에 포함되어 있는지 반환합니다.
*
* @param string $type 확장 유형 ('plugin' | 'module')
* @param string $name 확장 디렉토리명 (예: 'sirsoft-gdpr')
* @return bool allowlist 포함 여부
*/
public static function isAllowed(string $type, string $name): bool
{
return match ($type) {
'plugin' => in_array($name, self::$plugins, true),
'module' => in_array($name, self::$modules, true),
default => false,
};
}
}
+107 -9
View File
@@ -20,6 +20,13 @@ trait ClearsTemplateCaches
*/
private static string $extensionCacheVersionKey = 'ext.cache_version';
/**
* 프로세스 1회 메모이즈된 확장 좌표 캐시 스토어 이름 (write/read 스토어 일관성).
* `extensionCacheStore()` 가 최초 1회 채우며, 테스트는 `resetExtensionCacheStoreMemo()`
* 로 초기화한다. null = 미해소.
*/
private static ?string $extensionCacheStore = null;
/**
* 확장 기능 캐시 버전을 증가시킵니다.
*
@@ -46,11 +53,56 @@ trait ClearsTemplateCaches
/**
* 현재 확장 기능 캐시 버전을 반환합니다.
*
* @return int 캐시 버전 (타임스탬프) 또는 0 (미설정 시)
* 키가 없거나 무효값(0)이면 — `php artisan cache:clear` 로 `ext.cache_version`
* 키가 소실된 경우 — 그 자리에서 새 `time()` 버전을 생성·저장하고 그 값을 반환한다.
* 0 을 그대로 내려보내면 모든 자원 URL 이 `?v=0` 으로 수렴하고, 과거 `?v=0` 에
* 1년 immutable 로 박힌 구버전 에셋을 브라우저가 재검증 없이 영구 사용한다
* (cache:clear 후 구버전 모듈 JS 가 고착되어 "Unknown action handler" 회귀).
* 유효한 새 버전을 내려주면 프론트가 새 URL(`?v={새값}`)로 요청해 자동 해소된다.
*
* 읽기 메서드에 쓰기 부수효과가 생기지만 호출처는 모두 "현재 유효 버전 1개" 를
* 원하므로 의미상 정확하다. cache:clear 직후 동시 요청 경합은 (a) 같은 초면 동일
* `time()`, (b) 달라도 둘 다 유효하고 다음 요청에 수렴 → 0 붕괴보다 명백히 안전하다.
*
* @return int 캐시 버전 (타임스탬프). 키 부재/무효 시 새로 생성된 유효 버전.
*/
public static function getExtensionCacheVersion(): int
{
return (int) self::resolveExtensionCache()->get(self::$extensionCacheVersionKey, 0);
$version = (int) self::resolveExtensionCache()->get(self::$extensionCacheVersionKey, 0);
if ($version > 0) {
return $version;
}
return self::regenerateExtensionCacheVersion();
}
/**
* 캐시 버전 키 부재/무효 시 새 유효 버전을 생성·저장하고 반환합니다.
*
* static 컨텍스트에서 호출되므로 인스턴스 메서드(incrementExtensionCacheVersion)
* 대신 동일 로직을 직접 수행한다. 저장 실패 시에도 0 으로 붕괴하지 않도록
* 생성한 `time()` 값을 반환한다(다음 요청이 다시 생성·저장 시도).
*
* @return int 새로 생성된 유효 캐시 버전 (타임스탬프)
*/
private static function regenerateExtensionCacheVersion(): int
{
$newVersion = time();
try {
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
Log::info('확장 기능 캐시 버전 재생성 (키 부재/무효)', [
'new_version' => $newVersion,
]);
} catch (\Exception $e) {
Log::warning('확장 기능 캐시 버전 재생성 중 오류', [
'error' => $e->getMessage(),
]);
}
return $newVersion;
}
/**
@@ -90,17 +142,63 @@ trait ClearsTemplateCaches
}
/**
* CacheInterface 인스턴스를 컨테이너에서 lazy 조회합니다.
* 확장 기능 캐시 버전 저장에 사용할 코어 캐시 드라이버를 반환합니다.
*
* 컨테이너 미구성 환경(예: 일부 단위 테스트)에서도 동작하도록
* fallback 으로 직접 CoreCacheDriver 를 생성합니다.
* 확장 기능 캐시 버전(`ext.cache_version`)은 코어 소유 키이므로 항상
* `g7:core:` 접두사 네임스페이스에 저장/조회되어야 한다. 따라서
* 컨테이너의 `CacheInterface` 바인딩(모듈/플러그인 테스트가 일시적으로
* `PluginCacheDriver` 등으로 재바인딩할 수 있음)에 의존하지 않고
* 항상 CoreCacheDriver 를 직접 생성한다.
*
* 스토어는 **고정 결정적 스토어**(`extensionCacheStore()`)를 쓴다 —
* `config('cache.default')` 를 직접 쓰면 `SettingsServiceProvider::applyCacheConfig`
* 가 부팅 중 admin 설정(`g7_core_settings('cache.driver')`)으로 `cache.default` 를
* 런타임 오버라이드하므로, settings 로드 타이밍/컨텍스트(웹 vs CLI vs 큐, 설정
* 미시드 환경)에 따라 write 와 read 가 **서로 다른 스토어**를 가리킬 수 있다.
* 그 경우 bump 한 버전이 read 경로에서 보이지 않아 프론트엔드가 영구 stale
* 캐시를 받는다(편집기 데이터소스 명칭/레이아웃 변경 미반영 — 반복 회귀).
* 접두사가 코어 고정이듯 스토어도 코어 고정으로 일관시킨다.
*/
private static function resolveExtensionCache(): CacheInterface
{
try {
return app(CacheInterface::class);
} catch (\Throwable $e) {
return new CoreCacheDriver(config('cache.default', 'array'));
return new CoreCacheDriver(self::extensionCacheStore());
}
/**
* 확장 좌표 키(`ext.cache_version`)의 고정 캐시 스토어 이름을 반환합니다.
*
* **프로세스 1회 메모이즈** — 최초 호출 시점의 스토어를 캡처해 같은 프로세스 안에서
* write 와 read 가 항상 동일 스토어를 쓰도록 고정한다. `SettingsServiceProvider::
* applyCacheConfig` 가 부팅 중 `cache.default` 를 admin 설정으로 오버라이드하므로,
* 메모이즈 없이 매번 `config('cache.default')` 를 읽으면 settings 적용 전/후 호출이
* 서로 다른 스토어를 가리켜 bump 가 read 에서 안 보이는 회귀가 난다(편집기 명칭/
* 레이아웃 변경 미반영 — 반복 제보).
*
* 비영속 `array` 스토어는 명시 회피(프로세스 경계에서 유실 → CLI write/웹 read
* 불일치). `array` 만 가용한 환경(일부 테스트)에서는 그대로 array 를 쓰되, 그 경우
* 동일 프로세스 안에서는 일관되므로 단위 테스트 격리에는 영향 없다.
*
* @return string 캐시 스토어 이름
*/
private static function extensionCacheStore(): string
{
if (self::$extensionCacheStore !== null) {
return self::$extensionCacheStore;
}
$configured = config('cache.default');
self::$extensionCacheStore = is_string($configured) && $configured !== ''
? $configured
: 'file';
return self::$extensionCacheStore;
}
/**
* 테스트 격리용 — 메모이즈된 스토어를 초기화한다(setUp/tearDown 에서 호출 가능).
*/
public static function resetExtensionCacheStoreMemo(): void
{
self::$extensionCacheStore = null;
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Extension\Traits;
/**
* 확장(모듈/플러그인) 컴포넌트 매니페스트(components.json) 생성 트레이트
*
* `module:build` / `plugin:build` 의 빌드 성공 직후 호출되어, 그 확장의 컴포넌트
* 소스를 스캔해 `{ identifier, version, components: { basic[], composite[], layout[] } }`
* 매니페스트를 빌드 경로 루트에 작성한다.
*
* 편집 모드 부팅 시 코어 `ComponentRegistry` 가 활성 확장의 components.json 을
* 네임스페이스 병합해 자동 컨트롤 생성(props 메타)의 입력으로 쓴다. 본 매니페스트가
* 없으면 그 확장 컴포넌트는 메타데이터 없이 무손실 보존만 된다(원칙 4.6 디그레이드).
*
* 스캔 규칙:
* - 컴포넌트 소스는 확장의 `resources/js/components/{basic,composite,layout}/` 하위
* `*.tsx` / `*.ts` 파일로 본다. 하위 분류 디렉토리 이름이 type(basic/composite/layout).
* - 분류 디렉토리 밖의 컴포넌트는 `composite` 로 분류(보수적 기본값).
* - 컴포넌트 name = 파일명(확장자 제외). props 정밀 추출은 본 S6-1 범위 밖 —
* `props: {}` 빈 메타로 둔다(코어 ComponentRegistry 가 런타임 props 메타로 보강).
* - 컴포넌트 소스 디렉토리가 없으면 빈 매니페스트를 작성한다(핸들러 전용 확장 등).
*
* 본 트레이트는 도메인-특화 컴포넌트 구조를 가정하지 않는다 — 디렉토리 컨벤션만으로
* 분류하고, 컨벤션 밖 구조는 composite 로 보존한다.
*
* @since engine-v1.54.0
*/
trait GeneratesComponentManifest
{
/**
* 확장 빌드 경로의 컴포넌트 소스를 스캔해 components.json 을 작성합니다.
*
* @param string $buildPath 빌드 경로 (확장 루트 — _bundled 또는 활성)
* @param string $identifier 확장 식별자 (vendor-extension 형식)
* @return array{written: bool, count: int, path: string} 작성 결과
*/
protected function generateComponentManifest(string $buildPath, string $identifier): array
{
$version = $this->readManifestVersion($buildPath);
$components = $this->scanComponentSources($buildPath);
$manifest = [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'identifier' => $identifier,
'version' => $version,
'components' => $components,
];
$outputPath = $buildPath.'/components.json';
$json = json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
$written = $json !== false && file_put_contents($outputPath, $json.PHP_EOL) !== false;
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
return ['written' => $written, 'count' => $count, 'path' => $outputPath];
}
/**
* 확장 매니페스트(module.json/plugin.json)에서 version 을 읽습니다.
*
* @param string $buildPath 빌드 경로
* @return string version 문자열 (미확인 시 '0.0.0')
*/
private function readManifestVersion(string $buildPath): string
{
foreach (['module.json', 'plugin.json'] as $file) {
$path = $buildPath.'/'.$file;
if (file_exists($path)) {
$decoded = json_decode((string) file_get_contents($path), true);
if (is_array($decoded) && isset($decoded['version']) && is_string($decoded['version'])) {
return $decoded['version'];
}
}
}
return '0.0.0';
}
/**
* 컴포넌트 소스 디렉토리를 스캔해 type 별 컴포넌트 목록을 구성합니다.
*
* @param string $buildPath 빌드 경로
* @return array{basic: list<array<string, mixed>>, composite: list<array<string, mixed>>, layout: list<array<string, mixed>>}
*/
private function scanComponentSources(string $buildPath): array
{
$result = ['basic' => [], 'composite' => [], 'layout' => []];
$componentsRoot = $buildPath.'/resources/js/components';
if (! is_dir($componentsRoot)) {
return $result;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($componentsRoot, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if (! in_array($ext, ['ts', 'tsx'], true)) {
continue;
}
$name = $file->getBasename('.'.$file->getExtension());
// index/엔트리 파일·테스트 파일은 컴포넌트로 보지 않는다
if (in_array(strtolower($name), ['index', 'main', 'entry'], true)) {
continue;
}
if (str_contains(strtolower($file->getFilename()), '.test.')
|| str_contains($file->getPathname(), DIRECTORY_SEPARATOR.'__tests__'.DIRECTORY_SEPARATOR)) {
continue;
}
$type = $this->classifyComponentByPath($componentsRoot, $file->getPathname());
$relativePath = ltrim(
str_replace(
[base_path().DIRECTORY_SEPARATOR, '\\'],
['', '/'],
$file->getPathname()
),
'/'
);
$result[$type][] = [
'name' => $name,
'type' => $type,
'path' => $relativePath,
'props' => new \stdClass,
];
}
// 결정적 순서 — name 기준 정렬 (빌드 재현성)
foreach ($result as $type => $list) {
usort($result[$type], fn ($a, $b) => strcmp($a['name'], $b['name']));
}
return $result;
}
/**
* 파일 경로의 분류 디렉토리(basic/composite/layout)로 컴포넌트 type 을 판정합니다.
*
* @param string $componentsRoot components 디렉토리 절대 경로
* @param string $filePath 컴포넌트 파일 절대 경로
* @return 'basic'|'composite'|'layout' 컴포넌트 type (컨벤션 밖이면 composite)
*/
private function classifyComponentByPath(string $componentsRoot, string $filePath): string
{
$relative = str_replace('\\', '/', substr($filePath, strlen($componentsRoot)));
$segments = array_values(array_filter(explode('/', $relative)));
$firstDir = $segments[0] ?? '';
return match (strtolower($firstDir)) {
'basic' => 'basic',
'layout' => 'layout',
default => 'composite',
};
}
}
@@ -17,7 +17,8 @@ use Illuminate\Support\Facades\Log;
*
* 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는
* incrementExtensionCacheVersion() + TTL로 무효화됩니다.
* 레이아웃 내용 편집 시에만 현재 버전 키를 능동 삭제합니다.
* 레이아웃 내용 편집 시에만 현재 버전 키를 능동 삭제합니다 — 일반 응답 키와
* 레이아웃 편집기 응답 키(`.meta` 접미사, `with_source_meta=1`) 두 가지 모두.
*
* 이 Trait를 사용하는 클래스는 반드시 다음 속성/메서드를 제공해야 합니다:
* - $layoutRepository: LayoutRepositoryInterface 인스턴스
@@ -105,24 +106,32 @@ trait InvalidatesLayoutCache
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
}
// 3. PublicLayoutController 캐시 (버전 포함)
// 레이아웃 내용 편집 시 현재 버전 키 삭제 (버전 변경 없이 내용만 바뀜)
// 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두.
// PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta`
// 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화
// (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를
// 받는다. 레이아웃
// 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다.
if ($templateIdentifier) {
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}.meta");
}
}
/**
* CacheInterface 인스턴스를 lazy 조회합니다.
* 레이아웃 캐시 무효화에 사용할 코어 캐시 드라이버를 반환합니다.
*
* 레이아웃 캐시 키(`template.*.layout.*`, `layout.*` 등)는 모두 코어 소유
* 키이므로 항상 `g7:core:` 접두사 네임스페이스에서 저장/삭제되어야 한다.
* 따라서 컨테이너의 `CacheInterface` 바인딩(모듈/플러그인 테스트가 일시적으로
* `PluginCacheDriver` 등으로 재바인딩할 수 있음)에 의존하지 않고 항상
* CoreCacheDriver 를 직접 생성한다. 의존 시 누수된 바인딩 때문에
* `g7:plugin.*` 네임스페이스로 forget 이 빗나가 캐시가 실제로 삭제되지 않는다.
*/
private function resolveLayoutCache(): CacheInterface
{
try {
return app(CacheInterface::class);
} catch (\Throwable $e) {
return new CoreCacheDriver(config('cache.default', 'array'));
}
return new CoreCacheDriver(config('cache.default', 'array'));
}
/**
@@ -29,17 +29,19 @@ trait RefreshesLayoutExtensions
* @param ModuleInterface|PluginInterface $extension 모듈 또는 플러그인 인스턴스
* @param Collection $adminTemplates admin 템플릿 컬렉션
* @param LayoutSourceType $sourceType 소스 타입 (Module 또는 Plugin)
* @return array{refreshed: int, created: int, updated: int, deleted: int} 갱신 통계
* @param bool $preserveModified 관리자가 편집한 확장을 보존할지 여부 (--layout-strategy=keep)
* @return array{refreshed: int, created: int, updated: int, deleted: int, skipped: int} 갱신 통계
*/
protected function refreshExtensionLayoutExtensions(
ModuleInterface|PluginInterface $extension,
Collection $adminTemplates,
LayoutSourceType $sourceType
LayoutSourceType $sourceType,
bool $preserveModified = false
): array {
$extensionFiles = $extension->getLayoutExtensions();
$identifier = $extension->getIdentifier();
$extensionType = $sourceType === LayoutSourceType::Module ? 'module' : 'plugin';
$stats = ['refreshed' => 0, 'created' => 0, 'updated' => 0, 'deleted' => 0];
$stats = ['refreshed' => 0, 'created' => 0, 'updated' => 0, 'deleted' => 0, 'skipped' => 0];
if ($adminTemplates->isEmpty()) {
return $stats;
@@ -79,17 +81,37 @@ trait RefreshesLayoutExtensions
// 파일에서 읽은 확장 등록/업데이트
foreach ($fileExtensions as $targetKey => $extensionData) {
try {
// 대상(target_layout / extension_point)이 이 템플릿에 존재하지 않으면 등록하지 않는다.
// 모든 활성 템플릿에 일괄 적용 시, admin 레이아웃 대상 확장이 user 템플릿에
// (또는 그 반대로) 잘못 등록되는 것을 방지한다.
if (! $this->layoutExtensionService->isExtensionApplicableToTemplate($extensionData, $template->id)) {
// 과거 무차별 등록으로 잘못 생성된 행이 있으면 정리
if ($this->layoutExtensionService->removeInapplicableExtension(
$extensionData,
$sourceType,
$identifier,
$template->id
)) {
$stats['deleted']++;
}
continue;
}
$result = $this->layoutExtensionService->registerExtension(
$extensionData,
$sourceType,
$identifier,
$template->id
$template->id,
$preserveModified
);
if ($result === 'created') {
$stats['created']++;
} elseif ($result === 'updated') {
$stats['updated']++;
} elseif ($result === 'skipped') {
$stats['skipped']++;
}
$stats['refreshed']++;
} catch (\Exception $e) {
+10 -1
View File
@@ -36,13 +36,20 @@ class UpgradeContext
* @param string $fromVersion 업그레이드 시작 버전 (현재 설치 버전)
* @param string $toVersion 업그레이드 목표 버전
* @param string $currentStep 현재 실행 중인 스텝 버전
* @param string $logChannel 로그 채널명 — 코어 업그레이드는 'upgrade'(기본), 확장
* (모듈/플러그인) 업그레이드는 'extension-upgrade'. 실행
* 주체가 다른(root vs www-data) 두 흐름이 같은 로그 파일을
* 공유하여 발생하던 소유권 충돌(Permission denied)을 파일
* 분리로 원천 차단한다. step 작성자 인터페이스($context->logger)
* 는 채널과 무관하게 동일하다.
*/
public function __construct(
public readonly string $fromVersion,
public readonly string $toVersion,
public readonly string $currentStep = '',
public readonly string $logChannel = 'upgrade',
) {
$this->logger = Log::channel('upgrade');
$this->logger = Log::channel($logChannel);
}
/**
@@ -71,6 +78,7 @@ class UpgradeContext
* 현재 스텝 버전을 변경한 새 컨텍스트를 반환합니다.
*
* @param string $stepVersion 현재 실행할 스텝 버전
* @return self 스텝 버전만 교체하고 나머지(버전·로그 채널)를 승계한 새 컨텍스트
*/
public function withCurrentStep(string $stepVersion): self
{
@@ -78,6 +86,7 @@ class UpgradeContext
fromVersion: $this->fromVersion,
toVersion: $this->toVersion,
currentStep: $stepVersion,
logChannel: $this->logChannel,
);
}
@@ -0,0 +1,484 @@
<?php
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\PermissionService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* 레이아웃 편집기용 어드민 템플릿 자산 서빙 컨트롤러
*
* 설치돼 있으나 비활성 상태인 템플릿도 편집할 수 있어야 한다.
* `PublicTemplateController` 의 모든 서빙 메서드는 비활성 템플릿을 차단하므로,
* 편집기 부팅용 admin 경로를 별도 제공한다.
*
* 5개 엔드포인트 — 모두 admin 권한 가드 (`core.templates.layouts.edit`):
* - getEditorAssets: 자산 매니페스트 (IIFE/CSS URL) — bootstrap.ts 가 조건부 로드
* - serveComponents: components.json (정상)
* - serveRoutes: routes.json (활성 상태 무관)
* - serveEditorSpec: editor-spec.json
* - serveLanguage: 다국어 데이터 (활성 상태 무관)
*
* 모두 활성/비활성 무관 200 응답. public 경로(`PublicTemplateController`) 는
* 종전대로 활성만 허용 (일반 사이트 보안 유지).
*/
class AdminTemplateAssetController extends AdminBaseController
{
public function __construct(
private TemplateService $templateService,
private PermissionService $permissionService,
) {
parent::__construct();
}
/**
* 편집 자산 매니페스트 — IIFE / CSS URL 목록 반환.
*
* bootstrap.ts 가 비활성 템플릿 부팅 시 본 응답의 js/css 를 동적으로 주입.
* 활성 템플릿은 코어 일반 부팅이 이미 자산을 로드하므로 본 엔드포인트는
* 비활성 템플릿 케이스에서 주로 사용된다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 자산 매니페스트 응답
*/
public function getEditorAssets(string $identifier): JsonResponse
{
// 빌드 결과물 위치 — 활성 디렉토리 우선, _bundled 폴백
// 실제 IIFE/CSS 는 `php artisan template:build` 가 `dist/js/components.iife.js` /
// `dist/css/components.css` 에 산출하며, `/api/templates/assets/{id}/...` 라우트가
// 활성/_bundled 자동 폴백 + 권한 가드를 거쳐 서빙한다 (resources/views/admin.blade.php
// 의 자산 URL 패턴과 동일).
$iifeCandidates = [
base_path("templates/{$identifier}/dist/js/components.iife.js"),
base_path("templates/_bundled/{$identifier}/dist/js/components.iife.js"),
];
$cssCandidates = [
base_path("templates/{$identifier}/dist/css/components.css"),
base_path("templates/_bundled/{$identifier}/dist/css/components.css"),
];
$jsAvailable = false;
$jsSource = null;
foreach ($iifeCandidates as $candidate) {
if (file_exists($candidate)) {
$jsAvailable = true;
$jsSource = str_contains($candidate, '/_bundled/') ? 'bundled' : 'active';
break;
}
}
$cssAvailable = false;
foreach ($cssCandidates as $candidate) {
if (file_exists($candidate)) {
$cssAvailable = true;
break;
}
}
if (! $jsAvailable) {
return $this->success(
__('templates.messages.editor_assets_missing'),
['identifier' => $identifier, 'js' => [], 'css' => [], 'manifest_present' => false],
);
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? "?v={$extensionCacheVersion}" : '';
return $this->success(
__('templates.messages.editor_assets_retrieved'),
[
'identifier' => $identifier,
'js' => ["/api/templates/assets/{$identifier}/js/components.iife.js{$version}"],
// CSS 는 편집기 전용 엔드포인트로 — 다크 셀렉터를 프리뷰 마커로 치환해 서빙
// 일반 자산 서빙은 원본.
'css' => $cssAvailable
? ["/api/admin/templates/{$identifier}/editor/components.css{$version}"]
: [],
'manifest_present' => true,
'manifest_source' => $jsSource,
],
);
}
/**
* components.json 서빙 (활성/비활성 무관).
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$candidates = [
base_path("templates/{$identifier}/components.json"),
base_path("templates/_bundled/{$identifier}/components.json"),
];
foreach ($candidates as $path) {
if (file_exists($path)) {
$data = json_decode((string) file_get_contents($path), true);
if (is_array($data)) {
return $this->success(__('templates.messages.config_retrieved'), $data);
}
}
}
return $this->error(__('templates.layout_not_found'), 404);
}
/**
* routes.json 서빙 (활성/비활성 무관) — 편집기 라우트 트리용.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 라우트 정의 응답
*/
public function serveRoutes(string $identifier): JsonResponse
{
// 편집기 라우트 트리는 각 라우트의 `source`(`{kind, identifier}`) 태깅에 의존한다
// (useRouteTree.buildRouteTree 가 `route.source.kind` 로 그룹핑). raw routes.json
// 을 그대로 반환하면 source 가 없어 클라이언트가 `undefined.kind` 접근에서 throw →
// 라우트 트리 전체가 network 에러로 무너진다. public getRoutes 와 동일한 source
// 태깅 + 모듈/플러그인 병합을 활성/비활성 무관 + _bundled 폴백으로 수행한다.
$result = $this->templateService->getEditorRoutesDataWithModules($identifier);
if (! $result['success']) {
return match ($result['error']) {
'routes_not_found' => $this->error(__('templates.messages.routes_not_found'), 404),
'invalid_json' => $this->error(__('templates.errors.invalid_json'), 422),
default => $this->error(__('templates.messages.routes_not_found'), 404),
};
}
return $this->success(__('templates.messages.routes_retrieved'), $result['data']);
}
/**
* 편집기 스펙 서빙 — editor-spec.json 파일 반환 (활성/비활성 무관).
*
* 활성 디렉토리 → _bundled 폴백 순으로 editor-spec.json 을 읽어 반환한다.
* Phase 4 S6-1 부터 응답에 sampleData/sampleGlobal/states 등 전 블록이 포함된다
* ("골격 → 정식 응답" 이행). 파일 미작성 시 spec=null 폴백.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
// 분할 editor-spec.json 은 manifest + `$include` 블록으로 구성된다.
// 활성 디렉토리만 기준으로 합본한 단일 spec 을 반환한다(_bundled 폴백 없음).
// 비활성 템플릿도 편집 가능하므로 status 가드는 없다. 미분할 파일은
// 원본 반환(하위 호환), 미존재 시 null.
$spec = EditorSpecAssembler::assemble(
base_path("templates/{$identifier}/editor-spec.json")
);
$message = $spec === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success($message, ['identifier' => $identifier, 'spec' => $spec]);
}
/**
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
*
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
* 일반 사용자 페이지 CSS(public serveAsset)는 원본 그대로 → 사용자 페이지 100% 무영향.
*
* 치환 규칙은 템플릿 editor-spec 의 `darkMode.previewIsolation`(rewriteSelector →
* replaceWith)에서 가져온다(라이브러리 중립). 미선언/`strategy:none` 이면 원본 그대로.
* 변환 결과는 템플릿+확장 캐시 버전 키로 캐시(대용량 CSS 매요청 재가공 방지). 치환 실패
* 또는 CSS 부재 시 원본/빈 응답으로 안전 폴백한다.
*
* @param string $identifier 템플릿 식별자
* @return Response 변환된 CSS 응답 (text/css)
*/
public function serveEditorCss(string $identifier)
{
$cssCandidates = [
base_path("templates/{$identifier}/dist/css/components.css"),
base_path("templates/_bundled/{$identifier}/dist/css/components.css"),
];
$cssPath = null;
foreach ($cssCandidates as $candidate) {
if (file_exists($candidate)) {
$cssPath = $candidate;
break;
}
}
if ($cssPath === null) {
// CSS 부재 — 빈 CSS 폴백(편집기 부팅이 실패하지 않도록 200).
return response('', 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
// 코어 소유 키(`template.*`)는 CoreCacheDriver 직접 생성으로 다룬다 — `app(CacheInterface)`
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
// 코어 캐시 store 를 빗나갈 수 있다.
$cache = new CoreCacheDriver;
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
$mtime = (int) @filemtime($cssPath);
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
$cached = $cache->get($cacheKey);
if (is_string($cached)) {
return response($cached, 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
$css = (string) file_get_contents($cssPath);
// editor-spec 의 darkMode.previewIsolation 로 치환 규칙 해석.
$isolation = $this->resolveDarkPreviewIsolation($identifier);
if ($isolation !== null) {
$css = $this->rewriteDarkSelectors($css, $isolation['rewrite'], $isolation['replace']);
// @layer 평탄화 — CSS cascade-layer 를 쓰는 라이브러리(예 Tailwind v4)에서, 편집기
// 프리뷰 CSS 가 어드민 호스트 CSS 와 같은 `@layer` 이름을 공유하면 cross-build 레이어
// 우선순위 충돌로 프리뷰의 다크 규칙이 적용되지 않는다(브라우저 실측 확인). 편집기 CSS 는
// 프리뷰 전용 + 마지막 로드이므로, 레이어 래퍼를 제거해 전부 unlayered(최고 우선순위,
// 파일 내 소스 순서 보존)로 만들어 호스트 레이어드 규칙을 확실히 이긴다. 라이브러리
// 특성이므로 템플릿이 `previewIsolation.flattenLayers: true` 로 명시 옵트인할 때만 수행한다
// (라이브러리 중립 — @layer 비사용 CSS 는 평탄화 불필요).
if ($isolation['flattenLayers']) {
$css = $this->flattenCssLayers($css);
}
}
// 변환 결과 캐시(실패해도 원본을 그대로 캐시 — 매요청 재가공 방지).
$cache->put($cacheKey, $css, 3600);
return response($css, 200)->header('Content-Type', 'text/css; charset=UTF-8');
}
/**
* 템플릿 editor-spec 의 `darkMode.previewIsolation` 치환 규칙 해석.
*
* @param string $identifier 템플릿 식별자
* @return array{rewrite: string, replace: string, flattenLayers: bool}|null 치환 규칙, 미선언/none 이면 null
*/
private function resolveDarkPreviewIsolation(string $identifier): ?array
{
$candidates = [
base_path("templates/{$identifier}/editor-spec.json"),
base_path("templates/_bundled/{$identifier}/editor-spec.json"),
];
foreach ($candidates as $path) {
if (! file_exists($path)) {
continue;
}
$spec = json_decode((string) file_get_contents($path), true);
if (! is_array($spec)) {
continue;
}
$dark = $spec['darkMode'] ?? null;
if (! is_array($dark)) {
return null;
}
$strategy = $dark['strategy'] ?? null;
if ($strategy === 'none') {
return null;
}
$iso = $dark['previewIsolation'] ?? null;
if (! is_array($iso)) {
return null;
}
$rewrite = $iso['rewriteSelector'] ?? null;
$replace = $iso['replaceWith'] ?? null;
if (is_string($rewrite) && $rewrite !== '' && is_string($replace) && $replace !== '') {
return [
'rewrite' => $rewrite,
'replace' => $replace,
// @layer 평탄화는 라이브러리 특성(CSS @layer cascade-layer 사용 시 cross-build
// 우선순위 충돌)이라 템플릿이 명시 옵트인할 때만 수행한다(라이브러리 중립).
'flattenLayers' => ($iso['flattenLayers'] ?? false) === true,
];
}
return null;
}
return null;
}
/**
* CSS 텍스트의 다크 조상 셀렉터를 프리뷰 마커로 안전 치환.
*
* 조상 셀렉터(`.dark` 가 후손 결합자/그룹 경계 앞)만 치환하고, 유틸리티 클래스
* 자체(`.dark\:bg-x` — 이스케이프 콜론)는 건드리지 않는다. 경계 = 공백/`,`/`)`/`{`/`>`/`~`/`+`.
*
* @param string $css 원본 CSS
* @param string $rewrite 원본 다크 셀렉터(예 `.dark`)
* @param string $replace 치환 마커(예 `.g7le-preview-dark`)
* @return string 치환된 CSS (치환 실패 시 원본)
*/
private function rewriteDarkSelectors(string $css, string $rewrite, string $replace): string
{
// `.dark` 다음에 셀렉터 경계 문자(공백/조합자/그룹 경계)가 오는 경우만 치환.
// `.dark\:` (유틸리티 클래스 — 이스케이프된 콜론)는 lookahead 가 `\` 라 매칭 안 됨.
$pattern = '/'.preg_quote($rewrite, '/').'(?=[\s,){>~+])/';
$result = preg_replace($pattern, $replace, $css);
// preg 실패(null) 시 원본 폴백 — 프리뷰가 깨지지 않도록.
return is_string($result) ? $result : $css;
}
/**
* CSS 의 `@layer NAME { ... }` 블록 래퍼를 제거해 내부 규칙을 unlayered 로 평탄화한다.
*
* Tailwind v4 는 규칙을 `@layer theme/base/components/utilities` 로 감싼다. 편집기
* 프리뷰 CSS 와 어드민 호스트 CSS 가 같은 레이어 이름을 쓰면 cross-build 레이어 우선순위
* 충돌로 프리뷰 규칙이 적용되지 않을 수 있다. 편집기 CSS 는 프리뷰 전용 + 마지막 로드라
* 권위를 가져야 하므로, 레이어 블록 래퍼만 벗겨 내부 규칙을 unlayered 로 만든다(unlayered >
* layered). 규칙 자체와 소스 순서는 보존하므로 파일 내 cascade 는 불변.
*
* 중괄호 균형 스캐너로 `@layer <names> {` 의 짝 `}` 만 찾아 제거한다(정규식은 중첩 중괄호를
* 안전히 매칭 못 함). `@layer <names>;`(선언만, 블록 없음)은 그대로 둔다(레이어 순서 선언).
*
* @param string $css 치환 완료된 CSS
* @return string 레이어 래퍼가 제거된 CSS
*/
private function flattenCssLayers(string $css): string
{
// 중첩 `@layer` 대비 — 더 이상 블록 오프너가 없을 때까지 반복(최대 8회, 폭주 방지).
for ($pass = 0; $pass < 8; $pass++) {
if (! preg_match('/@layer\s+[^;{]*\{/', $css)) {
break;
}
$css = $this->flattenCssLayersOnce($css);
}
return $css;
}
/**
* `@layer NAME { ... }` 블록 래퍼 1패스 제거(중괄호 균형 스캔). flattenCssLayers 가 반복 호출.
*
* @param string $css CSS
* @return string 1패스 평탄화 결과
*/
private function flattenCssLayersOnce(string $css): string
{
$out = '';
$len = strlen($css);
$i = 0;
// `@layer` 다음에 이름 목록 + `{` 가 오는 블록 오프너를 찾는다.
while ($i < $len) {
// `@layer` 리터럴 탐색
$at = strpos($css, '@layer', $i);
if ($at === false) {
$out .= substr($css, $i);
break;
}
// `@layer` 앞부분 그대로 출력
$out .= substr($css, $i, $at - $i);
// `@layer` 뒤 ~ 다음 `{` 또는 `;` 까지 — 이름 목록.
$j = $at + 6; // strlen('@layer')
$brace = strpos($css, '{', $j);
$semi = strpos($css, ';', $j);
// 블록 없는 선언(`@layer a, b;`)이면 `;` 가 먼저 — 그대로 보존.
if ($semi !== false && ($brace === false || $semi < $brace)) {
$out .= substr($css, $at, $semi - $at + 1);
$i = $semi + 1;
continue;
}
if ($brace === false) {
// 형식 이상 — 남은 전체 출력 후 종료(폴백).
$out .= substr($css, $at);
break;
}
// `@layer <names> {` 블록 — 오프너(`@layer ... {`)는 버리고 내부를 평탄화.
// 짝 `}` 를 중괄호 균형으로 찾는다.
$depth = 1;
$k = $brace + 1;
while ($k < $len && $depth > 0) {
$ch = $css[$k];
if ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
break;
}
}
$k++;
}
if ($depth !== 0) {
// 짝 불일치 — 평탄화 포기, 원문 그대로 출력 후 종료(안전 폴백).
$out .= substr($css, $at);
break;
}
// 내부 내용(오프너 `{` 다음 ~ 짝 `}` 직전)만 채택 — 레이어 래퍼 제거.
$out .= substr($css, $brace + 1, $k - ($brace + 1));
$i = $k + 1; // 짝 `}` 다음부터 계속
}
return $out;
}
/**
* 다국어 데이터 서빙 (활성/비활성 무관).
*
* `TemplateService::getLanguageDataWithModules` 는 활성 검증이 있으므로,
* 편집기 admin 경로는 본 메서드에서 활성 검증을 우회한 폴백 로드를 수행한다.
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일
* @return JsonResponse 다국어 데이터 응답
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
{
$candidates = [
base_path("templates/{$identifier}/lang/{$locale}.json"),
base_path("templates/_bundled/{$identifier}/lang/{$locale}.json"),
];
foreach ($candidates as $path) {
if (file_exists($path)) {
$data = json_decode((string) file_get_contents($path), true);
if (is_array($data)) {
return $this->success(__('templates.messages.language_retrieved'), $data);
}
}
}
// 다국어 파일 부재 — 빈 객체 폴백
return $this->success(__('templates.messages.language_empty'), []);
}
/**
* 레이아웃 편집기 표시 권한 후보 서빙.
*
* 코어 + 활성 확장 권한 전체를 `{key, name}` 목록으로 반환한다. 편집기 진입 권한
* (`core.templates.layouts.edit`) 가드 하에서만 노출되므로, 권한 카탈로그가 모든
* admin 페이지에 상시 노출되던 종전 방식(`G7Config.permissions`)보다 노출 범위가
* 편집기로 한정된다. 후보 미존재/조회 실패 시 빈 목록 → TagInput "+ 추가" 디그레이드.
*
* @param string $identifier 템플릿 식별자 (라우트 일관성용 — 후보는 전역 권한)
* @return JsonResponse 권한 후보 목록 응답
*/
public function servePermissionCandidates(string $identifier): JsonResponse
{
$candidates = $this->permissionService->getPermissionCandidates(app()->getLocale());
return $this->success(
__('templates.messages.config_retrieved'),
['identifier' => $identifier, 'permissions' => $candidates],
);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\Template\ListTemplateLayoutAttachmentsRequest;
use App\Http\Requests\Admin\Template\UploadTemplateLayoutAttachmentRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
use Illuminate\Http\JsonResponse;
/**
* 템플릿 레이아웃 첨부 파일 어드민 컨트롤러
*
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 제공한다.
* ImagePickerControl(11.2.2)이 본 API 를 호출한다. 권한은 라우트의 permission
* 미들웨어(core.templates.layouts.edit)가 담당한다.
*/
class AdminTemplateLayoutAttachmentController extends AdminBaseController
{
public function __construct(
private TemplateLayoutAttachmentService $service,
) {
parent::__construct();
}
/**
* 첨부 파일 목록 조회 — 그 템플릿의 첨부(이미지 재선택용).
*
* @param ListTemplateLayoutAttachmentsRequest $request 검증된 요청 (layout_name 쿼리 선택)
* @param string $identifier 템플릿 식별자
* @return JsonResponse 첨부 목록 응답
*/
public function index(ListTemplateLayoutAttachmentsRequest $request, string $identifier): JsonResponse
{
$layoutName = $request->validated('layout_name');
$result = $this->service->list($identifier, is_string($layoutName) ? $layoutName : null);
if (! $result['success']) {
return $this->notFound(__('templates.errors.not_found', ['template' => $identifier]));
}
$items = $result['attachments']->map(fn (TemplateLayoutAttachment $a) => [
'id' => $a->id,
'layout_name' => $a->layout_name,
'original_name' => $a->original_name,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => $this->service->resolveUrl($a),
'created_at' => $a->created_at?->toIso8601String(),
])->all();
return $this->success(__('templates.layout_attachments.messages.listed'), $items);
}
/**
* 첨부 파일 업로드 → 스토리지 저장 + 행 생성 → 접근 URL 반환.
*
* @param UploadTemplateLayoutAttachmentRequest $request 검증된 요청
* @param string $identifier 템플릿 식별자
* @return JsonResponse 업로드 결과 응답
*/
public function store(UploadTemplateLayoutAttachmentRequest $request, string $identifier): JsonResponse
{
$result = $this->service->upload(
$identifier,
$request->file('file'),
$request->input('layout_name'),
);
if (! $result['success']) {
return match ($result['error']) {
'template_not_found' => $this->notFound(__('templates.errors.not_found', ['template' => $identifier])),
default => $this->error(__('templates.layout_attachments.errors.upload_failed'), 500),
};
}
$attachment = $result['attachment'];
return $this->success(__('templates.layout_attachments.messages.uploaded'), [
'id' => $attachment->id,
'layout_name' => $attachment->layout_name,
'original_name' => $attachment->original_name,
'mime_type' => $attachment->mime_type,
'size' => $attachment->size,
'url' => $result['url'],
]);
}
/**
* 첨부 파일 삭제 — 스토리지 파일 실삭제 + DB 행 삭제.
*
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(TemplateLayoutAttachment $attachment): JsonResponse
{
$deleted = $this->service->delete($attachment);
if (! $deleted) {
return $this->error(__('templates.layout_attachments.errors.delete_failed'), 500);
}
return $this->success(__('templates.layout_attachments.messages.deleted'), null);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Seo\Editor\BroadcastCatalogService;
use Illuminate\Http\JsonResponse;
/**
* 웹소켓 채널/이벤트 카탈로그 엔드포인트 — 데이터소스 websocket 후보.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(권한/SEO 후보 엔드포인트와
* 동일 패턴 — admin 전역 broadcast 회피, Bearer fetch). 등록 채널 + (동적) 이벤트 목록을
* 반환한다. 미응답/빈 목록 시 편집기는 자유 텍스트 폴백.
*/
class BroadcastCatalogController extends AdminBaseController
{
public function __construct(
private readonly BroadcastCatalogService $catalogService,
) {
parent::__construct();
}
/**
* 채널/이벤트 카탈로그를 반환합니다.
*
* @param string $identifier 템플릿 식별자(라우트 일관성 — 카탈로그는 설치본 전역)
* @return JsonResponse channels / events 응답
*/
public function index(string $identifier): JsonResponse
{
$catalog = $this->catalogService->collect();
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $catalog),
);
}
}
@@ -90,4 +90,22 @@ class DashboardController extends AdminBaseController
return $this->error('dashboard.alerts_failed', 500, $e->getMessage());
}
}
/**
* 최근 발송된 알림 이력을 조회합니다.
*
* 대시보드 "최근 알림" 카드에 표시할 최근 알림 발송 이력을 반환합니다.
*
* @return JsonResponse 최근 알림 이력을 포함한 JSON 응답
*/
public function recentNotifications(): JsonResponse
{
try {
$notifications = $this->dashboardService->getRecentNotificationLogs();
return $this->success('dashboard.recent_notifications_loaded', $notifications);
} catch (\Exception $e) {
return $this->error('dashboard.recent_notifications_failed', 500, $e->getMessage());
}
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\Identity;
use App\Enums\IdentityPolicySourceType;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Identity\AdminIdentityPolicyDestroyRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyIndexRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyResetFieldRequest;
use App\Http\Requests\Identity\AdminIdentityPolicyStoreRequest;
@@ -12,23 +13,27 @@ use App\Http\Resources\Identity\PolicyCollection;
use App\Http\Resources\Identity\PolicyResource;
use App\Services\IdentityPolicyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 관리자 — IDV 정책 CRUD 컨트롤러.
*
* S1d 서브섹션(DataGrid + 편집 모달)을 위한 API.
* 선언형 정책(source_type != 'admin')은 enabled/grace_minutes/provider_id/fail_mode/conditions 5개 필드만
* 편집 가능하며, 편집 시 user_overrides JSON 에 필드명이 append 되어 Seeder 재실행 시 보존됩니다.
* 선언형 정책(source_type != 'admin')은 키(key)/시점(scope)/위치(target) 만 readonly 이며,
* 그 외 필드(purpose/provider_id/grace_minutes/applies_to/priority/fail_mode/enabled/conditions)는
* 운영자가 자유로이 편집할 수 있습니다. 편집 시 user_overrides JSON 에 필드명이 append 되어
* Seeder 재실행 시 보존됩니다.
*/
class AdminIdentityPolicyController extends AdminBaseController
{
/**
* source_type = core/module/plugin 정책이 수정 가능한 필드 화이트리스트.
*
* key/scope/target 은 확장이 발행하는 훅/라우트 지점 식별자라 변경 시 정책이 실제 지점과
* 어긋나므로 제외한다. 나머지("어떻게 인증할지")는 운영자 자유 편집 대상이다.
*
* @var list<string>
*/
protected const LIMITED_EDITABLE_FIELDS = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions'];
protected const LIMITED_EDITABLE_FIELDS = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions', 'purpose', 'applies_to', 'priority'];
/**
* @param IdentityPolicyService $policyService 정책 유스케이스 Service
@@ -128,11 +133,11 @@ class AdminIdentityPolicyController extends AdminBaseController
/**
* 정책을 삭제합니다 (source_type='admin' 정책만 가능, 선언형 정책은 비활성화로 대체).
*
* @param Request $request HTTP 요청
* @param AdminIdentityPolicyDestroyRequest $request 검증된 요청
* @param int $id 정책 ID
* @return JsonResponse
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(Request $request, int $id): JsonResponse
public function destroy(AdminIdentityPolicyDestroyRequest $request, int $id): JsonResponse
{
$policy = $this->policyService->findById($id);
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
@@ -26,8 +27,8 @@ class LayoutController extends AdminBaseController
/**
* 특정 템플릿의 모든 레이아웃 목록 조회
*
* @param string $templateName 템플릿 identifier
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @return JsonResponse 레이아웃 목록 응답
*/
public function index(string $templateName): JsonResponse
{
@@ -39,18 +40,24 @@ class LayoutController extends AdminBaseController
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
return $this->success(
'common.success',
LayoutResource::collection($layouts)
// 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다.
$routePathMap = $this->templateService->getLayoutRoutePathMap($templateName);
$collection = LayoutResource::collection($layouts);
$collection->collection->transform(
fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap)
);
return $this->success('common.success', $collection);
}
/**
* 특정 레이아웃 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 레이아웃 상세 응답
*/
public function show(string $templateName, string $name): JsonResponse
{
@@ -75,10 +82,10 @@ class LayoutController extends AdminBaseController
/**
* 레이아웃 수정
*
* @param UpdateLayoutContentRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param UpdateLayoutContentRequest $request 레이아웃 수정 요청
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 수정된 레이아웃 응답
*/
public function update(UpdateLayoutContentRequest $request, string $templateName, string $name): JsonResponse
{
@@ -99,6 +106,20 @@ class LayoutController extends AdminBaseController
'common.success',
new LayoutResource($layout)
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (\Exception $e) {
DB::rollBack();
@@ -113,9 +134,9 @@ class LayoutController extends AdminBaseController
/**
* 레이아웃의 모든 버전 목록 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, string $name): JsonResponse
{
@@ -142,10 +163,10 @@ class LayoutController extends AdminBaseController
/**
* 특정 버전의 레이아웃 content 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $version 버전 번호
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $version 버전 번호
* @return JsonResponse 버전 content 응답
*/
public function showVersion(string $templateName, string $name, int $version): JsonResponse
{
@@ -163,17 +184,18 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutVersionResource($layoutVersion)
// 버전 비교 diff 용 — content 원본 전체(slots/extends 등 포함) 노출.
(new LayoutVersionResource($layoutVersion))->withFullContent()
);
}
/**
* 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $versionId 버전 ID
* @return JsonResponse
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $versionId 버전 ID
* @return JsonResponse 복원된 버전 응답
*/
public function restoreVersion(string $templateName, string $name, int $versionId): JsonResponse
{
@@ -200,10 +222,10 @@ class LayoutController extends AdminBaseController
*
* 편집 중인 레이아웃 content를 임시 저장하고 미리보기 URL을 반환합니다.
*
* @param StoreLayoutPreviewRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
* @param StoreLayoutPreviewRequest $request 미리보기 생성 요청
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 미리보기 토큰/URL 응답
*/
public function storePreview(StoreLayoutPreviewRequest $request, string $templateName, string $name): JsonResponse
{
@@ -225,7 +247,7 @@ class LayoutController extends AdminBaseController
'common.success',
[
'token' => $preview->token,
'preview_url' => '/preview/' . $preview->token,
'preview_url' => '/preview/'.$preview->token,
'expires_at' => $preview->expires_at->toIso8601String(),
]
);
@@ -0,0 +1,315 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutExtensionPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutExtensionContentRequest;
use App\Http\Resources\LayoutExtensionResource;
use App\Http\Resources\LayoutExtensionVersionResource;
use App\Models\LayoutExtension;
use App\Services\LayoutExtensionService;
use App\Services\LayoutPreviewService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 레이아웃 확장 관리 컨트롤러 (admin)
*
* 모듈/플러그인이 주입한 레이아웃 확장을 관리자가 편집·버전관리·미리보기할 수 있도록 합니다.
* 확장은 동일 target_name 에 여러 source 가 존재할 수 있어 정수 PK(extensionId)로 식별합니다.
*/
class LayoutExtensionController extends AdminBaseController
{
public function __construct(
private LayoutExtensionService $layoutExtensionService,
private TemplateService $templateService,
private LayoutPreviewService $layoutPreviewService
) {
parent::__construct();
}
/**
* 특정 템플릿의 레이아웃 확장 목록 조회 (출처별 그룹핑)
*
* @param string $templateName 템플릿 identifier
* @return JsonResponse 출처별 그룹핑된 확장 목록
*/
public function index(string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$groups = $this->layoutExtensionService->getExtensionsByTemplateId($template->id);
// 그룹별 extensions 를 LayoutExtensionResource 컬렉션으로 직렬화.
// 각 확장에 호스트 레이아웃 목록(host_layouts)을 부착해, 라우트 트리가 클릭(캔버스 로드)
// 없이도 layoutName 매칭으로 화면별 연결 확장 목록을 정적 구성하게 한다.
$data = array_map(function (array $group): array {
foreach ($group['extensions'] as $extension) {
$extension->setAttribute(
'host_layouts',
$this->layoutExtensionService->getExtensionHostLayouts($extension)
);
}
$group['extensions'] = LayoutExtensionResource::collection($group['extensions']);
return $group;
}, $groups);
return $this->success('common.success', $data);
}
/**
* 특정 레이아웃 확장 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 확장 상세 응답
*/
public function show(string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 호스트 레이아웃 후보 — 확장 편집 모드 캔버스가 호스트 병합
// 렌더할 대상. overlay = [target_layout], extension_point = 그 확장점을 포함하는
// 레이아웃 전체(복수면 클라이언트가 대표 호스트 선택 picker 를 띄운다).
// ResponseHelper::success 는 Resource additional() 을 보존하지 않으므로 data 안에 병합.
$payload = (new LayoutExtensionResource($extension))->resolve();
$payload['host_layouts'] = $this->layoutExtensionService->getExtensionHostLayouts($extension);
return $this->success('common.success', $payload);
}
/**
* 레이아웃 확장 content 수정
*
* @param UpdateLayoutExtensionContentRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 업데이트된 확장 응답
*/
public function update(UpdateLayoutExtensionContentRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
// content 는 input() 으로 전체 배열을 전달한다.
// validated() 는 content.* 하위 규칙이 있을 경우 하위 키만 재구성하여
// extension_point/components 등 최상위 키가 누락되기 때문이다.
$data = [
'content' => $request->input('content'),
];
if ($request->has('priority')) {
$data['priority'] = $request->input('priority');
}
if ($request->has('expected_lock_version')) {
$data['expected_lock_version'] = $request->input('expected_lock_version');
}
$updated = $this->layoutExtensionService->updateExtension($extensionId, $data);
DB::commit();
return $this->success('common.success', new LayoutExtensionResource($updated));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장의 모든 버전 목록 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
$versions = $this->layoutExtensionService->getExtensionVersions($extensionId);
return $this->success('common.success', LayoutExtensionVersionResource::collection($versions));
}
/**
* 특정 버전의 레이아웃 확장 content 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $version 버전 번호
* @return JsonResponse 특정 버전 응답
*/
public function showVersion(string $templateName, int $extensionId, int $version): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
$extensionVersion = $this->layoutExtensionService->getExtensionVersion($extensionId, $version);
} catch (ModelNotFoundException) {
return $this->notFound('common.not_found');
}
return $this->success('common.success', new LayoutExtensionVersionResource($extensionVersion));
}
/**
* 레이아웃 확장 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $versionId 복원할 버전 ID
* @return JsonResponse 복원 후 새 버전 응답
*/
public function restoreVersion(string $templateName, int $extensionId, int $versionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$newVersion = $this->layoutExtensionService->restoreExtensionVersion($extensionId, $versionId);
DB::commit();
return $this->success('common.success', new LayoutExtensionVersionResource($newVersion));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장 미리보기 생성
*
* 편집 중인 확장 content를 임시 저장하고, 대표 레이아웃에 적용한 미리보기 URL을 반환합니다.
*
* @param StoreLayoutExtensionPreviewRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 미리보기 토큰 응답
*/
public function storePreview(StoreLayoutExtensionPreviewRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 대표 레이아웃 결정:
// - overlay 타입은 target_name 자체가 대표 레이아웃
// - extension_point 타입은 프론트가 preview_layout 으로 전달
$previewLayout = $request->validated('preview_layout');
if (! $previewLayout) {
$previewLayout = $extension->extension_type->value === 'overlay'
? $extension->target_name
: null;
}
if (! $previewLayout) {
return $this->error('validation.layout_extension.preview_layout.required', 422);
}
try {
$preview = $this->layoutPreviewService->createExtensionPreview(
$extension->template_id,
$extensionId,
$previewLayout,
$request->validated('content'),
$request->user()->id
);
return $this->success('common.success', [
'token' => $preview->token,
'preview_url' => '/preview/'.$preview->token,
'expires_at' => $preview->expires_at->toIso8601String(),
]);
} catch (\Exception $e) {
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 템플릿 식별자와 확장 ID 로 확장을 조회하고, 템플릿 소속을 교차검증합니다.
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null (템플릿 미존재 / 확장 미존재 / 소속 불일치)
*/
private function resolveExtension(string $templateName, int $extensionId): ?LayoutExtension
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return null;
}
try {
$extension = $this->layoutExtensionService->getExtensionById($extensionId);
} catch (ModelNotFoundException) {
return null;
}
// 확장이 요청 템플릿에 속하는지 교차검증
if ($extension->template_id !== $template->id) {
return null;
}
return $extension;
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoBotPreviewRequest;
use App\Seo\Editor\SeoBotPreviewService;
use Illuminate\Http\JsonResponse;
/**
* 봇 HTML 실시간 미리보기 엔드포인트 — 편집기 [검색엔진] 탭.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(Bearer fetch). dirty 레이아웃
* + 편집기 샘플 데이터로 운영과 동일 코드 경로(`renderFromResolved`)를 거쳐 완성 HTML 을
* 반환한다(SEO 캐시 우회). 설정 변경 디바운스 후 재호출로 실시간 갱신된다.
*/
class SeoBotPreviewController extends AdminBaseController
{
public function __construct(
private readonly SeoBotPreviewService $previewService,
) {
parent::__construct();
}
/**
* dirty 레이아웃 + 샘플로 봇 HTML 미리보기를 반환합니다.
*
* @param SeoBotPreviewRequest $request dirty 레이아웃·샘플·route·locale 검증된 입력
* @param string $identifier 템플릿 식별자
* @return JsonResponse 완성 HTML 또는 SEO 미노출 안내
*/
public function show(SeoBotPreviewRequest $request, string $identifier): JsonResponse
{
$validated = $request->validated();
$html = $this->previewService->render(
$validated['layout'],
$validated['route_params'] ?? [],
$validated['url'] ?? '/',
$validated['locale'] ?? app()->getLocale(),
$identifier,
$validated['module_id'] ?? null,
$validated['plugin_id'] ?? null,
$validated['seed_context'] ?? [],
);
// JSON 직렬화 경계 — 미리보기 HTML 에 유효하지 않은 UTF-8 바이트가 있으면 JsonResponse 가
// "Malformed UTF-8" 로 500 을 던진다(샘플 데이터/표현식 평가 잔여로 잘린 멀티바이트 가능).
// 응답 직전 한 번 정화해 직렬화를 보장한다(산출물 내용 불변 — 깨진 바이트만 제거).
if (is_string($html)) {
$clean = @iconv('UTF-8', 'UTF-8//IGNORE', $html);
$html = $clean === false ? mb_convert_encoding($html, 'UTF-8', 'UTF-8') : $clean;
}
return $this->success('common.success', [
'identifier' => $identifier,
// null = meta.seo.enabled=false 또는 미렌더 → 편집기 "검색엔진 미노출" 안내.
'enabled' => $html !== null,
'html' => $html,
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCandidateRequest;
use App\Seo\Editor\SeoCandidateService;
use Illuminate\Http\JsonResponse;
/**
* SEO 후보 엔드포인트 — 레이아웃 편집기 [검색엔진] 탭 후보 공급.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출된다(권한 후보 엔드포인트와
* 동일 패턴 — admin 전역 G7Config broadcast 회피, Bearer fetch). page_type 후보·toggle_setting
* 후보·유효 vars 후보를 한 번에 공급한다. 후보 미존재 시 빈 목록 → 편집기 자유 텍스트 폴백.
*
* 입력(query): `extensions`(JSON `[{type,id}]`), `page_type`(string|null).
*/
class SeoCandidateController extends AdminBaseController
{
public function __construct(
private readonly SeoCandidateService $candidateService,
) {
parent::__construct();
}
/**
* SEO 후보를 수집해 반환합니다.
*
* @param SeoCandidateRequest $request 편집 중 레이아웃의 extensions/page_type 을 query 로 전달
* @param string $identifier 템플릿 식별자(라우트 일관성 — 후보는 활성 확장 기준)
* @return JsonResponse page_types / toggle_settings / vars / extensions 후보 응답
*/
public function index(SeoCandidateRequest $request, string $identifier): JsonResponse
{
$extensions = $this->parseExtensions($request->query('extensions'));
$pageType = $request->query('page_type');
$pageType = is_string($pageType) && $pageType !== '' ? $pageType : null;
$candidates = $this->candidateService->collect(
$extensions,
$pageType,
app()->getLocale(),
);
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $candidates),
);
}
/**
* extensions query 파라미터를 `[{type,id}]` 배열로 파싱합니다.
*
* JSON 문자열 또는 배열 모두 허용(잘못된 형태는 빈 배열로 폴백 — 가드는 라우트 미들웨어).
*
* @param mixed $raw query('extensions')
* @return array<int, array{type: string, id: string}>
*/
private function parseExtensions(mixed $raw): array
{
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (! is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $ext) {
if (is_array($ext) && isset($ext['type'], $ext['id'])
&& is_string($ext['type']) && is_string($ext['id'])) {
$out[] = ['type' => $ext['type'], 'id' => $ext['id']];
}
}
return $out;
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoOgPreviewRequest;
use App\Seo\Editor\SeoOgPreviewService;
use Illuminate\Http\JsonResponse;
/**
* OG/Twitter/구조화 미리보기 엔드포인트 — 편집기 [검색엔진] 탭.
*
* 편집기 전용 가드(`core.templates.layouts.edit`) 하에서만 노출(Bearer fetch). dirty meta.seo +
* 샘플로 og/twitter cascade 를 실제 계산하고 필터 전/후 diff 로 출처·잠김을 산출한다. 탭 진입 /
* page_type·extensions 변경 시 재호출(이전 기본값 무효화).
*/
class SeoOgPreviewController extends AdminBaseController
{
public function __construct(
private readonly SeoOgPreviewService $previewService,
) {
parent::__construct();
}
/**
* og/twitter/structured 미리보기를 반환합니다.
*
* @param SeoOgPreviewRequest $request dirty meta.seo·샘플·route 검증된 입력
* @param string $identifier 템플릿 식별자
* @return JsonResponse 키별 cascade(값/출처/override/필터잠김) + structured 미리보기
*/
public function show(SeoOgPreviewRequest $request, string $identifier): JsonResponse
{
$validated = $request->validated();
$preview = $this->previewService->preview(
$validated['seo'],
$validated['seed_context'] ?? [],
$validated['route_params'] ?? [],
$validated['own_seo'] ?? null,
app()->getLocale(),
);
return $this->success(
'common.success',
array_merge(['identifier' => $identifier], $preview),
);
}
}
@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\TemplateCustomTranslation\BulkDestroyCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\IndexCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\StoreCustomTranslationRequest;
use App\Http\Requests\TemplateCustomTranslation\UpdateCustomTranslationRequest;
use App\Http\Resources\TemplateCustomTranslationResource;
use App\Services\TemplateCustomTranslationService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 템플릿 커스텀 다국어 키 컨트롤러.
*
* 레이아웃 편집기 인라인 편집/번역 탭에서 동적 다국어 키를
* CRUD 합니다. 권한은 `core.templates.layouts.edit` 미들웨어에서 처리합니다.
*/
class TemplateCustomTranslationController extends AdminBaseController
{
/**
* @param TemplateCustomTranslationService $service 커스텀 다국어 키 서비스
* @param TemplateService $templateService 템플릿 서비스 (식별자 → ID 변환)
*/
public function __construct(
private readonly TemplateCustomTranslationService $service,
private readonly TemplateService $templateService,
) {
parent::__construct();
}
/**
* 커스텀 다국어 키 목록 조회
*
* @param IndexCustomTranslationRequest $request 목록 조회 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 커스텀 키 목록 응답
*/
public function index(IndexCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$list = $this->service->getList(
$template->id,
$request->input('layout_name'),
$request->input('status'),
);
return $this->success(
'common.success',
TemplateCustomTranslationResource::collection($list),
);
}
/**
* 커스텀 다국어 키 생성 (인라인 편집 확정)
*
* @param StoreCustomTranslationRequest $request 생성 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 생성된 커스텀 키 응답
*/
public function store(StoreCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$model = $this->service->createKey(
templateId: $template->id,
layoutName: $request->input('layout_name'),
locale: $request->input('locale'),
value: $request->input('value'),
createdBy: $this->getCurrentUser()?->id,
);
DB::commit();
return $this->success(
'common.success',
new TemplateCustomTranslationResource($model),
201,
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 수정 (번역 탭 일괄 편집, 낙관적 잠금)
*
* @param UpdateCustomTranslationRequest $request 수정 요청
* @param string $templateName 템플릿 identifier
* @param int $id 커스텀 키 ID
* @return JsonResponse 수정된 커스텀 키 응답 (충돌 시 409)
*/
public function update(UpdateCustomTranslationRequest $request, string $templateName, int $id): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$model = $this->service->find($id);
if ($model === null || (int) $model->template_id !== (int) $template->id) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$updated = $this->service->updateValues(
id: $id,
values: $request->input('values'),
expectedLockVersion: (int) $request->input('expected_lock_version'),
updatedBy: $this->getCurrentUser()?->id,
);
DB::commit();
return $this->success(
'common.success',
new TemplateCustomTranslationResource($updated),
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (ModelNotFoundException $e) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 삭제
*
* @param string $templateName 템플릿 identifier
* @param int $id 커스텀 키 ID
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(string $templateName, int $id): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$model = $this->service->find($id);
if ($model === null || (int) $model->template_id !== (int) $template->id) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$this->service->deleteKey($id);
DB::commit();
return $this->success('common.success');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 커스텀 다국어 키 일괄 삭제 (관리 모달 "선택 삭제"/"미사용 전체 삭제")
*
* 요청 ids 중 해당 템플릿 소속 키만 삭제합니다(교차 템플릿 삭제 차단).
*
* @param BulkDestroyCustomTranslationRequest $request 일괄 삭제 요청
* @param string $templateName 템플릿 identifier
* @return JsonResponse 삭제 결과 응답 (삭제 건수 포함)
*/
public function bulkDestroy(BulkDestroyCustomTranslationRequest $request, string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
// 요청 ids 중 해당 템플릿 소속만 추려 교차 템플릿 삭제를 차단.
$ownedIds = [];
foreach ((array) $request->input('ids', []) as $id) {
$model = $this->service->find((int) $id);
if ($model !== null && (int) $model->template_id === (int) $template->id) {
$ownedIds[] = (int) $id;
}
}
if ($ownedIds === []) {
return $this->success('common.success', ['deleted' => 0]);
}
try {
DB::beginTransaction();
$deleted = $this->service->deleteKeys($ownedIds);
DB::commit();
return $this->success('common.success', ['deleted' => $deleted]);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
}
@@ -36,7 +36,7 @@ class UserController extends AdminBaseController
/**
* 필터링된 사용자 목록을 조회합니다.
*
* @param UserListRequest $request 사용자 목록 요청 데이터
* @param UserListRequest $request 사용자 목록 요청 데이터
* @return JsonResponse 사용자 목록과 통계 정보를 포함한 JSON 응답
*/
public function index(UserListRequest $request): JsonResponse
@@ -61,7 +61,7 @@ class UserController extends AdminBaseController
/**
* 새로운 사용자를 생성합니다.
*
* @param CreateUserRequest $request 사용자 생성 요청 데이터
* @param CreateUserRequest $request 사용자 생성 요청 데이터
* @return JsonResponse 생성된 사용자 정보를 포함한 JSON 응답
*/
public function store(CreateUserRequest $request): JsonResponse
@@ -84,7 +84,7 @@ class UserController extends AdminBaseController
/**
* 특정 사용자의 상세 정보를 조회합니다.
*
* @param User $user 조회할 사용자 모델
* @param User $user 조회할 사용자 모델
* @return JsonResponse 사용자 상세 정보를 포함한 JSON 응답
*/
public function show(User $user): JsonResponse
@@ -109,8 +109,8 @@ class UserController extends AdminBaseController
/**
* 기존 사용자 정보를 수정합니다.
*
* @param UpdateUserRequest $request 사용자 수정 요청 데이터
* @param User $user 수정할 사용자 모델
* @param UpdateUserRequest $request 사용자 수정 요청 데이터
* @param User $user 수정할 사용자 모델
* @return JsonResponse 수정된 사용자 정보를 포함한 JSON 응답
*/
public function update(UpdateUserRequest $request, User $user): JsonResponse
@@ -132,8 +132,8 @@ class UserController extends AdminBaseController
/**
* 사용자를 삭제합니다.
*
* @param DeleteUserRequest $request 사용자 삭제 요청 데이터
* @param User $user 삭제할 사용자 모델
* @param DeleteUserRequest $request 사용자 삭제 요청 데이터
* @param User $user 삭제할 사용자 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(DeleteUserRequest $request, User $user): JsonResponse
@@ -149,7 +149,13 @@ class UserController extends AdminBaseController
} catch (CannotDeleteSuperAdminException $e) {
return $this->error('exceptions.cannot_delete_super_admin', 422);
} catch (ValidationException $e) {
return $this->error('user.delete_failed', 422, $e->errors());
// UserService 가 던진 ValidationException 의 general[0] 에는 이미 `:error` 가
// 치환된 완성 메시지(예: "사용자 삭제에 실패했습니다: <상세 사유>")가 들어있다.
// 이를 최상위 message 로도 노출해 토스트에 `:error` 가 그대로 보이지 않게 한다
// (에러 상세 표시 기능 유지). errors 배열도 함께 전달.
$detail = $e->errors()['general'][0] ?? null;
return $this->error($detail ?? 'user.delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.delete_failed', 500, $e, ['error' => $e->getMessage()]);
}
@@ -198,7 +204,7 @@ class UserController extends AdminBaseController
/**
* 키워드로 사용자를 검색합니다. (이름, 닉네임, 이메일)
*
* @param SearchUserRequest $request 사용자 검색 요청 데이터
* @param SearchUserRequest $request 사용자 검색 요청 데이터
* @return JsonResponse 검색된 사용자 목록을 포함한 JSON 응답
*/
public function search(SearchUserRequest $request): JsonResponse
@@ -228,7 +234,7 @@ class UserController extends AdminBaseController
/**
* 이메일 주소의 중복 여부를 확인합니다.
*
* @param CheckEmailRequest $request 이메일 중복 확인 요청 데이터
* @param CheckEmailRequest $request 이메일 중복 확인 요청 데이터
* @return JsonResponse 이메일 사용 가능 여부를 포함한 JSON 응답
*/
public function checkEmail(CheckEmailRequest $request): JsonResponse
@@ -251,6 +257,9 @@ class UserController extends AdminBaseController
/**
* 현재 로그인된 사용자의 언어 설정을 업데이트합니다.
*
* @param UpdateLanguageRequest $request 언어 변경 요청 데이터
* @return JsonResponse 변경된 사용자 정보를 포함한 JSON 응답
*/
public function updateMyLanguage(UpdateLanguageRequest $request): JsonResponse
{
@@ -274,6 +283,9 @@ class UserController extends AdminBaseController
/**
* 여러 사용자의 상태를 일괄 변경합니다.
*
* @param BulkUpdateUserStatusRequest $request 일괄 상태 변경 요청 데이터
* @return JsonResponse 일괄 변경 결과를 포함한 JSON 응답
*/
public function bulkUpdateStatus(BulkUpdateUserStatusRequest $request): JsonResponse
{
@@ -2,13 +2,13 @@
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
@@ -36,7 +36,7 @@ class AuthController extends AuthBaseController
/**
* 사용자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
@@ -64,7 +64,7 @@ class AuthController extends AuthBaseController
/**
* 새로운 사용자를 등록시킵니다.
*
* @param RegisterRequest $request 등록 요청 데이터
* @param RegisterRequest $request 등록 요청 데이터
* @return JsonResponse 등록 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function register(RegisterRequest $request): JsonResponse
@@ -84,7 +84,7 @@ class AuthController extends AuthBaseController
/**
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
@@ -97,7 +97,7 @@ class AuthController extends AuthBaseController
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logoutFromAllDevices(Request $request): JsonResponse
@@ -110,7 +110,7 @@ class AuthController extends AuthBaseController
/**
* 현재 로그인된 사용자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
@@ -120,16 +120,18 @@ class AuthController extends AuthBaseController
// 역할 관계 로드 (권한은 역할을 통해 간접 연결)
$user->load(['roles.permissions']);
return $this->successWithResource(
// toAuthArray(): core.user.filter_resource_data 필터를 적용해 모듈 필드(결제 통화 등)를 병합.
// 프론트 currentUser 출처라, 로그인 시 계정 영속 통화 덮어씀(D-LOGIN-CUR)을 충족한다.
return $this->success(
'common.success',
new UserResource($user)
(new UserResource($user))->toAuthArray($request)
);
}
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @param Request $request HTTP 요청
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
@@ -147,7 +149,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호 재설정 토큰을 검증합니다.
*
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @return JsonResponse 토큰 유효성 검증 결과 JSON 응답
*/
public function validateResetToken(ValidateResetTokenRequest $request): JsonResponse
@@ -171,7 +173,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호 찾기 요청을 처리하고 인증 이메일을 발송합니다.
*
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @return JsonResponse 이메일 발송 결과 JSON 응답
*/
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
@@ -192,7 +194,7 @@ class AuthController extends AuthBaseController
/**
* 비밀번호를 재설정합니다.
*
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @return JsonResponse 비밀번호 재설정 결과 JSON 응답
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
@@ -103,11 +103,19 @@ class IdentityVerificationController extends PublicBaseController
],
);
// verify 실패 시에도 서버 측 시도 횟수를 응답에 포함 — 클라이언트가 자체 카운트를 서버와 동기화하여
// "남은 시도 횟수" UI 가 다른 탭/세션과 불일치하지 않도록.
if (! $result->success) {
$fresh = $challenge->fresh();
return $this->error(
$result->failureReason ?: 'identity.errors.generic',
422,
['failure_code' => $result->failureCode],
[
'failure_code' => $result->failureCode,
'attempts' => $fresh ? (int) $fresh->attempts : (int) $challenge->attempts,
'max_attempts' => $fresh ? (int) $fresh->max_attempts : (int) $challenge->max_attempts,
],
);
}
@@ -59,14 +59,45 @@ class PublicLayoutController extends PublicBaseController
}
try {
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
//
// 서버 캐시 키에는 **정수 버전만** 쓴다(소수 nonce 제거). 레이아웃 편집기는 같은 세션의
// 저장·버전 복원 직후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}` 형식으로
// 요청한다(클라이언트 cache-bust nonce). 그런데 `serve` 가 이 문자열을 그대로 캐시 키에
// 쓰면 키가 `...v{cacheVersion}.{nonce}.meta` 가 되는데, 저장 경로
// `LayoutService::clearPublicServingCache` 는 `(int) ext.cache_version` 으로 nonce 없는
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
$cacheVersion = (int) request()->query('v', 0);
// 서버 측 캐싱 (1시간 유효)
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
// - 일반 사이트 렌더는 옵션을 전달하지 않으므로 응답 형식 종전과 100% 동일
$withSourceMeta = (bool) request()->query('with_source_meta', false);
// 출처 메타 요청은 편집 권한 필요 — 일반 사용자가 메타를 보면 안 됨
// @since engine-v1.50.0
if ($withSourceMeta) {
$user = request()->user();
if ($user === null) {
return $this->unauthorized('auth.layout_guest_permission_denied', [
'required_permissions' => 'core.templates.layouts.edit',
]);
}
if (! PermissionHelper::check('core.templates.layouts.edit', $user)) {
return $this->forbidden('auth.layout_permission_denied', [
'required_permissions' => 'core.templates.layouts.edit',
]);
}
}
// 서버 측 캐싱 (1시간 유효) — 메타 포함/미포함은 별도 캐시 키
// getLayout()을 사용하여 레이아웃 로드, 병합, 확장 적용을 한 번에 수행
$metaSuffix = $withSourceMeta ? '.meta' : '';
$mergedLayout = $this->cached(
"layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}",
fn () => $this->layoutService->getLayout($templateIdentifier, $layoutName),
"layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}{$metaSuffix}",
fn () => $this->layoutService->getLayout($templateIdentifier, $layoutName, $withSourceMeta),
self::CACHE_TTL
);
@@ -28,7 +28,7 @@ class PublicModuleController extends PublicBaseController
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @param string $path 에셋 경로 (dist/js/module.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServeModuleAssetRequest $request,
@@ -55,4 +55,57 @@ class PublicModuleController extends PublicBaseController
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
/**
* 모듈 편집기 스펙 조회 — editor-spec.json 반환
*
* 활성 모듈만 대상으로 하며, 활성 디렉토리 → _bundled 폴백 순으로 읽어
* 템플릿 serveEditorSpec 과 동일한 응답 형태(`data.spec`)로 반환한다.
* 비활성/미존재 모듈은 404. 파일 미작성은 spec=null 정상 응답.
*
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('modules.editor_spec', ['identifier' => $identifier]);
$result = $this->moduleService->getEditorSpec($identifier);
if (! $result['success']) {
return $this->notFound(__('modules.errors.not_found', ['module' => $identifier]));
}
$message = $result['spec'] === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success($message, [
'identifier' => $identifier,
'spec' => $result['spec'],
]);
}
/**
* 모듈 컴포넌트 정의 파일 서빙 — components.json 반환
*
* 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스
* 병합하기 위해 fetch 한다. 미생성(구버전 모듈) 시 빈 components 로
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 모듈 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
$result = $this->moduleService->getComponents($identifier);
if (! $result['success']) {
return $this->notFound(__('modules.errors.not_found', ['module' => $identifier]));
}
return $this->cachedJsonResponse($result['components'] ?? new \stdClass, 3600);
}
}
@@ -28,7 +28,7 @@ class PublicPluginController extends PublicBaseController
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @param string $path 에셋 경로 (dist/js/plugin.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServePluginAssetRequest $request,
@@ -55,4 +55,57 @@ class PublicPluginController extends PublicBaseController
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
/**
* 플러그인 편집기 스펙 조회 — editor-spec.json 반환
*
* 활성 플러그인만 대상으로 하며, 활성 디렉토리 → _bundled 폴백 순으로 읽어
* 템플릿 serveEditorSpec 과 동일한 응답 형태(`data.spec`)로 반환한다.
* 비활성/미존재 플러그인은 404. 파일 미작성은 spec=null 정상 응답.
*
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('plugins.editor_spec', ['identifier' => $identifier]);
$result = $this->pluginService->getEditorSpec($identifier);
if (! $result['success']) {
return $this->notFound(__('plugins.errors.not_found', ['plugin' => $identifier]));
}
$message = $result['spec'] === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success($message, [
'identifier' => $identifier,
'spec' => $result['spec'],
]);
}
/**
* 플러그인 컴포넌트 정의 파일 서빙 — components.json 반환
*
* 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스
* 병합하기 위해 fetch 한다. 미생성(구버전 플러그인) 시 빈 components 로
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
$this->logApiUsage('plugins.components', ['identifier' => $identifier]);
$result = $this->pluginService->getComponents($identifier);
if (! $result['success']) {
return $this->notFound(__('plugins.errors.not_found', ['plugin' => $identifier]));
}
return $this->cachedJsonResponse($result['components'] ?? new \stdClass, 3600);
}
}
@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,7 +20,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicTemplateController extends PublicBaseController
{
public function __construct(
private TemplateService $templateService
private TemplateService $templateService,
private TemplateLayoutAttachmentService $layoutAttachmentService,
) {
parent::__construct();
}
@@ -26,6 +30,7 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
* @return JsonResponse 라우트 정보 응답
*/
public function getRoutes(string $identifier): JsonResponse
{
@@ -82,6 +87,11 @@ class PublicTemplateController extends PublicBaseController
/**
* 템플릿 정적 파일 서빙
*
* @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증)
* @param string $identifier 템플릿 식별자
* @param string $path 요청 경로
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러
*/
public function serveAsset(ServeTemplateAssetRequest $request, string $identifier, string $path): BinaryFileResponse|JsonResponse|Response
{
@@ -110,6 +120,7 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답
*/
public function serveComponents(string $identifier): JsonResponse
{
@@ -140,6 +151,7 @@ class PublicTemplateController extends PublicBaseController
* error_config 등 템플릿 메타데이터를 프론트엔드에 제공합니다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 템플릿 설정 응답
*/
public function serveConfig(string $identifier): JsonResponse
{
@@ -207,6 +219,7 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
* @return JsonResponse 다국어 데이터 응답
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
{
@@ -262,4 +275,68 @@ class PublicTemplateController extends PublicBaseController
// 성공 응답 (JSON 데이터 직접 반환, 래핑 없음)
return $this->cachedJsonResponse($languageData['data'], 3600);
}
/**
* 편집기 스펙 조회 — 템플릿 editor-spec.json 파일 반환
*
* Phase 3 S5a-1 에서 `nesting` 블록이 추가되었다. 본 엔드포인트는
* 활성 디렉토리 → _bundled 폴백 순으로 editor-spec.json 을 읽어 반환한다.
* 파일 미존재 시 spec=null 로 폴백 (편집기는 spec 미제공 안내).
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 편집기 스펙 응답
*/
public function serveEditorSpec(string $identifier): JsonResponse
{
$this->logApiUsage('templates.editor_spec', ['identifier' => $identifier]);
// 분할 editor-spec.json 은 manifest + `$include` 블록으로 구성된다.
// 활성 디렉토리만 기준으로 합본한 단일 spec 을 반환한다(_bundled 폴백 없음).
// 합본 결과는 분할 전 단일 파일과 동일 형태(프론트엔드 로더 무영향).
// 미분할 파일은 원본 반환(하위 호환), 미존재 시 null.
$spec = EditorSpecAssembler::assemble(
base_path("templates/{$identifier}/editor-spec.json")
);
$message = $spec === null
? __('templates.messages.editor_spec_empty')
: __('templates.messages.editor_spec_retrieved');
return $this->success(
$message,
[
'identifier' => $identifier,
'spec' => $spec,
]
);
}
/**
* 레이아웃 첨부 이미지 파일 서빙.
*
* 발행된 배경 이미지는 일반 사이트 방문자에게도 로드되어야 하므로 인증 불필요한
* 공개 엔드포인트로 둔다. 첨부는 비공개 `attachments` 디스크에 저장되므로 직접
* 공개 URL 이 없어, 본 라우트가 파일을 캐싱 헤더와 함께 인라인 스트림한다.
* 첨부가 경로의 템플릿 소속이 아니거나 파일이 없으면 404.
*
* @param string $identifier 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
* @return BinaryFileResponse|Response|JsonResponse 파일 응답 또는 404
*/
public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): BinaryFileResponse|Response|JsonResponse
{
$filePath = $this->layoutAttachmentService->getServableFilePath($identifier, $attachment);
if ($filePath === null) {
return $this->notFound('templates.layout_attachments.errors.not_found');
}
// 이미지/일반 파일 모두 캐싱 헤더와 함께 인라인 응답 (레이아웃 캐시 TTL, 기본 24시간).
// PublicAttachmentController 의 이미지 서빙과 동일한 fileResponse(ETag/Cache-Control) 사용.
return $this->fileResponse(
$filePath,
$attachment->mime_type,
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
}
@@ -2,10 +2,11 @@
namespace App\Http\Requests\Admin\Identity;
use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityPolicy;
use App\Rules\LocaleRequiredTranslatable;
use App\Rules\TranslatableField;
use Closure;
@@ -100,9 +101,9 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
return;
}
$exists = IdentityPolicy::where('key', $value)
->where('source_type', 'admin')
->exists();
// Service-Repository 패턴: Model facade 직접 호출 금지 → Repository Interface 경유.
$exists = app(IdentityPolicyRepositoryInterface::class)
->existsByKeyAndSourceType($value, 'admin');
if (! $exists) {
$fail(__('validation.identity_message.scope_value_not_admin_policy'));
@@ -125,12 +126,11 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
return;
}
$exists = IdentityMessageDefinition::where('provider_id', $providerId)
->where('scope_type', $scopeType)
->where('scope_value', $value)
->exists();
// Service-Repository 패턴: Model facade 직접 호출 금지 → Repository Interface 경유.
$existing = app(IdentityMessageDefinitionRepositoryInterface::class)
->findByScope($providerId, $scopeType, $value);
if ($exists) {
if ($existing !== null) {
$fail(__('validation.identity_message.definition_already_exists'));
}
};
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Admin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 봇 HTML 미리보기 요청 검증.
*
* 편집기가 dirty 레이아웃 + 샘플 컨텍스트를 POST 로 보낸다. 권한 체크는 라우트
* `core.templates.layouts.edit` 미들웨어에서 수행한다(authorize() 는 true 고정).
*/
class SeoBotPreviewRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 검증 규칙 배열
*/
public function rules(): array
{
$rules = [
'layout' => ['required', 'array'],
'route_params' => ['nullable', 'array'],
'url' => ['nullable', 'string'],
'locale' => ['nullable', 'string'],
'module_id' => ['nullable', 'string'],
'plugin_id' => ['nullable', 'string'],
'seed_context' => ['nullable', 'array'],
];
return HookManager::applyFilters('core.seo_bot_preview.show_validation_rules', $rules, $this);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Admin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* SEO 후보 조회 요청 검증.
*
* 편집기가 현재 레이아웃의 extensions·page_type 을 query 로 보낸다. 권한 체크는 라우트
* `core.templates.layouts.edit` 미들웨어에서 수행한다(authorize() 는 true 고정).
*/
class SeoCandidateRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 검증 규칙 배열
*/
public function rules(): array
{
$rules = [
// extensions 는 JSON 문자열 또는 배열 — 컨트롤러가 관대하게 파싱(가드는 미들웨어).
'extensions' => ['nullable'],
'page_type' => ['nullable', 'string'],
];
return HookManager::applyFilters('core.seo_candidate.index_validation_rules', $rules, $this);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Admin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* OG/Twitter/구조화 미리보기 요청 검증.
*
* 편집기가 dirty meta.seo + 샘플 컨텍스트를 POST 로 보낸다. 권한 체크는 라우트
* `core.templates.layouts.edit` 미들웨어에서 수행한다(authorize() 는 true 고정).
*/
class SeoOgPreviewRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 검증 규칙 배열
*/
public function rules(): array
{
$rules = [
'seo' => ['required', 'array'],
// 이 레이아웃이 직접 선언한 meta.seo(base 병합 전). 병합본에는 있으나 own 에 없는
// og/twitter 키 = base 상속(SEO-B). 선택 — 미전달 시 상속/자체 구분 안 함.
'own_seo' => ['nullable', 'array'],
'seed_context' => ['nullable', 'array'],
'route_params' => ['nullable', 'array'],
];
return HookManager::applyFilters('core.seo_og_preview.show_validation_rules', $rules, $this);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Admin\Template;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 템플릿 레이아웃 첨부 파일 목록 조회 요청 검증
*
* 권한은 라우트의 permission 미들웨어(core.templates.layouts.edit)가 담당하므로
* authorize()는 true 를 고정 반환한다.
*/
class ListTemplateLayoutAttachmentsRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 — layout_name 쿼리 필터(선택).
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'layout_name' => ['nullable', 'string', 'max:150'],
];
// 모듈/플러그인이 검증 규칙을 동적으로 확장할 수 있도록 훅 제공
return HookManager::applyFilters('core.template_layout_attachment.list_validation_rules', $rules, $this);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Requests\Admin\Template;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 템플릿 레이아웃 첨부 파일 업로드 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.templates.layouts.edit)가 담당하므로
* authorize()는 true 를 고정 반환한다(FormRequest authorize 에 권한 로직 금지 규칙).
*/
class UploadTemplateLayoutAttachmentRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
// 배경 이미지 등 — 이미지 파일만 허용. 최대 크기는 첨부 설정 재사용(MB→KB).
$maxSize = config('attachment.max_file_size', 10240);
$rules = [
'file' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,gif,webp,svg', 'max:'.$maxSize],
'layout_name' => ['nullable', 'string', 'max:150'],
];
// 모듈/플러그인이 검증 규칙을 동적으로 확장할 수 있도록 훅 제공
return HookManager::applyFilters('core.template_layout_attachment.upload_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'file.required' => __('templates.layout_attachments.validation.file_required'),
'file.file' => __('templates.layout_attachments.validation.file_invalid'),
'file.image' => __('templates.layout_attachments.validation.file_image'),
'file.mimes' => __('templates.layout_attachments.validation.file_mimes'),
'file.max' => __('templates.layout_attachments.validation.file_max', [
'max' => (int) (config('attachment.max_file_size', 10240) / 1024),
]),
'layout_name.max' => __('templates.layout_attachments.validation.layout_name_max'),
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Identity;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 운영자가 IDV 정책을 삭제할 때의 요청 검증.
*
* 삭제 가능 여부(source_type='admin' 만 허용)는 Controller 가 도메인 규칙으로 판정하며,
* 인증/권한은 라우트의 permission 미들웨어 체인이 담당합니다. 이 FormRequest 는 base
* Illuminate\Http\Request 직접 주입을 피하고 모듈/플러그인의 동적 규칙 확장 지점을
* 제공하기 위한 전용 서브클래스입니다.
*/
class AdminIdentityPolicyDestroyRequest extends FormRequest
{
/**
* 인증/권한은 route middleware 가 담당 — FormRequest 는 true 고정.
*
* @return bool 항상 true (권한 판정은 미들웨어 책임)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙 — 삭제는 라우트 {id} 만 사용하므로 본문 규칙은 없으나,
* 확장이 동적 규칙을 주입할 수 있도록 필터 훅을 통과시킵니다.
*
* @return array<string, array<int, mixed>> 검증 규칙
*/
public function rules(): array
{
$rules = [];
return HookManager::applyFilters('core.identity_policy.destroy_validation_rules', $rules, $this);
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Requests\Identity;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -15,7 +16,7 @@ class AdminIdentityPolicyResetFieldRequest extends FormRequest
/**
* 인증/권한은 route middleware 가 담당 — FormRequest 는 true 고정.
*
* @return bool
* @return bool 항상 true (권한 판정은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -29,8 +30,10 @@ class AdminIdentityPolicyResetFieldRequest extends FormRequest
*/
public function rules(): array
{
return [
'field' => ['required', 'string', 'in:enabled,grace_minutes,provider_id,fail_mode,conditions'],
$rules = [
'field' => ['required', 'string', 'in:enabled,grace_minutes,provider_id,fail_mode,conditions,purpose,applies_to,priority'],
];
return HookManager::applyFilters('core.identity_policy.reset_field_validation_rules', $rules, $this);
}
}
@@ -5,7 +5,9 @@ namespace App\Http\Requests\Identity;
use App\Enums\IdentityPolicyAppliesTo;
use App\Enums\IdentityPolicyFailMode;
use App\Enums\IdentityPolicyScope;
use App\Extension\HookManager;
use App\Models\IdentityPolicy;
use App\Rules\UniquePolicyPriorityPerTarget;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -19,7 +21,7 @@ class AdminIdentityPolicyStoreRequest extends FormRequest
/**
* 요청 권한 — 라우트 permission 미들웨어가 담당하므로 true 고정.
*
* @return bool
* @return bool 항상 true (권한 판정은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -33,7 +35,7 @@ class AdminIdentityPolicyStoreRequest extends FormRequest
*/
public function rules(): array
{
return [
$rules = [
'key' => ['required', 'string', 'max:120', Rule::unique(IdentityPolicy::class, 'key')],
'scope' => ['required', Rule::enum(IdentityPolicyScope::class)],
'target' => ['required', 'string', 'max:255'],
@@ -41,7 +43,18 @@ class AdminIdentityPolicyStoreRequest extends FormRequest
'provider_id' => ['nullable', 'string', 'max:64'],
'grace_minutes' => ['required', 'integer', 'min:0', 'max:43200'],
'enabled' => ['boolean'],
'priority' => ['integer', 'min:0', 'max:65535'],
// priority 동률 차단 — 같은 scope+target 에 동일 priority 활성 정책이 이미 있으면 거부.
// 동률 시 적용 순서 비결정성 을 저장 시점에 원천 봉쇄.
'priority' => [
'integer',
'min:0',
'max:65535',
new UniquePolicyPriorityPerTarget(
scope: (string) $this->input('scope', ''),
target: (string) $this->input('target', ''),
enabled: $this->boolean('enabled'),
),
],
'conditions' => ['nullable', 'array'],
'applies_to' => ['required', Rule::enum(IdentityPolicyAppliesTo::class)],
'fail_mode' => ['required', Rule::enum(IdentityPolicyFailMode::class)],
@@ -50,6 +63,8 @@ class AdminIdentityPolicyStoreRequest extends FormRequest
// 모듈/플러그인 sync 경로 및 목록 필터가 모두 raw identifier 컨벤션을 사용하므로 동일하게 통일.
'source_identifier' => ['nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_\-]*$/'],
];
return HookManager::applyFilters('core.identity_policy.store_validation_rules', $rules, $this);
}
/**
@@ -2,24 +2,29 @@
namespace App\Http\Requests\Identity;
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
use App\Enums\IdentityPolicyAppliesTo;
use App\Enums\IdentityPolicyFailMode;
use App\Enums\IdentityPolicyScope;
use App\Extension\HookManager;
use App\Models\IdentityPolicy;
use App\Rules\UniquePolicyPriorityPerTarget;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 운영자가 IDV 정책을 수정할 때의 검증.
*
* source_type != 'admin' 정책은 enabled/grace_minutes/provider_id/fail_mode 4개 필드만 허용됩니다.
* (key/scope/target/conditions 등은 readonly — 선언형 Seeder 의 SSoT 유지를 위해 Controller 에서 필터링)
* source_type != 'admin' 정책은 키(key)/시점(scope)/위치(target) 만 readonly 이며 (확장이 발행하는
* 훅/라우트 지점 식별자라 변경 시 정책이 지점과 어긋남), 그 외 필드는 운영자가 자유로이 편집할 수
* 있습니다 — Controller 의 LIMITED_EDITABLE_FIELDS 화이트리스트가 key/scope/target 만 필터링합니다.
*/
class AdminIdentityPolicyUpdateRequest extends FormRequest
{
/**
* 요청 권한 — 라우트 permission 미들웨어가 담당하므로 true 고정.
*
* @return bool
* @return bool 항상 true (권한 판정은 미들웨어 책임)
*/
public function authorize(): bool
{
@@ -33,7 +38,7 @@ class AdminIdentityPolicyUpdateRequest extends FormRequest
*/
public function rules(): array
{
return [
$rules = [
'enabled' => ['sometimes', 'boolean'],
'grace_minutes' => ['sometimes', 'integer', 'min:0', 'max:43200'],
'provider_id' => ['sometimes', 'nullable', 'string', 'max:64'],
@@ -44,10 +49,87 @@ class AdminIdentityPolicyUpdateRequest extends FormRequest
'scope' => ['sometimes', Rule::enum(IdentityPolicyScope::class)],
'target' => ['sometimes', 'string', 'max:255'],
'purpose' => ['sometimes', 'string', 'max:64'],
'priority' => ['sometimes', 'integer', 'min:0', 'max:65535'],
// priority 동률 차단 — 같은 scope+target 에 동일 priority 활성 정책이 이미 있으면 거부 (자기 자신 제외).
// scope/target/enabled 는 요청에 없으면 기존 정책 값으로 폴백 (선언형 정책은 scope/target 변경 불가).
'priority' => [
'sometimes',
'integer',
'min:0',
'max:65535',
new UniquePolicyPriorityPerTarget(
scope: $this->effectiveScope(),
target: $this->effectiveTarget(),
enabled: $this->effectiveEnabled(),
ignoreId: $this->currentPolicy()?->id,
),
],
'conditions' => ['sometimes', 'nullable', 'array'],
'applies_to' => ['sometimes', Rule::enum(IdentityPolicyAppliesTo::class)],
];
return HookManager::applyFilters('core.identity_policy.update_validation_rules', $rules, $this);
}
/**
* 동일 요청 내 1회 조회 캐시 (false = 미조회). static 금지 — 인스턴스별로 격리해야
* 한 프로세스에서 여러 요청이 처리되는 환경(테스트 러너 등)에서 이전 요청의 정책이
* 재사용되는 오염을 방지한다.
*/
private IdentityPolicy|null|false $cachedPolicy = false;
/**
* 수정 대상 정책을 라우트 {id} 로 1회 조회해 캐싱합니다 (priority 동률 검사용 폴백).
*
* @return IdentityPolicy|null 대상 정책 또는 null
*/
protected function currentPolicy(): ?IdentityPolicy
{
if ($this->cachedPolicy === false) {
$id = $this->route('id');
$this->cachedPolicy = is_numeric($id)
? app(IdentityPolicyRepositoryInterface::class)->findById((int) $id)
: null;
}
return $this->cachedPolicy;
}
/**
* 동률 검사에 사용할 scope — 요청에 있으면 그 값, 없으면 기존 정책 값.
*/
protected function effectiveScope(): string
{
$scope = $this->input('scope');
if (is_string($scope) && $scope !== '') {
return $scope;
}
return (string) ($this->currentPolicy()?->scope?->value ?? '');
}
/**
* 동률 검사에 사용할 target — 요청에 있으면 그 값, 없으면 기존 정책 값.
*/
protected function effectiveTarget(): string
{
$target = $this->input('target');
if (is_string($target) && $target !== '') {
return $target;
}
return (string) ($this->currentPolicy()?->target ?? '');
}
/**
* 동률 검사에 사용할 enabled — 요청에 있으면 그 값, 없으면 기존 정책 값.
*/
protected function effectiveEnabled(): bool
{
if ($this->has('enabled')) {
return $this->boolean('enabled');
}
return (bool) ($this->currentPolicy()?->enabled ?? false);
}
/**
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Requests\Layout;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 레이아웃 확장 미리보기 생성 요청 검증
*
* 편집 중인 확장 content를 임시 저장하여, 대표 레이아웃에 적용한 미리보기를 생성합니다.
*/
class StoreLayoutExtensionPreviewRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 데이터 전처리
*
* content가 JSON 문자열로 전송된 경우 배열로 변환합니다.
*/
protected function prepareForValidation(): void
{
$content = $this->input('content');
if (is_string($content)) {
$decoded = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$this->merge(['content' => $decoded]);
}
}
}
/**
* 요청에 적용할 검증 규칙
*
* preview_layout: 미리보기에 사용할 대표 레이아웃명.
* - overlay 타입은 target_layout 자체가 대표 레이아웃이므로 생략 가능.
* - extension_point 타입은 프론트가 선택한 레이아웃명을 전달.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'content' => ['required', 'array'],
'preview_layout' => ['nullable', 'string', 'max:255'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.layout_extension.store_preview_validation_rules', $rules, $this);
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'content.required' => __('validation.layout_extension.content.required'),
'content.array' => __('validation.layout_extension.content.array'),
'preview_layout.string' => __('validation.layout_extension.preview_layout.string'),
'preview_layout.max' => __('validation.layout_extension.preview_layout.max'),
];
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Requests\Layout;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Rules\NoExternalUrls;
use App\Rules\ValidDataSourceMerge;
@@ -11,6 +12,8 @@ use App\Rules\ValidPermissionStructure;
use App\Rules\ValidSlotStructure;
use App\Rules\WhitelistedEndpoint;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -26,6 +29,8 @@ class UpdateLayoutContentRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 처리)
*/
public function authorize(): bool
{
@@ -52,21 +57,184 @@ class UpdateLayoutContentRequest extends FormRequest
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$this->merge(['content' => $decoded]);
$content = $decoded;
}
// JSON 파싱 실패 시 원본 유지 (검증에서 'array' 규칙으로 실패 처리)
}
// 편집기 응답의 상속/주입/partial 노드 + 편집기 전용 메타 제거.
// 클라이언트 1차 마스킹(`stripInheritedFromLayoutContent`)이 누락/우회되어도 백엔드가
// 최종 방어선으로 같은 정책을 적용한다. 정책 상세는 클라이언트 동명 함수 주석 참조.
if (is_array($content)) {
$this->merge(['content' => $this->stripInheritedFromLayoutContent($content)]);
}
// 라우트 파라미터에서 templateName을 가져와 template_id로 변환
// ValidParentLayout 규칙에서 template_id를 사용하기 때문에 필요
// Service-Repository 패턴: Model facade 직접 호출 금지 → Repository Interface 경유.
$templateName = $this->route('templateName');
if ($templateName && ! $this->has('template_id')) {
$template = \App\Models\Template::where('identifier', $templateName)->first();
$template = app(TemplateRepositoryInterface::class)->findByIdentifier($templateName);
if ($template) {
$this->merge(['template_id' => $template->id]);
}
}
}
/**
* 레이아웃 content 페이로드에서 상속/주입/partial 노드와 편집기 전용 메타를 제거.
*
* 편집 모드 응답(`with_source_meta=1`)은 자식 레이아웃의 슬롯에 base 레이아웃 노드를
* 머지해 노출한다. 이 메타 노드들이 그대로 자식 레이아웃 content 로 저장되면 다음
* 로드 시 머지가 중복되거나 base/확장/partial 의 변경이 자식에 박힌 사본에 가려진다.
*
* 정책 (클라이언트 `stripInheritedFromLayoutContent` 와 동일):
* - `__editor.original` 은 자식 레이아웃의 **구조 메타** (extends, slots 이름, meta,
* data_sources 등) 의 SSoT 일 뿐, **콘텐츠의 SSoT 가 아니다**. 콘텐츠는 사용자가
* 캔버스에서 누적 편집한 머지된 components 트리에서 마스킹으로 추출한다.
* - 1) 머지된 components 트리를 마스킹 → 본 자식 레이아웃의 route 콘텐츠만 남김
* - 2) `__editor.original` 의 구조 메타를 골격으로 차용, 콘텐츠 자리는 1) 결과로 채움
* - 3) extends 가 있으면 components 키 제거 + slots 로 재구성, 없으면 components 사용
* - 4) 응답 전용 메타 키(`lock_version`, `__editor`) 제거
*
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
private function stripInheritedFromLayoutContent(array $content): array
{
// 응답 전용 메타 키 제거 (어느 경로든 페이로드에 박히면 안 됨)
$content_clean = $content;
unset($content_clean['lock_version'], $content_clean['__editor']);
$hasMergedComponents = is_array($content['components'] ?? null) && ! empty($content['components']);
$hasSlots = is_array($content['slots'] ?? null);
$hasExtends = isset($content_clean['extends']) && is_string($content_clean['extends']) && $content_clean['extends'] !== '';
// 경로 A — 클라이언트가 이미 마스킹한 결과 (extends + slots, components 없음 또는 빈 배열).
// 클라이언트 1차 마스킹의 정상 페이로드. components 트리에서 다시 추출하지 않고
// slots 안의 각 콘텐츠만 안전망 마스킹 (재마스킹은 메타가 없으므로 no-op 에 가까움).
if ($hasExtends && $hasSlots && ! $hasMergedComponents) {
$nextSlots = [];
foreach ($content_clean['slots'] as $slotName => $slotValue) {
$nextSlots[$slotName] = is_array($slotValue)
? $this->stripInheritedNodes($slotValue)
: $slotValue;
}
$content_clean['slots'] = $nextSlots;
unset($content_clean['components']);
return $content_clean;
}
// 경로 B — 머지된 components 트리가 포함된 페이로드 (편집기 응답 그대로 우회 전송).
// route 콘텐츠 추출 후 골격에 매핑.
$original = is_array($content['__editor']['original'] ?? null)
? $content['__editor']['original']
: null;
$maskedComponents = $hasMergedComponents
? $this->stripInheritedNodes($content['components'])
: [];
$skeleton = $original !== null ? $original : $content_clean;
unset($skeleton['lock_version'], $skeleton['__editor']);
$skeletonHasExtends = isset($skeleton['extends']) && is_string($skeleton['extends']) && $skeleton['extends'] !== '';
if ($skeletonHasExtends) {
unset($skeleton['components']);
$skeletonSlots = is_array($skeleton['slots'] ?? null) ? $skeleton['slots'] : [];
$slotNames = array_keys($skeletonSlots);
if (count($slotNames) === 1) {
$skeleton['slots'] = [$slotNames[0] => $maskedComponents];
} elseif (count($slotNames) === 0) {
$skeleton['slots'] = ['content' => $maskedComponents];
} else {
$nextSlots = [];
foreach ($skeletonSlots as $slotName => $slotValue) {
$nextSlots[$slotName] = is_array($slotValue)
? $this->stripInheritedNodes($slotValue)
: $slotValue;
}
$skeleton['slots'] = $nextSlots;
}
} else {
$skeleton['components'] = $maskedComponents;
unset($skeleton['slots']);
}
return $skeleton;
}
/**
* components 배열 마스킹 — 노드 종류에 따라 0개·1개·N개를 펼쳐 누적.
*
* @param array<int, mixed> $components
* @return array<int, array<string, mixed>>
*/
private function stripInheritedNodes(array $components): array
{
$result = [];
foreach ($components as $node) {
if (! is_array($node)) {
continue;
}
$cleanedList = $this->stripInheritedNode($node);
foreach ($cleanedList as $cleaned) {
$result[] = $cleaned;
}
}
return $result;
}
/**
* 단일 노드 마스킹 — 노드 종류에 따라 0개·1개·N개를 반환.
*
* `LayoutService::replaceSlots` 가 머지할 때:
* - **slot 래퍼**: `__source.kind === 'base'` + `_fromBase` 부재. base 가 정의한
* 슬롯 위치 컨테이너로, 그 안에 자식 레이아웃의 route 콘텐츠가 끼워진다.
* 슬롯 자체는 base 소유지만 안의 콘텐츠는 자식 레이아웃 소속이므로,
* **자체는 버리되 children 을 부모 배열로 끌어올린다** (재귀 마스킹).
* - **일반 base 노드**: `__source.kind === 'base'` + `_fromBase: true`. 헤더/사이드바
* /푸터 등 자식 레이아웃에 속하지 않는 base 콘텐츠 → 통째 제거.
* - extension/partial 노드: 통째 제거 (별도 SSoT 가 책임).
* - route 또는 메타 미부여 노드: 보존 + 메타 제거 + children 재귀 마스킹.
*
* @param array<string, mixed> $node
* @return array<int, array<string, mixed>>
*/
private function stripInheritedNode(array $node): array
{
$fromBase = ($node['_fromBase'] ?? false) === true;
$kind = is_array($node['__source'] ?? null) ? ($node['__source']['kind'] ?? null) : null;
// extension/partial 노드 — 통째 제거
if ($kind === 'extension' || $kind === 'partial') {
return [];
}
// base 출처 노드 (slot 래퍼 또는 일반 base 노드) — 자체는 버리되 children 의 route
// 콘텐츠는 끌어올린다. `LayoutService::replaceSlots` 가 base 의 깊은 자손에 slot
// 래퍼를 두고 그 안에 route 콘텐츠를 끼우므로, base 노드를 무조건 제거하면 그
// 자손의 route 콘텐츠까지 사라진다.
if ($kind === 'base' || $fromBase) {
$children = is_array($node['children'] ?? null) ? $node['children'] : [];
return $this->stripInheritedNodes($children);
}
unset($node['__source'], $node['_fromBase']);
if (isset($node['children']) && is_array($node['children'])) {
$node['children'] = $this->stripInheritedNodes($node['children']);
}
return [$node];
}
/**
* 요청에 적용할 검증 규칙
*
@@ -74,7 +242,7 @@ class UpdateLayoutContentRequest extends FormRequest
* - standalone: endpoint, components 필수
* - extends: extends, slots 사용 (endpoint, components는 부모에서 상속)
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -87,6 +255,11 @@ class UpdateLayoutContentRequest extends FormRequest
$isBaseLayout = is_array($content) && isset($content['slots']) && is_array($content['slots']);
$rules = [
// 낙관적 잠금 — 클라이언트가 로드한 시점의 lock_version 필수 전달
// Service::updateLayout 가 현재 DB lock_version 과 비교해 불일치 시
// ConcurrentModificationException 으로 409 반환
'expected_lock_version' => ['required', 'integer', 'min:0'],
// content 전체 검증 (ValidLayoutStructure에서 extends/standalone 분기 처리)
'content' => [
'required',
@@ -132,13 +305,24 @@ class UpdateLayoutContentRequest extends FormRequest
'content.metadata' => ['nullable', 'array'],
// 메타 정보 (title, description, auth_required 등)
// 주의: content.meta 는 'array' 규칙 + 하위 키 일부 명시 조합이므로 Laravel validated()
// 는 명시된 하위 키만 추출한다(미명시 키는 저장 시 누락). 엔진이 소비하는 meta 하위 키는
// 빠짐없이 명시해야 한다.
'content.meta' => ['nullable', 'array'],
'content.meta.title' => ['nullable', 'string'],
'content.meta.description' => ['nullable', 'string'],
'content.meta.keywords' => ['nullable', 'string'],
'content.meta.auth_required' => ['nullable', 'boolean'],
'content.meta.is_base' => ['nullable', 'boolean'],
// 비로그인 전용 라우트 표식 — _redirect_if_logged_in 가드 + SEO/sitemap 제외 판정에 소비
'content.meta.guest_only' => ['nullable', 'boolean'],
// 에러 레이아웃 표식 — ErrorPageHandler 가 소비
'content.meta.is_error_layout' => ['nullable', 'boolean'],
'content.meta.error_code' => ['nullable', 'integer'],
// SEO 메타데이터
// 주의: content.meta.seo 도 'array' 규칙 + 하위 키 일부 명시이므로 SEO 페이지 생성기가
// 소비하는 모든 하위 키를 명시해야 validated 에서 보존된다.
'content.meta.seo' => ['nullable', 'array'],
'content.meta.seo.enabled' => ['nullable', 'boolean'],
'content.meta.seo.data_sources' => ['nullable', 'array'],
@@ -147,6 +331,11 @@ class UpdateLayoutContentRequest extends FormRequest
'content.meta.seo.changefreq' => ['nullable', 'string', 'in:always,hourly,daily,weekly,monthly,yearly,never'],
'content.meta.seo.og' => ['nullable', 'array'],
'content.meta.seo.structured_data' => ['nullable', 'array'],
// SEO 페이지 생성기 소비 키 (SeoRenderer/TemplateRouteResolver)
'content.meta.seo.page_type' => ['nullable', 'string'],
'content.meta.seo.toggle_setting' => ['nullable', 'string'],
'content.meta.seo.vars' => ['nullable', 'array'],
'content.meta.seo.extensions' => ['nullable', 'array'],
// 모달 컴포넌트 정의
'content.modals' => ['nullable', 'array'],
@@ -163,6 +352,26 @@ class UpdateLayoutContentRequest extends FormRequest
// 초기 상태 (init_state - state의 대체 키)
'content.init_state' => ['nullable', 'array'],
// 데이터소스 병합 전 정적 로컬/전역/격리 상태 초기값 (TemplateApp 가 소비)
// [초기 상태] 탭의 컴포넌트 격리 상태 초기값(initIsolated)
// 추가. 미명시 시 validated() 가 떨궈 격리 초기값이 저장되지 않음(R9 누락 가드).
'content.initLocal' => ['nullable', 'array'],
'content.initGlobal' => ['nullable', 'array'],
'content.initIsolated' => ['nullable', 'array'],
// 전역 상태 초기값 (레이아웃 레벨 _global 초기화)
'content.global_state' => ['nullable', 'array'],
// 에러 핸들링 정책 (상태 코드별 handler — ErrorHandlingResolver 가 소비)
'content.errorHandling' => ['nullable', 'array'],
// 레이아웃 레벨 재사용 액션 정의 (ActionDispatcher 가 id 로 참조)
'content.actions' => ['nullable', 'array'],
// 플러그인 설정 레이아웃 전용 — 안내/스키마 (settings UI 렌더가 소비)
'content.pageConfig' => ['nullable', 'array'],
'content.schema' => ['nullable', 'array'],
// 라우트 정의
'content.routes' => ['nullable', 'array'],
@@ -201,27 +410,29 @@ class UpdateLayoutContentRequest extends FormRequest
'content.transition_overlay.wait_for.*' => ['string', 'max:100'],
];
// extends 레이아웃 또는 Base 레이아웃이 아닌 경우에만 endpoint, components 필수
// - extends 레이아웃: 부모로부터 endpoint 상속
// - Base 레이아웃 (slots 정의): 자식 레이아웃이 endpoint 정의
// content.endpoint 는 항상 선택적(nullable)이다.
//
// 최상위 endpoint 는 화면이 주로 fetch 하는 데이터 API 경로를 가리키는 레거시 필드로,
// 로그인/대시보드/정적 페이지처럼 주 데이터 fetch 가 없는 standalone 레이아웃은 정당하게
// endpoint 가 없다(번들 admin 103 + basic 39 = 전 142 레이아웃이 endpoint 부재 상태로
// 정상 렌더·동작). 구조 SSoT(ValidLayoutStructure) 도 endpoint 를 필수로 요구하지 않으며,
// 런타임 어디에서도 최상위 endpoint 를 소비하지 않는다(데이터소스의 endpoint 만 사용).
//
// 과거 standalone 분기에서 endpoint 를 required 로 강제하던 규칙은, endpoint 없이 잘
// 동작하던 전 레이아웃을 편집기로 컴포넌트만 추가해 저장하려 해도 422 로 막는 회귀를
// 일으켰다. endpoint 가 명시되면 whitelist/외부URL 차단 검증은 그대로 적용한다.
$rules['content.endpoint'] = [
'nullable',
'string',
new WhitelistedEndpoint,
new NoExternalUrls,
];
if (! $isExtending && ! $isBaseLayout) {
$rules['content.endpoint'] = [
'required',
'string',
new WhitelistedEndpoint,
new NoExternalUrls,
];
// standalone 레이아웃은 components 필수 (ValidLayoutStructure 와 동일 계약)
$rules['content.components'] = ['required', 'array'];
} else {
// extends 또는 Base 레이아웃은 endpoint가 선택적
$rules['content.endpoint'] = [
'nullable',
'string',
new WhitelistedEndpoint,
new NoExternalUrls,
];
// extends 레이아웃은 components 또는 slots 중 하나 사용
// ValidLayoutStructure에서 상세 검증
// extends 레이아웃은 components 또는 slots 중 하나 사용 — ValidLayoutStructure 에서 상세 검증
$rules['content.components'] = ['nullable', 'array'];
}
@@ -234,10 +445,12 @@ class UpdateLayoutContentRequest extends FormRequest
*
* wait_for 는 spinner 가 fetch 완료까지 표시되어야 할 데이터소스 ID 목록이지만,
* 의미상 사용자를 차단할 수 없는 background/websocket 데이터소스는 사전에 차단한다.
*
* @param Validator $validator Laravel 검증기 인스턴스
*/
public function withValidator(\Illuminate\Contracts\Validation\Validator $validator): void
public function withValidator(Validator $validator): void
{
$validator->after(function (\Illuminate\Contracts\Validation\Validator $v): void {
$validator->after(function (Validator $v): void {
$waitFor = $this->input('content.transition_overlay.wait_for');
if (! is_array($waitFor) || empty($waitFor)) {
return;
@@ -285,6 +498,9 @@ class UpdateLayoutContentRequest extends FormRequest
public function messages(): array
{
return [
'expected_lock_version.required' => __('validation.layout.expected_lock_version.required'),
'expected_lock_version.integer' => __('validation.layout.expected_lock_version.integer'),
'expected_lock_version.min' => __('validation.layout.expected_lock_version.min'),
'content.required' => __('validation.layout.content.required'),
'content.array' => __('validation.layout.content.array'),
'content.version.required' => __('validation.layout.version.required'),
@@ -292,7 +508,7 @@ class UpdateLayoutContentRequest extends FormRequest
'content.layout_name.required' => __('validation.layout.layout_name.required'),
'content.layout_name.string' => __('validation.layout.layout_name.string'),
'content.layout_name.max' => __('validation.layout.layout_name.max'),
'content.endpoint.required' => __('validation.layout.endpoint.required'),
// content.endpoint 는 nullable 이므로 required 메시지 키는 더 이상 발화되지 않는다.
'content.endpoint.string' => __('validation.layout.endpoint.string'),
'content.extends.string' => __('validation.layout.extends.string'),
'content.slots.array' => __('validation.layout.slots.array'),
@@ -303,8 +519,12 @@ class UpdateLayoutContentRequest extends FormRequest
'content.meta.array' => __('validation.layout.meta.array'),
'content.meta.title.string' => __('validation.layout.meta.title.string'),
'content.meta.description.string' => __('validation.layout.meta.description.string'),
'content.meta.keywords.string' => __('validation.layout.meta.keywords.string'),
'content.meta.auth_required.boolean' => __('validation.layout.meta.auth_required.boolean'),
'content.meta.is_base.boolean' => __('validation.layout.meta.is_base.boolean'),
'content.meta.guest_only.boolean' => __('validation.layout.meta.guest_only.boolean'),
'content.meta.is_error_layout.boolean' => __('validation.layout.meta.is_error_layout.boolean'),
'content.meta.error_code.integer' => __('validation.layout.meta.error_code.integer'),
'content.meta.seo.array' => __('validation.layout.meta.seo.array'),
'content.meta.seo.enabled.boolean' => __('validation.layout.meta.seo.enabled.boolean'),
'content.meta.seo.data_sources.array' => __('validation.layout.meta.seo.data_sources.array'),
@@ -316,11 +536,22 @@ class UpdateLayoutContentRequest extends FormRequest
'content.meta.seo.changefreq.in' => __('validation.layout.meta.seo.changefreq.in'),
'content.meta.seo.og.array' => __('validation.layout.meta.seo.og.array'),
'content.meta.seo.structured_data.array' => __('validation.layout.meta.seo.structured_data.array'),
'content.meta.seo.page_type.string' => __('validation.layout.meta.seo.page_type.string'),
'content.meta.seo.toggle_setting.string' => __('validation.layout.meta.seo.toggle_setting.string'),
'content.meta.seo.vars.array' => __('validation.layout.meta.seo.vars.array'),
'content.meta.seo.extensions.array' => __('validation.layout.meta.seo.extensions.array'),
'content.modals.array' => __('validation.layout.modals.array'),
'content.state.array' => __('validation.layout.state.array'),
'content.init_actions.array' => __('validation.layout.init_actions.array'),
'content.defines.array' => __('validation.layout.defines.array'),
'content.init_state.array' => __('validation.layout.init_state.array'),
'content.initLocal.array' => __('validation.layout.initLocal.array'),
'content.initGlobal.array' => __('validation.layout.initGlobal.array'),
'content.global_state.array' => __('validation.layout.global_state.array'),
'content.errorHandling.array' => __('validation.layout.errorHandling.array'),
'content.actions.array' => __('validation.layout.actions.array'),
'content.pageConfig.array' => __('validation.layout.pageConfig.array'),
'content.schema.array' => __('validation.layout.schema.array'),
'content.routes.array' => __('validation.layout.routes.array'),
'content.computed.array' => __('validation.layout.computed.array'),
'content.permissions' => __('validation.layout.permissions.array'),
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Requests\Layout;
use App\Extension\HookManager;
use App\Rules\NoExternalUrls;
use App\Rules\ValidDataSourceMerge;
use App\Rules\ValidLayoutExtensionStructure;
use App\Rules\WhitelistedEndpoint;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 레이아웃 확장 Content 업데이트 요청 검증
*
* 레이아웃 확장(extension_point / overlay)의 content JSON 구조를 검증합니다.
*/
class UpdateLayoutExtensionContentRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 데이터 전처리
*
* content가 JSON 문자열로 전송된 경우 배열로 변환합니다.
*/
protected function prepareForValidation(): void
{
$content = $this->input('content');
if (is_string($content)) {
$decoded = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$this->merge(['content' => $decoded]);
}
}
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
// 낙관적 잠금 — 클라이언트가 로드한 시점의 lock_version 필수 전달
'expected_lock_version' => ['required', 'integer', 'min:0'],
'content' => [
'required',
'array',
new ValidLayoutExtensionStructure,
],
// 우선순위 (선택 — content.priority 와 별개로 직접 지정 가능)
'priority' => ['nullable', 'integer', 'min:0', 'max:9999'],
// content 내부 priority
'content.priority' => ['nullable', 'integer', 'min:0', 'max:9999'],
// 데이터소스 검증
'content.data_sources' => ['nullable', 'array', new ValidDataSourceMerge],
// 데이터소스 endpoint 검증
'content.data_sources.*.endpoint' => [
'nullable',
'string',
new WhitelistedEndpoint,
new NoExternalUrls,
],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.layout_extension.update_content_validation_rules', $rules, $this);
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'expected_lock_version.required' => __('validation.layout_extension.expected_lock_version.required'),
'expected_lock_version.integer' => __('validation.layout_extension.expected_lock_version.integer'),
'expected_lock_version.min' => __('validation.layout_extension.expected_lock_version.min'),
'content.required' => __('validation.layout_extension.content.required'),
'content.array' => __('validation.layout_extension.content.array'),
'priority.integer' => __('validation.layout_extension.priority.integer'),
'priority.min' => __('validation.layout_extension.priority.min'),
'priority.max' => __('validation.layout_extension.priority.max'),
'content.priority.integer' => __('validation.layout_extension.priority.integer'),
'content.priority.min' => __('validation.layout_extension.priority.min'),
'content.priority.max' => __('validation.layout_extension.priority.max'),
'content.data_sources.array' => __('validation.layout_extension.data_sources.array'),
];
}
}
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -69,7 +70,7 @@ class SaveSettingsRequest extends FormRequest
/**
* Determine if the user is authorized to make this request.
*
* @return bool
* @return bool 권한 체크는 permission 미들웨어 체인이 담당하므로 항상 true 반환
*/
public function authorize(): bool
{
@@ -291,7 +292,6 @@ class SaveSettingsRequest extends FormRequest
// 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨
'identity.default_provider' => ['nullable', 'string', 'max:100'],
'identity.purpose_providers' => ['sometimes', 'array'],
'identity.purpose_providers.*' => ['nullable', 'string', 'max:100'],
'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'),
'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'),
];
@@ -422,6 +422,73 @@ class SaveSettingsRequest extends FormRequest
]);
}
/**
* 추가 검증을 위해 validator after 콜백을 등록합니다.
*
* 목적별 프로바이더(purpose_providers) 매핑은 purpose id 에 점(.)이 포함될 수
* 있어 단일 깊이 와일드카드 룰로는 leaf 값을 검증할 수 없습니다.
* 임의 깊이의 leaf 값을 재귀 순회하며 string|max:100 으로 검증합니다.
*
* @param Validator $validator 검증기 인스턴스
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$purposeProviders = $this->input('identity.purpose_providers');
if (is_array($purposeProviders)) {
$this->validatePurposeProviderLeaves(
$purposeProviders,
'identity.purpose_providers',
$validator
);
}
});
}
/**
* 목적별 프로바이더 매핑을 재귀 순회하며 leaf 값을 검증합니다.
*
* purpose id 에 점(.)이 포함될 수 있습니다 (예: KG이니시스 플러그인의
* `inicis.adult_verification`). 프론트엔드 폼 자동 바인딩이 dot-notation name
* (`identity.purpose_providers.inicis.adult_verification`) 을 중첩 객체로 풀어
* `purpose_providers.inicis = { adult_verification: '...' }` 형태로 전송하므로,
* 깊이에 무관하게 각 leaf 값을 검증하고 위반 시 전체 dot-path 키로 에러를 부착합니다.
* 백엔드 조회(IdentityVerificationManager::resolveForPurpose) 가 config dot-path
* 라 이 중첩 구조 자체는 정상입니다.
*
* @param array<string, mixed> $node 검증할 노드 (purpose_providers 또는 중첩 하위)
* @param string $path 현재 노드의 dot-path (에러 키 prefix)
* @param Validator $validator 검증기 인스턴스
*/
private function validatePurposeProviderLeaves(array $node, string $path, $validator): void
{
foreach ($node as $key => $value) {
$childPath = $path.'.'.$key;
if (is_array($value)) {
$this->validatePurposeProviderLeaves($value, $childPath, $validator);
continue;
}
// null / '' (기본 프로바이더 사용) 은 허용
if ($value === null || $value === '') {
continue;
}
if (! is_string($value)) {
$validator->errors()->add($childPath, __('validation.settings.identity_purpose_provider_string'));
continue;
}
if (mb_strlen($value) > 100) {
$validator->errors()->add($childPath, __('validation.settings.identity_purpose_provider_max'));
}
}
}
/**
* 지원되는 검색엔진 드라이버 목록을 반환합니다.
*
@@ -648,8 +715,8 @@ class SaveSettingsRequest extends FormRequest
'identity.default_provider.string' => __('validation.settings.identity_default_provider_string'),
'identity.default_provider.max' => __('validation.settings.identity_default_provider_max'),
'identity.purpose_providers.array' => __('validation.settings.identity_purpose_providers_array'),
'identity.purpose_providers.*.string' => __('validation.settings.identity_purpose_provider_string'),
'identity.purpose_providers.*.max' => __('validation.settings.identity_purpose_provider_max'),
// 목적별 프로바이더 leaf 값(점 포함 purpose id 대응)은 withValidator() 에서
// validation.settings.identity_purpose_provider_string / _max 를 직접 부착합니다.
'identity.challenge_ttl_minutes.required' => __('validation.settings.identity_challenge_ttl_required'),
'identity.challenge_ttl_minutes.integer' => __('validation.settings.identity_challenge_ttl_integer'),
'identity.challenge_ttl_minutes.min' => __('validation.settings.identity_challenge_ttl_min'),
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\TemplateCustomTranslation;
use App\Extension\HookManager;
use App\Models\TemplateCustomTranslation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 커스텀 다국어 키 일괄 삭제 요청 검증.
*
* 레이아웃 편집기 다국어 관리 모달의 "선택 삭제"/"미사용 전체 삭제" 에서
* 호출됩니다. 권한은 라우트 permission 미들웨어(core.templates.layouts.edit)
* 에서 처리합니다.
*/
class BulkDestroyCustomTranslationRequest extends FormRequest
{
/**
* 권한은 미들웨어 체인에서 처리합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer', Rule::exists(TemplateCustomTranslation::class, 'id')],
];
return HookManager::applyFilters('core.custom_translation.bulk_destroy_validation_rules', $rules, $this);
}
/**
* 검증 실패 메시지
*
* @return array<string, string> 메시지 맵
*/
public function messages(): array
{
return [
'ids.required' => __('validation.custom_translation.ids.required'),
'ids.array' => __('validation.custom_translation.ids.array'),
'ids.min' => __('validation.custom_translation.ids.min'),
'ids.*.integer' => __('validation.custom_translation.ids.integer'),
'ids.*.exists' => __('validation.custom_translation.ids.exists'),
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\TemplateCustomTranslation;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 커스텀 다국어 키 목록 조회 요청 검증.
*
* 권한은 라우트 permission 미들웨어(core.templates.layouts.edit)에서 처리합니다.
*/
class IndexCustomTranslationRequest extends FormRequest
{
/**
* 권한은 미들웨어 체인에서 처리합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'layout_name' => ['sometimes', 'nullable', 'string', 'max:150'],
'status' => ['sometimes', 'nullable', 'in:active,orphaned'],
];
return HookManager::applyFilters('core.custom_translation.index_validation_rules', $rules, $this);
}
/**
* 검증 실패 메시지
*
* @return array<string, string> 메시지 맵
*/
public function messages(): array
{
return [
'layout_name.string' => __('validation.custom_translation.layout_name.string'),
'layout_name.max' => __('validation.custom_translation.layout_name.max', ['max' => 150]),
'status.in' => __('validation.custom_translation.status.in'),
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\TemplateCustomTranslation;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 커스텀 다국어 키 생성 요청 검증.
*
* 인라인 편집 확정 시 평문을 동적 다국어 키로 전환할 때 호출됩니다.
* 키(`custom.{layout}.{seq}`)는 Service 가 자동 생성하므로 클라이언트는
* 출처 레이아웃·편집 로케일·입력값만 전달합니다.
*
* 권한은 라우트 permission 미들웨어(core.templates.layouts.edit)에서 처리합니다.
*/
class StoreCustomTranslationRequest extends FormRequest
{
/**
* 권한은 미들웨어 체인에서 처리합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'layout_name' => ['required', 'string', 'max:150'],
'locale' => ['required', 'string', 'max:35'],
'value' => ['required', 'string'],
];
return HookManager::applyFilters('core.custom_translation.store_validation_rules', $rules, $this);
}
/**
* 검증 실패 메시지
*
* @return array<string, string> 메시지 맵
*/
public function messages(): array
{
return [
'layout_name.required' => __('validation.custom_translation.layout_name.required'),
'layout_name.string' => __('validation.custom_translation.layout_name.string'),
'layout_name.max' => __('validation.custom_translation.layout_name.max', ['max' => 150]),
'locale.required' => __('validation.custom_translation.locale.required'),
'locale.string' => __('validation.custom_translation.locale.string'),
'value.required' => __('validation.custom_translation.value.required'),
'value.string' => __('validation.custom_translation.value.string'),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\TemplateCustomTranslation;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 커스텀 다국어 키 수정 요청 검증.
*
* 속성 편집 모달 번역 탭에서 로케일별 값을 일괄 편집할 때 호출됩니다.
* 낙관적 잠금을 위해 `expected_lock_version` 이 필수이며,
* 현재 DB 버전과 불일치 시 Service 가 409 Conflict 를 던집니다.
*
* 권한은 라우트 permission 미들웨어(core.templates.layouts.edit)에서 처리합니다.
*/
class UpdateCustomTranslationRequest extends FormRequest
{
/**
* 권한은 미들웨어 체인에서 처리합니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'values' => ['required', 'array'],
'values.*' => ['nullable', 'string'],
'expected_lock_version' => ['required', 'integer', 'min:0'],
];
return HookManager::applyFilters('core.custom_translation.update_validation_rules', $rules, $this);
}
/**
* 검증 실패 메시지
*
* @return array<string, string> 메시지 맵
*/
public function messages(): array
{
return [
'values.required' => __('validation.custom_translation.values.required'),
'values.array' => __('validation.custom_translation.values.array'),
'expected_lock_version.required' => __('validation.custom_translation.expected_lock_version.required'),
'expected_lock_version.integer' => __('validation.custom_translation.expected_lock_version.integer'),
'expected_lock_version.min' => __('validation.custom_translation.expected_lock_version.min'),
];
}
}
+29 -1
View File
@@ -34,7 +34,7 @@ abstract class BaseApiCollection extends ResourceCollection
/**
* 컬렉션 레벨 abilities를 해석합니다.
*
* @param Request $request HTTP 요청 객체
* @param Request $request HTTP 요청 객체
* @return array<string, bool>
*/
public function resolveCollectionAbilities(Request $request): array
@@ -53,4 +53,32 @@ abstract class BaseApiCollection extends ResourceCollection
return $abilities;
}
/**
* paginator 인 경우 표준 pagination 메타를 반환합니다.
*
* 무한스크롤 화면이 전체 개수(total)와 다음 페이지 존재 여부(has_more_pages)를
* 정확히 판정할 수 있도록 노출합니다. 전체 조회(get) 등 paginator 가 아닌
* 경우에는 빈 배열을 반환하므로 toArray 에서 array_merge 로 안전하게 합칠 수 있습니다.
*
* @return array<string, mixed> ['pagination' => [...]] 또는 빈 배열
*/
protected function paginationMeta(): array
{
if (! method_exists($this->resource, 'currentPage')) {
return [];
}
return [
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
];
}
}
@@ -34,6 +34,7 @@ class ChallengeResource extends BaseApiResource
'redirect_url' => $c->redirectUrl,
'expires_at' => $c->expiresAt->toIso8601String(),
'public_payload' => $c->publicPayload,
'max_attempts' => $c->maxAttempts,
...$this->resourceMeta($request),
];
}
@@ -44,6 +44,7 @@ class ProviderResource extends BaseApiResource
'id' => $p->getId(),
'label' => $p->getLabel(),
'channels' => $p->getChannels(),
'channel_labels' => $p->getChannelLabels(),
'render_hint' => $p->getRenderHint(),
'is_available' => $p->isAvailable(),
...$this->resourceMeta($request),
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Resources;
use App\Extension\Traits\ComputesLayoutContentHash;
use Illuminate\Http\Request;
/**
* 레이아웃 확장 리소스
*
* 관리자 레이아웃 편집 화면에서 사용하는 레이아웃 확장 직렬화 리소스입니다.
*/
class LayoutExtensionResource extends BaseApiResource
{
use ComputesLayoutContentHash;
/**
* 리소스를 배열로 변환
*
* @param Request $request 요청 객체
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$content = $this->getValue('content', []);
$contentJson = is_array($content) ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '{}';
$extensionType = $this->getValue('extension_type');
$sourceType = $this->getValue('source_type');
return [
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'extension_type' => $extensionType?->value ?? $extensionType,
'target_name' => $this->getValue('target_name'),
'source_type' => $sourceType?->value ?? $sourceType,
'source_identifier' => $this->getValue('source_identifier'),
'source_label' => $this->getValue('source_label', $this->getValue('source_identifier')),
'override_target' => $this->getValue('override_target'),
// 템플릿 오버라이드 여부 — index 에서 부착되며, 미부착 시 source_type 으로 폴백
'is_override' => (bool) $this->getValue(
'is_override',
($sourceType?->value ?? $sourceType) === 'template'
),
'priority' => $this->getValue('priority'),
'is_active' => (bool) $this->getValue('is_active'),
// 호스트 레이아웃 목록 — 이 확장이 주입되는 레이아웃명들.
// overlay = [target_layout], extension_point = 그 확장점을 포함하는 레이아웃 전체.
// index 에서 부착되며, 라우트 트리가 layoutName 매칭으로 화면별 연결 목록을 구성한다.
'host_layouts' => $this->getValue('host_layouts', []),
// 레이아웃 편집 페이지용 필드
'content' => $contentJson,
'size' => strlen($contentJson),
'size_formatted' => $this->formatFileSize(strlen($contentJson)),
'is_modified' => $this->resolveIsModified($content),
// 낙관적 잠금 — 다음 저장 요청에 expected_lock_version 으로 전달
'lock_version' => (int) $this->getValue('lock_version', 0),
// 현재(최신) 저장 버전 번호 — index(목록 부착)/update(저장
// 경로 transient)에서만 채워짐. 이력 없는 확장/그 외 응답은 null (배지 미표시).
'current_version' => $this->getValue('current_version') !== null
? (int) $this->getValue('current_version')
: null,
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
/**
* 리소스별 권한 매핑을 반환합니다.
*
* @return array<string, string>
*/
protected function abilityMap(): array
{
return [
'can_update' => 'core.templates.layouts.edit',
];
}
/**
* 관리자 수정 여부를 판단합니다.
*
* 현재 content 해시가 original_content_hash 와 다르면 수정된 것으로 간주합니다.
*
* @param array $content 현재 확장 content
* @return bool 수정 여부
*/
private function resolveIsModified(array $content): bool
{
$originalHash = $this->getValue('original_content_hash');
if (! $originalHash || empty($content)) {
return false;
}
return $this->computeContentHash($content) !== $originalHash;
}
/**
* 파일 크기를 사람이 읽기 쉬운 형태로 변환
*
* @param int $bytes 바이트 크기
* @return string 포맷된 크기 (예: "12.5 KB")
*/
private function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes.' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1).' KB';
}
return round($bytes / 1048576, 1).' MB';
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
/**
* 레이아웃 확장 버전 리소스
*
* 레이아웃 확장의 버전 이력을 직렬화합니다.
*/
class LayoutExtensionVersionResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환
*
* @param Request $request 요청 객체
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$content = $this->getValue('content', []);
$contentJson = is_array($content) ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '{}';
return [
'id' => $this->getValue('id'),
'extension_id' => $this->getValue('extension_id'),
'version' => $this->getValue('version'),
'content' => $contentJson,
'changes_summary' => $this->formatChangesSummary($this->getValue('changes_summary', [])),
// 저장자 이름만 노출 (버전 히스토리 표시용 — 레이아웃 본체와 패리티
// 버전 기록). created_by ID 는 보안상 제외 유지. 관계 미로딩/탈퇴 사용자 시 null.
'created_by_name' => $this->resolveCreatorName(),
...$this->formatTimestamps(),
// created_by는 보안상 제외
...$this->resourceMeta($request),
];
}
/**
* 저장자 이름을 해석합니다.
*
* creator 관계(eager load)에서 이름만 추출합니다. 관계 미로딩 또는 탈퇴 사용자
* (created_by null)인 경우 null 을 반환합니다. created_by ID 자체는 노출하지 않습니다.
*
* @return string|null 저장자 이름 또는 null
*/
private function resolveCreatorName(): ?string
{
$creator = $this->whenLoaded('creator');
// whenLoaded 미로딩 시 MissingValue 반환 → instanceof 검사로 null 처리 (누출 방지).
return $creator instanceof User ? $creator->name : null;
}
/**
* changes_summary를 버전 목록 표시용 카운트로 노출합니다.
*
* changes_summary 는 추가/삭제 라인 수(added/removed 정수)와 문자 수 변화만 저장한다
* (라인 원문 미저장 — 적재 비대화 제거). 프론트는 added_count/removed_count
* 로 받으므로 키를 매핑한다. 구버전(라인 원문 배열을 담던 시절) 레코드는 정수가 아니라
* 배열일 수 있어 is_array 시 count() 로 보정한다. modified 는 라인 diff 에 대응 개념이
* 없어 노출하지 않는다.
*
* @param array $changesSummary 원본 changes_summary
* @return array{added_count: int, removed_count: int, char_diff: int}
*/
private function formatChangesSummary(array $changesSummary): array
{
return [
'added_count' => $this->countValue($changesSummary['added'] ?? 0),
'removed_count' => $this->countValue($changesSummary['removed'] ?? 0),
'char_diff' => $changesSummary['char_diff'] ?? 0,
];
}
/**
* changes_summary 값을 정수 카운트로 정규화합니다.
*
* 신규 구조는 정수(라인 수), 구버전 레코드는 배열(라인/경로 원문)일 수 있어 양쪽을
* 카운트로 환산한다.
*
* @param mixed $value added/removed 원본 값 (정수 또는 배열)
* @return int 라인 수
*/
private function countValue(mixed $value): int
{
return is_array($value) ? count($value) : (int) $value;
}
}
+45 -6
View File
@@ -6,20 +6,50 @@ use Illuminate\Http\Request;
class LayoutResource extends BaseApiResource
{
/**
* 레이아웃 이름 → 라우트 path 매핑 (코드 편집기 URL 동기화용).
*
* 컨트롤러가 템플릿 routes.json 으로부터 빌드해 컬렉션 전체에 주입한다.
* 라우트가 없는 레이아웃(base/partial 등)은 매핑에 없어 null 로 직렬화된다.
*
* @var array<string, string>
*/
protected array $routePathMap = [];
/**
* 레이아웃 이름 → 라우트 path 매핑을 주입합니다.
*
* @param array<string, string> $map 레이아웃 이름 → 라우트 path
* @return $this
*/
public function withRoutePathMap(array $map): static
{
$this->routePathMap = $map;
return $this;
}
/**
* 리소스를 배열로 변환
*
* @param Request $request 현재 요청 객체
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(Request $request): array
{
$content = $this->getValue('content', []);
$contentJson = is_array($content) ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '{}';
$name = $this->getValue('name');
return [
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'name' => $this->getValue('name'),
'description' => $content['meta']['description'] ?? $this->getValue('name'),
'name' => $name,
'description' => $content['meta']['description'] ?? $name,
'endpoint' => $content['endpoint'] ?? null,
// 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 코드 편집기가
// 파일 선택 시 ?route= 동기화 / 위지윅에서 넘어온 ?route= 복원에 사용.
'route_path' => $this->routePathMap[$name] ?? null,
'components' => $content['components'] ?? [],
'data_sources' => $content['data_sources'] ?? [],
'metadata' => $content['metadata'] ?? [],
@@ -30,6 +60,15 @@ class LayoutResource extends BaseApiResource
'size_formatted' => $this->formatFileSize(strlen($contentJson)),
'has_update' => false, // TODO: 템플릿 파일과 DB 버전 비교 로직 추가
// 낙관적 잠금 — 다음 저장 요청에 expected_lock_version 으로 전달
'lock_version' => (int) $this->getValue('lock_version', 0),
// 현재(최신) 저장 버전 번호 — updateLayout 저장 경로에서만
// transient 로 부착됨(라우트 트리 버전 배지 동기화). 그 외 응답은 null.
'current_version' => $this->getValue('current_version') !== null
? (int) $this->getValue('current_version')
: null,
...$this->formatTimestamps(),
...$this->resourceMeta($request),
// created_by, updated_by는 보안상 제외
@@ -51,17 +90,17 @@ class LayoutResource extends BaseApiResource
/**
* 파일 크기를 사람이 읽기 쉬운 형태로 변환
*
* @param int $bytes 바이트 크기
* @param int $bytes 바이트 크기
* @return string 포맷된 크기 (예: "12.5 KB")
*/
private function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
return $bytes.' B';
} elseif ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
return round($bytes / 1024, 1).' KB';
} else {
return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1048576, 1).' MB';
}
}
}
+78 -11
View File
@@ -6,14 +6,39 @@ use Illuminate\Http\Request;
class LayoutVersionResource extends BaseApiResource
{
/**
* content 원본 전체를 포함할지 여부 (단건 조회 전용).
*
* 목록(versions.index)은 버전이 많아(수백 건) content 전체를 다 실으면 페이로드가
* 비대해지므로 분해된 일부 키만 노출한다. 단건 조회(showVersion)는 버전 비교 diff 가
* content 전체(slots/extends 등 분해되지 않는 키 포함)를 필요로 하므로 full_content 를
* 추가 노출한다. withFullContent() 로 opt-in.
*/
private bool $includeFullContent = false;
/**
* content 원본 전체(full_content)를 응답에 포함하도록 표시합니다 (단건 조회용).
*
* @return $this
*/
public function withFullContent(): static
{
$this->includeFullContent = true;
return $this;
}
/**
* 리소스를 배열로 변환
*
* @param Request $request 현재 요청
* @return array 버전 표시 필드 배열 (full_content 는 단건 조회 시에만 포함)
*/
public function toArray(Request $request): array
{
$content = $this->getValue('content', []);
return [
$payload = [
'id' => $this->getValue('id'),
'layout_id' => $this->getValue('layout_id'),
'version' => $this->getValue('version'),
@@ -22,29 +47,71 @@ class LayoutVersionResource extends BaseApiResource
'data_sources' => $content['data_sources'] ?? [],
'metadata' => $content['metadata'] ?? [],
'changes_summary' => $this->formatChangesSummary($this->getValue('changes_summary', [])),
// 저장자 이름만 노출 (버전 히스토리 표시용) — created_by ID 는 보안상 제외 유지.
// creator 관계 미로딩(eager load 누락)/탈퇴 사용자 시 null.
'created_by_name' => $this->resolveCreatorName(),
...$this->formatTimestamps(),
// created_by는 보안상 제외
...$this->resourceMeta($request),
];
// 단건 조회 시에만 content 원본 전체를 노출 — 버전 비교 diff 가 slots/extends 등
// 분해되지 않는 키까지 비교하려면 원본 전체가 필요하다. 목록에는 제외(비대화 회피).
if ($this->includeFullContent) {
$payload['full_content'] = is_array($content) ? $content : [];
}
return $payload;
}
/**
* changes_summary를 포맷팅하여 count 필드 추가
* 저장자 이름을 해석합니다.
*
* @param array $changesSummary 원본 changes_summary
* @return array 포맷팅된 changes_summary
* creator 관계(eager load)에서 이름만 추출합니다. 관계 미로딩 또는 탈퇴 사용자
* (created_by null)인 경우 null 을 반환합니다. created_by ID 자체는 노출하지 않습니다.
*
* @return string|null 저장자 이름 또는 null
*/
private function resolveCreatorName(): ?string
{
$creator = $this->whenLoaded('creator');
// whenLoaded 미로딩 시 MissingValue 반환 → 삼항으로 null 처리 (MissingValue 누출 방지).
return $creator instanceof \App\Models\User ? $creator->name : null;
}
/**
* changes_summary를 버전 목록 표시용 카운트로 노출합니다.
*
* changes_summary 는 추가/삭제 라인 수(added/removed 정수)와 문자 수 변화만 저장한다
* (라인 원문 미저장 — 적재 비대화 제거). 프론트는 added_count/removed_count
* 로 받으므로 키를 매핑한다. 구버전(라인 원문 배열을 담던 시절) 레코드는 정수가 아니라
* 배열일 수 있어 is_array 시 count() 로 보정한다. modified 는 라인 diff 에 대응 개념이
* 없어 노출하지 않는다.
*
* @param array $changesSummary 원본 changes_summary
* @return array{added_count: int, removed_count: int, char_diff: int}
*/
private function formatChangesSummary(array $changesSummary): array
{
return [
'added' => $changesSummary['added'] ?? [],
'removed' => $changesSummary['removed'] ?? [],
'modified' => $changesSummary['modified'] ?? [],
'added_count' => count($changesSummary['added'] ?? []),
'removed_count' => count($changesSummary['removed'] ?? []),
'modified_count' => count($changesSummary['modified'] ?? []),
'added_count' => $this->countValue($changesSummary['added'] ?? 0),
'removed_count' => $this->countValue($changesSummary['removed'] ?? 0),
'char_diff' => $changesSummary['char_diff'] ?? 0,
];
}
/**
* changes_summary 값을 정수 카운트로 정규화합니다.
*
* 신규 구조는 정수(라인 수), 구버전 레코드는 배열(라인/경로 원문)일 수 있어 양쪽을
* 카운트로 환산한다.
*
* @param mixed $value added/removed 원본 값 (정수 또는 배열)
* @return int 라인 수
*/
private function countValue(mixed $value): int
{
return is_array($value) ? count($value) : (int) $value;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Resources;
use App\Models\TemplateCustomTranslation;
use Illuminate\Http\Request;
/**
* 템플릿 커스텀 다국어 키 리소스.
*
* @property TemplateCustomTranslation $resource
*/
class TemplateCustomTranslationResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 변환된 배열 데이터
*/
public function toArray(Request $request): array
{
return [
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'layout_name' => $this->getValue('layout_name'),
'translation_key' => $this->getValue('translation_key'),
'values' => $this->getValue('values', []),
'status' => $this->getValue('status'),
'lock_version' => (int) $this->getValue('lock_version', 0),
'created_at' => $this->getValue('created_at'),
'updated_at' => $this->getValue('updated_at'),
];
}
}

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