58 Commits
Author SHA1 Message Date
HeuJung cb2b481caf Merge branch 'develop' 2026-07-08 13:55:16 +09:00
HeuJung 487283b33a fix(core): core:update 증분 모드가 _bundled 확장 파일을 반영하도록 protected 오버라이드
targets 에 명시된 {modules,plugins,templates,lang-packs}/_bundled 가
상위 protected(modules 등)에 걸려 3-way apply 목록에서 제외되던 결함 수정.
targets 가 protected 보다 더 구체적이면 오버라이드해 통과시켜, 코어 업데이트로
번들 확장 갱신 파일(composer.json·vendor-bundle.json 등)이 정상 반영되도록 한다.
자동 발견 폴백의 방어 및 prune 롤백 경로는 불변.

아울러 이슈 템플릿의 사용법 문의 링크를 Discussions 에서 sir.kr Q&A 로 전환.
2026-07-08 13:54:18 +09:00
HeuJung 212ecb3577 Merge branch 'develop' 2026-07-08 12:45:19 +09:00
HeuJung 6acc914100 fix(ecommerce): 버전 bump 시 누락된 vendor-bundle 재생성 (무결성 검증 복구)
에서 이커머스 모듈 버전을 1.0.0 → 1.0.1 로 올리며 composer.json version
필드를 변경했으나 vendor-bundle 을 재생성하지 않아, 번들 기록 해시와 실제
composer.json SHA 가 불일치. VendorIntegrityChecker 가 module:update 를 차단.
module:vendor-bundle 로 재생성해 해시를 재동기화하고 무결성 검증을 복구.
2026-07-08 12:43:22 +09:00
HeuJung e4f5319dad Merge branch 'develop' 2026-07-08 12:26:44 +09:00
HeuJung 683ebdc396 chore(release): 7.0.2 공개 release 누출 검사 대상 주석·시나리오 내부 단서 제거
코어/번들 확장의 주석·테스트·시나리오 yaml 에 남은 내부 역할 호칭("")과
 파일명 참조를 제거해 공개 release 누출 검사(error 0/warn 0)를 통과.
기술적 의미는 그대로 유지하고 비공개 단서만 정리.
2026-07-08 12:26:00 +09:00
HeuJung 3ab879ea9a Merge branch 'develop' 2026-07-08 12:20:22 +09:00
HeuJung e7bb07f80e docs(changelog): 7.0.2 릴리스 대상 코어·확장·언어팩 CHANGELOG 날짜를 출시일(2026-07-08)로 통일
7.0.2 번들과 함께 버전이 상향되는 코어(7.0.2), 모듈(board/ecommerce/page 1.0.1),
템플릿(admin_basic/basic 1.0.1) 및 대응 번들 언어팩의 릴리스 날짜를 오늘로 정렬.
이니시스 결제(pay_kginicis 1.0.0)는 기존 출시일(07-01) 유지.
2026-07-08 11:53:40 +09:00
정정홍andGitHub 93f6bd5b71 Merge pull request from gnuboard:HeuJung/issue452
feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
2026-07-08 10:21:41 +09:00
HeuJung d0943d6cb6 feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
core:update Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여
사용자 .htaccess 커스텀 블록·_bundled 커스텀 확장이 소실되던 사고(공개 )를
차단한다. 백업(base)↔_pending(theirs) 3-way md5 비교로 코어가 실제 변경/추가한
파일만 산출해 적용하고, 코어 미변경 파일은 복사·권한·mtime 을 전부 스킵해 현재
디스크 상태(사용자 수정 포함)를 보존한다. 전체 덮어쓰기 + orphan 정리가 필요하면
--prune 으로 opt-in. 백업 부재(--no-backup) 시 base 가 없어 전체 덮어쓰기로 안전
회귀 + 안내를 출력한다.

기존 stale 정리 테스트는 새 기본(증분)에서 orphan 을 삭제하지 않으므로 prune
컨텍스트로 전환했고, 증분 3-way 판정·목록 적용·fallback 단위 테스트와 --prune
커맨드 표면 테스트를 추가했다.

번들 ja 언어팩은 코어 apply_mode 안내 키(g7-core-ja)와 함께, 같은 빌드에서
동기화된 관리자↔유저 크로스링크 라벨(board/ecommerce/admin_basic/basic)의 일본어
번역을 반영하고 각 패키지 버전·CHANGELOG 를 갱신했다.
2026-07-07 22:45:16 +09:00
정정홍andGitHub efc8a7ae6b Merge pull request from gnuboard:HeuJung/issue450
feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
2026-07-07 10:42:23 +09:00
HeuJung 6505d85959 feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
관리자·유저 화면 왕복 이동 수단을 추가하고, 상품/게시판 URL 식별자를
사람이 읽을 수 있는 값(상품코드/슬러그)으로 전환한다.

크로스 이동 링크 (전부 새 탭):
- 관리자 사이드바 "사이트 보기" + 모바일 드로어 상단 잘림 수정
- 관리자 게시물 조회 ↔ 유저 게시판, 유저 상품 상세 ↔ 관리자 상품 수정
- 유저 화면의 관리자 진입은 is_admin 이 아니라 세부 권한 플래그로 게이트
 (게시판 can_access_admin, 상품 can_update)

상품 라우팅 id → product_code:
- Product::resolveRouteBinding(code/id) + 공개 라우트 정규식 확장
- 모든 상품 링크(목록·최근본·검색·장바구니·주문내역·문의내역·관리자) 전환
- 상품 검색을 상품코드·번호 양쪽 조회 가능하게 확장(관리자+통합검색)

게시판 라우팅 board id → slug:
- Board::resolveRouteBinding(slug/id) + 관리자 라우트 slug 전환
- 생성 후 리다이렉트·토글·삭제·수정·add-to-menu·편집기 스코프 전수 정정
- BoardController::addToAdminMenu 를 Board 바인딩으로 정합

검증: 프론트 레이아웃 Vitest 36건 green, 백엔드 회귀 테스트 추가,
g7.dev 라이브 실측(생성/삭제/메뉴추가 slug, 문의내역·검색 상품코드 URL).
2026-07-07 10:40:22 +09:00
정정홍andGitHub 32b1b11930 Merge pull request from gnuboard:HeuJung/issue446
fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
2026-07-06 17:18:04 +09:00
HeuJung cbc91117d7 fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
설치 시 use_read_db=false 인데도 db_read_host 잔존값으로 read 커넥션을
조립해 5단계 마이그레이션이 실패하던 문제를 수정한다. "read 사용 여부"
판정이 세 지점에서 달랐던 것이 근본 원인으로, use_read_db 플래그를 단일
SSoT 로 통일한다.

- installer-runtime.php: read 블록을 use_read_db 게이트로. 미사용 시 runtime
 에 read 키 미생성 → 하류가 write 로 자동 fallback. mergeRuntimeIntoEnv 도
 read 부재 시 DB_READ_* 를 빈 값으로 기록(write 복사 제거).
- config/database.php: read env 를 Elvis(?:) fallback 으로 복원. 중첩
 env(A, env(B)) 는 DB_READ_* 가 빈 문자열일 때 fallback 이 무력화되므로,
 빈 값까지 흡수하는 ?: 를 사용(tinker 실측 확인). 설치 이후 운영 중
 DB_READ_* 공란 시에도 SELECT 가 write DB 로 정확히 향하게 한다.
- functions.php / request-handler.php: read 미사용 시 .env DB_READ_* 빈 값
 기록 + state.json 잔존 read 필드 제거(2단계 방어).

기존 설치 환경은 .env 에 DB_READ_* 가 이미 채워져 Elvis fallback 미발동 →
동작 불변이라 업그레이드 스텝 불필요(실측 확인).

무관 사전 실패 테스트(beta.7 stale-ENV 복구)는 코어 version 이 beta.7 이
아니면 skip 하도록 가드 헬퍼 추가(프로덕션 한시 가드 전제를 테스트에 반영).

자유형식 제보에서 진단 축(버전/활성 확장/커스텀 자산/환경)이 누락되기 쉬워
G7 전용 이슈 템플릿을 추가한다. bug_report / feature_request / upgrade_issue
+ config.yml(blank 비활성, Discussions·보안신고 링크).
2026-07-06 17:15:29 +09:00
정정홍andGitHub 94d2cd2c11 Merge pull request from gnuboard:HeuJung/issue445
HeuJung/issue445
2026-07-06 17:10:22 +09:00
HeuJung 157485003d fix(installer): 요구사항 검증의 디렉토리 권한 재귀 스캔 깊이 제한으로 2단계 지연 해소
인스톨러 2단계(설치 환경 확인)의 checkDirectoryPermissions 가 storage 를
재귀 스캔하는데, checkSubdirectoriesRecursive 가 깊이 제한 없이 무한 재귀하여
storage/framework/testing 하위에 누적된 수만 개 디렉토리를 전부 순회 → 응답이
8초 이상 지연되던 문제.

쓰기 권한 문제는 트리 상위 얕은 깊이에서 결정되므로(상위 쓰기 가능 시 하위 상속),
재귀에 maxDepth(기본 1) 제한을 추가. 기본값이 있어 호출부는 변경 불필요.
실측: checkDirectoryPermissions 8.4초 → 0.086초.
2026-07-06 17:09:42 +09:00
HeuJung 342486a20b fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완
Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의
invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의
json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로
진행 상황이 표시되지 않던 문제.

- 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본)
- composer 스트림 라인 정규화 (SSE/폴링 공통 경계)
- 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에
 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망)

무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로
깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트
절대경로 앵커로 격리.

공개 이슈 gnuboard/g7 (@kitrio 제보).
2026-07-06 17:09:41 +09:00
eunhyeandGitHub 5ee1d1f2b5 Merge pull request from gnuboard/chym1217/issue424
fix(page): 페이지 검수 이슈 일괄 수정
2026-07-06 09:54:05 +09:00
chym1217 9d7bccdfb9 chore(page): 코드·테스트·라우트 주석 정리 및 CHANGELOG 날짜 정정
코드/테스트 주석에 남아 있던 내부 이슈번호(-14, -15 등)를
규정에 따라 제거하고, 첨부 라우트 주석의 오해 소지 표현을 정정한다.
1.0.1 릴리스 날짜를 2026-07-06 으로 맞춘다.

- Listener/커맨드 주석, Feature/Unit/레이아웃/E2E 테스트 주석
- 시나리오 매니페스트 설명 (audit:allow 지시어는 보존)
- 첨부 라우트: admin 그룹의 오해성 "단일화" 주석 제거,
 공개 pages 그룹 각 라우트에 게시판과 동일한 방식·사유를 간결히 명시
 (미리보기=공개 이미지, 다운로드=발행 상태 조건부 접근 제어)
- CHANGELOG [1.0.1] 날짜를 2026-07-06 으로 정정
 (page 모듈 / basic·admin_basic 템플릿 / 대응 ja 언어팩 3종)
- 로직·동작 변경 없음
2026-07-06 09:37:07 +09:00
chym1217 155b95989a fix(page): 페이지 관리 화면 UI 정돈 및 모바일 검색·취소 이동 결함 수정
목록·상세·등록/수정 화면의 버튼·선택 상자·카드 여백을 시맨틱 클래스로 통일하고,
검수 중 발견한 동작 결함을 함께 수정한다.

- 모바일 검색이 저장소 불일치(_global 저장 ↔ _local 읽기)로 미작동하던 문제 수정
- 폼 취소 이동을 수정=상세 / 등록=목록으로 명시 (기존 navigateBack 안티패턴 제거)
- 검증 에러 박스 제목 색/굵기·하단 여백, 저장 버튼 시맨틱화(spinner 포함)
- 상세: 첨부 기본 열림 + 항목별 구분선, 다크모드 카드 대비 개선,
 메타 정보 2열 배치, 카드 헤더 통일(이중 여백·구분선 제거), 실패 토스트 실제 메시지 노출
- 언어 탭이 모바일에서 줄바꿈되도록 반응형 처리

레이아웃 렌더 회귀 테스트 113건 통과 + 취소 이동 E2E spec 추가.
2026-07-06 09:37:07 +09:00
chym1217 b98a536162 fix(page): 페이지 첨부 썸네일·다운로드 미노출 수정
썸네일(<img>)·다운로드(<a>)는 브라우저 직접 GET 이라 토큰을 실을 수 없는데
커밋 d48c684 가 첨부 URL 을 auth:sanctum admin 라우트로 바꿔 401 회귀가 났다.
게시판·이커머스 표준으로 정렬한다.

- 썸네일(preview): 공개 hash 서빙으로 단일화 (발행/미발행 무관 200)
- 다운로드: 공개 hash 라우트 + 권한 게이트(발행=누구나 / 미발행=pages.read 관리자만)
- 프론트 다운로드를 downloadAttachment 핸들러(토큰 fetch→blob)로 전환
- 다운로드 아이콘 arrow-down-tray(FA 미존재) → download
- 사용자 페이지에도 이미지 첨부 썸네일 추가 (관리자 상세와 일관)
- admin 첨부 preview/download 데드 라우트·메서드 제거
- iteration 안 정적 id 를 att_idx 동적 id 로 교정 (위생 정리)
2026-07-06 09:37:06 +09:00
chym1217 98989e3c2c fix(page): 첨부 업로드 검증 실패 시 사용자 안내 토스트 노출
- 페이지 폼 FileUploader 에 onUploadError→toast 연결 (용량/확장자 초과 안내)
- 사용자 템플릿(sirsoft-basic) 개수 초과 시 onUploadError 발화 추가 (silent 무시 방지)
- 개수 초과 문구를 이미지 전용에서 일반 파일 범용 공통 메시지로 통일 (ko/en/ja)
2026-07-06 09:37:06 +09:00
chym1217 2473ca7242 fix(page): 버전 복원 후 검색엔진 캐시가 즉시 갱신되도록 after_restore 훅 구독
버전 복원 시 발화되는 after_restore 훅을 SeoPageCacheListener가
구독하지 않아, 복원 후에도 검색봇에 복원 전 버전이 계속 노출되던
문제를 수정. 복원은 콘텐츠 변경의 일종이므로 수정/삭제와 동일한
onPageChange 무효화 정책을 재사용.
2026-07-06 09:37:05 +09:00
chym1217 ba2adcf748 fix(page): 미발행 페이지를 권한 관리자가 사용자 화면에서 미리보도록 수정
미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없어
발행 전 미리보기가 불가능했다. 페이지 조회 권한(sirsoft-page.pages.read)
보유 관리자는 미발행 페이지를 사용자 화면에서 미리볼 수 있게 하고, 미리보기
화면에 안내 배너를 노출한다. 비로그인·일반 회원은 기존대로 404를 유지한다.

- getPublishedPageBySlug 에 미리보기 허용 플래그 추가 (권한 판정은 컨트롤러)
- 미발행 미리보기 시 첨부 URL 을 admin 컨텍스트로 전환해 첨부까지 미리보기
- page/show.json 에 미발행 미리보기 안내 배너 + ko/en/ja 다국어
- E2E 시드 커맨드 구현 + 커맨드 등록 + 사용자 화면 미리보기 spec
2026-07-06 09:37:05 +09:00
chym1217 8cf55ad88f fix(page): 버전 이력 변경내역을 다국어 라벨로 표시 + E2E 인프라 신설
- 페이지 상세 버전 이력의 "변경내역"이 title·content 등 영문 필드명으로
 노출되던 문제를 field_labels 다국어 매핑으로 정정 (ko/en/ja)
- sirsoft-page 모듈에 Playwright E2E 인프라를 이커머스와 동일 패턴으로 신설
 (config·fixture·seed stub + spec 3건, data-testid 보강 전까지 skip placeholder)
- 출시된 1.0.0 이후 변경이므로 module 1.0.1 bump (3곳+lock 동기화),
 ja 언어팩 1.0.1 bump
2026-07-06 09:37:04 +09:00
chym1217 4c4f90a091 fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정
- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로
 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색.
- 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치).
- 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를
 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
2026-07-06 09:37:03 +09:00
chym1217 bb654fb56c fix(page): 일괄 발행/미발행 시 활동 이력이 기록되도록 after_publish 훅 발화
페이지 목록 일괄 발행/미발행이 after_publish 훅을 발화하지 않아
활동로그에 발행/미발행이 기록되지 않던 문제를 수정한다.

- bulkChangePublishStatus 를 DB::transaction 내부에서 페이지별로
 before_publish -> update -> after_publish 발화하도록 재작성 (per-item 로그)
- 존재하지 않는 id 가 섞이면 전체 롤백 (all-or-nothing)
- 단일 mass UPDATE 전용 bulkUpdatePublished 제거 (dead code, 의존 확장 영향 없음)
- 일괄 발행 성공 응답 메시지의 :count 미치환 정정
 (success 의 4번째 인자 messageParams 로 치환 파라미터 전달)
