164 Commits
Author SHA1 Message Date
HeuJung f00b8d04a1 Merge branch 'develop' 2026-09-09 14:37:34 +09:00
HeuJung 0b6f329249 fix(core): 레이아웃 버전 변경량 계산의 메모리 초과와 편집기 확장 편집·재로드·충돌 안내·버전 기록 결함 수정
- 버전 이력 변경량(+N/-N 줄) 계산이 변경 영역 (줄 수)² 크기의 LCS 표를 만들어
 큰 공통 레이아웃의 첫 편집기 저장이 PHP 기본 메모리 한도(128M) 서버에서
 500 으로 끝났다. 두 행 DP 로 길이만 구하도록 바꿔 메모리가 줄 수에 비례하고
 표시 숫자는 종전과 같다(참조 구현 동치·메모리 상한 회귀 테스트)
- 확장 편집 모드 저장이 overlay 확장의 injections 를 비우던 결함: 서버가 주입
 노드 출처 메타에 injection 순번을 싣고, 편집기는 그 순번(없으면 원본 노드 id)
 으로 되돌리며, 되돌릴 수 없는 노드가 있으면 저장하지 않고 안내한다
- 서빙 캐시 키를 서버 현재 확장 캐시 버전으로만 조립해 저장·복원 두 번째부터
 재로드·「최신 불러오기」가 옛 내용을 받던 결함 수정(요청 v 는 HTTP 캐시 우회용)
- 409 충돌 안내가 errors 아래의 버전을 읽지 못해 「최신 버전: -1」 로 표시되던
 결함을 세 저장 경로 공용 판독으로 수정
- 버전 저장 시 저장자를 기록하고, 복원 시에도 잠금 번호를 올린다
- 회귀 테스트(PHPUnit·Vitest), 트러블슈팅 사례 32~34, 규정·API 문서, ja 언어팩,
 편집기 번들 재빌드, 이력 문서 동반
2026-09-09 12:19:12 +09:00
HeuJung 72f46a6468 fix(core): 레이아웃 첨부 URL 을 사이트 상대 경로로 발급하고 저장 게이트가 자기 주소를 외부로 보지 않게 수정
편집기 헤더 「로고 이미지」에 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은
허용되지 않습니다」 422 로 거부되던 결함. 프록시 서빙 URL 이 절대 형태로 발급되어
NoExternalUrls 가 서버 자신의 주소를 외부로 차단했고, 배경 이미지는 style 로 들어가
스캔 밖이어서 로고(propValue)에서만 드러났다.

- proxyUrl 을 사이트 상대 경로로 발급 (발급 시점 도메인·스킴에 묶이지 않는다)
- SiteAssetHosts 신설: app.url host + 운영자가 선언한 공개 자산 디스크 host 를
 브라우저와 같은 정규화 뒤 등가 비교. 요청 Host 헤더는 근거로 쓰지 않고,
 흉내 host·protocol-relative·위험 스킴은 종전대로 차단
- 회귀 테스트 4클래스(71 passed) + 시나리오 매니페스트 + Playwright 2케이스,
 공개 문서(SECURITY·security·storage-driver·api)와 규정 표 갱신

함께 담긴 릴리즈 정비:
- 7.0.11 미출시 섹션의 릴리즈 날짜를 09-09 로 갱신 (코어·board·page·admin_basic·basic·ja 언어팩 3종)
- 배포본에 실리는 주석·독블록·시나리오의 내부 호칭을 중립 표현으로 정리하고,
 동봉 vendor 자산의 우연한 토큰은 누출 검사에서 경로 규칙으로만 제외
- 버전 bump 검사: github_url 없는 번들을 언어팩과 같은 origin/release 페이로드
 기준선으로 판정(상시 경고 제거), 코어 버전 판독이 env 래핑을 읽도록 보정
2026-09-09 09:20:29 +09:00
정정홍andGitHub a8a98f6ed0 Merge pull request from gnuboard:HeuJung/issue671
chore(deps): 7.0.11 Composer 패키지 최신화 · 번들 확장 lock 재동기 · vendor 번들 재빌드
2026-09-08 23:00:54 +09:00
HeuJung 455d009a83 chore(deps): 7.0.11 Composer 패키지 최신화 · 번들 확장 lock 재동기 · vendor 번들 재빌드
코어 composer update 69 패키지(Laravel 12.69.1 · Reverb 1.11.1 · Scout 11.6.1 · Monolog 3.11 ·
Symfony 7.4.18 등, 메이저 상향은 보류). 번들 확장 16개 lock content-hash 재동기(신규 2),
코어·이커머스 vendor-bundle.zip 재빌드(g7_version 7.0.11).

vendor-bundle:build-all --check 가 미설치 확장을 「소스 경로 없음」으로 건너뛰어 _bundled 의
stale 번들이 점검을 통과하던 결함을 수정 — 판정 파일을 빌드·해시와 같은 규칙(_bundled 우선,
활성 폴백)으로 고르고 회귀 테스트 2건 추가.

회귀 점검: 변경 로그 대조 9축 + PHPUnit 4배치 + Chrome MCP 매트릭스 11축 통과.
2026-09-08 22:03:58 +09:00
정정홍andGitHub 3b0a8c03cf Merge pull request from gnuboard:HeuJung/issue664
fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
2026-09-08 17:23:59 +09:00
HeuJung dd8f9a49c4 fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.

방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).

전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.

- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
 「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
 선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
 열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
 사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
 판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
 까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
 과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
 한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
 동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
 걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
 setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
2026-09-08 17:20:01 +09:00
정정홍andGitHub 9ca7b42e80 Merge pull request from gnuboard:HeuJung/issue663
fix(core): 레이아웃 첨부 URL 이 항상 프록시로 발급되던 결함 수정
2026-09-08 12:58:03 +09:00
HeuJung 5c2e42c133 fix(core): 레이아웃 첨부 URL 이 항상 프록시로 발급되던 결함 수정
공개 자산 스토리지를 설정해도 레이아웃 배경 이미지가 매번 오리진 PHP 를
거쳐 서빙되던 결함을 고친다 (https://github.com/gnuboard/g7/issues/134).
판정 기준은 운영자가 명시 선언한 core.storage.public_asset_disk 와 행 disk
의 일치다 — 디스크의 url 설정 유무로 판정하면 비공개 버킷에 공개 URL 이
설정된 구성에서 발급된 주소가 403 이 되어 그 이미지가 전부 깨진다.

전수조사에서 함께 처리한 것: 게시판 첨부 응답 url 칸(항상 null → 게이트가
살아 있는 서빙 URL), 페이지의 미배선 메서드 삭제, 확장 카테고리별 저장소
URL·경로 조회 교정, 그리고 이번 변경이 새로 만드는 고아 disk 위험 방어
(무인증 공개 서빙 라우트가 500 이 되는 것을 404 로 degrade).

문서화 하네스도 함께 보강한다. 실측을 수행하고도 이력 문서에는 판정만 남기면
대화 로그가 사라진 뒤 아무에게도 도달하지 않는데, 그 강제가 보고 형식에만
있었다.
2026-09-08 12:54:29 +09:00
정정홍andGitHub e160a86f0d Merge pull request from gnuboard:HeuJung/issue658
HeuJung/issue658
2026-09-08 11:17:53 +09:00
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지
않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를
--no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는
`composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를
찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상
종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다.
(sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10)

3계층으로 막는다.

 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear
 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다.
 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전
 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다.
 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로
 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을
 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI
 자체에 도달할 수 없다).

업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고
CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의
복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다.

실측 중 드러난 결함 2건을 함께 고쳤다.

 - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아
 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다.
 - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다
 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다).

인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인
카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를
정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다.
코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3).

코어 7.0.10 → 7.0.11.
2026-09-08 09:46:57 +09:00
정정홍andGitHub 21177c8e17 Merge pull request from gnuboard:HeuJung/issue659
fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정
2026-09-08 09:08:02 +09:00
HeuJung f32729e20e fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정
KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함
5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그
이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다.

- 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에
 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한
 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고
 코어 구현일 때만 항목 전체를 읽는다.
- 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이
 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키
 상한 3072바이트). down 은 잘리는 행을 먼저 지운다.
- 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 →
 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다.
- 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은
 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중
 예외는 비용을 이미 치른 것이라 환급하지 않는다.
- 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이
 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께
 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이
 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라
 종전 per-extension warning 은 기록되지 않아 흔적이 없었다.

회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트
클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
2026-09-08 08:17:51 +09:00
HeuJung f506c040c1 fix(core): 공개 확장 번들의 캐시 우선 서빙 + SEO 봇 캐시 상한
공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는
(type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지
않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중
경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한
캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는
드러나는 증상이 없다.

프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로
1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는
실패로 바꾸지 않고 각자 빌드로 폴백한다.

병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를
만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해
방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번
반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라
응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다.

같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시
키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그
미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한
저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스
렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다
— 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다.

저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아
(30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에
저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를
훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을
쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고,
인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다.

캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상
0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식
축이 되지 않게 했다.

(KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
2026-09-08 00:31:20 +09:00
정정홍andGitHub 893c63c605 Merge pull request from gnuboard:HeuJung/issue657
fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2026-09-07 17:11:12 +09:00
HeuJung 50007d5cc6 fix(auth): 2단계 인증을 켠 사이트의 로그인 흐름 구현
2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.

원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.

이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.

## 구현

- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
 `ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
 「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
 수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
 코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
 뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
 사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
 못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
 문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.

## 이번 점검에서 함께 고친 것

- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
 그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
 한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
 성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
 같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.

## 버전

코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
2026-09-07 17:08:14 +09:00
정정홍andGitHub 72fb12f267 Merge pull request from gnuboard:HeuJung/issue655
HeuJung/issue655
2026-09-07 08:08:22 +09:00
HeuJung b46396b079 fix(harness): CHANGELOG 한 버전 섹션 안 카테고리 헤딩 중복 검출
이슈 브랜치마다 working 버전 섹션 머리에 자기 ### Fixed 블록을 얹고
병합이 양쪽을 이어 붙여 sirsoft-ecommerce 1.2.1 에 Fixed·Changed 가
각 2회 남았다. 기존 판정기는 [Unreleased] 오용만 봐서 어느 훅에서도
검출되지 않았다.

- check-changelog-section: 카테고리 헤딩 중복·버전 헤더 중복·허용 밖
 카테고리 축 추가. working 섹션(+[Unreleased])만 차단, 공개 배포된
 과거 섹션은 notice (--notices). 모집단에 lang-packs 포함
- validate-runner: 공개 CHANGELOG 편집 직후 그 파일만 검사해 즉시 알림
- stop-guard 7.6: 판정기·픽스처 편집 시 픽스처 자동 실행 (저장소
 위반 0건이라 전수 실행이 공허 통과)
- 픽스처 13건, 규정 4문서, coverage 항목
- ecommerce 1.2.1 두 번째 Fixed·Changed 블록을 첫 블록에 병합 (항목 삭제 0)
2026-09-07 08:05:55 +09:00
HeuJung bc0b47b12b Merge branch 'develop' 2026-09-06 22:55:07 +09:00
HeuJung e3ca585afd fix(core): 신규 설치 후 대시보드 정적 게시 실패 알림 — 마법사 권한 검사 누락·알림 이중 등록 수정
- 설치 마법사 디렉토리 권한 검사 목록(REQUIRED_DIRECTORIES)에 public/build 추가
 (웹 계정이 쓸 수 없어도 막지 않고 진행해 설치 마지막 단계의 정적 게시가 실패했다)
- AppServiceProvider 의 대시보드 알림 리스너 수동 등록 제거 — CoreServiceProvider
 자동 발견(훅 캐시/스캔)과 이중 등록되어 같은 알림 카드가 두 장 표시됐다
- 회귀 테스트: RequiredDirectoriesPublishRootTest, StaticPublishAlertTest 중복 등록 단언
- INSTALL.md 마법사 안내 문장, CHANGELOG 7.0.10 Fixed 2건
2026-09-06 22:44:24 +09:00
HeuJung f41915124a Merge branch 'develop' 2026-09-06 22:23:45 +09:00
HeuJung 7946410ba6 fix(core,gdpr): 신규 설치 시 확장 설정 기본값 단계의 "Class not found" 중단 수정
- 설치 흐름(installPlugin/installModule)이 파일 복사 직후 그 확장의 PSR-4 경로를
 등록하도록 앞당김 (종전에는 시더 직전에만 등록해 검증·설정 초기화 단계가 사각)
- gdpr: 잠금 집합을 진입 파일 Plugin::lockedNecessaryStorage 가 소유하고
 Support\NecessaryAllowlist::locked 는 위임 — plugin.php 가 src/ 오토로드에 비의존
- 회귀 테스트: EntryFileAutoloadIndependenceTest, ExtensionInstallAutoloadOrderTest
- CHANGELOG 정책 정리: 코어 항목에서 확장·쇼핑몰 문구 제거, 쇼핑몰 증상은
 이커머스 1.2.1 로 이동, gdpr 1.0.4 항목·AGENTS.md 갱신
2026-09-06 22:23:15 +09:00
HeuJung d8426c5341 Merge branch 'develop' 2026-09-06 19:54:19 +09:00
정정홍andGitHub 98bce431f8 Merge pull request from gnuboard:HeuJung/issue651
fix(core): 코어 업데이트 격리 디렉토리 잔존·root 소유권·완료 안내문 수정
2026-09-06 19:35:05 +09:00
HeuJung 033d2e6d9b fix(core): 코어 업데이트 격리 디렉토리 잔존·root 소유권·완료 안내문 수정
7.0.9 → 7.0.10 sudo 업데이트 실측 후 점검에서 드러난 3건.

- 정리 단계가 소스 경로(core_{ts}/extracted/{루트}) 안쪽만 지워 껍데기가
 업데이트마다 남던 것을 격리 디렉토리 루트째 삭제로 바꾼다. 부모는 구버전
 클래스로 돌아 다음 업데이트부터 효력이 있으므로, 신버전 코드로 도는 두 자식
 (execute-upgrade-steps / execute-bundled-updates)이 파일이 없는 core_* 만
 골라 치운다 — 구버전 부모에서 올라오는 업데이트도 껍데기 없이 끝난다.
- 항목별 소유권 스냅샷이 격리 디렉토리 생성 뒤에 찍혀 root 가 만든 추출본을
 원본으로 기록하고 복원이 다시 root 로 되돌리던 것을, 이번 실행의 격리
 디렉토리를 스냅샷에서 제외해 닫는다.
- 완료 안내문이 성공 경로에서는 동작하지 않는 hotfix:rollback-stale-files 를
 가리키던 것을 --prune 재실행 안내만 남기도록 정정한다(ko/en/ja).

부수: ExecuteUpgradeStepsStandaloneTest 가 앞선 core:update 실행이 남긴
프로세스 env 플래그에 따라 결과가 갈리던 순서 의존을 setUp/tearDown 에서 제거.
2026-09-06 19:33:34 +09:00
정정홍andGitHub 24f9df9de4 Merge pull request from gnuboard:HeuJung/issue651
chore(release): 7.0.10 및 동반 확장·언어팩 출시일을 2026-09-06 으로 재정렬
2026-09-06 18:42:44 +09:00
HeuJung 67cae00dd8 fix(core): 코어 업데이트 spawn 자식의 config 캐시 오판 3건 수정 + 컨테이너 보존 래퍼 전수
7.0.9 클린 설치본에서 7.0.10 으로 올리자 업그레이드 스텝 단계가 "부모 메모리 stale" 로 중단됐다.
스텝 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅해 env APP_VERSION 오버라이드와
신버전 config/app.php 의 update 목록을 모두 보지 못한다. 이전 릴리즈에서는 자식 진입부의
config:cache 가 전역 Container 를 일회용 앱으로 바꿔 놓는 부수효과로 가드가 우연히 침묵했고,
그 부수효과를 걷어낸 withPreservedContainer 가 잠복 결함을 드러냈다.

- stale 가드는 CoreVersionChecker::getCoreVersion(env 우선) 으로 판독
- 부모는 spawn 직전 config 캐시를 비우고, 자식은 캐시 파일이 있으면 디스크 config 를 직접 읽는다
 (7.0.10 이 추가한 public/build/ext 쓰기 권한 정상화 누락 차단)
- route:cache 도 같은 부수효과를 남기므로 RouteCacheHelper·optimizeSystem 을 보존 래퍼로 감쌌다
 (단독 스텝 재실행·시스템 최적화 뒤 정적 재게시 예약 소실)
- 과거 업그레이드 사고 14부류를 7.0.10 변경과 대조 — 재발 조건은 위 3건뿐
- audit 룰 fresh-app-artisan-call-outside-cache-helper + 픽스처, 회귀 테스트 6건(수정 전 red)
2026-09-06 18:41:53 +09:00
정정홍andGitHub b79e082ca3 Merge pull request from gnuboard:HeuJung/issue651
fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
2026-09-06 15:24:25 +09:00
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00
HeuJung d263f74cae chore(release): 7.0.10 및 동반 확장·언어팩 출시일을 2026-09-06 으로 재정렬
출시가 다시 미뤄져 코어 7.0.10 과 함께 나가는 패키지의 CHANGELOG 출시일을
오늘로 맞췄다. origin/release(7.0.9, 2026-08-24) 페이로드 대비 버전이 오른
패키지 31종 + 코어 = 32건이 대상이며, 최상단 절 헤더 날짜 한 줄씩만 바뀐다.

버전이 오르지 않은 언어팩 10종은 7.0.9 이전 릴리즈분이라 건드리지 않았다.
활성 디렉토리 사본은 gitignore 대상이라 릴리즈 페이로드에 포함되지 않으므로
update 커맨드를 돌리지 않았다.

결제 3종(kginicis·nhnkcp·nicepayments)은 버전 검사 하네스가 차단으로 띄우지만
저장소 릴리즈 미발행 확장이라 origin/release 페이로드가 기준이며, 재실측 결과
셋 다 페이로드 +1 로 정합이다.
2026-09-06 08:24:30 +09:00
정정홍andGitHub 0be081c997 Merge pull request from gnuboard:HeuJung/issue645
fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
2026-09-06 00:50:00 +09:00
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00
정정홍andGitHub 8f072895ff Merge pull request from gnuboard:HeuJung/issue647
fix(admin_basic,basic): 상한 총 건수 목록의 페이지 번호 목록 복원
2026-09-04 18:54:39 +09:00
HeuJung 3e41dc6075 fix(admin_basic,basic): 상한 총 건수 목록의 페이지 번호 목록 복원
총 건수를 상한까지만 세는 목록(last_page: null)에서 Pagination 이 번호 목록을
통째로 비우고 현재 페이지 숫자 하나만 남겼다. 합의되고 CHANGELOG 에 약속된
동작은 "마지막 페이지 점프만 감춤" 이었으므로, 1..현재 페이지와 (다음이 있으면)
현재+1 까지는 그대로 그리고 그 너머만 계속됨 표시로 둔다. 활동 로그처럼
10,000건을 넘는 화면에서 앞 페이지로 직접 이동할 방법이 없던 것을 되돌린다.

두 템플릿 컴포넌트 회귀 테스트(작성 시 6/7 fail 확인)와 활동 로그 E2E 를 추가하고,
pagination.md 의 화면 규약 서술을 실제 동작으로 정정했다.
2026-09-04 18:53:31 +09:00
HeuJung 9d3b15e300 feat(core,extensions): 확장 진입 문서 제목에 그누보드7 확장 유형 표기
번들 확장 README 제목이 manifest 확장명 그대로(「게시판」)라 그 문서만 연
사람이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없었다.
README · AGENTS.md · docs/README.md 진입 문서 60개의 제목을
「그누보드7 {확장명} {유형}」 으로 바꾸고, 조립 규칙을 ExtensionInventory::docTitle
한 곳에 두어 골격 생성기와 계약 테스트가 같은 헬퍼를 쓰게 했다.
확장명이 이미 유형으로 끝나면 겹쳐 붙이지 않는다.

공개 규정에는 번들 확장 전용 관례임을 주어로 명시하고 제3자 확장에는
요구하지 않는다는 문장을 두었다. 언어팩은 README 가 없어 대상 밖이다.
2026-09-04 16:40:42 +09:00
정정홍andGitHub f417488816 Merge pull request from gnuboard:HeuJung/issue644
HeuJung/issue644
2026-09-04 15:38:16 +09:00
HeuJung 0833bd1f6a fix(core,engine): 확장 번들 503 판정을 선언 축에서 소실 축으로 교체
빈 번들 503 을 "에셋을 선언한 활성 확장이 있는데 결과가 비었다" 로 판정해,
스타일 규칙이 아직 없는 0바이트 CSS 만 선언된 기본 구성이 통째로 503 이 됐다.
file_get_contents 는 0바이트에서 false 가 아니라 '' 를 돌려주므로 읽기 실패
분기도 타지 않는다. 선언 축 게터는 전부 file_exists 게이트라 부재를 셀 수 없어,
존재 게이트가 없는 getDeclaredAssetAbsolutePaths 를 통로로 두고 503 의 근거를
소실 축(findMissingDeclaredAssets)으로 옮긴다. 존재하되 비었으면 빈 200 이다.

곁들여: 실패 배너 항목명을 내부 구분 키에서 사용자 어휘로(engine-v1.64.7),
봇 화면이 없는 템플릿 CSS 를 링크하지 않도록 실재 게이트, 매니페스트 자산
선언 드리프트 4건 정정.
2026-09-04 15:36:33 +09:00
HeuJung 482faf00be chore(release): 7.0.10 출시일 정렬 + 공개 배포본 누출 정정
7.0.9 페이로드 대비 버전이 오른 패키지 31종과 코어의 CHANGELOG 최상단 절
날짜를 2026-09-04 로 맞췄다.

이어서 릴리즈 스크립트의 게이트를 브랜치 생성 없이 전수 실행한 결과, 파일
본문 누출 검사가 error 25건으로 릴리즈를 막고 있었다. 전부 이번 사이클에
들어온 것이다.

- 확장 문서·코어 주석 20건이 공개 배포본에 없는 를 가리켰다.
 공개되는 AGENTS.md 참조로 바꾸고, 참조된 9개 절의 실재를 전수 확인했다.
 유일하게 빠져 있던 「독립 레이아웃의 글로벌 호스트 컴포넌트」절은 원래
 동기화 대상이었으므로 AGENTS.md 에 반영했다.
- 시나리오 매니페스트의 내부 문서 포인터는 소비자가 하나도 없어 제거했다.
- 공개 테스트 3건이 내부 검사 스크립트를 열어 도구 간 리터럴을 대조하고
 있었다. tests/** 는 배포 페이로드에 포함되고 는 제외되므로
 배포본에서 원리상 성립할 수 없는 검사다. 대조를 그 도구 옆으로 옮기고
 공개 테스트에는 자기 동작 단언만 남겼다. 존재 가드로 무마하지 않은 것은
 그 경우 배포본에서 검사가 조용히 건너뛰어져 "검사하지 않음" 과 "통과" 가
 구분되지 않기 때문이다.

옮긴 대조는 돌지 않으면 잠그지 못하므로 대상 파일 편집 시 자동 실행되도록
등록했다. 누출 검사 자체도 릴리즈 시점에만 돌아 위반이 다음 릴리즈까지
드러나지 않았으므로, 룰을 복제하지 않고 실행 시점만 세션 종료로 앞당겼다.

같은 검증 중 드러난 선행 결함 하나(이커머스 문서의 집계 블록 드리프트)도
함께 정리했다.
2026-09-04 12:18:22 +09:00
정정홍andGitHub c305b56248 Merge pull request from gnuboard:HeuJung/issue641
HeuJung/issue641
2026-09-04 10:24:19 +09:00
HeuJung 381fcc4e0b feat(core,admin_basic): .env 키 단위 우선 모드 추가
관리자 환경설정 저장값이 config 를 덮는 기존 설계는 공유호스팅을 1차 대상으로
삼은 결과다. .env 를 배포 기준값으로 관리하는 설치에서는 그 설계가 반대로
작동해, 설치기가 defaults 를 시드하는 순간부터 .env 의 28개 항목이 오류도
로그도 없이 사문화된다.

G7_ENV_PRIORITY 옵트인으로 그 소유권을 키 단위로 되돌린다. 스위치를 켜지 않은
설치는 전 경로가 조기 return 이라 동작이 100% 동일하다.

명시 여부는 config 빌드 시점에 캡처한다 — 런타임 env 는 config:cache 환경에서
null 로 고정되어 판정이 영구 미발동한다(attachment.disk_explicit 와 같은 함정).
판정은 strict 라 APP_DEBUG=false·REDIS_DB=0 같은 falsy 명시도 명시로 본다.

잠금은 그 키의 주입만 건너뛰는 것이지만, 제거된 키의 부재를 값으로 읽는 자리가
있으면 형제 설정까지 무너진다. 게이트(웹소켓 토글·메일 드라이버·디버그 모드)와
기본값 주입(mailgun.endpoint·ses.region), 형제 폴백(reverb server endpoint),
파생 판정(storage_driver→attachment.disk)을 유효값으로 보정했다.
2026-09-04 10:08:30 +09:00
정정홍andGitHub b97deee43a Merge pull request from gnuboard:HeuJung/issue640
HeuJung/issue640
2026-09-03 22:39:18 +09:00
HeuJung 1124fad027 feat(gdpr): 필수 저장 항목 허용목록을 운영자 설정으로 이관
허용목록이 플러그인 코드 상수라, 새 확장이 저장 키를 도입할 때마다 이 플러그인을
고쳐야 했다. 고치지 않으면 그 키는 방문마다 파기되는데 예외도 로그도 남지 않아
운영자에게는 "설정이 저장되지 않는다" 는 증상만 보인다.

허용목록을 운영자 설정(necessary_storage_allowlist)으로 옮기고, 관리자 환경설정에
브라우저 저장소·세션 저장소·쿠키 세 카드를 두어 직접 편집하게 했다. 확장은 GDPR 을
알 필요가 없고 GDPR 도 확장을 알 필요가 없다 — 연결은 운영자가 화면에서 한다.
저장 키를 플러그인이 관측·수집하지 않는 것도 같은 이유다: 관측 자체가 추적이다.

판정은 서버(미들웨어)와 클라이언트 셋(인터셉터 둘 + 정리기)이 같은 설정을 같은 매칭
규칙으로 읽는다. 잠금 항목(로그인 토큰·CSRF·세션 쿠키·동의 기록)만 설정 밖에 두고
판정 시점에 합집합으로 얹는다 — 설정에 담기면 저장 요청 한 번으로 지워져 잠금이
아니게 된다. 기설치본은 업그레이드 스텝이 현재 카탈로그를 설정 파일에 못박되,
운영자가 이미 편집했으면 그대로 둔다.

함께 고친 선재 결함 셋:
- 차단 도메인 추천 목록이 응답에 실리지 않아 자동완성이 동작하지 않던 문제
- 세션 쿠키 이름을 기본값에서 바꾼 사이트에서 브라우저 측 목록의 그 항목이 죽던 문제
- 쿠키 목록에만 앞부분 매칭(`이름_*`)이 적용되지 않던 문제
2026-09-03 22:18:18 +09:00
HeuJung 8cf829f953 fix(core,extensions): 설치 완료 스크롤 + 미인증 화면 테마 버튼 무반응 수정
Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건.

설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라
화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이
끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동).

미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는
action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을
쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다.

같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어
함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다.
정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른
형태를 막고 있었으므로 두 미러를 함께 정정했다.

테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키
미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서
빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던
항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록,
관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가
"다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된
문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다).

재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지
테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
2026-09-03 18:42:30 +09:00
정정홍andGitHub 92923a5011 Merge pull request from gnuboard:HeuJung/issue631
fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
2026-09-03 07:50:59 +09:00
HeuJung 5d14d01d50 fix(core): 디버그 라우트 그룹 게이트 일원화 + 확장 자산 CSS 상대 참조 치환
https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무
전수조사에서 확정한 결함군을 닫는다.

## 디버그 라우트 게이트 (F1~F5)

게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에
`File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고,
production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은
User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴
구조였다.

게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로
올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다.
`withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는
제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치
우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다.

## 확장 자산 CSS 상대 참조 (범위 추가 — 결정)

`asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준
디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가
존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘
약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서
통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로
제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다.

둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이
정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류
갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 —
갈라지면 한쪽만 고쳐진 채 남는다.

이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가
정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다.

## 회귀 잠금

되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입,
`channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들
치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다.

게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과
구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환
배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은
목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
2026-09-03 07:42:28 +09:00
정정홍andGitHub db8640df86 Merge pull request from gnuboard:HeuJung/issue629
HeuJung/issue629
2026-09-02 21:56:38 +09:00
HeuJung 1e89335258 fix(core,extensions): 동적 스크립트 주입 경로 전부에 출처 게이트 적용
레이아웃 scripts[] 에만 있던 원격 스크립트 차단 게이트가 다른 주입 경로에는
없어, 저장측(SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 422 로
막아도 런타임 디스패치 한 번으로 임의 원격 코드가 로드됐다. 저장 검증이
통째로 무의미해지는 상태였고 오류도 경고도 남지 않았다.

판정식을 support/scriptSrcPolicy.ts 로 분리해 런타임 SSoT 를 하나로 두고,
loadScript 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 · 편집기 자산 매니페스트 ·
G7Core.asset.loadScript · PG 플러그인 SDK 주입이 모두 그것을 경유하게 했다.
사본을 두지 않은 이유는 차집합이 그대로 우회로가 되기 때문이다.

- 게이트는 캐시 검사보다 앞에 둔다 (뒤에 두면 이미 로드된 미신뢰 스크립트가
 캐시 히트로 통과한다)
- NoExternalUrls 순회를 9키로 확대 — 번들 레이아웃 601개 사전 스캔으로
 정당한 외부 URL 0건을 확인한 뒤 넓혔다 (저장 회귀 없음)
- callExternal 은 생성자를 참조 동일성으로 거부하고(별칭 전역 포함),
 프로토타입 경로 세그먼트를 읽기·매핑 키 양쪽에서 차단한다
- PG SDK 는 서비스 SDK 라 자체 호스팅이 불가능하므로 manifest 선언 +
 주입 직전 호스트 확인이 게이트다. tosspayments SDK URL 은 확장자가 없어
 정적 검사에 걸리지 않아 그 런타임 확인이 유일한 게이트다
- 함께 드러난 결함: 동시 loadScript 가 로드 전에 완료 처리되던 문제,
 확장 재로드에서 script 기존재가 CSS 까지 건너뛰던 문제, loadCSS 의
 in-flight 미공유
2026-09-02 21:43:13 +09:00
정정홍andGitHub 621cd28376 Merge pull request from gnuboard:HeuJung/issue632
fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄
2026-09-02 17:39:39 +09:00
HeuJung 21fc114f37 fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄
KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고,
그 과정에서 드러난 두 결함군을 함께 닫는다.

- 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다
- 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다
- 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고,
 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는
 결제창 미완료 주문은 만료 자동취소가 거둔다
- 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다
 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다
- 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
2026-09-02 17:36:11 +09:00
정정홍andGitHub d4439ea30a Merge pull request from gnuboard:HeuJung/issue630
HeuJung/issue630
2026-09-01 22:59:41 +09:00
HeuJung 151a7ae945 fix(extensions,core): 저장 대상이 아닌 편집기가 폼을 변경됨으로 만들지 않게
미리보기 편집기는 설정을 시험해 보는 자리인데, 본문 동기화가 hasChanges 를
렌더를 일으키는 setLocal 로 올리면서 시험 입력만으로 [저장] 이 켜졌다.
어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명을 알아보게
하지 않고 trackChanges 선언으로 받는다 — 하드코딩하면 다른 확장이 같은
미리보기 패턴을 쓸 때 그대로 재발한다.

같은 결함이 형제 경로에도 있었다. 평문 폴백은 hasChanges 를 본문 배치
(render:false + selfManaged:true)에 실어, 편집기 자산을 못 불러온 환경에서
본문만 고치면 저장 버튼이 잠긴 채로 남았다 — 폐쇄망에서는 그 폴백이 정상
경로다. 폴백은 렌더 시점에도 같은 함수를 부르므로 화면을 여는 것만으로
변경됨이 되지 않도록, 사용자 입력에서 온 호출만 플래그를 올린다.

components.json 생성기는 종결 개행을 PHP_EOL 로 써서 Windows 빌드마다
그 파일을 유령 변경으로 남겼다. 줄 내용이 같아 diff 가 비어 보이므로
무엇이 바뀐 것인지 알 수 없는 변경만 쌓인다.
2026-09-01 21:53:47 +09:00
HeuJung de584ea93f fix(core): 자동바인딩 키입력이 편집기 본문을 서버 원본으로 되돌리던 문제
자동바인딩은 키입력마다 __g7PendingLocalState 에 저장소 A 기반 전체 스냅샷을
대입해 왔다. 그 base(parentFormContext.state)는 extendedDataContext useMemo 의
결과라, selfManaged 플러그인이 저장소 B 에만 쓴 뒤 memo 가 재계산되지 않은
구간에서는 편집 이전 스냅샷으로 고정된다. 이어지는 setLocal 이
currentSnapshot = pendingState || baseLocal 로 그것을 채택하면 저장소 B 가
통째 교체되어 편집분이 사라진다.

pending 은 getLocal 이 읽는 "화면과 같은 전체 스냅샷" 이므로, 렌더러가
_local 을 만드는 순서(dataContext._local → dynamicState → __g7ForcedLocalFields)를
그대로 따르게 합성한다. 방금 입력한 경로는 마지막에 다시 얹어 오버레이의 직전
값이 입력을 되돌리지 못하게 했다.

저장소 A 경로와 setLocal 의 base 우선순위는 건드리지 않았다. 전자는 2026-04-22 에
로그인 폼 email 손실로 철회된 자리이고, 후자는 _localInit 이 초기 데이터를
pending 에만 싣는 구간을 깨뜨린다.

engine-v1.63.3이 저장 클릭 경로를 고쳤다면 이번 수정은 그 앞의 키입력
경로를 고친다. 성립 조건에는 memo deps 와 무관한 리렌더가 선행해야 하며,
리사이즈 없는 대조군은 수정 전에도 정상이다(브라우저 실측).

트러블슈팅 246 사례 전수 판정: 영향 있음 0 · 개선 3 · 불변 10.
2026-09-01 19:57:24 +09:00
HeuJung cd189190c2 fix(core,extensions): 창 크기 변경 후 저장 시 편집기 본문이 사라지던 문제
저장소 B(_global._local)를 동기화하는 두 지점이 저장소 A 계열 스냅샷을 그대로
넘겨 왔다. setGlobalState 는 _local 을 얕게 병합하므로 그 쓰기는 patch 가 아니라
통째 교체이며, A 가 아직 받지 못한 값 — CKEditor 가 setLocal({render:false,
selfManaged:true}) 로 B 에만 쓴 본문 — 이 예외도 로그도 없이 사라진다.

handleSetState COMPONENT 분기의 B 쓰기와 sequence 반환값을 함께 live B 기반으로
바꾼다. 요청 body 는 B 가 아니라 그 반환값에서 오므로, B 쓰기만 고치면 저장은
여전히 422 가 난다. 같은 형태의 setParentLocal 도 함께 정정했다.

트리거는 브레이크포인트 교차가 아니라 19px 폭 변경이다 — 모바일 URL바 숨김·
가상키보드·화면 회전이 모두 해당한다. 작성 화면은 422 로, 수정 화면은 성공
토스트와 함께 조용한 데이터 손실로 나타났다.

함께 담은 것:
- ckeditor5: 미리보기 입력이 설정으로 저장되던 문제, 본문만 고치면 저장 버튼이
 비활성으로 남던 문제
- board: 일괄 적용 확인 창의 항목 목록이 통째로 렌더되지 않던 문제(표현식
 평가기가 콜백 안 멤버 대입을 거부), 「금지어」 라벨 누락
- 확장 문서의 Playwright 명령이 코어 config 를 잡아 "No tests found" 로 조용히
 0건이 지나가던 문제
2026-09-01 18:08:40 +09:00
정정홍andGitHub da5f76a9d8 Merge pull request from gnuboard:HeuJung/issue601
HeuJung/issue601
2026-09-01 13:46:28 +09:00
HeuJung 3cb598ce5f fix(core,extensions): 편집기 스펙 한 줄 요약을 사람 메모 대신 실측에서 생성
직전 커밋 산출물에 「Phase 4/5 에서 추가」 문구가 박힌 것을 가 지적했다.
그 문자열은 이번 브랜치에서 쓴 것이 아니라 editor-spec.json 의 description 에
원래 있던 값이고, 새로 만든 생성기가 그것을 문서 앞머리로 옮겨 실으면서
승격된 것이다.

그 필드는 코드가 아니라 사람이 쓴 메모라 실측과 대조되지 않는다. 전수 조사에서
12건 중 5건이 오염돼 있었고, 그중 둘은 바로 아래 실측 표와 정반대를 말했다 —
「controls 는 나중에 추가」라는데 표는 303개가 있다고 한다. 오류도 경고도 나지
않는다: 문서가 자기 자신을 반박한 채로 읽힐 뿐이다.

한 줄 요약은 남기되 실측에서 만든다. 두 표에 흩어진 값을 한 줄로 압축하므로
읽는 쪽이 잃는 것이 없고, 코드에서 파생되므로 어긋날 자리가 사라진다. 도메인
의미는 바로 아래 사람 서술 칸이 이미 담당한다.

스펙 파일의 오염된 메모 5건도 정리했다. 그 파일을 여는 개발자는 문서와 별개로
그 값을 읽고, 확장과 함께 공개 배포된다. 재발 방지는 규정에 명시하고 검사는
두지 않았다 — 어휘를 정규식으로 고정하면 정당한 서술까지 걸리고 목록 밖 표현은
통과해, 「검사했으나 통과」와 「보지 않음」이 구분되지 않는다.
2026-09-01 12:56:16 +09:00
HeuJung 3a0aba85bc feat(core,extensions): 확장 문서 호칭 통일과 레이아웃 편집기 문서 축 신설
확장 문서의 제품 호칭을 「그누보드7」로 통일했다. 가 지적한 두 문구는
개별 오타가 아니라 생성기가 찍는 정형 문구여서, 산출물이 아니라 방출 지점
세 곳을 먼저 고쳤다 — 그러지 않으면 21번째 확장부터 다시 샌다. 정리 범위는
확장 문서와 그 생성기까지이며, 코어 docs 와 언어팩 표시명은 의도적으로 남긴다.

레이아웃 편집기 대응 문서가 없던 문제는 확장마다 docs/editor-spec.md 를
두어 닫았다. 실측은 EditorSpecCollector 가 유지하며, 합본에 런타임과 같은
EditorSpecAssembler 를 써서 문서가 말하는 스펙과 편집기가 읽는 스펙이
갈라질 경로를 두지 않았다. 스펙을 두지 않은 확장에도 문서를 둔다 — 미보유가
정상일 수 있고, 그 정상 여부를 적을 자리가 없으면 다음 사람이 부재를 누락으로
오해하거나 필요한 시점을 놓친다.

초안이 낸 수치 다섯 건이 틀렸고 전부 오류 없이 "사실" 로 실릴 값이었다.
블록 최상위 키를 세어 팔레트가 79 대신 3 이 되던 것, `_` 접두 일괄 배제가
실제 항목을 삼키던 것 등을 정정했다.

게이트를 인위적으로 깨뜨려 점검한 결과 사각 하나가 드러났다 — 채워 넣으라는
표시만 지우고 서술을 쓰지 않으면 검사를 통과해, 빈 문서가 완비로 집계됐다.
미채움을 두 축으로 만들어 닫았고 되돌림으로 검산했다. 편집기 스펙 편집 시
규정이 주입되지 않던 것과 스캐폴딩 안내가 새 문서를 빠뜨리던 것도 함께 고쳤다.
2026-09-01 12:02:55 +09:00
HeuJung eb7ff4ec6a fix(gdpr,hooks): 동의 출처 어휘 정정과 확장 문서 강제의 Codex 트리 이식
쿠키 동의 저장 요청이 서버만 기록하는 출처(가입 시 동의)를 지정할 수 있었다.
주석은 지정 불가라고 적혀 있었고 코드가 그것을 잃은 쪽이었다 — 그 값을 쓰는
곳은 회원가입 리스너뿐이고, 받는 경로는 비인증 방문자도 도달한다. 형제 값 둘이
이미 같은 이유로 막혀 있어 대칭을 회복한다.

확장 문서 동반 강제가 두 도구 트리 중 한쪽에만 있었다. 다른 쪽으로 작업하면
문서를 빠뜨려도 아무 경고가 없고, 그 결과는 이상 0건과 구분되지 않는다. 룰·
coverage·편집 시점 안내·세션 종료 검사·미채움 기준선을 이식하고, 두 사본이
같은 입력에 같은 판정을 내는지를 픽스처가 한 곳에서 단언하게 했다.
2026-09-01 08:20:36 +09:00
HeuJung 6c63536f81 docs(core,extensions): 확장 20개 개발자 문서 완비와 문서 소유 이관
번들 확장 20개 전부에 AGENTS.md · README.md · docs/ 를 채우고, 코어가 들고
있던 확장 소유 문서 두 갈래를 그 확장으로 옮긴다. 번들 템플릿의 컴포넌트·
핸들러·레이아웃 상세와 확장이 구독하는 활동 로그 훅 목록이 그 대상이며,
코어에는 총계와 링크만 남아 확장이 기능을 늘릴 때 코어 문서를 고쳐야 하던
역방향 의존이 사라진다.

전수 완비를 확인하고 강제를 조인다 — 문서 동반 룰을 대상 목록 없는 error 로
승격하고, 검사 스크립트가 문서 미보유를 실패로 올리며, 미채움 마커 baseline 을
0 으로 기록한다. 한쪽만 조이면 "새 확장이 문서 없이 들어와도 초록" 인 상태가
남는데 그 결과는 이상 0건과 구분되지 않는다.

집필 과정에서 드러난 생성기 결함 셋을 함께 고친다. 스케줄 주기 열이 계약 키를
읽지 않아 모든 확장에서 '-' 였고, 네임스페이스를 붙인 핸들러 등록 키가 수집에서
통째로 빠졌으며, README 골격이 폐기된 히어로 배지를 계속 찍어내고 있었다.
셋 다 산출물이 아니라 원천이 틀린 것이라, 가드의 모집단에 생성기 출력 자체를
넣어 다음 확장이 같은 상태로 태어나는 경로를 막는다.
2026-08-31 22:57:36 +09:00
HeuJung 8328b1db77 docs(core,extensions): 확장 개발자 문서 9세트 집필과 생성기 정합 보강
S1 파일럿 3종(sirsoft-board · sirsoft-gdpr · sirsoft-admin_basic)에 이어
결제·본인인증 동형군 6종의 AGENTS.md · README.md · docs/ 5문서를 집필했다.
확장을 고치려는 쪽이 매번 src/ 를 훑어 구조를 재발견하지 않도록, 설계 의도와
확장점(발행·구독 훅)·수정 시 동반 의무·금지 패턴을 코드 근거로 서술했다.

생성기(ExtensionDocScaffolder)에서 표를 무의미하게 만들던 세 결함을 함께 고쳤다.
getLayoutExtensions 기본 구현이 돌려주는 절대경로가 파일 목록과 중복돼 로컬
머신 경로가 커밋 문서에 실리던 문제, getNotificationDefinitions 를 'key'/'event'
로 읽어 모든 행이 '-' 로 찍히던 문제, getSettingsLayout 절대경로가 정규화 없이
노출되던 문제다. 셋 다 예외를 남기지 않고 표만 조용히 망가뜨리므로
ExtensionDocContractTest 에 각각의 되돌림 red 를 확인한 단언을 두었다.
템플릿의 extensions/{id}/ 는 모듈·플러그인의 발행과 반대 방향(오버라이드)이라
별도 블록(template-overrides)으로 분리했다.

sirsoft-admin_basic 의 컴포넌트·핸들러·레이아웃 문서를 코어 docs/ 에서 그 템플릿
소유로 이관하고, 남은 참조 6축을 재는 가드를 추가했다. 이관 후 남은 옛 경로는
오류가 아니라 헛걸음으로만 나타나 드러나지 않는다. 의 상대 링크가
한 단계 얕아 공유 docs/ 대신 를 가리키던 문제도 함께 고쳤다.

README 상단의 확장명 이미지 배지를 평문 H1 로 바꿨다( 지시 2026-08-31).
루트 README.md · README.ko.md 도 같은 기준을 적용했다. 정보 배지는 유지한다.

sirsoft-gdpr: 회원탈퇴로 자동 철회된 동의가 관리자 동의 이력 화면의 출처 필터로
걸러지지 않던 문제를 고쳤다. Repository 가 'withdraw' 리터럴을 직접 UPDATE 에
싣는데 그 값이 ConsentSource enum 에 없어, 화면 필터 옵션·라벨 어느 쪽에도
도달하지 못했다. 어휘를 enum 단일 출처로 모으고 ko·en·ja 라벨과 필터 옵션을
함께 채웠으며, 어휘 대조 테스트의 모집단에 Repository 를 편입했다.
2026-08-31 15:57:44 +09:00
HeuJung 11175d35d6 feat(core,docs): 확장별 개발자 문서 생성기와 검사 하네스 구축
번들 확장 20개 중 에이전트·확장개발자가 읽을 수 있는 문서를 가진 확장이 0개였다.
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답하고, 확장을 고치려는
쪽이 실제로 묻는 것(왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안
되는가)은 어디에도 없었다. 그 결과 확장이 발행하는 훅은 확장점인데도 사실상 비공개였다.

이 커밋은 인프라만 담는다. 문서 집필은 확장별로 순차 진행한다.

- `php artisan ext:docgen` — 진입 클래스의 선언형 getter 를 실제로 호출하고 소스를 스캔해
 훅·라우트·권한·메뉴·설정·모델·레이아웃·핸들러·테스트 경로·의존 관계를 실측한다.
 `_bundled` 를 읽는다 (활성 디렉토리에 같은 FQCN 이 로드돼 있으면 eval-rename 으로 우회).
- 자동 생성 블록은 **안쪽만** 교체한다. 사람 서술이 소실될 경로를 만들지 않으려고 파괴적
 재생성 플래그를 두지 않았고, 문서에 없는 블록 키는 주입하지 않고 누락으로 보고한다.
- 필수 문서·섹션·블록 목록은 `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT 다. 검사
 스크립트는 이를 복제하지 않고 `--check --json` 을 소비하며, PHP 를 못 돌리면 "이상 0건"
 이 아니라 "점검 불가" 로 구분 보고한다.
- 두 audit 룰은 도입 시점 전수가 공허 통과하므로(대상 문서 0건) 픽스처 테스트가 판정식을
 잠근다. 스캐너·mermaid·검사 스크립트 축도 같은 이유로 픽스처를 함께 둔다.

동반 수정: 훅이 넘기는 `file_path` 는 항상 절대경로인데 `FILE_RULES` 다수가 루트로
앵커해 있어 그 규칙들이 조용히 죽어 있었다(추적 파일 440개가 절대경로에서 0건 매치).
`toRepoRelative` 로 정규화하고 세 경로 형태의 판정 일치를 테스트로 고정했다.
문서 인덱스 생성기의 CRLF 문서 제목·TL;DR 추출 누락도 같은 성격이라 함께 고쳤다.
2026-08-31 07:58:44 +09:00
정정홍andGitHub 266b723b9d Merge pull request from gnuboard:HeuJung/issue627
fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
2026-08-29 15:15:08 +09:00
HeuJung 01d319e74b fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면
invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다.
echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도
남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다.

2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차),
응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에
남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다.

정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub
단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.

같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가
500 이 되던 것도 함께 막았다.
2026-08-29 15:13:14 +09:00
정정홍andGitHub f094a5f3fc Merge pull request from gnuboard:HeuJung/issue625
fix(core,templates): 의존성 보안 버전 정비 및 감사 사각 해소
2026-08-29 09:26:41 +09:00
HeuJung 4d60b6cefb fix(core,templates): 의존성 보안 버전 정비 및 감사 사각 해소
제보(https://github.com/gnuboard/g7/issues/126)는 "패키지가 낡았다" 로 읽히지만
본질은 감사 신호가 거짓이었다는 데 있다. 실행에 쓰이는 axios·laravel-echo·pusher-js
가 devDependencies 에 있어 `npm audit --omit=dev` 가 이들을 통째로 건너뛰었고,
동봉 자산은 어떤 잠금파일에도 없어 감사 도구가 원리상 볼 수 없었다. 버전만 올리면
같은 사각이 남으므로 분류를 바로잡고 전수 점검 통로와 회귀 차단을 함께 둔다.

사용자 템플릿 정화기를 3.4.14 로, 코드 편집기를 monaco 0.56.0 으로 올렸다. 0.56.0 은
`editor.main` 의 `m` export 제거와 JSON 언어 서비스 이전이라는 파괴 변경을 얹었으므로
로더를 1.7.0 으로 올리고 진입점을 버전 관용적으로 해석한다.

상류가 못 고치는 잔여(monaco 내장 정화기 3.4.8)는 overrides 로 가리지 않고 하한과
사유를 기록해 그 아래로 내려가는 것만 막는다. 가리면 감사만 조용해지고 브라우저가
받는 자산은 그대로여서, 이 이슈가 고치려던 거짓 신호를 재생산한다.
2026-08-29 00:08:48 +09:00
정정홍andGitHub f9ef15f287 Merge pull request from gnuboard:HeuJung/issue622
feat(core): 리버스 프록시 신뢰 설정 지원 및 미설정 진단
2026-08-28 19:05:34 +09:00
HeuJung b6c5e1f323 feat(core): 리버스 프록시 신뢰 설정 지원 및 미설정 진단
TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성에서 X-Forwarded-* 가 전부
무시되어 화면 백지·IP 왜곡·webhook 403 이 함께 발생했다. 코어에 신뢰 프록시 설정
지점이 아예 없던 것이 원인이다.

- config/trustedproxy.php 로 내장 TrustProxies 미들웨어에 값을 공급한다.
 bootstrap/app.php 는 건드리지 않는다 — withMiddleware 클로저는 .env 로드 전에
 평가되어 env 가 항상 null 이 되는 조용한 no-op 이다.
- 판정은 App\Support\TrustedProxyDiagnostic 단일 SSoT 에서 계산하고 대시보드
 알림·환경설정 고급 탭·설치 마법사·trusted-proxy:status 네 면이 소비한다.
 판정식은 "HTTPS 인식 실패" 가 아니라 "X-Forwarded-* 수신 중 AND 신뢰 프록시
 미설정" 이다 — HTTP 전용 사이트가 프록시 뒤에 있으면 화면은 정상인 채로
 나머지만 조용히 어긋나기 때문이다.
- 값 편집 UI 와 쓰기 엔드포인트는 두지 않는다(잠금 역설 + XFF 위조 경로).
- 혼합 콘텐츠 차단을 부트스트랩 폴백의 별도 사유로 가른다. 새로고침으로 낫지
 않으므로 버튼을 렌더하지 않고, 원인·조치는 콘솔로 운영자에게 보낸다.
- 대시보드 알림을 심각도로 배치한다 — warning 은 상단 배너, 그 외는 하단 카드.
 같은 알림이 두 곳에 뜨지 않으며, 여러 건은 간격을 두고 쌓인다.

공개 이슈: (@lyg-kaban 제보)
2026-08-28 18:09:27 +09:00
정정홍andGitHub 46ada4dddb Merge pull request from gnuboard:HeuJung/issue621
fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
2026-08-28 15:26:29 +09:00
HeuJung 85e266d361 fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때
HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로
끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라
PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만
기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다.

캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로
수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면
안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다
(출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다).

그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다.

- 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와
 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가
 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와
 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대
 시끄러운 실패)은 호출부가 정하게 둔다.

- 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리
 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정
 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트
 분기는 config/filesystems.php 한 줄에서 끝낸다.

함께 고친 것

- 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던
 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아,
 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다.
- PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를
 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성
 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만
 골라 별도 로더로 등록한다.
- 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지
 못하던 문제.
- 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이
 쓰이던 문제.

동반 산출물

- 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서
- audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로
 판정식은 픽스처 36건이 잠근다
- INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
2026-08-28 15:22:22 +09:00
정정홍andGitHub fc27a6edf6 Merge pull request from gnuboard:HeuJung/issue619
feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
2026-08-28 08:08:27 +09:00
HeuJung b4ed33aa44 feat(core): 정적 게시 수명주기 보완 — 빌드 파괴·권한·무결성
https://github.com/gnuboard/g7/issues/122 제보자의 추가 지적 2건(빌드가 게시본을
지움 / CLI 최초 게시 후 웹 재생성 불가)에 대응하고, 같은 근본 원인을 공유하는
결함을 전수조사로 함께 고친다.

- 빌드가 자기 산출물만 교체한다: 루트 vite `emptyOutDir: false`
 기본값 true 는 폴백 없는 코어 3번들과 배달된 immutable URL 의 게시본을 함께 지웠다
- 게시 트리 권한을 웹이 이어받는다: 병합 전 프리플라이트 + 부모 그룹 상속·g+w
 종전 소유권 정상화는 root 축만 덮어 비-root CLI 계정은 무방비였다
- 갱신 → 생성 → 완전성 확인을 한 묶음으로: 기록 바이트 대조, .old 원자 스왑,
 rename 일시 거부 재시도, 프론트 JSON 파싱 검증
- 실패를 억제하고 기록한다: 버전 한정 실패 마커 + 사유별 대시보드 알림
 + ext-static:status 점검 커맨드
- 파생: catch-all 제외 목록을 에셋 화이트리스트 합집합에서 파생(mjs·webp·otf 누락),
 번들 디스크 쓰기 fail-soft·빈 번들 503, 활성 디렉토리 prune 회피
2026-08-28 08:02:22 +09:00
정정홍andGitHub fe973f40af Merge pull request from gnuboard:HeuJung/issue616
feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
2026-08-27 16:56:33 +09:00
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00
정정홍andGitHub 72f2e94c1a Merge pull request from gnuboard:HeuJung/issue615
HeuJung/issue615
2026-08-25 19:12:11 +09:00
HeuJung 0253c1aa68 fix(core): sudo 업데이트의 root 캐시 산출물 소유권 정상화 + 캐시 쓰기 fail-soft
클린 7.0.9→7.0.10 실서버 업그레이드에서 전면 500 재발. 치명점을 캐시 파일
내용 역산으로 확정 — sha1('g7:_idx:g7:core') = 모든 remember 가 갱신하는
G7 캐시 키 인덱스 파일이 업데이트 마지막 root 쓰기(cache:clear 직후 버전
bump·상태/훅 캐시 재생성)로 root 소유 생성되면, 웹 프로세스의 모든 캐시
쓰기가 인덱스 갱신에서 Permission denied 로 죽어 부팅 경로가 500 이 된다
(로거 도달 전이라 laravel.log 공백). 직전 커밋의 자식측 게시 게이트만으로
부족했던 이유: 마지막 root 쓰기의 주체가 구코드 부모라 그 이후에 신코드가
개입할 지점이 없다.

- 원인 수정: CoreUpdateService::normalizeRuntimeOwnershipAfterRootRun —
 root 실행 시 storage/framework/cache·bootstrap/cache·storage/app/ext-bundles
 를 디렉토리 소유자 기준 재귀 정상화 + 그룹 쓰기 동기(업그레이드 로그
 정상화 선례 확장). 부모·자식 커맨드의 모든 흐름 종료부에 짝으로 배선 —
 이후 업데이트는 root 산출물을 남기지 않고 과거 잔재도 재귀 자가 수복
- 안전망: AbstractCacheDriver 쓰기 fail-soft — put/forget/putMany 는 false,
 remember 는 콜백 1회 실행 보장 후 저장 실패를 삼키고 결과 반환(무캐시
 동작), 경고 로그는 조합당 프로세스 1회. 구코드가 남긴 오염처럼 신코드가
 선제 개입 못 하는 상황에서도 화면이 죽지 않는다 — 캐시는 최적화다
- red→green: CacheDriverWriteFailSoftTest 4케이스, CoreUpdateRuntimeOwnershipTest
 (비-root no-op + 종료부 짝 호출 소스 훑기)
2026-08-25 19:00:12 +09:00
HeuJung 0bb33e3baa fix(core): root 프로세스의 terminating 정적 게시를 웹 자가 치유로 위임
sudo 코어 업데이트(7.0.9→7.0.10 실서버)에서 자식 프로세스(신코드, root)의
terminating 게시가 소유권 복원 이후 시점에 실행되며 캐시 락 샤드 디렉토리
(storage/framework/cache/data/xx)와 병합 번들(storage/app/ext-bundles)을
root 소유로 남겼고, 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간
Permission denied 로 죽어 전면 500 이 됐다 (laravel.log 무기록 — 로거 도달 전).

normalizeOwnership 은 게시 트리(build/ext)만 다뤘다 — 게시의 간접 write-set
(캐시 락·번들 빌드)이 사각이었다. 뒤쫓아 chown 하는 대신 root 프로세스에서는
terminating 자동 게시를 예약하지 않고 다음 웹 렌더의 자가 치유(웹 계정)에
위임한다. 명시적 ext-static:publish 는 게이트 밖(운영자 책임 — 규정 §6).

- 시나리오 매니페스트에 process_user(web/root_cli) 축 신설 — uid 조합 실측
 부재가 이 결함을 통과시킨 구조적 원인 (기존 마커 전수에 web 부여)
- red 소급 증빙: 게이트 부재 커밋본에서 신규 케이스 실패 확인 → 15/15 green
2026-08-25 17:37:24 +09:00
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00
HeuJung 56836a2b66 Merge branch 'develop' 2026-08-24 17:55:37 +09:00
HeuJung 78bb192bb0 fix(docs): 신규 번들 확장이 README 인벤토리에서 누락되던 문제 수정 및 재발 방지
7.0.9 에 추가한 비즈뿌리오 플러그인과 그 일본어 언어팩이 README.md·README.ko.md
인벤토리 표 양쪽에서 빠진 채 릴리즈 직전까지 남아 있었다.

manifest·CHANGELOG·버전 동기는 기존 하네스가 잡지만 README 인벤토리 표를 보는
검사는 없었다. 누락되어도 오류가 나지 않고 릴리즈 절차도 그대로 통과하므로,
공개 저장소 첫 화면에서 그 확장만 없는 상태로 배포된다.

- 두 README 의 플러그인·언어팩 표에 누락 행 추가
- check-readme-bundled-inventory.cjs 신설 — 네 종류(_bundled 모듈/플러그인/
 템플릿/언어팩)를 두 README 와 대조. 학습용 샘플은 현행대로 제외
- Stop 훅에 등록해 세션 종료 전 자동 검출
- 검출력 확인: 수정 전 README 로 4건(플러그인 2 + 언어팩 2)을 red 로 잡는다
2026-08-24 17:49:33 +09:00
HeuJung 9ff363bd14 Merge branch 'develop' 2026-08-24 17:27:59 +09:00
정정홍andGitHub 7b26c4beec Merge pull request from gnuboard:HeuJung/issue597
HeuJung/issue597
2026-08-24 17:07:41 +09:00
HeuJung 28d6a7752c fix(core,message_bizppurio): 브랜치 검증 발견 결함 4건 — 스냅샷 회귀·조건부 skip·낡은 주석
출시 전 /validate-branch 통합 검증에서 발견한 결함을 처리한다.

- BindingShape 라우팅 diff 스냅샷이 중간 상태에서 구워져 HEAD 가 red 였다.
 발산 517건을 덤프해 신규 41건이 전부 객체·배열 리터럴 바인딩(구 정규식
 오판 계열)이고 유해 지점 스냅샷은 이미 green 임을 확인한 뒤 기록값을 맞춘다.
- 채널 잔재 정리 검증이 게시판 모듈 활성 환경에서만 실행돼 대부분의 실행에서
 미검증으로 남았다. 정리 로직이 소유자를 묻지 않는 실제 계약대로 모듈 소유
 정의를 테스트 안에서 구성해 무조건 실행되게 한다.
- activity_log 주석이 폐기된 설계(조회 전용 전환·콘솔 위임)를 서술하고 있어
 현행 사실로 정정한다. 사용자 문구는 바뀌지 않는다.
- 설정성 테이블의 상한 없는 조회 근거와 seeder 반환 키 타입을 코드에 남긴다.
2026-08-24 17:03:42 +09:00
HeuJung 0bccd867a7 chore(release): 7.0.9 출시 준비 — 출시일자 정렬·공개 배포물 정합 결함 정리
7.0.9 릴리즈 사전 점검에서 공개 배포물에 실릴 결함을 전수 정리한다.

출시일자: 이번 사이클에 버전이 오르거나 새로 추가된 대상 10건의 CHANGELOG
출시일을 실제 공개일로 정렬. 결제 3종은 직전 릴리즈 페이로드로 이미 나갔으므로
제외했다.

내부 역할 호칭: 공개 배포물 26파일 49곳을 중립 표현으로 치환. 이미 공개된
페이로드에는 실질 0건이라 이번 릴리즈가 첫 유입이었다.

최초 출시 규격: 신규 플러그인과 그 언어팩의 CHANGELOG 를 선례 규격으로
재구성. 작성 중 확인되지 않은 서술 2건은 코드 실측으로 교정했다.

번들 ja 언어팩: 번역 값에 남은 한글 40건 정정. 같은 용어가 세 갈래로 갈려
있던 표기도 함께 통일했다. 정정값의 근거는 저장소가 이미 갖고 있었다 —
테스트가 정답으로 단언하는 값과 배포물이 어긋난 상태였다.

재발 방지: 원인은 번역 생성기의 용어집 미등록이므로 출력만 고치면 다음
빌드에서 되살아난다. 용어집에 8건을 등록하고, 규정 3건과 잔존 검출 룰을
신설했다. 룰은 수정 전 배포물로 red 가 되는지를 픽스처로 고정해 판정기가
모집단에 닿는지를 증명한다.
2026-08-24 16:18:52 +09:00
HeuJung e7fa1b5535 feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
2026-08-24 14:51:37 +09:00
HeuJung 40f62e79aa docs(changelog): 게시판·이커머스 CHANGELOG 의 비즈뿌리오 기능 항목 제거
템플릿 작성·검수·발송 기능의 실체는 전부 플러그인(sirsoft-message_bizppurio)
디렉토리에 있고 두 모듈 화면에는 주입만 되므로, 그 기능 서술은 플러그인
CHANGELOG 가 정합 위치다. 모듈 자신의 변경(통합 탭 선언 지원)을 기록한
Changed 항목만 남긴다.
2026-08-22 23:05:31 +09:00
HeuJung e17731d8cc feat(message_bizppurio): 알림톡 템플릿 라이프사이클 개편 — DB 기반 작성·검수·승인
비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내
작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다.
발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지.

- 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체
- 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request
- 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결
- SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송
- 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면
- 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단
- admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원
- 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정,
 PHPUnit 421 / Vitest 228 green, audit 0 error
2026-08-22 22:44:57 +09:00
HeuJung 6a8a537f10 feat(message_bizppurio): 임시 삭제된 비즈뿌리오 플러그인 복구 및 현행 규정 정합화
32afe9530 에서 게시판 반응 기능과 함께 임시 제거됐던 비즈뿌리오 메시징
플러그인 본체(137 파일)와 일본어 번들 언어팩(9 파일)을 삭제 직전 상태
(32afe9530~1)로 복구한다. 템플릿 신청·승인 개편의 기반 작업.

복구 범위
- plugins/_bundled/sirsoft-message_bizppurio 전체 + 번들 ja 언어팩
- 공유 파일의 비즈뿌리오 참조 복원: build-language-pack-ja.cjs 팩 정의,
 api-doc-unfilled-baseline.json(32건), docs/backend/api/README.md 표 행,
 audit coverage 노트·vite-sourcemap-env-gate 룰 주석, via 테스트 주석,
 ·AGENTS.md 확장 API 표(자동 재생성), 라우팅 패리티 스냅샷(+1)

삭제 이후 강화된 규정 2건 정합화
- TokenCheckController: 예외 원문을 메시지 키 자리에 전달하던 422 응답을
 키(token_check.failed) + errors.bizppurio_message 페이로드로 분리
 (GenericCatchStatusCodeContractTest 계약). 관리자 토스트는 errors 페이로드로
 상세 사유를 계속 표시하도록 레이아웃 동기 수정, lang ko/en/ja 키 추가
- AlimtalkTemplateController::index: base Request 주입 금지 룰에 따라
 AlimtalkTemplateListRequest FormRequest 신설 (형태 검증만 — kapi 위임 유지)

미복원(의도)
- 게시판·이커머스 CHANGELOG 의 알림톡 연결 문구 2줄은 연결 방식이 로
 재설계되므로 되살리지 않고, 완료 시점에 새 동작 기준으로 차기 버전에 기재

검증: TokenCheck 7 + AlimtalkController 10 + GenericCatch 계약 3 (PHPUnit),
플러그인 레이아웃 Vitest 137건, BindingShape 라우팅 패리티 8건 green.
audit 는 복구 전부터 baseline 처리된 API 문서 미채움 32건만 잔존.
2026-08-22 22:39:36 +09:00
정정홍andGitHub acdff53792 Merge pull request from gnuboard:HeuJung/issue610
feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
2026-08-22 22:26:42 +09:00
HeuJung 1ba67ca7cf feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가
카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을
코어+3모듈+2템플릿에서 해소.

- 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용)
 + seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인
- board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼,
 saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지),
 filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝
- ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정
- sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종)
- 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정
2026-08-22 22:21:54 +09:00
HeuJung 14eb2e39e4 Merge branch 'develop' 2026-08-22 11:07:52 +09:00
HeuJung ca4834bbb9 fix(release): 7.0.8 사전점검 전수조사 — 릴리즈 차단 2건 포함 결함 7건 수정 및 출시일 정렬
- pay_nhnkcp 1.0.3 분리: 관리자 API 권한 게이트 변경분이 이미 배포된 1.0.2
 섹션에 소급 누적돼 있어 배포본과 어긋나고 업데이트 감지도 불가하던 것을
 manifest/package/composer/lock 동기와 함께 정정
- 공개 시나리오 매니페스트의 내부 경로 참조 제거 — 릴리즈 파일 누출 검사가
 error 로 발행을 차단하던 상태 해소 (내부 정적 검사 연계는 도구 비귀속 서술로 이관)
- 모듈/플러그인/템플릿 수동 설치에서 깨진 zip·manifest 누락 같은 사용자 입력
 오류가 500 으로 위장되던 422 계약 회귀 복원 — 서비스 경계에서 도메인 예외로
 승격해 상태코드와 실패 사유를 함께 보존 (회귀 테스트 동반)
- 본인인증 정책 검색 버튼이 입력 중인 검색어를 반영하지 않고, 초기화 후에도
 입력창에 이전 검색어가 남던 문제 수정 — 폼 자동바인딩 옵트아웃 + 명시 상태
 전환, E2E 회귀 스펙과 트러블슈팅 사례 동반
- 프록시 URL 검증 비문자열 분기의 :schemes 치환 누락 보정 (회귀 테스트 동반)
- 7.0.8 사이클 출시일자를 2026-08-22 로 정렬 (코어·엔진·확장 7종·언어팩 3종,
 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
2026-08-22 10:16:08 +09:00
정정홍andGitHub df918de2a6 Merge pull request from gnuboard:HeuJung/issue605
fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
2026-08-22 09:13:33 +09:00
HeuJung 494e581973 fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과
동종 결함 전수 조사에서 나온 결함을 함께 조치했다.

권한 게이트 (KVE-2026-1893)

- NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아,
 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로
 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성
 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다.
- 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라
 core.plugins.update 를 부착했다.

첨부 해시 노출 (KVE-2026-1894 잔재)

- 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다.
 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은
 게이트를 써서 값만 가리고 필드는 유지한다.

경쟁 조건 (KVE-2026-1886 + 전수 조사)

- 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시
 사용을 막고, 선점당한 주문은 409 로 되돌린다.
- 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이
 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다.
 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이
 아니라 실제 등록 경로를 태워 고정했다.
- 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write,
 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다.
 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다.

규정·문서

- sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와
 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를
 트러블슈팅 사례로 남겼다.
2026-08-22 01:19:36 +09:00
정정홍andGitHub 510e498fec Merge pull request from gnuboard:HeuJung/issue604
fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지
2026-08-21 21:07:33 +09:00
HeuJung 0db8f9c5fb fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지
코어 엔진 번들의 정규식 lookbehind 2건이 Safari 16.4 미만에서 파싱 자체를
거부당해 iOS 15 대 전 기기에서 SPA 가 통째로 뜨지 않았다. 정규식 리터럴은
스크립트 파싱 단계에서 검증되므로 그 문법을 모르는 엔진은 파일을 한 줄도
실행하지 못하고, 번들이 async/defer 없는 동기 classic 스크립트라 폴백 경로도
없었다. 빌드 타깃(es2020)이 이를 막지 못한 이유는 ES 연도와 브라우저 지원
연도가 다르기 때문이며, 정규식 리터럴 문법은 다운레벨 자체가 불가능해
어떤 타깃 설정으로도 걸러지지 않는다.

- 두 정규식을 lookbehind 없는 형태로 교체(engine-v1.60.6). 선행 따옴표·구분자를
 소비한 뒤 캡처 그룹으로 분기하며, 식별자 인덱스가 match[1] → match[2] 로
 이동한다. 무작위 200,034 표본 퍼징에서 교체 전후 결과 불일치 0건.
 파싱 하한 실측 Safari 16.4 → 14.1 (제보자 기기 15.6 포함).

- 부팅 실패 안내를 사유별로 가른다. 번들을 받았는데 실행되지 않은 경우와
 끝내 받지 못한 경우는 사용자가 취할 행동이 정반대인데 같은 문구였다.
 사유는 추측이 아니라 window error 이벤트로 SyntaxError 를 관측해 판정한다
 (파싱 실패 시 script 는 error 가 아니라 load 를 발생시켜 element 이벤트로는
 구분 불가). 비호환 분기는 새로고침해도 낫지 않으므로 버튼을 렌더하지 않는다.
 인라인 부트스트랩은 ES5 를 유지해야 이 안내 자체가 구형에서 살아남는다.

- 재발 방지: 배포 JS 산출물의 브라우저 하한 초과 문법·API 를 정적 검사로 차단.
 부팅 임계 번들(코어 엔진·템플릿 컴포넌트)은 파싱에 실패하면 안내 화면조차
 렌더되지 않으므로 하한과 같은 버전을 요구하는 정규식 리터럴 문법도 금지한다.
 lookbehind 의 Safari 16.4 는 하한과 정확히 같아 하한 초과만 보는 검사로는
 영원히 잡히지 않았다 — 그 지점이 이번 사각이었다.

- 지원 브라우저 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)과, 하한 미만
 브라우저에서도 화면 표시와 기본 이용은 유지한다는 방침을 문서에 명시.

부수 수정: network-resilience E2E 4건이 자산 URL extensionless 모드에서 URL
glob 이 한 건도 매칭되지 않아 실패하고 있었다. 같은 파일의 컴포넌트 번들
테스트만 이중 모드를 알고 형제 케이스는 몰랐던 불균형이라 매처를 통일했다.
2026-08-21 20:55:57 +09:00
정정홍andGitHub 80d16c80bc Merge pull request from gnuboard:HeuJung/issue603
fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
2026-08-21 17:48:10 +09:00
HeuJung 03cbb99196 fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.

플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.

실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
2026-08-21 17:09:30 +09:00
HeuJung 565f6c0117 chore(pay_kginicis): 7.0.7 배포 이후 변경분을 1.1.2 로 분리
CBT 연결 점검의 프록시 경유 수정과 코어 최소 버전 상향이 이미 배포된
1.1.1 섹션에 누적돼 있었다. 배포본과 내용이 어긋나지 않도록 1.1.2 로 분리한다.
2026-08-21 13:53:49 +09:00
HeuJung d8ca281464 chore(release): 코어 7.1.0 → 7.0.8 하향 및 7.0.7 이후 변경분 출시일 정렬
프록시 설정 추가분을 마이너(7.1.0)가 아닌 패치(7.0.8)로 내보낸다.
7.0.7 이후 변경된 코어·템플릿·플러그인·언어팩의 출시일을 2026-08-21 로 맞춘다.

>=7.1.0 을 요구하던 본인인증 플러그인의 코어 최소 버전을 >=7.0.8 로 내린다 —
그대로 두면 존재하지 않는 버전을 요구해 플러그인이 자동 비활성화된다.
앞선 작업에서 누락된 package-lock.json 버전(1.0.3)도 함께 맞춘다.
2026-08-21 13:53:42 +09:00
정정홍andGitHub 6d2677e7bd Merge pull request from gnuboard:HeuJung/issue600
feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
2026-08-20 17:05:39 +09:00
HeuJung a7b7c64573 feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.

게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의
조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질
게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다.

적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께
경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은
OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증
승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은
원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다.

저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
2026-08-20 17:01:27 +09:00
HeuJung 0e8b625436 Merge branch 'develop' 2026-08-19 09:20:00 +09:00
HeuJung 8fa97bc8bd docs(changelog): 7.0.7 릴리즈 공개 제보자 attribution 전수 검사 반영
- 이커머스 「삭제된 PG」 항목에 @Tuwasduliebst 제보 attribution 부착
 (제보 8/12 → 수정 8/14 — 같은 배치 제보 중 유일한 누락)
- 언어팩(ja) 5종의 공개 이슈 파생 번역 항목 11건에 원본 ko attribution 승계
 ( — core-ja/admin_basic-ja 기존 선례와 기준 통일)
- README 기여자 목록 변경 없음(신규 기여자 없음), ko↔ja 동기 검사 통과
2026-08-19 09:17:01 +09:00
정정홍andGitHub f0562a8553 Merge pull request from gnuboard:HeuJung/issue596
docs(changelog): 장바구니 선택 개수 수정에 공개 PR 기여 attribution 반영
2026-08-19 08:55:40 +09:00
HeuJung 5d58fb5c5b docs(changelog): 장바구니 선택 개수 수정에 공개 PR 기여 attribution 반영
공개 PR https://github.com/gnuboard/g7/pull/93 은 내부에서 이미 상위 호환으로
수정 완료된 결함과 중복이라 코드는 이식하지 않고, 지시에 따라 sirsoft-basic
1.1.1 CHANGELOG 의 해당 항목 attribution 에 수정 기여를 병기한다. 동일인의
제보+수정이므로 이슈 번호만 묶고 핸들은 1회 표기.
2026-08-18 23:12:00 +09:00
HeuJung 8bf40465df docs(release): 7.0.7 릴리즈 CHANGELOG 섹션 내 Changed 카테고리 중복 병합
병렬 검수 병합 과정에서 미릴리스 신규 버전 섹션 4곳(pay_kginicis 1.1.1,
pay_nhnkcp 1.0.2, pay_nicepayments 1.0.2, g7-core-ja 1.0.6)에 ### Changed
헤딩이 두 번 생긴 것을 하나로 병합. 불릿 내용은 무변경이며, 이미 공개
배포된 과거 버전 섹션의 동형 중복 12건은 소급 수정 금지 규정에 따라 유지.
2026-08-18 12:55:24 +09:00
HeuJung 6a756bdec1 docs(release): 7.0.7 출시 연기 — 릴리즈 CHANGELOG 27종 날짜 2026-08-18 → 2026-08-19 변경
코어·엔진·모듈 3종·플러그인 10종·템플릿 2종·언어팩 11종의 미공개 working
섹션 헤딩 날짜만 변경 (버전·콘텐츠 무변경).
2026-08-18 11:46:49 +09:00
HeuJung 07c459f0ff Merge branch 'develop' of https://github.com/gnuboard/dev-g7 into develop 2026-08-18 11:04:01 +09:00
HeuJung 932866da8f fix(ecommerce): 숨김 리뷰 이미지의 관리자 썸네일을 한시 서명 URL 로 복구
KVE-2026-1914 S-2 게이트가 숨김(HIDDEN) 리뷰 이미지를 무조건 404 차단해,
관리자 리뷰 화면의 <img> 썸네일(인증 헤더를 실을 수 없는 렌더 경로)이 깨졌다.
게시판·페이지 첨부와 동일 설계로, 게이트를 통과한 관리자 응답의 직렬화
시점에만 30분 한시 서명 download URL 을 발급하고 서빙 엔드포인트가 유효
서명을 허용한다. 무서명·변조·만료 요청 게이트와 전시중 리뷰의 무서명
공개 URL(직접 URL/CDN 포함)은 종전과 동일하다.

숨김 리뷰가 이미지와 함께 직렬화되는 경로는 관리자 응답뿐이므로(공개
목록은 VISIBLE 필터, 작성 직후는 이미지 미로드) 서명 발급이 곧 게이트
통과 자격의 위임이다.
2026-08-18 10:54:26 +09:00
HeuJung 04f1066e66 fix(release): 7.0.7 사전점검 실측 발견 결함 6건 보완 및 규정 반영
브라우저·SEO 실측(T1~T7 매트릭스 + 봇 렌더 스윕)에서 발견된 결함을 보완한다.

- 코어: 회원 일괄 상태변경 응답 메시지의 :count 플레이스홀더 미치환 수정
- 게시판·페이지: 비밀글·삭제글·미발행 첨부 썸네일이 KVE-2026-1914 게이트로
 깨진 것을 한시 서명 preview URL(게이트 통과 직렬화 발급 + 유효 서명 허용)로
 복구 — 무서명·변조·만료 요청 게이트는 종전과 동일
- 페이지: 발행/미발행 전환(after_publish)에도 SEO 캐시·사이트맵 갱신 구독
- 게시판: 게시판 디렉토리(전체 목록 + 내장 최근글)에 열람 권한 필터 적용
- 검색 화면: searchResults 데이터소스 auth_mode=optional 선언으로 로그인
 회원 검색 범위 복구 + data-sources.md 선언 의무 규정화(coverage 등록)
2026-08-18 10:38:50 +09:00
HeuJung 4b986be0b2 Merge: 7.0.7 병렬 검수 수정분 통합 — 구조분해·JSON-LD 중복 구현 단일화, 양측 고유 수정 보존
동일 결함 2건(표현식 배열 구조분해 회귀, JSON-LD 이스케이프)을 양 세션이 독립
구현해 충돌. 해소 기준:

- 평가기 구조분해: JS iterator 시맨틱 구현 채택(구 평가기 new Function 패리티가
 회귀 수정의 본래 의도). 관대(undefined) 바인딩 구현과 그 단언 1건은 폐기.
 단위 테스트는 양측 병합(구조분해 10케이스), E2E·시나리오 축은
 destructuring_param 단일 케이스로 통합. 무관 심볼의 @since 소급 변경은 원복.
- JSON-LD: JSON_HEX_TAG|JSON_HEX_AMP(상위집합) 채택, XSS 단위 테스트 중복 제거.
- 양측 고유 수정 각각 보존: composeTitleSuffix 접미사 조립(타 세션) /
 스케줄 확장 명령 웹 검증 폴백·SeoMetaResolverTest 설정 격리(본 세션).
- min.js 는 병합 소스로 core:build --production 재빌드.

검증: SafeExpressionEvaluator 120/120, SeoMetaResolverTest 68/68, 배포 번들
E2E 6/6. 실측 — 게시판 환경설정 권한 섹션 60컨트롤 렌더, 장바구니 담기 201,
봇 title 접미사 공백 복원·매달린 구분자 제거, 검색 XSS 봇/_escaped_fragment_
양 경로 이스케이프 확인.
2026-08-18 10:16:50 +09:00
HeuJung a1e1706ddc fix(release): 7.0.7 회귀 검수 발견 결함 3건 수정 — 표현식 배열 구조 분해·스케줄 확장 명령 웹 검증·JSON-LD 이스케이프
- 표현식 평가기가 화살표/함수 파라미터의 평탄한 배열 구조 분해(([k, v]) =>, 엘리전 홀)를
 지원하지 않아 배포 레이아웃 60여 파일(게시판 권한 computed·검증 오류 표시)이 평가 실패하던
 7.0.7 평가기 교체 회귀 수정 (engine-v1.60.5) + --production 재빌드.
- 스케줄 저장 검증이 HTTP 문맥에서 확장 커맨드를 해석하지 못하던 문제(확장 프로바이더의
 runningInConsole 등록 게이트로 Artisan 레지스트리 부재)를 프로바이더 $commands 선언 폴백으로
 수정 — 판정 기준(클래스 네임스페이스 + 실제 명령명 대조) 동일, fail-closed 유지.
- 봇 렌더 JSON-LD json_encode 에 JSON_HEX_TAG 추가 — 사용자 입력(검색어·게시글 제목)의
 </script> 조기 종료로 인한 저장형 XSS 차단, schema.org 데이터는 무손실.
- SeoMetaResolverTest 의 og_default_site_name 미고정 환경의존 실패를 setUp 고정으로 격리.
- 루트 CHANGELOG 7.0.7 Security/Fixed 각 1건(기출시 결함만), 시나리오 매니페스트 2건 갱신.
2026-08-18 10:00:01 +09:00
HeuJung 0bb2ab9b49 fix(seo,engine): 7.0.7 검증 발견 결함 수정 — 표현식 구조분해 회귀·타이틀 접미사 조립·JSON-LD 이스케이프
- SafeExpressionEvaluator 교체(engine-v1.60.0)가 구 평가기가 허용하던 화살표
 파라미터 배열 구조분해(([k, v]) / ([, vid]))를 누락해 해당 문법을 쓰는 액션
 params 가 미평가 원문으로 전송, 장바구니 담기/바로 구매가 전 상품 불능이던
 회귀를 수정 (engine-v1.60.5, production 재빌드 동반). 저장소 레이아웃 54파일
 104곳 사용 형태 전수를 단위/E2E 테스트로 잠금
- TrimStrings 가 타이틀 접미사의 선행 공백을 제거해 봇 렌더 제목이
 "제목| 사이트명" 으로 접착되고 빈 제목 화면은 "| 사이트명" 이 매달리던 기존
 결함 수정 — 조립 규칙을 composeTitleSuffix 단일 지점(SSoT)으로 정규화
- JSON-LD 직렬화에 JSON_HEX_TAG 미적용으로 검색어의 </script> 가 스크립트
 컨텍스트를 조기 종료해 실행 가능한 script 요소가 재생성되던 반사형 XSS(기존
 결함) 차단 — 태그 문자 이스케이프, 구조화 데이터 의미는 보존
2026-08-18 09:53:50 +09:00
HeuJung b3d0da9355 fix(release): 7.0.7 릴리즈 검증 발견 결함 전수 처리 및 출시일 8/18 정렬
7.0.7 페이로드 전수 검증에서 발견된 error 4건·warn 14건을 릴리즈 전에
처리했다. 미출시 분량의 자체 결함(회원 통합검색 500, 관리자 스코프 403 의
500 위장 등)이므로 페이로드에 합류시키고, 출시 대상 26개 CHANGELOG 의
버전 섹션 날짜를 2026-08-18 로 정렬했다.

- 통합검색 import 복원 + 이커머스 관리자 typed 403 catch 대칭(테스트 동반)
- admin 판정 스니핑 사설 사본 전수 수렴(BoardResource) + 조건 스캔 테스트
- 스케줄 인터프리터 변형 표기(확장자·버전 접미사) 정규화 차단
- ckeditor5 참조 판정 배치화·fail-safe, 결제 플러그인 진단 페이로드 대칭
- 템플릿 다크 대비·상태 Select value-제어 + --production 재빌드
- Upgrade_7_0_7 잔존 중복 색인 정리 스텝, API 문서·i18n(ko/en/ja) 동기
2026-08-18 08:12:55 +09:00
HeuJung ed985dd175 chore(release): 7.0.7 공개 릴리즈 사전점검 정비 — 누출 단서 주석 정리 + 태그 관례 정합
- 공개 릴리즈 누출 검사 error 3건 해소: 소스 주석의 내부 규정 파일명·내부 스크립트
 경로 참조를 공개 문서 참조/도구 비귀속 표현으로 교체
- warn 4건 해소: 테스트·주석의 내부 역할 호칭을 "운영 실측 제보" 로 정리
- 릴리즈 스크립트(코어·확장)의 버전 태그를 공개 저장소 기존 태그 관례(v 접두사 없음)에
 정합 — 접두사 혼재로 태그 시리즈가 갈라지는 것을 차단
2026-08-17 21:36:15 +09:00
정정홍andGitHub 3ee37e8399 Merge pull request from gnuboard:HeuJung/issue594
fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단
2026-08-17 21:02:05 +09:00
HeuJung 2fb452aa44 fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단
셸 스케줄 검증이 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않아, 운영자가 bash 를 등록하면 `bash -c id` 로 임의 OS 명령이
실행됐다(KVE-2026-1653). 인터프리터는 범용 크론 용도로 등록을 허용하되,
스크립트 자리(첫 인자)에 형태 규칙을 적용해 인라인 코드·상대경로·트래버설·
artisan 우회·완전거부형(env/awk/make 등)을 저장·실행 양쪽에서 거부한다.
2026-08-17 20:59:42 +09:00
정정홍andGitHub 19424877e8 Merge pull request from gnuboard:HeuJung/issue573
feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
2026-08-17 17:12:34 +09:00
HeuJung 3ebbda5d86 feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
 change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
2026-08-17 17:04:44 +09:00
정정홍andGitHub 60652fdc07 Merge pull request from gnuboard:HeuJung/issue577
fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
2026-08-17 16:01:21 +09:00
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00
정정홍andGitHub 2b5972437b Merge pull request from gnuboard:HeuJung/issue589
HeuJung/issue589
2026-08-17 15:18:07 +09:00
HeuJung febffb173e fix(core,admin-template): GC 스케줄 허용목록 동기 + 스토리지 잔존물 GC 신설 + 스케줄 모달 저장 회귀 수정
공개 후속 전수 조사의 실작업:
- attachments:prune-orphans 관리자 예약 허용목록 등재 + 가드 테스트 도출 규칙을
 런타임 validator 와 일치 (옵션 포함 키는 영원히 매칭되지 않는 죽은 항목)
- layout-previews:cleanup 을 gc-schedules 가드 구간으로 편입 (+onOneServer)
- storage:prune-leftovers 신설: 중단된 업데이트/설치의 _pending 스테이징·코어 임시·
 vendor 스테이징(3일)과 오래된 백업(30일, 최신 1개 상시 보존), 레거시 browser.log 회수
 — 산출물 누적 + 회수 경로 부재( 동형) 7건 해소
- 언어팩 설치 임시 디렉토리 정리를 finally 로 이동 (설치 성공 시 100% 누수)
- 브라우저 콘솔 로그를 daily(7일) 로테이션으로 전환 (단일 파일 989MB 실측)
- 검수 중 발견한 스케줄 등록/수정 모달 저장 불능 수정 (Form id 부재 + 커스텀
 Select/Toggle 값 미전송) + 동형 재발 차단 audit 룰 2종 신설
2026-08-17 15:17:20 +09:00
정정홍andGitHub db49e8b9b8 Merge pull request from gnuboard:HeuJung/issue588
fix(core): 공개 템플릿 config 캐시 무효화 누락과 확장 캐시 삭제 경로 동종 결함 정비
2026-08-17 13:26:13 +09:00
HeuJung e819db9d42 fix(core): 공개 템플릿 config 캐시 무효화 누락과 확장 캐시 삭제 경로 동종 결함 정비
공개 config.json 의 고정 캐시 키(template.config.{identifier})가 어떤 갱신
경로에서도 forget 되지 않아 template:update/cache-clear/비활성화/삭제 후에도
최대 1시간(TTL) 이전 manifest 로 응답되던 결함(공개 )을 수정한다.

- TemplateManager::clearTemplateCache 공개화 — config/components_manifest(두
 변종)/현재 버전 routes·language 키 능동 forget, updateTemplate cleanup 호출
- forgetLayoutCacheKeys 가 편집기용 .with_source_meta 변종까지 삭제
- ext.cache_version 쓰기를 incrementExtensionCacheVersion 단일 지점으로 통일
- module/plugin:cache-clear 의 유령 키 forget 을 실재 캐시(레이아웃 서빙/해석
 캐시, 상태 키, 미들웨어 인덱스, 버전 bump) 무효화로 교체
- 공개 routes/language/layout API 의 ?v 생략 폴백을 0 → 현재 버전으로 교정
2026-08-17 13:22:48 +09:00
정정홍andGitHub 09a700e4c7 Merge pull request from gnuboard:HeuJung/issue571
HeuJung/issue571
2026-08-17 03:18:10 +09:00
HeuJung 01526faac2 chore(core): 리베이스 후속 — LSP 일시 파일 ignore 추가 + 충돌 해소 검증 이력
- storage/framework/.gitignore 에 lsp-*.php 추가 — LSP/tinker 가 만드는
 일시 파일이 add -A 에 혼입되는 것을 차단 (추적 이력 없음 확인)
- develop 리베이스 충돌 4파일 해소 근거와 후속 검증(신규 룰 --all 전수 0건,
 병합 테스트 green, 활성 디렉토리 재동기) 기록
2026-08-17 03:11:07 +09:00
HeuJung 7f457a05b3 fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비
- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고,
 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더
- 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록)
- 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝,
 결제 3플러그인 컨트롤러 51지점 모델 파생 전환)
- audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase
 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
2026-08-17 03:02:26 +09:00
정정홍andGitHub 3ccfb24417 Merge pull request from gnuboard:HeuJung/issue575
feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
2026-08-17 02:51:54 +09:00
HeuJung cfc2caba1b feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
공개 (@Tuwasduliebst 제보) 대응.

- ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰,
 filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인,
 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본
 설정 백필 upgrade step
- board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI,
 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정
- ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정
- page: 임시 첨부 GC
- 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시
 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자
 메뉴 설치·업데이트 자동 동기화(F1·F7)
- 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정
 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서,
 hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
2026-08-17 02:44:54 +09:00
정정홍andGitHub 1d895a6428 Merge pull request from gnuboard:HeuJung/issue568
fix(security): KVE-2026-1914/1915/1919 remediation 전건
2026-08-17 01:49:14 +09:00
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00
정정홍andGitHub cd84b63d2b Merge pull request from gnuboard:HeuJung/issue574
fix(core,board,ecommerce,page,pay): 예외→응답 매핑 정비와 저장 배송지 승계 결함 수정
2026-08-16 00:56:44 +09:00
HeuJung b16c5c38a5 fix(core,board,ecommerce,page,pay): 예외→응답 매핑 정비와 저장 배송지 승계 결함 수정
generic catch 가 4xx 를 돌려주면 인프라 장애가 입력 오류로 위장돼 장애 인지가
늦어지고, 이미 번역된 예외 메시지를 응답의 메시지 키 자리에 넘기면 키 해석에
실패해 원문이 그대로 화면에 나간다. 두 형태를 코어와 전 번들 확장에서 함께
정리하고, 판정기를 한 확장이 아닌 코어 모집단에 두어 확장 밖 동형 결함도
red 가 되게 했다 — 그 사각에 실제로 15건이 있었다.

주소록에서 해외 배송지를 고르면 국내 6필드만 옮겨 담아 해외 주소가 통째로
사라지던 결함도 함께 고쳤다. 저장은 200 으로 성공 처리돼 주문 상세를 다시
열기 전에는 드러나지 않았다.
2026-08-16 00:53:21 +09:00
정정홍andGitHub b33559e462 Merge pull request from gnuboard:HeuJung/issue570
fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
2026-08-14 17:29:38 +09:00
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00
정정홍andGitHub d7ef20668a Merge pull request from gnuboard:HeuJung/issue564
docs(core,scripts): 요구사항 선언과 실제 검증 범위 정합화 + 내부 하네스 3건
2026-08-14 11:55:01 +09:00
HeuJung 7e6f41cc53 docs(core,scripts): 요구사항 선언과 실제 검증 범위 정합화 + 내부 하네스 3건
요구사항 문서가 "지원한다"고 선언한 항목과 실제로 검증되는 범위가 어긋나 있었다.
지원 브라우저 3종 "최신 2개 버전" 선언에 비해 자동 테스트는 Chromium 단일이었고,
같은 형태의 불일치가 문서 전반에 있었다 — 디스크 700MB↔게이트 500MB, 확장 33개
필수 선언↔게이트 14개, HTTPS "필수"↔비차단 경고.

선언을 낮추는 대신 검증 수준을 명시하는 방향으로 정리했다. 항목마다 "설치 프로그램이
자동 검사" 와 "운영자 확인 필요" 를 구분하는 절(§9)을 두어, 이슈가 지적한 질문
("지원 선언이 어느 수준의 검증을 의미하는가")을 문서 전반으로 일반화했다.

조사 중 드러난 내부 하네스 결함 3건도 함께 고쳤다.
- 테스트 스코프 훅이 npm run test:e2e:smoke 를 오차단 — 필터가 package.json 안에
 있어 명령 문자열에서 보이지 않았다. 회귀 케이스 3건 red 확인 후 수정
- 브라우저 테스트 안내 문서가 없어진 스크립트·디렉토리를 가리키고 있었다
- E2E 동반 강제 룰의 확장 config 경로 패턴이 실제 경로와 달라, 확장 8개의 config
 변경이 인정되지 않았다

E2E 브라우저 확대는 하지 않는다 — 도입 조건만 문서에 못박았다.
2026-08-14 11:46:55 +09:00
정정홍andGitHub 071719e570 Merge pull request from gnuboard:HeuJung/issue572
fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
2026-08-14 11:19:34 +09:00
HeuJung 4e1ec6e001 fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분.

- 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화
- 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할
- 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일
- 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope)
- 비밀번호 정책 문구를 도메인 중립 라벨로 정정
- 정적 검사 2종·검증 스킬 설계 정합성 축 신설
2026-08-14 11:06:25 +09:00
정정홍andGitHub 49c155509e Merge pull request from gnuboard:HeuJung/issue562
fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
2026-08-14 10:33:50 +09:00
HeuJung c341fe28a4 fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
공개 (S3 드라이버) × (공개 자산 CDN) 병합 결과를 실 AWS S3 로 교차 실측하다
발견한 결함과, 그 과정에서 드러난 설정 화면의 사각을 함께 처리한다.

## 교차 출처 공개 자산 요청

공개 자산 디스크를 켜면 첨부·이미지의 download_url 이 외부 origin 절대 URL 이 되는데,
API 클라이언트가 URL 출처를 보지 않고 Authorization 을 붙이고 있었다. 그 결과
① 세션 토큰이 제3자 origin 으로 전송되고(응답을 허용하는 CDN 에서는 접근 로그에 남는다)
② 안전목록 밖 헤더라 preflight 가 발생해 CORS 미설정 버킷에서는 이미지가 통째로 실패했다.
상점 화면은 <img> 라 무증상이고 관리자 화면에서만 발현해 발견이 늦었다.

- 코어: 동일 출처 요청에만 Authorization 첨부 (상대 경로는 항상 동일 출처로 간주)
- 템플릿 2종: 교차 출처 URL 은 인증 XHR 없이 그대로 사용 (썸네일·갤러리·다운로드 3지점씩)

로컬 public 디스크나 허용적 CORS 를 주는 오브젝트 스토리지에서는 재현되지 않는다 —
외부 origin 과 CORS 미제공이 겹쳐야 드러나므로 회귀 테스트는 두 조건을 모두 고정한다.

## S3 접속 필드 노출 조건

파일 스토리지는 로컬로 두고 공개 자산만 S3 로 보내는 구성에서 접속 정보를 넣을 화면이
없었다(도움말이 보이지 않는 필드를 가리켰다). 두 축 중 하나라도 s3 면 블록을 노출하고,
연결 테스트 게이트의 적용 대상도 같은 조건으로 넓힌다. 재검증 트리거는 S3 접속 설정
변경으로 한정한다 — 트리거까지 넓히면 "파일 스토리지=s3 에서 공개 자산만 변경" 하는
기존 흐름이 저장 불가가 된다.
2026-08-14 10:32:24 +09:00
정정홍andGitHub a5a7caf8ba Merge pull request from gnuboard:HeuJung/issue562
feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
2026-08-14 08:09:02 +09:00
HeuJung 057e002ad1 feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
StorageInterface::url 의 public 하드코딩을 완화하고 core.storage.filter_url 훅을
신설해, 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)의 직접 URL 서빙을 옵트인으로
열었다. 미설정 시 기존 스트리밍 100% 보존.

디스크 결정은 확장 개별 설정 > 코어 전역 > 미설정 순이며, 서빙·삭제·이동은 행에 기록된
disk 를 따라 전환 전후 자산이 혼재해도 각자 실제 저장 위치를 향한다. 고아 disk(공급
플러그인 비활성화)는 스트리밍으로 폴백해 500 이 되지 않는다.

공개 이슈 (@lyg-kaban 건의) 대응.
2026-08-14 08:05:54 +09:00
정정홍andGitHub 37fad55333 Merge pull request from gnuboard:HeuJung/issue563
fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
2026-08-13 15:16:05 +09:00
HeuJung ea58c606a2 fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이
동작하지 않던 결함의 전면 수정.

- S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함
 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단
 (부트 시 확장 부재 감지 → predis 자동 폴백)
- storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환
 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙)
- 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체
 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500
 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control)
- S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정
 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와
 동일 설정(endpoint/path-style)으로 정렬
- 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께
 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정)
- 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만
 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소
- env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을
 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리)
- 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및
 websocket 유령 설정 키 제거
- 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
2026-08-13 15:15:19 +09:00
정정홍andGitHub c128a6ea3a Merge pull request from gnuboard:HeuJung/issue551
fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
2026-08-12 23:49:06 +09:00
HeuJung 55bc9399e6 fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
구간별 배송비의 종료값이 구간에 포함되지 않아 경계값이 무료배송으로
새고, 상품 옵션의 g/cm³ 저장값을 정책의 kg/L 로 환산하지 않아 금액이
1000배로 청구되던 문제를 수정한다.

- 구간 매칭을 종료값 오름차순 사다리로 교체 — 시작값은 표시 전용이
 되어 기존 저장 데이터의 두 형태 모두에서 정확히 계산된다
- 단위 환산을 배송비 계산 한 지점으로 모으고, 부피무게와 실무게를
 같은 kg 단위로 비교하도록 교정
- 계산이 조용히 0원이 되는 설정(구간 미등록·단위값/기준금액 누락·
 중간 구간 무제한·계산 API 폴백 0원)을 저장 시점에 차단하고,
 런타임 잔여 경로에는 경고 로그를 남긴다
- 연속성 규칙을 정책 타입별로 분리해 소수 구간(2.5kg) 저장을 허용
- 상품 옵션 무게/부피 응답 왕복, 주문 총 무게/부피 실값 기록
2026-08-12 23:43:08 +09:00
정정홍andGitHub 9131ee6bb7 Merge pull request from gnuboard:HeuJung/issue557
fix(ecommerce,board): 쿠폰 중복사용 라디오 boolean 문자열화로 저장이 422 실패하던 결함 수정
2026-08-12 16:24:29 +09:00
HeuJung d675c8ac9f fix(ecommerce,board): 쿠폰 중복사용 라디오 boolean 문자열화로 저장이 422 실패하던 결함 수정
공개 제보(gnuboard/g7 @lyg-kaban): 관리자 쿠폰 폼의 「타 쿠폰과 중복 사용」
라디오를 클릭하면 자동바인딩이 DOM 문자열 "true"/"false" 를 폼 상태에 그대로
저장해 서버 boolean 규칙에서 422 로 저장이 실패했다. 표시 계층은 느슨 비교로
정상이라 저장 시점에야 드러나는 결함.

- 레이아웃: is_combinable 라디오 autoBinding 해제 + String 표시 바인딩
 + change 액션 === 'true' boolean 캐스팅
- 서버: Store/UpdateCouponRequest prepareForValidation 에서 문자열 표기 수용
 boolean 정규화 (해석 불가값은 유지해 boolean 규칙이 422 처리)
- 게시판 is_secret 동일 잠재 위험 서버 방어 + User Store/UpdatePostRequest 의
 Board 직접 조회 4곳을 Repository 경유로 교체 (board 1.0.4)
- 회귀: PHPUnit 11건(RED 선행) + 레이아웃 쓰기경로 타입 프로브 + E2E 왕복 spec
 + 시나리오 매니페스트(cases=32)
- 재발 차단: ·AGENTS.md 금지 패턴 + audit 룰
 layout-boolean-option-autobinding 신설(전수 0건) + 형제 룰 4종
 extensions/** glob 누락 보강 + 트러블슈팅 사례 8 + 회귀 테스트
2026-08-12 16:22:33 +09:00
정정홍andGitHub 7c10f45aaf Merge pull request from gnuboard:HeuJung/issue555
HeuJung/issue555
2026-08-12 15:24:30 +09:00
HeuJung b1b5bfa417 feat(core,ecommerce,ckeditor5): 이미지 업로드 필터 훅 대칭화
공개 제보(리뷰 이미지에 filter_upload_file 부재)를 계기로 전 이미지 업로드
경로의 훅 트라이어드를 대칭화한다. 확장이 업로드 파일을 저장 전에 가로채
압축/변환할 수 있도록 하기 위함.

- 리뷰 이미지·CKEditor5 이미지 업로드에 filter_upload_file 필터 훅 추가
- 레이아웃 편집기 첨부 업로드에 훅 트라이어드(before/filter/after) 신설
- ckeditor5 1.0.2 버전 동기(manifest/package/composer/lock), 각 CHANGELOG 기재,
 README 기여자 등재, hooks.md 업로드 트라이어드 및 필터 반환 계약 문서화
- 시나리오 매니페스트(upload-filter-hook-parity) + 3계층 훅 발화 테스트 추가
2026-08-12 15:23:50 +09:00
정정홍andGitHub 709fbff278 Merge pull request from gnuboard:HeuJung/issue554
fix(admin_basic,core,ecommerce,plugins,basic): stale dist 전수 재빌드 + 소스맵 게이트 audit 룰 신설
2026-08-12 14:35:13 +09:00
HeuJung 0bcd42ac04 fix(admin_basic,core,ecommerce,plugins,basic): stale dist 전수 재빌드 + 소스맵 게이트 audit 룰 신설
공개 — admin_basic 1.0.4 가 이전 소스로 빌드된 stale dist 를 릴리즈에
실어 주문 상세 합계행에 표현식 원문이 노출되던 회귀를 1.0.5 재빌드로 재수록.

- admin_basic 1.0.5: --production 재빌드 + 서빙 번들 회귀 E2E(red→green) + 시나리오 매니페스트
- 전수 점검: sourcemap 잔존 9확장+코어 3번들 --production 재빌드, untracked .map 3개 삭제
 (+1 bump: gdpr 1.0.3 / tosspayments 1.0.2 / verification_kginicis 1.0.3 /
 verification_nhnkcp 1.0.1 / basic 1.1.1 — 미공개 워킹 5건은 CHANGELOG 누적)
- 리터럴 대조 전수: 기능 drift 는 admin_basic 단독 확정(타 대상 차집합 0), 릴리즈 패리티 7/7 일치
- board/page 사문 build 스크립트 제거 (vite.config 부재 — module:build --all 실패 요인)
- audit 룰 신설: extension-dist-sourcemap-reference(error),
 extension-dist-source-literal-sync(warn) + coverage/룰 테스트/규정 문서 정정
2026-08-12 14:31:26 +09:00
정정홍andGitHub 2de257491f Merge pull request from gnuboard:HeuJung/issue550
HeuJung/issue550
2026-08-12 10:55:22 +09:00
HeuJung b44f2befd7 fix(console): 콘솔 없는 실행에서 확인 프롬프트가 무한 대기하던 문제 수정
Symfony 는 posix_isatty 로 비대화 실행을 감지해 isInteractive 를 false 로
내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
--no-interaction 없이 CI·스케줄러·에이전트에서 부르면 isInteractive 가 true 로
남고 ask 가 오지 않을 응답을 무한히 기다린다. 프롬프트 출력마저 버퍼에 갇혀
예외도 로그도 없이 "커맨드가 느리다" 로만 관측된다.

posix 없이도 동작하는 stream_isatty 로 한 번 더 판정한다. 판단 재료가 없으면
질문하는 쪽으로 떨어진다 — 물어볼 수 있는데 안 묻는 것이 더 위험하기 때문이다.

이 트레이트를 공유하는 13개 커맨드의 기본값을 전수 확인했다. 파괴적 확인은
모두 false(중단)이고 true 는 search:index --repair 하나뿐이라, 확인 없이
파괴적 동작이 수행되는 경로는 생기지 않는다.

문서는 이미 "비TTY → default 즉시 반환" 을 계약으로 명시하고 있었다 —
구현이 그 계약을 Windows 에서 지키지 못한 것이다.
2026-08-12 10:53:04 +09:00
HeuJung 7765695339 fix(cart): 장바구니 전체 선택 개수 최초 로딩 미표시 수정
표시식이 selectedCount 를 fallback 없이 보간했다. 이 키는 체크박스 change
핸들러에서만 기록되므로 최초 마운트 구간에 undefined 이고, 엔진이 undefined 를
예외 없이 빈 문자열로 서식해 (/27) 로 보였다. 선택 상태의 SSoT 인 selectedItems
파생으로 교체해 주문 요약과 계산 방식을 일치시켰다.

같은 결함 클래스(초기값 없는 키를 옵셔널 없이 하위 접근) 28곳을 함께 방어하고,
3조건 동시 충족만 잡는 audit 룰로 codify 했다. 브라우저 실측(T9)에서 다운로드
이력 모달의 iteration 문자열 선언(매 렌더 TypeError)·미존재 아이콘명·정적 id
중복 3종을 추가로 발견해 정정했다.

공개 이슈: gnuboard/g7
2026-08-12 10:17:22 +09:00
정정홍andGitHub 95957bb167 Merge pull request from gnuboard:HeuJung/issue547
fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
2026-08-12 10:12:31 +09:00
HeuJung c77ddee03b fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
2026-08-12 10:10:01 +09:00
HeuJung c09711f609 Merge branch 'develop' 2026-08-11 15:58:47 +09:00
정정홍andGitHub 5ae34002f3 Merge pull request from gnuboard:HeuJung/issue546
fix(readme): 영문 번들 언어팩 목록을 표로 교체 — 인라인 코드 나열 렌더링 깨짐
2026-08-11 15:57:29 +09:00
HeuJung 22175d42b0 fix(readme): 영문 번들 언어팩 목록을 표로 교체 — 인라인 코드 나열 렌더링 깨짐
한 문단에 인라인 코드 스팬 17개를 늘어놓은 형태가 렌더러에서 깨져 식별자가
사라지고 쉼표만 남았다. 한국어판과 같은 표로 바꾸면 이 형태 의존이 사라진다.

부수 효과로 표별 행 수가 한국어판과 완전히 일치하게 되어, 두 README 사이에
남아 있던 구조 차이가 해소된다.
2026-08-11 15:56:18 +09:00
정정홍andGitHub 284b38b16c Merge pull request from gnuboard:HeuJung/issue546
docs(readme): README 영문 기본화 + 한국어판 분리 + 커뮤니티 기여자 하네스
2026-08-11 15:51:28 +09:00
HeuJung 5ca8f636e6 docs(readme): README 영문 기본화 + 한국어판 분리 + 커뮤니티 기여자 하네스
글로벌 방문자에게 한국어 README 는 완전한 이탈 요인인 반면 한국 방문자에게
영문 README 는 한 클릭 불편이라, 비대칭 비용에 맞춰 영문을 기본값으로 둔다.
한국어판은 이력을 보존하도록 git mv 로 옮기고 본문은 그대로 둔다.

커뮤니티 기여자 목록은 공개 CHANGELOG 의 제보자 표기에서 도출한다. 손으로
옮겨 적으면 이슈가 쌓일수록 누락·중복이 생기므로 생성기와 판정기를 한
스크립트로 두고, 세션 종료 시 두 README 와 CHANGELOG 의 불일치를 검사한다.

README 가 두 벌이 되면서 버전 뱃지 축도 둘이 됐다. 한 축이 빠져도 나머지가
통과시켜 초록이 누락을 감추므로, 각 축이 독립적으로 red 를 내는지를
합성 저장소로 고정한다.
2026-08-11 15:50:34 +09:00
3481 changed files with 429225 additions and 13423 deletions
+50 -1
View File
@@ -3,7 +3,17 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.11
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
# TRUSTED_PROXIES=*
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
# SESSION_SECURE_COOKIE=true
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -100,9 +110,25 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
# ── .env 키 단위 우선 (선택) ────────────────────────────────────────────────
# 기본값은 관리자 환경설정(admin UI)이 운영 SSoT 이고, 저장된 값이 아래 항목들의
# .env 값을 덮습니다. 이 스위치를 켜면 .env 에 **값이 명시된 항목만** 그 덮어쓰기에서
# 빠져 .env 가 권위를 갖고, 관리자 화면의 해당 필드는 편집 불가로 표시됩니다.
# 켜지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
# 주의: 이 파일은 값을 채워 배포되므로, 켜는 즉시 APP_NAME·LOG_LEVEL·
# G7_UPDATE_GITHUB_URL 같은 이미 채워진 항목이 함께 잠깁니다.
# .env 만 편집한 뒤에는 php artisan config:cache 를 다시 실행해야 반영됩니다.
# 대상 항목과 규약: docs/backend/admin-settings-access.md
# G7_ENV_PRIORITY=true
# 코어 업데이트
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
@@ -112,3 +138,26 @@ G7_UPDATE_PENDING_PATH=
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
# 부트스트랩 리소스 정적 게시 (public/build/ext) — 끄면 전면 API 폴백(종전 동작).
# 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」
# G7_STATIC_CACHE=true
# SEO 봇 캐시 상한 (config/core.php seo_cache_limits). 봇 판정은 User-Agent 뿐이라 위장이
# 가능하고, 캐시 키에 쿼리가 들어가므로 값만 바꾼 반복 요청이 매번 렌더·저장을 유발한다.
# 렌더 예산을 넘긴 요청은 오류가 아니라 일반 SPA 응답(X-SEO-Cache: BYPASS)을 받는다.
# G7_SEO_CACHE_MAX_QUERY_PARAMS=10
# G7_SEO_CACHE_MAX_QUERY_LENGTH=512
# G7_SEO_CACHE_MAX_VARIANTS_PER_PATH=50
# G7_SEO_CACHE_MAX_ENTRIES=20000
# G7_SEO_RENDER_MISSES_PER_MINUTE=60
# G7_SEO_STATS_RECORDS_PER_MINUTE=300
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
# 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다).
# 기본값은 config/app.php 의 update 절과 같다.
# G7_UPDATE_EXCLUDES=node_modules,.git,bootstrap/cache,build/ext
# G7_UPDATE_TARGETS=app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,scripts,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vite.config.devdashboard.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled
# G7_UPDATE_PROTECTED_PATHS= (기본 목록은 config/app.php 의 update.protected_paths — 재정의할 때 그 목록을 그대로 옮겨 적고 항목을 덧붙인다)
# G7_UPDATE_RESTORE_OWNERSHIP=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
# G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE=storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext
+20 -1
View File
@@ -3,7 +3,17 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.11
# 리버스 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 HTTPS 를 인식하려면
# 신뢰할 프록시를 지정합니다. 미설정 시 아무 프록시도 신뢰하지 않습니다(기존 동작).
# * = 직전 호출 IP 만 신뢰 (동일 호스트 프록시·ALB. ** 도 동일하게 동작합니다)
# IP,IP/CIDR = 지정 목록만 신뢰 (프록시가 여러 단이면 모든 단을 나열해야 합니다)
# * 은 앱이 프록시 없이는 도달 불가한 구성에서만 사용하세요 — docs/backend/reverse-proxy.md
# TRUSTED_PROXIES=*
# HTTPS 사이트에서 세션 쿠키에 Secure 속성을 강제합니다(미설정 시 요청 스킴으로 자동 판정).
# SESSION_SECURE_COOKIE=true
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -77,6 +87,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
@@ -92,3 +107,7 @@ INSTALLER_COMPLETED=true
# Telescope Configuration
TELESCOPE_ENABLED=false
TELESCOPE_DB_CONNECTION=mysql
# .env 키 단위 우선(G7_ENV_PRIORITY)은 테스트 환경에서 활성화하지 않습니다.
# 이 파일에 활성 라인을 두면 테스트가 개발 머신의 .env 명시 상태에 좌우되어
# 재현 불가능해집니다. 잠금 축은 config 를 직접 주입해 검증합니다.
+6
View File
@@ -145,3 +145,9 @@ test-results/
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
# ===== 부트스트랩 리소스 정적 게시 (bake, #122) =====
# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는
# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다.
# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
/public/build/ext/
+503 -13
View File
@@ -6,11 +6,11 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (34개)
### 백엔드 [backend/](docs/backend/) (36개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
@@ -35,17 +35,19 @@
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [reverse-proxy.md](docs/backend/reverse-proxy.md) | 리버스 프록시 환경 (Reverse Proxy) | 프록시 뒤에서는 요청이 스스로 스킴·IP 를 증명하지 못한다 — 신뢰할 프록시를 지정해야 한다 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
| [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... |
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트·자산 URL ... |
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
### 프론트엔드 [frontend/](docs/frontend/) (50개)
### 프론트엔드 [frontend/](docs/frontend/) (44개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -93,20 +95,15 @@
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 |
| [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
### 확장 시스템 [extension/](docs/extension/) (30개)
### 확장 시스템 [extension/](docs/extension/) (31개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
| [extension-documentation.md](docs/extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) | 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다 |
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
@@ -152,10 +149,10 @@
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 328 |
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -165,9 +162,10 @@
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
@@ -176,6 +174,34 @@
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
### 확장 개발자 문서 (20개 확장, 자동 스캔)
> 확장을 수정하기 전에 읽는 문서. 설계 의도 · 디렉토리 지도 · 확장점(발행/구독 훅) · 수정 시 동반 의무 · 금지 패턴을 담는다. `php artisan ext:docgen` 이 실측 부분을 유지하며, 이 표는 `{modules,plugins,templates}/_bundled/*/docs/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
| 확장 | 유형 | 에이전트 가이드 | 문서 목차 | 실측 집계 |
|------|------|----------------|----------|----------|
| `gnuboard7-hello_module` | 모듈 | [AGENTS.md](modules/_bundled/gnuboard7-hello_module/AGENTS.md) | [docs/](modules/_bundled/gnuboard7-hello_module/docs/README.md) | 훅 1 · 라우트 7 · 모델 1 · 레이아웃 3 |
| `sirsoft-board` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-board/AGENTS.md) | [docs/](modules/_bundled/sirsoft-board/docs/README.md) | 훅 90 · 라우트 80 · 모델 9 · 레이아웃 46 |
| `sirsoft-ecommerce` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-ecommerce/AGENTS.md) | [docs/](modules/_bundled/sirsoft-ecommerce/docs/README.md) | 훅 508 · 라우트 239 · 모델 47 · 레이아웃 206 |
| `sirsoft-page` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-page/AGENTS.md) | [docs/](modules/_bundled/sirsoft-page/docs/README.md) | 훅 21 · 라우트 17 · 모델 3 · 레이아웃 3 |
| `gnuboard7-hello_plugin` | 플러그인 | [AGENTS.md](plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md) | [docs/](plugins/_bundled/gnuboard7-hello_plugin/docs/README.md) | 훅 1 · 라우트 0 · 모델 0 · 레이아웃 1 |
| `sirsoft-ckeditor5` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-ckeditor5/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-ckeditor5/docs/README.md) | 훅 4 · 라우트 5 · 모델 1 · 레이아웃 2 |
| `sirsoft-daum_postcode` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-daum_postcode/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-daum_postcode/docs/README.md) | 훅 2 · 라우트 0 · 모델 0 · 레이아웃 1 |
| `sirsoft-gdpr` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-gdpr/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-gdpr/docs/README.md) | 훅 2 · 라우트 15 · 모델 3 · 레이아웃 4 |
| `sirsoft-marketing` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-marketing/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-marketing/docs/README.md) | 훅 4 · 라우트 2 · 모델 2 · 레이아웃 1 |
| `sirsoft-message_bizppurio` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-message_bizppurio/docs/README.md) | 훅 1 · 라우트 21 · 모델 2 · 레이아웃 1 |
| `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 |
| `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 |
| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 5 · 모델 0 · 레이아웃 1 |
| `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
| `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 146 |
| `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
---
@@ -252,6 +278,57 @@
| `"item"`, `"index"` | `"item_var"`, `"index_var"` |
| iteration 내 if 순서 무시 | if가 iteration보다 먼저 평가됨 |
### 위젯 값 형태 ↔ apply 경로
편집기 위젯 중 **값이 스칼라가 아닌 것**(`image` → `{url,size,repeat,position}` 객체)을 값 슬롯이 하나뿐인 apply 경로에 연결하면, 객체가 그대로 `props[key]` 에 저장되어 소비 컴포넌트가 `[object Object]` 를 URL 로 받는다. 이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 **200(HTML)** 이고, 편집기 위젯의 미리보기는 정상이라 조작 중에는 이상이 보이지 않는다. 화면의 엑박이 유일한 증상이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `image` 위젯을 `classToken`·`cssVar` 에 연결하거나 `apply` 를 생략 | 값이 스칼라로 축약되는 경로만 — `propValue`(맨 url 문자열) 또는 `backgroundImage` 를 포함한 `styleProp` 묶음(4속성 분해) |
| 축약 판정을 **값 형태 sniffing** 으로 게이트 | `widget === 'image'` 게이트 — `isImageValueObject` 는 4키 중 **하나만** 있어도 참이라 `props.tooltip = {position:'left'}` 같은 정당한 객체 prop 을 이미지로 오인해 삭제한다 |
| 축약 분기를 writer 마다 복붙 | 공용 헬퍼 `scalarizeImageValue` 단일 지점 — 이번 결함의 원인이 정확히 "방어가 `applyStyleProp` 안에만 있었다" 이다 |
| 쓰기만 축약하고 읽기는 그대로 | `propValue` 역해석이 저장 문자열을 `{url}` 로 되감는다. **표현식 문자열도 감싼다** — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이 소리 없이 소실된다 |
| 저장되지 않는 컨트롤을 `disabled` 로 남김 | 단일 값 슬롯이면 표시모드 버튼을 **컨테이너째 미렌더** — `disabled` 는 *일시적* 비활성의 시각 언어라 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다 |
| 저장되지 않는 `size` 를 미리보기에 반영 | 단일 슬롯 미리보기는 `contain` 고정 — 실제 표시 방식은 소비 컴포넌트의 클래스가 정하므로 편집기가 흉내내면 거짓 미리보기다 |
| 코어 엔진의 느슨한 판정식을 **백필**에 이식 | 백필·런타임 방어는 **엄격 판정식**(키 집합 ⊆ 4키 **AND** `url` 키 존재). 느슨한 판정식은 레이아웃 전수에서 2,219건을 매치하고 그 대부분이 정상 props 다(`{className,name,size}` 674건 · `{name,size}` 552건) — 엄격 판정식의 매치는 0건이었다 |
| 백필 순회 범위를 **노드 키 allowlist** 로 정의 | `props` 키 진입 시 모드 ON / `style` 키 진입 시 OFF 인 **모드 플래그 전역 재귀** — 실측상 `props` 안에 컴포넌트 노드가 1,150건 살아 allowlist 는 원리상 완결 불가다 |
| 두 방어선(런타임 `Img` / 백필)의 판정 강도를 따로 정함 | 완전히 같은 엄격도 — 어긋나면 한쪽만 통과하는 값이 생긴다 |
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "controls — 재사용 스타일 컨트롤"
> 정적 검사가 두 축을 함께 본다 — editor-spec 선언과 코어 엔진의 축약 분기 실존. 선언 축만 보면 코어 분기가 삭제돼도 통과하는데 결함은 부활한다. 기설치본 보정은 DB 데이터 상태라 정적 검사 대상이 아니며, 업그레이드 스텝의 회귀 테스트가 그 축을 잠근다
### 편집기 컨트롤의 데이터 연결 값 보호
레이아웃의 prop 자리에는 `{{_global.settings?.general?.site_logo_url}}` 같은 **표현식 문자열**이 저장돼 있을 수 있다. 위젯은 그 값을 해석하지 못해 **빈 컨트롤**로 보이고, 조작하는 순간 그 연결이 사라진다 — 값 하나가 아니라 **환경설정과의 연결**이 끊기고, 원문이 화면 어디에도 남지 않아 되돌릴 수단조차 없다. 예외도 콘솔 오류도 남지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 바인딩 판정·배지·잠금을 위젯마다 구현 | `ControlRenderer` 의 **공용 게이트 한 곳** — 새 위젯을 등록해도 자동 적용된다 |
| 해제 경로 없이 잠그기만 | 「직접 지정으로 바꾸기」로 **명시적으로만** 연다 |
| 「직접 지정으로 바꾸기」를 편도로 두기 | 「되돌리기」 동반 — 해제 직후엔 취소로, 값을 이미 넣은 뒤엔 원문 복구로 동작한다 |
| 파괴적 조작 표면 중 일부만 잠그기 | 그 위젯의 **전 표면** — 업로드·제거·목록 선택뿐 아니라 **관리 모달 진입**까지. 같은 동작이 두 곳에 렌더되면 하나만 잠근 것은 판단이 아니라 누락이다 |
| 위젯이 자체 처리를 가지면서 공용 게이트도 통과 | 둘 중 하나 — 자체 처리 위젯은 제외 목록에 등재하고, 그 위젯이 **원문 표시·해제·복구 셋을 모두** 제공하는지 확인한다 |
| 자체 분기가 공용 해제 경로를 막음 | 해제된 뒤에는 위젯이 평소대로 편집 가능해야 한다 |
| 해석 못 하는 값을 위젯이 흉내내 표시 | 원문 배지로 대체 — 흉내내면 거짓 컨트롤이다 |
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "데이터 연결 값 보호"
### 상속·주입 노드의 편집 표면
저장 마스킹(`stripInheritedFromLayoutContent`)은 상속(base)·주입(extension) 출처 노드를 **정상 저장에서도 항상 폐기**한다. 그래서 그 노드를 편집할 수 있게 열어 두면 편집분이 오류도 경고도 없이 사라진다 — 저장은 200 으로 성공하고 `history.clear()` 로 undo 도 불가능하다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 잠금 판정에 편집 모드 예외를 둠 (`editMode !== 'route' && isNodeLocked(...)`) | 출처 잠금이 `data_bound` 보다 **항상 우선** — "route 는 종전 동작을 한 줄도 바꾸지 않는다" 는 계약이 아니라 보수성 선언이었고, 그 보수성이 곧 결함이었다 |
| `data_bound` 의 **의미**를 좁혀 해결 | 의미는 그대로 두고(= 편집 가능, 텍스트만 잠금) **어느 노드가 그렇게 분류되는가**만 좁힌다 — 형제 경로(DnD·오버레이)가 그 계약에 실제로 의존한다 |
| 게이트 조건을 표면마다 복사 | 단일 판정 헬퍼 `isEditableLockKind` / `resolveDndDenial` 만 호출 — 한 곳만 빠져도 같은 소실 결함이 재발한다 |
| ⓘ 메뉴와 드래그 핸들만 막고 끝냄 | 인라인 편집(더블클릭)·복제·키보드 `Delete`·잘라내기까지 전 표면 — 키보드 경로는 ⓘ 메뉴를 거치지 않아 무방비였다 |
| 드래그 거부를 `zone === null` 에 기댄 간접 방어로 | commit 직전 최종 가드 — stale 슬롯이나 유효 zone 이 들어오면 그대로 이동 commit 된다 |
| 거부된 드래그가 `activeDragPath` 를 남김 | 거부 시 즉시 비운다 — 남으면 DragOverlay 가 잡힌 노드를 따라다녀 "옮길 수 있다" 는 거짓 어포던스를 준다 |
| 조상 산출 구현을 파일마다 복제 | `collectAncestors` 단일 출처 — 잠금 판정의 입력이 갈리면 "핸들은 있는데 드래그는 거부" 같은 어긋남이 조용히 생긴다 |
차단된 노드에는 「🔒 공통 레이아웃 편집」·「🔒 확장 편집」 진입 어포던스가 대신 뜬다. 상속 노드의 「데이터 영역」 라벨은 사라지지만 후자가 행동 가능한 정보이므로 순증이다.
### 컴포넌트 Props
| 금지 | 올바른 사용 |
@@ -260,6 +337,9 @@
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) |
| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) |
| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) |
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
@@ -284,6 +364,26 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| await 후 캡처된 상태 사용 | await 후 `G7Core.state.getLocal()` 재조회 |
| setState params 키에 `{{}}` 사용 | 키는 정적 경로만, 배열 조작은 `.map()`/`.filter()` |
### 저장소 B 통째 교체 금지
엔진은 폼 상태를 React `localDynamicState`(저장소 A)와 `globalState._local`(저장소 B)에 이중 저장한다. `TemplateApp.setGlobalState` 는 최상위 키를 **얕게** 병합하므로 `setGlobalState({ _local: X })` 는 B 를 patch 가 아니라 **통째 교체**한다. X 가 A 계열 스냅샷이면, A 가 아직 받지 못한 값이 조용히 사라진다.
| 금지 | 올바른 사용 |
|------|------------|
| `globalStateUpdater({ _local: <A 계열 스냅샷> })` (저장소 B 통째 교체) | live B(`getGlobalState()._local`)를 base 로 변경 키만 얹기 |
| sequence 반환값을 stale base 로 구성 | 반환값도 live B 기반 + `addMissingLeafKeys` 로 A 전용 키 보충 |
| 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 |
| `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 |
| 자동바인딩이 `__g7PendingLocalState` 에 저장소 A 스냅샷을 그대로 대입 | 렌더러와 같은 순서로 `__g7ForcedLocalFields` 를 얹고 방금 입력한 경로를 다시 적용 — pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이다 |
| 저장소 A 에만 쓰는 `_local` 경로 (`context.setState(payload)` 단독) | 같은 지배 분기 안에서 B 도 갱신 — `G7Core.state.setLocal(payload, { render: false })`. B 에 이미 키가 있으면 보충 대상에서 빠져 A 의 값이 조용히 유실된다 |
| 미러를 **형제 분기**에 두고 이 분기도 지켜진다고 간주 | 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 — 긴 함수를 통째로 보면 한 분기의 미러가 다른 분기를 면죄한다 |
A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다.
pending 은 저장소 B 의 base 가 된다 — `setLocal` 이 `currentSnapshot = pendingState || baseLocal` 로 pending 을 우선 채택하기 때문이다. 그래서 A 스냅샷을 그대로 실으면 위와 같은 통째 교체가 **저장 클릭 전, 키입력 시점에** 일어난다. 방아쇠는 memo deps 와 무관한 리렌더(폭 변경 등)가 선행하는 것이고, 그것이 없으면 성립하지 않는다.
이 결함군은 예외도 콘솔 에러도 남기지 않는다 — 화면에는 본문이 그대로 보이는데 요청 body 만 비어 나가고(작성 화면 422), 수정 화면에서는 성공 토스트와 함께 **직전 본문이 저장되어 편집분이 사라진다**. 정적 검사가 `_local` 동기화 호출의 base 를 검사한다.
### 핸들러 정의
| 금지 | 올바른 사용 |
@@ -331,6 +431,22 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
### 조건부로만 열리는 라우트군의 게이트 (디버그·개발 라우트)
특정 조건에서만 열려야 하는 라우트군은 판정을 핸들러 안이 아니라 그룹 미들웨어에 둔다. 게이트가 핸들러마다 흩어져 있으면 라우트를 추가할 때 함께 적는 것을 잊게 되고, 빠뜨려도 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이 유일한 증상이다.
| 금지 | 올바른 사용 |
|------|------------|
| 디버그 라우트 핸들러 안에서 `DebugGate::isEnabled()` 로 개별 판정 | `bootstrap/app.php` 의 그룹 래퍼(`Route::middleware(['api', 'debug.gate'])`)가 단일 부착 |
| catch-all 제외 패턴에 예약 프리픽스 누락 (`_boost`·`modules`) | `(?!admin)(?!api)(?!plugins)(?!_boost)(?!modules)` 전수 제외 — shadow 를 보호로 삼지 않는다 |
| 게이트 부착을 행위 테스트(403 이 나오는지)로만 확인 | 라우트군 전체의 `gatherMiddleware()` 에 게이트 별칭이 있는지 단언하는 등록 계약 테스트 + 모집단 가드 |
| 그룹 게이트가 라우트 캐시에도 구워질 것이라 가정 | 캐시 상태에서의 차단도 검증 — 라우트 캐시는 확장 수명주기 지점에서 자동 생성되어 오히려 흔한 상태다 |
| `withRouting(channels: ...)` 로 채널 정의를 로드 | 프로바이더에서 `require routes/channels.php` — `channels:` 인자는 `Broadcast::routes()`(게이트 없는 `/broadcasting/auth`)까지 자동 등록해 킬스위치 우회로를 만든다 |
catch-all shadow 는 보호처럼 보인다는 점이 위험하다. 가려진 라우트는 도달 불가라 게이트가 없어도 증상이 없고, 제외 패턴이 한 줄 바뀌는 순간 무방비로 노출된다 — 실제로 `_boost` GET 4종이 그 상태였고, 같은 그룹의 `DELETE clear` 는 shadow 밖이라 운영 환경·`APP_DEBUG=false` 에서 미인증 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128). 등록 계약 축을 행위 테스트로 대체할 수 없는 이유도 같다: 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않는다.
정적 검사가 디버그 라우트 파일의 개별 게이트를 차단하며, 부착·행위·캐시 축과 방송 인증 라우트 단일성은 테스트가 잠근다. 상세: [routing.md](docs/backend/routing.md) "디버그·개발 라우트는 그룹 단위로 게이트한다".
### 목록 컨텍스트 왕복 (list context round-trip)
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
@@ -355,6 +471,21 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
### 일괄 처리 목록의 선택 범위
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 누르면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다. 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 실행 후에도 오류가 남지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 선택을 화면 밖 상태에 저장하는 DataGrid 에 `selectionScope` 미선언 | `"selectionScope": "page"`(일괄 처리 목록) 또는 `"free"`(선택 자체가 저장 대상인 폼)를 **명시** |
| 일괄 처리 버튼이 달린 목록에 `"free"` | `"page"` — 대상은 언제나 "화면에 보이고 체크된 행" |
| 여러 페이지에 걸쳐 고르는 폼 선택기에 `"page"` | `"free"` — 페이지를 넘기면 앞 페이지 선택이 사라져 기능이 깨진다 |
| `selectable` 이 꺼진 화면이라 안전하다고 간주 | 체크박스가 없으면 남은 선택이 **더** 안 보인다 — 범위 판정은 `selectable` 과 무관 |
| `"selectionScope": "{{조건}}"` 표현식 분기 | 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 대상 밖 행을 싣는다 |
| 화면마다 검색·필터·페이지 액션에 선택 초기화 액션을 복제 | 컴포넌트가 단일 지점에서 정리 — 액션 복제는 한 곳만 빠져도 같은 결함이 남는다 |
정적 검사가 `onSelectionChange` 가 배선된 DataGrid 를 전수 검사해 미선언을 차단한다. 상세: [component-props.md DataGrid](docs/frontend/component-props.md)
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
@@ -369,6 +500,204 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
#### 보안 게이트 대칭성 (KVE-2026-1914/1915/1919)
접근 게이트와 권한 등급 상한은 한 경로에만 있으면 다른 경로가 조용한 우회로가 된다. 게이트는 생산 지점(부모 비밀 판정 · 소유권 판정 · 등급 판정) 한 곳을 SSoT 로 두고, 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 비밀/비공개 부모(게시글)의 비밀 게이트를 하위 리소스(댓글·첨부·문의) 독립 엔드포인트에서 재적용하지 않음 | 부모 비밀 판정을 하위 전 경로(훅·서비스·첨부 서빙·댓글 목록)에 재적용 — PostResource 한 곳만으로는 부족하다 (KVE-2026-1914) |
| hash 기반 file-serving(preview/download)이 소유권·비밀·발행 상태 검사 없이 서빙 | preview 와 download 가 동일 게이트 공유 — 미발행·비소유·비밀 첨부는 404 (KVE-2026-1914 A-3/S-1/S-2) |
| User/Role 의 쓰기·상태변경·권한부여 경로가 삭제 경로보다 약한 등급 가드 | 전 경로에 동일 등급-상한(rank ceiling)을 대칭 적용 — 정적 라우트(bulk)는 스코프 미들웨어가 우회되므로 서비스 계층에서 강제한다 (KVE-2026-1919) |
| 저장측 레이아웃 표현식 검증(SafeLayoutExpressions)을 문자열 endpoint 필드에만 부착 | 표현식이 실릴 수 있는 배열 트리 전체(`content`)에 부착 — 문자열 한정 부착은 `is_array` 가드로 무력화되어 no-op 이 된다 (KVE-2026-1915) |
| 배열 트리 순회용 규칙(`NoExternalUrls`)이 문자열 필드에도 부착돼 `is_array` 로 조용히 통과 | 규칙이 문자열 스칼라도 처리하거나, 그 자리에서 떼어낸다 — 부착만 해두고 통과시키는 상태가 최악이다 |
| 같은 저장 대상의 FormRequest 마다 부착 규칙이 다름 (편집기 경로만 누락) | Store·Update·Content·ExtensionContent 4경로 동일 강도 — 편집기 저장 경로가 가장 약하면 그 경로가 우회로다 |
| same-origin 을 `//` 접두·scheme·`/` 시작 **문자열 검사**로만 판정 | 브라우저 URL 파서와 동일 정규화(tab·LF·CR 제거 → 백슬래시를 슬래시로 → 선행 슬래시 런 접기) 후 판정 — `/\/evil.com/x.js` 는 문자열상 path 지만 브라우저는 외부 origin 으로 해석한다. 런타임·저장측·정적검사 3층이 같은 정규화를 공유한다 (KVE-2026-1915 B-2) |
| same-origin 판정만 정규화하고 **신뢰 호스트 추출(`hostOf`)은 원문**으로 판정 | 두 판정이 같은 `if` 안에서 이어지므로 정규화도 공유 — 어긋나면 `https://evil.com\@cdn.신뢰.com/x.js` 가 저장측에서만 신뢰 호스트로 보여 통과한다 |
| 정적 일괄 라우트(`bulk-*`)에 등급 상한만 적용하고 **스코프 축은 비움** | 라우트 모델이 없으면 미들웨어 스코프 검사가 스킵되므로 서비스가 상세 경로와 **같은 스코프 판정**(`PermissionHelper::filterByScope`)을 재적용 — 등급 축만 막으면 스코프 축이 우회로다 (KVE-2026-1919) |
| 권한 상한(ceiling) 검사를 DB 쓰기 **뒤**에 배치 | 가드 → 쓰기 순서 — 쓰기 뒤에 검사하면 거부된 요청이 고아 행·반영된 속성 변경을 남긴다. 회귀 테스트는 403 뿐 아니라 **상태 불변**까지 단언한다 |
| 같은 리소스를 쓰는 public 서비스 메서드 중 일부만 보호 가드 보유 | 형제 public 메서드 전부 동일 가드 — 서비스는 확장에 열려 있으므로 "현재 호출부가 없다" 는 방어가 아니다 |
| 라우트 파라미터가 Model 로 resolve 되지 않는 쓰기 경로를 미들웨어 스코프 검사에 맡김 | 서비스 계층에서 재적용 — 스킵 조건은 정적 경로(`bulk-*`·`reorder`)뿐 아니라 **파라미터명 불일치**(`{id}` + `int` 타입힌트)도 있고, 후자는 상세 경로까지 무가드다 |
| 순서 변경·일괄 작업의 스코프 거부를 "대상 일부 제외" 로 처리 | 순서·트리처럼 집합 전체가 하나의 값인 작업은 **전량 거부** — 일부만 반영하면 나머지와 어긋난 상태가 저장된다 |
| 가시성 판정을 호출부가 넘기는 옵트인 플래그(`$filters['is_public'] ?? false`)에 의존 | 열람자 신원 기반 fail-closed — 옵트인은 호출부가 빠뜨리면 조용히 열린다(읽기만 하고 쓰는 곳이 없는 사문 플래그가 실재했다) |
| 부모 상태로 판정하는 게이트를 `$x->parent && …` 로 작성 | 부모를 못 읽으면 차단 — 부모가 soft-delete 되면 조건이 성립하지 않아 통과한다 |
| 리소스 `abilityMap can_*` 을 연관/타 리소스 권한으로 게이팅 | 그 엔드포인트의 라우트 권한(SSoT)과 **같은 리소스 prefix** — 상승 방지는 게이트 이중화가 아니라 rank ceiling 이 담당한다 |
이 결함군은 예외도 오류도 남기지 않는다 — 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이다. secret 게이트 재적용·hash 서빙 게이트·rank 대칭·URL 판정 3층 동형·정적 bulk 스코프 재적용·가드 선행·형제 메서드 가드 패리티·abilityMap prefix 정합은 의미 판정 영역이라 정적 검사가 일부만 덮으므로, 부모 변경·하위 서빙·등급 경로·URL 검증 지점을 건드릴 때 코드 리뷰에서 대칭성을 확인한다.
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
### 서버가 조건에 따라 다른 형태의 200 을 돌려주는 엔드포인트
같은 엔드포인트가 설정·상태에 따라 **다른 형태의 2xx** 를 낸다면, 프론트는 형태를 판별한 뒤에 읽어야 한다. 한 형태만 가정하면 다른 형태에서 필드 접근이 그 자리에서 던지고, 그 원문이 오류 박스에 영문으로 노출된다. 서버는 정상 응답했으므로 **서버 로그에는 흔적이 없다** — 깨진 것은 클라이언트뿐이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 응답 타입을 한 형태로 고정 선언하고 `response.data.user.*` 를 바로 읽기 | 판별 유니온으로 두 형태를 표현 (`LoginResult` = `{status:'authenticated', user}` \| `{status:'two_factor_required', challenge}`) |
| 저장 지점에 형태 가드 없이 `setToken(response.data.token)` | 비어 있지 않은 문자열만 저장 — `localStorage` 는 무엇을 넣든 문자열로 바꾸므로 `undefined` 가 `"undefined"`(truthy)로 남아 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 된다 |
| 대체 형태 분기를 사용자 경로에만 두고 관리자 경로는 그대로 | 관리자 경로가 먼저 500 이 되면 설정을 되돌릴 수단까지 사라진다 — 두 경로 동시 적용 |
| `onSuccess` 후속 액션에 조건 없이 성공 처리를 나열 | 대체 형태에서 실행되면 안 되는 액션마다 `if:"{{!response.대체형태플래그}}"` |
| `onSuccess`·시퀀스 안에서 방금 저장한 상태(`_global.*`/`_local.*`)를 형제 액션의 `if`·값으로 재독 | 그 시점 컨텍스트는 아직 갱신 전이다 — `{{response.*}}` 만 읽는다 (`onSuccess` 결과는 `handleSequence` 의 상태 동기화 대상이 아니다) |
| 서버가 제공하는 기능의 프론트 화면 부재를 "미사용" 으로 간주 | 토글을 켠 사이트에서만 드러나는 미구현이다 — 서버 토글 ↔ 화면 존재를 전수 대조 |
착수 전 전수조사 축은 **"서버가 대체 형태 2xx 를 내는 엔드포인트 ↔ 프론트 처리 여부"** 다. 그리고 **"서버 토글 ON 시 프론트 화면 존재 여부"** 를 함께 본다 — 2단계 인증은 도입 후 여러 버전 동안 입력 화면이 없었고, 그 토글을 켠 사이트에서만 전원 로그인 불가로 나타났다(공개 #133).
> 상세: [auth-system.md "2단계 인증 로그인"](docs/frontend/auth-system.md)
> 정적 검사로는 잡히지 않는다 — 응답 변종은 서버 분기의 의미 판정이므로 코드 리뷰에서 확인한다.
### 제3자 라이브러리는 쓰기 경로를 지정받는다
제3자 라이브러리는 캐시·임시파일 경로를 설정하지 않으면 **자기 설치 폴더**(vendor 안)나 시스템 temp 에 쓴다. 표준 Laravel 배포는 웹서버에 `storage/` 와 `bootstrap/cache` 만 쓰기 권한을 주므로 그 쓰기는 실패하는데, 실패가 예외가 아니라 PHP 경고라 Laravel `HandleExceptions` 가 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 해시당 1회만 기록하는 라이브러리라면 캐시가 영영 생기지 않아 **매 요청이 같은 실패를 반복**한다 — 개발 머신에서는 vendor 가 쓰기 가능해 한 번 성공하고 끝나므로 재현되지 않는다 (공개 #125).
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 제3자 라이브러리를 기본 설정 그대로 인스턴스화 | 캐시·임시파일 경로를 `ExtensionStoragePath::module($id, 'cache/…')` 로 명시 — 기본값은 **라이브러리 자기 설치 폴더**다 |
| 쓰기 경로만 지정하고 디렉토리 생성은 라이브러리에 맡김 | `FilePermissionHelper::ensureWritableDirectory()` 로 **먼저 확보한다** — 라이브러리는 대개 하위 디렉토리만 만들고, base 가 없으면 경고만 내고 끝난다 |
| 확보 절차(억제 생성·chmod·setgid·소유권·쓰기 판정)를 호출부가 자기 안에 복사 | 코어 프리미티브 한 곳에서 수행 — 사본은 서로 다른 하드닝을 갖고 갈라진다(실제로 억제 mkdir·setgid·`clearstatcache` 가 사본마다 한쪽씩 빠져 있었다) |
| 확장 저장 경로를 `storage_path('app/modules/…')` 로 직접 조립 | `ExtensionStoragePath::{module,plugin}()` — 디스크 root 가 단일 출처이고 테스트 환경을 인지하므로, 확장이 `runningUnitTests()` 분기를 복사하지 않는다. 복사본은 한 곳만 빠뜨려도 그 확장의 테스트가 **운영 설정 파일을 덮어쓴다** |
| 캐시 쓰기 실패를 그대로 500 으로 흘림 | 캐시는 성능 장치다 — 확보 실패 시 캐시만 끄고 본래 기능은 계속한다. **정화·검증 자체를 건너뛰는 폴백은 금지** |
| 폴백 통지를 `Log::warning` 으로 남김 | `Log::error` — 출하 기본 로그 수준(`config/settings/defaults.json` 의 `log_level`)이 `error` 라 `warning` 은 기본 설치 상태에서 파일에 기록되지 않는다. 기능은 성공하므로 그 통지가 유일한 흔적이다 |
확보 프리미티브는 **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()` 는 `mkdir()` 을 억제 없이 부르므로 생성 실패가 `E_WARNING` → `ErrorException` 으로 승격되어, 막으려던 500 이 다른 줄에서 그대로 난다. 실패는 `bool` 과 사유(`occupied_by_file` / `ancestor_not_writable` / `create_failed` / `not_writable`)로 올라오고, 그 사유를 통지에 실어 운영자가 고칠 대상을 지목한다.
경로는 `ExtensionStoragePath` 가 해석한다. `getBasePath('cache')` 는 `Storage::disk()->path()` 위임이라 비로컬 디스크(S3 등)에서 파일시스템 경로가 아니게 되는데, 그러면 라이브러리가 상대경로를 CWD 기준으로 해석해 **조용히 엉뚱한 곳에 쓴다** — 지금 결함보다 나쁘다. 대부분의 정의 캐시는 `file_put_contents` 로 쓰는 로컬 전용 장치다.
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "제3자 라이브러리에 절대 경로를 넘길 때", [service-repository.md](docs/backend/service-repository.md) "서비스가 제3자 라이브러리를 붙일 때"
### 공개 자산 직접 URL 은 운영자 선언으로만 열린다
저장된 파일의 주소를 직접 URL(CDN)로 내보내면 서버 스트리밍 경로가 통째로 건너뛰어진다. 그 경로에 게이트가 있으면 게이트가 사라지고, 대상 저장소가 실제로는 비공개면 발급된 주소가 403 이 된다. 둘 다 예외도 로그도 남기지 않는다 — 그 이미지들만 조용히 깨지거나, 막아야 할 파일이 조용히 열린다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `filesystems.disks.{disk}.url` 이 설정돼 있다는 이유로 그 디스크를 공개로 간주해 직접 URL 발급 | 운영자가 명시 선언한 `core.storage.public_asset_disk` 와 **행 disk 가 일치할 때만** 직접 URL. `url` 설정 유무는 "URL 을 만들 수 있는가" 이지 "익명 읽기가 되는가" 가 아니다 |
| 권한·게이트(비밀글·소유권·발행 상태·본인인증·다운로드 카운트)가 걸린 첨부 경로를 직접 URL 로 전환 | 게이트가 없는 완전 공개 자산 카테고리에만 배선. 게이트가 있는 경로의 응답 `url` 칸은 그 게이트를 통과하는 서빙 URL 로 채운다 |
| 공개 판정을 `PublicAssetDisk::resolve()` 밖에서 사본으로 재작성 | 판정은 그 단일 지점 경유. `''`·`'none'`·config 에 없는 고아 디스크가 등가 비교만으로 통과하는 것을 막는다 |
| 행 disk 로 `withDisk()` 를 무검증 호출 | 존재 확인 후 폴백 — 공개 자산 디스크가 플러그인 등록 디스크일 수 있고, 그 플러그인 비활성화 시 무인증 공개 서빙 라우트가 **500** 이 된다 |
| 직접 URL 로 전환하면서 "행 삭제 = 접근 차단" 전제를 그대로 둠 | 직접 URL 은 행과 무관하게 파일을 가리킨다 — 회수 수단(파일 삭제)과 그 한계를 문서에 남긴다 |
| 서버가 스스로 발급하는 자산 주소(프록시 서빙 URL)를 절대 URL 로 만듦 | 사이트 상대 경로 — 절대면 저장 규칙(외부 URL 차단)이 자기 주소를 외부로 차단해 image 위젯의 업로드 → 저장이 422 가 되고(배경은 `style` 이라 스캔되지 않아 로고에서만 드러났다), 저장된 레이아웃이 발급 시점 도메인·스킴에 묶인다 |
| 저장 규칙의 "외부" 판정을 스킴 접두 문자열로만 두고 자기 주소를 예외 없이 차단 | 사이트 자기 host(`app.url`)·선언된 공개 자산 디스크 host 는 외부가 아니다 — `SiteAssetHosts` 단일 판정(정규화 후 host 등가 비교). 접두 비교는 `host.evil.com`·`host@evil.com` 을 통과시키고, 요청 `Host` 헤더는 위조 가능해 근거가 아니다 |
이 결함군의 유일한 증상은 화면이다: 직접 URL 이 403 을 돌려주면 그 이미지들만 깨지고, 게이트가 우회되면 정상 200 이 나간다. 서버 로그에는 어느 쪽도 흔적이 없다.
> 상세: [storage-driver.md](docs/extension/storage-driver.md) "공개 자산 전용 디스크 분리"
> 이 규칙은 정적 검사로 판정할 수 없다 — "이 경로에 게이트가 있는가"·"행 disk 와 설정이 같은 축인가" 는 의미 판정이므로, 회귀 테스트(특히 비공개 S3 행 + 공개 URL 설정 조합에서 서빙 라우트가 나오는 케이스)가 잠근다
### 직접 전송로는 자격증명을 스스로 싣는다
레이아웃의 `globalHeaders` 는 **데이터소스(DataSourceManager)와 `apiCall` 핸들러(ActionDispatcher)** 에만 적용된다. 코어 ApiClient(`G7Core.api.*`)를 직접 부르거나 `fetch` 를 쓰는 경로는 그 배선을 타지 않아 `Authorization` 과 `Accept-Language` 만 실린다. 게이트된 엔드포인트를 그렇게 부르면 서버는 정당한 사용자를 거부하는데, 화면은 버튼·썸네일을 이미 내준 뒤라 **예외도 콘솔 오류도 없이 그 자리만 비는 것**이 유일한 증상이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 게이트된 엔드포인트를 `G7Core.api.*` / `fetch` 로 부르며 자격증명 헤더를 생략 | 호출부가 직접 싣는다 — 비밀글 첨부는 `X-Board-Secret-View-Token`, 비회원 주문은 `X-Guest-Order-Token` |
| 자격증명이 없을 때 조용히 `return null` 로 이탈 | 회원/비회원 두 경로를 모두 구성한다 — 한쪽을 비우면 서버가 지원하는 기능이 도달 불가로만 남는다 |
| 헤더 구성을 호출부마다 복제 | 확장·템플릿 안에 단일 지점(`secretContentHeaders()` / `buildOrderRequestHeaders()`)을 두고 경유 |
| `<img src>` 에 헤더를 실으려 시도 | 이미지 태그는 헤더를 실을 수 없다 — 한시 서명 URL 을 발급하거나 blob 으로 받아 그린다 |
| 자격증명을 GET 쿼리 문자열로 전달 | 헤더로 보낸다 — 쿼리는 웹서버 접근 기록과 `Referer` 에 그대로 남는다 |
같은 기능을 여러 확장이 제공할 때는 **형제 구현의 강도가 갈리지 않는지** 확인한다. 서버가 비회원을 지원하는데 프론트 한쪽만 토큰을 보내면, 나머지 확장에서는 그 서버 기능이 존재하지만 도달 불가인 상태로 남는다.
### 확장·템플릿 구동 에셋은 자체 제공한다
브라우저가 화면을 그리기 위해 제3자 CDN 에 도달해야 하면, 그 도달 실패는 **예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다.** 폐쇄망·방화벽·광고차단기에서 재현되며 자체 서버 로그에 흔적이 없어 운영자가 원인을 특정할 수 없다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 구동 자산(js/css/웹폰트)을 외부 CDN 에서 실시간 로드 | 확장이 `dist/vendor/{lib}/{version}/` 에 동봉하고 same-origin 서빙 |
| `trusted_script_hosts` 만 선언하고 사유는 생략 | `trusted_script_hosts_reason` 에 호스트별 사유 동반 — 자체 제공이 원칙이고 예외는 근거가 코드에 남는다 |
| 자산 URL 을 문자열로 조립 (`'/api/plugins/assets/'+id+'/…'`) | `G7Core.asset.{template,module,plugin}` — 확장자를 정적 location 이 가로채는 서버에서 조립 URL 만 404 가 된다 |
| AMD 로더·워커에 `G7Core.asset.template()` 결과를 base 로 전달 | `G7Core.asset.templateDir()` — 쿼리 형태(`?file=`)는 뒤에 파일명을 이어 붙일 수 없다. 확장자 없는 모드에서 404 일 수 있으므로 **소비자가 폴백을 갖춘다** |
| CSS 로드에 `onerror` 미설치 또는 `resolve()` 로 삼킴 | `loadStylesheetWithRetry` — 아이콘만으로 조작하는 버튼이 있는 화면에서 스타일 소실은 곧 조작 불능이다 |
| 자산 실패를 `console.error` 한 줄로 끝냄 | `G7Core.assets.notifyFailure({id,label,retry})` — 사용자가 사실을 알고 조치할 수 있어야 한다 |
| 편집기·코드편집기 확보 실패 시 빈 컨테이너를 남김 | 평문 입력(textarea) 폴백 + 저장 계약 유지(`{name}_mode='text'`) + 재시도 시 입력 내용 승계 |
| 확장 `dist/`·`src/` 에 운영자 CSS 를 둠 | 확장 디렉토리 안의 **`custom/`** — 빌드 불필요, 확장 교체가 보존 |
| 번들 확장이 `custom/` 을 담아 배포 | `dist/vendor/` 에 담는다. `custom/` 은 운영자 소유라 보존 계층이 덮어쓰지 않아 **저작자 파일이 영영 반영되지 않는다** |
| 사용자 추가 에셋 URL 을 `ext.cache_version` 으로 무효화 | 파일 서명(수정 시각) — 확장 캐시 버전은 운영자가 파일을 고쳤다고 오르지 않는다 |
| `custom/` 보존을 rename 경로에만 적용 | 교체 **두 경로 모두**(rename · 제자리 동기화 폴백) — 한쪽만 고치면 Windows 잠금 상황에서만 조용히 사라진다 |
동봉 자산은 배포 산출물이므로 `sourceMappingURL` 참조를 남기지 않는다(`.map` 은 gitignore 대상이라 404 가 된다). 인라인 여부는 "없으면 조작 불능인가" 로 가른다 — 아이콘 폰트는 인라인, 글꼴·장식 아이콘은 파일 분리. 분리한 자산을 CSS 가 상대 경로로 가리켜도 된다: 확장 자산 CSS 는 서빙 시점에 내부 상대 참조가 절대 자산 URL 로 치환된다(`ServesRewritableCssAssets`). 치환이 없으면 쿼리 형태(`?file=`) 서버에서 그 참조가 조용히 404 가 된다.
사용자 추가 에셋(`custom/`)은 **출처에 의존하지 않는 서술자**로 해석하고 `core.assets.custom_assets` 필터 훅을 해석기 끝에 둔다. 소비자(뷰 컴포저·프론트 로더·서빙)가 출처를 보면, 나중에 다른 출처(템플릿 환경설정의 화면 입력 등)가 붙을 때 평행 경로가 생기고 "운영자 CSS 가 어디서 오는가" 의 SSoT 가 둘로 갈린다.
> 상세: [module-assets.md](docs/extension/module-assets.md) "사용자 추가 에셋", [static-asset-publishing.md](docs/backend/static-asset-publishing.md)
> 정적 검사가 외부 자산 URL 과 번들 확장의 `custom/` 배포를 차단한다. 서술자 형태와 교체 2경로 보존은 테스트가 잠근다.
### 확장은 자기 개발자 문서를 소유한다
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 고치려는 쪽이 실제로 묻는 것은 그 앞이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.** 그 답이 코드 안에만 있으면 매번 `src/` 전체를 훑어 구조를 재발견하게 되고, 확장이 발행하는 훅은 확장점인데도 사실상 비공개가 된다.
확장마다 `AGENTS.md`(고치는 쪽) · `README.md`(도입·운영 쪽) · `docs/**`(상세)를 두고, 코드에서 실측되는 표는 `php artisan ext:docgen` 이 유지한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 확장 표면(훅·라우트·권한·모델·레이아웃·핸들러)을 바꾸고 그 확장 문서를 그대로 둠 | 같은 작업 단위에 `ext:docgen --scope={type}:{id}` 재실행 + 낡은 서술 정정 |
| 자동 생성 블록(`@generated:*`) 안쪽을 손으로 고침 | 생성기가 교체하는 자리다 — 코드를 고치거나 블록 **밖**에 서술한다 |
| 생성기에 파괴적 재생성 플래그(`--force`)를 추가 | 기본 동작이 "블록 안쪽 교체" 다. 사람 서술이 소실될 경로를 만들지 않는다 |
| 문서에 없는 블록 키를 생성기가 임의 위치에 주입 | 누락으로 보고하고 사람이 마커 자리를 정한다 (문서 구조는 사람 소유) |
| 필수 문서·섹션·블록 목록을 검사 스크립트에 복제 | `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT — 스크립트는 `ext:docgen --check --json` 을 소비한다 |
| `TODO:` 마커를 추측으로 채움 | 코드 근거를 읽어 서술한다 — 다섯 자리(의도·흐름·금지패턴·사용방법·트러블슈팅)는 생성기가 채울 수 없는 **왜** 다 |
| `5. 수정 시 동반 의무` 에 코어 횡단 규정을 전부 나열 | 그 확장에 **실제로 걸리는 것만** 추린다 — 전부 적으면 정작 걸리는 항목이 묻힌다 |
| 신규 확장을 문서 없이 스캐폴딩 | `php artisan ext:docgen --scope={type}:{id} --init` 으로 골격을 함께 만든다 — 없으면 21번째 확장부터 다시 문서 없이 태어난다 |
| 확장 문서를 활성 디렉토리에서 작성 | `_bundled` 에서만 작성하고 update 커맨드로 반영 (문서만이면 빌드 불필요) |
| 확장이 훅을 추가할 때 코어 문서를 고침 | 훅 집계는 그 확장의 `docs/extension-points.md` 소유 — 코어에는 총계와 링크만 |
| 레이아웃에 `data_source` 를 추가하고 `editor-spec.json` 의 `sampleData` 를 그대로 둠 | 같은 ID 로 프리뷰 샘플 추가 — 없으면 **편집기 캔버스에서만** 그 영역이 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다 |
| 컴포넌트를 추가하고 팔레트에만 등록 | 템플릿 스펙은 `componentPalette.entries` · `componentPalette.groups` · `nesting` · `componentCapabilities` **넷 다** — 하나만 빠지면 편집기에서 절반만 동작하고, 어느 단계가 빠졌는지는 증상으로만 구분된다 |
| 모듈·플러그인 스펙에 `componentPalette` 선언 | 컴포넌트는 템플릿 소유 — 모듈·플러그인 스펙은 도메인 데이터(`sampleData`·`states`)만 담는다. 같은 자리를 두고 다투면 어느 쪽이 이기는지가 병합 순서에 좌우된다 |
| 공용 ID(`settings`·`roles`·`me`)를 확장마다 각자 선언 | 템플릿 스펙 한 곳 — 사본이 갈라져도 오류가 나지 않는다 |
| 편집기 스펙 `description` 에 작업 단계·심사 판정·작업 방법을 적음 (`Phase 4/5 에서 추가`·`— 정당`·`전수 스캔 기반`) | **무엇을 담았는가**만 적는다 — 확장만 내려받은 제3자에게 내부 맥락은 해석 불가이고, "다음에 추가" 는 그 항목이 실제로 들어온 뒤에도 남아 **거짓이 된다**. 문서의 한 줄 요약은 이 필드를 옮기지 않고 실측에서 생성한다 |
| 편집기 스펙을 고치고 update 커맨드 생략 | 서빙은 **활성 디렉토리만** 읽는다(`_bundled` 폴백 없음) — 파일은 고쳤는데 편집기에 직전 내용이 그대로 보인다 |
이 결함군은 오류를 남기지 않는다. 문서가 코드와 어긋난 채로 계속 읽히는 것이 유일한 증상이며, 훅 이름이 어긋나면 그 확장을 잡으려던 쪽이 **잡히지 않는 훅을 구독**하게 된다(예외도 경고도 없이 리스너가 호출되지 않을 뿐이다).
mermaid 문법 오류는 GitHub 렌더 시점에만 드러난다. 구조 검사가 잡을 수 있는 것은 선언된 다이어그램 종류·빈 본문·괄호 균형까지이므로, 새 형식은 실제 렌더를 눈으로 확인한다.
`docs/editor-spec.md` 는 세 유형 공통이다 — 편집기 스펙을 두지 않는 확장에도 문서를 둔다. 미보유가 정상일 수 있고("이 확장은 공용 ID 만 쓴다") 그 정상 여부를 적을 자리가 없으면 다음 사람이 부재를 누락으로 오해하거나 필요한 시점을 놓친다. 그 문서의 "샘플 데이터와 페이지 상태" 절은 그 확장 레이아웃의 `data_source` 중 프리뷰 샘플이 붙지 않는 것을 실측해 나열한다 — 이 결함은 편집기 캔버스에서만 빈 화면으로 나타나므로 그 목록이 유일한 통로다.
> 상세: [extension-documentation.md](docs/extension/extension-documentation.md)
> 정적 검사가 확장 표면 변경 시 문서 미동반과 미채움 마커 잔존을 검출한다. 생성기의 비파괴 계약(블록 밖 손실 0 · 재실행 멱등 · 미존재 키 미주입)과 필수 문서·섹션·블록 목록은 테스트가 잠근다.
### 의존성 감사 신호는 거짓일 수 있다
`npm audit --omit=dev` 는 **`dependencies` 에 선언된 것만** 본다. 실행에 쓰이는 라이브러리가 `devDependencies` 에 있으면 그 패키지는 검사 대상에서 통째로 빠지고, 취약점이 있어도 감사는 0건을 돌려준다. "운영 의존성 취약점 없음" 이라는 완료 조건이 취약한 상태로도 충족된 것처럼 보인다.
동봉(vendored) 제3자 자산은 더 나아가 **어떤 잠금파일에도 없어** 감사 도구가 원리상 볼 수 없다. 그 안에 재번들된 라이브러리가 브라우저로 나간다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 런타임 소스가 import 하는 패키지를 `devDependencies` 에만 선언 | `dependencies` 로 선언 — 검사 대상에 들어가야 감사 신호가 사실이 된다 |
| 루트 잠금파일만 감사하고 완료 선언 | 확장의 잠금파일까지 전수 (`php artisan security:audit-dependencies`) — 루트만 보면 확장 전부가 사각이다 |
| 동봉 자산을 감사 범위에 들었다고 간주 | 잠금파일에 없으므로 **사람이 확인한다** — 점검 명령이 목록으로 노출한다 |
| 잠금만 올리고 재빌드를 생략 | 브라우저가 받는 버전은 커밋된 `dist/` 가 정한다 — 잠금 갱신 후 반드시 재빌드·재게시 |
| "점검 대상 0" 과 "점검 불가" 를 같은 문구로 보고 | 구분 보고 — 뭉뚱그리면 운영자가 "전부 정상" 으로 읽는다 |
| 동봉 자산 버전을 여러 곳에 적고 상향 시 일부만 갱신 | 모든 기재(디렉토리 · 의존성 핀 · 복사 스크립트 · 소스 상수 · 테스트 어서션)를 한 버전으로 — 하나만 어긋나도 그 자산이 404 가 되는데 빌드와 테스트는 통과한다 |
| 상류가 못 고치는 잔여 취약을 감사에서 가리기(`overrides` 로 무마) | 잔여는 **하한과 사유를 기록**하고 그 아래로 내려가는 것만 막는다 — 가리면 그 지점이 영원히 보이지 않는다 |
이 결함군은 예외도 로그도 남기지 않는다. 취약한 라이브러리가 정상 동작하고 감사가 0건을 보고하는 것이 유일한 증상이다. 정적 검사가 앞의 두 항목을 차단하고, 출하 산출물의 정화기 버전과 점검 명령의 모집단은 테스트가 고정한다.
> 상세: [module-assets.md](docs/extension/module-assets.md), [cheatsheet.md](docs/cheatsheet.md)
### 프록시 뒤 요청은 스킴·IP 를 스스로 증명하지 않는다
TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB, CloudFront, Cloudflare, nginx/Apache 리버스 프록시, ngrok)에서 신뢰할 프록시가 지정되지 않으면 Laravel 은 `X-Forwarded-*` 를 전부 무시한다. 요청 객체가 평문 HTTP 로 인식되어 `asset()`/`url()` 이 `http://` 를 만들고(HTTPS 페이지에서 혼합 콘텐츠로 차단 → **사이트 전체 백지**), `$request->ip()` 가 프록시 IP 가 되어 통보 IP 화이트리스트·rate limit·IP 기록·GeoIP 가 동시에 무너진다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `bootstrap/app.php` 에서 `trustProxies(at: env('TRUSTED_PROXIES'))` | `config/trustedproxy.php` 의 `'proxies' => env('TRUSTED_PROXIES')` — `withMiddleware` 클로저는 `.env` 로드 전에 평가되어 `env()` 가 항상 `null` 이다(오류 없이 no-op) |
| 신뢰 프록시를 `'*'` 로 하드코딩 | env opt-in — 앱이 직접 노출된 환경에서 `X-Forwarded-For` 위조로 기록 IP·IP 제한이 조작된다 |
| `$middleware->trustHosts()` 호출 | 호출 자체가 모든 설치처에서 Host 검증을 켠다(미등록 호스트 400) — opt-in 원칙 위반 |
| IP 화이트리스트·rate limit·IP 기록을 `$request->ip()` 로 두면서 프록시 구성을 문서화하지 않음 | 그 기능이 프록시 신뢰 설정에 의존한다는 사실을 문서에 남긴다 |
| 절대 URL 이 필요한 곳에서 요청 스킴 의존(`url()`/`asset()`)과 설정 앵커(`config('app.url')`)를 혼용 | 외부 시스템에 등록·전송되는 URL(PG 콜백·webhook 안내)은 설정 앵커, 화면 자산은 요청 기준 |
| 진단 판정을 "HTTPS 인식 실패" 로 세움 | `X-Forwarded-* 수신 중 AND 신뢰 프록시 미설정` — HTTP 전용 사이트가 프록시 뒤에 있으면 **화면은 완전히 정상 렌더되면서** webhook 403·IP 왜곡만 계속된다. HTTPS 기준 판정은 그 구성에서 침묵한다 |
| 같은 판정을 노출면(대시보드·환경설정·설치 마법사·커맨드)마다 다시 작성 | `App\Support\TrustedProxyDiagnostic` 단일 판정 — 면마다 조건을 복제하면 한 곳만 어긋나도 서로 다른 답을 내놓는다 |
| 신뢰 프록시 값을 관리자 화면에서 편집 가능하게 제공 | 읽기 전용 진단만. ① 프록시 뒤에서는 그 화면 자체가 뜨지 않는 것이 이 결함이라 정작 필요한 순간에 도달 불가(잠금 역설) ② 웹 편집이 가능해지면 관리자 계정 탈취가 곧 XFF 위조 경로가 된다 |
이 결함군은 서버 로그에 흔적을 남기지 않는다. 브라우저 콘솔의 차단 로그와 "모든 방문자가 같은 IP" 라는 데이터 상태만이 증상이며, 설치 마법사는 `X-Forwarded-Proto` 를 읽어 "HTTPS 정상" 이라고 보고하므로 운영자에게는 원인 추적 단서가 없다.
`**` 는 Laravel 내장 미들웨어에서 `*` 과 같은 코드 경로를 타므로 "모든 프록시 신뢰" 가 아니다. 프록시가 여러 단인 구성에서는 체인의 모든 프록시 IP·CIDR 를 나열해야 최초 클라이언트 IP 가 해석된다.
> 상세: [reverse-proxy.md](docs/backend/reverse-proxy.md)
> `config/trustedproxy.php` 의 키 계약, 신뢰/미신뢰 분기, 쿠키 Secure 자동 판정, `bootstrap/app.php` 로의 `env()` 함정 재유입 차단은 테스트가 잠근다. `url()`/`asset()`/`$request->ip()` 는 정상 사용례가 다수라 정적 금지 규칙을 두지 않는다.
### 목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
@@ -387,6 +716,26 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 확장 캐시 버전은 트레이트 게터로만 읽고, 만료시키지 않는다
확장 캐시 버전(`ext.cache_version`)은 모든 자산 URL 의 `?v=`·정적 게시본(`public/build/ext/{v}/`)·병합 번들 파일명의 좌표다. 그 키를 올리는 단일 지점(`incrementExtensionCacheVersion()`)이 재게시까지 예약하므로, 게시본에 구워지는 입력이 바뀌는 경로는 전부 그 지점을 타야 하고, 키 자체는 만료로 재생성되어서는 안 된다 — 재생성은 정적 파일 전체 재생성이자 전 방문자의 자산 URL 변경이다.
| 금지 | 올바른 사용 |
|------|------------|
| `$cache->get('ext.cache_version', 0)` / `app(CacheInterface::class)->get(...)` / `->forget('ext.cache_version')` 원시 접근 | `ExtensionStaticCacheService::getExtensionCacheVersion()` 또는 트레이트를 조합한 클래스 안의 `self::getExtensionCacheVersion()` — 부재 시 재생성하므로 `cache:clear` 직후 0 이 새지 않고, 컨테이너 바인딩의 확장 네임스페이스 누수도 없다 |
| 트레이트 정적 메서드를 트레이트 이름으로 직접 호출 (`ClearsTemplateCaches::getExtensionCacheVersion()`) | 트레이트를 조합한 코어 클래스 경유 — PHP 8.1+ E_DEPRECATED 이고, 트레이트 상수는 `self::` 로 읽을 수 없다 |
| 게시본에 구워지는 설정값(`general.asset_url_mode`)을 바꾸는 경로에서 bump 누락 | 저장·단건 저장·복원·CLI 어느 경로든 `incrementExtensionCacheVersion()` — 번들 CSS 안의 `url()` 형태가 본문에 구워져 있다 |
| 게시 입력이 바뀌는 수명주기 경로를 조건부 bump 에 위임 (템플릿 update 가 레이아웃 변경 건수 > 0 일 때만) | 모듈·플러그인과 동형으로 무조건 bump — 조건은 게시 입력 중 레이아웃 축만 본다 |
| custom 변경 감지 서명과 게시 복사 집합을 서로 다른 코드가 정의 | 같은 열거자(`CustomAssets::publishableFiles()`) — 감지가 최상위 css/js 만 보면 하위 글꼴·이미지 교체가 영영 미게시다 |
| 버전 키·서명 키를 기본 TTL 로 `put()` | `PERSISTENT_TTL_SECONDS`(10년) 명시 — `forever()` 는 `CacheInterface` 밖(공개 표면 변경), `put(…, 0)` 은 forget |
| 서명 스코프를 렌더 템플릿만으로 나눔 | `{템플릿}@{호스트명}` — 다중 서버 공유 캐시에서 서버 간 mtime 차이로 요청마다 재게시가 왕복한다 |
| `config:cache` / `route:cache` / `event:cache` / `optimize` 를 헬퍼 밖에서 `Artisan::call` | `ConfigCacheHelper::rebuild()` / `RouteCacheHelper::rebuild()` (내부가 `withPreservedContainer`) — 이 명령들은 새 Application 을 부팅하며 전역 `Container` 를 일회용 앱으로 바꿔 놓아, 그 뒤 등록되는 `app()->terminating()` 재게시 예약이 종료되지 않는 앱에 걸려 사라진다 |
| 코어 업데이트 흐름에서 현재 프로세스의 버전·update 목록을 `config('app.version')`·`config('app.update.*')` 로 판독 | spawn 자식은 부모가 비우지 않은 이전 버전 config 캐시로 부팅한다 — 버전은 `CoreVersionChecker::getCoreVersion()`(업데이트 트리 안에서만 env 우선), update 목록은 캐시 부팅이면 `CoreUpdateService::freshDiskUpdateConfig()`, 부모는 spawn 직전 `ConfigCacheHelper::clear()` + `PackageManifestCacheHelper::clear()` |
이 결함군은 예외도 로그도 남기지 않는다 — 게시본이 정상 200 으로 옛 내용을 내보내는 것, 또는 매일 전체 재생성이 일어나는 것이 유일한 증상이다. 재게시 누락의 안전망은 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」의 [지금 다시 만들기](`POST /api/admin/settings/static-cache/republish`)이며, 상태 판정은 `ExtensionStaticCacheService::statusReport()` 한 곳이 CLI·API·화면에 공급한다.
> 상세: [static-asset-publishing.md](docs/backend/static-asset-publishing.md) "버전은 만료되지 않는다" · "5-1. 관리자 화면에서의 수동 복구"
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
@@ -403,6 +752,45 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
### vendor 를 교체한 뒤 새 PHP 프로세스를 띄우기 전에는 패키지 매니페스트를 비운다
Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 provider 를 `new` 한다. 그래서 vendor 를 바꾼 뒤 그 파일을 남겨 두면 다음에 부팅하는 프로세스가 새 vendor 에 없는 클래스를 찾다 부팅 단계에서 죽고, 예외는 부팅 전이라 앱 로그에 남지 않는다.
| 금지 | 올바른 사용 |
|------|------------|
| vendor 교체 뒤 `proc_open`·`config:cache`·`route:cache` 등 새 부팅을 `bootstrap/cache/{packages,services}.php` 정리 없이 실행 | 부팅 직전 `PackageManifestCacheHelper::clear()`(재생성까지 필요하면 `rebuild()`) — `ConfigCacheHelper::clear()` 와 짝으로 |
| 정리 로직을 호출부마다 `@unlink` 로 복제 | 헬퍼 단일 지점 — `clearAllCaches()` 도 같은 헬퍼를 쓴다 |
| 자식 프로세스 보호를 부모 코드에만 두기 | 부모는 이미 배포된 옛 코드일 수 있다 — 새 버전의 `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS` 를 보고 스스로 비운다(App\ 클래스 미참조·실패 무시) |
| 코어 버전 판정에서 프로세스 env `APP_VERSION` 을 무조건 우선 | env 우선은 `CoreUpdateContext::isInProgress()` 인 프로세스 트리 안에서만 — 업데이트 전에 뜬 `artisan serve`·큐 워커는 옛 값을 물고 있다 |
| 업데이트 커맨드 argv 판정을 SAPI 게이트 없이 두기 | argv 는 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다 — CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링에서 채워(`?x+core:update`) 비인증 웹 요청이 업데이트 트리로 판정되고, 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로 둔다 |
| 매니페스트 삭제 실패를 `@unlink` 로 삼키기 | `clear()` 가 지우지 못한 경로를 돌려주고 spawn 직전 호출부가 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식도 같은 이유로 실패해 증상은 제보와 같고, 이 경고가 원인을 가리키는 유일한 흔적이다 |
| 업데이트 트리 판정을 지점마다 다시 작성 | `App\Support\CoreUpdateContext` 단일 SSoT — `CoreServiceProvider::isCoreUpdateInProgress()` 도 위임이다. `bootstrap/app.php` 의 복제본은 부팅 전이라 불가피한 예외이며 주석으로 상호 참조한다 |
| 자동 비활성화 로그의 `core_version` 을 `config('app.version')` 으로 적기 | 판정과 같은 `CoreVersionChecker::getCoreVersion()` — 로그와 판정 근거가 갈리면 운영자가 원인을 특정할 수 없다 |
| "이미 있으니 건너뛴다" 분기(인스톨러 vendor 재사용)가 산출물의 출처를 보지 않음 | `installed.json` 의 `dev`/`dev-package-names` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 |
| 코어 업데이트를 마치고 상주 워커에 신호를 보내지 않음 | Step 11·핸드오프·단독 재개 사후 단계에서 `signalQueueRestart()` |
이 결함군은 예외도 로그도 남기지 않는다 — 자식 프로세스가 부팅 단계에서 죽어 부모가 핸드오프로 멈추는 것, 또는 업데이트 직후 확장이 `incompatible_core` 로 꺼지는 것이 유일한 증상이다. 후자는 관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없어 자가 회복 경로가 없다.
> 상세: [core-update-system.md](docs/backend/core-update-system.md) "spawn 전 캐시 정리 계약(3계층)" · [extension-update-system.md](docs/extension/extension-update-system.md) "판정의 단일 출처와 이 플래그가 게이트하는 것"
### 설정 주입과 `.env` 우선
관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다.
| 금지 | 올바른 사용 |
|--------|---------------|
| `SettingsServiceProvider` 에 settings → config 주입을 추가하면서 `EnvPriority::MAP`(또는 `EXEMPT`) 미등재 | 맵을 동반 갱신 — 등재를 잊으면 그 키는 영원히 settings 승으로 남고, 화면상 "그 키가 `.env` 에 없다"와 구분되지 않는다 |
| env 명시 여부를 런타임 `env()` 로 판별 | config 빌드 시점 캡처(`config/env-priority.php`, `disk_explicit` 패턴) — `config:cache` 에서 `env()` 는 null 이라 판정이 영구 미발동한다 |
| 잠긴 키 표시값에 sensitive 값(`.env` 비밀값) 노출 | 잠금 표시만 — 민감 키는 유효값 오버레이 대상에서 제외한다 |
| 제거된 키의 **부재를 값으로 읽는 지점**을 그대로 둠 (게이트·기본값 주입·형제 폴백) | 그 자리는 유효값(런타임 config)으로 보정하거나 잠금일 때만 건너뛴다 — `array_key_exists`/`empty()` 로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이 구분되지 않는다 |
명시 판별은 strict 다 — `KEY=`(빈 값)·`KEY=null`·미설정만 미명시이고, `APP_DEBUG=false`·`REDIS_DB=0` 같은 falsy 명시는 명시로 취급한다(`?:` 를 쓰면 그 값들이 미명시로 오판된다).
잠금은 **그 키의 주입만** 건너뛰는 것이다. 그런데 제거된 키를 읽는 자리가 그 부재를 값으로 해석하면 잠금이 형제 설정까지 무너뜨린다 — 다섯 형태가 있고 전부 오류도 로그도 남기지 않는다: ① 마스터 토글의 OFF 강제(웹소켓) ② 게이트가 되는 키(디버그 모드 → 로그 레벨 강제·프록시 적용, 메일 드라이버 → mailgun/ses 하위 주입) ③ 저장값이 비어도 박히던 기본값(`services.{mailgun.endpoint,ses.region}`) ④ 형제 값으로의 폴백(웹소켓 server endpoint 가 관리자 소유 client 값을 `.env` 자리에 덮어씀) ⑤ 파생 판정(`storage_driver=s3` → `attachment.disk`). 판정에 쓰는 자리는 유효값으로 보정하고, 주입을 건너뛰어야 하는 자리는 `EnvPriority::isLocked()` 를 직접 묻는다.
> 상세: [admin-settings-access.md](docs/backend/admin-settings-access.md) "env 우선 모드(G7_ENV_PRIORITY)"
> 자동 차단: 정적 검사가 주입 메서드의 필터 배선 실존을 확인하고, 맵 패리티·캡처 일치·`env()` 재유입과 표시값·저장 게이트는 계약 테스트가 잠근다. 부재를 값으로 읽는 자리는 의미 판정 영역이라 정적 검사가 덮지 못한다 — 게이트·기본값·폴백·파생 네 축을 각각 고정하는 회귀 테스트가 그 자리를 잠근다
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
@@ -448,6 +836,19 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [pagination.md](docs/backend/pagination.md)
### 입력 크기에 비례해 커지는 메모리는 PHP 기본 한계 안에서 잰다
브라우저에서 잘 돌던 알고리즘·상수를 PHP 로 옮길 때 시간 상한(O(n·m) 가드)만 함께 오고 **메모리 상한은 오지 않는다.** PHP 배열은 원소당 수십 바이트라 (줄 수)² 표는 2,350줄에서 약 150MB 이고, 운영 서버의 기본 `memory_limit` 은 128M 이다. 개발 머신(512M)에서는 통과하고 서버에서만 500 이 되며, 예외는 `FatalError` 한 줄뿐이라 어느 요청의 어떤 입력이었는지 로그에 남지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 카운트·길이만 쓰는 LCS/DP 에 전체 표 + backtrack | 두 행(또는 한 행) DP 로 길이만 구한다 — 추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS 라 숫자가 같다 |
| 브라우저 구현의 임계값(`DIFF_MAX_LINES` 등)을 그대로 이식하고 "가드가 있다" 고 간주 | 그 임계에서의 PHP 메모리를 실측하고 128M 아래인지 확인 — 임계가 시간 축만 막는 경우가 있다 |
| "인접 버전 비교는 변경 영역이 작다" 는 가정으로 최악 경로를 비워 둠 | 변경 영역은 양끝이 동시에 바뀌면 파일 전체다 — 편집기가 `comment` 키를 떼어내는 첫 저장이 정확히 그 형태 |
| 메모리 회귀 테스트를 "통과했다" 로만 잠금 | `memory_get_peak_usage()` 증가량 상한을 단언하고, 수정 전 값(146MB)을 테스트 메시지에 남긴다 |
> 상세: [service-repository.md "입력 크기에 비례하는 메모리"](docs/backend/service-repository.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
@@ -500,6 +901,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
동결 대상은 환율만이 아니다 — **소수 자릿수·절사 규칙·환산 분모(base_unit)까지 스냅샷이 SSoT** 다. 이 값들을 현재 설정에서 조회하면, 운영자가 그 통화를 삭제하는 순간 설정에서 사라져 폴백(자릿수 2)이 적용된다. 소수 0자리 통화의 과거 주문 표기가 `¥14,835` → `¥14,835.00` 으로 바뀌고, 3자리 이상으로 설정했던 통화는 표시 금액이 절사된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따라가면 같은 화면 안에서 근거가 갈린다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 주문·환불 표시에서 `getDecimalPlaces($code)` 를 스냅샷 없이 호출 | `getDecimalPlaces($code, $currencySnapshot)` — 스냅샷이 있으면 그것이 우선 |
| 리소스가 주문 스냅샷을 자식에게 전파하지 않음 | `withOrderCurrency()` 를 전파하는 지점마다 `withCurrencySnapshot()` 도 함께 전파 |
| 상품·카탈로그 표시까지 스냅샷으로 고정 | 현재 판매가는 **현재 설정**이 정답 — 스냅샷 없이 호출한다 |
| 자릿수를 박제하지 않은 구형 스냅샷에서 예외/0 자리 강제 | 박제값이 없으면 현재 설정 폴백을 그대로 탄다 (하위호환) |
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 확장 결제수단은 자기 능력을 선언한다
@@ -515,6 +925,31 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다.
### 예외를 응답으로 바꾸는 자리
`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애·코드 결함이 "입력 오류" 로 위장되어 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다. 그리고 이미 번역된 `$e->getMessage()` 를 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문(SQL 상태코드·경로 포함 가능)이 그대로 화면에 나간다. 둘 다 예외도 로그도 남기지 않는다.
| 금지 | 올바른 사용 |
|--------|---------------|
| generic catch 가 4xx 반환 | 5xx — 의도적 4xx 는 사유와 함께 판정 테스트의 상수에 선언 |
| generic catch 에서 상태코드 인자 생략 (`moduleError($mod,'key')`) | 상태코드 명시 — 생략 시 `ResponseHelper` 기본값 **400** 이 조용히 적용된다 |
| 도메인 예외를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 유지 | 승격한 예외로 좁힌다 — 도메인 예외의 **부모**를 잡으면 남는 것은 인프라 예외뿐인데 그것까지 4xx 가 된다 |
| `error($e->getMessage(), 422)` | `error($e->getMessageKey(), 422, null, $e->getMessageParams())` |
| 도메인 예외가 번역문만 들고 다님 | 생성자에 키+치환 파라미터 보관 → `getMessageKey()` / `getMessageParams()` |
| 서비스를 typed 예외로 승격하고 컨트롤러는 generic 만 유지 | 그 서비스 메서드를 호출하는 **컨트롤러 메서드 전수**에 typed catch 추가 (없으면 도메인 사유가 500 이 된다) |
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -525,6 +960,8 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) |
| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 |
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
@@ -667,6 +1104,7 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
- `## [버전] - YYYY-MM-DD` 헤더 필수
- `### Added` / `### Changed` / `### Fixed` / `### Removed` 카테고리 사용
- 최신 버전이 파일 상단
- 한 버전 섹션 안에 같은 카테고리 헤딩은 한 번만 — 브랜치마다 섹션 머리에 자기 `### Fixed` 블록을 얹고 병합이 양쪽을 이어 붙이면 같은 제목이 두 번 남는다. 항목은 기존 카테고리 블록 끝에 추가하고, 병합 뒤 두 번째 블록이 보이면 그 항목을 첫 블록에 합치고 헤딩만 지운다(항목 삭제 금지). 같은 버전 헤더 중복·허용 밖 카테고리도 같은 결함군이며 정적 검사가 working 버전 섹션에서 차단한다. 공개 배포된 과거 섹션은 소급 수정하지 않는다
---
@@ -700,6 +1138,20 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
필수: data_sources ID 고유성 유지, 조건부 렌더링은 if 속성만 사용 (type: "conditional" 미지원)
```
### 독립 레이아웃(extends 없음)의 글로벌 호스트 컴포넌트
`toast`, `openModal` 등 글로벌 상태 기반 핸들러(`_global.toasts`, `_global.modal`)는 호스트 컴포넌트(`Toast`, `ModalRoot` 등)가 마운트되어야 화면에 렌더된다. 베이스 레이아웃(`_user_base`, `_admin_base`)은 일반적으로 이들을 마운트하므로 자식 레이아웃은 별도 작업이 필요 없지만, `extends` 없이 정의된 독립 레이아웃(예: `admin_login.json`)은 호스트 컴포넌트가 자동 주입되지 않는다.
```text
필수: 독립 레이아웃에서 toast/modal 사용 시 components 최상단에 호스트 컴포넌트를 직접 추가
- Toast: { type: "composite", name: "Toast", props: { toasts: "{{_global.toasts}}", ... } }
- 누락 시 핸들러는 success 로 기록되나 화면에는 미노출 (조용한 실패)
필수: 의심 시 베이스 레이아웃의 호스트 컴포넌트 정의를 그대로 복사
- sirsoft-admin_basic: layouts/_admin_base.json 의 #global_toast 블록
- sirsoft-basic: layouts/_user_base.json 의 토스트 컴포넌트 블록
```
---
## 테스트 프로토콜
@@ -977,6 +1429,8 @@ BaseApiController (최상위)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
@@ -1045,6 +1499,21 @@ php artisan language-pack:update g7-core-ja --force
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
### 배포 산출물의 브라우저 하한
선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(?<!` `(?<=`, `v` 플래그) — 하한과 같은 버전이어도 | 그 두 파일만은 하한 미만 브라우저에서도 **파싱**돼야 한다 |
| 빌드 타깃을 낮춰 해결 시도 | 소스에서 그 문법을 쓰지 않는다 |
| 바이트 길이 비교식 번들러 검출기로 판정 | 문법·API 표 기반 검사 (프린터 표기 차이가 오탐을 낸다) |
부팅 임계 번들 둘은 `async`/`defer` 없는 동기 classic 스크립트다. 파싱에 실패하면 **폴백 안내 화면조차 렌더되지 않아** 사용자에게는 백지 또는 거짓 진단만 남는다. 하한 미만 브라우저에 "지원 범위 밖" 안내를 띄우려면 이 두 파일은 파싱에 성공해야 하므로, 하한과 **정확히 같은** 버전을 요구하는 문법(lookbehind = Safari 16.4)도 금지한다 — 하한 초과만 보는 검사로는 영원히 잡히지 않는 지점이다. 정적 검사가 이 규칙을 강제한다.
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
@@ -1068,6 +1537,8 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
- 프로덕션은 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 키는 `(type, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다.
- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다.
### 빌드 명령어 (Artisan)
@@ -1097,6 +1568,24 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
```
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production`.
### 빌드는 자기 산출물만 교체한다 (`emptyOutDir`)
모든 vite config 는 `build.emptyOutDir: false` 를 **명시**한다. 기본값 `true` 는 산출물 디렉토리를 통째로 비우는데, 그 디렉토리에는 vite 가 만들지 않는 서빙 자산이 함께 산다.
| 함께 지워지던 것 | 결과 |
|---|---|
| `public/build/core/` 3번들 | 폴백이 없다 — `template-engine.min.js` 는 동기 classic 스크립트라 소실 = **사이트 부팅 불가**, 안내 화면조차 렌더되지 않는다 |
| `public/build/ext/{v}/` 게시본 | 이미 배달된 HTML 의 immutable URL 이 404. 재게시로 새 버전이 생겨도 **그 URL 은 복구되지 않는다** |
| 확장 `dist/vendor/` | 확장이 동봉한 구동 제3자 자산 소실 (자체 제공 원칙 위반) |
소실은 예외도 서버 로그도 남기지 않는다 — 브라우저 404 로만 나타나므로 운영자에게는 흔적이 없다. 잔존하는 구 해시 산출물은 `manifest.json`(또는 고정 파일명)이 선택하므로 참조되지 않는 사표이고, 정리 책임은 빌드 커맨드가 진다.
빌드 커맨드의 산출물 정리는 **활성 디렉토리를 건너뛴다.** 정리는 빌드 *전에* 돌므로 웹이 서빙 중인 `dist/` 를 비우면 빌드 완료까지가 통째로 서빙 공백이 되고, 빌드가 실패하면 빈 채로 남는다. 정리 대상은 `_bundled` / `_pending` 소스 디렉토리뿐이다.
정적 검사가 모든 vite config 의 명시 선언을 강제한다 (기본값 의존 금지 — 규약이 코드에 남지 않으면 다음 편집자가 같은 결함을 재도입한다).
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
@@ -1183,6 +1672,7 @@ php artisan migrate:rollback
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
| `(modules\|plugins\|templates)/_bundled/{id}/**` (그 확장의 소스 전반) | 그 확장의 `AGENTS.md` · `docs/README.md` — 설계 의도·디렉토리 지도·확장점·**수정 시 동반 의무**·금지 패턴. 수정 후 표면이 바뀌었으면 `php artisan ext:docgen --scope={type}:{id}` ([extension-documentation.md](docs/extension/extension-documentation.md)) |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
+258
View File
@@ -4,6 +4,264 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.11] - 2026-09-09
### Added
- 2단계 인증을 켠 사이트의 로그인 화면에 인증번호 입력 단계가 추가되었습니다. 비밀번호를 확인하면 같은 카드 안에서 인증번호 입력으로 넘어가고, 「인증번호 다시 받기」로 새 번호를 받거나 「처음부터」로 되돌아갈 수 있습니다. 관리자 로그인 화면도 같은 흐름으로 동작합니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
### Changed
- 인증번호를 보내지 못해 로그인을 마칠 수 없을 때의 응답이 「인증에 실패했습니다」(401)에서 「인증번호를 보내지 못했습니다」(503)로 바뀌었습니다. 자격 증명은 올바른데도 로그인 실패로 안내되어 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없었습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
- 설치 마법사가 이미 준비된 `vendor/` 에 개발용 패키지가 섞여 있으면 설치 환경 확인 단계와 설치 로그에서 이를 알리고 정리 명령을 안내합니다. 설치는 그대로 진행되며, 종전에는 아무 표시 없이 그대로 사용해 이후 코어 업데이트 중단의 원인이 되었습니다.
- 설치 안내서의 Composer 설치 명령이 운영용 구성(`--no-dev`)으로 바뀌었고, `vendor/` 가 없으면 설치 마법사가 자동 설치하므로 이 단계를 생략해도 된다는 안내가 추가되었습니다.
- 설치 중 Composer 단계가 실패했을 때 안내하는 수동 명령도 운영용 구성(`--no-dev`)으로 바뀌었습니다. 종전 안내를 그대로 따르면 개발용 패키지가 섞인 상태로 설치되어 이후 코어 업데이트가 중단될 수 있었습니다.
- 코어 업데이트가 운영 `vendor/` 에 개발용 패키지가 있는지 확인해 로그에 남깁니다. 의존성 변경이 없어 재설치를 건너뛰는 경우에는 개발용 패키지가 그대로 남는다는 경고와 정리 명령을 함께 안내합니다.
- 코어 업데이트가 끝나면 실행 중인 큐 워커에 재시작 신호를 보내, 워커를 손수 재시작하지 않아도 새 코드가 반영됩니다.
- 「공개 자산 스토리지」를 지정하면 레이아웃 배경 이미지가 그 저장소에 저장됩니다. 이미 올라간 파일은 옮기지 않으며 각자 저장된 위치에서 그대로 서비스됩니다. 기존 첨부 저장소와 공개 자산 저장소를 같은 곳으로 지정한 경우에는 설정을 켜는 순간 이전에 올린 이미지도 저장소 주소로 바뀝니다. 자세한 사항은 관리자 > 환경설정 > 드라이버의 안내를 참고하세요.
- PHP 의존성 패키지를 최신 안정 버전으로 갱신했습니다 (Laravel 12.69, Reverb 1.11, Scout 11.6, Monolog 3.11, Symfony 7.4.18 등). 각 패키지의 보안 수정과 오류 수정이 함께 반영되며, Composer 를 실행할 수 없는 환경을 위한 코어와 이커머스 모듈의 `vendor-bundle.zip` 도 같은 구성으로 다시 만들었습니다.
### Fixed
- 2단계 인증을 켠 사이트에서 로그인이 되지 않던 문제를 수정했습니다. 로그인 화면에 영문 오류(`Cannot read properties of undefined`)가 표시되고 그 뒤로는 진행할 방법이 없었으며, 관리자 로그인은 서버 오류(500)가 되어 설정을 되돌릴 수단까지 사라졌습니다. 이제 인증번호 입력 단계가 표시되고 관리자도 같은 흐름으로 로그인할 수 있습니다. (#133 @keidichoi-gif 님께서 제보해주셨습니다.)
- 로그인 응답을 잘못 해석해 사용할 수 없는 인증 정보가 브라우저에 저장되던 문제를 수정했습니다. 그 뒤로는 화면을 새로 열 때마다 「세션이 만료되었습니다」 안내와 함께 로그인 화면으로 되돌아갔습니다.
- 본인인증 화면에서 로그인용 인증 요청을 처리할 수 있어, 그 요청으로는 다시 로그인할 수 없게 되던 문제를 수정했습니다. 로그인용 인증 요청은 이제 로그인 화면에서만 처리됩니다.
- 로그인 시도가 많아 잠시 차단될 때 영문 안내(`Too Many Attempts.`)가 그대로 표시되던 문제를 수정했습니다. 이제 다시 시도할 수 있는 시각과 함께 사이트 언어로 안내합니다.
- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다.
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다.
- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. 비우지 못한 파일이 있으면 업데이트 로그에 남겨 권한 문제를 바로 확인할 수 있습니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.)
- 이미 배포된 이전 버전(7.0.9·7.0.10)에서 업데이트를 시작하는 경우에도 같은 중단이 나지 않도록, 새 버전의 스텝 프로세스가 시작할 때 이전 패키지 목록 캐시를 스스로 정리합니다.
- `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다.
- 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다.
- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다.
- 레이아웃 편집기에서 올린 배경 이미지의 주소가 「공개 자산 스토리지」 설정과 무관하게 항상 사이트 서버를 거쳐 전달되던 문제를 수정했습니다. 파일은 설정한 저장소에 정상 저장되지만 방문자 요청은 매번 사이트 서버가 저장소에서 받아 다시 내보내고 있어, CDN 을 쓰도록 설정해도 그 효과를 볼 수 없었습니다. 이제 「공개 자산 스토리지」를 지정하면 그 저장소에 올라간 배경 이미지가 저장소 주소로 직접 전달됩니다. 설정하지 않은 경우와 설정 이전에 올린 이미지는 종전과 똑같이 동작합니다. (#134 @lyg-kaban 님께서 제보해주셨습니다.)
- 확장이 카테고리별로 다른 저장소를 쓰도록 설정했을 때, 확장 개발용 파일 주소·경로 조회가 그 설정을 반영하지 않고 언제나 기본 저장소를 보던 문제를 수정했습니다.
- 화면 편집기에서 로고처럼 이미지 주소를 직접 받는 항목에 이미지를 지정하면 그림이 깨져 보이던 문제를 수정했습니다. 지정한 주소 대신 알 수 없는 값이 저장된 것이 원인이며, 편집기 화면에서는 미리보기가 정상으로 보여 저장 전에는 알아챌 수 없었습니다. 이제 주소가 그대로 저장되고, 이미 잘못 저장된 사이트도 업데이트하면 자동으로 정정됩니다. 업데이트 전에도 화면은 정상 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.)
- 화면 편집기의 「탭 표시 게시판 수」처럼 숫자를 받는 항목이 「지원하지 않는 컨트롤」로 표시되어 편집할 수 없던 문제를 수정했습니다. 이제 숫자 입력칸이 나타납니다.
- 화면 편집기에서 공통 레이아웃이나 확장이 넣은 요소를 고쳐도 저장되지 않던 문제를 수정했습니다. 편집은 되는데 저장 후 되돌아가 있었고 실행 취소로도 되살릴 수 없었습니다. 이제 그런 요소는 편집 대신 「공통 레이아웃 편집」·「확장 편집」 진입 안내가 표시되며, 옮기기·복제·삭제도 같은 기준으로 막힙니다.
- 화면 편집기가 열리자마자 빈 화면이 되는 경우가 있던 문제를 수정했습니다. 편집기가 잠깐 보였다가 사라졌고, 새로고침에 따라 나타났다 사라졌다 했습니다.
- 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다.
- `vendor-bundle:build-all --check` 가 설치되지 않은 번들 확장을 「소스 경로 없음」으로 건너뛰어, 그 확장의 vendor 번들이 갱신되지 않은 상태가 점검을 통과하던 문제를 수정했습니다. 이제 설치 여부와 무관하게 배포 원본 기준으로 판정합니다.
- 레이아웃 편집기의 헤더 「로고 이미지」에서 파일을 올린 뒤 저장하면 「HTTPS 프로토콜 URL은 허용되지 않습니다」로 거부되던 문제를 수정했습니다. 업로드한 이미지의 주소가 사이트 전체 주소 형태로 발급되어 레이아웃 저장의 외부 주소 차단에 걸리고 있었으며, 배경 이미지는 검사 대상이 아니라 증상이 없었습니다. 이제 업로드 주소는 사이트 상대 경로로 발급되고, 사이트 자신의 주소와 「공개 자산 스토리지」로 지정한 저장소의 주소는 외부로 취급하지 않습니다.
- 큰 공통 레이아웃(예: 사용자 화면 공통 레이아웃)을 레이아웃 편집기에서 처음 저장할 때 「저장 중 네트워크 오류가 발생했습니다 / Server Error」로 끝나던 문제를 수정했습니다. 버전 이력의 변경량(+N/-N 줄)을 계산하는 과정이 레이아웃 크기의 제곱에 비례하는 메모리를 써서 PHP 기본 메모리 한도(128MB)인 서버에서 실패했습니다. 이제 메모리가 레이아웃 크기에 비례하며 표시되는 변경량 숫자는 그대로입니다.
- 레이아웃 편집기의 「확장 편집」 모드에서 저장하면 그 확장이 화면에 끼워 넣던 요소(예: 헤더의 통화 선택기)가 사라지던 문제를 수정했습니다. 내용을 바꾸지 않고 저장해도 일어났고 오류나 안내가 없어 사이트에서 그 요소가 없어진 뒤에야 알 수 있었습니다. 이제 끼워 넣은 요소가 그대로 보존되며, 되돌릴 자리를 확인할 수 없는 요소가 있으면 저장을 막고 그 사실을 안내합니다. 이미 사라진 사이트는 「버전 기록」에서 직전 버전을 복원하면 됩니다.
- 레이아웃 편집기에서 저장이나 복원을 두 번 이상 한 뒤 「초기화」나 충돌 안내의 「최신 불러오기」를 누르면 최신이 아닌 옛 내용이 올라오던 문제를 수정했습니다. 그 화면을 다시 저장하면 최신 내용을 옛 내용이 덮을 수 있었고, 충돌 안내 뒤에는 새로고침 전까지 저장이 계속 거부됐습니다.
- 레이아웃 편집기의 충돌 안내가 「최신 버전: -1」 로 표시되던 문제를 수정했습니다. 이제 실제 최신 버전 번호가 표시됩니다.
- 레이아웃·레이아웃 확장의 버전 기록에 저장자가 항상 「알 수 없음」으로 표시되던 문제를 수정했습니다. 이제 저장한 관리자가 기록됩니다.
- 버전 기록에서 복원한 직후, 복원 전 화면을 열어 둔 다른 관리자가 저장하면 복원 결과가 충돌 안내 없이 덮이던 문제를 수정했습니다. 이제 복원도 저장과 같이 충돌 검사의 기준 번호를 올립니다.
## [7.0.10] - 2026-09-06
### Added
- 화면 구동에 필요한 아이콘·글꼴·편집기 등을 외부 CDN 이 아니라 사이트 자신의 서버에서 불러옵니다. 폐쇄망이나 외부 접속이 제한된 환경에서도 관리자·사용자 화면이 정상 동작합니다. 외부 연결이 필요한 기능은 주소 검색 하나만 남았습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 운영자가 자기 CSS·JS 를 덧붙일 자리를 각 확장이 제공합니다. 확장 디렉토리의 `custom/` 에 파일을 놓으면 빌드 없이 바로 적용되고, 확장을 업데이트해도 그 파일은 지워지지 않습니다. 적용 순서는 항상 확장 스타일보다 뒤라서 재정의가 그대로 반영됩니다. (sir.kr 커뮤니티에서 문의해주신 내용입니다.)
- 화면에 필요한 파일을 끝내 불러오지 못하면 안내와 [다시 시도] 를 표시합니다. 아이콘 글꼴이나 본문 글꼴처럼 페이지가 직접 불러오는 파일도 대상이라, 아이콘이 통째로 사라져 버튼을 못 누르게 되는 상황에서도 원인이 화면에 남습니다. 종전에는 아무 표시 없이 기능만 사라져 원인을 알 수 없었습니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 설치 마법사와 개발 대시보드도 외부 CDN 없이 동작합니다. 설치 마지막 단계의 선택 작업(초기 화면 파일 게시·설정 캐시 생성 등)이 실패하면 어떤 작업이 실패했는지 그 이름으로 안내합니다.
- 확장을 삭제할 때 `custom/` 에 넣어 둔 운영자 파일을 먼저 사본으로 보관하고, 그 보관 경로를 삭제 결과에 함께 알립니다. 관리자 화면과 콘솔 양쪽에서 확인할 수 있습니다.
- 운영자가 덧붙인 CSS·JS·글꼴·이미지를 레이아웃 편집기 화면에서 직접 넣고 고칠 수 있습니다. 서버 접속 없이 파일을 만들고 편집하고 지울 수 있으며, 저장하면 다음 화면부터 바로 반영됩니다. 편집 중인 템플릿뿐 아니라 설치된 모듈·플러그인도 대상으로 고를 수 있습니다. 이 기능은 레이아웃 편집과 별도의 「커스텀 자산 관리」 권한으로 열립니다 — 여기서 올린 스크립트는 사이트 전 화면에서 실행되기 때문입니다. 저장할 폴더를 만들지 못하면 사유·소유자·권한·실행 계정과 조치 예시를 함께 안내합니다. (#123 @bigmsg 님께서 건의해주셨습니다.)
- 주소 끝에 `?custom=off` 를 붙여 열면 운영자가 추가한 CSS·JS 없이 화면이 표시됩니다. 추가한 스타일이 화면을 망가뜨려 고치러 들어갈 수조차 없게 된 상황에서 쓰는 탈출구이며, 레이아웃 편집기 툴바에도 같은 동작의 버튼이 있습니다. 이 설정은 저장되지 않고 그 화면에만 적용됩니다.
- 템플릿·모듈·플러그인의 `custom/` 파일이 다른 확장 자산과 같은 방식으로 정적 파일로 게시됩니다. CSS 안에서 글꼴·이미지를 상대 경로(`url('./font.woff2')`)로 참조할 수 있게 되었고, 파일을 고치면 자동으로 다시 게시됩니다. 하위 폴더의 글꼴·이미지만 바꿔도 감지되어 다시 게시되며, 여러 서버가 캐시를 공유하는 구성에서도 서버마다 따로 판정해 불필요한 재게시가 반복되지 않습니다.
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. 관리자 > 환경설정 > 일반 의 「초기 화면 정적 파일」에서 게시 상태(버전·파일 수·마지막 게시 시각·최근 실패)를 확인하고 「지금 다시 만들기」로 즉시 다시 만들 수 있습니다. 게시 파일의 버전 번호는 변경이 있을 때만 바뀝니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 관리자 대시보드에 초기 화면 파일 생성 실패 알림이 추가되었습니다. 원인(폴더 권한·디스크 공간·캐시)에 따라 다른 안내가 표시되며, 서버에서 `php artisan ext-static:status` 로 더 자세한 상태를 확인할 수 있습니다. 알림의 「다시 만들기」 버튼으로 그 자리에서 바로 다시 만들 수 있습니다. 상태·게시 명령을 root 로 실행하면 캐시 폴더가 root 소유가 될 수 있음을 경고하고 웹 서버 계정으로 실행하는 명령 형태를 안내합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- AWS ALB·CloudFront·Cloudflare·nginx 처럼 HTTPS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성을 지원합니다. `.env` 에 `TRUSTED_PROXIES` 를 지정하면 사이트가 접속 주소를 올바르게 인식해 화면이 정상 표시되고, 게시글 작성 IP·로그인 시도 제한·결제 통보 수신도 실제 방문자 기준으로 동작합니다. 지정하지 않으면 종전과 동일하게 동작합니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
- 사이트가 쓰는 외부 라이브러리의 알려진 취약점을 한 번에 점검하는 명령이 추가되었습니다. `php artisan security:audit-dependencies` 로 코어와 설치된 모든 확장을 함께 확인할 수 있고, 개발 대시보드에서도 실행할 수 있습니다. 점검 도구가 원리상 볼 수 없는 동봉 라이브러리는 버전 목록으로 함께 표시해 운영자가 직접 확인할 수 있게 했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, **동봉된 확장 20개 전부에 문서가 채워졌습니다.** 새 확장을 만들면 스캐폴딩 단계에서 이 문서 골격이 함께 생성됩니다.
- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목이 추가되었습니다. 그 확장이 레이아웃 편집기에 무엇을 선언했는지(추가 가능한 화면 요소, 스타일 조절 항목, 미리보기용 샘플 데이터, 화면 상태)와 화면 요소·데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담으며, 편집기 스펙을 두지 않은 확장에는 그것이 정상인지 아닌지를 적습니다.
- 확장 문서 검사가 「설명 자리를 비워 둔 상태」도 미작성으로 셉니다. 종전에는 채워 넣으라는 표시만 지우고 내용을 쓰지 않으면 검사를 통과해, 빈 문서가 완비된 것으로 집계되었습니다.
- 확장의 화면에 데이터를 붙였는데 레이아웃 편집기 미리보기에서 그 자리가 비는 경우를 문서가 실측해 알려 줍니다. 이 어긋남은 실제 화면이 정상 동작해 아무 오류도 남지 않으므로 종전에는 편집기를 열어 보기 전까지 드러나지 않았습니다.
- `.env` 를 운영 기준값으로 삼는 설치를 지원합니다. `.env` 에 `G7_ENV_PRIORITY=true` 를 넣으면 `.env` 에 값이 적혀 있는 항목만 관리자 환경설정 저장값의 영향을 받지 않고, 그 항목은 관리자 화면에서 「`.env` 고정」 표시와 함께 편집 불가가 되며 실제로 적용 중인 값이 표시됩니다. 저장 요청에 그 항목이 섞여 있어도 서버가 걸러냅니다. 종전에는 설치 직후부터 관리자 화면 저장값이 항상 이겨 메일·드라이버·디버그 등 20여 개 항목의 `.env` 설정이 조용히 무시되었습니다. 스위치를 넣지 않으면 종전과 동일하게 동작합니다. (#37 @glitter-gim 님께서 건의해주셨습니다.)
### Changed
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다.
- 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다.
- 동봉 확장의 문서 제목이 「그누보드7 {확장명} {유형}」(예: 「그누보드7 게시판 모듈」) 형식으로 통일되었습니다. 종전에는 확장명만 제목이라 그 문서만 연 사람이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없었습니다. 새 확장의 문서 골격도 같은 형식의 제목으로 생성되며, 직접 만든 확장에는 이 표기를 요구하지 않습니다.
- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다.
### Security
- 화면이 새 스크립트를 불러오는 모든 경로에 같은 출처 확인을 적용했습니다. 종전에는 레이아웃에 적어 둔 스크립트만 확인 대상이어서, 화면 동작(액션)으로 스크립트를 불러오거나 확장을 활성화할 때 자산을 불러오는 경로, 레이아웃 편집기의 미리보기 화면은 확인 없이 외부 주소를 그대로 불러왔습니다. 이제 모든 경로가 사이트 자신의 주소이거나 확장이 미리 선언한 주소만 허용하며, 그 밖의 주소는 불러오지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃을 저장할 때 외부 주소를 검사하는 범위를 넓혔습니다. 종전에는 검사가 컴포넌트 속성·동작과 화면 진입 동작에만 미쳐, 모달·이름 붙인 동작·오류 처리·컴포넌트 생명주기·슬롯·반응형 설정에 적어 넣은 외부 주소는 그대로 저장되었습니다. 예시·안내용 주소를 담는 데이터 항목은 종전처럼 검사하지 않습니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
- 화면 동작으로 외부 라이브러리를 호출하는 기능에 안전장치를 더했습니다. 임의 코드 실행에 쓰일 수 있는 내장 함수 호출과, 호출 결과를 화면 값에 옮길 때 프로그램 내부 구조를 건드리는 이름은 거부됩니다. (#127 @glitter-gim 님께서 건의해주셨습니다.)
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
- 디버그 도구 엔드포인트 전체에 디버그 모드 확인을 적용했습니다. 종전에는 8개 중 3개에만 확인이 있어, 디버그 모드가 꺼진 운영 사이트에서도 로그인 없이 요청하면 저장된 디버그 데이터를 통째로 삭제할 수 있었습니다. 이제 확인은 개별 엔드포인트가 아니라 디버그 도구 전체에 한 번에 걸리므로, 앞으로 추가되는 엔드포인트도 자동으로 보호됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 본인인증을 마친 뒤 받은 확인값이 유효기간을 넘겨도 계속 통했던 문제를 수정했습니다. 그래서 오래전에 받아 둔 값 하나로 회원탈퇴처럼 본인인증이 필요한 작업을 언제든 다시 수행할 수 있었습니다. 이제 유효기간이 지난 확인값은 받아들이지 않으며, 그 경우 종전처럼 본인인증을 다시 요구합니다. 본인인증이 걸린 모든 지점(회원가입·비밀번호 재설정·정책이 지정한 화면 포함)에 함께 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2029)
- 설치 마법사에 입력하는 사이트 이름·사이트 주소·업데이트 저장소 주소에 줄바꿈을 섞어 설치 설정 파일에 임의의 설정 항목을 끼워 넣을 수 있던 문제를 수정했습니다. 이제 설치 설정 파일에 기록되는 모든 입력이 같은 검사를 지나며, 줄바꿈이 섞이면 해당 항목에 오류를 표시하고 저장하지 않습니다. 주소 항목은 형식까지 확인합니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2042)
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 네트워크 공유 경로(`\\서버\공유`)와 임의 이름의 `.phar` 파일을 지정할 수 있던 문제를 수정했습니다. 공격자가 지정한 원격 파일이나 미리 올려 둔 아카이브가 설치 과정에서 실행될 수 있었습니다. 이제 로컬 절대경로만 허용하고 Composer 자리는 composer 계열 이름만 받습니다. 이 문제는 설치가 끝나지 않은 사이트에서만 성립합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2043)
- 웹소켓 채널 인증 주소가 로그인 확인 없는 형태로 하나 더 등록되어 있던 문제를 수정했습니다. 화면에서는 쓰이지 않는 주소였지만 「웹소켓 사용 안 함」 설정을 우회할 수 있었습니다. 이제 로그인과 설정을 함께 확인하는 주소 하나만 남으며, 채널별 권한 확인은 종전과 동일하게 동작합니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
### Fixed
- 설치 마법사의 디렉토리 권한 검사 단계(`storage`·`bootstrap/cache` 와 같은 단계)에서 `public/build` 가 검사 목록에 빠져 있던 문제를 수정했습니다. 웹 서버 계정이 그 폴더에 쓸 수 없어도 마법사가 막거나 실행할 명령을 보여 주지 않은 채 설치를 진행했고, 설치 마지막 단계의 초기 화면 파일 생성이 실패한 뒤 대시보드에 「초기 화면 파일 생성 실패」 알림이 떴습니다. 이제 다른 폴더와 같은 방식으로 통과할 때까지 진행을 막고 권한 부여 명령을 안내합니다.
- 관리자 대시보드의 시스템 알림(초기 화면 파일 생성 실패·확장 호환성·신뢰 프록시)이 같은 내용으로 두 장씩 표시되던 문제를 수정했습니다. 알림 리스너가 자동 발견과 별도 등록으로 두 번 등록된 것이 원인입니다.
- 새로 설치할 때 일부 확장의 설치가 "Class not found" 오류로 중단되던 문제를 수정했습니다. 확장의 설정 기본값을 읽는 단계가 그 확장의 클래스 경로 등록보다 먼저 실행된 것이 원인이며, 이제 설치는 확장 파일을 복사한 직후 클래스 경로를 먼저 등록합니다. 이미 설치된 사이트의 업데이트에는 영향이 없습니다.
- 설정 캐시가 만들어져 있는 사이트(설치 마법사·환경설정 저장·확장 업데이트를 한 번이라도 거친 대부분의 사이트)에서 코어 업데이트가 업그레이드 스텝 단계에서 "수동 재개" 안내와 함께 멈추던 문제를 수정했습니다. 새 버전으로 실행되는 스텝 프로세스가 이전 버전의 설정 캐시를 읽어 자기 자신을 옛 버전으로 오판한 것이 원인이며, 실행할 스텝이 하나도 없는 버전으로 올릴 때도 같은 안내가 나왔습니다. 같은 원인으로 새 버전이 추가한 쓰기 폴더가 `sudo` 업데이트의 권한 정리에서 빠지던 문제도 함께 바로잡았고, 이제 업데이트는 스텝 실행 전에 이전 설정 캐시를 비웁니다.
- 명령줄로 업그레이드 스텝을 직접 재실행하거나 관리자 「시스템 최적화」를 실행한 뒤, 그 실행이 예약한 초기 화면 정적 파일 재생성이 조용히 건너뛰어지던 문제를 수정했습니다.
- 아웃바운드 프록시를 지정한 사이트에서 글·상품 저장이 수십 초씩 걸리던 문제를 수정했습니다. 사이트가 자기 자신에게 보내는 내부 요청까지 프록시로 나가고 있었고, 프록시가 응답하지 않으면 그 요청이 연결 실패 시각까지 매달렸습니다. 실패는 화면에 드러나지 않고 저장만 느려져 원인을 알기 어려웠습니다. 이제 사이트 자기 주소와 로컬 주소는 운영자가 예외 목록에 적지 않아도 항상 프록시를 거치지 않습니다.
- 설치 마법사에서 PHP·Composer 경로가 거부될 때 안내 문구가 실제 허용 범위와 달라, 안내대로 고쳐도 계속 거부되던 문제를 수정했습니다. 이제 파일 이름 조건과 사용할 수 없는 경로 형태(네트워크 경로·scheme:// 등)를 문구에 함께 안내합니다.
- 같은 스크립트를 거의 동시에 두 번 불러오면, 두 번째 요청이 첫 번째 로드가 끝나기 전에 완료된 것으로 처리되어 그 뒤 동작이 아무 반응 없이 끝나던 문제를 수정했습니다. 이제 두 요청 모두 실제 로드가 끝난 뒤에 이어집니다.
- 디버그 모드에서도 디버그 도구의 조회 주소(상태·액션 이력·캐시·변경 감지)가 사용자 화면에 가려져 응답하지 못하던 문제를 수정했습니다. 사이트가 미리 예약해 둔 주소는 사용자 화면 처리에서 제외됩니다. (#128 @glitter-gim 님께서 건의해주셨습니다.)
- 「자산 주소에 확장자 사용 안 함」 설정을 켠 사이트에서 글꼴과 국기 아이콘이 표시되지 않던 문제를 수정했습니다. 스타일시트가 그 안에서 상대 경로로 가리키던 글꼴·이미지 파일을 브라우저가 엉뚱한 주소로 찾아 불러오지 못했고, 화면에는 기본 서체와 빈 아이콘만 보였습니다. 이제 스타일시트를 내보낼 때 그 경로를 올바른 주소로 바꿔 전달합니다. 설정을 바꾸면(화면 저장·단건 저장·설정 복원·명령줄 어느 경로든) 스타일시트와 초기 화면 파일이 곧바로 새 주소 방식으로 다시 만들어집니다.
- 스타일 안에서 글꼴·이미지를 상대 경로로 가리키는 확장이 있으면, 그 확장의 스타일이 화면에 하나도 적용되지 않던 문제를 수정했습니다. 여러 확장의 스타일을 하나로 합쳐 전달하는 과정에서 그런 확장을 통째로 빼고 있었고, 빠졌다는 사실은 어디에도 표시되지 않았습니다. 이제 빼는 대신 그 경로를 올바른 주소로 바꿔 함께 전달합니다.
- 확장을 활성화할 때 스크립트가 이미 있으면 그 확장의 스타일(CSS)까지 함께 건너뛰던 문제를 수정했습니다. 스타일만 제공하는 확장은 활성화해도 스타일이 적용되지 않았습니다.
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
- 화면에서 목록에 담은 항목(선택한 옵션 등)이 저장·전송 요청에 실리지 않던 문제를 수정했습니다. 화면에는 담긴 것으로 보이는데 실제 요청에는 빈 목록이 전송되어, 상황에 따라 오류로 끝나거나 「선택해주세요」 안내만 반복됐습니다. 화면 상태를 두 곳에 나눠 보관하는 구조에서 한쪽에만 기록되던 것이 원인입니다.
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
- 템플릿을 업데이트해도 레이아웃이 바뀌지 않은 릴리스(다국어·라우트·컴포넌트 정의·에셋만 바뀐 경우)에서는 다국어·라우트·에셋 캐시가 갱신되지 않아 이전 내용이 계속 표시되던 문제를 수정했습니다. 모듈·플러그인 업데이트와 같이 항상 갱신됩니다.
- 자산 캐시 번호가 하루마다 자동으로 바뀌어 매일 모든 방문자가 자산을 다시 내려받고 서버가 초기 화면 파일 전체를 다시 만들던 문제를 수정했습니다. 이제 확장 설치·업데이트, 설정 변경, 운영자 파일 변경처럼 실제 변경이 있을 때만 바뀝니다.
- `sudo` 로 코어를 업데이트한 뒤 모듈·플러그인 설정 저장이나 관리자 화면의 확장 업데이트가 폴더 권한 오류로 실패할 수 있던 문제를 수정했습니다. 업데이트 과정이 만드는 설정 폴더·임시 폴더·로그 파일의 소유권을 웹 서버 계정으로 맞춥니다. 설치 안내(INSTALL.md)에 명령줄·cron 을 웹 서버 계정으로 실행하는 규칙과 cron 예시를 추가했습니다.
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
- 프론트엔드를 다시 빌드하면 초기 화면 파일과 화면 구동에 필요한 코어 파일이 함께 지워져, 이미 열려 있던 페이지에서 파일을 찾지 못하던 문제를 수정했습니다. 빌드는 이제 자기 산출물만 교체합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 명령줄에서 초기 화면 파일을 처음 만들면 이후 웹에서 다시 만들지 못해, 확장을 설치·변경해도 초기 화면 파일이 갱신되지 않던 문제를 수정했습니다. 만들어진 파일이 웹 프로세스도 쓸 수 있는 권한을 갖도록 정리하고, 권한이 부족하면 화면이 느려질 뿐 멈추지 않도록 처리한 뒤 관리자 대시보드에 원인을 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 디스크가 가득 찬 상태에서 만들어진 손상된 초기 화면 파일이 그대로 사용되어 화면이 뜨지 않던 문제를 수정했습니다. 파일을 만들 때 기록된 내용이 온전한지 확인하고, 손상이 확인되면 종전 방식으로 자동 전환합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 초기 화면 파일을 새로 만드는 도중 짧은 시간 동안 기존 파일을 찾지 못할 수 있던 문제를 수정했습니다. 새 파일이 완성된 뒤에야 이전 파일을 정리합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 캐시를 비운 뒤 정리 작업이 먼저 실행되면 아직 사용 중인 직전 초기 화면 파일이 삭제될 수 있던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 확장 프로그램의 스크립트·스타일 묶음을 저장하지 못하는 환경에서 해당 요청이 서버 오류(500)가 되던 문제를 수정했습니다. 저장에 실패해도 화면에는 정상적으로 전달됩니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. 스타일이 비어 있는 확장만 설치된 사이트처럼 내용이 원래 없는 경우는 오류로 보지 않습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 설치 마법사 2단계(설치 환경 확인)가 Windows 사용자 계정 이름에 한글이 포함되어 있으면 빈 응답을 받아 `Unexpected end of JSON input` 오류로 더 진행되지 않던 문제를 수정했습니다. 계정 이름을 영문으로 바꾸지 않아도 설치할 수 있습니다. (sir.kr 커뮤니티의 FreeMax 님께서 제보해주셨습니다.)
- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
- 설치 완료·실패·중단 안내와 필수 파일 생성 안내가 나타날 때 화면이 그 위치로 부드럽게 이동합니다. 종전에는 설치 진행 로그를 보느라 화면이 아래쪽에 머물러 있으면 안내가 표시되어도 눈에 들어오지 않았습니다. 화면 움직임을 최소화하도록 설정한 사용자에게는 즉시 이동합니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
- 코어 업데이트가 끝난 뒤 임시 작업 폴더의 껍데기(`storage/app/core_pending/core_*/extracted`)가 업데이트마다 남던 문제를 수정했습니다. `sudo` 로 실행한 경우 그 폴더가 root 소유로 남아 운영자 계정이나 웹서버 계정으로는 지울 수 없었습니다. 이제 임시 폴더를 통째로 정리하고, 이전 버전이 남긴 빈 껍데기도 업데이트 과정에서 함께 치웁니다. 소유권 복원 기준에서도 이번 실행이 만든 임시 폴더를 제외해, 남는 것이 있더라도 운영자가 지울 수 있는 소유권을 갖습니다.
- 코어 업데이트 완료 안내문이 성공한 업데이트 뒤에는 동작하지 않는 정리 명령(`hotfix:rollback-stale-files --prune`)을 함께 안내하던 것을 바로잡았습니다. 성공한 업데이트는 백업을 지우므로 그 명령은 "사용 가능한 백업이 없습니다" 로 끝났습니다. 이제 신 버전에서 제거된 파일을 정리하려면 같은 업데이트를 `--prune` 옵션으로 다시 실행하도록만 안내합니다.
## [7.0.9] - 2026-08-24
### Added
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
### Fixed
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
## [7.0.8] - 2026-08-22
### Added
- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
### Changed
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
### Fixed
- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
## [7.0.7] - 2026-08-19
### Security
- 부관리자(위임 관리자)가 슈퍼 관리자 계정을 함부로 손대지 못하도록 막았습니다. 슈퍼 관리자 보호는 삭제·탈퇴 경로에만 있었고 비밀번호 변경·상태 변경(차단/탈퇴)·계정 잠금 해제·일괄 상태 변경 경로에는 없어, 회원 관리 권한만 위임받은 계정이 슈퍼 관리자 계정을 무력화할 수 있었습니다. 이제 이 경로 전부에 같은 기준을 적용해, 슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다. 슈퍼 관리자 본인의 작업은 종전처럼 정상 동작합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 부관리자가 역할 권한을 통해 자신보다 높은 권한을 획득하지 못하도록 막았습니다. 이전에는 역할에 권한을 부여할 때 부여하는 사람이 그 권한을 가졌는지 확인하지 않아, 권한 관리 권한만 위임받은 계정이 자신에게 없는 권한이나 더 넓은 범위의 권한을 역할에 실어 우회 상승할 수 있었습니다. 이제 자신이 보유한 권한을 자신의 범위 이내로만 부여할 수 있으며, `admin` 같은 시스템·확장 소유 역할의 권한·활성 상태 변경도 슈퍼 관리자로 제한됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 위 상한을 사용자에게 역할을 붙이는 경로에도 동일하게 적용했습니다. 이전에는 사용자 생성·수정 화면에서 역할을 배정할 때 그 역할이 담은 권한을 배정자가 모두 가졌는지 확인하지 않아, `admin` 같은 고권한 역할을 통째로 붙여 우회 상승할 수 있었습니다. 이제 배정자가 자신의 권한 범위 안에서 전부 부여할 수 있는 역할만 붙일 수 있습니다. 같은 기준이 역할을 **떼는** 방향에도 적용되어, 자신이 부여할 수 없는 상위 역할을 다른 관리자에게서 박탈하는 것도 차단됩니다. 사용자가 이미 가진 역할을 유지하는 수정은 그대로 허용되고, 슈퍼 관리자의 역할 배정은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃 편집기에 저장하는 표현식이 서버나 다른 사용자 브라우저에서 임의 코드로 실행될 수 없도록 표현식 평가 방식을 근본적으로 바꿨습니다. 이전에는 표현식을 실제 코드로 만들어 실행했기 때문에 특정한 우회 기법으로 편집 권한을 넘어선 동작이 가능했습니다. 이제 정해진 문법·함수만 해석하는 안전한 방식으로 평가하며, 위험한 표현식과 외부 주소의 스크립트 로드는 저장 단계에서도 거부합니다. 기존 레이아웃의 정상 표현식(조건·계산·목록 가공·경로 조립 등)은 그대로 동작하고, 위지윅 에디터·주소 검색처럼 정해진 외부 스크립트를 쓰는 확장은 각자 신뢰 출처를 선언해 정책 강화 이후에도 정상 동작합니다. 신뢰 출처는 모듈·플러그인·템플릿이 모두 자기 설정 파일에 선언할 수 있으며, 활성 상태인 확장의 선언만 반영됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1915)
- 레이아웃에 적는 주소가 "내 사이트 경로"인지 판정하는 방식을 브라우저의 실제 해석과 일치시켰습니다. 이전에는 주소 앞부분의 글자만 보고 판정했기 때문에, 슬래시 사이에 역슬래시나 보이지 않는 공백 문자를 끼워 넣은 주소가 내 사이트 경로처럼 통과한 뒤 브라우저에서는 외부 사이트 주소로 해석되어, 선언하지 않은 외부 스크립트가 실제로 불려 올 수 있었습니다. 이제 브라우저와 같은 기준으로 정규화한 뒤 판정하며, 저장 단계·화면 로드·정비 검사 세 곳이 같은 기준을 씁니다. 경로 중간에 역슬래시가 들어간 정상 주소는 종전처럼 그대로 동작합니다.
- 레이아웃 편집기로 저장할 때 외부 주소 차단이 적용되지 않던 문제를 수정했습니다. 레이아웃을 새로 만들거나 정보를 수정하는 경로에는 이 검사가 걸려 있었지만, 편집기가 실제로 사용하는 콘텐츠 저장 경로에는 빠져 있어 같은 내용도 어느 화면에서 저장하느냐에 따라 통과 여부가 달랐습니다. 함께, 주소 입력 칸 하나만 검사하도록 걸어 둔 설정이 실제로는 아무것도 검사하지 않고 지나가던 것도 바로잡았습니다. 이제 네 저장 경로가 모두 같은 강도로 검사합니다.
- 회원 일괄 상태 변경에서 담당 범위 제한이 적용되지 않던 문제를 수정했습니다. 회원 수정 권한은 "본인 계정만" 또는 "같은 역할 범위만" 으로 범위를 좁혀 위임할 수 있는데, 이 제한은 회원을 하나씩 여는 화면에서만 적용되고 목록에서 여러 명을 한 번에 처리하는 일괄 변경에는 적용되지 않았습니다. 그래서 범위를 좁혀 위임받은 관리자가 일괄 변경으로는 담당 밖 회원까지 차단·탈퇴 처리하고 그 회원들의 로그인 세션까지 끊을 수 있었습니다. 기본 제공 역할인 "매니저" 가 이 구성에 해당합니다. 이제 일괄 변경도 회원 상세와 같은 기준으로 대상마다 범위를 확인하며, 범위 밖 회원은 처리 대상에서 제외되고 처리 건수로 확인할 수 있습니다. 범위 제한 없이 위임받은 관리자의 일괄 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃에 적는 스크립트 주소가 확장이 선언한 신뢰 출처인지 판정할 때, 주소를 브라우저와 같은 기준으로 정규화하지 않던 문제를 수정했습니다. 신뢰 출처 이름을 주소 뒷부분에 끼워 넣고 그 앞에 역슬래시를 둔 주소가 저장 단계에서만 신뢰 출처로 보여 통과했습니다(화면 로드 단계는 차단하고 있었으므로 실제로 불려 오지는 않았습니다). 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 경우도 있었습니다. 이제 주소가 "내 사이트 경로인가" 와 "신뢰 출처인가" 를 같은 기준으로 판정합니다.
- 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다.
- 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919)
- 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915)
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
### Added
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
- S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다.
- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다.
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
### Changed
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
- 예약 작업의 실행 시각이 환경설정의 시간대를 기준으로 해석되도록 바꿨습니다. 종전에는 세계표준시 기준이어서 '새벽 4시'로 등록해도 국내 기준 낮에 실행됐습니다. 이번 변경으로 새로 추가된 자동 정리 작업은 물론, 기존 사이트맵 자동 생성·GeoIP 갱신·확장이 제공하는 예약 작업, 관리자가 직접 등록한 예약 작업까지 모두 같은 기준을 따릅니다. **시각을 지정해 둔 예약은 실제 실행 시각이 이동하므로 확인이 필요합니다.**
- 시스템 요구사항 문서를 실제 검증 기준에 맞게 정비했습니다. 지원 브라우저 항목에 검증 방식을 명시하고(자동 테스트는 Chromium 기준이며, Firefox·Safari 는 프론트엔드 의존성의 호환 범위를 기준으로 지원합니다), PHP 확장을 필수와 기능별 선택으로 구분했으며, 설치 프로그램이 자동으로 검사하는 항목과 운영자가 직접 확인해야 하는 항목을 구분하는 절을 새로 두었습니다. "요구사항" 이라고 적힌 항목이 모두 설치 단계에서 검사되는 것은 아니라는 점이 이제 문서에 드러납니다. (#95 @glitter-gim 님께서 제보해주셨습니다.)
- 문서 사이에 어긋나 있던 수치와 표기를 정정했습니다 — 디스크 용량 기준(설치 검사 기준 500MB), PHP 확장 개수, 지원 운영체제에 Windows 개발 환경 추가, 데이터베이스 표기에 MariaDB 병기, 한국어 README 의 시스템 요구사항 항목 보완, 그리고 데이터베이스 접두어 길이 제한·collation 변경 가능 여부처럼 실제로는 동작하지만 문서에 없던 조건의 추가.
- 확장 개발자용: 배포본에 함께 들어가는 브라우저 테스트 안내 문서가 실제 디렉토리 구성 및 실행 스크립트와 어긋나 있던 부분을 정정했습니다.
### Fixed
- 데이터베이스 테이블 접두어를 기본값(`g7_`)이 아닌 값으로 설치한 사이트에서, 7.0.6 업그레이드의 활동 로그 색인 교체가 옛 색인을 지우지 못하고 남겨 두던 문제를 수정했습니다. 남은 옛 색인은 조회 이득 없이 기록 쓰기 비용만 늘리므로, 이번 업그레이드에서 자동으로 정리됩니다.
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다.
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 일괄 상태 변경의 결과 안내 문구에 처리 인원수가 채워지지 않고 자리표시 원문이 그대로 표시되던 문제를 수정했습니다.
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
- 웹소켓 연결 테스트가 브라우저 접속 주소만 검사해, 테스트는 성공해도 서버 발송(알림 등)은 실패할 수 있던 문제를 수정했습니다 — 이제 서버 발송용 주소도 함께 검사하고, 실패 시 어느 쪽 주소가 문제인지 구분해 알려줍니다.
- 드라이버 설정의 S3 URL 이 너무 길 때 표시되는 영어 안내 문구가 실제 허용 길이(500자)와 다르게 255자로 안내되던 문제를 수정했습니다.
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
- 예약 작업 저장 화면에서 설치된 확장이 제공하는 Artisan 명령이 "예약 실행이 허용된 명령이 아닙니다" 로 거부되던 문제를 수정했습니다. 터미널에서의 검증은 통과하는 명령이 관리자 화면 저장에서만 거부되었습니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다.
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
## [7.0.6] - 2026-08-10
### Security
+121 -10
View File
@@ -8,7 +8,7 @@
| 항목 | 요구사항 |
|------|---------|
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
| **PHP** | 8.2 이상 (필수 확장 16개 포함 — 기능별 선택 확장은 별도) |
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
| **Composer** | 2.x |
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
@@ -148,12 +148,16 @@ git clone https://github.com/gnuboard/g7.git
cd g7
```
### 3단계: Composer 의존성 설치
### 3단계: Composer 의존성 설치 (선택)
```bash
composer install
composer install --no-dev --optimize-autoloader
```
> 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
>
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
### 4단계: 환경 설정 파일 생성
```bash
@@ -200,15 +204,17 @@ https://github.com/gnuboard/g7
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
### 4단계: Composer 의존성 설치
### 4단계: Composer 의존성 설치 (선택)
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다.
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다. 이 단계는 생략해도 됩니다 — `vendor/` 가 없으면 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다.
```bash
cd g7-버전명
composer install
composer install --no-dev --optimize-autoloader
```
> 개발용 패키지가 포함되는 `composer install`(옵션 없음)의 결과를 운영 사이트에 그대로 쓰지 마세요. 이미 그렇게 설치했다면 위 명령을 한 번 더 실행하면 개발용 패키지만 제거됩니다.
### 5단계: 환경 설정 파일 생성
```bash
@@ -289,7 +295,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.6 g7
# (필요 시) mv g7-7.0.11 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -343,12 +349,22 @@ http://도메인/install
|------|------|
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
| 1. 라이선스 | 동의 |
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 |
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 + 권장 항목(OPcache · Composer 의존성 구성) 안내 |
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
`.env` 파일 생성과 `storage` / `bootstrap/cache` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
`.env` 파일 생성과 `storage` / `bootstrap/cache` / `public/build` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
**Step 2 — Composer 의존성 구성 (선택 항목):**
이미 `vendor/` 가 준비되어 있으면 설치 마법사는 그것을 그대로 씁니다. 이때 그 `vendor/` 가 개발용 패키지까지 포함한 설치(`composer install`, 옵션 없음)인지 확인해 알려 줍니다.
- **운영용 구성 (개발용 패키지 없음)**: 권장 상태입니다. 조치가 필요 없습니다.
- **개발용 패키지 N개 포함**: 설치는 그대로 진행됩니다. 운영에 사용할 서버라면 설치를 마친 뒤 프로젝트 루트에서 `composer install --no-dev --optimize-autoloader` 를 한 번 실행하세요. 그대로 두면 이후 코어 업데이트가 `vendor/` 를 교체할 때 이전 패키지 목록 캐시와 어긋나 업데이트가 중단될 수 있습니다.
- **vendor 없음**: 설치 마법사가 운영용 구성으로 자동 설치하므로 조치가 필요 없습니다.
이 항목은 설치를 차단하지 않습니다.
**Step 3 — Vendor 설치 방식 선택:**
@@ -368,6 +384,28 @@ http://도메인/install
---
## 설치 트러블슈팅
### 2단계에서 "Unexpected end of JSON input" 오류가 표시되는 경우
**증상**: 설치 마법사 2단계(설치 환경 확인)에서 요구사항 카드가 표시되지 않고
`요구사항 검증 실패: ... Unexpected end of JSON input` 이 표시됩니다.
서버 로그에는 아무 오류도 남지 않습니다.
**원인**: Windows 사용자 계정 이름에 한글(또는 서버가 쓰는 문자 인코딩 밖의 문자)이
포함되어 있으면, 설치 마법사가 계정 이름을 조회하는 과정에서 응답을 만들지 못합니다.
**해결**:
- **7.0.10 이상**: 수정되었습니다. 별도 조치가 필요하지 않습니다.
- **7.0.9 이하**: 계정 이름이 영문인 Windows 계정으로 웹 서버를 실행하거나,
코어를 7.0.10 이상으로 올린 뒤 설치를 진행하세요.
설치 과정에서 문자 인코딩 관련 조치가 있었다면 `storage/logs/installation.log` 에
`[env] whoami output was not UTF-8 — normalized` 형태로 기록됩니다.
---
## 설치 후 확인
설치가 완료되면 아래 페이지에 접근할 수 있습니다.
@@ -379,6 +417,29 @@ http://도메인/install
> 사용자 페이지는 사용자 템플릿이 설치되어 있어야 접근할 수 있습니다. 인스톨러에서 사용자 템플릿을 함께 설치하거나, 관리자 페이지에서 템플릿을 먼저 설치해 주세요.
### 파일 권한 (설치 후 확인)
POSIX 권한 모델 (Linux/macOS/BSD) 환경에서 웹 서버 실행 계정이 쓸 수 있어야 하는 위치는 다음과 같습니다. Windows 환경에서는 해당하지 않습니다.
| 위치 | 필요한 이유 |
|------|------------|
| `storage/` · `bootstrap/cache` | 애플리케이션 동작에 항상 필요 (로그·캐시·세션·업로드·런타임 캐시) |
| `modules/` · `plugins/` · `templates/` · `public/build` | 관리자 화면에서 확장(모듈/플러그인/템플릿)을 설치·업데이트·삭제할 때 필요 |
```bash
sudo chown -R www-data:www-data storage bootstrap/cache modules plugins templates public/build
```
**`vendor/` 에는 쓰기 권한이 필요하지 않습니다.** 확장의 `vendor/` 는 설치·업데이트 시점에만 기록되며, 애플리케이션이 동작하면서 만드는 런타임 캐시는 모두 `storage/` 아래에 기록됩니다. 명령줄로 설치·업데이트를 수행한다면 그 계정만 쓸 수 있으면 됩니다.
전체 권한 모델(그룹 공유 방식 A / 소유자 통일 방식 B / ACL 방식 C 와 umask 운영)은 [docs/requirements.md](docs/requirements.md) "파일 권한 및 umask 운영 방식" 절을 참고하세요.
### 명령줄·cron 실행 계정
`php artisan` 명령과 cron 은 **웹 서버 실행 계정으로** 실행합니다 (예: `sudo -u www-data php artisan …`, 또는 `crontab -u www-data -e` 로 등록). 대부분의 명령이 캐시·병합 번들·임시 파일을 만드는데, root 나 다른 계정으로 실행하면 그 산출물이 그 계정 소유로 남아 이후 웹 요청이 같은 자리에 쓰려는 순간 `Permission denied` 로 500 을 낼 수 있습니다. 이 문제는 특정 명령의 결함이 아니라 캐시를 건드리는 모든 명령에 공통이므로, 계정을 맞추는 것이 유일한 해법입니다.
예외는 코어 업데이트(`php artisan core:update`)입니다. 이 명령은 `.env` 등 웹 계정이 쓸 수 없는 파일까지 고치므로 `sudo` 를 권장하며, 실행이 끝날 때 자기가 만든 파일의 소유권을 되돌리는 절차가 내장되어 있습니다.
---
## 업그레이드
@@ -540,6 +601,54 @@ sudo php artisan hotfix:rollback-stale-files --prune
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
`APP_URL` 은 명령줄 실행·큐 워커·메일 발송처럼 **요청이 없는 맥락**에서 절대 URL 을 만들 때의
기준입니다. 웹 요청에서 만들어지는 절대 URL(화면 자산, 콜백 주소 등)은 `APP_URL` 이 아니라
**요청 자체의 스킴과 호스트**를 따릅니다.
따라서 TLS 를 앞단에서 처리하고 사이트에는 HTTP 로 전달하는 구성(AWS ALB, CloudFront,
Cloudflare, nginx 리버스 프록시, ngrok)에서는 `APP_URL` 만 `https://` 로 두어서는 부족합니다.
사이트가 접속 주소와 방문자 IP 를 올바르게 인식하려면 `.env` 에 `TRUSTED_PROXIES` 를 함께
지정해야 합니다.
```dotenv
# 프록시 뒤에서 구동하는 경우에만 지정합니다 (미설정이 기본값).
TRUSTED_PROXIES=*
```
지정하지 않으면 화면이 표시되지 않거나(혼합 콘텐츠 차단), 결제 통보가 수신되지 않고, 모든
방문자가 같은 IP 로 기록됩니다. 값 선택 기준과 도입 시 후속 조치는
[docs/backend/reverse-proxy.md](docs/backend/reverse-proxy.md) 를 참고하세요.
### `.env` 를 운영 기준값으로 삼기 (선택)
기본 설계에서 **관리자 환경설정 화면이 운영 기준**입니다. 설치가 끝나면 메일·드라이버·디버그
같은 항목은 화면에서 관리하며, 그 값이 `.env` 의 같은 항목보다 우선합니다. 설치 마법사가
초기값을 저장하므로 **설치 직후부터** 그 우선순위가 적용됩니다.
컨테이너·구성 관리 도구·다중 서버처럼 `.env` 를 배포 기준값으로 관리하는 환경에서는 이
우선순위를 뒤집을 수 있습니다. `.env` 에 다음 한 줄을 추가하세요.
```dotenv
G7_ENV_PRIORITY=true
```
켜면 **`.env` 에 값이 적혀 있는 항목만** 화면 저장값의 영향을 받지 않습니다. 그 항목들은
관리자 화면에서 편집 불가로 표시되고, 화면에는 실제로 적용 중인 값이 나타납니다. 값이 비어
있거나 줄이 없는 항목은 종전대로 화면에서 관리합니다.
주의할 점:
- `.env.example` 은 `APP_NAME`, `LOG_LEVEL`, `G7_UPDATE_GITHUB_URL` 등에 값을 채워 배포합니다.
그 파일을 복사해 쓰고 있다면 스위치를 켜는 즉시 **그 항목들도 함께 잠깁니다.** 화면에서
관리하고 싶은 항목은 값을 비우거나(`KEY=`) 줄을 지우세요.
- `.env` 에 `BROADCAST_CONNECTION` 이나 `REVERB_*` 를 적으면 웹소켓 사용 여부까지 `.env` 가
결정합니다. 관리자 화면의 웹소켓 토글은 그 설치에서 동작하지 않습니다.
- `.env` 만 고친 경우에는 `php artisan config:cache` 를 다시 실행해야 반영됩니다. 관리자
화면에서 저장하는 경우에는 자동으로 처리됩니다.
- 비밀번호·시크릿·토큰 같은 값은 잠금 표시만 되고 화면에 나타나지 않습니다.
스위치를 넣지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
@@ -578,7 +687,9 @@ sudo php artisan hotfix:rollback-stale-files --prune
cron에 아래 항목을 등록합니다.
```bash
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
* * * * * cd /path/to/g7 && sudo -u www-data php artisan schedule:run >> /dev/null 2>&1
```
웹 서버 계정(`www-data` · `nginx` · `apache` 등)으로 실행해야 합니다 — root 의 crontab 에 그대로 등록하면 매분 캐시 파일이 root 소유로 만들어져 웹 요청이 실패할 수 있습니다. `crontab -u www-data -e` 로 그 계정의 crontab 에 등록하면 `sudo -u` 없이 같은 줄을 씁니다. 「명령줄·cron 실행 계정」 절을 참고하세요.
> 상세 내용은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
+576
View File
@@ -0,0 +1,576 @@
<p align="center"><a href="README.md">English</a> | 한국어</p>
# 그누보드7
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
A modern, extensible CMS platform built with Laravel + React
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
<p align="center">
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
</p>
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
---
## 그누보드7 소개
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
---
## 주요 기능
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
## 기술 스택
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
| **코드 품질** | Laravel Pint (PSR-12) |
---
## 아키텍처
```
Gnuboard7
├── Core (Laravel 12)
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│
└── Template Engine
├── JSON Layout → React Components
└── Dynamic Rendering + Data Binding
```
### 템플릿 엔진 동작 흐름
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 제공 기능
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
```mermaid
flowchart TB
subgraph Backend ["🔧 Backend — Laravel"]
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
B --> |"상속 해석<br/>extends / partial"| B
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
end
subgraph Frontend ["⚛️ Frontend — React"]
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
F & G --> H["🎨 DynamicRenderer"]
H --> I{"❓ 조건 평가<br/>if 표현식"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
N --> O["✨ React 렌더링"]
end
subgraph Actions ["👆 사용자 인터랙션"]
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — 페이지 이동"]
Q --> S["📡 apiCall — API 호출"]
Q --> T["🔄 setState — 상태 변경"]
Q --> U["📋 openModal — 모달 열기"]
S --> |"onSuccess · onError"| Q
T --> |"상태 변경 → 리렌더링"| H
end
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
style Frontend fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#064e3b
style Actions fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#831843
style A fill:#2563eb,stroke:#1d4ed8,color:#fff
style B fill:#2563eb,stroke:#1d4ed8,color:#fff
style M fill:#7c3aed,stroke:#6d28d9,color:#fff
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
style C fill:#dc2626,stroke:#b91c1c,color:#fff
style D fill:#059669,stroke:#047857,color:#fff
style E fill:#059669,stroke:#047857,color:#fff
style F fill:#0891b2,stroke:#0e7490,color:#fff
style G fill:#0891b2,stroke:#0e7490,color:#fff
style H fill:#d97706,stroke:#b45309,color:#fff
style I fill:#d97706,stroke:#b45309,color:#fff
style J fill:#2563eb,stroke:#1d4ed8,color:#fff
style K fill:#6b7280,stroke:#4b5563,color:#fff
style L fill:#7c3aed,stroke:#6d28d9,color:#fff
style N fill:#7c3aed,stroke:#6d28d9,color:#fff
style O fill:#059669,stroke:#047857,color:#fff
style Q fill:#e11d48,stroke:#be123c,color:#fff
style R fill:#be185d,stroke:#9d174d,color:#fff
style S fill:#be185d,stroke:#9d174d,color:#fff
style T fill:#be185d,stroke:#9d174d,color:#fff
style U fill:#be185d,stroke:#9d174d,color:#fff
linkStyle default stroke:#374151,stroke-width:2px
```
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
```json
{
"data_sources": [
{ "id": "products", "endpoint": "/api/products", "method": "GET" }
],
"layout": {
"type": "basic", "name": "Div",
"children": [
{ "type": "basic", "name": "H1", "text": "$t:product_list" },
{
"type": "basic", "name": "Div",
"iteration": { "source": "{{products?.data?.data}}", "item_var": "$item" },
"children": [
{ "type": "basic", "name": "Span", "text": "{{$item.name}}" }
]
},
{
"type": "basic", "name": "Button", "text": "$t:add",
"if": "{{products?.data?.abilities?.can_create}}",
"actions": [{
"event": "onClick",
"handler": "navigate",
"params": { "path": "/products/create" }
}]
}
]
}
}
```
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
### 핵심 시스템
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
#### 1. 확장 시스템 — 3원칙
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
#### 2. 훅 시스템 (Action / Filter)
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
```php
// Service 계층에서 훅 발행
HookManager::doAction('core.user.after_create', $user, $data);
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
// 모듈 Listener 가 훅 구독 (자동 발견)
public static function getSubscribedHooks(): array
{
return [
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
];
}
```
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
#### 3. 공통 캐시 시스템
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
| 드라이버 | 접두사 | 용도 |
| --- | --- | --- |
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
```php
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
public function __construct(
private BoardRepositoryInterface $repository,
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
) {}
```
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
#### 4. 알림 시스템
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
```text
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
│ variables=[...] │ │ subject, body, │ │ - role │
│ │ │ click_url │ │ - specific_users │
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
```
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
```php
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
// ↓ NotificationHookListener → NotificationDispatcher:
// 1. order.confirmed 정의 조회
// 2. 활성 템플릿 순회 (mail/database)
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
// 5. notification_logs 에 발송 이력 기록
```
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
#### 5. 언어팩 시스템
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
| 영역 | 동작 |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
#### 6. 본인인증 (Identity Verification)
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
---
## 빠른 시작
### 시스템 요구사항
- PHP 8.2+ (필수 확장 16개 포함 — `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, `zip` 등. `gd`/`imagick`, `intl`, `redis`, `bcmath` 등은 해당 기능을 쓸 때만 필요하며 전체 목록은 [docs/requirements.md](docs/requirements.md) 참조)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Composer 2.x
- Node.js 20+ (프론트엔드 에셋을 직접 빌드할 때만 필요)
- 웹 서버(Apache 또는 Nginx) — 문서 루트를 `public/` 으로 지정
- Redis 6.0+ (선택 — 프로덕션의 캐시·큐에 권장)
### 설치
```bash
# 1. 프로젝트 클론
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. 환경 설정 파일 복사
cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
```
> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
---
## 기본 제공 확장
### 모듈
| 모듈 | 설명 |
|------|------|
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
### 플러그인
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
### 템플릿
| 템플릿 | 설명 |
|--------|------|
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
### 번들 언어팩
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-message_bizppurio-ja** | 비즈뿌리오 메시지 발송 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
### 학습용 샘플 확장
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
---
## 비즈니스 모델
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
---
## 기존 그누보드 사용자
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
---
## 문서
| 문서 | 링크 |
|------|------|
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
| 전체 문서 | [docs/README.md](docs/README.md) |
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
## 기여하기
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- 코드 스타일: Laravel Pint (PSR-12)
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
---
## 만든 사람들
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
### Core Team
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<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>
### 커뮤니티 기여자
제보와 건의로 그누보드7을 함께 만들어 주신 분들입니다. 아래 목록은 변경 기록(CHANGELOG)에 남은 제보자 표기에서 모았습니다.
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
</p>
<!-- community-contributors:end -->
코드 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
---
## 커뮤니티
| 채널 | 링크 |
|------|------|
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
| 문의 | minsup@sir.kr |
---
## 변경 기록
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
---
## 보안 취약점
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
---
## 라이선스
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
Copyright (c) 2026 SIRSOFT
---
<p align="center">
Made by <a href="https://sir.kr">SIRSOFT</a>
</p>
+307 -260
View File
@@ -1,14 +1,12 @@
<p align="center">
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
</p>
<p align="center">English | <a href="README.ko.md">한국어</a></p>
# Gnuboard7
**A modern, extensible CMS platform built with Laravel + React**
The next generation of Gnuboard — Korea's most widely used open-source CMS
<p align="center">
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
A modern, extensible CMS platform built with Laravel + React
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.11-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -17,67 +15,71 @@
</p>
<p align="center">
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
<a href="https://g7.gnuboard.com"><strong>Live Demo</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>Admin Demo</strong></a>
</p>
<p align="center">
<sub>The demo UI language follows your browser (Korean/English); you can switch it in the UI.</sub>
</p>
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
[About](#about-gnuboard7) · [Key Features](#key-features) · [Tech Stack](#tech-stack) · [Architecture](#architecture) · [Quick Start](#quick-start) · [Bundled Extensions](#bundled-extensions) · [Business Models](#business-models) · [Migrating from Gnuboard 5](#migrating-from-gnuboard-5) · [Documentation](#documentation) · [Contributing](#contributing) · [Team](#team) · [Community](#community) · [Changelog](#changelog) · [License](#license)
---
## 그누보드7 소개
## About Gnuboard7
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
**Gnuboard7** is a complete, ground-up redesign of Gnuboard — Korea's most widely used open-source CMS for 23 years — rebuilt on a modern stack.
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
Everything from security to architecture was rewritten from scratch on Laravel and React.
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
- **JSON layout engine**: define React-based UI declaratively with JSON alone, no React knowledge required. Modules and plugins inject or extend UI dynamically through JSON without a frontend build. When you need something more advanced, you can develop and register custom React components
- **One platform, many business models**: community, storefront, subscription, booking — extend it to fit your business
- **Fine-grained access control**: Role + Permission + Scope, a three-tier model that keeps control as your service grows
- **Global-ready**: native i18n support, locale-driven UI, multi-currency handling
- **Extension system**: modules + plugins + templates, a three-layer structure that adds functionality without touching the core
---
## 주요 기능
## Key Features
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
Everything a modern web platform needs, built in.
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
| Area | Description |
|------|-------------|
| **Modular architecture** | Modules + plugins + templates, a three-layer extension structure. Independent modules (boards, commerce, and more) can be developed without modifying the core. Hook-based injection preserves the clear layering of the Service-Repository pattern |
| **Language pack system** | Install a new language from a ZIP file or GitHub URL without touching the core. Official bundled language packs (Japanese and others) are ready to use immediately, and labels an operator has edited are preserved per sub-key so a language pack never overwrites them. Packs apply independently to modules, plugins, and templates |
| **Localization** | A consistent multilingual development experience from backend to frontend. Active language packs automatically enrich notification channel labels, provider/registry payloads, and settings catalogs (payment methods, currencies, shippable countries). Activity log and message surfaces are separated so modules and plugins describe their own domain labels in their own territory |
| **Payment gateways** | A foundation for growing beyond a local business into global commerce. Payment integrations attach through the same extension-point pattern, and international gateways ship as separate plugins |
| **Access control** | Control menus, features, and even data scope per role. Role + Permission + Scope three-tier access control provides flexible management that mirrors your organization |
| **Identity verification (IDV)** | Every verification point — signup, password reset, sensitive operations — is managed centrally through declarative route/hook-level policies. The core ships a mail provider built in, and external providers (Korean identity-verification services such as KG Inicis and NHN KCP, as well as SMS, PortOne, and Stripe Identity-style services) attach through the same provider contract. When the server returns HTTP 428, the frontend interceptor opens a verification modal automatically and replays the original request on success |
| **Security** | Automatic input validation and token-based authentication. Layered defense designed in from the start (CSRF/XSS/SQL injection), a real implementation of login throttling and account lockout (HTTP 423), and automatic blocking of installer endpoints once setup completes (HTTP 410) |
| **Flexible screen composition** | Define a screen structure and see it applied immediately. Web-app-grade dynamic screens are achievable with JSON declarations alone, no frontend infrastructure required |
| **Layout editor** | A WYSIWYG layout editor lets you place screen blocks directly and see the result right away |
| **Proven foundation** | Built on Laravel + React — a stack adopted by companies worldwide, offering high extensibility and flexible UI implementation |
| **Shared cache system** | `CacheInterface` plus three drivers (core/module/plugin) isolate key prefixes automatically (`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`). Tag-based automatic invalidation and central TTL management via `g7_core_settings('cache.*_ttl')` keep operations free of hardcoded values |
| **Notification system** | A three-tier model — Definition × Template × Recipients — supports independent multi-channel delivery over mail, database, and real-time broadcast (Reverb). Targeting by author, role, specific users, or permission holders, plus hook-based dispatch, lets modules register their own notifications freely |
| **SEO** | Powered by `jaybizzle/crawler-detect`, roughly 1,000 bot types (search engines, social unfurlers, AI search) are detected automatically: bots receive static HTML while regular users get the SPA. OG/Twitter card metadata, domain schemas declared by modules (Article/Product/Offer/AggregateRating), automatic and manual sitemap generation, and generator meta tags are all provided by the core |
| **Activity log** | Administrator and user activity is recorded and searchable automatically. The Monolog-based structure is easy to extend, and action labels resolve from a module's or plugin's own translation files first, so each domain describes itself |
| **Search** | Full-text search powered by Laravel Scout, covering key content such as products and posts |
---
## 기술 스택
## Tech Stack
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
| **코드 품질** | Laravel Pint (PSR-12) |
| Layer | Technology |
|-------|------------|
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **Frontend** | React 19, Vite, Tailwind CSS 4 (dark mode supported) |
| **Authentication** | Laravel Sanctum (Bearer tokens) |
| **Testing** | PHPUnit 11.x, Vitest |
| **Code quality** | Laravel Pint (PSR-12) |
---
## 아키텍처
## Architecture
```
Gnuboard7
@@ -86,66 +88,66 @@ Gnuboard7
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Language Pack (virtual protected rows + ZIP/GitHub install + sub-key preservation)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│ ├── Modules — board, commerce, page ...
│ ├── Plugins — payment, verification, marketing ...
│ ├── Templates — admin UI, user UI
│ └── LanguagePacks — official and third-party packs (Japanese and more)
│
└── Template Engine
├── JSON Layout → React Components
└── Dynamic Rendering + Data Binding
```
### 템플릿 엔진 동작 흐름
### How the template engine works
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
In Gnuboard7 you **declare the UI structure in JSON**, and the engine interprets it and renders React components.
#### 제공 기능
#### What it gives you
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
- Build React-based UI from JSON declarations alone — screen development without React expertise
- Modules and plugins inject or extend UI dynamically through JSON, with no frontend build
- Develop and register custom React components when a screen needs something more advanced
- Because the UI is defined as data (JSON) rather than code, a **WYSIWYG layout editor** lets non-developers place and edit screen blocks and see the result immediately
```mermaid
flowchart TB
subgraph Backend ["🔧 Backend — Laravel"]
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
B --> |"상속 해석<br/>extends / partial"| B
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
A["📄 JSON layout file"] --> B["⚙️ LayoutService"]
B --> |"inheritance<br/>extends / partial"| B
M["📦 Module layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
P["🔌 Plugin layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
B --> C["🔒 Permission filtering<br/>drop components per user"]
C --> D["📨 Merged JSON response<br/>cached · 1 hour TTL"]
end
subgraph Frontend ["⚛️ Frontend — React"]
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
D --> E["📥 LayoutLoader<br/>receive layout JSON"]
E --> F["💾 State init<br/>_global · _local · _computed"]
E --> G["🌐 Data source loading<br/>parallel API calls"]
F & G --> H["🎨 DynamicRenderer"]
H --> I{"❓ 조건 평가<br/>if 표현식"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
H --> I{"❓ Condition eval<br/>if expression"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React component"]
I --> |"❌ false"| K["⏭️ Skip rendering"]
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
N --> O["✨ React 렌더링"]
J --> L["🔗 Data binding<br/>expression → value"]
L --> N["🖱️ Event binding<br/>onClick → ActionDispatcher"]
N --> O["✨ React render"]
end
subgraph Actions ["👆 사용자 인터랙션"]
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — 페이지 이동"]
Q --> S["📡 apiCall — API 호출"]
Q --> T["🔄 setState — 상태 변경"]
Q --> U["📋 openModal — 모달 열기"]
subgraph Actions ["👆 User interaction"]
O --> |"click · input"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — page transition"]
Q --> S["📡 apiCall — API request"]
Q --> T["🔄 setState — state change"]
Q --> U["📋 openModal — open a modal"]
S --> |"onSuccess · onError"| Q
T --> |"상태 변경 → 리렌더링"| H
T --> |"state change → re-render"| H
end
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
@@ -179,7 +181,7 @@ flowchart TB
linkStyle default stroke:#374151,stroke-width:2px
```
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
**JSON layout example** — the JSON below renders as a real React UI:
```json
{
@@ -211,29 +213,29 @@ flowchart TB
}
```
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
Activating a module or plugin injects its UI and components automatically.
Developers add or change UI with JSON alone — no separate frontend build — and UI elements are shown or hidden automatically according to permissions (abilities).
### 핵심 시스템
### Core systems
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
Four systems work together to hold the platform up.
#### 1. 확장 시스템 — 3원칙
#### 1. Extension system — three principles
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
1. **Minimal core modification** — all business logic lives in modules and plugins
2. **Dynamic loading** — discovered automatically by directory scan, with no `composer.json` hardcoding
3. **Hook-based extension** — functionality is injected at the service layer through action and filter hooks
#### 2. 훅 시스템 (Action / Filter)
#### 2. Hook system (Action / Filter)
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
A lightweight hook system that operates separately from Laravel events. Actions handle side effects (logging, notifications); filters transform values (injecting defaults, extending permissions).
```php
// Service 계층에서 훅 발행
// Publish hooks from the service layer
HookManager::doAction('core.user.after_create', $user, $data);
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
// 모듈 Listener 가 훅 구독 (자동 발견)
// A module listener subscribes to the hook (auto-discovered)
public static function getSubscribedHooks(): array
{
return [
@@ -242,258 +244,275 @@ public static function getSubscribedHooks(): array
}
```
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
Modules and plugins only need to place a class in their `Listeners/` directory and `HookListenerRegistrar` subscribes it automatically. Asynchronous execution through queue serialization is supported as well, and context such as `Auth::user()`, `request()->ip()`, and `App::getLocale()` is restored automatically inside the worker.
#### 3. 공통 캐시 시스템
#### 3. Shared cache system
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
Built on `CacheInterface`, the **core, modules, and plugins** each manage their own cache without key collisions.
| 드라이버 | 접두사 | 용도 |
| Driver | Prefix | Purpose |
| --- | --- | --- |
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
| `CoreCacheDriver` | `g7:core:{key}` | Core services (layouts, SEO, notifications, settings) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | Per-module isolated cache (board and product lists, cooldowns) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | Per-plugin isolated cache |
```php
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
// Register a module service in the BaseModuleServiceProvider::$cacheServices array
// and it is injected from the constructor type hint alone (same as the storage pattern)
public function __construct(
private BoardRepositoryInterface $repository,
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
private CacheInterface $cache, // ← g7:module.sirsoft-board: prefix applied automatically
) {}
```
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
- **Central TTL management** — every cache TTL follows `g7_core_settings('cache.*_ttl')`. No hardcoding
- **Automatic invalidation** — apply the `CacheInvalidatable` trait to a model and related caches are dropped by tag on `saved` / `deleted`
- **Lifecycle integration** — when a module is deactivated or removed, `ModuleManager` flushes that module's isolated cache in bulk
- **Frontend cache busting** — incrementing `ext.cache_version` propagates through the response `config.json` and invalidates browser caches via a `?v=` query parameter
#### 4. 알림 시스템
#### 4. Notification system
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
Multi-channel notifications are managed through a three-tier model: **Definition × Template × Recipients**.
```text
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
│ variables=[...] │ │ subject, body, │ │ - role │
│ │ │ click_url │ │ - specific_users │
│ tion ├──────┤ (independent per ├──────┤ - trigger_user │
│ type=order.created │ │ channel) │ │ - related_user │
│ variables=[...] │ │ channel=mail|db|... │ │ - role │
│ │ │ subject, body, │ │ - specific_users │
│ │ │ click_url │ │ │
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
```
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
- **Definition** — declares the notification type, supported channels, and variable metadata
- **Template** — an independent subject, body, and click URL per channel (`mail` / `database` / `broadcast`). Administrators can customize them in multiple languages
- **Recipients** — recipient rules declared as JSON per template. Because they are per-template, you can branch freely: "mail to the buyer, database notification to role holders"
```php
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
// A module service only publishes a hook; the delivery pipeline runs automatically
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
// ↓ NotificationHookListener → NotificationDispatcher:
// 1. order.confirmed 정의 조회
// 2. 활성 템플릿 순회 (mail/database)
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
// 5. notification_logs 에 발송 이력 기록
// 1. Look up the order.confirmed definition
// 2. Iterate active templates (mail/database)
// 3. Resolve each template's recipients JSON → recipient collection
// 4. Deliver per channel to each recipient (GenericNotification)
// 5. Record the delivery in notification_logs
```
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
- Three core notifications: `welcome`, `reset_password`, `password_changed`
- Seven e-commerce module notifications: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- Real-time broadcasting runs on Laravel Reverb (WebSocket). Where Reverb is not configured, it skips gracefully without errors
- A single `GenericNotification` class handles every notification — adding a new notification type requires no new notification class
#### 5. 언어팩 시스템
#### 5. Language pack system
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
An operations tool for adding a new language without touching the core, with the same lifecycle as module, plugin, and template management (install → activate → update → remove, with automatic backup and rollback).
| 영역 | 동작 |
| Area | Behavior |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
| Install paths | ZIP upload / GitHub URL / the `lang-packs/_bundled` bundled directory (synchronized in bulk on core updates) |
| Scope | Applied separately to the core, modules, plugins, and templates — a module pack activates only while the matching core pack is active |
| Preserving operator edits | Multilingual JSON columns record user overrides per sub-key (`name.ko` / `name.ja`), so editing one language's label preserves only that language while new languages sync automatically |
| Activation effects | On activation or deactivation, the entity seeders of affected modules and plugins re-run, so menus, permissions, roles, manifests, and notification labels reach the database immediately |
| Virtual protected rows | Korean and English are built into the core and bundled extensions and are always exposed as active and protected (editing and removal are blocked) |
| Security | Installation is blocked if a pack contains executable PHP beyond language translations |
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
Sixteen official Japanese (ja) bundled packs — the core plus the main modules, plugins, and templates — are ready to use, and step 4 of the installer links language pack cards to your module, plugin, and template selection so they can be installed together.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
> Details: [docs/extension/language-packs.md](docs/extension/language-packs.md) (Korean)
#### 6. 본인인증 (Identity Verification)
#### 6. Identity verification
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
Every verification point — signup, password reset, sensitive operations, the moment before payment — is managed centrally through declarative route/hook-level policies.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
│ (enforcement point │ │ (verification goal, │ │ (mail, KCP, Inicis, │
│ failure mode, │ ◀▶ │ allowed channels, │ ◀▶ │ SMS, external IDV) │
│ step, conditions) │ │ source tracking) │ │ │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
└──────────▶ Message Template (policy × purpose) ◀──┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
- **Policy as the single source of truth** — toggling a policy takes effect immediately without editing route code. Every API route matches against the policy database automatically
- **428 interceptor** — when the server returns HTTP 428, the frontend opens a verification modal automatically and replays the original request once verification succeeds
- **Declarative registration** — modules and plugins declare `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()`, and those are registered automatically on activation or update while preserving operator edits
- **Message templates** — define multilingual subjects and bodies per provider and per purpose/policy, falling back in the order policy → purpose → provider default
- **External provider slots** — plugins can inject their own SDK UI for services such as KCP, PortOne, Toss verification, or Stripe Identity through the standard G7 extension-point pattern
- **History management** — the admin screen offers tabs per verification method, unified search, multi-filters for status/purpose/channel/IP, and bulk destruction by retention period (180 days)
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
> Details: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md) (Korean)
---
## 빠른 시작
## Quick Start
### 시스템 요구사항
### System requirements
- PHP 8.2+ (필수 확장 30개 포함)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Node.js 20+ (빌드 시에만 필요)
- PHP 8.2+ with the required extensions (16 in total), including `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`. Additional extensions (`gd`/`imagick`, `intl`, `redis`, `bcmath`, and others) are optional and only needed for the features that use them — see [docs/requirements.md](docs/requirements.md)
- MySQL 8.0+ or MariaDB 10.3+ (utf8mb4)
- Composer 2.x
- Node.js 20+ (only needed when building frontend assets)
- A web server (Apache or Nginx) with the document root pointed at `public/`
- Redis 6.0+ (optional — recommended for cache and queue in production)
### 설치
### Installation
```bash
# 1. 프로젝트 클론
# 1. Clone the project
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. 환경 설정 파일 복사
# 2. (Optional) Install PHP dependencies — you can skip this step:
# the setup wizard installs them automatically with the production
# configuration when vendor/ is absent. Do not use a plain composer
# install on a production site; it pulls in development packages.
composer install --no-dev --optimize-autoloader
# 3. Copy the environment file
cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
# 4. Point your web server's document root at the public/ directory,
# then open /install in a browser and follow the setup wizard
```
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
The setup wizard creates the application key, configures the database connection, runs the migrations, and lets you choose which modules, plugins, templates, and language packs to install. Once setup completes, the installer endpoints are blocked automatically (HTTP 410).
> Detailed installation guide (Korean): [INSTALL.md](INSTALL.md)
---
## 기본 제공 확장
## Bundled Extensions
### 모듈
### Modules
| 모듈 | 설명 |
|------|------|
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
| Module | Description |
|--------|-------------|
| **sirsoft-board** | Boards — multiple boards, comments, file attachments |
| **sirsoft-ecommerce** | Storefront — products, orders, payments, shipping, coupons, product inquiries |
| **sirsoft-page** | Pages — static content management |
### 플러그인
### Plugins
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
| Plugin | Description |
|--------|-------------|
| **sirsoft-pay_kginicis** | KG Inicis payment integration (Korean payment gateway) |
| **sirsoft-pay_nicepayments** | NICE Payments integration (Korean payment gateway, unified checkout) |
| **sirsoft-pay_nhnkcp** | NHN KCP payment integration (Korean payment gateway, Standard Pay) |
| **sirsoft-tosspayments** | Toss Payments integration (Korean payment gateway) |
| **sirsoft-verification_kginicis** | KG Inicis identity verification (Korean identity verification) |
| **sirsoft-verification_nhnkcp** | NHN KCP mobile identity verification (Korean identity verification) |
| **sirsoft-daum_postcode** | Daum postcode lookup (Korean address search) |
| **sirsoft-marketing** | Marketing tools |
| **sirsoft-ckeditor5** | CKEditor 5 editor |
| **sirsoft-gdpr** | Privacy and GDPR support |
| **sirsoft-message_bizppurio** | Bizppurio messaging (SMS/LMS and KakaoTalk alimtalk delivery) |
### 템플릿
### Templates
| 템플릿 | 설명 |
|--------|------|
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
| Template | Description |
|----------|-------------|
| **sirsoft-admin_basic** | Default admin template |
| **sirsoft-basic** | Default user template |
### 번들 언어팩
### Bundled language packs
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
Official language packs you can install alongside the initial setup, so the core and the main modules, plugins, and templates share one consistent translation from the start.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
| Identifier | Description |
| ---------- | ----------- |
| **g7-core-ja** | Core, Japanese |
| **g7-module-sirsoft-board-ja** | Board module, Japanese |
| **g7-module-sirsoft-ecommerce-ja** | E-commerce module, Japanese |
| **g7-module-sirsoft-page-ja** | Page module, Japanese |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor 5 plugin, Japanese |
| **g7-plugin-sirsoft-daum_postcode-ja** | Daum postcode plugin, Japanese |
| **g7-plugin-sirsoft-gdpr-ja** | Privacy/GDPR plugin, Japanese |
| **g7-plugin-sirsoft-marketing-ja** | Marketing plugin, Japanese |
| **g7-plugin-sirsoft-message_bizppurio-ja** | Bizppurio messaging plugin, Japanese |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG Inicis payment plugin, Japanese |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | NICE Payments plugin, Japanese |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP payment plugin, Japanese |
| **g7-plugin-sirsoft-tosspayments-ja** | Toss Payments plugin, Japanese |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG Inicis identity verification plugin, Japanese |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP mobile identity verification plugin, Japanese |
| **g7-template-sirsoft-admin_basic-ja** | Default admin template, Japanese |
| **g7-template-sirsoft-basic-ja** | Default user template, Japanese |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
> Korean and English are built into the core and bundled extensions and are always active without installation. Any other language can be added freely from a ZIP file or a GitHub URL.
### 학습용 샘플 확장
### Sample extensions for learning
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
Minimal implementations for learning the extension system. They appear in the admin UI when the "include hidden" toggle is on, and are always visible from the CLI.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
| Identifier | Type | Description |
| ---------- | ---- | ----------- |
| **gnuboard7-hello_module** | Module | Memo CRUD plus a hook publishing demo |
| **gnuboard7-hello_plugin** | Plugin | Action/filter hook subscription demo |
| **gnuboard7-hello_admin_template** | Admin template | A minimal set of basic components |
| **gnuboard7-hello_user_template** | User template | Home page plus a memo list integration |
---
## 비즈니스 모델
## Business Models
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
One Gnuboard7 installation can run a range of businesses.
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
| Model | Description | Status |
|-------|-------------|--------|
| **Community** | Boards, comments, member management | Stable |
| **Commerce** | Product registration, orders, payments, shipping management | Stable |
---
## 기존 그누보드 사용자
## Migrating from Gnuboard 5
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
A migration tool from Gnuboard 5 is planned.
---
## 문서
## Documentation
| 문서 | 링크 |
|------|------|
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
| 전체 문서 | [docs/README.md](docs/README.md) |
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
> Documentation is currently available in Korean only. English documentation is planned.
| Document | Link |
|----------|------|
| Installation guide | [INSTALL.md](INSTALL.md) |
| Full documentation | [docs/README.md](docs/README.md) |
| System requirements | [docs/requirements.md](docs/requirements.md) |
| Backend development | [docs/backend/README.md](docs/backend/README.md) |
| Frontend development | [docs/frontend/README.md](docs/frontend/README.md) |
| Database | [docs/database-guide.md](docs/database-guide.md) |
| Extension system | [docs/extension/README.md](docs/extension/README.md) |
| Module development | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| Plugin development | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| Template development | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| Testing | [docs/testing-guide.md](docs/testing-guide.md) |
| API reference | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API documentation policy | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
## 기여하기
## Contributing
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
Gnuboard7 is an open-source project, and contributions of every kind are welcome.
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- 코드 스타일: Laravel Pint (PSR-12)
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
- Bug reports and feature proposals: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- Code style: Laravel Pint (PSR-12)
- Testing: PHPUnit (backend) + Vitest (frontend)
- AI collaboration: the repository ships a development rule specification for AI agents (AGENTS.md) along with MCP debugging tools, so AI tooling fits naturally into the workflow
---
## 만든 사람들
## Team
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
Developed by **[SIRSOFT](https://sir.kr)**.
### Core Team
@@ -503,37 +522,65 @@ cp .env.example .env
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### Contributors
### Community Contributors
커뮤니티 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
Thanks to everyone who reported an issue or suggested a feature that shipped — the list below is compiled from the attributions in our changelogs.
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/keidichoi-gif" title="keidichoi-gif"><img src="https://github.com/keidichoi-gif.png" width="48" alt="keidichoi-gif"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
</p>
<!-- community-contributors:end -->
The list of code contributors is available on [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors).
---
## 커뮤니티
## Community
| 채널 | 링크 |
|------|------|
| Channel | Link |
|---------|------|
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
| 문의 | minsup@sir.kr |
| SIR community (Korean) | [sir.kr](https://sir.kr) |
| Contact | minsup@sir.kr |
---
## 변경 기록
## Changelog
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
For details on recent changes, see the [CHANGELOG](CHANGELOG.md) (Korean).
---
## 보안 취약점
## Security Vulnerabilities
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
If you discover a security vulnerability, please report it as a private post on the [SIR inquiry board](https://sir.kr/boards/co_qa) (Korean board), or email minsup@sir.kr.
---
## 라이선스
## License
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
Gnuboard7 is open-source software distributed under the [MIT License](LICENSE).
Copyright (c) 2026 SIRSOFT
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
/**
* 오래된 부트스트랩 리소스 정적 게시 디렉토리를 정리하는 커맨드
*
* 게시 디렉토리(`public/build/ext/{version}/`)는 캐시 스토어 밖 파일시스템이라
* version bump 가 구버전 디렉토리를 지우지 않는다. 게시 성공 직후 인라인 GC 가
* 돌지만, 게시가 오래 없거나 실패한 환경의 잔존물을 이 커맨드가 회수한다.
* (`CleanupExtensionBundlesCommand` 파일 산출물 GC 패턴 미러, #122)
*/
class CleanupExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:cleanup';
/**
* The console command description.
*/
protected $description = '오래된 부트스트랩 리소스 정적 게시 디렉토리(구 version)를 삭제합니다';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionStaticCacheService $service): int
{
$deleted = $service->cleanup();
$this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
return self::SUCCESS;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 빌드 산출물 정리 Trait
*
* 확장이 구동에 필요한 제3자 자산을 `dist/vendor/` 에 동봉하면서, vite 의
* `emptyOutDir: true` 를 그대로 둘 수 없게 됐다 — 매 빌드마다 동봉 자산이 삭제된다.
* 각 확장의 vite config 를 `emptyOutDir: false` 로 바꾸는 대신, **정리 책임을 빌드
* 커맨드로 옮긴다.** 확장마다 정리 규칙을 복제하면 한 곳만 빠져도 그 확장에서만
* 해시 청크가 누적되고, 그것은 배포본이 커진 뒤에야 드러난다.
*
* 정리 범위는 종전 `emptyOutDir: true` 와 같다 — `dist/` 전체를 비우되 **보존 대상만
* 남긴다.** "빌드가 만드는 것만 골라 지운다" 로 좁히면 소스에서 사라진 파일의 산출물이
* `dist/` 에 stale 로 남는다.
*/
trait PrunesBuildOutput
{
/**
* 빌드 산출물 디렉토리에서 보존 대상을 제외한 전부를 삭제합니다.
*
* 감시(watch) 모드에서는 호출하지 않는다 — 개발 중 재빌드마다 지우면 브라우저가
* 참조 중인 파일이 사라진다.
*
* **활성 디렉토리는 정리하지 않는다.** prune 은 빌드 *전에* 실행되므로, 웹이 서빙
* 중인 `dist/` 를 비우면 prune~빌드 완료 구간 전체가 서빙 공백이 된다(빌드가 실패하면
* 빈 채로 남는다). 확장 개발은 `_bundled` 에서 수행하고 활성 반영은 `{type}:update`
* 가 담당하므로, 활성 경로 빌드는 예외적 경로다 — 그 경우 stale 산출물이 누적되는
* 것을 감수하고 서빙 연속성을 택한다. 외부 확장처럼 `_bundled` 가 없어 활성 빌드가
* 유일한 경로인 경우에도 사이트가 끊기지 않는다.
*
* @param string $buildPath 확장 루트 경로 (`dist/` 의 부모)
* @param array<int, string> $preserve 삭제하지 않을 최상위 항목명
* @return array<int, string> 삭제한 최상위 항목명 목록
*/
private function pruneBuildOutput(string $buildPath, array $preserve = ['vendor']): array
{
$distPath = rtrim($buildPath, '/\\').DIRECTORY_SEPARATOR.'dist';
if (! File::isDirectory($distPath)) {
return [];
}
if (! $this->isBundledSourcePath($buildPath)) {
$this->warn(
' ⚠️ 활성 디렉토리 빌드 — 이전 산출물을 정리하지 않습니다 '
.'(정리하면 빌드 완료까지 서빙이 끊깁니다). stale 산출물이 누적될 수 있으니 '
.'개발은 _bundled 에서 하고 활성 반영은 update 커맨드로 하세요.'
);
return [];
}
$removed = [];
foreach (new \FilesystemIterator($distPath, \FilesystemIterator::SKIP_DOTS) as $item) {
$name = $item->getBasename();
if (in_array($name, $preserve, true)) {
continue;
}
$deleted = $item->isDir()
? File::deleteDirectory($item->getPathname())
: File::delete($item->getPathname());
if ($deleted) {
$removed[] = $name;
continue;
}
// 삭제 실패는 빌드를 막을 이유가 못 된다 — vite 가 같은 경로를 덮어쓴다.
// 다만 조용히 넘기면 stale 산출물이 남은 것을 알 수 없으므로 기록한다.
Log::warning('빌드 산출물 정리 실패 (다음 빌드에서 재시도)', [
'path' => $item->getPathname(),
]);
}
return $removed;
}
/**
* 빌드 경로가 소스 디렉토리(`_bundled` / `_pending`)인지 판정합니다.
*
* 이 둘은 웹이 서빙하지 않는 소스 보관소라 비워도 서빙 공백이 없다. 그 밖의 경로는
* 활성 디렉토리(= 서빙 중)로 본다 — 판정을 뒤집어 두면(활성 목록을 열거하면) 새로운
* 배치가 생길 때마다 조용히 활성 경로가 정리 대상이 된다.
*
* @param string $buildPath 확장 루트 경로
* @return bool 소스 디렉토리 여부
*/
private function isBundledSourcePath(string $buildPath): bool
{
$normalized = str_replace('\\', '/', $buildPath);
return str_contains($normalized, '/_bundled/') || str_contains($normalized, '/_pending/');
}
}
+57 -3
View File
@@ -88,7 +88,7 @@ class BuildCoreCommand extends Command
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
if ($watchMode) {
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
return $this->runWatchBundles($projectPath);
@@ -125,13 +125,63 @@ class BuildCoreCommand extends Command
return $devtoolsResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
// ── 4) 개발 대시보드 CSS (자체 제공 — 종전 Tailwind Play CDN 대체) ──
$this->info('🔨 코어 빌드 시작 (개발 대시보드 CSS)'.($productionMode ? ' (프로덕션)' : ''));
$dashboardResult = $this->runNpmCommand(['npm', 'run', 'build:core-devdashboard'], $projectPath, true, $buildEnv);
if ($dashboardResult !== Command::SUCCESS) {
$this->error('❌ 개발 대시보드 CSS 빌드 실패');
return $dashboardResult;
}
$this->pruneStaleSourceMaps($productionMode);
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools + 개발 대시보드 CSS)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
return Command::SUCCESS;
}
/**
* 프로덕션 빌드 후 `public/build/core/` 에 남은 소스맵을 제거합니다.
*
* 프로덕션 빌드는 `G7_BUILD_SOURCEMAP=0` 으로 맵을 **만들지 않을 뿐**, 이전 개발 빌드가
* 남긴 맵을 지우지는 않는다. 그 디렉토리는 웹루트라 남아 있는 맵은 웹서버가 그대로
* 서빙하고, 맵에는 원본 코드 전문(`sourcesContent`)이 담긴다 — 확장자 화이트리스트가
* 막아 주는 확장 에셋과 달리 이 경로는 정적 서빙이라 통과한다.
*
* 종전에는 루트 `npm run build` 의 `emptyOutDir` 이 디렉토리를 통째로 비우면서 이 맵들을
* 함께 지웠다. 그 동작은 서빙 중인 코어 번들·게시본까지 지우는 결함이라 껐으므로(#122),
* 소스맵 정리 책임을 빌드 커맨드가 명시적으로 넘겨받는다.
*
* @param bool $productionMode 프로덕션 빌드 여부
*/
private function pruneStaleSourceMaps(bool $productionMode): void
{
if (! $productionMode) {
// 로컬 빌드는 디버깅을 위해 맵을 의도적으로 생성한다 — 지우면 그 목적이 사라진다.
return;
}
$removed = [];
foreach (glob(public_path('build/core').DIRECTORY_SEPARATOR.'*.map') ?: [] as $map) {
if (@unlink($map)) {
$removed[] = basename($map);
continue;
}
$this->warn(' ⚠️ 소스맵 삭제 실패 (수동 제거 필요): '.$map);
}
if ($removed !== []) {
$this->line(' 🧹 잔존 소스맵 제거: '.implode(', ', $removed));
}
}
/**
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
*
@@ -140,11 +190,14 @@ class BuildCoreCommand extends Command
*/
private function runWatchBundles(string $projectPath): int
{
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
// 엔진 + 편집기 + DevTools + 개발 대시보드 CSS 를 각각 vite --watch 로 병렬 감시.
// 1회 빌드가 굽는 산출물과 같은 집합이어야 한다 — 한쪽만 빠지면 감시 모드에서
// 그 산출물만 조용히 stale 해진다.
$bundles = [
'engine' => ['npm', 'run', 'build:core-watch'],
'editor' => ['npm', 'run', 'build:core-editor-watch'],
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
'dashboard' => ['npm', 'run', 'build:core-devdashboard-watch'],
];
/** @var array<string, Process> $processes */
@@ -209,6 +262,7 @@ class BuildCoreCommand extends Command
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->pruneStaleSourceMaps($productionMode);
$this->info('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
@@ -330,6 +330,10 @@ trait BundledExtensionUpdatePrompt
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
$env = array_merge(getenv(), $_ENV);
// audit:allow spawn-after-vendor-swap-clears-package-manifest 이 spawn 은 vendor 를 바꾸지 않고,
// 두 호출처가 모두 clearAllCaches() 뒤다 — CoreUpdateCommand Step 11(패키지 매니페스트 재생성)
// 다음의 Step 12, ExecuteUpgradeStepsCommand 단독 실행의 캐시 정리 다음 단계. 따라서 자식이
// 읽는 매니페스트는 이미 현재 vendor 기준으로 다시 만들어진 것이다.
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return null;
+75 -33
View File
@@ -16,7 +16,9 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ComposerInstallInfo;
use App\Support\ConfigCacheHelper;
use App\Support\PackageManifestCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -301,12 +303,16 @@ class CoreUpdateCommand extends Command
// Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
// 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
// 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
//
// 이번 실행이 방금 만든 격리 디렉토리(core_{ts})는 제외한다 — 스냅샷은 그 디렉토리가
// 생긴 뒤에 찍히므로, 제외하지 않으면 sudo 가 root 로 만든 추출본이 "원본" 으로
// 기록되고 복원이 잔존물을 다시 root 로 되돌린다(7.0.0~7.0.9 실사례).
$detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
'storage/logs',
'storage/framework',
'storage/app/core_pending',
'bootstrap/cache',
]);
], excludes: [$service->resolveStagingRoot($pendingPath)]);
if (! empty($detailedOwnershipSnapshot)) {
$log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
}
@@ -316,6 +322,22 @@ class CoreUpdateCommand extends Command
$composerSkipped = $vendorMode !== VendorMode::Bundled
&& $service->isComposerUnchangedForCore($pendingPath);
// 운영 vendor 에 개발용(require-dev) 패키지가 섞여 있는지 알린다. 그대로 두면
// 이전 설치본이 만든 패키지 매니페스트가 새 vendor 와 어긋나 이후 부팅이 깨지고,
// 그 사실은 오류 메시지 어디에도 "dev 설치본" 으로 드러나지 않는다.
$devPackages = ComposerInstallInfo::devPackageNames(base_path('vendor'));
if ($devPackages !== []) {
$devCount = count($devPackages);
$devSample = implode(', ', array_slice($devPackages, 0, 5)).($devCount > 5 ? ' …' : '');
if ($composerSkipped) {
$this->warn("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — composer.json/lock 변경이 없어 vendor 를 재설치하지 않으므로 그대로 남습니다. 업데이트 후 'composer install --no-dev --optimize-autoloader' 실행을 권장합니다.");
$log("운영 vendor 개발용 패키지 {$devCount}개 잔존 (composer 스킵): {$devSample}");
} else {
$this->line("운영 vendor 에 개발용 패키지 {$devCount}개 감지 — 이번 업데이트가 --no-dev vendor 로 교체합니다.");
$log("운영 vendor 개발용 패키지 {$devCount}개 감지 — --no-dev vendor 로 교체: {$devSample}");
}
}
if ($composerSkipped) {
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
$bar->advance();
@@ -507,6 +529,11 @@ class CoreUpdateCommand extends Command
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시가 새 코드 기준으로
// 정리된 직후 재시작 신호를 보낸다.
$service->signalQueueRestart();
$log('큐 워커 재시작 신호 전송 (queue:restart)');
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
@@ -593,6 +620,10 @@ class CoreUpdateCommand extends Command
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
@@ -628,6 +659,9 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 핸드오프로 멈춰도 파일은 이미 toVersion 이므로 워커는 새 코드로 재기동해야 한다.
$service->signalQueueRestart();
$log('큐 워커 재시작 신호 전송 (queue:restart)');
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
@@ -677,6 +711,10 @@ class CoreUpdateCommand extends Command
}
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS;
@@ -772,6 +810,10 @@ class CoreUpdateCommand extends Command
}
$this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::FAILURE;
}
@@ -862,6 +904,33 @@ class CoreUpdateCommand extends Command
'G7_UPDATE_IN_PROGRESS' => '1',
]);
// spawn 직전 config 캐시 제거. 자식은 새 프로세스라 `bootstrap/cache/config.php` 가 있으면
// 그 캐시로 부팅하는데, 그 캐시는 이전 버전 설치본이 만든 것이다(설치 마법사·설정 저장·
// 확장 업데이트). 캐시 부팅에서는 `.env` 도 읽지 않고 위 `$env` 의 APP_VERSION 오버라이드도
// config 에 반영되지 않으며, 신버전 `config/app.php` 가 추가한 update 목록(쓰기 권한
// 디렉토리 등)도 자식에게 보이지 않는다 (7.0.9→7.0.10 실사례: stale 가드 오판 +
// `public/build/ext` 권한 정상화 누락). 부모의 메모리 config 는 영향받지 않고, 캐시는
// Step 11 의 `ConfigCacheHelper::rebuild()` 가 모든 파일이 안착한 뒤 다시 만든다.
ConfigCacheHelper::clear();
// 같은 이유로 패키지 매니페스트(bootstrap/cache/packages.php · services.php)도 비운다.
// vendor 는 Step 6/8 에서 이미 교체됐지만 두 파일은 Step 11 clearAllCaches 까지 이전
// 설치본의 것이 남고, Laravel 은 packages.php 가 "없을 때만" 다시 만든다. 이전 설치본이
// dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면
// 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례).
// 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다.
//
// 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은
// 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이
// 권한이 원인이라는 유일한 흔적이다.
$remainingManifests = PackageManifestCacheHelper::clear();
if ($remainingManifests !== []) {
$manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): '
.implode(', ', $remainingManifests);
$log($manifestWarning);
$this->warn($manifestWarning);
}
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return $this->failSpawnWithMode(
@@ -1359,44 +1428,17 @@ class CoreUpdateCommand extends Command
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
* 쓰기 영역 소유자로 추정한다.
* 판정은 `FilePermissionHelper::describeWebServerAccount()` 가 소유한다 — 정적 게시 상태·게시
* 명령(`ext-static:*`)의 root 경고와 같은 4분기를 공유하기 위해 옮겼다(#651 C1). 이 메서드는
* 그 결과를 종전 반환 형태(`[모드, 계정명]`)로 바꿔 주는 위임만 한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['non_root', null];
}
$account = FilePermissionHelper::describeWebServerAccount();
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
if (posix_geteuid() !== 0) {
return ['non_root', null];
}
[$owner] = FilePermissionHelper::inferWebServerOwnership();
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
if ($owner === false) {
return ['root_web_unknown', null];
}
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
if ($owner === 0) {
return ['root_web_symmetric', null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['root_web_unknown', null];
}
return ['root_web_known', $name];
return [$account['mode'], $account['name']];
}
/**
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -183,6 +184,15 @@ class ExecuteBundledUpdatesCommand extends Command
}
}
// 이전 버전 부모가 남긴 빈 격리 디렉토리(core_{ts}/extracted 껍데기) 청소 —
// 부모의 정리 단계는 이 자식보다 먼저 끝나므로, 구버전 부모에서 올라오는
// 업데이트도 이 자리에서 껍데기 없이 마무리된다. 실패해도 업데이트 결과와 무관.
try {
app(CoreUpdateService::class)->sweepEmptyStagingDirectories();
} catch (\Throwable $e) {
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
}
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
@@ -111,7 +111,17 @@ class ExecuteUpgradeStepsCommand extends Command
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
if (! $stepsOnly) {
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
// 구버전 부모(7.0.9 이하)는 spawn 전에 config 캐시를 비우지 않아 이 자식이 이전 버전
// 캐시로 부팅할 수 있다. 그러면 `config()` 는 옛 목록이라 신버전이 추가한 디렉토리가
// 빠진다 — 캐시 부팅이면 디스크 config/app.php 를 직접 읽는다 (7.0.9→7.0.10 실사례).
// 판정은 캐시 파일의 실존으로 한다 — 부팅에 쓰였든 위 updateComposerAutoload() 가 방금
// 재생성했든, 파일이 있으면 메모리 config 를 신뢰하지 않는다 (디스크 판독은 멱등).
if (is_file($this->laravel->getCachedConfigPath())) {
Log::channel('upgrade')->warning('[spawn] 이전 버전 config 캐시로 부팅됨 — update 목록은 디스크 config/app.php 에서 읽는다');
$writablePaths = (array) $service->freshDiskUpdateConfig('restore_ownership_group_writable', []);
} else {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
}
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
@@ -206,6 +216,7 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->restoreUpgradeLogOwnership();
$service->normalizeRuntimeOwnershipAfterRootRun();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
@@ -217,6 +228,7 @@ class ExecuteUpgradeStepsCommand extends Command
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
$service->normalizeRuntimeOwnershipAfterRootRun();
return self::FAILURE;
}
@@ -249,6 +261,10 @@ class ExecuteUpgradeStepsCommand extends Command
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// 상주 큐 워커는 부팅이 한 번뿐이라 옛 코드를 물고 있다 — 캐시 정리 직후 재시작 신호.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모가 보낸다.
$service->signalQueueRestart();
$this->info('큐 워커 재시작 신호 전송 (queue:restart)');
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
@@ -278,10 +294,38 @@ class ExecuteUpgradeStepsCommand extends Command
}
$this->restoreUpgradeLogOwnership();
$this->sweepEmptyStagingDirectories($service);
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
$service->normalizeRuntimeOwnershipAfterRootRun();
return self::SUCCESS;
}
/**
* 이전 버전 부모가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)를 청소합니다.
*
* 부모의 정리 단계는 소스 경로 안쪽만 지우던 결함(7.0.0~7.0.9)이 있어 업데이트마다
* 껍데기가 남았고, sudo 실행이면 root 소유라 운영자·웹서버 계정이 지울 수 없었다.
* 부모는 구버전 클래스를 메모리에 들고 있어 고쳐도 다음 업데이트부터 효력이 있으므로,
* 신버전 코드로 도는 이 자식이 치운다. 파일이 있는 디렉토리(부모가 쓰는 중인 격리
* 디렉토리)는 술어상 건드리지 않는다. 실패는 업데이트 결과와 무관하므로 경고로 흡수한다.
*
* @param CoreUpdateService $service 코어 업데이트 서비스
*/
private function sweepEmptyStagingDirectories(CoreUpdateService $service): void
{
try {
$swept = $service->sweepEmptyStagingDirectories();
if ($swept > 0) {
$this->info("[spawn] 빈 격리 디렉토리 청소: {$swept}개");
}
} catch (\Throwable $e) {
Log::channel('upgrade')->warning('[spawn] 빈 격리 디렉토리 청소 실패', ['error' => $e->getMessage()]);
}
}
/**
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
*
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
*
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
*/
class ExpireIdentityChallengesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:expire-challenges';
/**
* The console command description.
*/
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
/**
* Execute the console command.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$expired = $service->expirePastDue();
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
return self::SUCCESS;
}
}
@@ -0,0 +1,453 @@
<?php
namespace App\Console\Commands\Extension;
use App\Support\ExtensionDoc\ExtensionDocContext;
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
use App\Support\ExtensionDoc\ExtensionInventory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
/**
* 확장 개발자 문서 생성 커맨드
*
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
*
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
*/
class ExtDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'ext:docgen
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
{--check : 생성하지 않고 누락·드리프트만 리포트}
{--json : 기계 판독 출력}
{--dry-run : 대상과 실측 집계만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
/**
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
*/
private array $malformed = [];
/**
* 커맨드를 실행합니다.
*
* @param ExtensionInventory $inventory 번들 확장 인벤토리
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return int 종료 코드
*/
public function handle(
ExtensionInventory $inventory,
ExtensionDocContext $context,
ExtensionDocScaffolder $scaffolder
): int {
$scope = (string) $this->option('scope');
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
// 함께 쓸 수 없다고 명시적으로 거부한다.
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
$conflict = match (true) {
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
default => null,
};
if ($conflict !== null) {
$message = $conflict;
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $message],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($message);
return self::FAILURE;
}
try {
$records = $inventory->collect($scope);
$this->malformed = $inventory->malformed();
} catch (InvalidArgumentException $e) {
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($e->getMessage());
return self::FAILURE;
}
if ($records === []) {
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
if ($this->option('json')) {
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return self::FAILURE;
}
$this->warn($message);
return self::FAILURE;
}
$results = [];
foreach ($records as $record) {
$ctx = $context->build($record);
$results[] = $this->processExtension($ctx, $scaffolder);
}
return $this->report($results, $scope);
}
/**
* 단일 확장을 처리합니다.
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return array<string, mixed> 처리 결과
*/
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
{
$record = $ctx['record'];
$stats = ExtensionDocScaffolder::statsOf($ctx);
$result = [
'type' => $record['type'],
'id' => $record['id'],
'relPath' => $record['relPath'],
'version' => $record['version'],
'stats' => $stats,
'surfaceAvailable' => $ctx['surface']['available'],
'surfaceReason' => $ctx['surface']['reason'],
'surfaceErrors' => $ctx['surface']['errors'],
'documents' => [],
'created' => [],
'updated' => [],
'missingDocuments' => [],
'missingSections' => [],
'missingBlocks' => [],
'driftedBlocks' => [],
'orphanBlocks' => [],
'unfilled' => [],
];
if ($this->option('dry-run')) {
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
return $result;
}
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
if ($this->option('init') && ! $this->option('check')) {
$this->initSkeletons($ctx, $scaffolder, $result);
}
$bodies = $scaffolder->renderBlocks($ctx);
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
if (! is_file($abs)) {
$result['missingDocuments'][] = $doc;
continue;
}
$content = (string) File::get($abs);
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
$result['missingSections'][] = $doc.' → '.$section;
}
}
// 미채움 마커 잔량
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
$count = substr_count($content, $marker);
if ($count > 0) {
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
}
}
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
if ($emptyIntents > 0) {
$result['unfilled'][] = [
'doc' => $doc,
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
'count' => $emptyIntents,
];
}
$docBodies = [];
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
if (array_key_exists($key, $bodies)) {
$docBodies[$key] = $bodies[$key];
}
}
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
foreach ($merged['missing'] as $key) {
$result['missingBlocks'][] = $doc.' → '.$key;
}
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
$result['orphanBlocks'][] = $doc.' → '.$key;
}
}
if ($this->option('check')) {
if (! $merged['unchanged']) {
foreach ($merged['replaced'] as $key) {
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
$result['driftedBlocks'][] = $doc.' → '.$key;
}
}
}
continue;
}
if (! $merged['unchanged']) {
File::put($abs, $merged['content']);
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
if (! in_array($doc, $result['created'], true)) {
$result['updated'][] = $doc;
}
}
}
return $result;
}
/**
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @param array<string, mixed> $result 처리 결과 (created 누적)
*/
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
{
$record = $ctx['record'];
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (is_file($abs)) {
continue;
}
File::ensureDirectoryExists(dirname($abs));
File::put($abs, $scaffolder->skeleton($doc, $ctx));
$result['created'][] = $doc;
}
}
/**
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
*
* @param string $content 문서 내용
* @param string $key 블록 키
* @param string $body 새 본문
* @return bool 다르면 true
*/
private function blockDiffers(string $content, string $key, string $body): bool
{
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
return false;
}
$from = (int) $sm[0][1] + strlen($sm[0][0]);
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
return false;
}
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
return $existing !== trim($body);
}
/**
* 처리 결과를 출력하고 종료 코드를 결정합니다.
*
* @param array<int, array<string, mixed>> $results 확장별 결과
* @param string $scope 범위
* @return int 종료 코드
*/
private function report(array $results, string $scope): int
{
$issues = 0;
foreach ($results as $result) {
$issues += count($result['missingDocuments'])
+ count($result['missingSections'])
+ count($result['missingBlocks'])
+ count($result['driftedBlocks'])
+ count($result['orphanBlocks']);
}
if ($this->option('json')) {
$this->line((string) json_encode([
'scope' => $scope,
'mode' => $this->mode(),
'extensions' => $results,
'malformed' => $this->malformed,
'issues' => $issues,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info(count($results).'개 확장 (scope='.$scope.')');
$this->newLine();
foreach ($results as $result) {
$parts = [];
foreach ($result['stats'] as $label => $value) {
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
$parts[] = $value === null
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
: "{$label} {$value}";
}
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
$this->line(' '.implode(' · ', $parts));
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
$this->line(' 선언형 표면: '.$result['surfaceReason']);
}
if ($result['surfaceErrors'] !== []) {
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->line(" ⚠ {$getter}(): {$message}");
}
}
}
return self::SUCCESS;
}
foreach ($results as $result) {
$label = sprintf('[%s] %s', $result['type'], $result['id']);
if ($result['created'] !== []) {
$this->info("{$label} 생성: ".implode(', ', $result['created']));
}
if ($result['updated'] !== []) {
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
}
if ($result['missingDocuments'] !== []) {
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
}
if ($result['missingBlocks'] !== []) {
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
}
if ($result['missingSections'] !== []) {
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
}
if ($result['driftedBlocks'] !== []) {
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
}
if ($result['orphanBlocks'] !== []) {
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
}
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
if ($result['unfilled'] !== []) {
$total = array_sum(array_column($result['unfilled'], 'count'));
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
$result['unfilled'],
)));
}
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
}
}
foreach ($this->malformed as $bad) {
$this->warn(sprintf(
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
));
}
$this->newLine();
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
if ($this->option('check') && $issues > 0) {
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* 현재 실행 모드를 반환합니다.
*
* @return string 모드 문자열
*/
private function mode(): string
{
if ($this->option('dry-run')) {
return 'dry-run';
}
if ($this->option('check')) {
return 'check';
}
if ($this->option('init')) {
return 'init';
}
return 'update';
}
}
@@ -96,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command
'debug_mode' => 'debug',
'sql_query_log' => 'debug',
'log_level' => 'debug',
'outbound_proxy' => 'debug',
'outbound_proxy_bypass' => 'debug',
];
/**
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
@@ -13,6 +14,7 @@ class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -189,6 +191,14 @@ class BuildModuleCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -2,15 +2,22 @@
namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use App\Services\LayoutResolverService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearModuleCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,15 +34,19 @@ class ClearModuleCacheCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService,
private LayoutResolverService $layoutResolver
) {
parent::__construct();
}
/**
* Execute the console command.
*
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearModuleCacheCommand extends Command
$clearedCount = $this->clearModuleCache($identifier);
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
'module' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearModuleCacheCommand extends Command
// 모든 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_all'));
// 전체 모듈 캐시 키 삭제
$cacheKeys = [
'modules.all',
'modules.active',
'modules.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 모듈별 캐시 삭제
$allModules = $this->moduleManager->getAllModules();
foreach ($allModules as $moduleName => $module) {
foreach ($this->moduleManager->getAllModules() as $module) {
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
@@ -113,26 +120,23 @@ class ClearModuleCacheCommand extends Command
/**
* 특정 모듈의 캐시를 삭제합니다.
*
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
*
* @param string $identifier 모듈 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearModuleCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'module');
$this->layoutResolver->clearResolutionCacheByModule($identifier);
// 모듈별 캐시 키
$cacheKeys = [
"module.config.{$identifier}",
"module.info.{$identifier}",
"module.routes.{$identifier}",
"module.permissions.{$identifier}",
"module.menus.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
->count();
}
}
@@ -95,7 +95,13 @@ class UninstallModuleCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(ModuleManager::UNINSTALL_STEPS);
try {
$result = $this->moduleManager->uninstallModule($identifier, $deleteData, $onProgress);
$result = $this->moduleManager->uninstallModule(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -108,6 +114,16 @@ class UninstallModuleCommand extends Command
$this->info(' - '.__('modules.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('modules.commands.uninstall.menus_deleted', ['count' => $menusCount]));
$this->info(' - '.__('modules.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('modules.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('modules.commands.uninstall.success', ['module' => $identifier]));
return Command::SUCCESS;
@@ -56,6 +56,9 @@ class PlaywrightIssueToken extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -104,6 +104,9 @@ class PlaywrightSeedLayout extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -0,0 +1,242 @@
<?php
namespace App\Console\Commands;
use App\Enums\ExtensionOwnerType;
use App\Enums\UserStatus;
use App\Models\IdentityVerificationLog;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Hash;
/**
* Playwright E2E 용 2단계 인증 픽스처 커맨드.
*
* 두 가지 일을 한다.
* - `--ensure-user` : 알려진 비밀번호를 가진 Active 사용자를 만들거나 갱신한다.
* (`--admin` 이면 admin 역할을 함께 부여)
* - `--plant` : 지정한 challenge 에 알려진 인증 코드의 해시를 심는다.
*
* 인증번호는 해시로만 저장되므로 브라우저 테스트가 되읽을 수 없다. 메일을 실제로 열어
* 보는 대신 알려진 값을 심어 "코드가 맞을 때" 를 재현한다 — 검증 대상은 코드 생성이
* 아니라 로그인 흐름(코드 확인 전 토큰 미발급 / 확인 후 발급)이다.
* 심는 방식은 PHPUnit `TwoFactorAuthTest::issuedCode()` 와 동일하다.
*
* 보안 가드 (3중, `playwright:issue-token` 과 동형):
* ① CLI 한정 — `php_sapi_name() === 'cli'`. production 웹 요청에서 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 필수
* ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작이 가능하도록
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --ensure-user=user --password='Passw0rd!2fa'
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-two-factor --plant=<challenge_id> --code=135790
*/
class PlaywrightSeedTwoFactor extends Command
{
protected $signature = 'playwright:seed-two-factor
{--ensure-user= : 이 접미사로 Active 테스트 사용자를 생성/갱신하고 이메일을 출력한다}
{--password= : --ensure-user 가 설정할 비밀번호 (기본값 Passw0rd!2fa)}
{--admin : --ensure-user 계정에 admin 역할을 부여한다}
{--plant= : 이 challenge 에 알려진 인증 코드의 해시를 심는다 (challenge UUID)}
{--code=135790 : --plant 가 심을 인증 코드}
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 2FA 테스트 계정을 정리. 0 이면 정리 안 함}
{--purge-users : 나이와 무관하게 playwright 2FA 테스트 계정을 전부 제거 (실측 종료 직후 호출)}';
protected $description = 'Playwright E2E 용 2단계 인증 픽스처 (테스트 계정 준비 / 인증 코드 심기)';
/** 테스트 전용 계정 이메일 접두사 */
private const TEST_EMAIL_PREFIX = 'playwright_2fa_';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단.
// 여기의 `env()` 는 `.env` 유래 값이 아니라 호출자가 그 자리에서 넘기는 프로세스
// 환경변수이므로 config:cache 의 영향을 받지 않는다.
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
return self::FAILURE;
}
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 픽스처 조작 허용
Config::set('app.debug', true);
$gcHours = (int) $this->option('gc-hours');
if ($gcHours > 0) {
$this->pruneStaleTestUsers($gcHours);
}
$did = false;
// 알려진 비밀번호를 가진 계정(관리자 포함)을 실측 뒤에 남기지 않는다 —
// 나이 기준 정리(gc-hours)는 그 사이의 창을 닫지 못한다.
if ($this->option('purge-users')) {
$this->purgeTestUsers();
$did = true;
}
if ($suffix = $this->option('ensure-user')) {
$this->ensureUser((string) $suffix);
$did = true;
}
if ($challengeId = $this->option('plant')) {
if (! $this->plantCode((string) $challengeId, (string) $this->option('code'))) {
return self::FAILURE;
}
$did = true;
}
if (! $did) {
$this->error('--ensure-user · --plant · --purge-users 중 하나는 지정해야 합니다.');
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* 알려진 비밀번호를 가진 Active 테스트 사용자를 만들거나 갱신하고 이메일을 출력합니다.
*
* @param string $suffix 계정 구분 접미사 (예: user / nonadmin)
*/
private function ensureUser(string $suffix): void
{
$email = self::TEST_EMAIL_PREFIX.$suffix.'@example.test';
$password = (string) ($this->option('password') ?: 'Passw0rd!2fa');
$user = User::where('email', $email)->first();
if ($user === null) {
$user = User::factory()->create([
'email' => $email,
'password' => Hash::make($password),
'status' => UserStatus::Active->value,
]);
} else {
// 이전 실행이 남긴 계정을 재사용한다 — 매 실행마다 계정이 늘면 회원 목록이
// 테스트 잔재로 뒤덮인다. 잠금·실패 카운트도 함께 초기화해 앞선 spec 의
// 잠금 상태가 다음 실행으로 새지 않게 한다.
$user->forceFill([
'password' => Hash::make($password),
'status' => UserStatus::Active->value,
'locked_until' => null,
'failed_login_attempts' => 0,
])->save();
}
if ($this->option('admin')) {
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! $user->roles()->where('roles.id', $adminRole->id)->exists()) {
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
}
} else {
// 관리자 거절 경로를 재현하려면 관리자 역할이 없어야 한다 — 재사용 계정에
// 앞선 실행의 admin 역할이 남아 있으면 403 을 측정할 수 없다.
$user->roles()->detach();
}
$this->line($email);
}
/**
* challenge 에 알려진 인증 코드의 해시를 심습니다.
*
* @param string $challengeId challenge UUID
* @param string $code 심을 인증 코드
* @return bool 성공 여부
*/
private function plantCode(string $challengeId, string $code): bool
{
$log = IdentityVerificationLog::find($challengeId);
if ($log === null) {
$this->error("challenge 를 찾을 수 없습니다: {$challengeId}");
return false;
}
$metadata = $log->metadata ?? [];
$metadata['code_hash'] = Hash::make($code);
$log->metadata = $metadata;
$log->save();
$this->line($code);
return true;
}
/**
* playwright 2FA 테스트 계정을 나이와 무관하게 전부 제거합니다.
*
* 이 계정들은 알려진 비밀번호를 갖고, `--admin` 으로 만든 것은 관리자 역할까지 갖는다.
* 실측이 끝난 뒤에도 남아 있으면 그 자체가 열린 문이므로 즉시 지운다.
*/
private function purgeTestUsers(): void
{
$removed = 0;
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
->chunkById(100, function ($chunk) use (&$removed) {
foreach ($chunk as $user) {
$user->tokens()->delete();
$user->roles()->detach();
$user->delete();
$removed++;
}
});
$this->info("[purge] playwright 2FA 테스트 계정 제거: {$removed}건");
}
/**
* 임계 시간보다 오래된 playwright 2FA 테스트 계정을 정리합니다.
*
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반
* chunk()/each() 는 줄어든 결과 집합만큼 커서가 밀려 일부를 건너뛴다.
*
* @param int $hours 이 시간보다 오래된 계정만 정리
*/
private function pruneStaleTestUsers(int $hours): void
{
$threshold = now()->subHours($hours);
$removed = 0;
User::where('email', 'like', self::TEST_EMAIL_PREFIX.'%')
->where('created_at', '<', $threshold)
->chunkById(100, function ($chunk) use (&$removed) {
foreach ($chunk as $user) {
$user->tokens()->delete();
$user->roles()->detach();
$user->delete();
$removed++;
}
});
if ($removed > 0) {
$this->info("[gc] playwright 2FA 테스트 계정 정리: {$removed}건");
}
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
@@ -13,6 +14,7 @@ class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -189,6 +191,14 @@ class BuildPluginCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -2,15 +2,21 @@
namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearPluginCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,8 +33,7 @@ class ClearPluginCacheCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache,
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -36,6 +41,12 @@ class ClearPluginCacheCommand extends Command
/**
* Execute the console command.
*
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearPluginCacheCommand extends Command
$clearedCount = $this->clearPluginCache($identifier);
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
'plugin' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearPluginCacheCommand extends Command
// 모든 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_all'));
// 전체 플러그인 캐시 키 삭제
$cacheKeys = [
'plugins.all',
'plugins.active',
'plugins.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 플러그인별 캐시 삭제
$allPlugins = $this->pluginManager->getAllPlugins();
foreach ($allPlugins as $pluginName => $plugin) {
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
@@ -113,25 +120,18 @@ class ClearPluginCacheCommand extends Command
/**
* 특정 플러그인의 캐시를 삭제합니다.
*
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
*
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearPluginCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
// 플러그인별 캐시 키 (메뉴 캐시 없음)
$cacheKeys = [
"plugin.config.{$identifier}",
"plugin.info.{$identifier}",
"plugin.routes.{$identifier}",
"plugin.permissions.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
->count();
}
}
@@ -92,7 +92,13 @@ class UninstallPluginCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(PluginManager::UNINSTALL_STEPS);
try {
$result = $this->pluginManager->uninstallPlugin($identifier, $deleteData, $onProgress);
$result = $this->pluginManager->uninstallPlugin(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -104,6 +110,16 @@ class UninstallPluginCommand extends Command
$this->info(' - '.__('plugins.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
$this->info(' - '.__('plugins.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('plugins.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('plugins.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
return Command::SUCCESS;
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\ActivityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
*/
class PruneActivityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ActivityLogService $service 활동 로그 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ActivityLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
*
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
*/
class PruneIdentityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
/**
* The console command description.
*/
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$days = (int) $this->option('days');
$purged = $service->purge($days);
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\NotificationLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
*/
class PruneNotificationLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param NotificationLogService $service 알림 발송 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(NotificationLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Services\AttachmentService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 소유자 없는 고아 첨부파일 정리 커맨드
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
*
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
*
* @example php artisan attachments:prune-orphans --dry-run
* @example php artisan attachments:prune-orphans --days=7 --limit=100
*/
class PruneOrphanAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'attachments:prune-orphans
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
/**
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
protected AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
return Command::SUCCESS;
}
$days = $this->resolveRetentionDays();
if ($days < 1) {
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
return Command::SUCCESS;
}
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
return Command::SUCCESS;
}
$this->info(sprintf(
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
return Command::SUCCESS;
}
/**
* 자동 정리 토글을 false 폴백으로 조회합니다.
*
* @return bool 자동 정리 활성 여부
*/
private function isCleanupEnabled(): bool
{
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
}
/**
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
*
* @return int 보존기간(일)
*/
private function resolveRetentionDays(): int
{
$option = $this->option('days');
if ($option !== null && $option !== '') {
return (int) $option;
}
return (int) g7_core_settings('upload.orphan_retention_days', 30);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ScheduleService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
*
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneScheduleHistoryCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ScheduleService $service 스케줄 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ScheduleService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->pruneHistory($days);
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Seo\SeoCacheStatsService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
*
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneSeoCacheStatsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(SeoCacheStatsService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->cleanup($days);
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Console\Commands;
use App\Services\StorageLeftoverPruneService;
use Illuminate\Console\Command;
/**
* 스토리지 잔존물 정리 커맨드
*
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
*/
class PruneStorageLeftoversCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'storage:prune-leftovers
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
/**
* The console command description.
*/
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
/**
* 대상군 키 → 출력 라벨.
*
* @var array<string, string>
*/
private const GROUP_LABELS = [
'staging' => '업데이트 스테이징 잔존',
'temp' => '코어 임시 산출물',
'vendor_bundle_staging' => 'vendor 번들 스테이징',
'extension_backups' => '확장 백업',
'core_backups' => '코어 백업',
'legacy_browser_log' => '레거시 브라우저 로그',
];
/**
* Execute the console command.
*
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(StorageLeftoverPruneService $service): int
{
$days = max(0, (int) $this->option('days'));
$backupDays = max(0, (int) $this->option('backup-days'));
$dryRun = (bool) $this->option('dry-run');
$result = $service->prune($days, $backupDays, $dryRun);
$total = 0;
foreach (self::GROUP_LABELS as $key => $label) {
$paths = $result[$key] ?? [];
$count = count($paths);
$total += $count;
$this->line("{$label}: {$count}건");
if ($dryRun) {
foreach ($paths as $path) {
$this->line(" - {$path}");
}
}
}
$this->info($dryRun
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
/**
* 부트스트랩 리소스 정적 게시(bake)를 수동 수행하는 커맨드
*
* 수명주기 이벤트(terminating 트리거)와 blade 자가 치유가 정상 경로지만,
* 배포 직후 워밍이나 수동 복구가 필요할 때 이 커맨드로 즉시 게시한다.
* 설치기 완료 단계에서도 호출된다 (#122).
*/
class PublishExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:publish {--force : 게시 완료 상태여도 강제 재게시}';
/**
* The console command description.
*/
protected $description = '부트스트랩 리소스(다국어·컴포넌트·라우트·번들·템플릿 에셋)를 정적 파일로 게시합니다';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionStaticCacheService $service): int
{
if (! $service->isEnabled()) {
$this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
return self::SUCCESS;
}
// root 로 실행 중이면 경고만 하고 **계속 진행**한다 — 명시적 명령은 운영자 책임이고, 중단 가드는
// 두지 않는다(#651 D3). 게시가 만드는 캐시 락 샤드·병합 번들이 root 소유로 남으면 이후 웹 요청의
// 캐시 쓰기가 Permission denied 로 죽으므로(전면 500 실사례) 웹 계정 실행 형태를 함께 적는다.
if (StatusExtensionStaticCacheCommand::isRootProcess()) {
$this->warn('root 로 실행 중입니다 — 이 계정으로 게시하면 캐시 폴더·병합 번들이 root 소유가 되어 이후 웹 요청이 실패할 수 있습니다.');
$this->warn('웹 계정으로 실행하세요: '.StatusExtensionStaticCacheCommand::publishCommandHint());
}
$published = $service->publishCurrent(force: (bool) $this->option('force'));
if (! $published) {
$this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
return self::FAILURE;
}
$this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
return self::SUCCESS;
}
}
@@ -0,0 +1,482 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
/**
* 의존성 취약점 전수 점검 커맨드 (#126)
*
* 이 저장소는 잠금파일이 60개 가까이 흩어져 있다 — 코어 하나와 확장 수십 개가 각자
* `package-lock.json` / `composer.lock` 을 갖는다. 루트만 감사하면 확장 전부가 사각이
* 되는데, 그 사각은 오류도 경고도 남기지 않는다: 취약한 라이브러리가 정상 동작하는 것이
* 유일한 증상이다.
*
* 동봉(vendored) 자산은 어떤 잠금파일에도 없어 `npm audit` 이 **원리상** 볼 수 없다.
* 그 축은 판정하지 않고 목록으로 노출한다 — 사람이 봐야 하는 대상이기 때문이다.
*
* 종료 코드: 취약점이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
* **조치 대상 발견 신호**다.
*/
class SecurityAuditDependenciesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'security:audit-dependencies
{--json : 결과를 JSON 으로 출력합니다}
{--npm-only : npm 잠금파일만 점검합니다}
{--composer-only : composer 잠금파일만 점검합니다}';
/**
* The console command description.
*/
protected $description = '저장소의 모든 잠금파일(npm·composer) 운영 의존성 취약점을 점검합니다 (취약점 발견 시 비-0 종료)';
/** 잠금파일 탐색에서 제외할 경로 조각 */
private const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', '_pending', 'storage'];
/** 개별 감사 프로세스 제한 시간(초) */
private const PROCESS_TIMEOUT = 300;
/**
* Execute the console command.
*
* @return int 취약점이 없으면 0, 있으면 1
*/
public function handle(): int
{
$npmOnly = (bool) $this->option('npm-only');
$composerOnly = (bool) $this->option('composer-only');
$results = [];
if (! $composerOnly) {
foreach ($this->lockFiles('package-lock.json') as $lock) {
$results[] = $this->auditNpm($lock);
}
}
if (! $npmOnly) {
foreach ($this->lockFiles('composer.lock') as $lock) {
$results[] = $this->auditComposer($lock);
}
}
$vendored = $this->vendoredAssets();
if ($this->option('json')) {
$this->line((string) json_encode([
'results' => $results,
'vendored_assets' => $vendored,
'totals' => $this->totals($results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $this->totals($results)['vulnerable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->renderTable($results);
$this->renderVendored($vendored);
$totals = $this->totals($results);
$this->line('');
if ($totals['unmeasurable'] > 0) {
$this->warn(sprintf(
'점검 불가 %d건 — 감사 도구가 실행되지 않았습니다. "취약점 없음" 과 다릅니다.',
$totals['unmeasurable']
));
}
if ($totals['vulnerable'] === 0) {
$this->info(sprintf(
'운영 의존성 취약점 0건 (감사 %d개 / 대상 없음 %d개 / 발견 %d개 잠금파일).',
$totals['audited'],
$totals['empty'],
$totals['checked']
));
return $totals['unmeasurable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->error(sprintf(
'%d개 잠금파일에서 운영 의존성 취약점이 발견되었습니다 (총 %d건).',
$totals['vulnerable'],
$totals['advisories']
));
return self::FAILURE;
}
/**
* 저장소에서 잠금파일을 전수 탐색합니다.
*
* @param string $fileName 잠금파일 이름
* @return array<int, string> 잠금파일 절대 경로 목록
*/
private function lockFiles(string $fileName): array
{
$root = base_path();
$found = [];
// 제외 디렉토리는 **내려가기 전에** 잘라낸다. 파일 단계에서 거르면 `node_modules`
// 수만 개를 전부 훑고 나서 버리게 되어 한 번 실행에 수 분이 걸린다.
$directories = new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS);
$pruned = new \RecursiveCallbackFilterIterator(
$directories,
function (\SplFileInfo $entry): bool {
if (! $entry->isDir()) {
return true;
}
return ! in_array($entry->getFilename(), self::EXCLUDED_SEGMENTS, true);
}
);
foreach (new \RecursiveIteratorIterator($pruned) as $entry) {
if (! $entry->isFile() || $entry->getFilename() !== $fileName) {
continue;
}
$found[] = $entry->getPathname();
}
sort($found);
return $found;
}
/**
* npm 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditNpm(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'dependencies'])) {
return $this->emptyLock($lockPath, 'npm');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['npm', 'audit', '--omit=dev', '--json'], $dir);
$decoded = json_decode($process['output'], true);
if (! is_array($decoded) || ! isset($decoded['metadata']['vulnerabilities'])) {
return $this->unmeasurable($lockPath, 'npm', trim($process['error']) ?: 'npm audit 출력을 해석할 수 없습니다.');
}
$severities = array_filter(
array_map('intval', $decoded['metadata']['vulnerabilities']),
static fn (int $count, string $key): bool => $count > 0 && $key !== 'total',
ARRAY_FILTER_USE_BOTH
);
$total = (int) ($decoded['metadata']['vulnerabilities']['total'] ?? 0);
return [
'target' => $this->relative($lockPath),
'kind' => 'npm',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['vulnerabilities'] ?? [])) : '',
];
}
/**
* composer 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditComposer(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'packages-dev'])) {
return $this->emptyLock($lockPath, 'composer');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['composer', 'audit', '--locked', '--no-dev', '--format=json'], $dir);
$decoded = json_decode($this->firstJsonObject($process['output']), true);
if (! is_array($decoded) || ! array_key_exists('advisories', $decoded)) {
return $this->unmeasurable($lockPath, 'composer', trim($process['error']) ?: 'composer audit 출력을 해석할 수 없습니다.');
}
$severities = [];
$total = 0;
foreach ($decoded['advisories'] as $advisories) {
foreach ((array) $advisories as $advisory) {
$total++;
$severity = (string) ($advisory['severity'] ?? 'unknown');
$severities[$severity] = ($severities[$severity] ?? 0) + 1;
}
}
return [
'target' => $this->relative($lockPath),
'kind' => 'composer',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['advisories'])) : '',
];
}
/**
* 잠금파일이 의존성을 하나도 담고 있지 않은지 판정합니다.
*
* 확장 대부분은 composer 의존성이 없어 빈 잠금을 갖는다. 그 상태에서 감사 도구는
* "설치본이 없다" 는 오류를 내는데, 그것을 점검 불가로 세면 조치할 것이 없는 대상이
* 24건씩 경고로 쌓여 진짜 점검 불가를 가린다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param array<int, string> $keys 의존성이 담기는 최상위 키
* @return bool
*/
private function isEmptyLock(string $lockPath, array $keys): bool
{
$decoded = json_decode((string) @file_get_contents($lockPath), true);
if (! is_array($decoded)) {
return false;
}
foreach ($keys as $key) {
$entries = $decoded[$key] ?? [];
if (! is_array($entries)) {
continue;
}
// npm 잠금의 `packages` 는 루트 자신을 빈 문자열 키로 항상 담는다 — 그것만 있으면
// 빈 것이다. composer 잠금의 `packages` 는 리스트라 키가 정수이므로 건드리지 않는다.
$meaningful = array_filter(
array_keys($entries),
static fn ($name): bool => $name !== ''
);
if ($meaningful !== []) {
return false;
}
}
return true;
}
/**
* 의존성이 없는 잠금파일을 기록합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function emptyLock(string $lockPath, string $kind): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'empty',
'advisories' => 0,
'severities' => [],
'detail' => '의존성 없음',
];
}
/**
* 감사 도구를 실행할 수 없었던 대상을 기록합니다.
*
* "점검 불가" 는 "취약점 없음" 과 다르다 — 뭉뚱그리면 운영자가 정상으로 읽는다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @param string $reason 사유
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function unmeasurable(string $lockPath, string $kind, string $reason): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'unmeasurable',
'advisories' => 0,
'severities' => [],
'detail' => mb_substr($reason, 0, 200),
];
}
/**
* 외부 명령을 실행합니다 ( 과 충돌하지 않도록 이름을 분리).
*
* @param array<int, string> $command 명령과 인자
* @param string $cwd 작업 디렉토리
* @return array{output: string, error: string, exit: int}
*/
private function runProcess(array $command, string $cwd): array
{
$process = new Process($command, $cwd, null, null, self::PROCESS_TIMEOUT);
try {
$process->run();
} catch (\Throwable $e) {
return ['output' => '', 'error' => $e->getMessage(), 'exit' => -1];
}
return [
'output' => $process->getOutput(),
'error' => $process->getErrorOutput(),
'exit' => (int) $process->getExitCode(),
];
}
/**
* 앞쪽 잡음을 걷어내고 첫 JSON 객체만 남깁니다.
*
* composer 는 오토로드 경고를 표준출력에 함께 흘릴 수 있다.
*
* @param string $output 명령 출력
* @return string JSON 문자열 (없으면 빈 문자열)
*/
private function firstJsonObject(string $output): string
{
$start = strpos($output, '{');
return $start === false ? '' : substr($output, $start);
}
/**
* 동봉 제3자 자산의 버전 디렉토리를 나열합니다.
*
* 감사 도구가 원리상 볼 수 없는 축이므로 판정하지 않고 노출만 한다.
*
* @return array<int, array{extension: string, library: string, versions: array<int, string>}>
*/
private function vendoredAssets(): array
{
$rows = [];
foreach (['templates', 'modules', 'plugins'] as $kind) {
foreach ([$kind.'/*/dist/vendor', $kind.'/_bundled/*/dist/vendor'] as $pattern) {
foreach ((array) glob(base_path($pattern), GLOB_ONLYDIR) as $vendorRoot) {
foreach ((array) glob($vendorRoot.'/*', GLOB_ONLYDIR) as $libDir) {
$versions = array_map(
'basename',
(array) glob($libDir.'/*', GLOB_ONLYDIR)
);
$rows[] = [
'extension' => $this->relative(dirname($vendorRoot, 2)),
'library' => basename($libDir),
'versions' => array_values($versions),
];
}
}
}
}
return $rows;
}
/**
* 결과 집계를 계산합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return array{checked: int, vulnerable: int, unmeasurable: int, advisories: int}
*/
private function totals(array $results): array
{
return [
'checked' => count($results),
'audited' => count(array_filter($results, static fn (array $r): bool => in_array($r['status'], ['clean', 'vulnerable'], true))),
'empty' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'empty')),
'vulnerable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'vulnerable')),
'unmeasurable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'unmeasurable')),
'advisories' => array_sum(array_column($results, 'advisories')),
];
}
/**
* 결과 표를 출력합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return void
*/
private function renderTable(array $results): void
{
$this->line('');
$this->line('<options=bold>의존성 취약점 점검 (운영 의존성 기준)</>');
$this->line('');
$rows = [];
foreach ($results as $result) {
$severities = [];
foreach ($result['severities'] as $severity => $count) {
$severities[] = $severity.' '.$count;
}
$rows[] = [
$result['kind'],
$result['target'],
match ($result['status']) {
'clean' => '이상 없음',
'empty' => '대상 없음',
'vulnerable' => '취약',
default => '점검 불가',
},
$result['advisories'] > 0 ? (string) $result['advisories'] : '-',
$severities === [] ? ($result['detail'] !== '' ? mb_substr($result['detail'], 0, 60) : '-') : implode(', ', $severities),
];
}
$this->table(['종류', '대상', '상태', '건수', '내역'], $rows);
}
/**
* 동봉 자산 목록을 출력합니다.
*
* @param array<int, array<string, mixed>> $vendored 동봉 자산 목록
* @return void
*/
private function renderVendored(array $vendored): void
{
$this->line('');
$this->line('<options=bold>동봉 제3자 자산 (감사 도구가 볼 수 없는 축 — 사람이 확인)</>');
$this->line('');
if ($vendored === []) {
$this->line(' (없음)');
return;
}
$this->table(
['확장', '라이브러리', '버전 디렉토리'],
array_map(
static fn (array $row): array => [
$row['extension'],
$row['library'],
$row['versions'] === [] ? '(버전 디렉토리 없음)' : implode(', ', $row['versions']),
],
$vendored
)
);
}
/**
* 저장소 상대 경로로 바꿉니다.
*
* @param string $path 절대 경로
* @return string
*/
private function relative(string $path): string
{
$base = base_path().DIRECTORY_SEPARATOR;
return str_replace('\\', '/', str_starts_with($path, $base) ? substr($path, strlen($base)) : $path);
}
}
@@ -0,0 +1,209 @@
<?php
namespace App\Console\Commands;
use App\Extension\Helpers\FilePermissionHelper;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* 부트스트랩 리소스 정적 게시 상태 점검 커맨드 (#122)
*
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이다. 그래서 정상
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
*
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
*
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
* 최근 실패 마커 / 잔존 버전 목록.
*
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
* **이상 발견 신호**다 — 운영자가 조치할 대상이 있다는 뜻이다.
*/
class StatusExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:status';
/**
* The console command description.
*/
protected $description = '부트스트랩 리소스 정적 게시 상태를 점검합니다 (이상 발견 시 비-0 종료)';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 이상 없으면 0, 이상 발견 시 1
*/
public function handle(ExtensionStaticCacheService $service): int
{
$problems = [];
$report = $service->statusReport();
$base = $service->baseDir();
$publishHint = self::publishCommandHint();
$this->line('');
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
$this->line('');
$this->line(' 실행 계정 : '.$report['process_user']);
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
if (self::isRootProcess()) {
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
}
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
$this->line(' 현재 캐시 버전 : '.$report['version']);
$this->line(' 게시 루트 : '.$base);
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
if (! $report['enabled']) {
$this->line('');
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
return self::SUCCESS;
}
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
if ($report['tree_writable']) {
$this->line(' 트리 쓰기 : 가능');
} else {
$this->line(' 트리 쓰기 : <error>불가</error>');
$problems[] = sprintf(
'게시 트리에 쓸 수 없습니다 (%s, owner=%s, perms=%s). 웹 계정이 재게시할 수 없어 '
.'모든 요청이 API 폴백으로 동작합니다.',
$base,
(string) (@fileowner($base) ?: 'unknown'),
File::exists($base) ? substr(sprintf('%o', @fileperms($base)), -4) : 'absent'
);
}
// 2) 현재 버전 게시 여부
if ($report['published']) {
$this->line(' 게시 상태 : 완료');
$this->line(' manifest 파일 : '.$report['files'].'건');
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
if ($report['files'] === 0) {
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
}
} else {
$this->line(' 게시 상태 : <comment>미게시</comment>');
$problems[] = sprintf(
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
.'「지금 다시 만들기」를 사용하세요.',
$report['version'],
$publishHint
);
}
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
$marker = $report['failure'];
if ($marker !== null) {
$this->line('');
$this->line(' <error>최근 게시 실패</error>');
$this->line(' 사유 : '.($marker['reason'] ?? '?').' — '.$this->reasonHint($marker['reason'] ?? ''));
$this->line(' 버전 : '.($marker['version'] ?? '?'));
$this->line(' 시각 : '.($marker['at'] ?? '?'));
$this->line(' 연속 실패 : '.($marker['count'] ?? '?').'회');
$this->line(' 상세 : '.($marker['message'] ?? ''));
$problems[] = '게시 실패 마커가 남아 있습니다 (사유: '.($marker['reason'] ?? '?').').';
}
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
$versions = $report['retained_versions'];
$this->line('');
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
if (count($versions) > 3) {
$problems[] = sprintf(
'게시 버전이 %d개 누적됐습니다 (정상은 현재+직전 2개). 삭제가 실패하고 있을 수 '
.'있습니다 — 소유권/권한을 확인하세요.',
count($versions)
);
}
$this->line('');
if ($problems === []) {
$this->info('이상 없음.');
return self::SUCCESS;
}
foreach ($problems as $problem) {
$this->warn('• '.$problem);
}
$this->line('');
return self::FAILURE;
}
/**
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
*
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
*
* @return string 안내 명령 문자열
*/
public static function publishCommandHint(): string
{
$command = 'php artisan ext-static:publish';
$account = FilePermissionHelper::describeWebServerAccount();
return match ($account['mode']) {
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
default => $command,
};
}
/**
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
*
* @return bool root 실행 여부
*/
public static function isRootProcess(): bool
{
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
}
/**
* 실패 사유 코드에 대한 조치 힌트를 반환합니다.
*
* 사유별로 볼 곳이 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
*
* @param string $reason 사유 코드
* @return string 조치 힌트
*/
private function reasonHint(string $reason): string
{
return match ($reason) {
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
.self::publishCommandHint(),
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
default => '상세 메시지를 확인하세요.',
};
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildTemplateCommand extends Command
{
use ClearsTemplateCaches;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -181,6 +183,14 @@ class BuildTemplateCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -2,16 +2,17 @@
namespace App\Console\Commands\Template;
use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearTemplateCacheCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -28,7 +29,6 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -67,6 +67,10 @@ class ClearTemplateCacheCommand extends Command
/**
* 특정 템플릿의 캐시 삭제
*
* 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점
* (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을
* 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119).
*/
private function clearSingleTemplateCache(string $identifier): void
{
@@ -78,34 +82,12 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
$clearedCount = 0;
// 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시
$clearedCount = $this->templateManager->clearTemplateCache($identifier);
// 1. 레이아웃 캐시 삭제
$templateRecord = Template::where('identifier', $identifier)->first();
if ($templateRecord) {
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$identifier}.{$layout->name}");
$clearedCount++;
}
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$identifier}.{$locale}");
$clearedCount++;
}
// 4. 활성 템플릿 타입 캐시 삭제
if ($templateRecord) {
$this->cache->forget("templates.active.{$templateRecord->type}");
$clearedCount++;
}
// 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump)
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
@@ -126,36 +108,17 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_all'));
$clearedCount = 0;
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
// 모든 설치된 템플릿의 캐시 삭제
$templates = Template::all();
foreach ($templates as $templateRecord) {
// 1. 레이아웃 캐시 삭제
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
$clearedCount++;
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$templateRecord->identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
$clearedCount++;
}
// 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점)
foreach (Template::all() as $templateRecord) {
$clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier);
}
// 4. 활성 템플릿 타입 캐시 삭제
$this->cache->forget('templates.active.admin');
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
// 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
@@ -71,7 +71,7 @@ class UninstallTemplateCommand extends Command
// 템플릿 삭제
$onProgress = $this->createProgressCallback(TemplateManager::UNINSTALL_STEPS);
try {
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress);
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress, $preservedBackups);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -83,6 +83,15 @@ class UninstallTemplateCommand extends Command
$this->info('✅ '.__('templates.commands.uninstall.success', ['template' => $identifier]));
$this->info(' - '.__('templates.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('templates.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('templates.commands.uninstall.success', ['template' => $identifier]));
return Command::SUCCESS;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Traits;
use App\Console\Helpers\ConsoleConfirm;
use Symfony\Component\Console\Input\StreamableInputInterface;
/**
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
@@ -12,6 +13,7 @@ use App\Console\Helpers\ConsoleConfirm;
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
*
* - `--no-interaction` 시: $default 즉시 반환
* - 응답을 받을 수 없는 실행(콘솔 없는 STDIN) 시: $default 즉시 반환
* - empty 입력 시: $default 반환
* - yes/y → true, no/n → false (대소문자 무시)
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
@@ -26,7 +28,7 @@ trait HasUnifiedConfirm
*/
protected function unifiedConfirm(string $question, bool $default = false): bool
{
if (! $this->input->isInteractive()) {
if (! $this->input->isInteractive() || ! $this->canPromptForAnswer()) {
return $default;
}
@@ -47,4 +49,38 @@ trait HasUnifiedConfirm
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
}
}
/**
* 이 실행이 프롬프트 응답을 실제로 받을 수 있는지 판정합니다.
*
* Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로
* 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
* `--no-interaction` 없이 CI·스케줄러·에이전트처럼 콘솔이 없는 곳에서 부르면
* `isInteractive()` 가 true 로 남고 `ask()` 가 오지 않을 응답을 무한히 기다린다.
* 프롬프트 출력마저 버퍼에 갇혀 있어 겉으로는 "커맨드가 느리다" 로만 보인다.
*
* `stream_isatty()` 는 posix 확장 없이도 Windows 를 포함해 동작하므로 이를 쓴다.
* 판정 재료가 없으면 true 를 돌려 기존 동작(질문)을 유지한다 — 물어보지 못해 멈추는
* 것보다 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
*
* @return bool 응답을 받을 수 있으면 true
*/
protected function canPromptForAnswer(): bool
{
// 테스트는 QuestionHelper 를 대체하거나 입력을 주입하므로 STDIN 을 쓰지 않는다
if ($this->laravel !== null && $this->laravel->runningUnitTests()) {
return true;
}
// 입력 스트림이 주입된 실행(CommandTester::setInputs 등)도 STDIN 과 무관하다
if ($this->input instanceof StreamableInputInterface && is_resource($this->input->getStream())) {
return true;
}
if (! \defined('STDIN') || ! is_resource(STDIN) || ! \function_exists('stream_isatty')) {
return true;
}
return stream_isatty(STDIN);
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Console\Command;
/**
* 신뢰 프록시 설정 상태 점검 커맨드 (#124)
*
* 프록시 뒤에서 신뢰 프록시가 설정되지 않으면 관리자 화면 자체가 뜨지 않을 수 있다. 화면으로
* 확인할 수 없는 바로 그 상태에서 쓰는 통로가 이 커맨드다.
*
* 콘솔에는 요청이 없으므로 `isSecure()`·`ip()` 같은 **실측 항목은 판정할 수 없다.** 그 항목은
* `판정 불가` 로 구분해 표시하고, 값이 비어 있는 것과 뭉뚱그리지 않는다 — 둘을 같은 문구로
* 내보내면 운영자가 "설정이 비었다" 로 오독한다.
*
* 종료 코드: 설정이 없으면 1. 비-0 은 "커맨드 실행 실패" 가 아니라 **점검 대상 신호**다.
* 다만 프록시를 쓰지 않는 직접 노출 구성에서는 미설정이 정상이므로, 그 사실을 출력에 명시한다.
*
* @since 7.0.10
*/
class TrustedProxyStatusCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'trusted-proxy:status';
/**
* The console command description.
*/
protected $description = '리버스 프록시 신뢰 설정(TRUSTED_PROXIES) 상태를 점검합니다';
/**
* Execute the console command.
*
* @return int 설정되어 있으면 0, 미설정이면 1
*/
public function handle(): int
{
// 콘솔에는 요청이 없다 — null 을 넘겨 실측 축을 not_applicable 로 받는다.
$diagnostic = TrustedProxyDiagnostic::forRequest(null);
$this->line('');
$this->line('<options=bold>리버스 프록시 신뢰 설정 상태</>');
$this->line('');
$this->line(' 설정 파일 : config/trustedproxy.php');
$this->line(' 환경변수 : TRUSTED_PROXIES');
if ($diagnostic['trusted_configured']) {
$this->line(' 현재 값 : '.$diagnostic['configured_proxies']);
} else {
$this->line(' 현재 값 : <comment>미설정 (아무 프록시도 신뢰하지 않음)</comment>');
}
$this->line('');
$this->line(' <options=bold>요청 기반 실측</>');
$this->line(' 수신 전달 헤더 : <comment>판정 불가 (콘솔에는 요청이 없습니다)</comment>');
$this->line(' HTTPS 인식 : <comment>판정 불가</comment>');
$this->line(' 방문자 IP : <comment>판정 불가</comment>');
$this->line('');
$this->comment(' 실측 축은 웹 요청에서만 판정됩니다 — 관리자 대시보드 알림 또는');
$this->comment(' 환경설정 > 고급 의 진단 블록에서 확인하세요.');
$this->line('');
if ($diagnostic['trusted_configured']) {
$this->info('신뢰 프록시가 설정되어 있습니다. X-Forwarded-* 헤더를 신뢰합니다.');
$this->line('');
return self::SUCCESS;
}
$this->warn('• 신뢰 프록시가 설정되어 있지 않습니다.');
$this->line('');
$this->line(' 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 구동 중이라면');
$this->line(' .env 에 TRUSTED_PROXIES 를 지정하세요. 미설정 시 접속 주소·방문자 IP 가');
$this->line(' 프록시 기준으로 인식되어 화면 표시·IP 기록·결제 통보 수신이 어긋납니다.');
$this->line(' 프록시를 쓰지 않는 직접 노출 구성이라면 미설정이 정상입니다.');
$this->line(' 상세: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md');
$this->line('');
return self::FAILURE;
}
}
@@ -3,8 +3,8 @@
namespace App\Console\Commands\Vendor\Concerns;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorBundleResult;
use App\Extension\Vendor\VendorBundler;
use App\Extension\Vendor\VendorBundleResult;
use App\Extension\Vendor\VendorIntegrityChecker;
use Illuminate\Support\Facades\File;
@@ -210,14 +210,17 @@ trait RunsVendorBundleAction
$sourcePath = $this->resolveSourcePath($target);
$outputPath = $this->resolveOutputPath($target);
if (! is_dir($sourcePath)) {
$this->warn("- $label: 소스 경로 없음 ($sourcePath)");
// 판정 기준 파일은 빌드(VendorBundler::build / isStale)와 같은 규칙 — 출력(_bundled) 우선,
// 없으면 활성 디렉토리 폴백. 활성 디렉토리만 보면 미설치 확장을 "소스 경로 없음" 으로
// 보고해 _bundled 의 stale 번들이 --check 를 통과한다.
$composerJsonPath = $this->resolveCheckFile($sourcePath, $outputPath, 'composer.json');
if ($composerJsonPath === null) {
if (! is_dir($sourcePath) && ! is_dir($outputPath)) {
$this->warn("- $label: 경로 없음 ($outputPath)");
return false;
}
return false;
}
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
if (! is_file($composerJsonPath)) {
$this->line("- $label: SKIPPED (composer.json 없음)");
return false;
@@ -240,6 +243,24 @@ trait RunsVendorBundleAction
return $stale;
}
/**
* --check 가 읽을 composer 파일 경로 — 출력(_bundled) 우선, 없으면 활성 디렉토리 폴백.
*
* VendorBundler::resolveHashTarget() 과 같은 규칙. 판정 파일이 빌드·해시 기준과
* 다르면 --check 결과가 실제 stale 여부와 어긋난다.
*/
private function resolveCheckFile(string $sourcePath, string $outputPath, string $filename): ?string
{
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
if (is_file($outputFile)) {
return $outputFile;
}
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
return is_file($sourceFile) ? $sourceFile : null;
}
/**
* composer.json 에 외부 패키지 의존성이 있는지 확인합니다.
*
@@ -25,6 +25,17 @@ interface CacheableExtensionInterface
*/
public function getStorage(): StorageInterface;
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* getStorageDiskFor($category) 가 결정한 디스크의 인스턴스를 반환하며,
* 기본 구현은 getStorage() 와 동일한 디스크를 사용합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return StorageInterface 카테고리 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
+11 -7
View File
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 확장(모듈/플러그인) 스토리지 인터페이스
*
@@ -50,12 +52,14 @@ interface StorageInterface
/**
* 파일의 공개 URL을 반환합니다.
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 반환하고, 그 외에는 null 을 반환합니다 (별도 API 엔드포인트 사용).
* 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 항상 통과하므로,
* 확장이 URL 을 공급/수정/차단할 수 있습니다 (null 반환도 훅 발화 대상).
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string;
@@ -110,9 +114,9 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null)
* @return StreamedResponse|null 파일 스트림 (파일이 없으면 null)
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
/**
* 사용할 디스크를 변경한 새 인스턴스를 반환합니다.
@@ -132,7 +136,7 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
* @return StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
}
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
* 모든 템플릿을 로드하고 초기화합니다.
* 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
*/
public function loadTemplates(): void;
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
* (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
*/
public function ensureLoaded(): void;
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
@@ -65,4 +65,12 @@ interface ActivityLogRepositoryInterface
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int;
/**
* 보존 기간이 지난 활동 로그를 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
}
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
/**
* 첨부파일 Repository 인터페이스
@@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface
*/
public function findById(int $id): ?Attachment;
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로,
* 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다.
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment;
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로,
* 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다.
*
* 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고,
* 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
* @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
}
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
public function getActiveCoreLocales(): array;
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
* 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator;
/**
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
*
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
*/
public function allForUpdateCheck(): Collection;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
@@ -14,9 +14,10 @@ interface LayoutExtensionVersionRepositoryInterface
* @param int $extensionId 레이아웃 확장 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
*/
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion;
/**
* 특정 확장의 모든 버전 조회 (최신순)
@@ -14,9 +14,10 @@ interface LayoutVersionRepositoryInterface
* @param int $layoutId 레이아웃 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 레이아웃 content 사용)
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutVersion 생성된 버전 모델
*/
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null): TemplateLayoutVersion;
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null, ?int $createdBy = null): TemplateLayoutVersion;
/**
* 특정 레이아웃의 모든 버전 조회 (최신순)
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
*/
public function findById(int $id): ?Menu;
/**
* 여러 ID로 메뉴를 한 번에 조회합니다.
*
* 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
* 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
*
* @param array<int, int> $ids 메뉴 ID 목록
* @return Collection 메뉴 컬렉션
*/
public function findByIds(array $ids): Collection;
/**
* 슬러그로 메뉴를 찾습니다.
*
@@ -48,6 +59,14 @@ interface MenuRepositoryInterface
*/
public function findBySlug(string $slug): ?Menu;
/**
* URL 로 메뉴를 찾습니다.
*
* @param string $url 메뉴 URL
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findByUrl(string $url): ?Menu;
/**
* 새로운 메뉴를 생성합니다.
*
@@ -40,6 +40,14 @@ interface NotificationLogRepositoryInterface
*/
public function bulkDelete(array $ids): int;
/**
* 보존 기간이 지난 발송 이력을 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
/**
* 페이지네이션 목록 조회.
*
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Permission;
/**
* 여러 ID로 권한을 일괄 조회합니다.
*
* @param array<int> $ids 권한 ID 배열
* @return Collection 권한 컬렉션 (ID 기준)
*/
public function getByIds(array $ids): Collection;
/**
* 새로운 권한을 생성합니다.
*
@@ -0,0 +1,25 @@
<?php
namespace App\Exceptions\Auth;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* 2단계 인증 코드 발송 실패 예외 — 비밀번호 확인은 통과했으나 인증번호를 보낼 수 없어
* 로그인을 완료할 수단이 없을 때 발생합니다.
*
* HTTP 503 Service Unavailable 로 매핑됩니다. 자격 증명은 올바르므로 401 로 뭉뚱그리면
* 사용자는 비밀번호를 의심하며 같은 실패를 반복하게 되고, 운영자는 메일 설정이 깨진 사실을
* 알 방법이 없습니다.
*
* 메시지 자리에는 번역문이 아니라 다국어 **키**를 보관합니다 (선례: AccountLockedException).
*
* @since 7.0.11
*/
class TwoFactorDeliveryFailedException extends HttpException
{
public function __construct(?string $message = null)
{
parent::__construct(503, $message ?? 'auth.two_factor_delivery_failed');
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 보호된 역할(코어/확장 소유) 수정 시도 시 발생하는 예외
*
* 삭제 경로에만 있던 코어/확장 소유 역할 보호를 수정·상태변경 경로까지 대칭
* 적용하기 위한 예외입니다. 비-슈퍼관리자 액터는 `admin` 등 코어/확장 소유
* 역할의 권한·활성상태를 변경할 수 없습니다(KVE-2026-1919).
*/
class CannotModifyProtectedRoleException extends Exception
{
/**
* 보호된 역할 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_protected_role'));
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 비-슈퍼관리자 액터가 슈퍼 관리자 계정/역할을 수정하려 할 때 발생하는 예외
*
* 삭제 경로에만 있던 슈퍼 관리자 보호 가드를 수정·상태변경·권한부여 경로까지
* 대칭 적용하기 위한 등급 상한(rank ceiling) 위반 예외입니다.
*/
class CannotModifySuperAdminException extends Exception
{
/**
* 슈퍼 관리자 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_super_admin'));
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 사용자 추가 에셋(`custom/`) 관리 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존해 컨트롤러가 원본 키로 응답할 수 있게 한다. 이미 번역된
* 문장을 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문이 그대로 화면에 나간다.
*
* `TemplateOperationException` 과 같은 형태지만 별도 클래스로 둔다 — 컨트롤러가 이
* 예외만 골라 4xx 로 바꾸기 위해서다. 부모(`RuntimeException`)를 잡으면 인프라 예외까지
* 입력 오류로 위장된다.
*/
class CustomAssetOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'custom_assets.errors.not_found')
* @param array<string, mixed> $params 메시지 파라미터
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
public readonly string $errorKey,
public readonly array $params = [],
?Throwable $previous = null,
) {
parent::__construct(__($errorKey, $params), 0, $previous);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 권한 상승(escalation) 시도 시 발생하는 예외
*
* 비-슈퍼관리자 액터가 역할에 자신이 보유하지 않은 권한, 또는 자신의 범위(scope)보다
* 넓은 범위의 권한을 부여하려 할 때 발생합니다(KVE-2026-1919 권한 상승 차단).
*/
class PermissionEscalationException extends Exception
{
/**
* 권한 상승 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_grant_unheld_permission'));
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
/**
* 부트스트랩 리소스 정적 게시(bake) 실패 예외.
*
* 사용자 대면 예외가 아니다 — `ExtensionStaticCacheService::publishVersion()` 의
* 자체 catch 가 즉시 삼켜 Log::warning 진단으로만 남기고, 사이트는 API 폴백으로
* 정상 동작한다. 따라서 메시지는 다국어 키가 아니라 운영자 로그용 진단 문자열이다.
*/
class StaticCachePublishException extends \RuntimeException {}
@@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected array $storageServices = [];
/**
* 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리).
*
* 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이
* getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가
* 서비스 코드 무수정으로 카테고리 디스크를 따릅니다.
*
* @var array<class-string, string>
*/
protected array $storageCategoryServices = [];
/**
* CacheInterface 가 필요한 서비스 클래스 목록.
*
@@ -111,13 +122,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected function registerStorageBindings(): void
{
if (empty($this->storageServices)) {
return;
if (! empty($this->storageServices)) {
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
}
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
foreach ($this->storageCategoryServices as $service => $category) {
$this->app->when($service)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorageFor($category));
}
}
/**
+122 -5
View File
@@ -9,6 +9,8 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -21,6 +23,8 @@ use ReflectionClass;
*/
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
{
use ReportsLifecycleFailure;
/**
* 모듈 디렉토리 경로 (캐시)
*/
@@ -927,6 +931,31 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
*
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
*
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
*/
public function getTrustedScriptHosts(): array
{
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
if (! is_array($hosts)) {
return [];
}
return array_values(array_filter(
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
fn ($host) => $host !== ''
));
}
/**
* 레이아웃 확장 파일 경로 반환
*
@@ -1276,6 +1305,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return $result;
}
/**
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
*
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
*
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
*/
public function getDeclaredAssetAbsolutePaths(): array
{
$assets = $this->getAssets();
$result = [];
foreach (['js', 'css'] as $kind) {
if (! empty($assets[$kind]['output'])) {
$result[$kind] = $this->getModulePath().'/'.$assets[$kind]['output'];
}
}
return $result;
}
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
@@ -1309,6 +1361,67 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return 'modules';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
private array $storageByDisk = [];
/**
* 카테고리별 스토리지 디스크 이름 반환
*
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다.
*
* 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 —
* 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
* 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return string 디스크 이름
*/
public function getStorageDiskFor(string $category): string
{
return $this->getStorageDisk();
}
/**
* 카테고리별 스토리지 드라이버 인스턴스 반환
*
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
*
* @param string $category 카테고리
* @return StorageInterface 스토리지 드라이버 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
$disk = $this->getStorageDiskFor($category);
if (! isset($this->storageByDisk[$disk])) {
$base = $this->getStorage();
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
}
return $this->storageByDisk[$disk];
}
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
return PublicAssetDisk::resolve($override);
}
/**
* 모듈 캐시 드라이버 인스턴스 반환
*
@@ -1344,26 +1457,30 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
* 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
* 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return string 전체 파일 시스템 경로
*/
public function getStorageBasePath(string $category): string
{
return $this->getStorage()->getBasePath($category);
return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
* 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
* 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
* 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
* @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
return $this->getStorage()->url($category, $path);
return $this->getStorageFor($category)->url($category, $path);
}
}
+124 -6
View File
@@ -9,6 +9,8 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Storage\PluginStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use App\Support\PublicAssetDisk;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -19,10 +21,13 @@ use ReflectionClass;
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
* getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱됩니다.
*
* 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다.
* 참고: 플러그인도 관리자 메뉴(getAdminMenus)를 선언할 수 있습니다. 설치·업데이트·활성화가
* 공통으로 지나는 선언형 산출물 동기화에서 PluginManager 가 자동으로 반영합니다.
*/
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{
use ReportsLifecycleFailure;
/**
* 플러그인 디렉토리 경로 (캐시)
*/
@@ -771,6 +776,31 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return [];
}
/**
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
*
* plugin.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 플러그인이 레이아웃
* `scripts[].src` 로 로드하는 외부 CDN 호스트(예: `cdn.ckeditor.com`)를 선언합니다.
* 코어는 이 목록을 집계(AbstractPlugin/AbstractModule → TrustedScriptHosts)해 런타임
* 스크립트 로더·저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된
* 호스트만** 허용하도록 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
*
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.ckeditor.com'])
*/
public function getTrustedScriptHosts(): array
{
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
if (! is_array($hosts)) {
return [];
}
return array_values(array_filter(
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
fn ($host) => $host !== ''
));
}
/**
* 레이아웃 확장 파일 경로 반환
*
@@ -1097,6 +1127,29 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return $result;
}
/**
* 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관).
*
* `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서
* 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면
* 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다.
*
* @return array<string, string> kind('js'|'css') => 절대 경로 (선언된 kind 만)
*/
public function getDeclaredAssetAbsolutePaths(): array
{
$assets = $this->getAssets();
$result = [];
foreach (['js', 'css'] as $kind) {
if (! empty($assets[$kind]['output'])) {
$result[$kind] = $this->getPluginPath().'/'.$assets[$kind]['output'];
}
}
return $result;
}
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
@@ -1203,6 +1256,67 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return 'plugins';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
private array $storageByDisk = [];
/**
* 카테고리별 스토리지 디스크 이름 반환
*
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 플러그인이 오버라이드합니다.
*
* 주의: 오버라이드 구현은 'settings' 카테고리에서 플러그인 설정을 조회하면 안 됩니다 —
* 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
* 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
*
* @param string $category 카테고리 (settings, data, images, temp)
* @return string 디스크 이름
*/
public function getStorageDiskFor(string $category): string
{
return $this->getStorageDisk();
}
/**
* 카테고리별 스토리지 드라이버 인스턴스 반환
*
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
*
* @param string $category 카테고리
* @return StorageInterface 스토리지 드라이버 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
$disk = $this->getStorageDiskFor($category);
if (! isset($this->storageByDisk[$disk])) {
$base = $this->getStorage();
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
}
return $this->storageByDisk[$disk];
}
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
return PublicAssetDisk::resolve($override);
}
/**
* 플러그인 캐시 드라이버 인스턴스 반환
*
@@ -1238,26 +1352,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
/**
* 카테고리별 스토리지 기본 경로 반환
*
* 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크
* 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다.
*
* @param string $category 카테고리 (settings, data, temp)
* @return string 전체 파일 시스템 경로
*/
public function getStorageBasePath(string $category): string
{
return $this->getStorage()->getBasePath($category);
return $this->getStorageFor($category)->getBasePath($category);
}
/**
* 파일의 공개 URL 반환
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
* 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을,
* 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면
* 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
* @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null)
*/
public function getStorageUrl(string $category, string $path): ?string
{
return $this->getStorage()->url($category, $path);
return $this->getStorageFor($category)->url($category, $path);
}
}
@@ -139,4 +139,17 @@ final class InlineModuleExtensionAdapter implements CacheableExtensionInterface
{
return $this->storage ??= new ModuleStorageDriver($this->identifier);
}
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* fallback 어댑터는 모듈 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
*
* @param string $category 카테고리
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
return $this->getStorage();
}
}
@@ -130,4 +130,17 @@ final class InlinePluginExtensionAdapter implements CacheableExtensionInterface
{
return $this->storage ??= new PluginStorageDriver($this->identifier);
}
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* fallback 어댑터는 플러그인 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
*
* @param string $category 카테고리
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
return $this->getStorage();
}
}
+113 -29
View File
@@ -3,6 +3,7 @@
namespace App\Extension\Cache;
use App\Contracts\Extension\CacheInterface;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
@@ -48,15 +49,15 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
public function resolveKey(string $key): string
{
return $this->getPrefix() . ':' . $key;
return $this->getPrefix().':'.$key;
}
/**
* Laravel Cache 스토어 인스턴스를 반환합니다.
*
* @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스
* @return Repository 캐시 스토어 인스턴스
*/
protected function store(): \Illuminate\Cache\Repository
protected function store(): Repository
{
return Cache::store($this->store);
}
@@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolvedKey = $this->resolveKey($key);
$ttl = $ttl ?? $this->getDefaultTtl();
return $this->store()->put($resolvedKey, $value, $ttl);
try {
return $this->store()->put($resolvedKey, $value, $ttl);
} catch (\Throwable $e) {
$this->warnWriteFailure('put', $resolvedKey, $e);
return false;
}
}
/**
@@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
public function forget(string $key): bool
{
return $this->store()->forget($this->resolveKey($key));
try {
return $this->store()->forget($this->resolveKey($key));
} catch (\Throwable $e) {
$this->warnWriteFailure('forget', $this->resolveKey($key), $e);
return false;
}
}
// === Remember 패턴 ===
@@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
$result = $this->store()->remember($resolvedKey, $ttl, $callback);
$this->recordKeyTags($resolvedKey, $allTags);
//
// 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다.
// Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유:
// put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지).
// 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든
// remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10).
$cached = $this->store()->get($resolvedKey);
if ($cached !== null) {
return $cached;
}
$result = $callback();
try {
$this->store()->put($resolvedKey, $result, $ttl);
$this->recordKeyTags($resolvedKey, $allTags);
} catch (\Throwable $e) {
$this->warnWriteFailure('remember', $resolvedKey, $e);
}
return $result;
}
@@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
{
return $this->remember('query:' . $queryHash, $callback, $ttl, $tags);
return $this->remember('query:'.$queryHash, $callback, $ttl, $tags);
}
// === 벌크 연산 ===
@@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolved[$this->resolveKey($key)] = $value;
}
return $this->store()->putMany($resolved, $ttl);
try {
return $this->store()->putMany($resolved, $ttl);
} catch (\Throwable $e) {
$this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e);
return false;
}
}
// === 무효화 ===
@@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface
// === 키 인덱스 (태그 미지원 드라이버용) ===
/**
* 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft).
*
* 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례:
* sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든
* 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다
* (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다).
*
* 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다.
*
* @param string $operation 실패한 연산 (put/forget/putMany/remember/index)
* @param string $key 대상 키 (진단용)
* @param \Throwable $e 원인 예외
*/
protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void
{
static $warned = [];
$signature = $operation.'|'.$e->getMessage();
if (isset($warned[$signature])) {
return;
}
$warned[$signature] = true;
try {
Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [
'operation' => $operation,
'key' => $key,
'store' => $this->store,
'error' => $e->getMessage(),
]);
} catch (\Throwable) {
// 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속
}
}
/**
* 키-태그 매핑을 인덱스에 기록합니다.
*
@@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function flushByIndex(): bool
{
$indexKey = $this->getIndexKey();
$index = $this->store()->get($indexKey, []);
try {
$indexKey = $this->getIndexKey();
$index = $this->store()->get($indexKey, []);
foreach (array_keys($index) as $key) {
$this->store()->forget($key);
foreach (array_keys($index) as $key) {
$this->store()->forget($key);
}
$this->store()->forget($indexKey);
return true;
} catch (\Throwable $e) {
$this->warnWriteFailure('flush', $this->getIndexKey(), $e);
return false;
}
$this->store()->forget($indexKey);
return true;
}
/**
@@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function flushTagsByIndex(array $tags): bool
{
$indexKey = $this->getIndexKey();
$index = $this->store()->get($indexKey, []);
$tagsSet = array_flip($tags);
try {
$indexKey = $this->getIndexKey();
$index = $this->store()->get($indexKey, []);
$tagsSet = array_flip($tags);
foreach ($index as $key => $keyTags) {
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
$this->store()->forget($key);
unset($index[$key]);
foreach ($index as $key => $keyTags) {
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
$this->store()->forget($key);
unset($index[$key]);
}
}
$this->store()->put($indexKey, $index, 86400 * 30);
return true;
} catch (\Throwable $e) {
$this->warnWriteFailure('flushTags', implode(',', $tags), $e);
return false;
}
$this->store()->put($indexKey, $index, 86400 * 30);
return true;
}
/**
@@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface
*/
private function getIndexKey(): string
{
return 'g7:_idx:' . $this->getPrefix();
return 'g7:_idx:'.$this->getPrefix();
}
}
+18 -11
View File
@@ -5,6 +5,7 @@ namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Exceptions\CoreVersionMismatchException;
use App\Extension\Cache\CoreCacheDriver;
use App\Support\CoreUpdateContext;
use Composer\Semver\Semver;
use Exception;
use Illuminate\Support\Facades\Log;
@@ -31,10 +32,11 @@ class CoreVersionChecker
* 현재 설치된 그누보드7 코어 버전 반환
*
* 반환 우선순위:
* 1. 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
* 1. 코어 업데이트 프로세스 트리 안(`CoreUpdateContext::isInProgress()`)이면
* 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
* 2. `config('app.version')`
*
* 왜 env 를 우선 읽는가:
* 왜 업데이트 트리 안에서만 env 를 우선 읽는가:
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
@@ -44,23 +46,28 @@ class CoreVersionChecker
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
*
* env 를 우선 읽는 본 구현은 config cache 유무와 무관하게 spawn 이 전달한 버전을 그대로
* 신뢰한다. 일반 요청 경로에서는 `APP_VERSION` 이 `.env` 에 기록된 값 그대로이므로
* 동작에 차이가 없다.
* 왜 트리 밖에서는 env 를 읽지 않는가:
* `php artisan serve` · 큐 워커 · Horizon 처럼 오래 사는 프로세스는 기동 시점의
* `APP_VERSION` 을 프로세스 환경 테이블에 물고 있다. 업데이트가 끝나 `.env` 와 config 가
* 새 버전이 되어도 그 프로세스만 옛 버전으로 판정해, 새 코어를 요구하는 확장을
* `incompatible_core` 로 자동 비활성화한다 (관리자 템플릿이 꺼지면 복구 UI 에도 도달할 수
* 없다 — 2026-09-07 실측). 업데이트 트리 밖에서는 config 가 유일한 근거다.
*
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
* config cache 우회를 요구하기 때문이다.
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 업데이트 중
* 버전 판정이 config cache 우회를 요구하기 때문이다.
*
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
*/
public static function getCoreVersion(): string
{
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
if (is_string($envVersion) && $envVersion !== '') {
return $envVersion;
if (CoreUpdateContext::isInProgress()) {
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
if (is_string($envVersion) && $envVersion !== '') {
return $envVersion;
}
}
return config('app.version');
return (string) config('app.version');
}
/**
+5 -1
View File
@@ -74,8 +74,12 @@ class ExtensionManager
*
* 모듈/플러그인 리스너 수집을 위해 각 Manager 를 (재)로드한 뒤 HookCacheManager 에 위임한다.
* 생성 실패는 부팅 시 스캔 폴백으로 흡수되므로 확장 업데이트 흐름을 중단시키지 않는다.
*
* 확장 수명주기에서 상태를 되돌린 뒤 다시 부를 수 있도록 public 이다 —
* Updating 창 안에서 구워진 훅 캐시는 그 확장의 리스너가 빠진 채 남고,
* 훅 캐시 폴백은 파일 부재/손상에만 작동해 stale 한 내용은 조용히 통과하기 때문이다.
*/
protected function regenerateHookCache(): void
public function regenerateHookCache(): void
{
try {
$moduleManager = app(ModuleManager::class);
@@ -0,0 +1,54 @@
<?php
namespace App\Extension\Helpers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 실패한 설치가 남긴 활성 디렉토리를 되돌리는 헬퍼
*
* 설치 흐름은 원본(`_pending`/`_bundled`)을 활성 디렉토리로 **먼저 복사한 뒤** 확장을
* 로드해 코어 버전·의존성·언어 경로 등을 검증한다. 검증에 로드된 확장 인스턴스가 필요해
* 순서를 뒤집을 수 없는데, 그 검증이 실패하면 방금 만든 활성 디렉토리가 그대로 남는다.
*
* 남은 디렉토리는 DB 행이 없어 목록에도 뜨지 않고 번들 병합에도 참여하지 않는다 — 즉
* 오류도 경고도 없이 디스크만 점유하는 고아가 된다. 더 나쁜 것은 다음 설치 시도가 그
* 디렉토리를 "이미 있는 설치본" 으로 보고 원본 복사를 건너뛸 수 있다는 점이다.
*
* 이 헬퍼는 **이번 호출이 만든 디렉토리만** 지운다. 이미 설치돼 있던 확장을 `--force` 로
* 다시 설치하다 실패한 경우에는 운영자의 기존 설치본이므로 손대지 않는다.
*/
final class ExtensionInstallRollbackHelper
{
/**
* 이번 설치가 만든 활성 디렉토리를 제거합니다.
*
* @param string $activePath 활성 디렉토리 절대경로
* @param bool $existedBefore 이번 설치 이전에 그 디렉토리가 있었는지
* @param string $identifier 확장 식별자 (로그용)
* @param string $type 확장 유형 (module|plugin|template, 로그용)
* @return bool 실제로 제거했으면 true
*/
public static function removeIfCreatedByThisInstall(
string $activePath,
bool $existedBefore,
string $identifier,
string $type,
): bool {
if ($existedBefore || ! File::isDirectory($activePath)) {
return false;
}
$removed = File::deleteDirectory($activePath);
Log::warning('설치 실패로 활성 디렉토리를 되돌렸습니다.', [
'type' => $type,
'identifier' => $identifier,
'path' => $activePath,
'removed' => $removed,
]);
return $removed;
}
}
+519 -64
View File
@@ -21,6 +21,21 @@ class ExtensionPendingHelper
'node_modules',
];
/**
* 확장 교체 시 보존할 최상위 디렉토리명 목록
*
* `custom/` 은 운영자가 자기 CSS·JS·정적 파일을 두는 자리다. 확장이 소유한 것이
* 아니므로 새 배포본으로 덮어써서는 안 된다 — 덮어쓰면 업데이트할 때마다 운영자가
* 넣은 파일이 사라지고, 그 사실이 어디에도 남지 않는다(파일이 조용히 없어질 뿐이다).
*
* 보존은 **교체 경로 둘 다**에서 성립해야 한다: 디렉토리 rename 경로와, 하위 트리에
* 열린 핸들이 있을 때의 제자리 동기화 폴백. 한쪽만 고치면 Windows 잠금 상황에서만
* 조용히 사라진다.
*/
public const PRESERVED_DIRECTORIES = [
'custom',
];
/**
* _pending 또는 _bundled 디렉토리에서 확장 메타데이터를 로드합니다.
*
@@ -141,16 +156,30 @@ class ExtensionPendingHelper
// Windows에서 deleteDirectory 직후 같은 이름으로 rename이 실패하는
// 타이밍 이슈를 회피하기 위해 rename→rename→delete 패턴을 사용합니다.
// 임시 디렉토리를 _pending/ 하위에 생성하여 오토로드 오염 방지
//
// Windows 잠금 대응: 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의
// 디렉토리 핸들, IDE/Node 프로세스가 열어 둔 파일 등)이 하나라도 있으면
// 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없으므로(디렉토리 핸들은
// Restart Manager 로 감지 불가), rename 이 차단되면 파일 단위 연산으로
// 폴백한다 — 파일 생성/덮어쓰기/읽기는 디렉토리 핸들 잠금의 영향을 받지
// 않아 어떤 프로세스도 종료하지 않고 교체를 완료할 수 있다.
$basePath = dirname($targetPath);
$pendingPath = $basePath.DIRECTORY_SEPARATOR.'_pending';
File::ensureDirectoryExists($pendingPath, 0775);
$identifier = basename($targetPath);
// 이전 실패 실행이 남긴 교체용 임시 디렉토리 정리 (best-effort)
self::cleanupSwapLeftovers($pendingPath, $identifier);
$tempPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_updating_'.uniqid();
$oldPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_old_'.uniqid();
try {
self::copyDirectoryWithProgress($sourcePath, $tempPath, $sourcePath, $onProgress);
// 운영자 소유 디렉토리를 새 트리로 옮겨 심는다 (교체 전에 해 둬야 원본이 살아 있다)
self::carryOverPreservedDirectories($targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패 시 임시 디렉토리 정리 후 예외 전파
if (File::isDirectory($tempPath)) {
@@ -161,40 +190,77 @@ class ExtensionPendingHelper
// 기존 → _old 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
// 파일 잠금 감지 및 해제 시도
if (self::tryReleaseLocks($targetPath, $onProgress)) {
// 잠금 해제 후 재시도
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
}
} else {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
// 활성 디렉토리 rename 차단 (하위 트리에 열린 핸들 존재)
// → 파일 단위 제자리 동기화로 폴백. 덮어쓰기는 잠금의 영향을 받지 않는다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 제자리 교체로 전환합니다...');
Log::info('확장 교체: 활성 디렉토리 rename 차단 — 제자리 동기화 폴백', [
'target' => $targetPath,
]);
try {
self::syncDirectoryContents($tempPath, $targetPath, $onProgress);
} finally {
self::bestEffortDeleteDirectory($tempPath);
}
self::refreshRuntimeCaches();
return;
}
// 임시 → 활성 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($tempPath, $targetPath)) {
// 롤백: _old를 원래 위치로 복원
File::moveDirectory($oldPath, $targetPath);
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}"
);
// 방금 복사한 스테이징 트리를 파일 워처가 이미 열어 rename 이 차단된 경우
// → 파일 단위 복사로 폴백. 소스에는 읽기 접근만 필요해 잠금과 무관하게 성공한다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 복사로 전환합니다...');
Log::info('확장 교체: 스테이징 rename 차단 — 파일 단위 복사 폴백', [
'staging' => $tempPath,
'target' => $targetPath,
]);
try {
self::copyDirectoryWithProgress($tempPath, $targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패: 부분 복사본 제거 후 _old 를 원래 위치로 복원
self::bestEffortDeleteDirectory($targetPath);
if (! self::retryMoveDirectory($oldPath, $targetPath)) {
try {
self::syncDirectoryContents($oldPath, $targetPath, $onProgress);
} catch (\Throwable $restoreError) {
Log::error('확장 교체 롤백 실패 — 백업 복원이 필요합니다', [
'target' => $targetPath,
'old' => $oldPath,
'error' => $restoreError->getMessage(),
]);
}
}
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}",
0,
$e
);
}
self::bestEffortDeleteDirectory($tempPath);
}
// 교체 완료 후 _old 삭제 (실패해도 무해)
File::deleteDirectory($oldPath);
// 교체 완료 후 _old 삭제 (실패해도 무해 — 다음 교체 시작 시 잔존물 정리가 재시도)
self::bestEffortDeleteDirectory($oldPath);
// 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
// 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
// PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
// 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
// clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
self::refreshRuntimeCaches();
}
/**
* 교체 완료 후 PHP 런타임 캐시를 갱신합니다.
*
* 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
* 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
* PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
* 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
* clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
*/
private static function refreshRuntimeCaches(): void
{
clearstatcache(true);
// opcache 가 활성화된 프로덕션에서는 활성 디렉토리 하위의 이전 컴파일 바이트코드가
@@ -237,41 +303,6 @@ class ExtensionPendingHelper
]);
}
/**
* 파일 잠금을 감지하고 해제를 시도합니다.
*
* Windows에서 다른 프로세스(IDE 등)가 파일 핸들을 보유하고 있을 때,
* 해당 프로세스를 감지하고 종료하여 디렉토리 이동이 가능하도록 합니다.
* 프로그레스바와 별개로 STDERR에 직접 출력하여 메시지가 덮어씌워지지 않습니다.
*
* @param string $directoryPath 잠금 해제할 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
* @return bool 잠금 해제 성공 여부
*/
private static function tryReleaseLocks(string $directoryPath, ?\Closure $onProgress = null): bool
{
if (! FileHandleHelper::isWindows()) {
return false;
}
// 프로그레스바에 의해 메시지가 덮어씌워지지 않도록 STDERR에 직접 출력
$stderr = fopen('php://stderr', 'w');
$outputCallback = function (string $message) use ($stderr) {
if ($stderr) {
fwrite($stderr, $message.PHP_EOL);
}
Log::info($message, ['context' => 'file_lock_release']);
};
$result = FileHandleHelper::releaseLocks($directoryPath, $outputCallback);
if ($stderr) {
fclose($stderr);
}
return $result;
}
/**
* 디렉토리 이동을 재시도합니다 (Windows NTFS 타이밍 이슈 대응).
*
@@ -313,6 +344,11 @@ class ExtensionPendingHelper
?\Closure $onProgress = null
): void {
File::ensureDirectoryExists($dest, 0775);
// 디렉토리도 부모 소유권을 상속시킨다 — 파일만 `copyFile` 로 상속시키면 sudo 로
// 실행된 설치/업데이트가 만든 **디렉토리**가 root 소유로 남아, 이후 웹 프로세스의
// 쓰기가 그 디렉토리에서 막힌다. 형제 구현 `ExtensionBackupHelper::
// copyDirectoryWithProgress` 는 이미 같은 방어를 갖고 있다 (계층 불균형 해소).
FilePermissionHelper::inheritOwnershipFromParent($dest);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
@@ -333,19 +369,409 @@ class ExtensionPendingHelper
}
}
/**
* 새 버전 콘텐츠를 활성 디렉토리에 파일 단위로 제자리 동기화합니다.
*
* 디렉토리 rename 이 외부 프로세스의 핸들 잠금으로 차단될 때 사용하는 폴백입니다.
* 디렉토리 inode 를 건드리지 않고 파일 생성/덮어쓰기/삭제만 수행하므로
* 디렉토리 핸들 잠금(파일 워처 등)의 영향을 받지 않습니다.
*
* (1) 새 버전 파일 전체를 덮어쓰기 → (2) 새 버전에 없는 잔존 파일 제거.
* (1) 실패는 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하게 하고,
* (2) 실패는 로그만 남기고 진행합니다 (업데이트 전체 실패보다 잔존이 낫다).
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*
* @throws \RuntimeException 새 버전 파일을 심지 못했을 때
*/
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
{
File::ensureDirectoryExists($dest, 0775);
FilePermissionHelper::inheritOwnershipFromParent($dest);
$failed = [];
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
if (! empty($failed)) {
throw new \RuntimeException(
'Failed to replace files locked by another process: '
.implode(', ', array_slice($failed, 0, 5))
.(count($failed) > 5 ? ' (+'.(count($failed) - 5).' more)' : '')
);
}
$staleFailures = [];
self::removeStaleEntries($source, $dest, $staleFailures, true);
if (! empty($staleFailures)) {
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
'dest' => $dest,
'failed' => $staleFailures,
]);
}
}
/**
* 운영자 소유 디렉토리를 기존 활성 디렉토리에서 새 트리로 옮겨 심습니다.
*
* 새 배포본에 같은 이름의 디렉토리가 있으면 **그것을 치우고** 기존 것을 심는다 —
* `custom/` 은 확장이 소유하지 않는 자리이므로, 확장이 그 자리에 무언가를 담아
* 배포했더라도 운영자 파일이 우선한다(그런 배포 자체를 정적 검사가 막는다).
*
* @param string $existingPath 기존 활성 디렉토리
* @param string $stagingPath 새 배포본이 복사된 임시 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*/
private static function carryOverPreservedDirectories(
string $existingPath,
string $stagingPath,
?\Closure $onProgress = null
): void {
foreach (self::PRESERVED_DIRECTORIES as $name) {
$from = $existingPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($from)) {
continue;
}
$to = $stagingPath.DIRECTORY_SEPARATOR.$name;
if (File::isDirectory($to)) {
File::deleteDirectory($to);
}
$onProgress?->__invoke(null, "운영자 파일 보존: {$name}/");
self::copyDirectoryWithProgress($from, $to, $from, $onProgress);
}
}
/**
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
*
* @param string $source 소스 디렉토리
* @param string $dest 대상 디렉토리
* @param string $basePath 상대 경로 계산 기준
* @param \Closure|null $onProgress 진행 콜백
* @param array $failed 교체 실패한 상대 경로 수집 (참조)
*/
private static function overlayDirectory(
string $source,
string $dest,
string $basePath,
?\Closure $onProgress,
array &$failed
): void {
File::ensureDirectoryExists($dest, 0775);
// 제자리 동기화 폴백 경로도 동일 방어 — rename 경로만 고치면 파일 잠금으로
// 이 경로로 떨어진 교체에서만 소유권이 조용히 어긋난다.
FilePermissionHelper::inheritOwnershipFromParent($dest);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
if ($item->isDir() && in_array($item->getBasename(), self::EXCLUDED_DIRECTORIES, true)) {
continue;
}
$target = $dest.DIRECTORY_SEPARATOR.$item->getBasename();
$relativePath = ltrim(str_replace($basePath, '', $item->getPathname()), '/\\');
if ($item->isDir()) {
if (is_file($target) && ! self::deleteFileWithFallback($target)) {
// 파일 → 디렉토리로 바뀐 경로인데 기존 파일을 치우지 못함
$failed[] = $relativePath;
continue;
}
self::overlayDirectory($item->getPathname(), $target, $basePath, $onProgress, $failed);
} else {
if (is_dir($target)) {
// 디렉토리 → 파일로 바뀐 경로
File::deleteDirectory($target);
}
$onProgress?->__invoke(null, $relativePath);
if (! self::replaceFile($item->getPathname(), $target)) {
$failed[] = $relativePath;
}
}
}
}
/**
* 파일 하나를 덮어씁니다 (잠금 대응 단계적 폴백).
*
* Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용하므로,
* 다른 프로세스가 열어 둔 파일이라도 내용 덮어쓰기는 대부분 성공합니다.
* 덮어쓰기가 막힌 경우에만 삭제 후 재생성 → 옆으로 치우기(rename) 순으로
* 시도합니다. FilePermissionHelper::copyFile() 은 복사 실패를 보고하지 않으므로
* (File::copy 반환값 무시) 이 경로에서는 네이티브 copy 반환값으로 판정합니다.
*
* @param string $source 소스 파일
* @param string $destination 대상 파일
* @return bool 교체 성공 여부
*/
private static function replaceFile(string $source, string $destination): bool
{
File::ensureDirectoryExists(dirname($destination));
$isExisting = is_file($destination);
$existingPerms = $isExisting ? @fileperms($destination) : null;
$existingOwner = $isExisting ? @fileowner($destination) : null;
$existingGroup = $isExisting ? @filegroup($destination) : null;
$restoreMeta = function () use ($destination, $isExisting, $existingPerms, $existingOwner, $existingGroup) {
if (! $isExisting) {
// 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (sudo 실행 시 root 소유 방지)
FilePermissionHelper::inheritOwnershipFromParent($destination);
return;
}
if ($existingPerms !== false && $existingPerms !== null) {
@chmod($destination, $existingPerms);
}
if ($existingOwner !== false && $existingOwner !== null && function_exists('chown')) {
@chown($destination, $existingOwner);
}
if ($existingGroup !== false && $existingGroup !== null && function_exists('chgrp')) {
@chgrp($destination, $existingGroup);
}
};
// 1차: 그대로 덮어쓰기
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 2차: 읽기 전용 속성 해제 후 재시도
@chmod($destination, 0666);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 3차: 삭제 후 재생성
if (@unlink($destination) && @copy($source, $destination)) {
$restoreMeta();
return true;
}
// 4차: 잠긴 파일을 옆으로 치우고(rename 은 덮어쓰기와 별개 권한) 새 파일 생성.
// 옆으로 치운 파일은 즉시 삭제를 시도하고, 실패해도 새 버전에 없는 파일이므로
// 다음 제자리 동기화의 잔존 파일 제거가 다시 삭제를 시도한다.
$aside = $destination.'.g7stale_'.uniqid();
if (@rename($destination, $aside)) {
@unlink($aside);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
}
return false;
}
/**
* 파일 하나를 삭제합니다 (잠금 대응 단계적 폴백).
*
* @param string $path 삭제할 파일 경로
* @return bool 삭제(또는 옆으로 치우기) 성공 여부
*/
private static function deleteFileWithFallback(string $path): bool
{
if (@unlink($path)) {
return true;
}
@chmod($path, 0666);
if (@unlink($path)) {
return true;
}
// 삭제가 막힌 경우 옆으로 치워 원래 이름을 비운다
$aside = $path.'.g7stale_'.uniqid();
if (@rename($path, $aside)) {
@unlink($aside);
return true;
}
return false;
}
/**
* 대상 디렉토리에서 소스에 존재하지 않는 파일/디렉토리를 제거합니다.
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param array $failures 삭제 실패 경로 수집 (참조)
*/
private static function removeStaleEntries(string $source, string $dest, array &$failures, bool $isRoot = false): void
{
if (! is_dir($dest)) {
return;
}
$items = new \FilesystemIterator($dest, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
// 운영자 소유 디렉토리는 소스에 없어도 정리 대상이 아니다 (확장 루트에서만 판정)
if ($isRoot && $item->isDir() && in_array($item->getBasename(), self::PRESERVED_DIRECTORIES, true)) {
continue;
}
$counterpart = $source.DIRECTORY_SEPARATOR.$item->getBasename();
if ($item->isDir()) {
if (is_dir($counterpart)) {
self::removeStaleEntries($counterpart, $item->getPathname(), $failures);
} else {
File::deleteDirectory($item->getPathname());
if (is_dir($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
} elseif (! is_file($counterpart)) {
if (! self::deleteFileWithFallback($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
}
}
/**
* 이전 실패 실행이 남긴 교체용 임시 디렉토리를 정리합니다 (best-effort).
*
* 잠금으로 인해 삭제하지 못한 `_updating_`/`_old_` 디렉토리는 다음 교체 시작
* 시점(잠금이 풀린 뒤일 가능성이 높음)에 다시 삭제를 시도합니다.
* 호출자가 소유한 스테이징 디렉토리(`{identifier}_{Ymd_His}`)는 건드리지 않습니다.
*
* @param string $pendingPath _pending 디렉토리 경로
* @param string $identifier 확장 식별자
*/
private static function cleanupSwapLeftovers(string $pendingPath, string $identifier): void
{
foreach (['_updating_', '_old_'] as $marker) {
$pattern = $pendingPath.DIRECTORY_SEPARATOR.$identifier.$marker.'*';
foreach (glob($pattern) ?: [] as $leftover) {
if (is_dir($leftover)) {
self::bestEffortDeleteDirectory($leftover);
}
}
}
}
/**
* 디렉토리를 삭제하되, 실패해도 예외를 던지지 않습니다.
*
* 잠금으로 삭제하지 못한 디렉토리는 다음 교체 시작 시 잔존물 정리가 재시도합니다.
*
* @param string $path 삭제할 디렉토리 경로
*/
private static function bestEffortDeleteDirectory(string $path): void
{
if (! File::isDirectory($path)) {
return;
}
File::deleteDirectory($path);
if (File::isDirectory($path)) {
Log::info('디렉토리를 완전히 삭제하지 못했습니다 (다음 교체 시 잔존물 정리에서 재시도)', [
'path' => $path,
]);
}
}
/**
* 확장 디렉토리를 삭제합니다.
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관된 운영자 디렉토리 목록
*/
public static function deleteExtensionDirectory(string $basePath, string $identifier): void
public static function deleteExtensionDirectory(string $basePath, string $identifier): array
{
$targetPath = $basePath.DIRECTORY_SEPARATOR.$identifier;
if (File::isDirectory($targetPath)) {
File::deleteDirectory($targetPath);
if (! File::isDirectory($targetPath)) {
return [];
}
$archived = self::archivePreservedDirectories($targetPath, $identifier);
File::deleteDirectory($targetPath);
return $archived;
}
/**
* 삭제 전에 운영자 소유 디렉토리를 보관합니다.
*
* 확장을 삭제하면 그 안의 `custom/` 도 함께 사라진다 — 운영자가 넣은 파일이므로
* 되돌릴 방법 없이 없어지면 안 된다. 교체(업데이트)는 보존이 답이지만 삭제는
* "확장을 없앤다" 는 명시적 의사이므로 막지 않고, 대신 사본을 남기고 그 사실을
* 기록한다.
*
* 보관 실패가 삭제를 막지는 않는다 — 삭제는 운영자가 요청한 동작이다.
*
* @param string $targetPath 삭제 대상 확장 디렉토리
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관에 성공한 디렉토리 목록
*/
private static function archivePreservedDirectories(string $targetPath, string $identifier): array
{
$archived = [];
foreach (self::PRESERVED_DIRECTORIES as $name) {
$source = $targetPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($source) || self::isEmptyDirectory($source)) {
continue;
}
$archivePath = storage_path(
'app'.DIRECTORY_SEPARATOR.'extension-custom-backups'
.DIRECTORY_SEPARATOR.$identifier.'-'.date('Ymd_His')
.DIRECTORY_SEPARATOR.$name
);
try {
self::copyDirectoryWithProgress($source, $archivePath, $source, null);
Log::info('확장 삭제: 운영자 파일을 보관했습니다', [
'identifier' => $identifier,
'directory' => $name,
'archive' => $archivePath,
]);
$archived[] = ['directory' => $name, 'archive' => $archivePath];
} catch (\Throwable $e) {
Log::warning('확장 삭제: 운영자 파일 보관에 실패했습니다 (삭제는 계속합니다)', [
'identifier' => $identifier,
'directory' => $name,
'error' => $e->getMessage(),
]);
}
}
return $archived;
}
/**
* 디렉토리가 비어 있는지 판정합니다.
*
* @param string $path 대상 디렉토리
* @return bool 비어 있으면 true
*/
private static function isEmptyDirectory(string $path): bool
{
return ! (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS))->valid();
}
/**
@@ -353,6 +779,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _pending 존재 여부
*/
public static function isPending(string $basePath, string $identifier): bool
{
@@ -364,6 +791,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _bundled 존재 여부
*/
public static function isBundled(string $basePath, string $identifier): bool
{
@@ -375,6 +803,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _pending 절대 경로
*/
public static function getPendingPath(string $basePath, string $identifier): string
{
@@ -386,12 +815,38 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _bundled 절대 경로
*/
public static function getBundledPath(string $basePath, string $identifier): string
{
return $basePath.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.$identifier;
}
/**
* 확장 업데이트용 임시 디렉토리(다운로드·추출)를 부모 소유권까지 정합화해 확보합니다.
*
* 세 매니저(모듈·플러그인·템플릿)가 같은 경로 규약(`storage/app/temp/{type}_update_{uid}`)을 쓰므로
* 확보 절차도 한 곳에 둔다 — 복사본은 서로 다른 하드닝을 갖고 갈라진다.
*
* @param string $tempDir 임시 디렉토리 절대 경로
*/
public static function ensureUpdateTempDirectory(string $tempDir): void
{
// 부모(`storage/app/temp`)는 최초 1회만 만들어지고 자식만 삭제된다 — sudo 코어 업데이트의
// 번들 확장 업데이트가 그 최초 생성자면 부모가 root 소유로 굳어, 이후 관리자 화면(웹 계정)의
// 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14). 부모는 소유권 상속·그룹 쓰기까지
// 정합화하는 프리미티브로 확보하고, 자식은 만든 뒤 부모 소유권을 상속시킨다.
// 확보 실패는 종전 동작(`ensureDirectoryExists` 의 예외 흐름)으로 폴백해 계약을 바꾸지 않는다.
if (! FilePermissionHelper::ensureWritableDirectory(dirname($tempDir))) {
File::ensureDirectoryExists($tempDir);
return;
}
File::ensureDirectoryExists($tempDir);
FilePermissionHelper::inheritOwnershipFromParent($tempDir);
}
/**
* 업데이트 스테이징용 타임스탬프 디렉토리를 생성합니다.
*
@@ -387,6 +387,122 @@ class FilePermissionHelper
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
}
/**
* 이미 존재하는 디렉토리의 퍼미션·소유권을 umask 와 무관하게 정합화합니다.
*
* `File::ensureDirectoryExists($dir, 0775)` 등 생성 API 의 mode 인자는 **umask 로 깎인다** —
* umask 022 환경에서는 0775 요청이 0755 로 만들어져 그룹 공유(웹 계정)가 쓸 수 없다.
* 명시 `chmod` 로 umask 를 무력화하고, POSIX 에서는 setgid 를 세워 그 아래에 만들어지는
* 하위 디렉토리가 그룹을 상속하게 한다. setgid 가 없으면 CLI(스케줄러/큐)가 먼저 만든
* 하위 디렉토리를 웹 프로세스가 쓰지 못한다. Windows 에는 setgid 개념이 없어 제외한다.
*
* 마지막으로 부모 소유권을 상속시켜 sudo/CLI 계정 고정을 막는다.
*
* @param string $path 대상 디렉토리 절대 경로
* @param int $mode 적용할 퍼미션 (예: 0775)
*/
public static function hardenDirectory(string $path, int $mode = 0775): void
{
@chmod($path, $mode);
if (PHP_OS_FAMILY !== 'Windows') {
@chmod($path, $mode | 02000);
}
static::inheritOwnershipFromParent($path);
}
/**
* 쓰기 가능한 디렉토리를 확보합니다 — 없으면 만들고, 권한을 정합화한 뒤 실제 쓰기 가능 여부를 판정합니다.
*
* 제3자 라이브러리에 넘길 캐시·임시 디렉토리처럼 "확보하지 못하면 그 기능만 끄면 되는"
* 자리를 위한 프리미티브다. **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()`
* 는 `mkdir()` 을 억제 없이 호출하므로 생성 실패가 `E_WARNING` 으로 나오고 Laravel
* `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 쓰기 경로를
* 지정하는 목적 자체가 그 500 을 막는 것이므로, 확보 실패가 다시 500 을 내면 무의미하다.
*
* 실패 정책은 호출부가 정한다 — 조용히 성능 저하로 이어갈지(정의 캐시), 시끄럽게 실패로
* 처리할지(정적 게시)가 자리마다 다르기 때문이다. 사유는 `$failure` out 파라미터로 올린다.
*
* @param string $path 확보할 디렉토리 절대 경로
* @param int $mode 생성 시 적용할 퍼미션
* @param array{reason: string, path: string}|null $failure out — 실패 사유와 그 대상 경로.
* reason 은 `occupied_by_file`(경로가 파일로 점유) /
* `ancestor_not_writable`(실재하는 최근접 상위가 쓰기 불가) /
* `create_failed`(생성 실패) / `not_writable`(존재하나 쓰기 불가)
* @return bool 확보 성공 여부
*/
public static function ensureWritableDirectory(string $path, int $mode = 0775, ?array &$failure = null): bool
{
$failure = null;
if (! is_dir($path)) {
// 같은 이름의 파일이 자리를 차지하면 mkdir 이 경고를 낸다 — 먼저 걸러낸다.
if (file_exists($path)) {
$failure = ['reason' => 'occupied_by_file', 'path' => $path];
return false;
}
// 상위가 쓰기 불가라면 생성 자체가 불가능하다. 여기서 끊어야 호출부가 "무엇을
// 고쳐야 하는지"(대상 디렉토리가 아니라 그 상위)를 운영자에게 지목할 수 있다.
$ancestor = static::nearestExistingAncestor($path);
if ($ancestor === null || ! is_writable($ancestor)) {
$failure = ['reason' => 'ancestor_not_writable', 'path' => $ancestor ?? dirname($path)];
return false;
}
// force 인자로 경고를 억제한다. 실패는 아래 판정이 흡수한다.
File::makeDirectory($path, $mode, true, true);
if (is_dir($path)) {
static::hardenDirectory($path, $mode);
}
}
// 방금 만든 경로의 stat 은 캐시돼 있을 수 있다 — 판정 전에 비운다.
clearstatcache(true, $path);
if (! is_dir($path)) {
$failure = ['reason' => 'create_failed', 'path' => $path];
return false;
}
if (! is_writable($path)) {
$failure = ['reason' => 'not_writable', 'path' => $path];
return false;
}
return true;
}
/**
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
*
* @param string $path 기준 경로
* @return string|null 실재하는 최근접 상위 (루트까지 없으면 null)
*/
public static function nearestExistingAncestor(string $path): ?string
{
$current = dirname($path);
while (! File::isDirectory($current)) {
$parent = dirname($current);
if ($parent === $current) {
return null;
}
$current = $parent;
}
return $current;
}
/**
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
*
@@ -450,6 +566,73 @@ class FilePermissionHelper
return [$baseOwner, $baseGroup, 'base_path (대칭 구성)'];
}
/** 테스트 전용 — `describeWebServerAccount()` 판정 오버라이드 (null = 실판정) */
private static ?array $webServerAccountForTesting = null;
/**
* 테스트 전용 — 실행 환경 판정을 강제합니다 (posix 부재 환경에서 root 분기를 재현하기 위해).
*
* @param array{mode: string, name: string|null}|null $account 강제할 판정 (null 로 실판정 복귀)
*/
public static function fakeWebServerAccountForTesting(?array $account): void
{
self::$webServerAccountForTesting = $account;
}
/**
* 현재 프로세스 기준으로 "웹서버 계정" 안내에 필요한 실행 환경을 분류합니다.
*
* root(sudo) 로 artisan 을 실행하면 그 명령이 만드는 캐시 샤드·번들·임시 파일이 root 소유로 남아
* 이후 웹 프로세스의 쓰기가 실패한다. 그래서 root 실행을 감지한 명령은 "웹서버 계정으로 실행"
* 을 안내해야 하는데, 그 판정을 명령마다 다시 쓰면 한 곳만 어긋나도 서로 다른 안내가 나간다.
* `core:update` 의 재실행 안내가 처음 세운 4분기를 그대로 옮겨 공유한다 — 동작 불변.
*
* 반환 모드:
* - `non_root` : posix 미지원(Windows 등) 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `inferWebServerOwnership()` 이 storage/bootstrap 쓰기 영역 소유자로 추정한다.
*
* @return array{mode: string, name: string|null} 모드와 웹서버 계정명(`root_web_known` 일 때만)
*/
public static function describeWebServerAccount(): array
{
if (self::$webServerAccountForTesting !== null) {
return self::$webServerAccountForTesting;
}
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['mode' => 'non_root', 'name' => null];
}
if (posix_geteuid() !== 0) {
return ['mode' => 'non_root', 'name' => null];
}
[$owner] = static::inferWebServerOwnership();
if ($owner === false) {
return ['mode' => 'root_web_unknown', 'name' => null];
}
if ($owner === 0) {
return ['mode' => 'root_web_symmetric', 'name' => null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['mode' => 'root_web_unknown', 'name' => null];
}
return ['mode' => 'root_web_known', 'name' => $name];
}
/**
* 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다.
*
+8 -3
View File
@@ -2,6 +2,7 @@
namespace App\Extension\Helpers;
use App\Support\ExtensionStoragePath;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
@@ -356,9 +357,13 @@ class SettingsMigrator
return false;
}
// 디렉토리 확인 및 생성
// 디렉토리 확인 및 생성 — 파일(`writeJsonFile`)과 대칭으로 디렉토리도 부모 소유권을 상속한다.
// sudo 코어 업데이트의 업그레이드 스텝이 이 디렉토리를 root 로 만들면 `storage/app/{modules,
// plugins}` 는 restore_ownership 의도적 제외 경로라 코어 업데이트로도 되돌려지지 않고, 이후
// 웹 프로세스의 그 모듈 설정 저장이 영구 실패한다 (#651 F13).
if (! File::isDirectory($settingsDir)) {
File::makeDirectory($settingsDir, 0755, true);
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
}
// 카테고리 파일 생성
@@ -406,10 +411,10 @@ class SettingsMigrator
private function getSettingsDir(): string
{
if ($this->type === 'module') {
return storage_path('app/modules/'.$this->identifier.'/settings');
return ExtensionStoragePath::module($this->identifier, 'settings');
}
return storage_path('app/plugins/'.$this->identifier.'/settings');
return ExtensionStoragePath::plugin($this->identifier, 'settings');
}
/**
+273 -161
View File
@@ -21,11 +21,13 @@ use App\Enums\PermissionType;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
@@ -43,6 +45,7 @@ use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\ExtensionStoragePath;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
@@ -311,6 +314,7 @@ class ModuleManager implements ModuleManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
@@ -320,7 +324,10 @@ class ModuleManager implements ModuleManagerInterface
?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
?string &$failureReason = null,
): bool {
$failureReason = null;
// identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($moduleName);
@@ -368,165 +375,193 @@ class ModuleManager implements ModuleManagerInterface
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 모듈 설치 시)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
$rollbackActivePath = $this->modulesPath.DIRECTORY_SEPARATOR.$moduleName;
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackDirExisted = File::isDirectory($rollbackActivePath);
$onProgress?->__invoke('copy', '파일 복사 중...');
$this->copyFromPendingOrBundled($moduleName, $onProgress, $force);
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
$module = $this->getModule($moduleName);
if (! $module) {
// 복사 후 재로드 시도
$this->reloadModule($moduleName);
$module = $this->getModule($moduleName);
}
// 설치 시점에는 autoload-extensions.php 가 아직 갱신되지 않았다. module.php 의
// getConfigValues()/getSettingsSchema() 등이 자기 src/ 클래스를 호출하면 그 클래스가
// 해석되지 않아 "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본의
// 매핑이 이미 있어 재현되지 않는다). 시더 실행 직전이 아니라 진입 파일을 로드하기
// 전에 그 확장의 PSR-4 매핑을 등록한다.
ExtensionManager::registerExtensionAutoloadPaths('modules', $moduleName);
if (! $module) {
throw new \Exception(__('modules.not_found', ['module' => $moduleName]));
}
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$module->getRequiredCoreVersion(),
$module->getIdentifier(),
'module'
);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$onProgress?->__invoke('validate', '검증 중...');
$this->checkDependencies($module);
// 권한 구조 검증 (계층형 구조 필수)
$this->validatePermissionStructure($module, 'module');
// 언어 파일 경로 검증 (src/lang 경로 필수)
$this->validateTranslationPath($module, 'module');
// SEO 변수명 중복 검증
$this->validateSeoVariables($module, 'module');
// 모듈 설치 실행
$result = $module->install();
if (! $result) {
return false;
}
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($module);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
$module = $this->getModule($moduleName);
if (! $module) {
// 복사 후 재로드 시도
$this->reloadModule($moduleName);
$module = $this->getModule($moduleName);
}
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($module);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
if (! $module) {
throw new \Exception(__('modules.not_found', ['module' => $moduleName]));
}
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($module->getName());
$description = $this->convertToMultilingual($module->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"module.{$module->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 모듈 정보 저장
$this->moduleRepository->updateOrCreate(
['identifier' => $module->getIdentifier()],
[
'vendor' => $module->getVendor(),
'name' => $name,
'version' => $module->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $module->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'config' => $module->getConfig(),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$module->getRequiredCoreVersion(),
$module->getIdentifier(),
'module'
);
// Role 자동 생성
$this->createModuleRoles($module);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$onProgress?->__invoke('validate', '검증 중...');
$this->checkDependencies($module);
// 권한 자동 생성
$this->createModulePermissions($module);
// 권한 구조 검증 (계층형 구조 필수)
$this->validatePermissionStructure($module, 'module');
// 권한-Role 연결
$this->assignPermissionsToRoles($module);
// 언어 파일 경로 검증 (src/lang 경로 필수)
$this->validateTranslationPath($module, 'module');
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
// SEO 변수명 중복 검증
$this->validateSeoVariables($module, 'module');
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncModuleIdentityPolicies($module);
// 모듈 설치 실행
$module->clearLifecycleFailureReason();
$result = $module->install();
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
$this->syncModuleIdentityMessages($module);
if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncModuleNotificationDefinitions($module);
return false;
}
DB::commit();
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($module);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($module);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($module->getName());
$description = $this->convertToMultilingual($module->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"module.{$module->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 모듈 정보 저장
$this->moduleRepository->updateOrCreate(
['identifier' => $module->getIdentifier()],
[
'vendor' => $module->getVendor(),
'name' => $name,
'version' => $module->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $module->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'config' => $module->getConfig(),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
);
// Role 자동 생성
$this->createModuleRoles($module);
// 권한 자동 생성
$this->createModulePermissions($module);
// 권한-Role 연결
$this->assignPermissionsToRoles($module);
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncModuleIdentityPolicies($module);
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
$this->syncModuleIdentityMessages($module);
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncModuleNotificationDefinitions($module);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runModuleSeeders($module);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
$this->initializeModuleSettings($module);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
if (! $composerResult) {
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 모듈 설치 완료
HookManager::doAction('core.modules.installed', $moduleName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$moduleName,
'module',
);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runModuleSeeders($module);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
$this->initializeModuleSettings($module);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
if (! $composerResult) {
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 모듈 설치 완료
HookManager::doAction('core.modules.installed', $moduleName);
return true;
}
/**
@@ -540,9 +575,15 @@ class ModuleManager implements ModuleManagerInterface
{
$module = $this->getModule($moduleName);
if (! $module) {
return ['success' => false, 'layouts_registered' => 0];
return [
'success' => false,
'layouts_registered' => 0,
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
];
}
$module->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) {
@@ -628,6 +669,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
// 새 애플리케이션을 부팅해 "캐시된" 활성 모듈 목록을 읽는다. 여기서 비우지 않으면
// 방금 활성으로 바뀐 이 모듈이 목록에서 빠진 채 라우트가 박제되고,
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
self::invalidateModuleStatusCache();
// soft deleted된 모듈 레이아웃 복원 (재활성화 시)
$this->restoreModuleLayouts($module->getIdentifier());
@@ -650,9 +698,6 @@ class ModuleManager implements ModuleManagerInterface
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
@@ -667,7 +712,18 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.activated', $moduleName);
}
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
if (! $result) {
// 모듈이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
return [
'success' => false,
'layouts_registered' => $layoutsRegistered,
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
}
/**
@@ -714,9 +770,15 @@ class ModuleManager implements ModuleManagerInterface
): array {
$module = $this->getModule($moduleName);
if (! $module) {
return ['success' => false, 'layouts_deleted' => 0];
return [
'success' => false,
'layouts_deleted' => 0,
'reason' => __('modules.errors.not_found', ['module' => $moduleName]),
];
}
$module->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크
$record = $this->moduleRepository->findByIdentifier($module->getIdentifier());
if ($record) {
@@ -777,6 +839,12 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
// 모듈 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 모듈 목록을 읽으므로,
// 여기서 비우지 않으면 방금 비활성으로 바꾼 모듈의 라우트가 그대로 박제되어
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
self::invalidateModuleStatusCache();
// 모듈 레이아웃 soft delete
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
@@ -796,9 +864,6 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 모듈이 선언한 정책이 enforce 대상에서
// 즉시 제외되도록 한다. 정책 행 자체는 변경하지 않으므로(enabled 운영자 설정 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -811,7 +876,15 @@ class ModuleManager implements ModuleManagerInterface
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
if (! $result) {
return [
'success' => false,
'layouts_deleted' => $layoutsDeleted,
'reason' => $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_deleted' => $layoutsDeleted];
}
/**
@@ -859,12 +932,24 @@ class ModuleManager implements ModuleManagerInterface
* @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없을 때
*/
public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool
{
public function uninstallModule(
string $moduleName,
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
if ($existingRecord) {
@@ -897,8 +982,13 @@ class ModuleManager implements ModuleManagerInterface
DB::beginTransaction();
// 모듈 제거 실행
$module->clearLifecycleFailureReason();
$result = $module->uninstall();
if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
}
if ($result) {
// 권한·메뉴·역할은 $deleteData=true 시에만 삭제.
// false 시 보존하여 재설치 시 기존 역할 할당/커스터마이징이 복원 가능하도록 한다.
@@ -960,6 +1050,12 @@ class ModuleManager implements ModuleManagerInterface
// 오토로드 병합 실행 (트랜잭션 외부에서 실행)
if ($result) {
// 모듈 상태 캐시 무효화 — DB 에서 모듈 행을 지운 직후(커밋 직후)에 둔다.
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
// 캐시된 활성 모듈 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된 모듈이
// 목록에 남은 채로 라우트·훅이 박제된다.
self::invalidateModuleStatusCache();
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
@@ -977,15 +1073,15 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush();
// 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->modulesPath,
$module->getIdentifier()
);
// 메모리에서 모듈 제거
unset($this->modules[$module->getIdentifier()]);
@@ -1704,7 +1800,7 @@ class ModuleManager implements ModuleManagerInterface
}
$identifier = $module->getIdentifier();
$settingsDir = storage_path('app/modules/'.$identifier.'/settings');
$settingsDir = ExtensionStoragePath::module($identifier, 'settings');
// 이미 환경설정 디렉토리가 있고 파일이 있으면 스킵 (재설치 시 덮어쓰기 방지)
if (File::isDirectory($settingsDir) && count(File::files($settingsDir)) > 0) {
@@ -1741,9 +1837,11 @@ class ModuleManager implements ModuleManagerInterface
return;
}
// 디렉토리 생성
// 디렉토리 생성 — sudo 코어 업데이트(번들 확장 업데이트 프롬프트) 경로에서 root 로 만들어지면
// `storage/app/modules` 는 restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
if (! File::isDirectory($settingsDir)) {
File::makeDirectory($settingsDir, 0755, true);
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
}
// 카테고리별로 설정 파일 생성
@@ -1758,6 +1856,7 @@ class ModuleManager implements ModuleManagerInterface
$jsonContent = json_encode($categoryData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
File::put($filePath, $jsonContent);
FilePermissionHelper::inheritOwnershipFromParent($filePath);
$createdFiles[] = $category.'.json';
}
@@ -1828,7 +1927,7 @@ class ModuleManager implements ModuleManagerInterface
*/
protected function deleteModuleStorage(ModuleInterface $module): void
{
$moduleStoragePath = storage_path('app/modules/'.$module->getIdentifier());
$moduleStoragePath = ExtensionStoragePath::module($module->getIdentifier());
if (! File::isDirectory($moduleStoragePath)) {
Log::info('삭제할 모듈 스토리지 디렉토리가 없습니다.', [
@@ -1913,7 +2012,7 @@ class ModuleManager implements ModuleManagerInterface
// 5. 스토리지 디렉토리 1-depth 용량 조회
$storageInfo = $this->getStorageDirectoriesInfo(
storage_path('app/modules/'.$identifier)
ExtensionStoragePath::module($identifier)
);
// 6. Composer vendor 디렉토리 정보 조회
@@ -4052,7 +4151,7 @@ class ModuleManager implements ModuleManagerInterface
$tempDir = storage_path('app/temp/module_update_'.uniqid());
try {
File::ensureDirectoryExists($tempDir);
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
@@ -4495,6 +4594,13 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
// 모듈 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
// updateComposerAutoload() 가 DB 를 재조회해 이 모듈을 비활성으로 판정하고
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
self::invalidateModuleStatusCache();
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshModuleLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4518,7 +4624,13 @@ class ModuleManager implements ModuleManagerInterface
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidateModuleStatusCache();
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
// 그 시점 이 모듈이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
$this->extensionManager->regenerateHookCache();
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.modules.updated', $identifier);
+320 -161
View File
@@ -21,10 +21,13 @@ use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
@@ -43,6 +46,7 @@ use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\ExtensionStoragePath;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
@@ -295,6 +299,7 @@ class PluginManager implements PluginManagerInterface
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param VendorMode $vendorMode vendor 디렉토리 처리 모드
* @param bool $force 강제 설치 여부
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 설치 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
@@ -304,7 +309,10 @@ class PluginManager implements PluginManagerInterface
?\Closure $onProgress = null,
VendorMode $vendorMode = VendorMode::Auto,
bool $force = false,
?string &$failureReason = null,
): bool {
$failureReason = null;
// identifier 형식 검증 (내부 호출 방어)
ExtensionManager::validateIdentifierFormat($pluginName);
@@ -352,159 +360,191 @@ class PluginManager implements PluginManagerInterface
// _pending 또는 _bundled에서 활성 디렉토리로 복사 (미설치 플러그인 설치 시)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
// 검증은 로드된 확장 인스턴스를 요구해 복사보다 뒤에 온다. 그래서 검증이 실패하면
// 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고 오류도
// 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackDirExisted = File::isDirectory($activePath);
$onProgress?->__invoke('copy', '파일 복사 중...');
$this->copyFromPendingOrBundled($pluginName, $onProgress, $force);
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
// 복사 후 재로드 시도
$this->reloadPlugin($pluginName);
$plugin = $this->getPlugin($pluginName);
}
// 설치 시점에는 autoload-extensions.php 가 아직 갱신되지 않았다. plugin.php 의
// getConfigValues()/getSettingsSchema() 등이 자기 src/ 클래스를 호출하면 그 클래스가
// 해석되지 않아 "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본의
// 매핑이 이미 있어 재현되지 않는다). 시더 실행 직전이 아니라 진입 파일을 로드하기
// 전에 그 확장의 PSR-4 매핑을 등록한다.
ExtensionManager::registerExtensionAutoloadPaths('plugins', $pluginName);
if (! $plugin) {
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
}
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$plugin->getRequiredCoreVersion(),
$plugin->getIdentifier(),
'plugin'
);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$this->checkDependencies($plugin);
// 언어 파일 경로 검증 (lang 경로 필수)
$this->validateTranslationPath($plugin, 'plugin');
// SEO 변수명 중복 검증
$this->validateSeoVariables($plugin, 'plugin');
// 플러그인 설치 실행
$onProgress?->__invoke('validate', '검증 중...');
$result = $plugin->install();
if (! $result) {
return false;
}
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($plugin);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
// 복사 후 재로드 시도
$this->reloadPlugin($pluginName);
$plugin = $this->getPlugin($pluginName);
}
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($plugin);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
if (! $plugin) {
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
}
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($plugin->getName());
$description = $this->convertToMultilingual($plugin->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 플러그인 정보 저장
$this->pluginRepository->updateOrCreate(
['identifier' => $plugin->getIdentifier()],
[
'vendor' => $plugin->getVendor(),
'name' => $name,
'version' => $plugin->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $plugin->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $plugin->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$plugin->getRequiredCoreVersion(),
$plugin->getIdentifier(),
'plugin'
);
// Role 자동 생성
$this->createPluginRoles($plugin);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$this->checkDependencies($plugin);
// 권한 자동 생성
$this->createPluginPermissions($plugin);
// 언어 파일 경로 검증 (lang 경로 필수)
$this->validateTranslationPath($plugin, 'plugin');
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// SEO 변수명 중복 검증
$this->validateSeoVariables($plugin, 'plugin');
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
// 플러그인 설치 실행
$onProgress?->__invoke('validate', '검증 중...');
$plugin->clearLifecycleFailureReason();
$result = $plugin->install();
// IDV 메시지 정의/템플릿 자동 동기화
$this->syncPluginIdentityMessages($plugin);
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncPluginNotificationDefinitions($plugin);
if (! $result) {
return false;
}
DB::commit();
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($plugin);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($plugin);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($plugin->getName());
$description = $this->convertToMultilingual($plugin->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 플러그인 정보 저장
$this->pluginRepository->updateOrCreate(
['identifier' => $plugin->getIdentifier()],
[
'vendor' => $plugin->getVendor(),
'name' => $name,
'version' => $plugin->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $plugin->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $plugin->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
);
// Role 자동 생성
$this->createPluginRoles($plugin);
// 권한 자동 생성
$this->createPluginPermissions($plugin);
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
$this->createPluginMenus($plugin);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
// IDV 메시지 정의/템플릿 자동 동기화
$this->syncPluginIdentityMessages($plugin);
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncPluginNotificationDefinitions($plugin);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runPluginSeeders($plugin);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '설정 초기화 중...');
$this->initializePluginSettings($plugin);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
if (! $composerResult) {
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// Phase 6: 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 플러그인 설치 완료
HookManager::doAction('core.plugins.installed', $pluginName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$activePath,
$rollbackDirExisted,
$pluginName,
'plugin',
);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runPluginSeeders($plugin);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '설정 초기화 중...');
$this->initializePluginSettings($plugin);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
if (! $composerResult) {
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// Phase 6: 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 플러그인 설치 완료
HookManager::doAction('core.plugins.installed', $pluginName);
return true;
}
/**
@@ -518,9 +558,15 @@ class PluginManager implements PluginManagerInterface
{
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
return ['success' => false, 'layouts_registered' => 0];
return [
'success' => false,
'layouts_registered' => 0,
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
];
}
$plugin->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) {
@@ -609,6 +655,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 굽기(RouteCacheHelper::rebuild() 의 route:cache, 훅 캐시 재생성)는
// 새 애플리케이션을 부팅해 "캐시된" 활성 플러그인 목록을 읽는다. 여기서 비우지 않으면
// 방금 활성으로 바뀐 이 플러그인이 목록에서 빠진 채 라우트가 박제되고,
// 라우트 캐시에는 스캔 폴백이 없어 오류·경고 없이 그 엔드포인트만 404 가 된다.
self::invalidatePluginStatusCache();
// soft deleted된 플러그인 레이아웃 복원 (재활성화 시)
$this->restorePluginLayouts($plugin->getIdentifier());
@@ -631,9 +684,6 @@ class PluginManager implements PluginManagerInterface
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
IdentityPolicy::flushRouteScopeCache();
@@ -648,7 +698,18 @@ class PluginManager implements PluginManagerInterface
HookManager::doAction('core.plugins.activated', $pluginName);
}
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
if (! $result) {
// 플러그인이 스스로 활성화를 거부했다. 사유를 남겼으면 그대로 싣고,
// 남기지 않았으면 일반 문구로 대체한다 — 원인 자리를 비워 두면
// 관리자 화면에 치환되지 않은 자리표시자가 그대로 노출된다.
return [
'success' => false,
'layouts_registered' => $layoutsRegistered,
'reason' => $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error'),
];
}
return ['success' => true, 'layouts_registered' => $layoutsRegistered];
}
/**
@@ -695,9 +756,15 @@ class PluginManager implements PluginManagerInterface
): array {
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
return ['success' => false, 'layouts_deleted' => 0];
return [
'success' => false,
'layouts_deleted' => 0,
'reason' => __('plugins.errors.not_found', ['plugin' => $pluginName]),
];
}
$plugin->clearLifecycleFailureReason();
// 상태 가드: 진행 중 상태 체크
$record = $this->pluginRepository->findByIdentifier($plugin->getIdentifier());
if ($record) {
@@ -761,6 +828,12 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
// 플러그인 상태 캐시 무효화 — DB 상태 쓰기 직후에 둔다.
// 뒤따르는 RouteCacheHelper::rebuild() 가 캐시된 활성 플러그인 목록을 읽으므로,
// 여기서 비우지 않으면 방금 비활성으로 바꾼 플러그인의 라우트가 그대로 박제되어
// 비활성 상태에서도 그 API 가 계속 호출 가능한 상태로 남는다.
self::invalidatePluginStatusCache();
// 플러그인 레이아웃 soft delete
$layoutsDeleted = $this->softDeletePluginLayouts($plugin->getIdentifier());
@@ -780,9 +853,6 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 본인인증 route scope 캐시 무효화 — 비활성 플러그인이 선언한 정책이 enforce
// 대상에서 즉시 제외되도록 한다. 정책 행은 변경하지 않으므로(enabled 보존)
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
@@ -797,6 +867,10 @@ class PluginManager implements PluginManagerInterface
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
if (! $result) {
$response['reason'] = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if (! empty($driverWarnings)) {
$response['driver_warnings'] = $driverWarnings;
}
@@ -884,12 +958,24 @@ class PluginManager implements PluginManagerInterface
* @param string $pluginName 제거할 플러그인명
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없을 때
*/
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool
{
public function uninstallPlugin(
string $pluginName,
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
if ($existingRecord) {
@@ -918,8 +1004,13 @@ class PluginManager implements PluginManagerInterface
DB::beginTransaction();
// 플러그인 제거 실행
$plugin->clearLifecycleFailureReason();
$result = $plugin->uninstall();
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if ($result) {
// 권한/역할은 $deleteData=true 시에만 삭제.
// 운영 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
@@ -980,6 +1071,12 @@ class PluginManager implements PluginManagerInterface
// 트랜잭션 외부에서 실행
if ($result) {
// 플러그인 상태 캐시 무효화 — DB 에서 플러그인 행을 지운 직후(커밋 직후)에 둔다.
// 뒤따르는 굽기(오토로드 갱신 내 훅 캐시 재생성, RouteCacheHelper::rebuild())가
// 캐시된 활성 플러그인 목록을 읽으므로, 여기서 비우지 않으면 이미 제거된
// 플러그인이 목록에 남은 채로 라우트·훅이 박제된다.
self::invalidatePluginStatusCache();
// 플러그인 설정 디렉토리 삭제 (deleteData 옵션이 true인 경우)
if ($deleteData) {
$this->deletePluginSettingsDirectory($plugin);
@@ -1005,15 +1102,15 @@ class PluginManager implements PluginManagerInterface
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
ExtensionMiddlewareRegistry::flush();
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->pluginsPath,
$plugin->getIdentifier()
);
// 메모리에서 플러그인 제거
unset($this->plugins[$plugin->getIdentifier()]);
@@ -2003,10 +2100,54 @@ class PluginManager implements PluginManagerInterface
);
}
/**
* 플러그인이 선언한 관리자 메뉴를 동기화합니다.
*
* 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다.
* 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
* 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만
* 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다.
*
* helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징
* (user_overrides) 도 보존됩니다.
*
* @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스
*/
protected function createPluginMenus(PluginInterface $plugin): void
{
if (! method_exists($plugin, 'getAdminMenus')) {
return;
}
$menus = $plugin->getAdminMenus();
if (empty($menus)) {
return;
}
// 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록
// 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선).
$menus = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.admin_menus.translations",
$menus,
);
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($menus as $menuData) {
$helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$plugin->getIdentifier(),
);
}
}
/**
* 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙).
*
* 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상.
* 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로
* 여기서는 권한·역할만 대상으로 한다.
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
*/
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
@@ -2065,14 +2206,15 @@ class PluginManager implements PluginManagerInterface
* 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
*
* 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재):
* 동기화 대상:
* 1. 역할 (`getRoles`)
* 2. 권한 (`getPermissions`)
* 3. 역할-권한 매핑 (`getRolePermissions`)
* 4. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 5. IDV 정책 (`getIdentityPolicies`)
* 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 7. 알림 정의/템플릿 (`getNotificationDefinitions`)
* 4. 관리자 메뉴 (`getAdminMenus`)
* 5. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 6. IDV 정책 (`getIdentityPolicies`)
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
*
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
* 무해 (멱등).
@@ -2084,6 +2226,7 @@ class PluginManager implements PluginManagerInterface
$this->createPluginRoles($plugin);
$this->createPluginPermissions($plugin);
$this->assignPermissionsToRoles($plugin);
$this->createPluginMenus($plugin);
$this->cleanupStalePluginEntries($plugin);
$this->syncPluginIdentityPolicies($plugin);
$this->syncPluginIdentityMessages($plugin);
@@ -2476,7 +2619,7 @@ class PluginManager implements PluginManagerInterface
protected function initializePluginSettings(PluginInterface $plugin): void
{
$identifier = $plugin->getIdentifier();
$settingsDir = storage_path("app/plugins/{$identifier}/settings");
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
$settingsPath = $settingsDir.'/setting.json';
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
@@ -2502,14 +2645,17 @@ class PluginManager implements PluginManagerInterface
return;
}
// 디렉토리 생성
// 디렉토리 생성 — sudo 코어 업데이트 경로에서 root 로 만들어지면 `storage/app/plugins` 는
// restore_ownership 제외 경로라 되돌려지지 않는다 → 부모 소유권 상속 (#651 F13)
if (! File::isDirectory($settingsDir)) {
File::makeDirectory($settingsDir, 0755, true);
FilePermissionHelper::inheritOwnershipFromParent($settingsDir);
}
// 기본값 저장
$content = json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
File::put($settingsPath, $content);
FilePermissionHelper::inheritOwnershipFromParent($settingsPath);
Log::info('플러그인 기본 설정 파일 생성 완료', [
'plugin' => $identifier,
@@ -2557,7 +2703,7 @@ class PluginManager implements PluginManagerInterface
protected function deletePluginSettingsDirectory(PluginInterface $plugin): void
{
$identifier = $plugin->getIdentifier();
$pluginStorageDir = storage_path("app/plugins/{$identifier}");
$pluginStorageDir = ExtensionStoragePath::plugin($identifier);
if (File::isDirectory($pluginStorageDir)) {
File::deleteDirectory($pluginStorageDir);
@@ -2627,7 +2773,7 @@ class PluginManager implements PluginManagerInterface
// 5. 스토리지 디렉토리 1-depth 용량 조회
$storageInfo = $this->getStorageDirectoriesInfo(
storage_path('app/plugins/'.$identifier)
ExtensionStoragePath::plugin($identifier)
);
// 6. Composer vendor 디렉토리 정보 조회
@@ -4234,7 +4380,7 @@ class PluginManager implements PluginManagerInterface
$tempDir = storage_path('app/temp/plugin_update_'.uniqid());
try {
File::ensureDirectoryExists($tempDir);
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
@@ -4675,6 +4821,13 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
// 플러그인 상태 캐시 무효화 — 상태 복원 쓰기 직후에 둔다.
// Updating 전이 직후에는 비우지 않는다: 그러면 Updating 창 안의
// updateComposerAutoload() 가 DB 를 재조회해 이 플러그인을 비활성으로 판정하고
// 훅 캐시에서 리스너를 떨군다(지금 없는 결함을 새로 만든다).
// 복원 직후에 비워야 뒤따르는 굽기(라우트·훅)가 복원된 상태를 읽는다.
self::invalidatePluginStatusCache();
// 9. 레이아웃 갱신 (이전 상태가 active였으면)
// refreshPluginLayouts()는 캐시 무효화 + 캐시 버전 증가를 포함
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
@@ -4698,7 +4851,13 @@ class PluginManager implements PluginManagerInterface
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidatePluginStatusCache();
// 훅 캐시 재생성 — Updating 창 안의 updateComposerAutoload() 가 구운 훅 캐시에는
// 그 시점 이 플러그인이 Updating(=비활성)으로 판정되어 리스너가 통째로 빠져 있을 수 있다.
// 훅 캐시 폴백은 파일 부재/손상에만 작동하므로 내용이 stale 한 경우는 조용히 통과한다.
// 상태를 복원하고 상태 캐시를 비운 지금 다시 구워야 그 누락이 교정된다.
// updateComposerAutoload() 전체를 재호출하지 않는다 — composer autoload 병합은 이미 끝났고 비싸다.
$this->extensionManager->regenerateHookCache();
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
HookManager::doAction('core.plugins.updated', $identifier);
@@ -0,0 +1,70 @@
<?php
namespace App\Extension\Storage\Concerns;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Storage;
/**
* 스토리지 드라이버 공용 공개 URL 해석 trait
*
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 생성하고, 그 외에는 null 을 생성합니다. 생성 결과는 디스크 종류와 무관하게
* `core.storage.filter_url` 필터 훅을 항상 통과하므로, 확장이 URL 을 공급/수정/차단할 수 있습니다.
*/
trait ResolvesPublicUrl
{
/**
* 파일의 공개 URL을 반환합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로 (카테고리 하위 상대 경로)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string
{
$fullPath = $this->resolvePath($category, $path);
$url = $this->diskSupportsDirectUrl()
? Storage::disk($this->disk)->url($fullPath)
: null;
$filtered = HookManager::applyFilters('core.storage.filter_url', $url, array_merge(
$this->urlHookContext(),
[
'disk' => $this->disk,
'category' => $category,
'path' => $path,
'full_path' => $fullPath,
]
));
return (is_string($filtered) && trim($filtered) !== '') ? $filtered : null;
}
/**
* 현재 디스크가 직접 URL 생성을 지원하는지 판정합니다.
*
* public 디스크는 항상 지원, 그 외 디스크는 `filesystems.disks.{disk}.url` 이
* 비어 있지 않은 문자열로 설정된 경우에만 지원합니다 (AWS_URL 미설정/빈값 방어).
*
* @return bool 직접 URL 지원 여부
*/
private function diskSupportsDirectUrl(): bool
{
if ($this->disk === 'public') {
return true;
}
$base = config("filesystems.disks.{$this->disk}.url");
return is_string($base) && trim($base) !== '';
}
/**
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope(core|module|plugin) + 확장 식별자(코어는 null)
*/
abstract protected function urlHookContext(): array;
}
+14 -11
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 코어 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class CoreStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 사용할 디스크 이름
*/
@@ -93,17 +97,16 @@ class CoreStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 식별자 (코어는 null)
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'core',
'identifier' => null,
];
}
/**
@@ -155,7 +158,7 @@ class CoreStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -180,7 +183,7 @@ class CoreStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+14 -12
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 모듈 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class ModuleStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 모듈 식별자
*/
@@ -119,18 +123,16 @@ class ModuleStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 모듈 식별자
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
// public disk인 경우에만 직접 URL 반환
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'module',
'identifier' => $this->identifier,
];
}
/**
@@ -182,7 +184,7 @@ class ModuleStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -207,7 +209,7 @@ class ModuleStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+14 -12
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 플러그인 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class PluginStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 플러그인 식별자
*/
@@ -119,18 +123,16 @@ class PluginStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 플러그인 식별자
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
// public disk인 경우에만 직접 URL 반환
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'plugin',
'identifier' => $this->identifier,
];
}
/**
@@ -182,7 +184,7 @@ class PluginStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -207,7 +209,7 @@ class PluginStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+206 -95
View File
@@ -13,6 +13,7 @@ use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
@@ -51,6 +52,14 @@ class TemplateManager implements TemplateManagerInterface
protected array $templates = [];
/**
* 템플릿 디렉토리 스캔이 1회 이상 수행되었는지 여부
*
* `ensureLoaded()` 의 멱등 판정에만 쓴다 — 명시적 `loadTemplates()` 는 이 값과 무관하게
* 항상 재스캔한다(설치/삭제 직후 갱신 계약).
*/
protected bool $templatesLoaded = false;
/**
* _pending 디렉토리의 템플릿 메타데이터 배열
*
@@ -98,13 +107,33 @@ class TemplateManager implements TemplateManagerInterface
return new CoreCacheDriver(config('cache.default', 'array'));
}
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* 소비자가 "템플릿 맵이 채워져 있음" 만 필요로 할 때 쓴다. `loadTemplates()` 는 맵을
* 리셋하고 디렉토리를 통째로 재스캔하므로, 그것을 무조건 호출하면 공유 인스턴스의
* 상태를 매번 갈아엎으면서 풀스캔 비용까지 반복된다.
*/
public function ensureLoaded(): void
{
if ($this->templatesLoaded) {
return;
}
$this->loadTemplates();
}
/**
* 모든 템플릿을 로드하고 초기화합니다.
*
* 항상 재스캔한다 — 설치/삭제/업데이트 직후 갱신을 보장하는 계약이다.
* 단순히 "채워져 있으면 됨" 인 호출자는 `ensureLoaded()` 를 쓴다.
*/
public function loadTemplates(): void
{
// 기존 템플릿 캐시 초기화 (테스트 환경에서 재로드 지원)
$this->templates = [];
$this->templatesLoaded = true;
if (! File::exists($this->templatesPath)) {
return;
@@ -397,6 +426,12 @@ class TemplateManager implements TemplateManagerInterface
);
}
// 검증(2단계)은 복사된 활성 디렉토리를 읽어야 해서 복사보다 뒤에 온다. 그래서 검증이
// 실패하면 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고
// 오류도 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackActivePath = $this->templatesPath.DIRECTORY_SEPARATOR.$templateName;
$rollbackDirExisted = File::isDirectory($rollbackActivePath);
// 1. _pending/_bundled에서 활성 디렉토리로 복사 (활성 디렉토리에 없는 경우)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
$onProgress?->__invoke('copy', '파일 복사 중...');
@@ -407,79 +442,90 @@ class TemplateManager implements TemplateManagerInterface
// 2. 검증
$onProgress?->__invoke('validate', '검증 중...');
return DB::transaction(function () use ($templateName, $onProgress) {
$template = $this->getTemplate($templateName);
if (! $template) {
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
}
try {
return DB::transaction(function () use ($templateName, $onProgress) {
$template = $this->getTemplate($templateName);
if (! $template) {
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
}
// 의존성 확인
$this->checkDependencies($template);
// 의존성 확인
$this->checkDependencies($template);
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
$this->validateSeoConfig($templateName);
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
$this->validateSeoConfig($templateName);
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
$this->validateLayouts($templateName);
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
$this->validateLayouts($templateName);
// name과 description 다국어 변환 (역호환성)
$name = $this->convertToMultilingual($template['name']);
$description = $this->convertToMultilingual($template['description'] ?? '');
// name과 description 다국어 변환 (역호환성)
$name = $this->convertToMultilingual($template['name']);
$description = $this->convertToMultilingual($template['description'] ?? '');
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"template.{$templateName}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"template.{$templateName}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 3. DB 등록
$onProgress?->__invoke('db', 'DB 등록 중...');
// 3. DB 등록
$onProgress?->__invoke('db', 'DB 등록 중...');
// 템플릿 레코드 생성 또는 업데이트
$templateRecord = $this->templateRepository->updateOrCreate(
['identifier' => $templateName],
[
'vendor' => $template['vendor'],
'name' => $name,
'version' => $template['version'],
'type' => $template['type'],
'description' => $description,
'github_url' => $template['github_url'] ?? null,
'metadata' => $template['metadata'] ?? null,
'status' => ExtensionStatus::Inactive->value,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]
// 템플릿 레코드 생성 또는 업데이트
$templateRecord = $this->templateRepository->updateOrCreate(
['identifier' => $templateName],
[
'vendor' => $template['vendor'],
'name' => $name,
'version' => $template['version'],
'type' => $template['type'],
'description' => $description,
'github_url' => $template['github_url'] ?? null,
'metadata' => $template['metadata'] ?? null,
'status' => ExtensionStatus::Inactive->value,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]
);
// 4. 레이아웃 등록
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
// 레이아웃 JSON 파일 일괄 등록
$this->registerLayouts($templateName, $templateRecord->id);
// 모듈 레이아웃 오버라이드 등록
$this->registerLayoutOverrides($templateName, $templateRecord->id);
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
$this->registerExtensionOverrides($templateName, $templateRecord->id);
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
$this->validateErrorLayouts($templateName, $template);
// 템플릿 상태 캐시 무효화
self::invalidateTemplateStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
// 훅 발행: 템플릿 설치 완료
HookManager::doAction('core.templates.installed', $templateName);
return true;
});
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$templateName,
'template',
);
// 4. 레이아웃 등록
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
// 레이아웃 JSON 파일 일괄 등록
$this->registerLayouts($templateName, $templateRecord->id);
// 모듈 레이아웃 오버라이드 등록
$this->registerLayoutOverrides($templateName, $templateRecord->id);
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
$this->registerExtensionOverrides($templateName, $templateRecord->id);
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
$this->validateErrorLayouts($templateName, $template);
// 템플릿 상태 캐시 무효화
self::invalidateTemplateStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
// 훅 발행: 템플릿 설치 완료
HookManager::doAction('core.templates.installed', $templateName);
return true;
});
throw $e;
}
}
/**
@@ -644,15 +690,21 @@ class TemplateManager implements TemplateManagerInterface
* @param string $templateName 비활성화할 템플릿명 (identifier)
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @return bool 비활성화 성공 여부
*/
public function deactivateTemplate(
string $templateName,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
?string &$failureReason = null,
): bool {
$failureReason = null;
$template = $this->getTemplate($templateName);
if (! $template) {
$failureReason = __('templates.errors.not_found', ['template' => $templateName]);
return false;
}
@@ -697,12 +749,20 @@ class TemplateManager implements TemplateManagerInterface
*
* @param string $templateName 제거할 템플릿명 (identifier)
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없을 때
*/
public function uninstallTemplate(string $templateName, ?\Closure $onProgress = null): bool
{
public function uninstallTemplate(
string $templateName,
?\Closure $onProgress = null,
?array &$preservedBackups = null,
): bool {
$preservedBackups = [];
// 1. 캐시 삭제
$onProgress?->__invoke('cache', '캐시 삭제 중...');
@@ -748,7 +808,10 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('files', '파일 삭제 중...');
// 활성 템플릿 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
ExtensionPendingHelper::deleteExtensionDirectory($this->templatesPath, $templateName);
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->templatesPath,
$templateName
);
// 메모리에서 템플릿 제거
unset($this->templates[$templateName]);
@@ -1659,13 +1722,9 @@ class TemplateManager implements TemplateManagerInterface
}
if (! in_array($override->name, $registeredLayoutNames)) {
// 캐시 삭제 (레코드 삭제 전에 수행)
$cacheKey = "template.{$templateId}.layout.{$override->name}";
$this->cache()->forget($cacheKey);
$sourceHash = md5($override->source_type?->value.$override->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 캐시 삭제 (레코드 삭제 전에 수행) — 키 조립은 트레이트 단일 지점에 위임
// (.with_source_meta 변종 포함, 수작업 키 목록의 규약 드리프트 방지)
$this->forgetLayoutCacheKeys($override, $templateName);
$override->forceDelete();
$deletedCount++;
@@ -1712,14 +1771,10 @@ class TemplateManager implements TemplateManagerInterface
}
foreach ($overrideLayouts as $layout) {
// 기본 캐시 키 패턴으로 삭제
$cacheKey = "template.{$templateId}.layout.{$layout->name}";
$this->cache()->forget($cacheKey);
// sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴)
$sourceHash = md5($layout->source_type?->value.$layout->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 키 조립은 트레이트 단일 지점에 위임 (.with_source_meta 변종 포함).
// 이 경로는 identifier 를 보유하지 않으므로 '' 전달 — 버전 포함 공개
// 서빙 키는 종전과 동일하게 버전 bump 로 무효화된다.
$this->forgetLayoutCacheKeys($layout, '');
}
Log::info(__('templates.info.override_layouts_cache_invalidated'), [
@@ -1766,41 +1821,79 @@ class TemplateManager implements TemplateManagerInterface
/**
* 템플릿 관련 모든 캐시를 삭제합니다.
*
* 템플릿 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는
* 무효화 단일 지점이다. 버전 접미사 없는 고정 키(`template.config.{identifier}`,
* `template.{id|identifier}.components_manifest`)는 캐시 버전 bump 로 무효화되지
* 않으므로 여기서 능동 forget 한다 — 누락 시 공개 config.json 이 TTL(1시간) 동안
* 이전 manifest 로 응답된다 (#588, 공개 #119).
*
* template:cache-clear 커맨드가 호출할 수 있도록 public 이다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 능동 삭제를 시도한 캐시 키 수
*/
protected function clearTemplateCache(string $templateIdentifier): void
public function clearTemplateCache(string $templateIdentifier): int
{
$clearedCount = 0;
// identifier 만으로 가능한 고정 키 forget — uninstall 등 DB 레코드가 이미
// 없는 상황에서도 반드시 수행되어야 하므로 레코드 조회보다 먼저 둔다.
$this->cache()->forget("template.config.{$templateIdentifier}");
$clearedCount++;
// components_manifest identifier 변종 (ComponentExists 는 int|string 수용)
$this->cache()->forget("template.{$templateIdentifier}.components_manifest");
$clearedCount++;
// 활성 템플릿 + 레이아웃 무변경 업데이트 경로는 캐시 버전 bump 가 없어
// 현재 버전 routes/language 키가 stale 로 남는다 — warmTemplateCache() 와
// 대칭으로 현재 버전 키를 능동 삭제한다 (이전 버전 키는 TTL 자연 만료).
$cacheVersion = self::getExtensionCacheVersion();
$this->cache()->forget("template.routes.{$templateIdentifier}.v{$cacheVersion}");
$clearedCount++;
foreach (config('app.supported_locales', ['ko', 'en']) as $locale) {
$this->cache()->forget("template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}");
$clearedCount++;
}
// DB 기반 조회 — 파일 시스템 상태와 무관하게 캐시 삭제 보장
// (파일 교체 중이거나 reloadTemplate() 전에도 캐시를 확실히 삭제)
$templateRecord = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $templateRecord) {
return;
return $clearedCount;
}
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$this->clearLayoutCaches($templateIdentifier);
// components_manifest 숫자 id 변종 (StoreLayoutRequest 가 integer 검증 — 실운영 키)
$this->cache()->forget("template.{$templateRecord->id}.components_manifest");
$clearedCount++;
// Routes/다국어 캐시는 버전 포함 키이므로 incrementExtensionCacheVersion() + TTL로 무효화됨
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$clearedCount += $this->clearLayoutCaches($templateIdentifier);
Log::info(__('templates.info.cache_cleared'), [
'template' => $templateIdentifier,
]);
return $clearedCount;
}
/**
* 템플릿의 모든 레이아웃 캐시를 삭제합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
protected function clearLayoutCaches(string $templateIdentifier): void
protected function clearLayoutCaches(string $templateIdentifier): int
{
// 템플릿의 모든 레이아웃 조회
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $template) {
return;
return 0;
}
$this->invalidateTemplateLayoutCache($template->id, $templateIdentifier);
return $this->layoutRepository->getByTemplateId($template->id)->count();
}
/**
@@ -2761,7 +2854,7 @@ class TemplateManager implements TemplateManagerInterface
$tempDir = storage_path('app/temp/template_update_'.uniqid());
try {
File::ensureDirectoryExists($tempDir);
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
@@ -3082,13 +3175,22 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('cleanup', '정리 중...');
ExtensionBackupHelper::deleteBackup($backupPath);
// 고정 키(config/components_manifest) + 현재 버전 routes/language 키 능동 삭제.
// 비활성 업데이트·레이아웃 무변경 업데이트 경로는 버전 bump 가 없어 이 호출이
// 없으면 공개 config.json 이 TTL 동안 이전 manifest 로 응답된다 (#588, 공개 #119).
// 활성 경로의 refreshTemplateLayouts() 경유 중복 삭제는 forget 멱등이라 무해.
$this->clearTemplateCache($identifier);
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
// 비활성 템플릿이라 refreshTemplateLayouts()를 건너뛴 경우에만 여기서 증가
if ($previousStatus !== ExtensionStatus::Active->value) {
$this->incrementExtensionCacheVersion();
}
// 모듈(`updateModule`)·플러그인(`updatePlugin`)과 동형으로 **무조건** 올린다 —
// lang/routes/components/dist 만 바뀐 릴리스도 정적 게시본(#122)을 갱신해야 한다.
// 종전에는 활성 템플릿이면 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃
// 변경 건수 > 0)에 위임했는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로
// 레이아웃이 그대로인 릴리스는 게시본이 immutable 로 stale 하게 남았다(#651 F1).
// `refreshTemplateLayouts()` 내부의 조건부 bump 는 단독 호출처(refresh-layout)가
// 있어 그대로 두며, 이중 bump 는 put 1회 비용이고 terminating 게시는 1회로 병합된다.
$this->incrementExtensionCacheVersion();
self::invalidateTemplateStatusCache();
// 훅 발행: 템플릿 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
@@ -3138,6 +3240,15 @@ class TemplateManager implements TemplateManagerInterface
'updated_at' => now(),
]);
// 실패 경로도 bump 한다 — 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수
// 있어, 게시본을 새 버전으로 다시 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다
// 안전하다(#651 F2). 원래 예외를 가리지 않도록 bump 실패는 삼킨다.
try {
$this->incrementExtensionCacheVersion();
} catch (\Throwable) {
// 원래 예외(아래 throw)가 진짜 원인이다 — bump 실패는 로그(트레이트)로 충분
}
throw new \RuntimeException(
__('templates.errors.update_failed', [
'template' => $identifier,
+59 -2
View File
@@ -4,6 +4,7 @@ namespace App\Extension\Traits;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
@@ -20,6 +21,27 @@ trait ClearsTemplateCaches
*/
private static string $extensionCacheVersionKey = 'ext.cache_version';
/**
* 확장 캐시 버전·custom 서명 키의 저장 TTL (초) — 10년.
*
* 이 키들은 **만료로 재생성되어서는 안 된다.** 버전 키가 만료되면 다음 독자가
* `regenerateExtensionCacheVersion()` 으로 새 `time()` 을 만들고, 그것은 정적 게시본
* (`public/build/ext/{v}/`, 실측 715파일·40MB) **전체 재생성** + 병합 번들 재병합 + 전
* 방문자의 자산 URL 변경이다. TTL 을 넘기지 않으면 `AbstractCacheDriver::put()` 이
* `cache.default_ttl`(기본 86400) 을 적용해 그 재생성이 **매일** 우발적으로 일어났다(#651 F11).
*
* 영구 저장의 두 후보를 쓰지 않는 이유:
* - `forever()` 는 `CacheInterface` 밖이다 — 인터페이스 확장은 확장 공개 표면 변경이라
* 번들 확장 전수의 `g7_version` 동기화 의무를 낳는다.
* - `put(…, 0)` 은 Laravel `Repository::put` 이 `seconds <= 0` 을 **forget** 으로 처리해
* 키를 지운다. 파일 스토어는 만료를 `9999999999` 로 캡하므로 큰 TTL 은 안전하다.
*
* 트레이트 안에서는 `self::` 로 읽지 않는다 — 트레이트 정적 메서드를 트레이트 이름으로 직접
* 부르는 호출(테스트·레거시)에서 `self` 가 트레이트 자신으로 해석되어 "Cannot access trait
* constant directly" 가 난다. 트레이트를 조합한 코어 서비스 클래스 경유로 읽는다.
*/
public const PERSISTENT_TTL_SECONDS = 315360000;
/**
* 프로세스 1회 메모이즈된 확장 좌표 캐시 스토어 이름 (write/read 스토어 일관성).
* `extensionCacheStore()` 가 최초 1회 채우며, 테스트는 `resetExtensionCacheStoreMemo()`
@@ -38,7 +60,7 @@ trait ClearsTemplateCaches
{
try {
$newVersion = time();
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
Log::info('확장 기능 캐시 버전 증가', [
'new_version' => $newVersion,
@@ -48,6 +70,12 @@ trait ClearsTemplateCaches
'error' => $e->getMessage(),
]);
}
// 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가
// 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122).
// terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다
// (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상.
ExtensionStaticCacheService::schedulePublishOnTerminate();
}
/**
@@ -84,6 +112,9 @@ trait ClearsTemplateCaches
* 대신 동일 로직을 직접 수행한다. 저장 실패 시에도 0 으로 붕괴하지 않도록
* 생성한 `time()` 값을 반환한다(다음 요청이 다시 생성·저장 시도).
*
* 키는 `PERSISTENT_TTL_SECONDS` 로 저장되므로 만료로 이 경로에 오지 않는다 — 키 부재는
* `php artisan cache:clear` 또는 캐시 스토어 소실 때만이다.
*
* @return int 새로 생성된 유효 캐시 버전 (타임스탬프)
*/
private static function regenerateExtensionCacheVersion(): int
@@ -91,7 +122,7 @@ trait ClearsTemplateCaches
$newVersion = time();
try {
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
Log::info('확장 기능 캐시 버전 재생성 (키 부재/무효)', [
'new_version' => $newVersion,
@@ -102,6 +133,17 @@ trait ClearsTemplateCaches
]);
}
// 재생성도 bump 다 — 게시를 예약한다. `incrementExtensionCacheVersion()` 과
// 동형화해 "캐시 버전 갱신 → 게시" 가 **모든 경로**에서 성립하게 한다 (#122).
//
// 이 경로가 빠져 있던 동안 `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로
// 점프하고 산출물은 옛 버전에 남았다. 스케줄 GC(`ext-static:cleanup`)가 바로 그
// 첫 호출자라, cleanup 첫 줄의 `getExtensionCacheVersion()` 이 실존하지 않는 새
// 버전을 만들어 내고 보존 대상이 `[없는 새 버전, 실존 최신 1개]` 가 되어 **진짜
// 직전 버전이 삭제**됐다 (매일 04:28 재현). CLI 는 terminating 예약이 그대로
// 유효하므로 커맨드 종료 시점에 게시가 수행된다.
ExtensionStaticCacheService::schedulePublishOnTerminate();
return $newVersion;
}
@@ -164,6 +206,21 @@ trait ClearsTemplateCaches
return new CoreCacheDriver(self::extensionCacheStore());
}
/**
* custom 자산 변경 서명(`ext.custom_signature`)용 캐시 드라이버를 반환합니다.
*
* 버전 키와 **같은 고정 스토어·같은 코어 네임스페이스**를 쓴다. 서명이 버전 키와 다른
* 스토어에 저장되면(`app(CacheInterface::class)` 재바인딩 등) "첫 관측은 기록만" 규칙이
* 스토어마다 따로 성립해 실제 변경 1회를 조용히 삼킨다(#651 F8). 서명을 쓰는 뷰 컴포저와
* 지우는 관리 API 가 같은 통로를 써야 하므로 트레이트가 공급한다.
*
* @return CacheInterface 코어 네임스페이스 캐시 드라이버
*/
protected static function customSignatureCache(): CacheInterface
{
return new CoreCacheDriver(self::extensionCacheStore());
}
/**
* 확장 좌표 키(`ext.cache_version`)의 고정 캐시 스토어 이름을 반환합니다.
*
@@ -53,7 +53,11 @@ trait GeneratesComponentManifest
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
$written = $json !== false && file_put_contents($outputPath, $json.PHP_EOL) !== false;
// 종결 개행은 "\n" 고정 — PHP_EOL 은 Windows 에서 "\r\n" 이라, 같은 소스를 빌드해도
// 빌드한 OS 에 따라 산출물의 마지막 바이트가 달라진다. components.json 은 Git 추적
// 대상이므로 그 차이가 매 빌드마다 변경으로 잡히는데, 줄 내용이 같아 diff 는 비어
// 보인다 — 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
$written = $json !== false && file_put_contents($outputPath, $json."\n") !== false;
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
+27 -10
View File
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
* 버전 없는 내부 캐시 키를 능동 삭제합니다:
* 1. template.{templateId}.layout.{layoutName} - LayoutService에서 사용
* 2. template.{templateId}.layout.{layoutName}.{sourceHash} - 모듈/플러그인 레이아웃
* 3. 위 두 형태의 `.with_source_meta` 접미사 변종 - 편집기용 병합 응답 캐시
*
* 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는
* incrementExtensionCacheVersion() + TTL로 무효화됩니다.
@@ -98,22 +100,31 @@ trait InvalidatesLayoutCache
$cache = $this->resolveLayoutCache();
// 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제)
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
// 편집기용 병합 응답은 `.with_source_meta` 접미사 키로 별도 캐싱되므로
// (LayoutService::getMergedLayoutCacheKey) 두 변종을 함께 지운다 —
// 접미사 키를 남기면 activate/deactivate/uninstall/refresh 후에도
// 편집기가 stale 병합 캐시를 받는다 (#588).
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, withSourceMeta: true));
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제)
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) — 접미사는 소스 해시 뒤
if ($layout->source_type && $layout->source_identifier) {
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
$sourceType = $layout->source_type->value;
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier, true));
}
// 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두.
// PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta`
// 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화
// (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를
// 받는다. 레이아웃
// 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다.
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
// 지우므로 정합을 맞춘다.
if ($templateIdentifier) {
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
// 트레이트 게터 경유 — 이 트레이트만 조합한 클래스가 있을 수 있어 `self::` 가 아니라
// `ClearsTemplateCaches` 를 조합한 서비스 클래스를 통해 부른다(트레이트 정적 직접 호출은
// PHP 8.1+ E_DEPRECATED). 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 실제 키를 못 지운다.
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}.meta");
}
@@ -137,26 +148,32 @@ trait InvalidatesLayoutCache
/**
* 레이아웃 캐시 키를 생성합니다.
*
* `$withSourceMeta` 가 true 면 `.with_source_meta` 접미사를 붙인다 — 편집기용
* 병합 응답 키(LayoutService::getMergedLayoutCacheKey 와 동형, 접미사는 소스 해시 뒤).
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string|null $sourceType 소스 타입 (선택)
* @param string|null $sourceIdentifier 소스 식별자 (선택)
* @param bool $withSourceMeta 편집기용 병합 응답 키 여부 (선택)
* @return string 캐시 키
*/
protected function buildLayoutCacheKey(
int $templateId,
string $layoutName,
?string $sourceType = null,
?string $sourceIdentifier = null
?string $sourceIdentifier = null,
bool $withSourceMeta = false
): string {
$baseKey = "template.{$templateId}.layout.{$layoutName}";
$metaSuffix = $withSourceMeta ? '.with_source_meta' : '';
if ($sourceType && $sourceIdentifier) {
$sourceHash = md5($sourceType.$sourceIdentifier);
return "{$baseKey}.{$sourceHash}";
return "{$baseKey}.{$sourceHash}{$metaSuffix}";
}
return $baseKey;
return "{$baseKey}{$metaSuffix}";
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Extension\Traits;
/**
* 확장이 수명주기 훅에서 실패 사유를 알리는 통로.
*
* `install()` / `activate()` / `deactivate()` / `uninstall()` 은 bool 만 돌려주므로,
* 확장이 "왜" 거부했는지가 호출자에게 전달되지 않는다. 그 결과 관리자 화면에는
* 원인 자리가 빈 실패 문구만 남는다.
*
* 확장은 `failWith()` 로 사유를 남기며 false 를 돌려주고, 코어(Manager)는
* `getLifecycleFailureReason()` 으로 그 사유를 읽어 응답에 싣는다.
* 사유를 남기지 않은 확장은 null 이며, 이 경우 코어가 일반 문구로 대체한다.
*
* 사유는 이미 번역된 문장이어야 한다 — 확장의 언어 파일 키는 코어가 해석할 수 없다.
*/
trait ReportsLifecycleFailure
{
/** @var string|null 마지막 수명주기 실패 사유 (번역된 문장) */
protected ?string $lifecycleFailureReason = null;
/**
* 마지막 수명주기 훅이 남긴 실패 사유를 반환합니다.
*
* @return string|null 실패 사유. 남기지 않았으면 null
*/
public function getLifecycleFailureReason(): ?string
{
return $this->lifecycleFailureReason;
}
/**
* 실패 사유를 남기고 false 를 반환합니다.
*
* 수명주기 훅에서 `return $this->failWith(__('...'));` 형태로 사용합니다.
*
* @param string $reason 운영자에게 보일 실패 사유 (번역된 문장)
* @return false 언제나 false
*/
protected function failWith(string $reason): bool
{
$this->lifecycleFailureReason = $reason;
return false;
}
/**
* 직전 실패 사유를 지웁니다.
*
* 같은 확장 인스턴스로 수명주기 훅을 다시 부르기 전에 코어가 호출합니다.
* 지우지 않으면 이전 실패의 사유가 다음 성공/실패에 그대로 따라붙는다.
*/
public function clearLifecycleFailureReason(): void
{
$this->lifecycleFailureReason = null;
}
}
+32
View File
@@ -204,6 +204,38 @@ class PermissionHelper
return false;
}
/**
* 스코프 접근이 허용되는 모델만 남긴 배열을 반환합니다 (정적 일괄 라우트용).
*
* `PermissionMiddleware` 의 스코프 검사는 라우트에서 모델이 resolve 될 때만
* 동작합니다 — 모델이 없으면 목록 엔드포인트로 보아 건너뜁니다. 따라서
* `{user}` 같은 파라미터가 없는 **정적 일괄 라우트**(예: `PATCH users/bulk-status`)
* 에서는 스코프 검사가 통째로 우회됩니다. 상세 경로가 403 으로 막는 대상을
* 일괄 경로로는 바꿀 수 있으면 그 경로가 우회로이므로, 서비스 계층에서
* 같은 판정(`checkScopeAccess`)을 재적용해야 합니다.
*
* 판정은 대상별로 이뤄집니다 — 액터의 유효 스코프가 self 면 자기 소유만,
* role 이면 같은 역할 범위까지, 미지정(글로벌)이면 전체가 통과합니다.
*
* @param iterable<Model> $models 검사 대상 모델 목록
* @param string $permission 권한 식별자
* @param User|null $user 사용자 (null이면 현재 인증 사용자)
* @return array<Model> 스코프 접근이 허용된 모델 목록
*/
public static function filterByScope(iterable $models, string $permission, ?User $user = null): array
{
$user = $user ?? Auth::user();
$allowed = [];
foreach ($models as $model) {
if (self::checkScopeAccess($model, $permission, $user)) {
$allowed[] = $model;
}
}
return $allowed;
}
/**
* Permission 스코프 데이터를 static 캐시와 함께 조회합니다.
*
+18 -6
View File
@@ -1,5 +1,6 @@
<?php
use App\Support\ExtensionSettingsMirror;
use Illuminate\Support\Facades\Config;
if (! function_exists('g7_settings')) {
@@ -16,15 +17,12 @@ if (! function_exists('g7_settings')) {
* @example
* // 코어 메일 설정 조회
* $mailHost = g7_settings('core.mail.host');
*
* @example
* // 모듈 설정 조회
* $shopName = g7_settings('modules.sirsoft-ecommerce.basic_info.shop_name');
*
* @example
* // 플러그인 설정 조회
* $displayMode = g7_settings('plugins.sirsoft-daum_postcode.display_mode');
*
* @example
* // 전체 설정 조회
* $allSettings = g7_settings();
@@ -50,7 +48,6 @@ if (! function_exists('g7_core_settings')) {
* @example
* // 메일 호스트 조회
* $mailHost = g7_core_settings('mail.host');
*
* @example
* // 사이트명 조회
* $siteName = g7_core_settings('general.site_name');
@@ -77,7 +74,6 @@ if (! function_exists('g7_module_settings')) {
* @example
* // 이커머스 모듈의 쇼핑몰명 조회
* $shopName = g7_module_settings('sirsoft-ecommerce', 'basic_info.shop_name');
*
* @example
* // 모듈 전체 설정 조회
* $allSettings = g7_module_settings('sirsoft-ecommerce');
@@ -92,6 +88,23 @@ if (! function_exists('g7_module_settings')) {
}
}
if (! function_exists('g7_refresh_module_settings_config')) {
/**
* 모듈 환경설정 config 미러를 다시 채웁니다.
*
* 모듈 설정 저장에는 코어 공통 지점이 없어 각 모듈의 SettingsService 가 직접 저장합니다.
* 그 서비스가 자기 캐시를 비우는 자리에서 이 헬퍼를 호출하면, 상주 프로세스에서도
* `g7_module_settings()` 가 저장 직후의 값을 읽습니다 (공개이슈 #109).
*
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
* @return void
*/
function g7_refresh_module_settings_config(string $identifier): void
{
app(ExtensionSettingsMirror::class)->refreshModule($identifier);
}
}
if (! function_exists('g7_plugin_settings')) {
/**
* 그누보드7 플러그인 환경설정 조회 헬퍼 함수
@@ -104,7 +117,6 @@ if (! function_exists('g7_plugin_settings')) {
* @example
* // 다음 우편번호 플러그인의 표시 모드 조회
* $displayMode = g7_plugin_settings('sirsoft-daum_postcode', 'display_mode');
*
* @example
* // 플러그인 전체 설정 조회
* $allSettings = g7_plugin_settings('sirsoft-daum_postcode');
@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CustomAssetOperationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\Extension\ReadExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\SaveExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\UploadExtensionCustomAssetRequest;
use App\Rules\AllowedTemplateFileType;
use App\Services\CustomAssetService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 확장 사용자 추가 에셋(`custom/`) 어드민 컨트롤러
*
* 운영자가 자기 CSS·JS·폰트·이미지를 화면에서 직접 넣고 고칠 수 있게 한다. 레이아웃
* 편집기의 [커스텀 자산] 모달이 본 API 를 호출한다.
*
* 모듈·플러그인·템플릿을 **한 엔드포인트**가 다룬다. 타입별로 나누면 같은 검증·문서·테스트가
* 세 벌로 갈리고, 그중 하나만 약해지면 그 경로가 조용한 우회로가 된다. 기존
* `extensions/{type}/{identifier}` 선례(확장 복구 API)와 같은 형태다.
*
* 권한은 라우트의 permission 미들웨어(`core.extensions.custom_assets.manage`)가 담당한다.
* 레이아웃 편집 권한과 **분리**된 이유: 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라
* 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다.
*/
class AdminExtensionCustomAssetController extends AdminBaseController
{
/**
* 라우트 파라미터(단수) → 해석기 어휘(복수)
*
* 라우트는 기존 확장 공통 API 와 같은 단수형을 쓰고(`module|plugin|template`),
* 해석기·서빙은 디렉토리 이름과 같은 복수형을 쓴다. 변환을 한 곳에 모아 둔다 —
* 흩어지면 한쪽 표기만 고쳐 놓고 다른 쪽에서 조용히 빈 목록이 된다.
*/
private const TYPE_MAP = [
'module' => 'modules',
'plugin' => 'plugins',
'template' => 'templates',
];
public function __construct(
private CustomAssetService $service,
) {
parent::__construct();
}
/**
* 사용자 추가 에셋 목록 조회.
*
* @param string $type 확장 타입 (`module` | `plugin` | `template`)
* @param string $identifier 확장 식별자
* @return JsonResponse 파일 목록 + 편집기 메타(허용 확장자·크기 상한)
*/
public function index(string $type, string $identifier): JsonResponse
{
try {
$files = $this->service->list($this->resolveType($type), $identifier);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 목록 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => 'custom/']);
}
return $this->success('custom_assets.messages.listed', [
'files' => $files,
'editable_extensions' => CustomAssetService::EDITABLE_EXTENSIONS,
'uploadable_extensions' => AllowedTemplateFileType::getAllowedExtensions(),
'max_text_bytes' => CustomAssetService::MAX_TEXT_BYTES,
'max_upload_bytes' => CustomAssetService::MAX_UPLOAD_BYTES,
]);
}
/**
* 텍스트 파일 본문 조회.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 본문 응답
*/
public function show(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$file = $this->service->read($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 본문 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.listed', $file);
}
/**
* 텍스트 파일 본문 저장 (없으면 생성).
*
* @param SaveExtensionCustomAssetRequest $request 검증된 요청 (`path`, `content`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 저장 결과
*/
public function store(SaveExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$saved = $this->service->save(
$this->resolveType($type),
$identifier,
$path,
(string) $request->validated('content'),
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 저장 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.saved', $saved);
}
/**
* 파일 업로드 (폰트·이미지 등 바이너리 포함).
*
* @param UploadExtensionCustomAssetRequest $request 검증된 요청 (`file`, `directory`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 업로드 결과
*/
public function upload(UploadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$directory = $request->validated('directory');
try {
$uploaded = $this->service->upload(
$this->resolveType($type),
$identifier,
$request->file('file'),
is_string($directory) ? $directory : null,
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 업로드 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => (string) $directory]);
}
return $this->success('custom_assets.messages.uploaded', $uploaded);
}
/**
* 파일 삭제.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 삭제 결과
*/
public function destroy(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$this->service->delete($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 삭제 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.delete_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.deleted', ['path' => $path]);
}
/**
* 라우트 타입 파라미터를 해석기 어휘로 바꿉니다.
*
* 라우트 정규식이 이미 세 값으로 제한하지만, 매핑에 없으면 그대로 넘긴다 —
* 해석기가 알 수 없는 타입을 무효로 판정해 422 를 만든다(빈 목록으로 조용히
* 통과시키지 않는다).
*
* @param string $type 라우트 파라미터
* @return string 해석기 어휘
*/
private function resolveType(string $type): string
{
return self::TYPE_MAP[$type] ?? $type;
}
}
@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\Admin;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\ExtensionStaticCacheService;
use App\Services\PermissionService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
@@ -88,8 +88,10 @@ class AdminTemplateAssetController extends AdminBaseController
);
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
// 확장 캐시 버전은 트레이트 게터로만 읽는다 — 원시 키 읽기는 `cache:clear` 직후 0 을
// 돌려주어 버전 없는 URL 을 만들고, 컨테이너 바인딩은 확장 네임스페이스로 누수될 수 있다.
// 게터는 키 부재 시 재생성하므로 항상 유효 버전이다 (`AssetUrl::staticExtBase` 와 동형).
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
return $this->success(
__('templates.messages.editor_assets_retrieved'),
@@ -228,7 +230,7 @@ class AdminTemplateAssetController extends AdminBaseController
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
// 코어 캐시 store 를 빗나갈 수 있다.
$cache = new CoreCacheDriver;
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
$mtime = (int) @filemtime($cssPath);
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
@@ -11,6 +11,7 @@ use App\Http\Resources\AttachmentResource;
use App\Models\Attachment;
use App\Services\AttachmentService;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
/**
@@ -21,7 +22,7 @@ class AttachmentController extends AdminBaseController
/**
* AttachmentController 생성자
*
* @param AttachmentService $attachmentService 첨부파일 서비스
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
@@ -32,7 +33,7 @@ class AttachmentController extends AdminBaseController
/**
* 단일 파일 업로드
*
* @param UploadAttachmentRequest $request 업로드 요청
* @param UploadAttachmentRequest $request 업로드 요청
* @return JsonResponse
*/
public function upload(UploadAttachmentRequest $request): JsonResponse
@@ -64,7 +65,7 @@ class AttachmentController extends AdminBaseController
/**
* 여러 파일 일괄 업로드
*
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
* @return JsonResponse
*/
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
@@ -117,7 +118,7 @@ class AttachmentController extends AdminBaseController
/**
* 순서 변경
*
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @return JsonResponse
*/
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
@@ -126,9 +127,12 @@ class AttachmentController extends AdminBaseController
$this->attachmentService->reorder($request->input('order'));
return $this->success('attachment.reorder_success');
} catch (AuthorizationException $e) {
// 스코프 밖 첨부가 포함된 경우. 아래 제네릭 catch 보다 앞에 둬야 한다 —
// 뒤에 두면 인가 거부가 500 으로 뭉개져 상세 경로(403)와 응답이 갈린다.
return $this->error('auth.scope_denied', 403, $e->getMessage());
} catch (Exception $e) {
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
}
}
}
+112 -12
View File
@@ -3,9 +3,13 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\Auth\AccountLockedException;
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
use App\Http\Requests\Auth\TwoFactorResendRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
@@ -13,10 +17,21 @@ use Illuminate\Validation\ValidationException;
class AuthController extends AdminBaseController
{
use BuildsAuthFailureResponses;
public function __construct(
private AuthService $authService
) {
parent::__construct();
// 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
// parent::__construct();
// 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가
// 식별하며, 관리자 여부는 코드 확인에 성공한 뒤 서비스가 돌려준 사용자로 판정한다.
$this->middleware(['auth:sanctum', 'admin'])->except([
'login',
'verifyTwoFactor',
'resendTwoFactor',
]);
}
/**
@@ -33,10 +48,21 @@ class AuthController extends AdminBaseController
$request->validated()['password']
);
// 2단계 인증이 켜져 있으면 아직 토큰도 사용자도 없다 — 관리자 판정은 코드 확인
// 뒤로 미룬다. 이 분기가 없으면 $data['user'] 가 없어 500 이 되고, 관리자까지
// 로그인할 수 없어 설정을 되돌릴 수단이 사라진다.
if ($data['two_factor_required'] ?? false) {
return $this->success('auth.two_factor_required', $data);
}
$user = $data['user'];
// 관리자 권한 확인
if (! $user->isAdmin()) {
// 이 시점에는 이미 토큰과 web 세션이 발급되어 있다 — 거절하면서 남겨 두면
// 관리자가 아닌 사용자가 응답만 403 을 받을 뿐 세션은 그대로 유효해진다.
$this->authService->revokeIssuedSession($user, $data['token']);
return $this->forbidden('auth.admin_required');
}
@@ -45,22 +71,92 @@ class AuthController extends AdminBaseController
return $this->success('auth.admin_login_success', $data);
} catch (AccountLockedException $e) {
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
return $this->lockedResponse($e);
} catch (TwoFactorDeliveryFailedException $e) {
return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
/**
* 관리자의 2단계 인증 코드를 확인하고 로그인을 완료합니다.
*
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
*
* @param TwoFactorChallengeRequest $request challenge 확인 요청
* @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
*/
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
{
$validated = $request->validated();
try {
$data = $this->authService->completeTwoFactor(
$validated['challenge_id'],
['code' => $validated['code']]
);
$user = $data['user'];
if (! $user->isAdmin()) {
// completeTwoFactor() 는 코드 확인에 성공한 시점에 토큰을 발급한다.
// 관리자 판정으로 거절하면서 그 발급분을 회수하지 않으면 관리자가 아닌
// 사용자가 유효한 세션을 손에 쥔 채 응답만 403 을 받는다.
$this->authService->revokeIssuedSession($user, $data['token']);
return $this->forbidden('auth.admin_required');
}
$data['user'] = new UserResource($user);
return $this->success('auth.admin_login_success', $data);
} catch (AccountLockedException $e) {
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다.
return $this->lockedResponse($e);
} catch (TwoFactorDeliveryFailedException $e) {
return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
* 관리자의 2단계 인증 코드를 재발송합니다.
*
* 기존 challenge 는 취소되고 새 challenge 가 발행되므로, 앞서 받은 인증번호는
* 더 이상 통하지 않습니다.
*
* @param TwoFactorResendRequest $request challenge 재발송 요청
* @return JsonResponse 새 challenge 정보를 포함한 JSON 응답
*/
public function resendTwoFactor(TwoFactorResendRequest $request): JsonResponse
{
try {
$resolvedUser = null;
$data = $this->authService->resendTwoFactorChallenge(
$request->validated()['challenge_id'],
$resolvedUser
);
// 이 단계는 토큰을 발급하지 않으므로 회수할 것이 없다 — 다만 완료할 수 없는
// 상대에게 새 인증번호를 계속 보내지는 않는다.
if ($resolvedUser === null || ! $resolvedUser->isAdmin()) {
return $this->forbidden('auth.admin_required');
}
return $this->success('auth.two_factor_required', $data);
} catch (AccountLockedException $e) {
return $this->lockedResponse($e);
} catch (TwoFactorDeliveryFailedException $e) {
return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.two_factor_invalid_challenge');
}
}
/**
* 관리자를 로그아웃시킵니다.
*
@@ -110,6 +206,10 @@ class AuthController extends AdminBaseController
}
return $this->success('common.success', $data);
} catch (AccountLockedException $e) {
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
return $this->lockedResponse($e);
} catch (ValidationException $e) {
return $this->unauthorized('auth.unauthenticated');
}
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 모델 직접 조회를 Service 경유로 바꾼 내부 리팩토링 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin\Identity;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -8,6 +10,7 @@ use App\Http\Requests\Admin\Identity\UpdateIdentityMessageTemplateRequest;
use App\Http\Resources\Admin\Identity\IdentityMessageTemplateResource;
use App\Models\IdentityMessageTemplate;
use App\Services\IdentityMessageTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -29,7 +32,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
*
* @param UpdateIdentityMessageTemplateRequest $request
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function update(UpdateIdentityMessageTemplateRequest $request, IdentityMessageTemplate $template)
{
@@ -54,7 +57,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 활성/비활성 토글.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function toggleActive(IdentityMessageTemplate $template)
{
@@ -79,13 +82,13 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 변수 치환 미리보기.
*
* @param PreviewIdentityMessageTemplateRequest $request
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function preview(PreviewIdentityMessageTemplateRequest $request)
{
try {
$payload = $request->validated();
$template = IdentityMessageTemplate::findOrFail($payload['template_id']);
$template = $this->templateService->findOrFailById((int) $payload['template_id']);
$rendered = $this->templateService->getPreview(
$template,
$payload['data'] ?? [],
@@ -104,7 +107,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 템플릿을 시더 기본값으로 복원.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function reset(IdentityMessageTemplate $template)
{
@@ -3,15 +3,16 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\LanguagePackSlotConflictException;
use App\Extension\Helpers\ChangelogParser;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
use App\Http\Requests\LanguagePack\BulkActivateRequest;
use App\Http\Requests\LanguagePack\IndexLanguagePackRequest;
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
use App\Http\Requests\LanguagePack\InstallFromFileRequest;
use App\Http\Requests\LanguagePack\InstallFromGithubRequest;
use App\Http\Requests\LanguagePack\InstallFromUrlRequest;
use App\Http\Requests\LanguagePack\ManifestPreviewRequest;
use App\Http\Requests\LanguagePack\UninstallLanguagePackRequest;
use App\Extension\Helpers\ChangelogParser;
use App\Http\Resources\LanguagePackCollection;
use App\Http\Resources\LanguagePackResource;
use App\Services\LanguagePackService;
@@ -66,7 +67,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.fetch_success', $payload);
} catch (\Throwable $e) {
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.fetch_failed', 500, $e);
}
}
@@ -117,7 +118,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -145,7 +146,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -173,7 +174,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -202,7 +203,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -237,17 +238,17 @@ class LanguagePackController extends AdminBaseController
'target' => (new LanguagePackResource($e->target))->toArray($request),
]);
} catch (\Throwable $e) {
return $this->error('language_packs.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.activate_failed', 500, $e);
}
}
/**
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
* @param BulkActivateRequest $request 요청
* @return JsonResponse 결과 (succeeded/failed 분리)
*/
public function bulkActivate(\App\Http\Requests\LanguagePack\BulkActivateRequest $request): JsonResponse
public function bulkActivate(BulkActivateRequest $request): JsonResponse
{
$ids = $request->validated('ids');
$result = $this->service->bulkActivate($ids);
@@ -278,7 +279,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($pack))->toArray($request)
);
} catch (\Throwable $e) {
return $this->error('language_packs.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.deactivate_failed', 500, $e);
}
}
@@ -302,7 +303,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.uninstall_success');
} catch (\Throwable $e) {
return $this->error('language_packs.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.uninstall_failed', 500, $e);
}
}
@@ -318,7 +319,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.check_updates_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.check_updates_failed', 500, $e);
}
}
@@ -345,7 +346,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($updated))->toArray($request)
);
} catch (\Throwable $e) {
return $this->error('language_packs.update_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.update_failed', 500, $e);
}
}
@@ -361,7 +362,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.refresh_cache_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.refresh_cache_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
// 예외 원문을 응답에 싣지 않는다 — 디버그 노출은 ResponseHelper 가
// app.debug 에서만 Throwable 을 펼치는 기존 메커니즘에 위임한다.
return $this->error('language_packs.refresh_cache_failed', 500, $e);
}
}
@@ -378,7 +381,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.preview_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.preview_failed', 422, $e->getMessage(), ['error' => $e->getMessage()]);
// 업로드 ZIP 검증 실패는 의도적 422 로 존치하되, 예외 원문은 싣지 않는다
// (다른 manifestPreview 컨트롤러 3종과 동형).
return $this->error('language_packs.preview_failed', 422, $e);
}
}
@@ -410,7 +415,7 @@ class LanguagePackController extends AdminBaseController
'has_changelog' => $entries !== [] || $rawContent !== '',
]);
} catch (\Throwable $e) {
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.fetch_failed', 500, $e);
}
}
}
@@ -12,6 +12,7 @@ use App\Http\Resources\MenuCollection;
use App\Http\Resources\MenuResource;
use App\Models\Menu;
use App\Services\MenuService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
@@ -222,6 +223,10 @@ class MenuController extends AdminBaseController
}
} catch (ValidationException $e) {
return $this->error('menu.order_update_failed', 422, $e->errors());
} catch (AuthorizationException $e) {
// 스코프 밖 메뉴가 포함된 경우. 제네릭 catch 보다 앞에 둬야 인가 거부가 500 으로
// 뭉개지지 않고 상세 경로(403)와 같은 응답이 된다.
return $this->error('auth.scope_denied', 403, $e->getMessage());
} catch (\Exception $e) {
return $this->error('menu.update_error', 500, $e->getMessage());
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Exceptions\ModuleOperationException;
use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -199,7 +200,7 @@ class ModuleController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []);
$module = $this->moduleService->installModule($moduleName, $vendorMode);
$module = $this->moduleService->installModule($moduleName, $vendorMode, false, $installFailureReason);
if ($module) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -210,7 +211,9 @@ class ModuleController extends AdminBaseController
return $this->success('module.install_success', $payload, 201);
} else {
return $this->error('module.install_failed');
return $this->error('module.install_failed', 400, null, [
'error' => $installFailureReason ?? __('modules.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -271,10 +274,12 @@ class ModuleController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
return $this->error('module.activate_failed');
return $this->error('module.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('module.activate_failed', 422, $e->errors());
return $this->error('module.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -320,10 +325,12 @@ class ModuleController extends AdminBaseController
return $this->success('module.deactivate_success', $result);
} else {
return $this->error('module.deactivate_failed');
return $this->error('module.deactivate_failed', 400, null, [
'error' => $result['reason'] ?? __('modules.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('module.deactivate_failed', 422, $e->errors());
return $this->error('module.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -383,15 +390,26 @@ class ModuleController extends AdminBaseController
$moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->moduleService->uninstallModule($moduleName, $deleteData);
$result = $this->moduleService->uninstallModule(
$moduleName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('module.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('module.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('module.uninstall_failed');
return $this->error('module.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('module.uninstall_failed', 422, $e->errors());
return $this->error('module.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -433,8 +451,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (ModuleOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -457,8 +477,10 @@ class ModuleController extends AdminBaseController
new ModuleResource($module),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (ModuleOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -476,7 +498,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('modules.check_updates_failed', 422, $e->errors());
return $this->error('modules.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -505,7 +527,7 @@ class ModuleController extends AdminBaseController
return $this->success('modules.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -596,10 +618,12 @@ class ModuleController extends AdminBaseController
new ModuleResource($module)
);
} else {
return $this->error('module.refresh_layouts_failed');
return $this->error('module.refresh_layouts_failed', 400, null, [
'error' => __('modules.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('module.refresh_layouts_failed', 422, $e->errors());
return $this->error('module.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -27,6 +29,9 @@ class NotificationController extends AdminBaseController
/**
* 알림 목록을 조회합니다.
*
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
* @return JsonResponse 알림 목록 응답
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
@@ -51,7 +56,11 @@ class NotificationController extends AdminBaseController
/**
* 미읽음 알림 수를 반환합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 미읽음 알림 수 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function unreadCount(Request $request): JsonResponse
{
try {
@@ -70,7 +79,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
@@ -93,6 +107,9 @@ class NotificationController extends AdminBaseController
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
* @return JsonResponse 일괄 읽음 처리 결과 응답
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
@@ -114,7 +131,11 @@ class NotificationController extends AdminBaseController
/**
* 모든 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAllAsRead(Request $request): JsonResponse
{
try {
@@ -133,7 +154,11 @@ class NotificationController extends AdminBaseController
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroyAll(Request $request): JsonResponse
{
try {
@@ -152,7 +177,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroy(Request $request, string $id): JsonResponse
{
try {
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Exceptions\PluginOperationException;
use App\Extension\Vendor\VendorMode;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -188,7 +189,7 @@ class PluginController extends AdminBaseController
// cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
$this->installSelectedDependencies($validated['dependencies'] ?? []);
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode);
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode, false, $installFailureReason);
if ($pluginInfo) {
// cascade 2단계: 동반 번들 언어팩 best-effort 설치
@@ -199,7 +200,9 @@ class PluginController extends AdminBaseController
return $this->success('plugins.install_success', $payload);
} else {
return $this->error('plugins.install_failed');
return $this->error('plugins.install_failed', 400, null, [
'error' => $installFailureReason ?? __('plugins.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -260,13 +263,16 @@ class PluginController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
return $this->error('plugins.activate_failed');
return $this->error('plugins.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.activate_validation_failed',
422,
$e->errors()
$e->errors(),
['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -317,13 +323,16 @@ class PluginController extends AdminBaseController
return $this->success('plugins.deactivate_success', $result);
} else {
return $this->error('plugins.deactivate_failed');
return $this->error('plugins.deactivate_failed', 400, null, [
'error' => $result['reason'] ?? __('plugins.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.deactivate_validation_failed',
422,
$e->errors()
$e->errors(),
['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -388,18 +397,30 @@ class PluginController extends AdminBaseController
$pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData);
$result = $this->pluginService->uninstallPlugin(
$pluginName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('plugins.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('plugins.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('plugins.uninstall_failed');
return $this->error('plugins.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.uninstall_validation_failed',
422,
$e->errors()
$e->errors(),
['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -444,8 +465,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (PluginOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -468,8 +491,10 @@ class PluginController extends AdminBaseController
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (PluginOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -487,7 +512,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('plugins.check_updates_failed', 422, $e->errors());
return $this->error('plugins.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -512,7 +537,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors());
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -608,13 +633,16 @@ class PluginController extends AdminBaseController
'unchanged' => $result['unchanged'],
]);
} else {
return $this->error('plugins.refresh_layouts_failed');
return $this->error('plugins.refresh_layouts_failed', 400, null, [
'error' => __('plugins.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error(
'plugins.refresh_layouts_validation_failed',
422,
$e->errors()
$e->errors(),
['error' => $e->getMessage()]
);
} catch (\Exception $e) {
return $this->error(
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Extension\PluginManager;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
use App\Services\DriverRegistryService;
use App\Services\PluginSettingsService;
use App\Support\SensitiveSettingMask;
use Illuminate\Http\JsonResponse;
@@ -49,6 +50,34 @@ class PluginSettingsController extends AdminBaseController
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
}
/**
* 설정 스키마가 public_asset_disk 키를 선언한 플러그인의 응답에
* 공개 자산 디스크 카탈로그(available_public_asset_disks)를 부착합니다.
*
* 화면이 /api/admin/settings(core.settings.read)를 따로 조회하게 두면 이
* 화면의 권한(core.plugins.read)과 표면이 갈려, 커스텀 역할에서 카탈로그만
* 조용히 비는 비대칭이 생깁니다. 설정 응답 단일 표면에서 함께 내려 권한 축을
* 일치시킵니다 (ecommerce 모듈 설정 응답의 동명 키와 동형 계약).
* 저장 시에는 스키마 기반 검증 whitelist 가 이 키를 걸러 설정에 남지 않습니다.
*
* @param string $identifier 플러그인 식별자
* @param array<string, mixed> $settings 응답에 실을 설정
* @return array<string, mixed> 카탈로그가 부착된 설정
*/
private function withPublicAssetCatalog(string $identifier, array $settings): array
{
$plugin = $this->pluginManager->getPlugin($identifier);
if ($plugin === null || ! array_key_exists('public_asset_disk', $plugin->getSettingsSchema())) {
return $settings;
}
$settings['available_public_asset_disks'] = app(DriverRegistryService::class)
->getAvailableDrivers('public_asset');
return $settings;
}
/**
* 플러그인 설정을 조회합니다.
*
@@ -63,7 +92,10 @@ class PluginSettingsController extends AdminBaseController
return $this->notFound('plugins.not_found');
}
return $this->success('common.success', $this->maskSensitive($identifier, $settings));
return $this->success(
'common.success',
$this->withPublicAssetCatalog($identifier, $this->maskSensitive($identifier, $settings))
);
}
/**
@@ -81,15 +113,22 @@ class PluginSettingsController extends AdminBaseController
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
$settings = $request->validated();
$result = $this->pluginSettingsService->save($identifier, $settings);
$result = $this->pluginSettingsService->save($identifier, $settings, $failureReason);
if (! $result) {
return $this->error('plugins.settings.update_failed', 500);
return $this->error('plugins.settings.update_failed', 500, null, [
'error' => $failureReason ?? __('plugins.errors.unknown_error'),
]);
}
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
// 누락 시 저장 직후 선택지가 비어 보인다 (ecommerce 저장 응답과 동형)
return $this->success(
'plugins.settings.updated',
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
$this->withPublicAssetCatalog(
$identifier,
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
)
);
}
@@ -2,20 +2,23 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CannotModifyProtectedRoleException;
use App\Exceptions\ExtensionOwnedRoleDeleteException;
use App\Exceptions\PermissionEscalationException;
use App\Exceptions\SystemRoleDeleteException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Role\ActiveRolesRequest;
use App\Http\Requests\Role\RoleListRequest;
use App\Http\Requests\Role\StoreRoleRequest;
use App\Http\Requests\Role\UpdateRoleRequest;
use App\Http\Resources\RoleCollection;
use App\Http\Resources\RoleResource;
use App\Models\Role;
use App\Models\User;
use App\Services\RoleService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
@@ -60,13 +63,13 @@ class RoleController extends AdminBaseController
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
*
* @param Request $request HTTP 요청 객체
* @param ActiveRolesRequest $request 활성 역할 조회 요청
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
*/
public function active(Request $request): JsonResponse
public function active(ActiveRolesRequest $request): JsonResponse
{
try {
/** @var \App\Models\User $user */
/** @var User $user */
$user = $request->user();
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
@@ -78,7 +81,10 @@ class RoleController extends AdminBaseController
return $this->success('role.fetch_success', [
'data' => RoleResource::collection($roles),
'abilities' => [
'can_assign_roles' => PermissionHelper::check('core.permissions.update'),
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
// (core.permissions.update)이 아니다. 부여 가능한 개별 역할의 범위는 서버
// 상한(PermissionEscalationGuard)이 역할별로 강제한다.
'can_assign_roles' => PermissionHelper::check('core.users.update'),
],
]);
} catch (Exception $e) {
@@ -122,6 +128,8 @@ class RoleController extends AdminBaseController
new RoleResource($role),
201
);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('role.create_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -145,6 +153,10 @@ class RoleController extends AdminBaseController
'role.update_success',
new RoleResource($updatedRole)
);
} catch (CannotModifyProtectedRoleException $e) {
return $this->error('exceptions.cannot_modify_protected_role', 403);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('role.update_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -174,6 +186,8 @@ class RoleController extends AdminBaseController
} else {
return $this->error('role.update_failed');
}
} catch (CannotModifyProtectedRoleException $e) {
return $this->error('exceptions.cannot_modify_protected_role', 403);
} catch (Exception $e) {
return $this->error('role.update_failed', 500, $e->getMessage());
}
@@ -8,11 +8,16 @@ use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
use App\Http\Requests\Settings\TestOutboundProxyRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\ExtensionStaticCacheService;
use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use App\Support\EnvPriority;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
@@ -27,11 +32,34 @@ class SettingsController extends AdminBaseController
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester,
private DriverRegistryService $driverRegistryService
private DriverRegistryService $driverRegistryService,
private OutboundProxyTester $outboundProxyTester,
private ExtensionStaticCacheService $staticCacheService
) {
parent::__construct();
}
/**
* 설정 응답에 동봉할 `_meta` 를 만듭니다.
*
* 조회와 저장 두 응답이 같은 모양이어야 화면이 저장 직후에도 같은 판정을 이어갑니다 —
* 한쪽만 필드가 늘면 저장 후 잠금 표시가 조용히 사라집니다.
*
* - `limits`: 입력 한계값 (core.settings_limits)
* - `env_priority_enabled`: `.env` 우선 모드 활성 여부 (안내 배너 표시 판정)
* - `env_locked`: `.env` 로 잠긴 필드 목록 (프론트엔드 키 기준)
*
* @return array<string, mixed> 응답 메타 배열
*/
private function buildSettingsMeta(): array
{
return [
'limits' => config('core.settings_limits', []),
'env_priority_enabled' => EnvPriority::enabled(),
'env_locked' => $this->settingsService->envLockedMeta(),
];
}
/**
* 모든 시스템 설정을 조회합니다.
*
@@ -42,7 +70,7 @@ class SettingsController extends AdminBaseController
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
$settings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
@@ -70,7 +98,7 @@ class SettingsController extends AdminBaseController
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
$allSettings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.save_success', [
'settings' => $allSettings,
@@ -147,6 +175,56 @@ class SettingsController extends AdminBaseController
}
}
/**
* 신뢰 프록시(리버스 프록시) 설정 진단 결과를 조회합니다 (#124).
*
* 읽기 전용이다 — 값 편집 엔드포인트는 두지 않는다. 이 값은 "앱이 프록시 없이 도달
* 가능한가" 라는 배포 구조 지식이 있어야 정할 수 있고, 웹에서 편집 가능해지면 관리자
* 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. 편집은 `.env` 전용이다.
*
* 판정 대상은 관리자 브라우저의 **실제 요청**이므로 현재 요청을 그대로 쓴다.
* 입력을 받지 않는 읽기 전용 조회라 FormRequest 를 두지 않는다.
*
* @return JsonResponse 진단 결과 JSON 응답
*/
public function trustedProxy(): JsonResponse
{
return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request()));
}
/**
* 초기 화면 정적 파일(부트스트랩 리소스 정적 게시) 상태를 조회합니다 (#651).
*
* CLI `ext-static:status` 와 **같은 판정**(`ExtensionStaticCacheService::statusReport`)을 돌려준다.
* 읽기 전용이며 입력이 없어 FormRequest 를 두지 않는다 (`trustedProxy` 와 동형).
*
* @return JsonResponse 상태 보고서 JSON 응답
*/
public function staticCacheStatus(): JsonResponse
{
return $this->success('settings.static_cache_status_loaded', $this->staticCacheService->statusReport());
}
/**
* 초기 화면 정적 파일을 지금 다시 만듭니다 (관리자 수동 복구, #651).
*
* 캐시 버전을 올리고 현재 버전을 강제 재게시한다. 게시 실패·게시가 쓰이지 않는 환경은
* 요청 처리 실패가 아니라 **진단 결과**라 HTTP 200 으로 돌려주고 성공 여부는 페이로드
* (`republished`)가 말한다 — 연결 테스트·드라이버 테스트와 같은 규약. 사이트는 어느 경우에도
* API 폴백으로 정상이다.
*
* @return JsonResponse 재게시 결과 + 상태 보고서 JSON 응답
*/
public function republishStaticCache(): JsonResponse
{
$result = $this->staticCacheService->republish();
return $this->success(
($result['republished'] ?? false) ? 'settings.static_cache_republished' : 'settings.static_cache_republish_failed',
$result
);
}
/**
* 시스템 캐시를 정리합니다.
*
@@ -188,23 +266,21 @@ class SettingsController extends AdminBaseController
}
/**
* 데이터베이스를 백업합니다.
* 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다.
*
* @return JsonResponse 백업 결과 JSON 응답
* 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다.
* 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고,
* PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서
* 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3).
*
* 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다.
* 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다.
*
* @return JsonResponse 미제공 안내 JSON 응답 (501)
*/
public function backupDatabase(): JsonResponse
{
try {
$result = $this->settingsService->backupDatabase();
if ($result) {
return $this->success('settings.backup_success');
} else {
return $this->error('settings.backup_failed');
}
} catch (\Exception $e) {
return $this->error('settings.backup_error', 500, $e->getMessage());
}
return $this->error('settings.database_backup_unavailable', 501);
}
/**
@@ -342,4 +418,30 @@ class SettingsController extends AdminBaseController
return $this->error('settings.driver_test_error', 500, $e->getMessage());
}
}
/**
* 아웃바운드 프록시 연결을 테스트합니다.
*
* 저장하기 전에 프록시가 실제로 동작하는지, 그리고 그 프록시를 거쳐 나갔을 때 상대편에
* 어떤 IP 로 보이는지 확인합니다. 출발지 IP 는 운영자가 결제사·외부 서비스에 등록해야
* 하는 값이라 결과의 핵심입니다.
*
* 검사 대상은 저장된 설정이 아니라 이번 요청이 제출한 값입니다.
*
* @param TestOutboundProxyRequest $request 검증된 요청
* @return JsonResponse 검사 결과
*/
public function testOutboundProxy(TestOutboundProxyRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->outboundProxyTester->test(
(string) $validated['outbound_proxy'],
(array) ($validated['outbound_proxy_bypass'] ?? [])
);
// 연결 실패는 요청 처리 실패가 아니라 진단 결과다 — 200 으로 결과를 돌려주고
// 성공 여부는 페이로드가 말한다 (드라이버 연결 테스트와 같은 규약).
return $this->success($result['message_key'], $result);
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Exceptions\TemplateOperationException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
@@ -169,7 +170,9 @@ class TemplateController extends AdminBaseController
return $this->success('templates.install_success', $payload, 201);
} else {
return $this->error('templates.install_failed');
return $this->error('templates.install_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
@@ -230,10 +233,12 @@ class TemplateController extends AdminBaseController
'pending_language_packs' => $pendingLanguagePacks,
]));
} else {
return $this->error('templates.activate_failed');
return $this->error('templates.activate_failed', 400, null, [
'error' => $result['reason'] ?? __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.activate_failed', 422, $e->errors());
return $this->error('templates.activate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -249,7 +254,7 @@ class TemplateController extends AdminBaseController
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->deactivateTemplate($templateName);
$template = $this->templateService->deactivateTemplate($templateName, $deactivateFailureReason);
if ($template) {
return $this->successWithResource(
@@ -257,10 +262,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template)
);
} else {
return $this->error('templates.deactivate_failed');
return $this->error('templates.deactivate_failed', 400, null, [
'error' => $deactivateFailureReason ?? __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.deactivate_failed', 422, $e->errors());
return $this->error('templates.deactivate_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -279,15 +286,21 @@ class TemplateController extends AdminBaseController
$templateName = $validated['template_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->templateService->uninstallTemplate($templateName, $deleteData);
$result = $this->templateService->uninstallTemplate($templateName, $deleteData, $preservedBackups);
if ($result) {
return $this->success('templates.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('templates.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('templates.uninstall_failed');
return $this->error('templates.uninstall_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.uninstall_failed', 422, $e->errors());
return $this->error('templates.uninstall_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -348,8 +361,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (TemplateOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -372,8 +387,10 @@ class TemplateController extends AdminBaseController
new TemplateResource($template),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (TemplateOperationException $e) {
// 원본 키와 파라미터를 보존해 넘긴다 — 이미 번역된 getMessage() 를 키 자리에
// 넘기면 키 해석에 실패해 그 문장이 그대로 나간다 (상태코드는 기존 계약 유지).
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
@@ -397,10 +414,12 @@ class TemplateController extends AdminBaseController
new TemplateResource($template)
);
} else {
return $this->error('templates.refresh_layouts_failed');
return $this->error('templates.refresh_layouts_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
]);
}
} catch (ValidationException $e) {
return $this->error('templates.refresh_layouts_failed', 422, $e->errors());
return $this->error('templates.refresh_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -418,7 +437,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_updates_failed', 422, $e->errors());
return $this->error('templates.check_updates_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -446,7 +465,7 @@ class TemplateController extends AdminBaseController
return $this->success('templates.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors());
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors(), ['error' => $e->getMessage()]);
} catch (\Exception $e) {
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
@@ -1,8 +1,12 @@
<?php
// audit:allow api-doc-coverage reason: 응답 계약 불변 — lang 키에서 :error 플레이스홀더가 제거되어 사문화된 messageParams 인자만 정리 (키·상태코드·payload 형태 무변경)
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Exceptions\CannotModifySuperAdminException;
use App\Exceptions\PermissionEscalationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
use App\Http\Requests\User\CheckEmailRequest;
@@ -74,6 +78,8 @@ class UserController extends AdminBaseController
new UserResource($user),
201
);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('user.create_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -122,10 +128,14 @@ class UserController extends AdminBaseController
'user.update_success',
new UserResource($updatedUser)
);
} catch (CannotModifySuperAdminException $e) {
return $this->error('exceptions.cannot_modify_super_admin', 403);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('user.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
return $this->error('user.update_failed', 500, $e);
}
}
@@ -148,8 +158,10 @@ class UserController extends AdminBaseController
'auth.account_unlocked',
new UserResource($unlocked)
);
} catch (CannotModifySuperAdminException $e) {
return $this->error('exceptions.cannot_modify_super_admin', 403);
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
return $this->error('user.update_failed', 500, $e);
}
}
@@ -317,9 +329,13 @@ class UserController extends AdminBaseController
$validated = $request->validated();
$result = $this->userService->bulkUpdateStatus($validated['ids'], $validated['status']);
// 메시지 키(user.bulk_status_updated)가 :count 플레이스홀더를 가지므로
// 치환 파라미터를 함께 전달한다 (생략 시 원문 ":count명의 …" 이 그대로 노출)
return $this->success(
'user.bulk_status_updated',
$result
$result,
200,
['count' => $result['updated_count'] ?? 0]
);
} catch (ValidationException $e) {
return $this->error('user.bulk_update_status_failed', 422, $e->errors());

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