- 회귀 테스트 추가 (훅 발화 횟수/published 인자/롤백/메시지 치환)
2026-07-06 09:37:02 +09:00
chym1217 0de58baf46 fix(page): 첨부 URL 컨텍스트·업로드 순서·등록폼 잔존·슬러그 미리보기·SEO 캐시 정정
- 관리자 첨부 다운로드/미리보기를 admin 라우트 URL로 분기 (미발행 페이지 첨부를 관리자가 열 수 있도록)
- 다중 업로드 시 올린 순서대로 저장 (이커머스 패턴: temp_key 연결 + form.attachments 동기화, 코어/공용 컴포넌트 무수정)
- 수정→등록 전환 시 이전 첨부·제목·슬러그·검증안내 잔존 차단
 (initialFiles를 _local.form.attachments에 바인딩 + init_actions에서 form null→기본값 초기화)
- 등록 폼 슬러그 미리보기 {{slug}} 미치환 수정 (빈 슬러그를 못 거르던 ?? → || fallback)
- 페이지 변경 시 상세 SEO 캐시 무효화 URL 패턴을 단수형(/page/{slug})으로 정정
2026-07-06 09:37:02 +09:00
chym1217 b03042b6d7 fix(page): 페이지·첨부 삭제를 물리 삭제로 전환해 슬러그 재사용 가능하도록 수정
페이지 삭제 후 동일 슬러그로 재생성 시 "이미 사용 중" 오류가 나던 문제 수정.
슬러그 중복 체크(SoftDeletes 스코프로 삭제 행 제외)와 생성 검증(Rule::unique로
삭제 행 포함)이 soft delete를 다르게 취급해 발생. Page/PageAttachment의
SoftDeletes를 제거하고 deleted_at 컬럼을 마이그레이션으로 정리해 두 경로를 정합화.
2026-07-06 09:37:01 +09:00
정정홍andGitHub d6baa1c299 Merge pull request from gnuboard:HeuJung/issue436
fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
2026-07-06 09:32:57 +09:00
HeuJung 4a90046d54 fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
관리자 주문옵션 구매확정 경로가 유저 셀프 확정과 부수효과가 어긋나던
문제를 바로잡고, 검수에서 드러난 적립 금액·기능 토글 결함을 함께 수정한다.

- confirmed_at 대칭화: 관리자 CONFIRMED 전이 시 옵션·주문 헤더 confirmed_at
 기록 + order-option.after_confirm 훅 발화(즉시적립·활동로그·리뷰기한 정상화)
- 나눠 확정 델타 적립: 멱등 기준을 "적립 건수"에서 "금액 델타"로 전환.
 분할·병합으로 목표 적립액이 늘어도 차액만큼 정확히 지급(기존 lot 증액,
 적립 내역 한 줄 유지). 병합 시 적립 거래를 생존 옵션으로 이전
- 마일리지 미사용 시 즉시적립도 미지급하도록 통일(지연적립 게이트와 일관)

관리자·회원·비회원 셀프확정 전 표면 및 지연적립 스케줄러 연계를
Chrome MCP로 실측 검증(전 시나리오 통과). 회귀 테스트 12건 추가.
2026-07-06 09:32:16 +09:00
정정홍andGitHub d6555c0e7f Merge pull request from gnuboard:HeuJung/issue440
HeuJung/issue440
2026-07-06 09:15:17 +09:00
HeuJung d466aae6eb Merge origin/HeuJung/issue440 into HeuJung/issue440
원격 커밋(f267f21, 버전 bump 이전 7.0.1)과 로컬 재작성본
(7.0.2 bump 반영)이 갈라져 발생한 충돌 해결.

충돌 해결 방침 — 로컬(7.0.2) 기준 채택:
- docs/extension/module-assets.md: 번들 gzip 압축 섹션 유지
- template-engine/CHANGELOG.md: 루트 [7.0.2] 참조 유지
- history/20260704_2200: 7.0.2 bump 경위 기록 유지
- CHANGELOG.md: 자동 병합이 번들 병합 항목을 [7.0.1]에 중복
 삽입한 것을 제거 (해당 항목은 [7.0.2]에만 존재)
2026-07-05 22:36:58 +09:00
HeuJung c819820573 perf(core): JSON 서빙 네트워크 오버헤드 최적화
실측(g7.dev) 결과 줄바꿈 제거·gzip 은 이미 적용된 상태였고,
남은 실질 개선 3종을 구현:

- 모든 API 응답에 JSON_UNESCAPED_UNICODE 적용 — 한글이 \uXXXX(6B)로
 팽창하던 것을 raw UTF-8(3B)로 직렬화 (lang pack gzip -9.1%)
- 공개 레이아웃 서빙에서 개발자용 comment/_comment 재귀 제거,
 편집 모드(with_source_meta)는 보존 (layout gzip -34.9%)
- 번들 JS/CSS(BinaryFileResponse) gzip 압축 사각지대 해소 —
 기존 미들웨어가 getContent==false 로 압축을 건너뛰던 문제 (-73~77%)
2026-07-05 22:31:04 +09:00
HeuJung a958443d69 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전
- 코어 7.0.1 → 7.0.2 bump. 7.0.1 이 이미 2026-07-03 공개 릴리스이므로 그 위 신규
 기능은 +1 bump(new-bump). config/app.php(SSoT) + .env{.example,.testing.example,
 .testing} + README 뱃지 + INSTALL 예시 + 루트 CHANGELOG 신규 [7.0.2] 섹션 동기화.
- 확장 g7_version 상향 안 함(>=7.0.0 유지) — 6개 프론트 에셋 확장이 신규 코어
 표면을 자기 코드에서 호출하지 않고 개별 서빙 라우트 존치+프론트 폴백으로 코어
 7.0.0 에서도 동작 → 실제 의존 없음( 결정).
- 부수 결함 수정: config/app.php 편집으로 활성화된 core-update-targets-coverage
 룰이 v1.51.0 에서 추가된 vite.config.{editor,devtools}.js 가 app.update.targets
 에 미등재됨을 검출 → 추가. 코어 업데이트 시 편집기/devtools 빌드 설정 갱신 누락
 방지(CHANGELOG [7.0.2] Fixed).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.2] Added/Fixed / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-05 21:57:39 +09:00
HeuJung f267f21ea6 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전 동기화 판정 ( 결정)
코어 add-only(개별 서빙 라우트 존치 + 프론트 폴백). 6개 프론트 에셋 확장이 신규
코어 표면을 자기 코드에서 호출하지 않아 코어 7.0.0 에서도 동작 → 실제 의존 없음.
확장 g7_version 상향 안 함(>=7.0.0 유지). 코어 버전 무변경(7.0.1 accumulate).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.1] Added / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-04 22:19:55 +09:00
HeuJung 7c18adc09d Merge branch 'develop' 2026-07-03 19:16:37 +09:00
HeuJung a10200164c fix(core): 코어 업데이트 스텝 재실행 안내에 실행 권한별 4분기 지침 추가
sudo(root) 로 core:update 를 실행하면 업그레이드 스텝 spawn 자식이 실패할 때
운영자에게 core:execute-upgrade-steps 재실행을 안내하는데, 이 명령을 root 로
그대로 재실행하면 스텝이 만드는 파일이 root 소유가 되어 이후 웹서버(www-data)
요청이 그 경로에 쓰기 실패한다.

재실행 안내를 실행 환경 4가지로 분기한다:
- non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저) / posix 미지원 → 명령만
- root_web_known: 웹서버 계정 식별 → sudo -u {계정} + 계정명 경고
- root_web_symmetric: root 서비스 구성 → 명령만
- root_web_unknown: 계정 추정 실패 → placeholder + 확인 방법 안내

웹서버 계정은 FilePermissionHelper::inferWebServerOwnership 로 추정한다.
출력 전담 renderResumeGuidance 를 분리해 4분기 출력 계약을 회귀 테스트로 고정.
2026-07-03 19:16:11 +09:00
HeuJung a681908d54 Merge branch 'develop' 2026-07-03 18:45:59 +09:00
HeuJung 710f8e9135 fix(extension): 확장 토글 시 config 캐시 재생성을 동기 실행 (큐 지연 제거)
ExtensionConfigCacheListener 가 config 캐시 재생성을 큐로 디스패치해,
워커 미가동 환경·CLI 커맨드(plugin:deactivate 등)에서 재생성이 지연/누락되던
결함을 수정한다. 훅 Action 리스너의 기본 등록 정책이 큐 디스패치이므로,
getSubscribedHooks 각 훅에 'sync' => true 를 추가해 동기 실행한다.

- 회귀 테스트 test_all_hooks_are_synchronous 추가
- docs/backend/service-provider.md 에 sync 요구사항 명문화
- 2브라우저(Chrome MCP + Playwright MCP) 회귀 검증 중 발견
2026-07-03 18:40:19 +09:00
HeuJung c08dae7cb0 perf(core): 서빙 API 부팅 비용 최적화 (information_schema 가드 + 훅/오토로드/config 캐시)
매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다.

- 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증
 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존
 hasTable 폴백 유지.
- 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해
 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형).
 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시
 부재·손상은 스캔 폴백.
- 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에
 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격).
 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지.
- config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치
 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만
 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read
 요청당 1회 로드(memo)로 중복 파싱 제거.

부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정
8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
2026-07-03 17:47:23 +09:00
HeuJung deacbc7b66 Merge branch 'develop' 2026-07-03 10:03:19 +09:00
HeuJung 239bdffa10 docs(release): 7.0.1 출시일 7/3 조정 + 엔진 CHANGELOG 버전 병합·정합 정리
7.0.1 릴리즈 산출물 날짜를 2026-07-03 으로 조정하고, 엔진
CHANGELOG 의 engine-v1.52.0(DevTools 번들 분리) 과 engine-v1.51.0
(레이아웃 편집기 번들 분리) 을 단일 engine-v1.51.0 섹션으로 병합.

병합에 따라 DevTools 관련 코드 @since 를 v1.51.0 으로 정정하고,
그 과정에서 발견한 CHANGELOG 부재 유령 버전(v1.52.0/v1.52.2/
v1.52.4) 을 CHANGELOG 정본(engine-v1.50.0) 에 맞춰 정정.

주석/CHANGELOG 문자열 정정만으로 런타임 로직 변경 없음.
2026-07-03 10:02:38 +09:00
HeuJung 4371f0c90e Merge branch 'develop' 2026-07-02 18:03:37 +09:00
HeuJung 52ee8c855a chore(release): 공개 소스 본문 내부 단서(· 호칭) 정리
공개 release 발행 시 파일 본문 누출 검사(check-public-release-leakage)에
걸리던 내부 단서를 정리해 발행을 차단하지 않도록 함.

- 코드/테스트 주석·문서 본문의 "" 규정 인용을 "코어 규정/규칙" 등
 중립 표현으로 치환 (코어·이커머스·board·kginicis·gdpr·pay·템플릿 전 영역)
- 내부 역할 호칭 ""/""를 문맥에 맞는 중립 표현으로 일반화
- config/app.php 의 보호경로 목록(G7_UPDATE_PROTECTED_PATHS) 내 파일명은
 기능 코드이므로 삭제 대신 검사기 화이트리스트에 등록

주석/설명 문자열/문서 본문만 변경 — 로직·데이터·동작 무변경.
검증: 누출 재검사 error 0 / warn 0, 영향 테스트 TS 140/140·PHP 113/113 통과.
2026-07-02 17:58:33 +09:00
HeuJung 61608ca341 Merge branch 'develop' 2026-07-02 17:32:32 +09:00
HeuJung eb2b35ef36 chore(release): 7.0.1 출시일 오늘로 조정 + 공개 소스 내부 단서 정리
- 출시일: CHANGELOG [7.0.1] 및 엔진 CHANGELOG(v1.52.0/v1.51.0) 를 2026-07-02(오늘)로 조정
- 공개 release 포함 소스의 내부 이슈번호 정리:
 - 공개 이슈는 gnuboard/g7 명시 형식으로 정규화
 - 내부 이슈번호는 서술문으로 일반화
- 코드 주석의 내부 역할 호칭("")을 중립 표현으로 일반화
2026-07-02 17:19:23 +09:00
정정홍andGitHub 56d5200fcc Merge pull request from gnuboard:HeuJung/issue435
HeuJung/issue435
2026-07-02 15:14:53 +09:00
HeuJung ce8055e65c fix(core): 시스템 정보 조회가 일부 호스팅에서 전체 실패하던 문제 수정
disable_functions/open_basedir 제약 호스팅에서 CPU·메모리·디스크 등
개별 probe 실패가 system-info API 전체를 500 으로 떨구던 결함 수정.
buildSystemInfo 각 항목을 safeSystemProbe 로 격리해 실패 항목만 unknown
폴백으로 채우고 나머지는 정상 반환. 개별 실패는 Log::warning, 전체 실패는
컨트롤러 Log::error 로 기록. ( 대시보드 격리가 settings 에 미적용되어
재발한 공개 대응)

동반: SettingsController::restore 를 RestoreSettingsRequest 로 이전
(base Request + 컨트롤러 내 검증 제거), 미정의였던 backup_path_required·
restore_* lang 키 정의(ko/en/ja).
2026-07-02 15:11:59 +09:00
정정홍andGitHub 802e99bbcc Merge pull request from gnuboard:HeuJung/issue432
fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
2026-07-02 12:43:17 +09:00
HeuJung 27901dd254 fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
Windows JUNCTION 복사 실패:
- public/storage 는 storage:link 가 Windows 에서 생성하는 JUNCTION 으로,
 PHP is_link/is_dir 이 모두 false 를 반환해 copyDirectory 가 파일로
 오판 → copy(디렉토리) 예외로 코어 업데이트가 중단됐다.
- isReparsePoint 로 junction 을 정확 감지(link/file/dir 모두 false +
 readlink 성공)하고, copyLink 가 symlink 실패 시 mklink /J 로 폴백 복원.
 removeOrphanItems 도 junction orphan 을 rmdir 로 링크만 제거해 target
 재귀 삭제 사고를 차단.

실행할 스텝이 없는 업데이트를 실패로 오판:
- spawn 자식이 exit=0 + step 0건으로 종료하면 부모 가드가 from<to 만으로
 fail-fast 를 발동해, 스텝이 필요 없는 패치 릴리즈(예: 7.0.0→7.0.1)를
 실패로 처리하고 확장 업데이트 단계까지 도달하지 못했다.
- [STEPS_EXECUTED] 신호에 discovered(범위 내 발견된 스텝 파일 수)를 추가하고,
 handleSpawnExit 가 executed=0 && discovered=0 은 정상 통과, discovered>0
 일 때만 fail-fast 하도록 구분. 구버전 자식(discovered 부재)은 레거시 판정 유지.
 정상 통과 시 확장 일괄 업데이트 단계까지 진행된다.

테스트: FilePermissionHelperSymlinkTest / CoreUpdateCommandSpawnFailureTest /
CoreUpdateCommandHandoffTest / MultiVersionUpgradePathTest 전부 green.
2026-07-02 12:30:23 +09:00
정정홍andGitHub 68d2a68062 Merge pull request from gnuboard:HeuJung/issue429
perf(core): 코어 번들 3분할로 초기 로딩 최적화
2026-07-02 09:21:12 +09:00
HeuJung 32251b2a1d perf(core): 코어 번들 3분할로 초기 로딩 최적화
레이아웃 편집기·개발자 진단도구를 코어 번들에서 분리해 각각
편집 화면 진입 시·디버그 모드 시에만 지연 로드하도록 변경.
초기 접속 gzip payload 458KB → 221KB (52% 감소).

lazy 번들은 window.G7Core.__runtime 으로 코어 런타임을 공유해
React/컨텍스트/싱글톤 단일 인스턴스를 보장(재번들 0중복).
코어 7.0.1 bump.
2026-07-01 17:05:15 +09:00
HeuJung 917bfb603a v7.0.0 release 2026-07-01 11:32:54 +09:00
HeuJung c4ea9a6cd1 v7.0.0 release 2026-07-01 10:30:32 +09:00
HeuJung 0e7fa04b03 v7.0.0-beta.7 release 2026-05-15 19:24:41 +09:00
HeuJung c186d9a9c2 v7.0.0-beta.6 release 2026-05-14 12:24:08 +09:00
HeuJung 61f5874fd2 v7.0.0-beta.6 release 2026-05-14 11:13:08 +09:00
3788 changed files with 654161 additions and 167111 deletions
+4 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.5
APP_VERSION=7.0.2
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -36,7 +36,9 @@ DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (Optional - falls back to WRITE if not set)
# Read Database Connection (Optional)
# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php).
# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요.
DB_READ_HOST=
DB_READ_PORT=
DB_READ_DATABASE=
+2 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.5
APP_VERSION=7.0.2
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -34,7 +34,7 @@ DB_WRITE_PASSWORD=
DB_PREFIX=g7_
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다.
DB_READ_HOST=127.0.0.1
DB_READ_PORT=3306
DB_READ_DATABASE=g7_testing
+71
View File
@@ -0,0 +1,71 @@
---
name: 🐛 버그 제보
about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다.
title: "[Bug] "
labels: bug
assignees: ''
---
## 증상 요약
<!-- 어떤 문제가 발생했는지 한두 문장으로 적어 주세요. -->
## 재현 절차
1.
2.
3.
## 기대한 동작
<!-- 원래 어떻게 동작해야 하는지 적어 주세요. -->
## 실제 동작
<!-- 실제로 어떻게 동작했는지 적어 주세요. 오류 메시지가 있다면 그대로 붙여 주세요. -->
## 환경 정보 (필수)
> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요.
- **코어 버전**: <!-- 예: 7.0.2 / config/app.php 의 APP_VERSION 또는 관리자 > 정보 화면 -->
- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: <!-- 예: 7.0.0-beta.7 → 7.0.2 -->
- **PHP 버전**: <!-- 예: 8.2.x / php -v -->
- **웹서버·실행 방식**: <!-- 예: Apache + mod_php / Nginx + PHP-FPM / 공유호스팅 -->
- **DB**: <!-- 예: MySQL 8.0 / MariaDB 10.x -->
- **OS**: <!-- 예: Ubuntu 24.04 / Windows 11 -->
## 활성 확장 목록 (해당 시)
<!--
확장(모듈/플러그인/템플릿) 관련 문제이거나 업그레이드 문제라면 아래 명령 결과를 붙여 주세요.
php artisan module:list
php artisan plugin:list
php artisan template:list
-->
```text
(여기에 붙여넣기)
```
## 커스텀 자산 여부 (해당 시)
> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요.
- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다.
- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다.
- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다.
<!-- 위 항목에 해당하면 어떤 경로에 무엇을 추가했는지 적어 주세요. -->
## 스크린샷·로그
<!--
- 브라우저 콘솔 에러 (F12 > Console)
- storage/logs/ 관련 로그
- 화면 스크린샷
-->
## 추가 정보
<!-- 원인 추정, 임시 우회 방법 등 참고될 내용이 있으면 적어 주세요. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 질문·사용법 문의 (sir.kr Q&A)
url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7
about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요.
- name: 보안 취약점 신고
url: https://github.com/gnuboard/g7/security/advisories/new
about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요.
+26
View File
@@ -0,0 +1,26 @@
---
name: ✨ 기능 제안
about: 새로운 기능이나 개선 아이디어를 제안합니다.
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 해결하려는 문제
<!-- 어떤 불편이나 한계 때문에 이 기능이 필요한지 적어 주세요. -->
## 제안하는 기능
<!-- 원하는 동작을 구체적으로 적어 주세요. -->
## 적용 범위 (해당 시)
- [ ] 코어
- [ ] 모듈 <!-- 어떤 모듈인지: -->
- [ ] 플러그인 <!-- 어떤 플러그인인지: -->
- [ ] 템플릿 <!-- 어떤 템플릿인지: -->
## 대안·참고 사항
<!-- 고려한 다른 방법, 타 CMS 사례, 참고 링크 등이 있으면 적어 주세요. -->
+51
View File
@@ -0,0 +1,51 @@
---
name: ⬆️ 업그레이드 문제
about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다.
title: "[Upgrade] "
labels: "bug, upgrade"
assignees: ''
---
## 업그레이드 경로 (필수)
- **업그레이드 전 버전**: <!-- 예: 7.0.0-beta.7 -->
- **업그레이드 후(목표) 버전**: <!-- 예: 7.0.2 -->
- **실행한 명령**: <!-- 예: php artisan core:update --source=/tmp/g7-7.0.2 -->
- **실행 계정/권한**: <!-- 예: www-data / sudo(root) / 일반 사용자 -->
> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다.
> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다.
> 업그레이드 전 반드시 전체 백업을 권장합니다.
## 증상
<!-- 업데이트 중/후 어떤 문제가 발생했는지 적어 주세요. 오류 메시지는 그대로 붙여 주세요. -->
## 확장·커스텀 자산 상태 (필수)
- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. <!-- 경로: -->
- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다.
- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다.
<!-- 업데이트 전후 확장 목록을 붙여 주시면 도움이 됩니다:
php artisan module:list / plugin:list / template:list
-->
```text
(확장 목록 붙여넣기)
```
## 환경 정보
- **PHP 버전**:
- **웹서버·실행 방식**: <!-- Apache+mod_php / Nginx+PHP-FPM / 공유호스팅 등 -->
- **DB**:
- **OS**:
## 로그·스크린샷
<!--
- 콘솔 전체 출력 (업데이트 실행 로그)
- storage/logs/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+14
View File
@@ -49,6 +49,9 @@ node_modules/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
/storage/app/ext-bundles/
# Bootstrap cache (auto-generated)
/bootstrap/cache/*
!/bootstrap/cache/.gitkeep
@@ -104,6 +107,7 @@ boost.json
cypress/
cypress.config.js
.env.testing
.claude/settings.local.json
# ===== 보안: 민감 파일 방어 패턴 =====
*.pem
@@ -121,4 +125,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/
+57 -3
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 -->
@@ -380,8 +382,19 @@ Keep a Changelog 표준:
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
| Breaking Change | 이슈 번호 |
| Breaking Change | 내부 작업 이슈 번호 단독 (예: `refs #347`) |
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
| **공개 제보자 attribution** (`(#N @login 님께서 제보해주셨습니다.)`) | — |
| **KISA 등 공식 보안 채널** (`(KISA 측에서 제보해주셨습니다 — KVE-XXXX-XXXXX)`) | — |
### 공개 제보자 attribution
공개 저장소(GitHub) 이슈로 제보·건의된 항목이 출시 CHANGELOG 에 반영되면, 항목 끝에 공개 이슈 번호와 제보자 GitHub 핸들 멘션을 부착합니다.
- 형식: `- (본문) (#N @login 님께서 제보해주셨습니다.)` 또는 `... 건의해주셨습니다.`
- 톤: 버그 리포트는 "제보", 제안형 개선 요청은 "건의"
- 다중 매칭: `(#A @x, #B @y 님께서 제보해주셨습니다.)` / 혼재: `(#A @x 님께서 제보해주시고, #B @y 님께서 건의해주셨습니다.)`
- KISA 등 공식 보안 채널: GitHub 멘션 없이 텍스트 "KISA 측에서" + 공개 가능한 식별자만
### 신규 기능의 버그 수정 제외 규칙
@@ -555,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. 동적 로딩
@@ -749,6 +776,30 @@ php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
```
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
| 번들 | 로드 시점 | vite config |
|------|----------|-------------|
| `template-engine.min.js` | 모든 페이지 (동기 `<script>`) | `vite.config.core.js` |
| `layout-editor.min.js` | `/admin/layout-editor/*` 진입 시 런타임 주입 | `vite.config.editor.js` |
| `devtools.min.js` | 디버그 모드에서만 런타임 주입 | `vite.config.devtools.js` |
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
### 확장 번들 병합 (서버측 concat)
활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율:
- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다.
- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다.
- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다.
- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다.
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
### 빌드 명령어 (Artisan)
```bash
@@ -787,6 +838,7 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내/수동 복구용 — 사전·사후 단계 자동 수행)
# 모듈
php artisan module:list
@@ -875,6 +927,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) |
+211 -23
View File
@@ -4,11 +4,196 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.2] - 2026-07-08
### Added
- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.
### Changed
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
- 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.
### Fixed
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.
- 설치 시 "읽기 전용 데이터베이스(Read DB)를 사용하지 않음"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)
## [7.0.1] - 2026-07-03
### Changed
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.
- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.
- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.
- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.
### Fixed
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
## [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
- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)
- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.
- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.
- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.
- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.
- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.
### Changed
- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)
- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)
### Notes — `.env` 권한 권장안
위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.
- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.
## [7.0.0-beta.6] - 2026-05-14
### Fixed
- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.
- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.
- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.
- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.
- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.
- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\Program Files\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)
- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.
- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 "PHP 절대경로 + Composer 절대경로" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.
- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 "설치하기" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.
### Added
- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.
- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 "정의 추가" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다.
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
@@ -73,8 +258,8 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
@@ -89,7 +274,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
@@ -111,7 +296,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### Generator 메타 태그
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
@@ -162,7 +347,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
@@ -265,10 +450,10 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Changed
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
@@ -277,14 +462,14 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Security
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
### Fixed
@@ -314,14 +499,16 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2)
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)
- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)
## [7.0.0-beta.3] - 2026-04-23
### Fixed
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
@@ -361,17 +548,17 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### GeoIP 및 타임존
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
#### 인스톨러 개선
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
@@ -384,7 +571,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### 어드민 UI
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
- DataGrid 컴포넌트에 `id` prop 추가
@@ -480,6 +667,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
- 관리자 모듈/플러그인 목록의 "작성자" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)
### Removed
+176 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.5 g7
# (필요 시) mv g7-7.0.2 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -338,6 +338,157 @@ http://도메인/install
---
## 업그레이드
이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
### 개요
그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
```bash
sudo php artisan core:update
```
이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
- 자동 백업 생성 (실패 시 자동 롤백)
- 유지보수 모드 자동 진입
- 데이터베이스 마이그레이션 실행
- 업그레이드 스텝 실행 (버전별 데이터 보정)
- 번들 확장 자동 재동기화
- 캐시 정리 및 유지보수 모드 해제
### 업그레이드 사전 준비
업그레이드 전 다음을 확인하세요.
1. **데이터베이스 백업** (필수)
```bash
mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
```
2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
```bash
tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
```
3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
### 방법 2: CLI 자동 업그레이드 (표준 절차)
대다수 환경에서 권장되는 절차입니다.
```bash
sudo php artisan core:update
```
#### `sudo` 가 권장되는 이유
업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
#### `sudo` 없이 실행해도 되는 환경
- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
```bash
# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
unzip <파일명>.zip -d /tmp/g7-new
# 2. 압축 해제된 경로를 지정하여 업데이트 실행
sudo php artisan core:update --source=/tmp/g7-new
```
`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
### 사용자군별 단계적 업그레이드
여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
| 현재 버전 | 권장 경로 |
|----------|----------|
| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
### 권한/캐시 트러블슈팅
업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
#### `.env` 읽기 실패 (`Permission denied`)
```bash
ls -l .env
# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
```
대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
#### `bootstrap/cache` 쓰기 실패
```bash
sudo chown -R www-data:www-data bootstrap/cache storage
```
소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
#### 업그레이드 도중 자동 롤백 후 부팅 실패
beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
```bash
# 진단 모드 (실제 삭제 없음)
sudo php artisan hotfix:rollback-stale-files
# 운영자 확인 후 실제 정리
sudo php artisan hotfix:rollback-stale-files --prune
```
### 업그레이드 후 확인
업그레이드가 끝나면 다음을 점검합니다.
1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
---
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
@@ -346,6 +497,30 @@ http://도메인/install
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
```bash
sudo chown www-data:www-data .env
sudo chmod 600 .env
```
- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
```bash
sudo chgrp www-data .env
sudo chmod 640 .env
```
웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
+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.5-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.2-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
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
/**
* 오래된 확장 프론트엔드 병합 번들 파일을 정리하는 커맨드
*
* 번들 캐시 파일(`storage/app/ext-bundles/{type}.{version}.{js|css}`)은 Laravel
* 캐시 스토어 밖 파일시스템이라 version bump 나 cache:clear 가 구버전 파일을
* 지우지 않는다(TTL/GC 없음). 이 커맨드가 현재 version 외의 구파일을 삭제한다.
* (`CleanupLayoutPreviewsCommand` 파일 산출물 GC 패턴 미러)
*/
class CleanupExtensionBundlesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-bundles:cleanup';
/**
* The console command description.
*/
protected $description = '오래된 확장 프론트엔드 병합 번들 파일(구 version)을 삭제합니다';
/**
* Execute the console command.
*
* @param ExtensionBundleService $service 번들 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionBundleService $service): int
{
$currentVersion = $service->getCurrentVersion();
$deleted = $service->cleanupStaleBundles($currentVersion);
$this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
return self::SUCCESS;
}
}
+99 -23
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.
*/
@@ -82,26 +85,95 @@ class BuildCoreCommand extends Command
*/
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
{
$buildCommand = ['npm', 'run'];
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
if ($watchMode) {
// build:core는 watch 모드가 없으므로 dev 사용
$buildCommand[] = 'dev';
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build:core';
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
return $this->runWatchBundles($projectPath);
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
if ($engineResult !== Command::SUCCESS) {
return $engineResult;
}
return $result;
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
if ($editorResult !== Command::SUCCESS) {
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
return $editorResult;
}
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
if ($devtoolsResult !== Command::SUCCESS) {
$this->error('❌ DevTools 번들 빌드 실패');
return $devtoolsResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
return Command::SUCCESS;
}
/**
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
*
* @param string $projectPath 프로젝트 경로
* @return int 명령 실행 결과 코드
*/
private function runWatchBundles(string $projectPath): int
{
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
$bundles = [
'engine' => ['npm', 'run', 'build:core-watch'],
'editor' => ['npm', 'run', 'build:core-editor-watch'],
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
];
/** @var array<string, Process> $processes */
$processes = [];
foreach ($bundles as $label => $command) {
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($projectPath);
$process->setTimeout(null);
$process->start(function ($type, $buffer) use ($label) {
$this->output->write("[{$label}] ".$buffer);
});
$processes[$label] = $process;
}
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
do {
$anyRunning = false;
foreach ($processes as $process) {
if ($process->isRunning()) {
$anyRunning = true;
}
}
usleep(100000); // 100ms
} while ($anyRunning);
return Command::SUCCESS;
}
/**
@@ -130,6 +202,7 @@ class BuildCoreCommand extends Command
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
}
return $result;
@@ -150,22 +223,25 @@ class BuildCoreCommand extends Command
$this->line(' 빌드 결과:');
// template-engine.min.js 확인
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
$engineFile = $corePath.'/template-engine.min.js';
if (file_exists($engineFile)) {
$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)");
}
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
$editorFile = $corePath.'/layout-editor.min.js';
if (file_exists($editorFile)) {
$fileSize = number_format(filesize($editorFile) / 1024, 2);
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
}
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
$devtoolsFile = $corePath.'/devtools.min.js';
if (file_exists($devtoolsFile)) {
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
}
}
@@ -459,4 +459,5 @@ trait BundledExtensionUpdatePrompt
'has_updates' => true,
];
}
}
+404 -17
View File
@@ -3,27 +3,32 @@
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;
use App\Support\ConfigCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
@@ -331,13 +336,93 @@ class CoreUpdateCommand extends Command
}
}
// ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
//
// applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
// _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
// `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
// `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
// 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
// 있으므로 manifest 에서 제외되어 보존된다.
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
// 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출.
// null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀.
$applyList = null;
$applyStats = null;
$prune = (bool) $this->option('prune');
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
try {
$manifestStats = CoreBackupHelper::writeNewFilesManifest(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
$fromVersion,
$toVersion,
);
$log(sprintf(
'신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
$manifestStats['new_files_count'],
$manifestStats['new_dirs_count'],
));
} catch (\Throwable $manifestError) {
// manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
$log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
Log::warning('코어 업데이트: manifest 작성 실패', [
'error' => $manifestError->getMessage(),
]);
}
// ── 증분 적용 대상 산출 (3-way) ──
// --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵.
if (! $prune) {
try {
$applyStats = CoreBackupHelper::computeApplyList(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
);
$applyList = $applyStats['apply'];
$log(sprintf(
'증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)',
$applyStats['added_count'],
$applyStats['changed_count'],
count($applyList),
));
} catch (\Throwable $applyError) {
// 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지)
$applyList = null;
$applyStats = null;
$log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}");
Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [
'error' => $applyError->getMessage(),
]);
}
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
$log('코어 파일 덮어쓰기 시작');
$service->applyUpdate($pendingPath, $onProgress);
$log('코어 파일 덮어쓰기 완료');
if ($prune) {
$log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)');
} elseif ($applyList !== null) {
$log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList)));
} else {
$log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)');
}
$service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList);
$log('코어 파일 적용 완료');
// ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ──
if ($composerSkipped) {
@@ -419,6 +504,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 를 항목별
@@ -438,6 +528,13 @@ class CoreUpdateCommand extends Command
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -449,6 +546,9 @@ class CoreUpdateCommand extends Command
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
@@ -476,6 +576,10 @@ class CoreUpdateCommand extends Command
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (UpgradeHandoffException $e) {
@@ -510,6 +614,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);
@@ -539,7 +647,7 @@ class CoreUpdateCommand extends Command
$this->line(" 사유: {$e->reason}");
$this->newLine();
$this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):');
$this->line(" {$resumeCommand}");
$this->surfaceResumeCommandWithPermissionGuidance($resumeCommand);
$this->newLine();
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
$this->newLine();
@@ -549,6 +657,8 @@ class CoreUpdateCommand extends Command
$this->newLine();
}
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (\Throwable $e) {
@@ -581,6 +691,17 @@ class CoreUpdateCommand extends Command
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
// 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
// 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
// 캐시 정리 실패는 fatal 아님 — warning 후 진행.
try {
$service->clearAllCaches();
$log('롤백 후 캐시 정리 완료');
} catch (\Throwable $cacheError) {
$log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
$this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
@@ -631,6 +752,8 @@ class CoreUpdateCommand extends Command
}
}
$this->restoreUpgradeLogOwnership();
return Command::FAILURE;
}
}
@@ -654,9 +777,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
{
@@ -682,6 +805,17 @@ class CoreUpdateCommand extends Command
if ($force) {
$command[] = '--force';
}
// 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
// Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
// prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
// (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
// gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
// 가 단일 명령으로 통합되어 단독 안전성 보장.
$command[] = '--skip-migrations';
$command[] = '--skip-resync';
$command[] = '--skip-version-env';
$command[] = '--skip-cache-clear';
$command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
@@ -726,6 +860,7 @@ class CoreUpdateCommand extends Command
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
@@ -757,7 +892,13 @@ class CoreUpdateCommand extends Command
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
$log('[spawn] 실행된 step 수: '.$stepsExecuted);
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
$stepsDiscovered = $decoded['discovered'];
}
$log('[spawn] 실행된 step 수: '.$stepsExecuted
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
continue;
}
@@ -772,6 +913,50 @@ class CoreUpdateCommand extends Command
fclose($pipes[2]);
$exitCode = proc_close($process);
return $this->handleSpawnExit(
$exitCode,
$handoffPayload,
$stepsExecuted,
$stepsDiscovered,
$fromVersion,
$toVersion,
$log,
);
}
/**
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
*
* 판정 규칙 (exit=0 경우):
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
*
* @param int $exitCode 자식 프로세스 종료 코드
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
* @param string $fromVersion 업그레이드 시작 버전
* @param string $toVersion 업그레이드 대상 버전
* @param \Closure $log 로그 수집 콜백
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
*
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
*/
private function handleSpawnExit(
int $exitCode,
?array $handoffPayload,
?int $stepsExecuted,
?int $stepsDiscovered,
string $fromVersion,
string $toVersion,
\Closure $log,
): bool {
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
$log("spawn 핸드오프 종료 (exit={$exitCode})");
@@ -786,9 +971,7 @@ class CoreUpdateCommand extends Command
}
if ($exitCode === 0) {
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
@@ -798,9 +981,27 @@ class CoreUpdateCommand extends Command
);
}
if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) {
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
return true;
}
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
if ($stepsExecuted === 0
&& $stepsDiscovered !== 0
&& version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion),
sprintf(
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
@@ -833,9 +1034,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
{
@@ -913,6 +1114,41 @@ class CoreUpdateCommand extends Command
return true;
}
/**
* 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다.
*
* @param bool $prune --prune 지정 여부
* @param array<int, string>|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기)
* @param array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계
*/
private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void
{
if ($prune) {
$this->line(__('settings.core_update.apply_mode_prune'));
$this->newLine();
return;
}
if ($applyList === null) {
// 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀
$this->warn(__('settings.core_update.apply_mode_fallback'));
$this->newLine();
return;
}
// 증분 모드
$added = $applyStats['added_count'] ?? 0;
$changed = $applyStats['changed_count'] ?? 0;
$this->line(__('settings.core_update.apply_mode_incremental', [
'added' => $added,
'changed' => $changed,
]));
$this->line(__('settings.core_update.apply_mode_incremental_prune_hint'));
$this->newLine();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -940,10 +1176,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);
}
}
}
/**
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
*
@@ -991,6 +1261,125 @@ class CoreUpdateCommand extends Command
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로
* 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이
* 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로
* root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버
* (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
*
* 실행 환경을 4가지로 분기해 안내한다:
* 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등)
* → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함).
* 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름
* → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고.
* 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성)
* → 권한 문제 없으므로 명령만 그대로 안내.
* 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패)
* → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상).
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
*/
private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void
{
[$mode, $webUser] = $this->classifyResumeExecutionContext();
$this->renderResumeGuidance($resumeCommand, $mode, $webUser);
}
/**
* 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다.
*
* `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 —
* posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다.
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
* @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드
* @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효)
*/
private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void
{
// 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내.
// - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원
// - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해
if ($mode === 'non_root' || $mode === 'root_web_symmetric') {
$this->line(" {$resumeCommand}");
return;
}
// 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내.
if ($mode === 'root_web_known' && $webUser !== null) {
$this->line(" sudo -u {$webUser} {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드');
$this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수");
$this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요.");
return;
}
// 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고.
$this->line(" sudo -u <웹서버계정> {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.');
$this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어');
$this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,');
$this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.');
}
/**
* 재실행 안내의 실행 환경을 분류합니다.
*
* 반환하는 모드:
* - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
* 쓰기 영역 소유자로 추정한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['non_root', null];
}
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
if (posix_geteuid() !== 0) {
return ['non_root', null];
}
[$owner] = FilePermissionHelper::inferWebServerOwnership();
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
if ($owner === false) {
return ['root_web_unknown', null];
}
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
if ($owner === 0) {
return ['root_web_symmetric', null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['root_web_unknown', null];
}
return ['root_web_known', $name];
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
@@ -999,9 +1388,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
{
@@ -174,4 +174,5 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -2,7 +2,15 @@
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;
@@ -18,15 +26,35 @@ use Illuminate\Support\Facades\Log;
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
*
* 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
* 기본값으로 부모 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 : 동일 버전 강제 실행}';
{--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 = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행하며, --steps-only 로 스텝만 실행할 수 있습니다.';
/**
* 커맨드를 실행합니다.
@@ -40,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 는 필수 옵션입니다.');
@@ -57,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 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
@@ -72,26 +107,64 @@ 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 자식 감지.
//
// CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식에 `G7_UPDATE_IN_PROGRESS=1` env 를
// 전달한다 (beta.3 부터 존재). 이 시그널은 자식이 부모의 `core:update` 컨텍스트에서
// 실행 중임을 의미한다. 부모는 본인이 사전(Step 9 migration + resync) + 사후
// (Step 11 version-env + cache-clear + bundled prompt) 단계를 모두 책임지므로 자식은
// 5단계 모두 스킵해야 한다.
//
// 구버전 부모(예: beta.5)는 신버전 자식이 도입한 `--skip-*` 옵션을 모른다. 옵션 부재
// 시 자식이 사후 단계를 실행하면 (특히 bundled prompt) non-interactive 환경에서
// `Aborted.` exit=1 회귀 발생 (882deb9b0 사각지대). env 시그널이 옵션 부재를 보완.
//
// 단독 호출 (운영자 수동) 시엔 env 미설정 → 모든 단계 정상 진행.
$isSpawnChild = getenv('G7_UPDATE_IN_PROGRESS') === '1';
// 사전 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
// migration / permission / menu / seeder 누락 차단.
if (! $this->option('skip-migrations') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('마이그레이션 실행');
$service->runMigrations();
} else {
$this->info($stepsOnly ? '[steps-only] 마이그레이션 스킵' : '[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
}
if (! $this->option('skip-resync') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
$service->reloadCoreConfigAndResync();
} else {
$this->info($stepsOnly ? '[steps-only] resync 스킵' : '[spawn] resync 스킵 — 부모가 이미 실행');
}
$stepsExecuted = 0;
$stepsDiscovered = 0;
try {
$service->runUpgradeSteps(
@@ -102,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command
$stepsExecuted++;
},
$force,
function (int $discovered) use (&$stepsDiscovered): void {
$stepsDiscovered = $discovered;
},
);
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
@@ -127,6 +203,8 @@ class ExecuteUpgradeStepsCommand extends Command
'reason' => $e->reason,
]);
$this->restoreUpgradeLogOwnership();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
Log::error('core:execute-upgrade-steps 실패', [
@@ -136,6 +214,8 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
return self::FAILURE;
}
@@ -145,10 +225,86 @@ class ExecuteUpgradeStepsCommand extends Command
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'discovered' => $stepsDiscovered,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
// 사후 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
// 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
// --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 && ! $stepsOnly) {
$this->info(".env APP_VERSION={$to} 갱신");
$service->updateVersionInEnv($to);
} else {
$this->info($stepsOnly ? '[steps-only] .env APP_VERSION 갱신 스킵' : '[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
}
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
$this->incrementExtensionCacheVersion();
} else {
$this->info($stepsOnly ? '[steps-only] 캐시 정리 스킵' : '[spawn] 캐시 정리 스킵 — 부모가 처리');
}
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
$this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
app(PluginManager::class),
app(TemplateManager::class),
$force,
);
} else {
$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);
}
}
}
}
@@ -3,6 +3,9 @@
namespace App\Console\Commands\Extension;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
class UpdateAutoloadCommand extends Command
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
/**
* Execute the console command.
*/
public function handle(ExtensionManager $extensionManager): int
{
public function handle(
ExtensionManager $extensionManager,
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$this->info('확장 오토로드 파일을 생성합니다...');
try {
@@ -34,6 +41,16 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$hookCacheManager->generate($moduleManager, $pluginManager);
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
$this->line(' → bootstrap/cache/hooks.php');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
*
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
*/
class HooksCacheCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:cache';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @param ModuleManager $moduleManager 모듈 매니저
* @param PluginManager $pluginManager 플러그인 매니저
* @return int 종료 코드
*/
public function handle(
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
$this->info(__('hooks.cache_generated', [
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
'path' => $hookCacheManager->getCacheFilePath(),
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
*
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
* 동작하므로 항상 안전하다 (route:clear 동형).
*/
class HooksClearCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:clear';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @return int 종료 코드
*/
public function handle(HookCacheManager $hookCacheManager): int
{
if (! $hookCacheManager->isCached()) {
$this->info(__('hooks.cache_absent'));
return Command::SUCCESS;
}
$hookCacheManager->clear();
$this->info(__('hooks.cache_cleared'));
return Command::SUCCESS;
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands\Hotfix;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리하는 단발성
* 결함 보정 도구.
*
* 결함 컨텍스트: 7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식
* 으로만 동작하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지
* 않고 잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
* 디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등 부팅
* 부정합 발생.
*
* 도입 버전: 7.0.0-beta.6 신설.
*
* 동작:
* - `--backup` 미지정 시 `storage/app/core_backups/` 의 가장 최근 백업 자동 선택
* - 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 그 기반으로 잔존 파일 후보 식별 (정확)
* - manifest 부재 시 보수적 진단 모드 (실제 정리는 운영자 수동)
* - 기본은 진단 모드 (실제 삭제 없이 후보 목록만 출력)
* - `--prune` 옵션 시 확인 프롬프트 후 정리. symlink/protected_paths 가드 적용
* - 정리 결과는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 기록
*
* @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
* 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
*/
class RollbackStaleFilesCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'hotfix:rollback-stale-files
{--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
{--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
public function handle(): int
{
$backupPath = $this->resolveBackupPath();
if ($backupPath === null) {
$this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
return Command::SUCCESS;
}
$this->info("백업 디렉토리: {$backupPath}");
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$manifestAvailable = File::exists($manifestPath);
if (! $manifestAvailable) {
$this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
$this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
$this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
$this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
$this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
$this->newLine();
}
$candidates = $this->collectCandidates($backupPath, $manifestAvailable);
if ($candidates['files'] === [] && $candidates['dirs'] === []) {
$this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
return Command::SUCCESS;
}
$this->newLine();
$this->info(sprintf(
'잔존 후보: 파일 %d개, 디렉토리 %d개',
count($candidates['files']),
count($candidates['dirs']),
));
foreach (array_slice($candidates['files'], 0, 20) as $f) {
$this->line(" · 파일: {$f}");
}
if (count($candidates['files']) > 20) {
$this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
}
foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
$this->line(" · 디렉토리: {$d}");
}
if (count($candidates['dirs']) > 10) {
$this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
}
if (! $this->option('prune')) {
$this->newLine();
$this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
return Command::SUCCESS;
}
if (! $manifestAvailable) {
$this->newLine();
$this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
$this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
return Command::FAILURE;
}
$this->newLine();
if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
$this->info('취소되었습니다.');
return Command::SUCCESS;
}
$protectedPaths = (array) config('app.update.protected_paths', []);
$result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
$this->newLine();
$this->info(sprintf(
'정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
$result['removed_files'],
$result['removed_dirs'],
$result['protected_count'],
$result['symlink_skipped'],
$result['failed_count'],
));
$logPath = storage_path(sprintf(
'logs/hotfix_rollback_stale_files_%s.log',
date('Ymd_His'),
));
@file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
$this->line("로그: {$logPath}");
Log::info('hotfix:rollback-stale-files 정리 완료', [
'backup_path' => $backupPath,
'result' => $result,
]);
return Command::SUCCESS;
}
/**
* `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
*/
private function resolveBackupPath(): ?string
{
$explicit = $this->option('backup');
if ($explicit !== null && $explicit !== '') {
$real = realpath($explicit);
if ($real === false || ! is_dir($real)) {
$this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
return null;
}
return $real;
}
$backups = CoreBackupHelper::listBackups();
if ($backups === []) {
return null;
}
// listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $backups[0]['path'] ?? null;
}
/**
* 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
*
* @return array{files:array<int,string>, dirs:array<int,string>}
*/
private function collectCandidates(string $backupPath, bool $manifestAvailable): array
{
if (! $manifestAvailable) {
return ['files' => [], 'dirs' => []];
}
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
return ['files' => [], 'dirs' => []];
}
$manifest = json_decode($raw, true);
if (! is_array($manifest)) {
return ['files' => [], 'dirs' => []];
}
$files = [];
foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (file_exists($absolute) || is_link($absolute)) {
$files[] = $rel;
}
}
$dirs = [];
foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (is_dir($absolute) && ! is_link($absolute)) {
$dirs[] = $rel;
}
}
return ['files' => $files, 'dirs' => $dirs];
}
/**
* 진단/정리 결과를 로그 텍스트로 빌드.
*/
private function buildLogEntry(string $backupPath, array $result, array $candidates): string
{
$lines = [
'=== hotfix:rollback-stale-files 실행 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
"백업 디렉토리: {$backupPath}",
'',
'결과:',
sprintf(' 파일 제거: %d', $result['removed_files']),
sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
sprintf(' 보호 경로 skip: %d', $result['protected_count']),
sprintf(' symlink skip: %d', $result['symlink_skipped']),
sprintf(' 실패: %d', $result['failed_count']),
'',
'후보 목록 (실행 직전 스냅샷):',
];
foreach ($candidates['files'] as $f) {
$lines[] = " · 파일: {$f}";
}
foreach ($candidates['dirs'] as $d) {
$lines[] = " · 디렉토리: {$d}";
}
return implode("\n", $lines)."\n";
}
}
@@ -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,6 +5,7 @@ namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearModuleCacheCommand extends Command
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearModuleCacheCommand extends Command
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -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,6 +5,7 @@ namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearPluginCacheCommand extends Command
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -86,6 +88,9 @@ class ClearPluginCacheCommand extends Command
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -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;
}
}
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -153,6 +155,9 @@ class ClearTemplateCacheCommand extends Command
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
]));
@@ -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());
}
}
}
+88 -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 코어 요구 버전 제약 반환
*
@@ -1126,6 +1190,29 @@ abstract class AbstractModule implements ModuleInterface
return $result;
}
/**
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
*
* `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로
* 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled`
* 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이
* `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지
* 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
*
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
*/
public function getBuiltAssetAbsolutePaths(): array
{
$relative = $this->getBuiltAssetPaths();
$result = [];
foreach ($relative as $kind => $output) {
$result[$kind] = $this->getModulePath().'/'.$output;
}
return $result;
}
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
+66 -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 코어 요구 버전 제약 반환
*
@@ -970,6 +1011,30 @@ abstract class AbstractPlugin implements PluginInterface
return $result;
}
/**
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
*
* `getBuiltAssetPaths()` 는 plugin.json 의 상대 output 경로를 돌려주므로
* 파일을 실제로 읽으려면 플러그인 루트(`getPluginPath()`: 활성 dir 또는
* `_bundled` 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합
* (ExtensionBundleService)이 `getAssetFilePath()` 의
* `base_path("plugins/{id}/...")` 하드코딩을 복제하지 않고 `_bundled`
* 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
*
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
*/
public function getBuiltAssetAbsolutePaths(): array
{
$relative = $this->getBuiltAssetPaths();
$result = [];
foreach ($relative as $kind => $output) {
$result[$kind] = $this->getPluginPath().'/'.$output;
}
return $result;
}
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
+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);
}
}
+189 -18
View File
@@ -5,6 +5,9 @@ namespace App\Extension;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\Helpers\GithubHelper;
use App\Rules\ValidExtensionIdentifier;
use App\Support\ConfigCacheHelper;
use Composer\Autoload\ClassLoader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -37,14 +40,6 @@ class ExtensionManager
$this->autoloadFilePath = base_path('bootstrap/cache/autoload-extensions.php');
}
/**
* 설치된 모듈/플러그인의 오토로드 파일을 생성합니다.
*
* composer.json을 수정하지 않고, bootstrap/cache/autoload-extensions.php 파일을 생성하여
* 런타임에 Composer ClassLoader에 PSR-4 네임스페이스를 등록합니다.
*
* 테스트 환경(APP_ENV=testing)에서는 자동으로 스킵됩니다.
*/
public function updateComposerAutoload(): void
{
// 테스트 환경에서 오토로드 업데이트 스킵 (성능 최적화)
@@ -61,6 +56,41 @@ class ExtensionManager
// 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가
// 실패하지 않도록 런타임 재등록을 수행한다.
$this->reregisterRuntimeAutoload();
// 정적 훅 매핑 캐시도 오토로드 캐시와 나란히 재생성.
// 훅 매핑은 확장 install/activate/deactivate/uninstall/update 시에만 바뀌므로
// 이 지점에 편승하면 별도 무효화 발굴 없이 오토로드 캐시와 동일 생명주기를 갖는다.
$this->regenerateHookCache();
// config 캐시도 함께 재생성. 확장 설치/삭제/업데이트는 활성 확장 목록을 바꾸므로
// (optimizeSystem 이 config:cache 를 만든 시점의 스냅샷이 stale 해짐), 오토로드/훅
// 캐시와 동일 생명주기로 config 캐시를 재빌드해 캐시가 비활성 상태로 남지 않게 한다.
// activate/deactivate 는 이 메서드를 거치지 않으므로 각 Manager 에서 별도 호출한다.
ConfigCacheHelper::rebuild();
}
/**
* 정적 훅 매핑 캐시(bootstrap/cache/hooks.php)를 재생성합니다.
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*/
protected function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
$moduleManager->loadModules();
$pluginManager = app(PluginManager::class);
$pluginManager->loadPlugins();
app(HookCacheManager::class)->generate($moduleManager, $pluginManager);
} catch (\Throwable $e) {
// 훅 캐시 생성 실패는 치명적이지 않다 — 부팅 시 스캔 폴백이 안전망.
Log::warning('훅 매핑 캐시 재생성 실패 (스캔 폴백으로 동작)', [
'error' => $e->getMessage(),
]);
}
}
/**
@@ -78,7 +108,7 @@ class ExtensionManager
*/
protected function reregisterRuntimeAutoload(): void
{
if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) {
if (! class_exists(ClassLoader::class, false)) {
return;
}
@@ -86,7 +116,7 @@ class ExtensionManager
return;
}
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
$loaders = ClassLoader::getRegisteredLoaders();
if (empty($loaders)) {
return;
}
@@ -135,8 +165,13 @@ class ExtensionManager
$pluginAutoloads['vendor_autoloads']
);
// 확장 소스 classmap 생성 (FQCN → 상대경로).
// 런타임에 ClassLoader::addClassMap() 으로 등록되어 findFile 이 파일시스템 스캔 없이
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
$srcClassmap = $this->buildSourceClassmap($psr4);
// 파일 내용 생성
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads);
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
// 디렉토리 확인
$dir = dirname($this->autoloadFilePath);
@@ -151,6 +186,7 @@ class ExtensionManager
'path' => $this->autoloadFilePath,
'psr4_count' => count($psr4),
'classmap_count' => count($classmap),
'src_classmap_count' => count($srcClassmap),
'files_count' => count($files),
'vendor_autoloads_count' => count($vendorAutoloads),
]);
@@ -164,7 +200,7 @@ class ExtensionManager
* @param array $files 헬퍼 파일 목록
* @return string PHP 파일 내용
*/
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = []): string
protected function buildAutoloadFileContent(array $psr4, array $classmap, array $files = [], array $vendorAutoloads = [], array $srcClassmap = []): string
{
$generatedAt = now()->toDateTimeString();
@@ -173,12 +209,14 @@ class ExtensionManager
$classmapJson = json_encode($classmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$filesJson = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$vendorAutoloadsJson = json_encode($vendorAutoloads, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$srcClassmapJson = json_encode($srcClassmap, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// JSON을 PHP 배열 문법으로 변환
$psr4Php = $this->jsonToPhpArray($psr4Json, 1);
$classmapPhp = $this->jsonToPhpArray($classmapJson, 1);
$filesPhp = $this->jsonToPhpArray($filesJson, 1);
$vendorAutoloadsPhp = $this->jsonToPhpArray($vendorAutoloadsJson, 1);
$srcClassmapPhp = $this->jsonToPhpArray($srcClassmapJson, 1);
return <<<PHP
<?php
@@ -195,6 +233,7 @@ class ExtensionManager
return [
'psr4' => {$psr4Php},
'classmap' => {$classmapPhp},
'src_classmap' => {$srcClassmapPhp},
'files' => {$filesPhp},
'vendor_autoloads' => {$vendorAutoloadsPhp},
];
@@ -240,7 +279,7 @@ PHP;
*
* public/index.php 및 artisan에서 호출됩니다.
*
* @param \Composer\Autoload\ClassLoader $loader Composer ClassLoader 인스턴스
* @param ClassLoader $loader Composer ClassLoader 인스턴스
*/
public static function registerExtensionAutoload($loader): void
{
@@ -266,6 +305,17 @@ PHP;
}
}
// 확장 소스 classmap 등록 (FQCN → 절대경로).
// findFile 이 파일시스템 스캔 없이 즉시 반환하도록 in-memory 맵에 추가한다(lazy include).
// PSR-4 등록은 위에서 유지되므로 classmap 에 없는 클래스는 PSR-4 폴백(안전망).
if (! empty($autoloads['src_classmap'])) {
$absoluteClassmap = [];
foreach ($autoloads['src_classmap'] as $fqcn => $relPath) {
$absoluteClassmap[$fqcn] = base_path($relPath);
}
$loader->addClassMap($absoluteClassmap);
}
// Classmap 파일 로드 (module.php, plugin.php)
if (! empty($autoloads['classmap'])) {
foreach ($autoloads['classmap'] as $file) {
@@ -471,6 +521,130 @@ PHP;
return ['psr4' => $psr4, 'classmap' => $classmap, 'files' => $files, 'vendor_autoloads' => $vendorAutoloads];
}
/**
* PSR-4 매핑을 기반으로 확장 소스 클래스의 classmap 을 생성합니다.
*
* 각 PSR-4 네임스페이스 디렉토리를 재귀 스캔하여 PHP 파일마다 FQCN → 상대경로 를
* 산출한다. 이 classmap 은 런타임에 `ClassLoader::addClassMap()` 으로 등록되어
* `findFile()` 이 파일시스템 스캔 없이 즉시 경로를 반환하도록 한다(성능: cold OPcache /
* 느린 파일시스템 환경에서 findFile stat 비용 제거). 클래스 로딩은 여전히 지연(lazy)이다.
*
* 확장 **소스** 클래스만 대상으로 하며, 확장별 독립 vendor(서드파티)는 각 확장의
* `vendor/autoload.php`(별도 ClassLoader) 가 담당하므로 여기서 다루지 않는다
* (vendor 격리 불변조건 보존).
*
* @param array<string, string|array<int, string>> $psr4 네임스페이스 → base_path 상대경로(들)
* @return array<string, string> FQCN → base_path 상대경로 (.php 포함)
*/
protected function buildSourceClassmap(array $psr4): array
{
$classmap = [];
foreach ($psr4 as $namespace => $paths) {
foreach ((array) $paths as $relativePath) {
$absoluteDir = base_path($relativePath);
if (! is_dir($absoluteDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($absoluteDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$fqcn = $this->extractFqcnFromFile($file->getPathname());
if ($fqcn === null) {
continue;
}
// PSR-4 계약상 이 파일의 네임스페이스는 $namespace 로 시작해야 한다.
// (다른 네임스페이스 파일은 다른 PSR-4 항목이 담당 — 중복 방지)
if (! str_starts_with($fqcn, rtrim($namespace, '\\'))) {
continue;
}
// base_path 상대경로로 정규화 (autoload-extensions.php 의 다른 경로와 일관)
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen(base_path()) + 1));
$classmap[$fqcn] = $rel;
}
}
}
ksort($classmap);
return $classmap;
}
/**
* PHP 파일에서 FQCN(네임스페이스 + 클래스/인터페이스/트레이트/enum 명)을 추출합니다.
*
* 토큰 기반 파싱으로 파일당 최상위 선언 1개의 FQCN 을 반환한다. 선언이 없으면 null.
*
* @param string $filePath PHP 파일 절대경로
* @return string|null FQCN 또는 null
*/
protected function extractFqcnFromFile(string $filePath): ?string
{
$contents = @file_get_contents($filePath);
if ($contents === false) {
return null;
}
$tokens = token_get_all($contents);
$namespace = '';
$count = count($tokens);
for ($i = 0; $i < $count; $i++) {
$token = $tokens[$i];
if (! is_array($token)) {
continue;
}
// 네임스페이스 수집
if ($token[0] === T_NAMESPACE) {
$namespace = '';
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if ($t === ';' || $t === '{') {
break;
}
if (is_array($t) && in_array($t[0], [T_STRING, T_NS_SEPARATOR], true)) {
$namespace .= $t[1];
} elseif (is_array($t) && defined('T_NAME_QUALIFIED') && $t[0] === T_NAME_QUALIFIED) {
$namespace .= $t[1];
}
}
continue;
}
// 최상위 선언(class/interface/trait/enum) 감지
if (in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT], true)
|| (defined('T_ENUM') && $token[0] === T_ENUM)) {
// ::class, new class 등 익명/상수 사용 배제: 다음 유효 토큰이 T_STRING 이어야 함
for ($j = $i + 1; $j < $count; $j++) {
$t = $tokens[$j];
if (is_array($t) && $t[0] === T_WHITESPACE) {
continue;
}
if (is_array($t) && $t[0] === T_STRING) {
$class = $t[1];
return $namespace !== '' ? $namespace.'\\'.$class : $class;
}
break;
}
}
}
return null;
}
/**
* 모듈 identifier를 네임스페이스로 변환합니다.
*
@@ -640,7 +814,7 @@ PHP;
$failed = false;
$message = '';
(new \App\Rules\ValidExtensionIdentifier)->validate(
(new ValidExtensionIdentifier)->validate(
'identifier',
$identifier,
function ($msg) use (&$failed, &$message) {
@@ -718,7 +892,6 @@ PHP;
return ! empty($this->getComposerDependenciesAt($path));
}
/**
* 확장의 composer.json에서 외부 패키지 의존성 목록을 반환합니다.
*
@@ -1096,7 +1269,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1116,7 +1288,6 @@ PHP;
*
* @param string $zipPath ZIP 파일 경로
* @param string $extractDir 추출 대상 디렉토리
* @return void
*
* @throws \RuntimeException 추출 실패 시
*/
@@ -1138,7 +1309,7 @@ PHP;
/**
* unzip 명령어 사용 가능 여부를 확인합니다.
*
* @return bool
* @return bool unzip 실행 파일이 PATH 에 존재하면 true
*/
public function isUnzipAvailable(): bool
{
+632 -2
View File
@@ -2,11 +2,26 @@
namespace App\Extension\Helpers;
use FilesystemIterator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class CoreBackupHelper
{
/**
* 백업 디렉토리에 기록되는 manifest 파일명.
*/
public const NEW_FILES_MANIFEST = '_new_files_manifest.json';
/**
* Manifest 스키마 버전. DataMigration (Upgrade_7_0_0_beta_6) 의 사후 작성본과
* 바이트 단위 호환 invariant — 스키마 변경 시 양쪽 동시 갱신 필수.
*/
public const MANIFEST_SCHEMA_VERSION = 1;
/**
* 코어 파일을 선택적으로 백업합니다.
*
@@ -18,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);
@@ -51,6 +77,10 @@ class CoreBackupHelper
* 백업 파일을 복원하되 현재 파일의 퍼미션은 유지합니다.
* 개별 target 복원 실패 시에도 나머지 target 복원을 계속 진행합니다.
*
* 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 복사 직후 신 버전 신규 파일을
* 정리하는 prune 단계를 추가 실행한다. manifest 부재 시 기존 overlay 복사만 수행
* (기존 동작 유지).
*
* @param string $backupPath 백업 디렉토리 경로
* @param array $targets 복원 대상 경로 목록
* @param \Closure|null $onProgress 진행 콜백
@@ -86,6 +116,28 @@ class CoreBackupHelper
}
}
// Manifest 가 있으면 신 버전 신규 파일 prune. 부재 시 silent skip (기존 동작).
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
if (File::exists($manifestPath)) {
try {
$protected = (array) config('app.update.protected_paths', []);
$pruneResult = self::pruneNewFiles($backupPath, base_path(), $protected, $onProgress);
Log::info('코어 자동 롤백 prune 완료', [
'backup_path' => $backupPath,
'removed_files' => $pruneResult['removed_files'],
'removed_dirs' => $pruneResult['removed_dirs'],
'protected_count' => $pruneResult['protected_count'],
'symlink_skipped' => $pruneResult['symlink_skipped'],
'failed_count' => $pruneResult['failed_count'],
]);
} catch (\Throwable $e) {
Log::warning('코어 자동 롤백 prune 실패 (overlay 복사는 완료)', [
'backup_path' => $backupPath,
'error' => $e->getMessage(),
]);
}
}
if (empty($failedTargets)) {
Log::info('코어 백업 복원 완료', ['backup_path' => $backupPath]);
} else {
@@ -148,4 +200,582 @@ class CoreBackupHelper
return $backups;
}
/**
* 신 버전이 추가하는 파일/디렉토리 목록을 백업 디렉토리에 manifest 로 기록합니다.
*
* 비교 기준:
* - 백업 디렉토리 (`$backupPath/$target`) = 활성 디렉토리의 사전 스냅샷 (= 현재 디스크의 구버전)
* - 소스 디렉토리 (`$sourcePath/$target`) = 신 버전 _pending 소스
* - 신규 항목 정의 = `_pending` 에는 존재 + 백업에는 없는 파일/디렉토리
*
* 자동 롤백 시 `restoreFromBackup()` 이 본 manifest 를 참조하여 활성 디렉토리에서
* 정확히 그 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
* 있으므로 manifest 에서 제외되어 보존된다.
*
* `protectedPaths` 와 `excludes` 하위는 manifest 에서 사전 제외 (방어 깊이). `pruneNewFiles`
* 도 동일 가드를 재실행하므로 이중 방어.
*
* @param string $backupPath 백업 디렉토리 경로 (= 활성 사전 스냅샷)
* @param string $sourcePath 소스 디렉토리 경로 (= _pending 신 버전)
* @param array $targets 처리할 target 경로 목록 (`app.update.targets`)
* @param array $protectedPaths 보호 경로 목록 (manifest 에서 제외)
* @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git'])
* @param string $fromVersion 시작 버전 (manifest 기록용)
* @param string $toVersion 대상 버전 (manifest 기록용)
* @return array{new_files_count:int, new_dirs_count:int}
*/
public static function writeNewFilesManifest(
string $backupPath,
string $sourcePath,
array $targets,
array $protectedPaths,
array $excludes,
string $fromVersion,
string $toVersion,
): array {
$newFiles = [];
$newDirs = [];
$protectedSet = self::normalizeProtectedSet($protectedPaths);
$allowedTargetSet = self::normalizeProtectedSet($targets);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim($target);
if ($target === '') {
continue;
}
// target 자체가 보호 경로면 스킵 (단, targets 에 더 구체적으로 명시된
// 경로는 상위 protected 를 오버라이드 — _bundled 갱신 허용)
if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) {
continue;
}
$sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($sourceTargetPath)) {
continue;
}
// 단일 파일 target
if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) {
$backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($backupFilePath)) {
$newFiles[] = self::normalizeRelative($target);
}
continue;
}
if (! is_dir($sourceTargetPath)) {
continue;
}
// 디렉토리 target — 재귀 비교
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/'));
if (self::matchesExcludes($relative, $excludeSet)) {
continue;
}
if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) {
continue;
}
$backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
if ($item->isDir() && ! $item->isLink()) {
if (! is_dir($backupItemPath)) {
$newDirs[] = $relative;
}
} elseif ($item->isFile()) {
if (! file_exists($backupItemPath)) {
$newFiles[] = $relative;
}
}
// symlink 는 manifest 에 등재하지 않음 — prune 도 어차피 skip
}
}
sort($newFiles, SORT_STRING);
sort($newDirs, SORT_STRING);
$manifest = [
'version' => self::MANIFEST_SCHEMA_VERSION,
'created_at' => date('c'),
'from_version' => $fromVersion,
'to_version' => $toVersion,
'new_files' => $newFiles,
'new_dirs' => $newDirs,
];
File::ensureDirectoryExists($backupPath);
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
File::put($manifestPath, json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
));
Log::info('코어 신규 파일 manifest 작성 완료', [
'manifest_path' => $manifestPath,
'new_files_count' => count($newFiles),
'new_dirs_count' => count($newDirs),
'from_version' => $fromVersion,
'to_version' => $toVersion,
]);
return [
'new_files_count' => count($newFiles),
'new_dirs_count' => count($newDirs),
];
}
/**
* 코어(신 버전)가 실제로 추가·변경한 파일 목록(적용 대상)을 3-way 비교로 산출합니다.
*
* `core:update` 기본(증분) 모드에서 `applyUpdate()` 가 참조하는 "적용 대상 집합"을
* 만든다. 코어가 건드리지 않은 파일(base == theirs)은 목록에서 제외되어, 활성
* 디렉토리에 남은 현재 상태(사용자 수정 포함 가능)를 그대로 보존한다.
*
* 3-way 판정:
* - base = 구버전 원본 = 백업 스냅샷 (`$backupPath/$target`)
* - theirs = 신 버전 = _pending 소스 (`$sourcePath/$target`)
* - 규칙:
* · base 없음 (theirs 에만 존재) → added (신규 파일 — 적용)
* · base 있고 size 또는 md5 다름 → changed (코어가 변경 — 적용)
* · base 있고 size·md5 동일 → 제외 (코어 미변경 — 스킵/보존)
*
* 성능: size 가 다르면 md5 를 계산하지 않는다(즉시 changed). size 가 같을 때만
* `md5_file`. mtime 은 비교하지 않는다 — `_pending` 은 방금 추출되어 mtime 이
* 전부 추출 시각이므로 항상 "다름"으로 오판한다.
*
* symlink 는 목록에서 제외한다 — 링크 자체는 `copyDirectory` 의 별도 링크 처리
* 경로가 담당하며, 증분 모드에서도 항상 재생성되어야 한다(target 추적 없이 동일
* 링크). 따라서 반환값 `is_partial` 이 true 여도 링크는 정상 반영된다.
*
* `writeNewFilesManifest()` 와 동일한 순회 구조·가드(`excludes`/`protectedPaths`)를
* 재사용한다. 두 산출을 각각 별도 순회하지만, 반환값은 in-memory 로 즉시 소비되어
* 파일화 비용이 없다(롤백용 `_new_files_manifest.json` 은 별도).
*
* @param string $backupPath 백업 디렉토리 경로 (= base, 활성 사전 스냅샷)
* @param string $sourcePath 소스 디렉토리 경로 (= theirs, _pending 신 버전)
* @param array $targets 처리할 target 경로 목록 (`app.update.targets`)
* @param array $protectedPaths 보호 경로 목록 (목록에서 제외)
* @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git'])
* @return array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}
* apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화)
*/
public static function computeApplyList(
string $backupPath,
string $sourcePath,
array $targets,
array $protectedPaths,
array $excludes,
): array {
$added = [];
$changed = [];
$hasSymlink = false;
$protectedSet = self::normalizeProtectedSet($protectedPaths);
$allowedTargetSet = self::normalizeProtectedSet($targets);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim($target);
if ($target === '') {
continue;
}
if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) {
continue;
}
$sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($sourceTargetPath)) {
continue;
}
// 단일 파일 target
if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) {
$rel = self::normalizeRelative($target);
$backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target;
self::classifyForApply($rel, $sourceTargetPath, $backupFilePath, $added, $changed);
continue;
}
if (is_link($sourceTargetPath)) {
$hasSymlink = true;
continue;
}
if (! is_dir($sourceTargetPath)) {
continue;
}
// 디렉토리 target — 재귀 비교
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/'));
if (self::matchesExcludes($relative, $excludeSet)) {
continue;
}
if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) {
continue;
}
// symlink 는 목록 제외 — 링크 처리 경로가 별도 담당
if ($item->isLink()) {
$hasSymlink = true;
continue;
}
// 디렉토리 자체는 목록에 담지 않는다 — 파일 단위 적용이 디렉토리를
// 필요 시 생성한다(copyDirectory 가 대상 디렉토리를 ensure).
if (! $item->isFile()) {
continue;
}
$backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
self::classifyForApply($relative, $absolute, $backupItemPath, $added, $changed);
}
}
$apply = array_values(array_unique(array_merge($added, $changed)));
sort($apply, SORT_STRING);
return [
'apply' => $apply,
'added_count' => count($added),
'changed_count' => count($changed),
'has_symlink' => $hasSymlink,
];
}
/**
* 3-way 판정으로 파일 하나를 added/changed 로 분류하거나 제외합니다.
*
* base(백업) 부재 → added, size 다름 또는 md5 다름 → changed, 동일 → 제외.
*
* @param string $relative 슬래시 정규화된 상대 경로 (목록 등재값)
* @param string $theirsAbsolute 신 버전(_pending) 파일 절대 경로
* @param string $baseAbsolute 백업(base) 파일 절대 경로
* @param array<int,string> $added added 누적 배열 (참조)
* @param array<int,string> $changed changed 누적 배열 (참조)
*/
private static function classifyForApply(
string $relative,
string $theirsAbsolute,
string $baseAbsolute,
array &$added,
array &$changed,
): void {
if (! file_exists($baseAbsolute) || is_dir($baseAbsolute)) {
$added[] = $relative;
return;
}
// size 선필터 — 다르면 md5 생략하고 즉시 changed
$theirsSize = @filesize($theirsAbsolute);
$baseSize = @filesize($baseAbsolute);
if ($theirsSize !== $baseSize) {
$changed[] = $relative;
return;
}
// size 동일 → 내용 비교
if (@md5_file($theirsAbsolute) !== @md5_file($baseAbsolute)) {
$changed[] = $relative;
}
// 동일하면 제외 (코어 미변경 — 스킵)
}
/**
* 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다.
*
* 안전 가드:
* - symlink 는 manifest 에 있더라도 무조건 skip (`public/storage` 등 운영 데이터 보호)
* - protected_paths 하위 항목은 prune 시점에 재검증되어 skip (이중 방어)
* - manifest 부재/JSON 파싱 실패 시 noop + manifest_loaded:false 반환
* - 디렉토리는 깊이 역순으로 rmdir 시도. 빈 디렉토리만 제거 (사용자 파일이 있으면 유지)
* - 개별 항목 삭제 실패는 fatal 이 아닌 warning + failed_count 누적
*
* @param string $backupPath 백업 디렉토리 경로 (manifest 위치)
* @param string $activePath 활성(= 정리 대상) 디렉토리 경로
* @param array $protectedPaths 보호 경로 목록 (이중 가드)
* @param \Closure|null $onProgress 진행 콜백 (현재 미사용 — 호출 시그니처 호환용)
* @return array{removed_files:int, removed_dirs:int, protected_count:int, symlink_skipped:int, failed_count:int, manifest_loaded:bool}
*/
public static function pruneNewFiles(
string $backupPath,
string $activePath,
array $protectedPaths,
?\Closure $onProgress = null,
): array {
$result = [
'removed_files' => 0,
'removed_dirs' => 0,
'protected_count' => 0,
'symlink_skipped' => 0,
'failed_count' => 0,
'manifest_loaded' => false,
];
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
if (! File::exists($manifestPath)) {
return $result;
}
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
Log::warning('코어 prune: manifest 읽기 실패 — skip', ['path' => $manifestPath]);
return $result;
}
$manifest = json_decode($raw, true);
if (! is_array($manifest) || ! isset($manifest['new_files']) || ! isset($manifest['new_dirs'])) {
Log::warning('코어 prune: manifest JSON 파싱 실패 — skip', ['path' => $manifestPath]);
return $result;
}
$result['manifest_loaded'] = true;
$protectedSet = self::normalizeProtectedSet($protectedPaths);
// 신규 파일 정리
foreach ((array) $manifest['new_files'] as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$rel = self::normalizeRelative($rel);
if (self::isWithinProtectedPath($rel, $protectedSet)) {
$result['protected_count']++;
continue;
}
$absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (is_link($absolute)) {
$result['symlink_skipped']++;
continue;
}
if (! file_exists($absolute)) {
continue; // 이미 부재 — skip
}
if (is_dir($absolute) && ! is_link($absolute)) {
// 파일로 등재되었으나 실제는 디렉토리 — 안전 우선, skip
continue;
}
if (@unlink($absolute)) {
$result['removed_files']++;
} else {
$result['failed_count']++;
Log::warning('코어 prune: 파일 삭제 실패', ['path' => $absolute]);
}
}
// 신규 디렉토리 정리 — 깊이 역순으로 빈 디렉토리만 rmdir
$dirs = array_values(array_filter((array) $manifest['new_dirs'], 'is_string'));
usort($dirs, fn ($a, $b) => substr_count($b, '/') <=> substr_count($a, '/'));
foreach ($dirs as $rel) {
$rel = self::normalizeRelative($rel);
if (self::isWithinProtectedPath($rel, $protectedSet)) {
$result['protected_count']++;
continue;
}
$absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (is_link($absolute)) {
$result['symlink_skipped']++;
continue;
}
if (! is_dir($absolute)) {
continue;
}
// 빈 디렉토리만 rmdir — 사용자 파일이 남아있으면 유지
if (self::isEmptyDirectory($absolute)) {
if (@rmdir($absolute)) {
$result['removed_dirs']++;
} else {
$result['failed_count']++;
Log::warning('코어 prune: 디렉토리 삭제 실패', ['path' => $absolute]);
}
}
}
return $result;
}
/**
* protected_paths 배열을 정규화된 비교 집합으로 변환합니다.
*
* 슬래시 정규화 + 좌우 공백 제거 + 빈 항목 제거 + 최상위/하위 prefix 매칭에 사용.
*
* @param array<int, string> $paths
* @return array<int, string>
*/
private static function normalizeProtectedSet(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string) $p);
if ($p === '') {
continue;
}
$out[] = self::normalizeRelative($p);
}
return $out;
}
/**
* 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다.
*
* `$allowedTargetSet` 이 주어지면 "targets 에 명시된 더 구체적인 경로" 는 상위
* protected 를 오버라이드한다. 예: `protected_paths` 에 `modules` 가 있고
* `targets` 에 `modules/_bundled` 가 명시되어 있으면, `modules/_bundled/...` 파일은
* protected 로 차단되지 않고 apply 대상에 포함된다.
*
* 배경: `protected_paths` 의 확장 부모(`modules`/`plugins`/`templates`/`lang-packs`)는
* "자동 발견 폴백" 이 활성 서브디렉토리(`modules/sirsoft-*`)를 통째로 삭제하는
* 회귀(#347) 를 막기 위한 방어인데, 정상 증분 흐름에서는 `_bundled` 갱신까지
* 함께 막아 코어 업데이트로 번들 확장 파일이 반영되지 않는 결함(#452 / 공개 #64)을
* 유발했다. targets 에 명시된 경로가 protected 보다 더 구체적(하위)이면 예외 처리한다.
*
* @param string $relative 검사할 상대 경로
* @param array<int, string> $protectedSet 정규화된 보호 경로 집합
* @param array<int, string> $allowedTargetSet 정규화된 targets 집합 (protected 오버라이드 허용)
*/
private static function isWithinProtectedPath(string $relative, array $protectedSet, array $allowedTargetSet = []): bool
{
$relative = self::normalizeRelative($relative);
// 가장 구체적으로 매칭되는 protected 경로 길이(세그먼트 수)를 찾는다.
$matchedProtectedLen = -1;
foreach ($protectedSet as $p) {
if ($p === '') {
continue;
}
if ($relative === $p || str_starts_with($relative, $p.'/')) {
$len = substr_count($p, '/') + 1;
if ($len > $matchedProtectedLen) {
$matchedProtectedLen = $len;
}
}
}
if ($matchedProtectedLen === -1) {
return false;
}
// protected 하위이지만, targets 에 더 구체적으로(= 더 깊게) 명시된 경로의
// 하위이면 오버라이드하여 통과(= 보호 해제). target 이 protected 와 같거나
// 더 얕으면 오버라이드하지 않는다(예: target `storage` == protected `storage`).
foreach ($allowedTargetSet as $t) {
if ($t === '') {
continue;
}
$targetLen = substr_count($t, '/') + 1;
if ($targetLen <= $matchedProtectedLen) {
continue;
}
if ($relative === $t || str_starts_with($relative, $t.'/')) {
return false;
}
}
return true;
}
/**
* 상대 경로가 excludes 패턴(이름 또는 슬래시 포함 경로) 에 매칭되는지 검사합니다.
*
* 단순 이름(슬래시 미포함) 은 경로의 어떤 세그먼트와도 매칭 — `node_modules` 처럼
* 깊이 무관 제외 패턴 의도와 일치.
*/
private static function matchesExcludes(string $relative, array $excludes): bool
{
$segments = explode('/', $relative);
foreach ($excludes as $exclude) {
if ($exclude === '') {
continue;
}
if (str_contains($exclude, '/')) {
if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) {
return true;
}
} else {
if (in_array($exclude, $segments, true)) {
return true;
}
}
}
return false;
}
/**
* 경로 문자열을 슬래시 정규화 + 좌측 슬래시 제거.
*/
private static function normalizeRelative(string $path): string
{
$p = str_replace('\\', '/', $path);
$p = ltrim($p, '/');
// 연속 슬래시 제거
while (str_contains($p, '//')) {
$p = str_replace('//', '/', $p);
}
return $p;
}
/**
* 디렉토리가 비어있는지 검사 (`.` `..` 제외).
*/
private static function isEmptyDirectory(string $path): bool
{
if (! is_dir($path)) {
return true;
}
$entries = @scandir($path);
if ($entries === false) {
return false;
}
return count(array_diff($entries, ['.', '..'])) === 0;
}
}
@@ -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();
}
+148 -62
View File
@@ -16,20 +16,29 @@ 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 보존 여부
* @param array<string, bool>|null $applyList 적용 대상 파일 상대경로 화이트리스트
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
*/
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, ?array $applyList = null): void
{
if (! File::isDirectory($destination)) {
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
@@ -49,50 +58,104 @@ class FilePermissionHelper
$destPath = $destination.DIRECTORY_SEPARATOR.$itemName;
// symlink 자체 보존: target 추적 없이 동일 symlink 재생성.
// symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성.
// SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다.
// 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
if ($item->isLink()) {
if (static::copySymlink($item->getPathname(), $destPath)) {
// 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상.
//
// Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두
// false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면
// junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다.
// isReparsePoint() 로 junction 도 이 분기에 포함시킨다.
if ($item->isLink() || static::isReparsePoint($item->getPathname())) {
if (static::copyLink($item->getPathname(), $destPath)) {
continue;
}
// 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through.
// 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가
// 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다.
// 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능.
if (! $item->isLink()) {
continue;
}
}
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, applyList: $applyList);
} else {
// 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 —
// 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존.
if ($applyList !== null && ! isset($applyList[$itemRelativePath])) {
continue;
}
static::copyFile($item->getPathname(), $destPath);
}
}
// 소스에 없는 대상 파일/디렉토리 삭제
if ($removeOrphans && File::isDirectory($destination)) {
static::removeOrphanItems($source, $destination, $excludes, $relativePath);
static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans);
}
}
/**
* symlink 자체를 보존 복사합니다 (target 미추적).
* 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다.
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성.
* readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
* Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` /
* `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로
* target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는
* `public/storage` 가 이 형태다.
*
* Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는
* 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로
* fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동
* 명령으로 symlink 를 회복할 수 있다.
* 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction
* 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음"
* 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지
* 않는 경로를 배제한다.
*
* @param string $source 소스 symlink 경로
* @param string $destination 대상 symlink 경로
* @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
* 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다.
* junction 은 Windows 전용이므로 비-Windows 에서는 항상 false.
*
* @param string $path 검사할 경로
* @return bool junction 등 isLink() 미인식 reparse point 여부
*/
protected static function copySymlink(string $source, string $destination): bool
protected static function isReparsePoint(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return false;
}
// 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님.
if (is_link($path) || is_file($path) || is_dir($path)) {
return false;
}
// 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point).
return @readlink($path) !== false;
}
/**
* symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적).
*
* 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성.
* readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through.
*
* 재생성 전략:
* - 일반 symlink: `symlink()`
* - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백.
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은
* 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다.
*
* 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후
* `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다.
*
* @param string $source 소스 링크(symlink/junction) 경로
* @param string $destination 대상 링크 경로
* @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백)
*/
protected static function copyLink(string $source, string $destination): bool
{
$target = @readlink($source);
if ($target === false) {
Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]);
return false;
}
@@ -104,17 +167,29 @@ class FilePermissionHelper
File::deleteDirectory($destination);
}
if (! @symlink($target, $destination)) {
Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
if (@symlink($target, $destination)) {
return true;
}
return true;
// symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성.
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
exec(
'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1',
$out,
$code
);
if ($code === 0 && is_dir($destination)) {
return true;
}
}
Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [
'source' => $source,
'destination' => $destination,
'target' => $target,
]);
return false;
}
/**
@@ -122,14 +197,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) {
@@ -145,11 +227,19 @@ class FilePermissionHelper
// 소스에 존재하지 않는 항목만 삭제
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
// symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후
// 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고
// 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다.
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
//
// junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크
// 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다.
if (is_link($destItem->getPathname())) {
@unlink($destItem->getPathname());
} elseif (static::isReparsePoint($destItem->getPathname())) {
// junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도.
if (! @rmdir($destItem->getPathname())) {
@unlink($destItem->getPathname());
}
} elseif ($destItem->isDir()) {
File::deleteDirectory($destItem->getPathname());
} else {
@@ -165,9 +255,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 +289,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 +328,7 @@ class FilePermissionHelper
/**
* 부모 디렉토리의 퍼미션·소유자·그룹을 상속하여 신규 디렉토리를 생성합니다.
*
* @param string $path 생성할 디렉토리 경로
* @return void
* @param string $path 생성할 디렉토리 경로
*/
protected static function createDirectoryInheritingParent(string $path): void
{
@@ -262,7 +350,6 @@ class FilePermissionHelper
* 외부 호출처(예: `SettingsMigrator::writeJsonFile`) 가 직접 호출 가능하도록 public.
*
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
* @return void
*/
public static function inheritOwnershipFromParent(string $path): void
{
@@ -277,10 +364,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 +389,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 +431,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 +507,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();
}
+320
View File
@@ -0,0 +1,320 @@
<?php
namespace App\Extension;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\PluginInterface;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 정적 훅 매핑 캐시 관리자.
*
* 코어(app/Listeners 재귀 스캔) + 모듈/플러그인(getHookListeners)의 정적 훅 리스너를
* 사전 계산하여 bootstrap/cache/hooks.php 에 저장하고, 부팅 시 이 캐시를 읽어
* 매 요청 디렉토리 스캔·리플렉션·getSubscribedHooks() 클래스 로딩을 제거한다.
*
* 캐시는 "무엇을 등록할지 목록"만 제공하며, 실제 등록은 여전히 boot 에서 수행되므로
* 등록↔발화 순서 계약은 불변이다. 캐시 미스(파일 부재/설치 직후/캐시 클리어 후)는
* 항상 스캔 폴백으로 안전하게 동작한다.
*
* 오토로드 캐시(autoload-extensions.php)와 동일 위치·생명주기를 갖는다:
* 확장 install/activate/deactivate/uninstall/update 시
* ExtensionManager::updateComposerAutoload() 가 함께 재생성한다.
*
* @see HookListenerRegistrar::registerFromCache()
*/
class HookCacheManager
{
/**
* 캐시 파일 경로.
*/
protected string $cacheFilePath;
/**
* Request-scoped 메모리 캐시 (read() 결과).
*
* HookCacheManager 는 싱글톤이 아니라 부팅 경로에서 매번 새 인스턴스로
* 생성되므로(app(HookCacheManager::class)), 요청 단위 캐시는 static 이어야 한다.
* 세 상태를 구분한다:
* - null = 아직 read() 전 (미초기화). PHP static 프로퍼티는 unset() 불가하므로
* 무효화(generate/clear)는 이 값으로 되돌린다.
* - false = 이번 요청 캐시 미스 확정 (파일 부재/손상) — 재시도 없이 폴백 유지.
* - array = 유효 캐시 데이터.
*
* @var array{core: array, modules: array, plugins: array}|false|null
*/
private static array|false|null $memo = null;
public function __construct()
{
$this->cacheFilePath = base_path('bootstrap/cache/hooks.php');
}
/**
* 캐시 파일 경로를 반환합니다.
*
* @return string bootstrap/cache/hooks.php 절대 경로
*/
public function getCacheFilePath(): string
{
return $this->cacheFilePath;
}
/**
* 캐시 파일 존재 여부를 반환합니다.
*
* @return bool 캐시 파일이 존재하면 true
*/
public function isCached(): bool
{
return File::exists($this->cacheFilePath);
}
/**
* 캐시 파일을 읽어 반환합니다.
*
* 파일이 없거나 구조가 손상된 경우 null 을 반환하여 호출측이 스캔 폴백하도록 한다.
*
* @return array{core: array<int, array<string, mixed>>, modules: array<string, array<int, array<string, mixed>>>, plugins: array<string, array<int, array<string, mixed>>>}|null
*/
public function read(): ?array
{
// Request-scoped 메모리 캐시: 한 요청 동안 부팅 경로에서
// core/module/plugin 등록이 이 메서드를 여러 번 호출한다
// (호출당 86KB 캐시 파일 재파싱 — OPcache 부재 시 요청당 십수 ms 누적).
// 캐시 파일 내용은 요청 수명 동안 불변이므로 첫 로드 결과를 static 에 보관해
// 파일 접근을 요청당 1회로 고정한다. self::$memo 는 세 상태를 구분한다:
// - 미설정(unset): 아직 read() 호출 전
// - false: 이번 요청에서 캐시 미스(파일 부재/손상) 확정 — 재시도 없이 폴백 유지
// - array: 유효 캐시 데이터
if (self::$memo !== null) {
return self::$memo === false ? null : self::$memo;
}
if (! $this->isCached()) {
self::$memo = false;
return null;
}
try {
$data = require $this->cacheFilePath;
} catch (\Throwable $e) {
Log::warning('훅 캐시 파일 로드 실패 — 스캔 폴백', [
'path' => $this->cacheFilePath,
'error' => $e->getMessage(),
]);
self::$memo = false;
return null;
}
if (! is_array($data) || ! isset($data['core'], $data['modules'], $data['plugins'])) {
Log::warning('훅 캐시 파일 구조가 올바르지 않습니다 — 스캔 폴백', [
'path' => $this->cacheFilePath,
]);
self::$memo = false;
return null;
}
self::$memo = $data;
return $data;
}
/**
* 캐시 파일을 삭제합니다.
*
* @return bool 삭제 성공 또는 파일 부재 시 true
*/
public function clear(): bool
{
self::$memo = null;
if (! $this->isCached()) {
return true;
}
return File::delete($this->cacheFilePath);
}
/**
* 정적 훅 매핑을 스캔·리플렉션하여 캐시 파일로 생성합니다.
*
* 모듈/플러그인 리스너는 로드된 Module/Plugin 인스턴스의 getHookListeners() 에서
* 수집하므로, 호출 전에 각 Manager 의 loadModules()/loadPlugins() 가 선행되어야 한다.
*
* @param ModuleManager $moduleManager 로드 완료된 모듈 매니저
* @param PluginManager $pluginManager 로드 완료된 플러그인 매니저
* @return array{core: int, modules: int, plugins: int} 소스별 리스너 수
*/
public function generate(ModuleManager $moduleManager, PluginManager $pluginManager): array
{
$cache = [
'core' => $this->collectCoreListeners(),
'modules' => $this->collectExtensionListeners($moduleManager->getActiveModules()),
'plugins' => $this->collectExtensionListeners($pluginManager->getActivePlugins()),
];
$content = $this->buildCacheFileContent($cache);
$dir = dirname($this->cacheFilePath);
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
File::put($this->cacheFilePath, $content);
// 방금 쓴 새 매핑으로 request-scoped 메모리 캐시를 갱신
// (이후 read() 가 옛 memo 를 반환하지 않도록 무효화).
self::$memo = null;
$counts = [
'core' => count($cache['core']),
'modules' => array_sum(array_map('count', $cache['modules'])),
'plugins' => array_sum(array_map('count', $cache['plugins'])),
];
Log::info('훅 매핑 캐시 파일 생성 완료', [
'path' => $this->cacheFilePath,
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
]);
return $counts;
}
/**
* app/Listeners/ 재귀 스캔으로 코어 리스너 항목을 수집합니다.
*
* registerCoreHookListeners() 스캔 로직과 동일하게 HookListenerInterface 구현체만 대상.
*
* @return array<int, array<string, mixed>> [['listener' => FQCN, 'hooks' => [...], 'dynamic' => bool], ...]
*/
protected function collectCoreListeners(): array
{
$listenersPath = app_path('Listeners');
if (! is_dir($listenersPath)) {
return [];
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
$entries = [];
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$listenerClass = 'App\\Listeners\\'.$relativePath;
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* 로드된 모듈/플러그인 인스턴스에서 훅 리스너 항목을 수집합니다.
*
* @param array<string, ModuleInterface|PluginInterface> $extensions 식별자(디렉토리명) => 확장 인스턴스
* @return array<string, array<int, array<string, mixed>>> 확장 식별자 => 리스너 항목 목록
*/
protected function collectExtensionListeners(array $extensions): array
{
$result = [];
foreach ($extensions as $extension) {
if (! method_exists($extension, 'getHookListeners')) {
continue;
}
$identifier = $extension->getIdentifier();
$entries = [];
foreach ($extension->getHookListeners() as $listenerClass) {
$entry = $this->buildListenerEntry($listenerClass);
if ($entry !== null) {
$entries[] = $entry;
}
}
if (! empty($entries)) {
$result[$identifier] = $entries;
}
}
return $result;
}
/**
* 단일 리스너 클래스의 캐시 항목을 생성합니다.
*
* HookListenerInterface 미구현·클래스 부재 시 null 을 반환하여 제외합니다.
* 사전 계산된 getSubscribedHooks() 결과와 동적 훅 여부(registerDynamicHooks 존재)를 함께 저장합니다.
*
* @param string $listenerClass 리스너 FQCN
* @return array{listener: string, hooks: array<string, array<string, mixed>>, dynamic: bool}|null
*/
protected function buildListenerEntry(string $listenerClass): ?array
{
if (! class_exists($listenerClass)) {
Log::warning('훅 캐시 생성: 리스너 클래스를 찾을 수 없습니다', ['listener' => $listenerClass]);
return null;
}
if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) {
return null;
}
try {
$hooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
Log::warning('훅 캐시 생성: getSubscribedHooks() 오류 — 항목 제외', [
'listener' => $listenerClass,
'error' => $e->getMessage(),
]);
return null;
}
return [
'listener' => $listenerClass,
'hooks' => $hooks,
'dynamic' => method_exists($listenerClass, 'registerDynamicHooks'),
];
}
protected function buildCacheFileContent(array $cache): string
{
$exported = var_export($cache, true);
$generatedAt = now()->toDateTimeString();
$header = "<?php\n\n"
."/**\n"
." * 정적 훅 매핑 캐시\n"
." *\n"
." * 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.\n"
." * Generated at: {$generatedAt}\n"
." *\n"
." * @see \\App\\Extension\\HookCacheManager::generate()\n"
." */\n\n";
return $header."return {$exported};\n";
}
}
+117 -21
View File
@@ -27,18 +27,19 @@ class HookListenerRegistrar
*
* 모듈 install/uninstall 시나리오에서 재등록이 필요하면 clear() 사용.
*
* @var array<string, true> key: "{source}::{listenerClass}"
* @var array<string, true> key: "{source}::{listenerClass}"
*/
private static array $registered = [];
/**
* 리스너 클래스를 HookManager에 등록합니다.
*
* 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 런타임에 $listenerClass::getSubscribedHooks() 를 호출해 구독 훅을 조회한 뒤
* 등록한다. 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
* 사전 계산된 캐시 매핑으로 등록하려면 registerFromCache() 를 사용한다.
*
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
* @return void
*/
public static function register(string $listenerClass, ?string $source = null): void
{
@@ -46,14 +47,10 @@ class HookListenerRegistrar
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지
}
self::$registered[$key] = true;
try {
$subscribedHooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
// 실패 시 캐시 롤백하여 재시도 가능 상태 유지
unset(self::$registered[$key]);
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
'listener' => $listenerClass,
'source' => $source,
@@ -63,6 +60,49 @@ class HookListenerRegistrar
return;
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 캐시된 구독 훅 매핑으로 리스너를 등록합니다 (클래스 로딩 없음).
*
* bootstrap/cache/hooks.php 에서 읽어온 사전 계산 getSubscribedHooks() 결과를
* 그대로 받아 HookManager 에 등록한다. register() 와 달리 $listenerClass::getSubscribedHooks()
* 를 호출하지 않으므로 리스너 클래스가 부팅 시점에 오토로딩되지 않는다 —
* 실제 클래스 로딩은 훅 발화 시 클로저 내부 app($listenerClass) 에서 지연 수행된다.
*
* 등록 결과(HookManager 훅 매핑)는 register() 스캔 경로와 바이트 동일해야 한다
* (동일 applySubscribedHooks 위임).
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 사전 계산된 getSubscribedHooks() 결과
* @param string|null $source 등록 출처 ('core', 모듈/플러그인 식별자)
*/
public static function registerFromCache(string $listenerClass, array $subscribedHooks, ?string $source = null): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지 (스캔 경로와 동일 계약)
}
self::applySubscribedHooks($listenerClass, $subscribedHooks, $source);
}
/**
* 구독 훅 배열을 HookManager 에 등록합니다 (스캔/캐시 경로 공통).
*
* register()(런타임 getSubscribedHooks 호출)와 registerFromCache()(캐시 읽기)가
* 이 메서드에 위임하여 등록 결과의 동일성을 보장한다.
*
* @param string $listenerClass 리스너 FQCN
* @param array<string, array<string, mixed>> $subscribedHooks 구독 훅 매핑
* @param string|null $source 등록 출처
*/
private static function applySubscribedHooks(string $listenerClass, array $subscribedHooks, ?string $source): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
self::$registered[$key] = true;
foreach ($subscribedHooks as $hookName => $config) {
$method = $config['method'] ?? 'handle';
$priority = $config['priority'] ?? 10;
@@ -80,18 +120,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 +136,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
{
+87 -27
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
{
@@ -2951,13 +2961,6 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 모듈의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 모듈의 훅 리스너는 등록하지 않습니다.
*
* @param ModuleInterface $module 모듈 인스턴스
*/
protected function registerModuleHookListeners(ModuleInterface $module): void
{
// 모듈이 getHookListeners 메서드를 가지고 있는지 확인
@@ -2971,6 +2974,11 @@ class ModuleManager implements ModuleManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('modules', $module->getIdentifier())) {
return;
}
$listeners = $module->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -3000,6 +3008,44 @@ class ModuleManager implements ModuleManagerInterface
}
}
/**
* 훅 캐시에서 확장(모듈/플러그인)의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 확장 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('modules' 또는 'plugins')
* @param string $identifier 확장 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 모듈의 브로드캐스트 채널을 등록합니다.
*
@@ -3274,7 +3320,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 +3492,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 +3520,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 +4079,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 +4134,7 @@ class ModuleManager implements ModuleManagerInterface
{
$layouts = $this->layoutRepository->getBySourceIdentifier(
$identifier,
\App\Enums\LayoutSourceType::Module,
LayoutSourceType::Module,
);
$modifiedLayouts = $layouts->filter(function ($layout) {
@@ -4146,8 +4197,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 +4401,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,
+99 -38
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();
@@ -2791,13 +2802,6 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 플러그인의 훅 리스너를 자동으로 등록합니다.
*
* 비활성화된 플러그인의 훅 리스너는 등록하지 않습니다.
*
* @param PluginInterface $plugin 플러그인 인스턴스
*/
protected function registerPluginHookListeners(PluginInterface $plugin): void
{
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
@@ -2806,6 +2810,11 @@ class PluginManager implements PluginManagerInterface
return;
}
// 캐시 우선: 사전 계산된 훅 매핑이 있으면 클래스 로딩 없이 등록.
if ($this->registerExtensionHookListenersFromCache('plugins', $plugin->getIdentifier())) {
return;
}
$listeners = $plugin->getHookListeners();
foreach ($listeners as $listenerClass) {
@@ -2835,6 +2844,44 @@ class PluginManager implements PluginManagerInterface
}
}
/**
* 훅 캐시에서 플러그인의 정적 훅 리스너를 등록합니다.
*
* 캐시 파일(bootstrap/cache/hooks.php)에 해당 플러그인 식별자의 항목이 있으면
* getSubscribedHooks() 클래스 로딩·리플렉션 없이 사전 계산 매핑으로 등록한다.
* 테스트 환경은 매 setUp 스캔이 정확·격리 우선이므로 캐시 미사용(항상 스캔 폴백).
*
* @param string $bucket 캐시 버킷 ('plugins')
* @param string $identifier 플러그인 식별자
* @return bool 캐시로 등록했으면 true, 캐시 부재/해당 항목 없음/테스트 환경이면 false (스캔 폴백)
*/
protected function registerExtensionHookListenersFromCache(string $bucket, string $identifier): bool
{
if (app()->environment('testing')) {
return false;
}
$cache = app(HookCacheManager::class)->read();
if ($cache === null || ! isset($cache[$bucket][$identifier])) {
return false;
}
foreach ($cache[$bucket][$identifier] as $entry) {
try {
HookListenerRegistrar::registerFromCache($entry['listener'], $entry['hooks'], $identifier);
} catch (\Throwable $e) {
Log::error('확장 훅 리스너 캐시 등록 중 오류 발생', [
'listener' => $entry['listener'] ?? null,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
}
}
return true;
}
/**
* 플러그인의 브로드캐스트 채널을 등록합니다.
*
@@ -3758,7 +3805,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 +3833,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 +4257,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 +4314,7 @@ class PluginManager implements PluginManagerInterface
{
$layouts = $this->layoutRepository->getBySourceIdentifier(
$identifier,
\App\Enums\LayoutSourceType::Plugin,
LayoutSourceType::Plugin,
);
$modifiedLayouts = $layouts->filter(function ($layout) {
@@ -4325,8 +4377,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 +4581,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,
);
}
+33 -5
View File
@@ -98,12 +98,13 @@ class EnvironmentDetector
}
// composer.phar 폴백
// @is_file 로 open_basedir warning 억제 (BASE_PATH/getcwd 가 화이트리스트 밖일 가능성)
$pharCandidates = [
base_path('composer.phar'),
getcwd().DIRECTORY_SEPARATOR.'composer.phar',
];
foreach ($pharCandidates as $phar) {
if (is_file($phar)) {
if (@is_file($phar)) {
return $this->cachedComposerBinary = $phar;
}
}
@@ -275,6 +276,11 @@ class EnvironmentDetector
/**
* PATH 환경변수에서 composer 검색.
*
* stat (is_file) 은 open_basedir 같은 PHP 런타임 제약 환경에서 false negative
* 를 일으키므로 보조 신호로만 쓴다. stat 통과 후보가 있으면 우선 반환하고,
* 그렇지 않은 경우 메타문자 없는 첫 후보를 반환해 canExecuteComposer 의
* proc_open 결과로 최종 판정한다.
*/
private function searchComposerInPath(): ?string
{
@@ -289,20 +295,35 @@ class EnvironmentDetector
? ['composer.bat', 'composer.exe', 'composer.phar', 'composer']
: ['composer', 'composer.phar'];
$fallback = null;
foreach ($paths as $dir) {
foreach ($names as $name) {
$candidate = rtrim($dir, '/\\').DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
// stat 통과 후보가 있으면 우선 반환 (가장 신뢰도 높음)
if (@is_file($candidate)) {
return $candidate;
}
// stat 실패 후보는 첫 번째만 fallback 으로 보관.
// open_basedir 환경에서는 정상 binary 도 is_file false 가 되므로
// proc_open 결과로 최종 판정할 기회를 남긴다.
if ($fallback === null) {
$fallback = $candidate;
}
}
}
return null;
return $fallback;
}
/**
* 후보 경로가 실행 가능한 파일인지 확인.
*
* stat (is_file) 의존을 제거해 open_basedir 환경의 false negative 를 피한다.
* 단일 토큰의 셸 메타문자만 차단하고, 실제 실행 가능 여부는 canExecuteComposer
* 의 proc_open 결과로 최종 판정한다.
*/
private function isExecutableCandidate(?string $candidate): bool
{
@@ -310,11 +331,18 @@ class EnvironmentDetector
return false;
}
// 공백 포함 시 전체 커맨드로 간주 — 파일 존재 검사 스킵
// 공백 포함 시 전체 커맨드로 간주 — 외부 신뢰된 config/.env 값을 그대로 사용.
// buildComposerCommand 의 공백 분기와 동일한 신뢰 모델 (운영자 자기 책임 영역).
if (str_contains($candidate, ' ')) {
return true;
}
return is_file($candidate);
// 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
// 셸 인젝션 차단은 호출자의 escapeshellarg/buildComposerCommand 가 담당.
if (preg_match('/[;`$|<>"\'&\x00-\x1F]/', $candidate)) {
return false;
}
return true;
}
}
+104 -95
View File
@@ -9,14 +9,23 @@ use Illuminate\Support\Facades\App;
class ResponseHelper
{
/**
* JSON 응답 인코딩 옵션.
*
* JSON_UNESCAPED_UNICODE: 한글 등 멀티바이트 문자를 `\uXXXX` (6바이트) 로
* 이스케이프하지 않고 raw UTF-8 (한글 3바이트) 로 직렬화하여 응답 크기를 줄인다.
* 특히 다국어 팩(lang) 응답에서 전송량 절감 효과가 크다.
*/
public const JSON_ENCODE_OPTIONS = JSON_UNESCAPED_UNICODE;
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function success(
@@ -29,18 +38,18 @@ class ResponseHelper
return response()->json([
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data
], $statusCode);
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function error(
@@ -59,28 +68,28 @@ class ResponseHelper
if (config('app.debug')) {
// messageParams에 이미 에러 메시지가 포함된 경우 중복 방지
if (empty($messageParams)) {
$response['message'] .= ': ' . $errors->getMessage();
$response['message'] .= ': '.$errors->getMessage();
}
$response['debug'] = self::formatException($errors);
}
} elseif ($errors !== null) {
if ($statusCode >= 500 && is_string($errors) && !config('app.debug')) {
if ($statusCode >= 500 && is_string($errors) && ! config('app.debug')) {
// 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지)
} else {
$response['errors'] = $errors;
}
}
return response()->json($response, $statusCode);
return response()->json($response, $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 입력 검증 실패 응답을 생성합니다.
*
* @param mixed $errors 검증 오류 정보
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param mixed $errors 검증 오류 정보
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 422 상태 코드를 가진 JSON 응답
*/
public static function validationError(
@@ -92,16 +101,16 @@ class ResponseHelper
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain),
'errors' => $errors
], 422);
'errors' => $errors,
], 422, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 인증 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 401 상태 코드를 가진 JSON 응답
*/
public static function unauthorized(
@@ -111,16 +120,16 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 401);
'message' => self::trans($messageKey, $messageParams, $domain),
], 401, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 권한 부족 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 403 상태 코드를 가진 JSON 응답
*/
public static function forbidden(
@@ -130,16 +139,16 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 403);
'message' => self::trans($messageKey, $messageParams, $domain),
], 403, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 리소스를 찾을 수 없음 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 404 상태 코드를 가진 JSON 응답
*/
public static function notFound(
@@ -149,17 +158,17 @@ class ResponseHelper
): JsonResponse {
return response()->json([
'success' => false,
'message' => self::trans($messageKey, $messageParams, $domain)
], 404);
'message' => self::trans($messageKey, $messageParams, $domain),
], 404, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 서버 내부 오류 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 500 상태 코드를 가진 JSON 응답
*/
public static function serverError(
@@ -175,22 +184,22 @@ class ResponseHelper
if ($error instanceof \Throwable) {
if (config('app.debug')) {
$response['message'] .= ': ' . $error->getMessage();
$response['message'] .= ': '.$error->getMessage();
$response['debug'] = self::formatException($error);
}
} elseif ($error !== null && config('app.debug')) {
$response['error'] = $error;
}
return response()->json($response, 500);
return response()->json($response, 500, [], self::JSON_ENCODE_OPTIONS);
}
/**
* 다국어 메시지를 변환합니다.
*
* @param string $key 번역 키
* @param array $params 번역 매개변수
* @param string $domain 번역 도메인
* @param string $key 번역 키
* @param array $params 번역 매개변수
* @param string $domain 번역 도메인
* @return string 번역된 메시지
*/
private static function trans(
@@ -199,10 +208,10 @@ class ResponseHelper
string $domain = 'core'
): string {
$locale = self::getUserLocale();
// 도메인별로 다른 경로에서 번역 파일 로드
$translationKey = $domain === 'core' ? $key : "{$domain}::{$key}";
return __($translationKey, $params, $locale);
}
@@ -225,7 +234,7 @@ class ResponseHelper
/**
* 예외 정보를 디버그용 배열로 변환합니다.
*
* @param \Throwable $e 예외 인스턴스
* @param \Throwable $e 예외 인스턴스
* @return array 디버그 정보 배열
*/
private static function formatException(\Throwable $e): array
@@ -238,7 +247,7 @@ class ResponseHelper
'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
'function' => ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? ''),
])->toArray(),
];
}
@@ -246,11 +255,11 @@ class ResponseHelper
/**
* 모듈별 성공 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleSuccess(
@@ -266,11 +275,11 @@ class ResponseHelper
/**
* 모듈별 실패 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleError(
@@ -286,11 +295,11 @@ class ResponseHelper
/**
* 플러그인별 성공 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginSuccess(
@@ -306,11 +315,11 @@ class ResponseHelper
/**
* 플러그인별 실패 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginError(
@@ -326,11 +335,11 @@ class ResponseHelper
/**
* JSON Resource를 사용한 성공 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse JSON 응답
*/
public static function successWithResource(
@@ -341,22 +350,22 @@ class ResponseHelper
string $domain = 'core'
): JsonResponse {
$data = $resource ? $resource->resolve() : null;
return response()->json([
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data
], $statusCode);
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
* JSON Resource를 사용한 모듈 성공 응답을 생성합니다.
*
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $module 모듈명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function moduleSuccessWithResource(
@@ -372,11 +381,11 @@ class ResponseHelper
/**
* JSON Resource를 사용한 플러그인 성공 응답을 생성합니다.
*
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @param string $plugin 플러그인명
* @param string $messageKey 멤시지 키
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
* @return JsonResponse JSON 응답
*/
public static function pluginSuccessWithResource(
@@ -392,21 +401,21 @@ class ResponseHelper
/**
* 페이지네이션된 리소스 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답
*/
public static function successWithPagination(
string $messageKey = 'messages.success',
ResourceCollection|null $collection = null,
?ResourceCollection $collection = null,
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
$response = [
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain)
'message' => self::trans($messageKey, $messageParams, $domain),
];
if ($collection) {
@@ -424,7 +433,7 @@ class ResponseHelper
$response['links'] = $paginationData['links'] ?? null;
}
return response()->json($response);
return response()->json($response, 200, [], self::JSON_ENCODE_OPTIONS);
}
/**
@@ -444,6 +453,6 @@ class ResponseHelper
'error_code' => 'identity_verification_required',
'message' => self::trans('identity.errors.verification_required', [], 'core'),
'verification' => $verification,
], 428);
], 428, [], self::JSON_ENCODE_OPTIONS);
}
}
@@ -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 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
*
* 관리자 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),
);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
@@ -13,7 +14,7 @@ use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
@@ -137,7 +138,9 @@ class SettingsController extends AdminBaseController
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
@@ -264,16 +267,13 @@ class SettingsController extends AdminBaseController
/**
* 백업에서 설정을 복원합니다.
*
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(Request $request): JsonResponse
public function restore(RestoreSettingsRequest $request): JsonResponse
{
try {
$backupPath = $request->input('backup_path');
if (empty($backupPath)) {
return $this->error('settings.backup_path_required', 422);
}
$backupPath = $request->validated('backup_path');
$result = $this->settingsService->restoreSettings($backupPath);
@@ -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
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Base;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
@@ -20,11 +21,11 @@ abstract class BaseApiController extends Controller
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function success(
string $messageKey = 'common.success',
@@ -38,11 +39,11 @@ abstract class BaseApiController extends Controller
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param int $statusCode HTTP 상태 코드
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param int $statusCode HTTP 상태 코드
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function error(
string $messageKey = 'common.failed',
@@ -56,11 +57,11 @@ abstract class BaseApiController extends Controller
/**
* 리소스와 함께 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param mixed $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function successWithResource(
string $messageKey = 'common.success',
@@ -74,7 +75,7 @@ abstract class BaseApiController extends Controller
/**
* 현재 인증된 사용자를 반환합니다.
*
* @return \App\Models\User|null
* @return User|null
*/
protected function getCurrentUser()
{
@@ -84,9 +85,9 @@ abstract class BaseApiController extends Controller
/**
* Not Found 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function notFound(
string $messageKey = 'common.not_found',
@@ -98,9 +99,9 @@ abstract class BaseApiController extends Controller
/**
* Unauthorized 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function unauthorized(
string $messageKey = 'common.unauthorized',
@@ -112,9 +113,9 @@ abstract class BaseApiController extends Controller
/**
* Forbidden 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function forbidden(
string $messageKey = 'common.forbidden',
@@ -126,10 +127,10 @@ abstract class BaseApiController extends Controller
/**
* Validation Error 응답을 생성합니다.
*
* @param mixed $errors 검증 오류
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
* @param mixed $errors 검증 오류
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return JsonResponse
*/
protected function validationError(
mixed $errors,
@@ -142,9 +143,9 @@ abstract class BaseApiController extends Controller
/**
* 파일 응답을 반환합니다 (ETag 및 캐싱 헤더 포함).
*
* @param string $filePath 파일 경로
* @param string $mimeType MIME 타입
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
* @param string $filePath 파일 경로
* @param string $mimeType MIME 타입
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
*/
protected function fileResponse(string $filePath, string $mimeType, int $maxAge = 31536000): BinaryFileResponse|Response
{
@@ -176,35 +177,35 @@ abstract class BaseApiController extends Controller
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @return JsonResponse JSON 응답
*/
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
{
return response()->json($data, $status, [
'Cache-Control' => "public, max-age={$maxAge}",
]);
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
/**
* ETag를 생성합니다.
*
* @param mixed $data 해시할 데이터
* @param mixed $data 해시할 데이터
* @return string ETag 값 (따옴표 포함)
*/
protected function generateETag(mixed $data): string
{
$content = is_string($data) ? $data : json_encode($data);
return '"' . md5($content) . '"';
return '"'.md5($content).'"';
}
/**
* 클라이언트 캐시가 유효한지 확인합니다.
*
* @param string $etag 현재 ETag 값
* @param string $etag 현재 ETag 값
* @return bool 캐시가 유효하면 true
*/
protected function isNotModified(string $etag): bool
@@ -217,8 +218,8 @@ abstract class BaseApiController extends Controller
/**
* 304 Not Modified 응답을 반환합니다.
*
* @param string $etag ETag 값
* @param int $maxAge 캐시 TTL (초)
* @param string $etag ETag 값
* @param int $maxAge 캐시 TTL (초)
* @return Response 304 응답
*/
protected function notModifiedResponse(string $etag, int $maxAge = 3600): Response
@@ -233,10 +234,10 @@ abstract class BaseApiController extends Controller
*
* 클라이언트의 ETag가 일치하면 304 Not Modified를 반환합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
* @param array $messageParams 메시지 매개변수
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
* @param array $messageParams 메시지 매개변수
* @return JsonResponse|Response JSON 응답 또는 304 응답
*/
protected function successWithCache(
@@ -256,7 +257,7 @@ abstract class BaseApiController extends Controller
'success' => true,
'message' => __($messageKey, $messageParams),
'data' => $data,
])
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}")
->header('Vary', 'Accept-Encoding, Accept-Language');
@@ -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
);
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,18 +18,61 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicModuleController extends PublicBaseController
{
public function __construct(
private readonly ModuleService $moduleService
private readonly ModuleService $moduleService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
* 서빙(ETag/304/환경별 Cache-Control 재사용).
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
}
/**
* 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
}
/**
* 모듈 에셋 서빙
*
* @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 +99,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);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\PluginService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,18 +18,60 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PublicPluginController extends PublicBaseController
{
public function __construct(
private readonly PluginService $pluginService
private readonly PluginService $pluginService,
private readonly ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
* 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
public function serveBundleJs(): BinaryFileResponse|Response
{
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
}
/**
* 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다.
*
* @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답
*/
public function serveBundleCss(): BinaryFileResponse|Response
{
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
}
/**
* 플러그인 에셋 서빙
*
* @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 +98,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)
);
}
}
+75 -3
View File
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -98,6 +99,15 @@ class GzipEncodeResponse
}
// 최소 크기 이상인지 확인
// BinaryFileResponse 는 getContent() 가 false 를 반환하므로 파일 크기로 판정한다.
// (번들 JS/CSS 서빙은 response()->file() 로 BinaryFileResponse 를 반환하며,
// 이 분기가 없으면 미들웨어가 압축 없이 통과시켜 비압축 전송된다.)
if ($response instanceof BinaryFileResponse) {
$file = $response->getFile();
return $file !== null && $file->getSize() >= self::MIN_COMPRESS_SIZE;
}
$content = $response->getContent();
if ($content === false || strlen($content) < self::MIN_COMPRESS_SIZE) {
return false;
@@ -158,6 +168,12 @@ class GzipEncodeResponse
*/
private function compressResponse(Response $response): Response
{
// BinaryFileResponse 는 본문을 setContent() 로 교체할 수 없으므로
// 파일 내용을 읽어 일반 Response 로 치환한 뒤 압축한다 (헤더는 승계).
if ($response instanceof BinaryFileResponse) {
return $this->compressBinaryFileResponse($response);
}
$content = $response->getContent();
// gzip 압축 적용
@@ -173,14 +189,70 @@ class GzipEncodeResponse
$response->headers->set('Content-Encoding', 'gzip');
$response->headers->set('Content-Length', (string) strlen($compressed));
// Vary 헤더가 없으면 추가 (캐시 프록시 지원)
$this->ensureVaryAcceptEncoding($response);
return $response;
}
/**
* BinaryFileResponse 를 gzip 압축된 일반 Response 로 치환합니다.
*
* BinaryFileResponse 는 파일 스트림 기반이라 setContent() 로 본문을 교체할 수 없으므로,
* 파일 내용을 읽어 gzip 압축한 새 Response 를 생성하고 기존 헤더(Content-Type,
* ETag, Cache-Control, Expires 등)를 승계한다. Content-Encoding/Content-Length 는
* 압축 결과로 재설정한다.
*
* @param BinaryFileResponse $response 파일 응답
* @return Response gzip 압축된 응답 (실패 시 원본 반환)
*/
private function compressBinaryFileResponse(BinaryFileResponse $response): Response
{
$file = $response->getFile();
if ($file === null) {
return $response;
}
$content = @file_get_contents($file->getPathname());
if ($content === false) {
// 파일 읽기 실패 시 원본(비압축) 반환
return $response;
}
$compressed = gzencode($content, self::COMPRESSION_LEVEL);
if ($compressed === false) {
return $response;
}
// 기존 헤더를 승계한 새 Response 생성 (본문만 압축된 내용으로 교체)
$compressedResponse = new Response(
$compressed,
$response->getStatusCode(),
$response->headers->all()
);
$compressedResponse->headers->set('Content-Encoding', 'gzip');
$compressedResponse->headers->set('Content-Length', (string) strlen($compressed));
$this->ensureVaryAcceptEncoding($compressedResponse);
return $compressedResponse;
}
/**
* 응답에 `Vary: Accept-Encoding` 헤더를 보장합니다 (캐시 프록시 지원).
*
* @param Response $response HTTP 응답
*/
private function ensureVaryAcceptEncoding(Response $response): void
{
if (! $response->headers->has('Vary')) {
$response->headers->set('Vary', 'Accept-Encoding');
} elseif (! str_contains($response->headers->get('Vary', ''), 'Accept-Encoding')) {
$currentVary = $response->headers->get('Vary');
$response->headers->set('Vary', $currentVary.', Accept-Encoding');
}
return $response;
}
}
@@ -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);
}
}

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