89 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
2405 changed files with 340462 additions and 7892 deletions
+45 -1
View File
@@ -3,7 +3,17 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.8
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
@@ -108,6 +118,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ── .env 키 단위 우선 (선택) ────────────────────────────────────────────────
# 기본값은 관리자 환경설정(admin UI)이 운영 SSoT 이고, 저장된 값이 아래 항목들의
# .env 값을 덮습니다. 이 스위치를 켜면 .env 에 **값이 명시된 항목만** 그 덮어쓰기에서
# 빠져 .env 가 권위를 갖고, 관리자 화면의 해당 필드는 편집 불가로 표시됩니다.
# 켜지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
# 주의: 이 파일은 값을 채워 배포되므로, 켜는 즉시 APP_NAME·LOG_LEVEL·
# G7_UPDATE_GITHUB_URL 같은 이미 채워진 항목이 함께 잠깁니다.
# .env 만 편집한 뒤에는 php artisan config:cache 를 다시 실행해야 반영됩니다.
# 대상 항목과 규약: docs/backend/admin-settings-access.md
# G7_ENV_PRIORITY=true
# 코어 업데이트
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
@@ -117,3 +138,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
+15 -1
View File
@@ -3,7 +3,17 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.8
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
@@ -97,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/
+403 -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` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -168,6 +165,7 @@
| `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
| 금지 | 올바른 사용 |
@@ -287,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 를 검사한다.
### 핸들러 정의
| 금지 | 올바른 사용 |
@@ -334,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`)를 손실 없이 보존해야 한다.
@@ -414,6 +527,177 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
### 서버가 조건에 따라 다른 형태의 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건).
@@ -432,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` 는 참 그대로 남는다.
@@ -448,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()` 재유입과 표시값·저장 게이트는 계약 테스트가 잠근다. 부재를 값으로 읽는 자리는 의미 판정 영역이라 정적 검사가 덮지 못한다 — 게이트·기본값·폴백·파생 네 축을 각각 고정하는 회귀 테스트가 그 자리를 잠근다
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
@@ -493,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)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
@@ -748,6 +1104,7 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
- `## [버전] - YYYY-MM-DD` 헤더 필수
- `### Added` / `### Changed` / `### Fixed` / `### Removed` 카테고리 사용
- 최신 버전이 파일 상단
- 한 버전 섹션 안에 같은 카테고리 헤딩은 한 번만 — 브랜치마다 섹션 머리에 자기 `### Fixed` 블록을 얹고 병합이 양쪽을 이어 붙이면 같은 제목이 두 번 남는다. 항목은 기존 카테고리 블록 끝에 추가하고, 병합 뒤 두 번째 블록이 보이면 그 항목을 첫 블록에 합치고 헤딩만 지운다(항목 삭제 금지). 같은 버전 헤더 중복·허용 밖 카테고리도 같은 결함군이며 정적 검사가 working 버전 섹션에서 차단한다. 공개 배포된 과거 섹션은 소급 수정하지 않는다
---
@@ -781,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 의 토스트 컴포넌트 블록
```
---
## 테스트 프로토콜
@@ -1166,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)
@@ -1196,7 +1569,23 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production` (`--full` 은 앱 번들 전용 — `public/build` 를 비워 코어 3번들을 지운다).
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production`.
### 빌드는 자기 산출물만 교체한다 (`emptyOutDir`)
모든 vite config 는 `build.emptyOutDir: false` 를 **명시**한다. 기본값 `true` 는 산출물 디렉토리를 통째로 비우는데, 그 디렉토리에는 vite 가 만들지 않는 서빙 자산이 함께 산다.
| 함께 지워지던 것 | 결과 |
|---|---|
| `public/build/core/` 3번들 | 폴백이 없다 — `template-engine.min.js` 는 동기 classic 스크립트라 소실 = **사이트 부팅 불가**, 안내 화면조차 렌더되지 않는다 |
| `public/build/ext/{v}/` 게시본 | 이미 배달된 HTML 의 immutable URL 이 404. 재게시로 새 버전이 생겨도 **그 URL 은 복구되지 않는다** |
| 확장 `dist/vendor/` | 확장이 동봉한 구동 제3자 자산 소실 (자체 제공 원칙 위반) |
소실은 예외도 서버 로그도 남기지 않는다 — 브라우저 404 로만 나타나므로 운영자에게는 흔적이 없다. 잔존하는 구 해시 산출물은 `manifest.json`(또는 고정 파일명)이 선택하므로 참조되지 않는 사표이고, 정리 책임은 빌드 커맨드가 진다.
빌드 커맨드의 산출물 정리는 **활성 디렉토리를 건너뛴다.** 정리는 빌드 *전에* 돌므로 웹이 서빙 중인 `dist/` 를 비우면 빌드 완료까지가 통째로 서빙 공백이 되고, 빌드가 실패하면 빈 채로 남는다. 정리 대상은 `_bundled` / `_pending` 소스 디렉토리뿐이다.
정적 검사가 모든 vite config 의 명시 선언을 강제한다 (기본값 의존 금지 — 규약이 코드에 남지 않으면 다음 편집자가 같은 결함을 재도입한다).
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
@@ -1283,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) |
+156
View File
@@ -4,6 +4,162 @@
형식은 [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
+120 -9
View File
@@ -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.8 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)를 참조하세요.
+14 -13
View File
@@ -1,16 +1,12 @@
<p align="center"><a href="README.md">English</a> | 한국어</p>
<p align="center">
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
</p>
# 그누보드7
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
A modern, extensible CMS platform built with Laravel + React
<p align="center">
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
A modern, extensible CMS platform built with Laravel + React
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-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>
@@ -373,6 +369,8 @@ cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
```
> Composer 의존성은 설치 마법사가 운영용 구성(`--no-dev`)으로 자동 설치합니다. 직접 설치하려면 `composer install --no-dev --optimize-autoloader` 를 사용하세요.
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
---
@@ -401,6 +399,7 @@ cp .env.example .env
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
### 템플릿
@@ -423,6 +422,7 @@ cp .env.example .env
| **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 결제 플러그인 일본어 |
@@ -514,17 +514,18 @@ cp .env.example .env
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></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/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>
+17 -15
View File
@@ -1,16 +1,12 @@
<p align="center">English | <a href="README.ko.md">한국어</a></p>
<p align="center">
<img src="https://img.shields.io/badge/Gnuboard7-그누보드7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="Gnuboard7 (그누보드7)">
</p>
# Gnuboard7
**A modern, extensible CMS platform built with Laravel + React**
The next generation of Gnuboard — Korea's most widely used open-source CMS
<p align="center">
<strong>A modern, extensible CMS platform built with Laravel + React</strong><br>
The next generation of Gnuboard — Korea's most widely used open-source CMS
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.8-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>
@@ -373,8 +369,11 @@ Every verification point — signup, password reset, sensitive operations, the m
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. Install PHP dependencies
composer install
# 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
@@ -413,6 +412,7 @@ The setup wizard creates the application key, configures the database connection
| **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
@@ -435,6 +435,7 @@ Official language packs you can install alongside the initial setup, so the core
| **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 |
@@ -528,17 +529,18 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></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/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>
@@ -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,453 @@
<?php
namespace App\Console\Commands\Extension;
use App\Support\ExtensionDoc\ExtensionDocContext;
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
use App\Support\ExtensionDoc\ExtensionInventory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
/**
* 확장 개발자 문서 생성 커맨드
*
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
*
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
*/
class ExtDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'ext:docgen
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
{--check : 생성하지 않고 누락·드리프트만 리포트}
{--json : 기계 판독 출력}
{--dry-run : 대상과 실측 집계만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
/**
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
*/
private array $malformed = [];
/**
* 커맨드를 실행합니다.
*
* @param ExtensionInventory $inventory 번들 확장 인벤토리
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return int 종료 코드
*/
public function handle(
ExtensionInventory $inventory,
ExtensionDocContext $context,
ExtensionDocScaffolder $scaffolder
): int {
$scope = (string) $this->option('scope');
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
// 함께 쓸 수 없다고 명시적으로 거부한다.
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
$conflict = match (true) {
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
default => null,
};
if ($conflict !== null) {
$message = $conflict;
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $message],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($message);
return self::FAILURE;
}
try {
$records = $inventory->collect($scope);
$this->malformed = $inventory->malformed();
} catch (InvalidArgumentException $e) {
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($e->getMessage());
return self::FAILURE;
}
if ($records === []) {
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
if ($this->option('json')) {
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return self::FAILURE;
}
$this->warn($message);
return self::FAILURE;
}
$results = [];
foreach ($records as $record) {
$ctx = $context->build($record);
$results[] = $this->processExtension($ctx, $scaffolder);
}
return $this->report($results, $scope);
}
/**
* 단일 확장을 처리합니다.
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return array<string, mixed> 처리 결과
*/
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
{
$record = $ctx['record'];
$stats = ExtensionDocScaffolder::statsOf($ctx);
$result = [
'type' => $record['type'],
'id' => $record['id'],
'relPath' => $record['relPath'],
'version' => $record['version'],
'stats' => $stats,
'surfaceAvailable' => $ctx['surface']['available'],
'surfaceReason' => $ctx['surface']['reason'],
'surfaceErrors' => $ctx['surface']['errors'],
'documents' => [],
'created' => [],
'updated' => [],
'missingDocuments' => [],
'missingSections' => [],
'missingBlocks' => [],
'driftedBlocks' => [],
'orphanBlocks' => [],
'unfilled' => [],
];
if ($this->option('dry-run')) {
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
return $result;
}
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
if ($this->option('init') && ! $this->option('check')) {
$this->initSkeletons($ctx, $scaffolder, $result);
}
$bodies = $scaffolder->renderBlocks($ctx);
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
if (! is_file($abs)) {
$result['missingDocuments'][] = $doc;
continue;
}
$content = (string) File::get($abs);
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
$result['missingSections'][] = $doc.' → '.$section;
}
}
// 미채움 마커 잔량
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
$count = substr_count($content, $marker);
if ($count > 0) {
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
}
}
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
if ($emptyIntents > 0) {
$result['unfilled'][] = [
'doc' => $doc,
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
'count' => $emptyIntents,
];
}
$docBodies = [];
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
if (array_key_exists($key, $bodies)) {
$docBodies[$key] = $bodies[$key];
}
}
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
foreach ($merged['missing'] as $key) {
$result['missingBlocks'][] = $doc.' → '.$key;
}
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
$result['orphanBlocks'][] = $doc.' → '.$key;
}
}
if ($this->option('check')) {
if (! $merged['unchanged']) {
foreach ($merged['replaced'] as $key) {
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
$result['driftedBlocks'][] = $doc.' → '.$key;
}
}
}
continue;
}
if (! $merged['unchanged']) {
File::put($abs, $merged['content']);
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
if (! in_array($doc, $result['created'], true)) {
$result['updated'][] = $doc;
}
}
}
return $result;
}
/**
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @param array<string, mixed> $result 처리 결과 (created 누적)
*/
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
{
$record = $ctx['record'];
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (is_file($abs)) {
continue;
}
File::ensureDirectoryExists(dirname($abs));
File::put($abs, $scaffolder->skeleton($doc, $ctx));
$result['created'][] = $doc;
}
}
/**
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
*
* @param string $content 문서 내용
* @param string $key 블록 키
* @param string $body 새 본문
* @return bool 다르면 true
*/
private function blockDiffers(string $content, string $key, string $body): bool
{
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
return false;
}
$from = (int) $sm[0][1] + strlen($sm[0][0]);
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
return false;
}
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
return $existing !== trim($body);
}
/**
* 처리 결과를 출력하고 종료 코드를 결정합니다.
*
* @param array<int, array<string, mixed>> $results 확장별 결과
* @param string $scope 범위
* @return int 종료 코드
*/
private function report(array $results, string $scope): int
{
$issues = 0;
foreach ($results as $result) {
$issues += count($result['missingDocuments'])
+ count($result['missingSections'])
+ count($result['missingBlocks'])
+ count($result['driftedBlocks'])
+ count($result['orphanBlocks']);
}
if ($this->option('json')) {
$this->line((string) json_encode([
'scope' => $scope,
'mode' => $this->mode(),
'extensions' => $results,
'malformed' => $this->malformed,
'issues' => $issues,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info(count($results).'개 확장 (scope='.$scope.')');
$this->newLine();
foreach ($results as $result) {
$parts = [];
foreach ($result['stats'] as $label => $value) {
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
$parts[] = $value === null
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
: "{$label} {$value}";
}
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
$this->line(' '.implode(' · ', $parts));
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
$this->line(' 선언형 표면: '.$result['surfaceReason']);
}
if ($result['surfaceErrors'] !== []) {
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->line(" ⚠ {$getter}(): {$message}");
}
}
}
return self::SUCCESS;
}
foreach ($results as $result) {
$label = sprintf('[%s] %s', $result['type'], $result['id']);
if ($result['created'] !== []) {
$this->info("{$label} 생성: ".implode(', ', $result['created']));
}
if ($result['updated'] !== []) {
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
}
if ($result['missingDocuments'] !== []) {
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
}
if ($result['missingBlocks'] !== []) {
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
}
if ($result['missingSections'] !== []) {
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
}
if ($result['driftedBlocks'] !== []) {
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
}
if ($result['orphanBlocks'] !== []) {
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
}
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
if ($result['unfilled'] !== []) {
$total = array_sum(array_column($result['unfilled'], 'count'));
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
$result['unfilled'],
)));
}
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
}
}
foreach ($this->malformed as $bad) {
$this->warn(sprintf(
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
));
}
$this->newLine();
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
if ($this->option('check') && $issues > 0) {
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* 현재 실행 모드를 반환합니다.
*
* @return string 모드 문자열
*/
private function mode(): string
{
if ($this->option('dry-run')) {
return 'dry-run';
}
if ($this->option('check')) {
return 'check';
}
if ($this->option('init')) {
return 'init';
}
return 'update';
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
@@ -13,6 +14,7 @@ class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -189,6 +191,14 @@ class BuildModuleCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -95,7 +95,13 @@ class UninstallModuleCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(ModuleManager::UNINSTALL_STEPS);
try {
$result = $this->moduleManager->uninstallModule($identifier, $deleteData, $onProgress);
$result = $this->moduleManager->uninstallModule(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -108,6 +114,16 @@ class UninstallModuleCommand extends Command
$this->info(' - '.__('modules.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('modules.commands.uninstall.menus_deleted', ['count' => $menusCount]));
$this->info(' - '.__('modules.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('modules.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('modules.commands.uninstall.success', ['module' => $identifier]));
return Command::SUCCESS;
@@ -56,6 +56,9 @@ class PlaywrightIssueToken extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -104,6 +104,9 @@ class PlaywrightSeedLayout extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -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,
@@ -92,7 +92,13 @@ class UninstallPluginCommand extends Command
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(PluginManager::UNINSTALL_STEPS);
try {
$result = $this->pluginManager->uninstallPlugin($identifier, $deleteData, $onProgress);
$result = $this->pluginManager->uninstallPlugin(
$identifier,
$deleteData,
$onProgress,
$uninstallFailureReason,
$preservedBackups
);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -104,6 +110,16 @@ class UninstallPluginCommand extends Command
$this->info(' - '.__('plugins.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
$this->info(' - '.__('plugins.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('plugins.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('plugins.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
return Command::SUCCESS;
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
/**
* 부트스트랩 리소스 정적 게시(bake)를 수동 수행하는 커맨드
*
* 수명주기 이벤트(terminating 트리거)와 blade 자가 치유가 정상 경로지만,
* 배포 직후 워밍이나 수동 복구가 필요할 때 이 커맨드로 즉시 게시한다.
* 설치기 완료 단계에서도 호출된다 (#122).
*/
class PublishExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:publish {--force : 게시 완료 상태여도 강제 재게시}';
/**
* The console command description.
*/
protected $description = '부트스트랩 리소스(다국어·컴포넌트·라우트·번들·템플릿 에셋)를 정적 파일로 게시합니다';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionStaticCacheService $service): int
{
if (! $service->isEnabled()) {
$this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
return self::SUCCESS;
}
// root 로 실행 중이면 경고만 하고 **계속 진행**한다 — 명시적 명령은 운영자 책임이고, 중단 가드는
// 두지 않는다(#651 D3). 게시가 만드는 캐시 락 샤드·병합 번들이 root 소유로 남으면 이후 웹 요청의
// 캐시 쓰기가 Permission denied 로 죽으므로(전면 500 실사례) 웹 계정 실행 형태를 함께 적는다.
if (StatusExtensionStaticCacheCommand::isRootProcess()) {
$this->warn('root 로 실행 중입니다 — 이 계정으로 게시하면 캐시 폴더·병합 번들이 root 소유가 되어 이후 웹 요청이 실패할 수 있습니다.');
$this->warn('웹 계정으로 실행하세요: '.StatusExtensionStaticCacheCommand::publishCommandHint());
}
$published = $service->publishCurrent(force: (bool) $this->option('force'));
if (! $published) {
$this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
return self::FAILURE;
}
$this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
return self::SUCCESS;
}
}
@@ -0,0 +1,482 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
/**
* 의존성 취약점 전수 점검 커맨드 (#126)
*
* 이 저장소는 잠금파일이 60개 가까이 흩어져 있다 — 코어 하나와 확장 수십 개가 각자
* `package-lock.json` / `composer.lock` 을 갖는다. 루트만 감사하면 확장 전부가 사각이
* 되는데, 그 사각은 오류도 경고도 남기지 않는다: 취약한 라이브러리가 정상 동작하는 것이
* 유일한 증상이다.
*
* 동봉(vendored) 자산은 어떤 잠금파일에도 없어 `npm audit` 이 **원리상** 볼 수 없다.
* 그 축은 판정하지 않고 목록으로 노출한다 — 사람이 봐야 하는 대상이기 때문이다.
*
* 종료 코드: 취약점이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
* **조치 대상 발견 신호**다.
*/
class SecurityAuditDependenciesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'security:audit-dependencies
{--json : 결과를 JSON 으로 출력합니다}
{--npm-only : npm 잠금파일만 점검합니다}
{--composer-only : composer 잠금파일만 점검합니다}';
/**
* The console command description.
*/
protected $description = '저장소의 모든 잠금파일(npm·composer) 운영 의존성 취약점을 점검합니다 (취약점 발견 시 비-0 종료)';
/** 잠금파일 탐색에서 제외할 경로 조각 */
private const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', '_pending', 'storage'];
/** 개별 감사 프로세스 제한 시간(초) */
private const PROCESS_TIMEOUT = 300;
/**
* Execute the console command.
*
* @return int 취약점이 없으면 0, 있으면 1
*/
public function handle(): int
{
$npmOnly = (bool) $this->option('npm-only');
$composerOnly = (bool) $this->option('composer-only');
$results = [];
if (! $composerOnly) {
foreach ($this->lockFiles('package-lock.json') as $lock) {
$results[] = $this->auditNpm($lock);
}
}
if (! $npmOnly) {
foreach ($this->lockFiles('composer.lock') as $lock) {
$results[] = $this->auditComposer($lock);
}
}
$vendored = $this->vendoredAssets();
if ($this->option('json')) {
$this->line((string) json_encode([
'results' => $results,
'vendored_assets' => $vendored,
'totals' => $this->totals($results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $this->totals($results)['vulnerable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->renderTable($results);
$this->renderVendored($vendored);
$totals = $this->totals($results);
$this->line('');
if ($totals['unmeasurable'] > 0) {
$this->warn(sprintf(
'점검 불가 %d건 — 감사 도구가 실행되지 않았습니다. "취약점 없음" 과 다릅니다.',
$totals['unmeasurable']
));
}
if ($totals['vulnerable'] === 0) {
$this->info(sprintf(
'운영 의존성 취약점 0건 (감사 %d개 / 대상 없음 %d개 / 발견 %d개 잠금파일).',
$totals['audited'],
$totals['empty'],
$totals['checked']
));
return $totals['unmeasurable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->error(sprintf(
'%d개 잠금파일에서 운영 의존성 취약점이 발견되었습니다 (총 %d건).',
$totals['vulnerable'],
$totals['advisories']
));
return self::FAILURE;
}
/**
* 저장소에서 잠금파일을 전수 탐색합니다.
*
* @param string $fileName 잠금파일 이름
* @return array<int, string> 잠금파일 절대 경로 목록
*/
private function lockFiles(string $fileName): array
{
$root = base_path();
$found = [];
// 제외 디렉토리는 **내려가기 전에** 잘라낸다. 파일 단계에서 거르면 `node_modules`
// 수만 개를 전부 훑고 나서 버리게 되어 한 번 실행에 수 분이 걸린다.
$directories = new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS);
$pruned = new \RecursiveCallbackFilterIterator(
$directories,
function (\SplFileInfo $entry): bool {
if (! $entry->isDir()) {
return true;
}
return ! in_array($entry->getFilename(), self::EXCLUDED_SEGMENTS, true);
}
);
foreach (new \RecursiveIteratorIterator($pruned) as $entry) {
if (! $entry->isFile() || $entry->getFilename() !== $fileName) {
continue;
}
$found[] = $entry->getPathname();
}
sort($found);
return $found;
}
/**
* npm 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditNpm(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'dependencies'])) {
return $this->emptyLock($lockPath, 'npm');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['npm', 'audit', '--omit=dev', '--json'], $dir);
$decoded = json_decode($process['output'], true);
if (! is_array($decoded) || ! isset($decoded['metadata']['vulnerabilities'])) {
return $this->unmeasurable($lockPath, 'npm', trim($process['error']) ?: 'npm audit 출력을 해석할 수 없습니다.');
}
$severities = array_filter(
array_map('intval', $decoded['metadata']['vulnerabilities']),
static fn (int $count, string $key): bool => $count > 0 && $key !== 'total',
ARRAY_FILTER_USE_BOTH
);
$total = (int) ($decoded['metadata']['vulnerabilities']['total'] ?? 0);
return [
'target' => $this->relative($lockPath),
'kind' => 'npm',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['vulnerabilities'] ?? [])) : '',
];
}
/**
* composer 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditComposer(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'packages-dev'])) {
return $this->emptyLock($lockPath, 'composer');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['composer', 'audit', '--locked', '--no-dev', '--format=json'], $dir);
$decoded = json_decode($this->firstJsonObject($process['output']), true);
if (! is_array($decoded) || ! array_key_exists('advisories', $decoded)) {
return $this->unmeasurable($lockPath, 'composer', trim($process['error']) ?: 'composer audit 출력을 해석할 수 없습니다.');
}
$severities = [];
$total = 0;
foreach ($decoded['advisories'] as $advisories) {
foreach ((array) $advisories as $advisory) {
$total++;
$severity = (string) ($advisory['severity'] ?? 'unknown');
$severities[$severity] = ($severities[$severity] ?? 0) + 1;
}
}
return [
'target' => $this->relative($lockPath),
'kind' => 'composer',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['advisories'])) : '',
];
}
/**
* 잠금파일이 의존성을 하나도 담고 있지 않은지 판정합니다.
*
* 확장 대부분은 composer 의존성이 없어 빈 잠금을 갖는다. 그 상태에서 감사 도구는
* "설치본이 없다" 는 오류를 내는데, 그것을 점검 불가로 세면 조치할 것이 없는 대상이
* 24건씩 경고로 쌓여 진짜 점검 불가를 가린다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param array<int, string> $keys 의존성이 담기는 최상위 키
* @return bool
*/
private function isEmptyLock(string $lockPath, array $keys): bool
{
$decoded = json_decode((string) @file_get_contents($lockPath), true);
if (! is_array($decoded)) {
return false;
}
foreach ($keys as $key) {
$entries = $decoded[$key] ?? [];
if (! is_array($entries)) {
continue;
}
// npm 잠금의 `packages` 는 루트 자신을 빈 문자열 키로 항상 담는다 — 그것만 있으면
// 빈 것이다. composer 잠금의 `packages` 는 리스트라 키가 정수이므로 건드리지 않는다.
$meaningful = array_filter(
array_keys($entries),
static fn ($name): bool => $name !== ''
);
if ($meaningful !== []) {
return false;
}
}
return true;
}
/**
* 의존성이 없는 잠금파일을 기록합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function emptyLock(string $lockPath, string $kind): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'empty',
'advisories' => 0,
'severities' => [],
'detail' => '의존성 없음',
];
}
/**
* 감사 도구를 실행할 수 없었던 대상을 기록합니다.
*
* "점검 불가" 는 "취약점 없음" 과 다르다 — 뭉뚱그리면 운영자가 정상으로 읽는다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @param string $reason 사유
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function unmeasurable(string $lockPath, string $kind, string $reason): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'unmeasurable',
'advisories' => 0,
'severities' => [],
'detail' => mb_substr($reason, 0, 200),
];
}
/**
* 외부 명령을 실행합니다 ( 과 충돌하지 않도록 이름을 분리).
*
* @param array<int, string> $command 명령과 인자
* @param string $cwd 작업 디렉토리
* @return array{output: string, error: string, exit: int}
*/
private function runProcess(array $command, string $cwd): array
{
$process = new Process($command, $cwd, null, null, self::PROCESS_TIMEOUT);
try {
$process->run();
} catch (\Throwable $e) {
return ['output' => '', 'error' => $e->getMessage(), 'exit' => -1];
}
return [
'output' => $process->getOutput(),
'error' => $process->getErrorOutput(),
'exit' => (int) $process->getExitCode(),
];
}
/**
* 앞쪽 잡음을 걷어내고 첫 JSON 객체만 남깁니다.
*
* composer 는 오토로드 경고를 표준출력에 함께 흘릴 수 있다.
*
* @param string $output 명령 출력
* @return string JSON 문자열 (없으면 빈 문자열)
*/
private function firstJsonObject(string $output): string
{
$start = strpos($output, '{');
return $start === false ? '' : substr($output, $start);
}
/**
* 동봉 제3자 자산의 버전 디렉토리를 나열합니다.
*
* 감사 도구가 원리상 볼 수 없는 축이므로 판정하지 않고 노출만 한다.
*
* @return array<int, array{extension: string, library: string, versions: array<int, string>}>
*/
private function vendoredAssets(): array
{
$rows = [];
foreach (['templates', 'modules', 'plugins'] as $kind) {
foreach ([$kind.'/*/dist/vendor', $kind.'/_bundled/*/dist/vendor'] as $pattern) {
foreach ((array) glob(base_path($pattern), GLOB_ONLYDIR) as $vendorRoot) {
foreach ((array) glob($vendorRoot.'/*', GLOB_ONLYDIR) as $libDir) {
$versions = array_map(
'basename',
(array) glob($libDir.'/*', GLOB_ONLYDIR)
);
$rows[] = [
'extension' => $this->relative(dirname($vendorRoot, 2)),
'library' => basename($libDir),
'versions' => array_values($versions),
];
}
}
}
}
return $rows;
}
/**
* 결과 집계를 계산합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return array{checked: int, vulnerable: int, unmeasurable: int, advisories: int}
*/
private function totals(array $results): array
{
return [
'checked' => count($results),
'audited' => count(array_filter($results, static fn (array $r): bool => in_array($r['status'], ['clean', 'vulnerable'], true))),
'empty' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'empty')),
'vulnerable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'vulnerable')),
'unmeasurable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'unmeasurable')),
'advisories' => array_sum(array_column($results, 'advisories')),
];
}
/**
* 결과 표를 출력합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return void
*/
private function renderTable(array $results): void
{
$this->line('');
$this->line('<options=bold>의존성 취약점 점검 (운영 의존성 기준)</>');
$this->line('');
$rows = [];
foreach ($results as $result) {
$severities = [];
foreach ($result['severities'] as $severity => $count) {
$severities[] = $severity.' '.$count;
}
$rows[] = [
$result['kind'],
$result['target'],
match ($result['status']) {
'clean' => '이상 없음',
'empty' => '대상 없음',
'vulnerable' => '취약',
default => '점검 불가',
},
$result['advisories'] > 0 ? (string) $result['advisories'] : '-',
$severities === [] ? ($result['detail'] !== '' ? mb_substr($result['detail'], 0, 60) : '-') : implode(', ', $severities),
];
}
$this->table(['종류', '대상', '상태', '건수', '내역'], $rows);
}
/**
* 동봉 자산 목록을 출력합니다.
*
* @param array<int, array<string, mixed>> $vendored 동봉 자산 목록
* @return void
*/
private function renderVendored(array $vendored): void
{
$this->line('');
$this->line('<options=bold>동봉 제3자 자산 (감사 도구가 볼 수 없는 축 — 사람이 확인)</>');
$this->line('');
if ($vendored === []) {
$this->line(' (없음)');
return;
}
$this->table(
['확장', '라이브러리', '버전 디렉토리'],
array_map(
static fn (array $row): array => [
$row['extension'],
$row['library'],
$row['versions'] === [] ? '(버전 디렉토리 없음)' : implode(', ', $row['versions']),
],
$vendored
)
);
}
/**
* 저장소 상대 경로로 바꿉니다.
*
* @param string $path 절대 경로
* @return string
*/
private function relative(string $path): string
{
$base = base_path().DIRECTORY_SEPARATOR;
return str_replace('\\', '/', str_starts_with($path, $base) ? substr($path, strlen($base)) : $path);
}
}
@@ -0,0 +1,209 @@
<?php
namespace App\Console\Commands;
use App\Extension\Helpers\FilePermissionHelper;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* 부트스트랩 리소스 정적 게시 상태 점검 커맨드 (#122)
*
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이다. 그래서 정상
* 운영 환경에서 실패를 확인할 방법이 사실상 없었다(`/dev` 대시보드는 `app.debug` 가 필요한데
* 게시는 프로덕션 전용이다). 이 커맨드가 그 통로다.
*
* 판정은 `ExtensionStaticCacheService::statusReport()` 가 소유한다 — 관리자 화면의
* 「초기 화면 정적 파일」 카드와 같은 값을 소비하며, 이 커맨드는 문구·조치 힌트만 담당한다.
*
* 출력: 실행 계정 / 현재 버전 / 게시 여부 / manifest 파일 수 / 게시 트리 쓰기 가능성 /
* 최근 실패 마커 / 잔존 버전 목록.
*
* 종료 코드: 이상이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
* **이상 발견 신호**다 — 운영자가 조치할 대상이 있다는 뜻이다.
*/
class StatusExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:status';
/**
* The console command description.
*/
protected $description = '부트스트랩 리소스 정적 게시 상태를 점검합니다 (이상 발견 시 비-0 종료)';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 이상 없으면 0, 이상 발견 시 1
*/
public function handle(ExtensionStaticCacheService $service): int
{
$problems = [];
$report = $service->statusReport();
$base = $service->baseDir();
$publishHint = self::publishCommandHint();
$this->line('');
$this->line('<options=bold>부트스트랩 리소스 정적 게시 상태</>');
$this->line('');
$this->line(' 실행 계정 : '.$report['process_user']);
// root 로 실행 중이면 여기서 게시하지 말라고 먼저 알린다 — 캐시 락 샤드·병합 번들이 root
// 소유로 남아 이후 웹 요청의 캐시 쓰기가 Permission denied 로 죽는다(전면 500 실사례).
// 중단하지는 않는다 — 상태 점검은 읽기 전용이다. (#651 C1)
if (self::isRootProcess()) {
$this->line(' <comment>root 로 실행 중 — 이 계정으로 게시하면 캐시 폴더가 root 소유가 될 수 있습니다. '
.'웹 계정으로 실행하세요: '.$publishHint.'</comment>');
}
$this->line(' kill-switch : '.($report['enabled'] ? '활성 (게시함)' : '<comment>비활성 (게시 안 함)</comment>'));
$this->line(' 현재 캐시 버전 : '.$report['version']);
$this->line(' 게시 루트 : '.$base);
// kill-switch 가 꺼져 있으면 미게시는 정상이다 — 이상으로 세지 않는다.
if (! $report['enabled']) {
$this->line('');
$this->comment('kill-switch 가 꺼져 있어 게시 상태는 점검하지 않습니다 (core.static_cache.enabled).');
return self::SUCCESS;
}
// 1) 게시 트리 쓰기 가능성 — 제보 본건(P1/P2)의 직접 지표
if ($report['tree_writable']) {
$this->line(' 트리 쓰기 : 가능');
} else {
$this->line(' 트리 쓰기 : <error>불가</error>');
$problems[] = sprintf(
'게시 트리에 쓸 수 없습니다 (%s, owner=%s, perms=%s). 웹 계정이 재게시할 수 없어 '
.'모든 요청이 API 폴백으로 동작합니다.',
$base,
(string) (@fileowner($base) ?: 'unknown'),
File::exists($base) ? substr(sprintf('%o', @fileperms($base)), -4) : 'absent'
);
}
// 2) 현재 버전 게시 여부
if ($report['published']) {
$this->line(' 게시 상태 : 완료');
$this->line(' manifest 파일 : '.$report['files'].'건');
$this->line(' 게시 시각 : '.($report['published_at'] ?? '?'));
if ($report['files'] === 0) {
$problems[] = 'manifest 에 기록된 파일이 0건입니다 — 게시가 비어 있습니다.';
}
} else {
$this->line(' 게시 상태 : <comment>미게시</comment>');
$problems[] = sprintf(
'현재 버전(%d)이 게시되지 않았습니다. 다음 웹 렌더의 자가 치유가 시도하며, '
.'즉시 게시하려면 웹 계정으로 `%s` 를 실행하거나 관리자 > 환경설정 > 일반 의 '
.'「지금 다시 만들기」를 사용하세요.',
$report['version'],
$publishHint
);
}
// 3) 최근 실패 마커 — 원인별로 조치가 다르다
$marker = $report['failure'];
if ($marker !== null) {
$this->line('');
$this->line(' <error>최근 게시 실패</error>');
$this->line(' 사유 : '.($marker['reason'] ?? '?').' — '.$this->reasonHint($marker['reason'] ?? ''));
$this->line(' 버전 : '.($marker['version'] ?? '?'));
$this->line(' 시각 : '.($marker['at'] ?? '?'));
$this->line(' 연속 실패 : '.($marker['count'] ?? '?').'회');
$this->line(' 상세 : '.($marker['message'] ?? ''));
$problems[] = '게시 실패 마커가 남아 있습니다 (사유: '.($marker['reason'] ?? '?').').';
}
// 4) 잔존 버전 목록 — 누적은 삭제 실패(소유권 불일치)의 지표다
$versions = $report['retained_versions'];
$this->line('');
$this->line(' 잔존 버전 : '.($versions === [] ? '(없음)' : implode(', ', $versions)));
if (count($versions) > 3) {
$problems[] = sprintf(
'게시 버전이 %d개 누적됐습니다 (정상은 현재+직전 2개). 삭제가 실패하고 있을 수 '
.'있습니다 — 소유권/권한을 확인하세요.',
count($versions)
);
}
$this->line('');
if ($problems === []) {
$this->info('이상 없음.');
return self::SUCCESS;
}
foreach ($problems as $problem) {
$this->warn('• '.$problem);
}
$this->line('');
return self::FAILURE;
}
/**
* 수동 게시 명령을 웹 계정 실행 형태로 안내합니다 — `ext-static:publish` 와 공유.
*
* root 가 아니면 명령만 그대로다(일반 SSH 사용자 = 파일 소유자 / 공유 호스팅 대칭 구성 / posix
* 미지원). root 이면 웹서버 계정을 식별해 `sudo -u {계정}` 을 붙이고, 식별하지 못하면
* placeholder 와 확인 방법을 함께 적는다 — 판정은 `FilePermissionHelper::describeWebServerAccount()`
* 가 소유한다(`core:update` 재실행 안내와 같은 4분기).
*
* @return string 안내 명령 문자열
*/
public static function publishCommandHint(): string
{
$command = 'php artisan ext-static:publish';
$account = FilePermissionHelper::describeWebServerAccount();
return match ($account['mode']) {
'root_web_known' => 'sudo -u '.$account['name'].' '.$command,
'root_web_unknown' => 'sudo -u <웹서버계정> '.$command.' (웹서버 계정은 storage 디렉토리 소유자로 확인)',
default => $command,
};
}
/**
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다 — 안내 분기 전용.
*
* @return bool root 실행 여부
*/
public static function isRootProcess(): bool
{
return FilePermissionHelper::describeWebServerAccount()['mode'] !== 'non_root';
}
/**
* 실패 사유 코드에 대한 조치 힌트를 반환합니다.
*
* 사유별로 볼 곳이 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
*
* @param string $reason 사유 코드
* @return string 조치 힌트
*/
private function reasonHint(string $reason): string
{
return match ($reason) {
'parent_not_writable' => '게시 트리 권한 문제입니다. CLI 계정과 웹 계정이 그룹을 공유하고 '
.'`public/build` 가 그룹 쓰기(g+w)인지 확인하세요. 수동 게시는 웹 계정으로: '
.self::publishCommandHint(),
'write_failed' => '쓰기 도중 실패했습니다. 디스크 여유 공간과 quota 를 확인하세요.',
'lock_unavailable' => '캐시 락을 얻지 못했습니다. 캐시 저장소(파일 캐시 디렉토리 권한 등)를 확인하세요.',
default => '상세 메시지를 확인하세요.',
};
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Concerns\PrunesBuildOutput;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildTemplateCommand extends Command
{
use ClearsTemplateCaches;
use PrunesBuildOutput;
/**
* The name and signature of the console command.
@@ -181,6 +183,14 @@ class BuildTemplateCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 이전 산출물 정리 (동봉 vendor 는 보존 — vite emptyOutDir 대체)
if (! $watchMode) {
$removed = $this->pruneBuildOutput($buildPath);
if ($removed !== []) {
$this->line(' 🧹 이전 산출물 정리: '.implode(', ', $removed));
}
}
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
@@ -71,7 +71,7 @@ class UninstallTemplateCommand extends Command
// 템플릿 삭제
$onProgress = $this->createProgressCallback(TemplateManager::UNINSTALL_STEPS);
try {
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress);
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress, $preservedBackups);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
@@ -83,6 +83,15 @@ class UninstallTemplateCommand extends Command
$this->info('✅ '.__('templates.commands.uninstall.success', ['template' => $identifier]));
$this->info(' - '.__('templates.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 알리지 않으면
// 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
foreach ($preservedBackups ?? [] as $backup) {
$this->info(' - '.__('templates.commands.uninstall.custom_preserved', [
'directory' => $backup['directory'],
'archive' => $backup['archive'],
]));
}
Log::info(__('templates.commands.uninstall.success', ['template' => $identifier]));
return Command::SUCCESS;
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Console\Command;
/**
* 신뢰 프록시 설정 상태 점검 커맨드 (#124)
*
* 프록시 뒤에서 신뢰 프록시가 설정되지 않으면 관리자 화면 자체가 뜨지 않을 수 있다. 화면으로
* 확인할 수 없는 바로 그 상태에서 쓰는 통로가 이 커맨드다.
*
* 콘솔에는 요청이 없으므로 `isSecure()`·`ip()` 같은 **실측 항목은 판정할 수 없다.** 그 항목은
* `판정 불가` 로 구분해 표시하고, 값이 비어 있는 것과 뭉뚱그리지 않는다 — 둘을 같은 문구로
* 내보내면 운영자가 "설정이 비었다" 로 오독한다.
*
* 종료 코드: 설정이 없으면 1. 비-0 은 "커맨드 실행 실패" 가 아니라 **점검 대상 신호**다.
* 다만 프록시를 쓰지 않는 직접 노출 구성에서는 미설정이 정상이므로, 그 사실을 출력에 명시한다.
*
* @since 7.0.10
*/
class TrustedProxyStatusCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'trusted-proxy:status';
/**
* The console command description.
*/
protected $description = '리버스 프록시 신뢰 설정(TRUSTED_PROXIES) 상태를 점검합니다';
/**
* Execute the console command.
*
* @return int 설정되어 있으면 0, 미설정이면 1
*/
public function handle(): int
{
// 콘솔에는 요청이 없다 — null 을 넘겨 실측 축을 not_applicable 로 받는다.
$diagnostic = TrustedProxyDiagnostic::forRequest(null);
$this->line('');
$this->line('<options=bold>리버스 프록시 신뢰 설정 상태</>');
$this->line('');
$this->line(' 설정 파일 : config/trustedproxy.php');
$this->line(' 환경변수 : TRUSTED_PROXIES');
if ($diagnostic['trusted_configured']) {
$this->line(' 현재 값 : '.$diagnostic['configured_proxies']);
} else {
$this->line(' 현재 값 : <comment>미설정 (아무 프록시도 신뢰하지 않음)</comment>');
}
$this->line('');
$this->line(' <options=bold>요청 기반 실측</>');
$this->line(' 수신 전달 헤더 : <comment>판정 불가 (콘솔에는 요청이 없습니다)</comment>');
$this->line(' HTTPS 인식 : <comment>판정 불가</comment>');
$this->line(' 방문자 IP : <comment>판정 불가</comment>');
$this->line('');
$this->comment(' 실측 축은 웹 요청에서만 판정됩니다 — 관리자 대시보드 알림 또는');
$this->comment(' 환경설정 > 고급 의 진단 블록에서 확인하세요.');
$this->line('');
if ($diagnostic['trusted_configured']) {
$this->info('신뢰 프록시가 설정되어 있습니다. X-Forwarded-* 헤더를 신뢰합니다.');
$this->line('');
return self::SUCCESS;
}
$this->warn('• 신뢰 프록시가 설정되어 있지 않습니다.');
$this->line('');
$this->line(' 프록시(AWS ALB, CloudFront, Cloudflare, nginx, ngrok) 뒤에서 구동 중이라면');
$this->line(' .env 에 TRUSTED_PROXIES 를 지정하세요. 미설정 시 접속 주소·방문자 IP 가');
$this->line(' 프록시 기준으로 인식되어 화면 표시·IP 기록·결제 통보 수신이 어긋납니다.');
$this->line(' 프록시를 쓰지 않는 직접 노출 구성이라면 미설정이 정상입니다.');
$this->line(' 상세: https://github.com/gnuboard/g7/blob/main/docs/backend/reverse-proxy.md');
$this->line('');
return self::FAILURE;
}
}
@@ -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 에 외부 패키지 의존성이 있는지 확인합니다.
*
@@ -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;
/**
* 특정 레이아웃의 모든 버전 조회 (최신순)
@@ -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,32 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 사용자 추가 에셋(`custom/`) 관리 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존해 컨트롤러가 원본 키로 응답할 수 있게 한다. 이미 번역된
* 문장을 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문이 그대로 화면에 나간다.
*
* `TemplateOperationException` 과 같은 형태지만 별도 클래스로 둔다 — 컨트롤러가 이
* 예외만 골라 4xx 로 바꾸기 위해서다. 부모(`RuntimeException`)를 잡으면 인프라 예외까지
* 입력 오류로 위장된다.
*/
class CustomAssetOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'custom_assets.errors.not_found')
* @param array<string, mixed> $params 메시지 파라미터
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
public readonly string $errorKey,
public readonly array $params = [],
?Throwable $previous = null,
) {
parent::__construct(__($errorKey, $params), 0, $previous);
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
/**
* 부트스트랩 리소스 정적 게시(bake) 실패 예외.
*
* 사용자 대면 예외가 아니다 — `ExtensionStaticCacheService::publishVersion()` 의
* 자체 catch 가 즉시 삼켜 Log::warning 진단으로만 남기고, 사이트는 API 폴백으로
* 정상 동작한다. 따라서 메시지는 다국어 키가 아니라 운영자 로그용 진단 문자열이다.
*/
class StaticCachePublishException extends \RuntimeException {}
+34 -14
View File
@@ -10,6 +10,7 @@ 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;
@@ -1304,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;
}
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
@@ -1395,15 +1419,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
$disk = ($override !== null && $override !== '')
? $override
: (string) config('core.storage.public_asset_disk', '');
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
return null;
}
return $disk;
return PublicAssetDisk::resolve($override);
}
/**
@@ -1441,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);
}
}
+34 -14
View File
@@ -10,6 +10,7 @@ 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;
@@ -1126,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;
}
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
@@ -1290,15 +1314,7 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
$disk = ($override !== null && $override !== '')
? $override
: (string) config('core.storage.public_asset_disk', '');
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
return null;
}
return $disk;
return PublicAssetDisk::resolve($override);
}
/**
@@ -1336,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);
}
}
+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');
}
/**
@@ -0,0 +1,54 @@
<?php
namespace App\Extension\Helpers;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 실패한 설치가 남긴 활성 디렉토리를 되돌리는 헬퍼
*
* 설치 흐름은 원본(`_pending`/`_bundled`)을 활성 디렉토리로 **먼저 복사한 뒤** 확장을
* 로드해 코어 버전·의존성·언어 경로 등을 검증한다. 검증에 로드된 확장 인스턴스가 필요해
* 순서를 뒤집을 수 없는데, 그 검증이 실패하면 방금 만든 활성 디렉토리가 그대로 남는다.
*
* 남은 디렉토리는 DB 행이 없어 목록에도 뜨지 않고 번들 병합에도 참여하지 않는다 — 즉
* 오류도 경고도 없이 디스크만 점유하는 고아가 된다. 더 나쁜 것은 다음 설치 시도가 그
* 디렉토리를 "이미 있는 설치본" 으로 보고 원본 복사를 건너뛸 수 있다는 점이다.
*
* 이 헬퍼는 **이번 호출이 만든 디렉토리만** 지운다. 이미 설치돼 있던 확장을 `--force` 로
* 다시 설치하다 실패한 경우에는 운영자의 기존 설치본이므로 손대지 않는다.
*/
final class ExtensionInstallRollbackHelper
{
/**
* 이번 설치가 만든 활성 디렉토리를 제거합니다.
*
* @param string $activePath 활성 디렉토리 절대경로
* @param bool $existedBefore 이번 설치 이전에 그 디렉토리가 있었는지
* @param string $identifier 확장 식별자 (로그용)
* @param string $type 확장 유형 (module|plugin|template, 로그용)
* @return bool 실제로 제거했으면 true
*/
public static function removeIfCreatedByThisInstall(
string $activePath,
bool $existedBefore,
string $identifier,
string $type,
): bool {
if ($existedBefore || ! File::isDirectory($activePath)) {
return false;
}
$removed = File::deleteDirectory($activePath);
Log::warning('설치 실패로 활성 디렉토리를 되돌렸습니다.', [
'type' => $type,
'identifier' => $identifier,
'path' => $activePath,
'removed' => $removed,
]);
return $removed;
}
}
@@ -21,6 +21,21 @@ class ExtensionPendingHelper
'node_modules',
];
/**
* 확장 교체 시 보존할 최상위 디렉토리명 목록
*
* `custom/` 은 운영자가 자기 CSS·JS·정적 파일을 두는 자리다. 확장이 소유한 것이
* 아니므로 새 배포본으로 덮어써서는 안 된다 — 덮어쓰면 업데이트할 때마다 운영자가
* 넣은 파일이 사라지고, 그 사실이 어디에도 남지 않는다(파일이 조용히 없어질 뿐이다).
*
* 보존은 **교체 경로 둘 다**에서 성립해야 한다: 디렉토리 rename 경로와, 하위 트리에
* 열린 핸들이 있을 때의 제자리 동기화 폴백. 한쪽만 고치면 Windows 잠금 상황에서만
* 조용히 사라진다.
*/
public const PRESERVED_DIRECTORIES = [
'custom',
];
/**
* _pending 또는 _bundled 디렉토리에서 확장 메타데이터를 로드합니다.
*
@@ -162,6 +177,9 @@ class ExtensionPendingHelper
try {
self::copyDirectoryWithProgress($sourcePath, $tempPath, $sourcePath, $onProgress);
// 운영자 소유 디렉토리를 새 트리로 옮겨 심는다 (교체 전에 해 둬야 원본이 살아 있다)
self::carryOverPreservedDirectories($targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패 시 임시 디렉토리 정리 후 예외 전파
if (File::isDirectory($tempPath)) {
@@ -326,6 +344,11 @@ class ExtensionPendingHelper
?\Closure $onProgress = null
): void {
File::ensureDirectoryExists($dest, 0775);
// 디렉토리도 부모 소유권을 상속시킨다 — 파일만 `copyFile` 로 상속시키면 sudo 로
// 실행된 설치/업데이트가 만든 **디렉토리**가 root 소유로 남아, 이후 웹 프로세스의
// 쓰기가 그 디렉토리에서 막힌다. 형제 구현 `ExtensionBackupHelper::
// copyDirectoryWithProgress` 는 이미 같은 방어를 갖고 있다 (계층 불균형 해소).
FilePermissionHelper::inheritOwnershipFromParent($dest);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
@@ -366,6 +389,7 @@ class ExtensionPendingHelper
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
{
File::ensureDirectoryExists($dest, 0775);
FilePermissionHelper::inheritOwnershipFromParent($dest);
$failed = [];
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
@@ -379,7 +403,7 @@ class ExtensionPendingHelper
}
$staleFailures = [];
self::removeStaleEntries($source, $dest, $staleFailures);
self::removeStaleEntries($source, $dest, $staleFailures, true);
if (! empty($staleFailures)) {
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
@@ -389,6 +413,40 @@ class ExtensionPendingHelper
}
}
/**
* 운영자 소유 디렉토리를 기존 활성 디렉토리에서 새 트리로 옮겨 심습니다.
*
* 새 배포본에 같은 이름의 디렉토리가 있으면 **그것을 치우고** 기존 것을 심는다 —
* `custom/` 은 확장이 소유하지 않는 자리이므로, 확장이 그 자리에 무언가를 담아
* 배포했더라도 운영자 파일이 우선한다(그런 배포 자체를 정적 검사가 막는다).
*
* @param string $existingPath 기존 활성 디렉토리
* @param string $stagingPath 새 배포본이 복사된 임시 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*/
private static function carryOverPreservedDirectories(
string $existingPath,
string $stagingPath,
?\Closure $onProgress = null
): void {
foreach (self::PRESERVED_DIRECTORIES as $name) {
$from = $existingPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($from)) {
continue;
}
$to = $stagingPath.DIRECTORY_SEPARATOR.$name;
if (File::isDirectory($to)) {
File::deleteDirectory($to);
}
$onProgress?->__invoke(null, "운영자 파일 보존: {$name}/");
self::copyDirectoryWithProgress($from, $to, $from, $onProgress);
}
}
/**
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
*
@@ -406,6 +464,9 @@ class ExtensionPendingHelper
array &$failed
): void {
File::ensureDirectoryExists($dest, 0775);
// 제자리 동기화 폴백 경로도 동일 방어 — rename 경로만 고치면 파일 잠금으로
// 이 경로로 떨어진 교체에서만 소유권이 조용히 어긋난다.
FilePermissionHelper::inheritOwnershipFromParent($dest);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
@@ -550,7 +611,7 @@ class ExtensionPendingHelper
* @param string $dest 활성 디렉토리
* @param array $failures 삭제 실패 경로 수집 (참조)
*/
private static function removeStaleEntries(string $source, string $dest, array &$failures): void
private static function removeStaleEntries(string $source, string $dest, array &$failures, bool $isRoot = false): void
{
if (! is_dir($dest)) {
return;
@@ -559,6 +620,11 @@ class ExtensionPendingHelper
$items = new \FilesystemIterator($dest, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
// 운영자 소유 디렉토리는 소스에 없어도 정리 대상이 아니다 (확장 루트에서만 판정)
if ($isRoot && $item->isDir() && in_array($item->getBasename(), self::PRESERVED_DIRECTORIES, true)) {
continue;
}
$counterpart = $source.DIRECTORY_SEPARATOR.$item->getBasename();
if ($item->isDir()) {
@@ -627,14 +693,85 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관된 운영자 디렉토리 목록
*/
public static function deleteExtensionDirectory(string $basePath, string $identifier): void
public static function deleteExtensionDirectory(string $basePath, string $identifier): array
{
$targetPath = $basePath.DIRECTORY_SEPARATOR.$identifier;
if (File::isDirectory($targetPath)) {
File::deleteDirectory($targetPath);
if (! File::isDirectory($targetPath)) {
return [];
}
$archived = self::archivePreservedDirectories($targetPath, $identifier);
File::deleteDirectory($targetPath);
return $archived;
}
/**
* 삭제 전에 운영자 소유 디렉토리를 보관합니다.
*
* 확장을 삭제하면 그 안의 `custom/` 도 함께 사라진다 — 운영자가 넣은 파일이므로
* 되돌릴 방법 없이 없어지면 안 된다. 교체(업데이트)는 보존이 답이지만 삭제는
* "확장을 없앤다" 는 명시적 의사이므로 막지 않고, 대신 사본을 남기고 그 사실을
* 기록한다.
*
* 보관 실패가 삭제를 막지는 않는다 — 삭제는 운영자가 요청한 동작이다.
*
* @param string $targetPath 삭제 대상 확장 디렉토리
* @param string $identifier 확장 식별자
* @return array<int, array{directory: string, archive: string}> 보관에 성공한 디렉토리 목록
*/
private static function archivePreservedDirectories(string $targetPath, string $identifier): array
{
$archived = [];
foreach (self::PRESERVED_DIRECTORIES as $name) {
$source = $targetPath.DIRECTORY_SEPARATOR.$name;
if (! File::isDirectory($source) || self::isEmptyDirectory($source)) {
continue;
}
$archivePath = storage_path(
'app'.DIRECTORY_SEPARATOR.'extension-custom-backups'
.DIRECTORY_SEPARATOR.$identifier.'-'.date('Ymd_His')
.DIRECTORY_SEPARATOR.$name
);
try {
self::copyDirectoryWithProgress($source, $archivePath, $source, null);
Log::info('확장 삭제: 운영자 파일을 보관했습니다', [
'identifier' => $identifier,
'directory' => $name,
'archive' => $archivePath,
]);
$archived[] = ['directory' => $name, 'archive' => $archivePath];
} catch (\Throwable $e) {
Log::warning('확장 삭제: 운영자 파일 보관에 실패했습니다 (삭제는 계속합니다)', [
'identifier' => $identifier,
'directory' => $name,
'error' => $e->getMessage(),
]);
}
}
return $archived;
}
/**
* 디렉토리가 비어 있는지 판정합니다.
*
* @param string $path 대상 디렉토리
* @return bool 비어 있으면 true
*/
private static function isEmptyDirectory(string $path): bool
{
return ! (new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS))->valid();
}
/**
@@ -685,6 +822,31 @@ class ExtensionPendingHelper
return $basePath.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.$identifier;
}
/**
* 확장 업데이트용 임시 디렉토리(다운로드·추출)를 부모 소유권까지 정합화해 확보합니다.
*
* 세 매니저(모듈·플러그인·템플릿)가 같은 경로 규약(`storage/app/temp/{type}_update_{uid}`)을 쓰므로
* 확보 절차도 한 곳에 둔다 — 복사본은 서로 다른 하드닝을 갖고 갈라진다.
*
* @param string $tempDir 임시 디렉토리 절대 경로
*/
public static function ensureUpdateTempDirectory(string $tempDir): void
{
// 부모(`storage/app/temp`)는 최초 1회만 만들어지고 자식만 삭제된다 — sudo 코어 업데이트의
// 번들 확장 업데이트가 그 최초 생성자면 부모가 root 소유로 굳어, 이후 관리자 화면(웹 계정)의
// 확장 업데이트가 임시 폴더를 만들지 못한다 (#651 F14). 부모는 소유권 상속·그룹 쓰기까지
// 정합화하는 프리미티브로 확보하고, 자식은 만든 뒤 부모 소유권을 상속시킨다.
// 확보 실패는 종전 동작(`ensureDirectoryExists` 의 예외 흐름)으로 폴백해 계약을 바꾸지 않는다.
if (! FilePermissionHelper::ensureWritableDirectory(dirname($tempDir))) {
File::ensureDirectoryExists($tempDir);
return;
}
File::ensureDirectoryExists($tempDir);
FilePermissionHelper::inheritOwnershipFromParent($tempDir);
}
/**
* 업데이트 스테이징용 타임스탬프 디렉토리를 생성합니다.
*
@@ -387,6 +387,122 @@ class FilePermissionHelper
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
}
/**
* 이미 존재하는 디렉토리의 퍼미션·소유권을 umask 와 무관하게 정합화합니다.
*
* `File::ensureDirectoryExists($dir, 0775)` 등 생성 API 의 mode 인자는 **umask 로 깎인다** —
* umask 022 환경에서는 0775 요청이 0755 로 만들어져 그룹 공유(웹 계정)가 쓸 수 없다.
* 명시 `chmod` 로 umask 를 무력화하고, POSIX 에서는 setgid 를 세워 그 아래에 만들어지는
* 하위 디렉토리가 그룹을 상속하게 한다. setgid 가 없으면 CLI(스케줄러/큐)가 먼저 만든
* 하위 디렉토리를 웹 프로세스가 쓰지 못한다. Windows 에는 setgid 개념이 없어 제외한다.
*
* 마지막으로 부모 소유권을 상속시켜 sudo/CLI 계정 고정을 막는다.
*
* @param string $path 대상 디렉토리 절대 경로
* @param int $mode 적용할 퍼미션 (예: 0775)
*/
public static function hardenDirectory(string $path, int $mode = 0775): void
{
@chmod($path, $mode);
if (PHP_OS_FAMILY !== 'Windows') {
@chmod($path, $mode | 02000);
}
static::inheritOwnershipFromParent($path);
}
/**
* 쓰기 가능한 디렉토리를 확보합니다 — 없으면 만들고, 권한을 정합화한 뒤 실제 쓰기 가능 여부를 판정합니다.
*
* 제3자 라이브러리에 넘길 캐시·임시 디렉토리처럼 "확보하지 못하면 그 기능만 끄면 되는"
* 자리를 위한 프리미티브다. **예외도 PHP 경고도 내지 않는다** — `File::ensureDirectoryExists()`
* 는 `mkdir()` 을 억제 없이 호출하므로 생성 실패가 `E_WARNING` 으로 나오고 Laravel
* `HandleExceptions` 가 이를 `ErrorException` 으로 승격시켜 요청이 500 이 된다. 쓰기 경로를
* 지정하는 목적 자체가 그 500 을 막는 것이므로, 확보 실패가 다시 500 을 내면 무의미하다.
*
* 실패 정책은 호출부가 정한다 — 조용히 성능 저하로 이어갈지(정의 캐시), 시끄럽게 실패로
* 처리할지(정적 게시)가 자리마다 다르기 때문이다. 사유는 `$failure` out 파라미터로 올린다.
*
* @param string $path 확보할 디렉토리 절대 경로
* @param int $mode 생성 시 적용할 퍼미션
* @param array{reason: string, path: string}|null $failure out — 실패 사유와 그 대상 경로.
* reason 은 `occupied_by_file`(경로가 파일로 점유) /
* `ancestor_not_writable`(실재하는 최근접 상위가 쓰기 불가) /
* `create_failed`(생성 실패) / `not_writable`(존재하나 쓰기 불가)
* @return bool 확보 성공 여부
*/
public static function ensureWritableDirectory(string $path, int $mode = 0775, ?array &$failure = null): bool
{
$failure = null;
if (! is_dir($path)) {
// 같은 이름의 파일이 자리를 차지하면 mkdir 이 경고를 낸다 — 먼저 걸러낸다.
if (file_exists($path)) {
$failure = ['reason' => 'occupied_by_file', 'path' => $path];
return false;
}
// 상위가 쓰기 불가라면 생성 자체가 불가능하다. 여기서 끊어야 호출부가 "무엇을
// 고쳐야 하는지"(대상 디렉토리가 아니라 그 상위)를 운영자에게 지목할 수 있다.
$ancestor = static::nearestExistingAncestor($path);
if ($ancestor === null || ! is_writable($ancestor)) {
$failure = ['reason' => 'ancestor_not_writable', 'path' => $ancestor ?? dirname($path)];
return false;
}
// force 인자로 경고를 억제한다. 실패는 아래 판정이 흡수한다.
File::makeDirectory($path, $mode, true, true);
if (is_dir($path)) {
static::hardenDirectory($path, $mode);
}
}
// 방금 만든 경로의 stat 은 캐시돼 있을 수 있다 — 판정 전에 비운다.
clearstatcache(true, $path);
if (! is_dir($path)) {
$failure = ['reason' => 'create_failed', 'path' => $path];
return false;
}
if (! is_writable($path)) {
$failure = ['reason' => 'not_writable', 'path' => $path];
return false;
}
return true;
}
/**
* 경로에서 위로 올라가며 실재하는 첫 디렉토리를 찾습니다.
*
* @param string $path 기준 경로
* @return string|null 실재하는 최근접 상위 (루트까지 없으면 null)
*/
public static function nearestExistingAncestor(string $path): ?string
{
$current = dirname($path);
while (! File::isDirectory($current)) {
$parent = dirname($current);
if ($parent === $current) {
return null;
}
$current = $parent;
}
return $current;
}
/**
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
*
@@ -450,6 +566,73 @@ class FilePermissionHelper
return [$baseOwner, $baseGroup, 'base_path (대칭 구성)'];
}
/** 테스트 전용 — `describeWebServerAccount()` 판정 오버라이드 (null = 실판정) */
private static ?array $webServerAccountForTesting = null;
/**
* 테스트 전용 — 실행 환경 판정을 강제합니다 (posix 부재 환경에서 root 분기를 재현하기 위해).
*
* @param array{mode: string, name: string|null}|null $account 강제할 판정 (null 로 실판정 복귀)
*/
public static function fakeWebServerAccountForTesting(?array $account): void
{
self::$webServerAccountForTesting = $account;
}
/**
* 현재 프로세스 기준으로 "웹서버 계정" 안내에 필요한 실행 환경을 분류합니다.
*
* root(sudo) 로 artisan 을 실행하면 그 명령이 만드는 캐시 샤드·번들·임시 파일이 root 소유로 남아
* 이후 웹 프로세스의 쓰기가 실패한다. 그래서 root 실행을 감지한 명령은 "웹서버 계정으로 실행"
* 을 안내해야 하는데, 그 판정을 명령마다 다시 쓰면 한 곳만 어긋나도 서로 다른 안내가 나간다.
* `core:update` 의 재실행 안내가 처음 세운 4분기를 그대로 옮겨 공유한다 — 동작 불변.
*
* 반환 모드:
* - `non_root` : posix 미지원(Windows 등) 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `inferWebServerOwnership()` 이 storage/bootstrap 쓰기 영역 소유자로 추정한다.
*
* @return array{mode: string, name: string|null} 모드와 웹서버 계정명(`root_web_known` 일 때만)
*/
public static function describeWebServerAccount(): array
{
if (self::$webServerAccountForTesting !== null) {
return self::$webServerAccountForTesting;
}
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['mode' => 'non_root', 'name' => null];
}
if (posix_geteuid() !== 0) {
return ['mode' => 'non_root', 'name' => null];
}
[$owner] = static::inferWebServerOwnership();
if ($owner === false) {
return ['mode' => 'root_web_unknown', 'name' => null];
}
if ($owner === 0) {
return ['mode' => 'root_web_symmetric', 'name' => null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['mode' => 'root_web_unknown', 'name' => null];
}
return ['mode' => 'root_web_known', 'name' => $name];
}
/**
* 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다.
*
+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');
}
/**
+187 -148
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;
@@ -372,168 +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');
// 모듈 설치 실행
$module->clearLifecycleFailureReason();
$result = $module->install();
if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
return false;
}
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($module);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// 모듈이 활성 디렉토리에 있지 않으면 로드 시도
$module = $this->getModule($moduleName);
if (! $module) {
// 복사 후 재로드 시도
$this->reloadModule($moduleName);
$module = $this->getModule($moduleName);
}
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($module);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
if (! $module) {
throw new \Exception(__('modules.not_found', ['module' => $moduleName]));
}
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($module->getName());
$description = $this->convertToMultilingual($module->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"module.{$module->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 모듈 정보 저장
$this->moduleRepository->updateOrCreate(
['identifier' => $module->getIdentifier()],
[
'vendor' => $module->getVendor(),
'name' => $name,
'version' => $module->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $module->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'config' => $module->getConfig(),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$module->getRequiredCoreVersion(),
$module->getIdentifier(),
'module'
);
// Role 자동 생성
$this->createModuleRoles($module);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$onProgress?->__invoke('validate', '검증 중...');
$this->checkDependencies($module);
// 권한 자동 생성
$this->createModulePermissions($module);
// 권한 구조 검증 (계층형 구조 필수)
$this->validatePermissionStructure($module, 'module');
// 권한-Role 연결
$this->assignPermissionsToRoles($module);
// 언어 파일 경로 검증 (src/lang 경로 필수)
$this->validateTranslationPath($module, 'module');
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
// SEO 변수명 중복 검증
$this->validateSeoVariables($module, 'module');
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncModuleIdentityPolicies($module);
// 모듈 설치 실행
$module->clearLifecycleFailureReason();
$result = $module->install();
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
$this->syncModuleIdentityMessages($module);
if (! $result) {
$failureReason = $module->getLifecycleFailureReason() ?? __('modules.errors.unknown_error');
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncModuleNotificationDefinitions($module);
return false;
}
DB::commit();
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($module);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($module);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $module->getVersion(), '>') : false;
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($module->getName());
$description = $this->convertToMultilingual($module->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"module.{$module->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 모듈 정보 저장
$this->moduleRepository->updateOrCreate(
['identifier' => $module->getIdentifier()],
[
'vendor' => $module->getVendor(),
'name' => $name,
'version' => $module->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($module->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $module->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'config' => $module->getConfig(),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
);
// Role 자동 생성
$this->createModuleRoles($module);
// 권한 자동 생성
$this->createModulePermissions($module);
// 권한-Role 연결
$this->assignPermissionsToRoles($module);
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncModuleIdentityPolicies($module);
// IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
$this->syncModuleIdentityMessages($module);
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncModuleNotificationDefinitions($module);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runModuleSeeders($module);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
$this->initializeModuleSettings($module);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
if (! $composerResult) {
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 모듈 설치 완료
HookManager::doAction('core.modules.installed', $moduleName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$moduleName,
'module',
);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runModuleSeeders($module);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '환경설정 초기화 중...');
$this->initializeModuleSettings($module);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('modules', $moduleName)) {
$composerResult = $this->extensionManager->runComposerInstall('modules', $moduleName);
if (! $composerResult) {
Log::warning('모듈 Composer 의존성 설치 실패', ['module' => $moduleName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 모듈 설치 완료
HookManager::doAction('core.modules.installed', $moduleName);
return true;
}
/**
@@ -905,6 +933,9 @@ class ModuleManager implements ModuleManagerInterface
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없을 때
@@ -914,8 +945,10 @@ class ModuleManager implements ModuleManagerInterface
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->moduleRepository->findByIdentifier($moduleName);
@@ -1045,7 +1078,10 @@ class ModuleManager implements ModuleManagerInterface
// 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->modulesPath,
$module->getIdentifier()
);
// 메모리에서 모듈 제거
unset($this->modules[$module->getIdentifier()]);
@@ -1764,7 +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) {
@@ -1801,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);
}
// 카테고리별로 설정 파일 생성
@@ -1818,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';
}
@@ -1888,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('삭제할 모듈 스토리지 디렉토리가 없습니다.', [
@@ -1973,7 +2012,7 @@ class ModuleManager implements ModuleManagerInterface
// 5. 스토리지 디렉토리 1-depth 용량 조회
$storageInfo = $this->getStorageDirectoriesInfo(
storage_path('app/modules/'.$identifier)
ExtensionStoragePath::module($identifier)
);
// 6. Composer vendor 디렉토리 정보 조회
@@ -4112,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(
+185 -147
View File
@@ -21,11 +21,13 @@ use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionInstallRollbackHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\ExtensionUpgradeGuardHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
@@ -44,6 +46,7 @@ use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\ExtensionStoragePath;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
@@ -357,167 +360,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', '검증 중...');
$plugin->clearLifecycleFailureReason();
$result = $plugin->install();
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
if (! $result) {
return false;
}
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($plugin);
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// 플러그인이 활성 디렉토리에 있지 않으면 로드 시도
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
// 복사 후 재로드 시도
$this->reloadPlugin($pluginName);
$plugin = $this->getPlugin($pluginName);
}
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($plugin);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
if (! $plugin) {
throw new \Exception(__('plugins.not_found', ['plugin' => $pluginName]));
}
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($plugin->getName());
$description = $this->convertToMultilingual($plugin->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 플러그인 정보 저장
$this->pluginRepository->updateOrCreate(
['identifier' => $plugin->getIdentifier()],
[
'vendor' => $plugin->getVendor(),
'name' => $name,
'version' => $plugin->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $plugin->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $plugin->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
// 그누보드7 코어 버전 호환성 검증
CoreVersionChecker::validateExtension(
$plugin->getRequiredCoreVersion(),
$plugin->getIdentifier(),
'plugin'
);
// Role 자동 생성
$this->createPluginRoles($plugin);
// 의존성 확인 (트랜잭션 외부에서 먼저 검증)
$this->checkDependencies($plugin);
// 권한 자동 생성
$this->createPluginPermissions($plugin);
// 언어 파일 경로 검증 (lang 경로 필수)
$this->validateTranslationPath($plugin, 'plugin');
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// SEO 변수명 중복 검증
$this->validateSeoVariables($plugin, 'plugin');
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
$this->createPluginMenus($plugin);
// 플러그인 설치 실행
$onProgress?->__invoke('validate', '검증 중...');
$plugin->clearLifecycleFailureReason();
$result = $plugin->install();
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
if (! $result) {
$failureReason = $plugin->getLifecycleFailureReason() ?? __('plugins.errors.unknown_error');
}
// IDV 메시지 정의/템플릿 자동 동기화
$this->syncPluginIdentityMessages($plugin);
if (! $result) {
return false;
}
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncPluginNotificationDefinitions($plugin);
// Phase 1: 마이그레이션 실행 (DDL - 트랜잭션 외부)
// MySQL에서 CREATE TABLE 등 DDL 문은 암시적 커밋을 유발하므로 트랜잭션 외부에서 실행
$onProgress?->__invoke('migration', '마이그레이션 실행 중...');
$this->runMigrations($plugin);
DB::commit();
// Phase 2: 데이터 작업 (DML - 트랜잭션 내부)
$onProgress?->__invoke('db', 'DB 등록 중...');
try {
DB::beginTransaction();
// GitHub에서 최신 버전 정보 가져오기
$latestVersion = $this->fetchLatestVersion($plugin);
$updateAvailable = $latestVersion ? version_compare($latestVersion, $plugin->getVersion(), '>') : false;
// 다국어 name, description 처리 (역호환성 지원)
$name = $this->convertToMultilingual($plugin->getName());
$description = $this->convertToMultilingual($plugin->getDescription());
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 데이터베이스에 플러그인 정보 저장
$this->pluginRepository->updateOrCreate(
['identifier' => $plugin->getIdentifier()],
[
'vendor' => $plugin->getVendor(),
'name' => $name,
'version' => $plugin->getVersion(),
'latest_version' => $latestVersion,
'description' => $description,
'github_url' => $plugin->getGithubUrl(),
'github_changelog_url' => $this->buildChangelogUrl($plugin->getGithubUrl()),
'update_available' => $updateAvailable,
'metadata' => $plugin->getMetadata(),
'status' => ExtensionStatus::Inactive->value,
'vendor_mode' => $resolvedVendorMode->value,
'hooks' => $this->normalizeHooksToArray($plugin->getHooks()),
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
'created_at' => now(),
'updated_at' => now(),
]
);
// Role 자동 생성
$this->createPluginRoles($plugin);
// 권한 자동 생성
$this->createPluginPermissions($plugin);
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
$this->createPluginMenus($plugin);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
// IDV 메시지 정의/템플릿 자동 동기화
$this->syncPluginIdentityMessages($plugin);
// 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
$this->syncPluginNotificationDefinitions($plugin);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runPluginSeeders($plugin);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '설정 초기화 중...');
$this->initializePluginSettings($plugin);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
if (! $composerResult) {
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// Phase 6: 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 플러그인 설치 완료
HookManager::doAction('core.plugins.installed', $pluginName);
return true;
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$activePath,
$rollbackDirExisted,
$pluginName,
'plugin',
);
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
// Phase 3: 시더 실행 (트랜잭션 외부)
// 시더 내부에서 별도 트랜잭션을 사용할 수 있으므로 외부에서 실행
$onProgress?->__invoke('seed', '시더 실행 중...');
$this->runPluginSeeders($plugin);
// Phase 4: 기본 설정 파일 생성
$onProgress?->__invoke('settings', '설정 초기화 중...');
$this->initializePluginSettings($plugin);
// Phase 4.5: Composer 의존성 설치 (외부 패키지가 있는 경우에만)
// _pending에서 이미 설치한 경우 스킵 (vendor/가 활성 디렉토리에 복사됨)
if (! $composerDoneInPending) {
$onProgress?->__invoke('composer', 'Composer 의존성 설치 중...');
if (! app()->environment('testing')
&& $this->extensionManager->hasComposerDependencies('plugins', $pluginName)) {
$composerResult = $this->extensionManager->runComposerInstall('plugins', $pluginName);
if (! $composerResult) {
Log::warning('플러그인 Composer 의존성 설치 실패', ['plugin' => $pluginName]);
}
}
}
// Phase 5: 오토로드 병합 실행 (트랜잭션 외부)
$onProgress?->__invoke('autoload', '오토로드 갱신 중...');
$this->extensionManager->updateComposerAutoload();
// Phase 6: 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
// 훅 발행: 플러그인 설치 완료
HookManager::doAction('core.plugins.installed', $pluginName);
return true;
}
/**
@@ -932,6 +959,9 @@ class PluginManager implements PluginManagerInterface
* @param bool $deleteData 플러그인 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string|null $failureReason 실패 시 사유가 담기는 out 파라미터 (성공 시 null)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없을 때
@@ -941,8 +971,10 @@ class PluginManager implements PluginManagerInterface
bool $deleteData = false,
?\Closure $onProgress = null,
?string &$failureReason = null,
?array &$preservedBackups = null,
): bool {
$failureReason = null;
$preservedBackups = [];
// 상태 가드: 진행 중 상태 체크
$existingRecord = $this->pluginRepository->findByIdentifier($pluginName);
@@ -1075,7 +1107,10 @@ class PluginManager implements PluginManagerInterface
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
$onProgress?->__invoke('files', '파일 삭제 중...');
ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier());
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->pluginsPath,
$plugin->getIdentifier()
);
// 메모리에서 플러그인 제거
unset($this->plugins[$plugin->getIdentifier()]);
@@ -2584,7 +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';
// 이미 설정 파일이 존재하면 스킵 (재설치 시 기존 설정 유지)
@@ -2610,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,
@@ -2665,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);
@@ -2735,7 +2773,7 @@ class PluginManager implements PluginManagerInterface
// 5. 스토리지 디렉토리 1-depth 용량 조회
$storageInfo = $this->getStorageDirectoriesInfo(
storage_path('app/plugins/'.$identifier)
ExtensionStoragePath::plugin($identifier)
);
// 6. Composer vendor 디렉토리 정보 조회
@@ -4342,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(
+114 -73
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;
@@ -425,6 +426,12 @@ class TemplateManager implements TemplateManagerInterface
);
}
// 검증(2단계)은 복사된 활성 디렉토리를 읽어야 해서 복사보다 뒤에 온다. 그래서 검증이
// 실패하면 방금 만든 활성 디렉토리가 고아로 남는다 — DB 행이 없어 목록에도 뜨지 않고
// 오류도 남지 않은 채 디스크만 점유한다. 이번 호출이 만든 것이면 되돌린다.
$rollbackActivePath = $this->templatesPath.DIRECTORY_SEPARATOR.$templateName;
$rollbackDirExisted = File::isDirectory($rollbackActivePath);
// 1. _pending/_bundled에서 활성 디렉토리로 복사 (활성 디렉토리에 없는 경우)
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
$onProgress?->__invoke('copy', '파일 복사 중...');
@@ -435,79 +442,90 @@ class TemplateManager implements TemplateManagerInterface
// 2. 검증
$onProgress?->__invoke('validate', '검증 중...');
return DB::transaction(function () use ($templateName, $onProgress) {
$template = $this->getTemplate($templateName);
if (! $template) {
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
}
try {
return DB::transaction(function () use ($templateName, $onProgress) {
$template = $this->getTemplate($templateName);
if (! $template) {
throw new \Exception(__('templates.errors.not_found', ['template' => $templateName]));
}
// 의존성 확인
$this->checkDependencies($template);
// 의존성 확인
$this->checkDependencies($template);
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
$this->validateSeoConfig($templateName);
// SEO 설정 검증 (설치 전 seo-config.json 유효성 검사)
$this->validateSeoConfig($templateName);
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
$this->validateLayouts($templateName);
// 레이아웃 검증 (설치 전 모든 레이아웃 파일 유효성 검사)
$this->validateLayouts($templateName);
// name과 description 다국어 변환 (역호환성)
$name = $this->convertToMultilingual($template['name']);
$description = $this->convertToMultilingual($template['description'] ?? '');
// name과 description 다국어 변환 (역호환성)
$name = $this->convertToMultilingual($template['name']);
$description = $this->convertToMultilingual($template['description'] ?? '');
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"template.{$templateName}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 활성 언어팩의 manifest seed(ja 등)를 name/description 다국어 필드에 주입
$manifest = HookManager::applyFilters(
"template.{$templateName}.manifest.translations",
['name' => $name, 'description' => $description]
);
$name = $manifest['name'] ?? $name;
$description = $manifest['description'] ?? $description;
// 3. DB 등록
$onProgress?->__invoke('db', 'DB 등록 중...');
// 3. DB 등록
$onProgress?->__invoke('db', 'DB 등록 중...');
// 템플릿 레코드 생성 또는 업데이트
$templateRecord = $this->templateRepository->updateOrCreate(
['identifier' => $templateName],
[
'vendor' => $template['vendor'],
'name' => $name,
'version' => $template['version'],
'type' => $template['type'],
'description' => $description,
'github_url' => $template['github_url'] ?? null,
'metadata' => $template['metadata'] ?? null,
'status' => ExtensionStatus::Inactive->value,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]
// 템플릿 레코드 생성 또는 업데이트
$templateRecord = $this->templateRepository->updateOrCreate(
['identifier' => $templateName],
[
'vendor' => $template['vendor'],
'name' => $name,
'version' => $template['version'],
'type' => $template['type'],
'description' => $description,
'github_url' => $template['github_url'] ?? null,
'metadata' => $template['metadata'] ?? null,
'status' => ExtensionStatus::Inactive->value,
'created_by' => Auth::id(),
'updated_by' => Auth::id(),
]
);
// 4. 레이아웃 등록
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
// 레이아웃 JSON 파일 일괄 등록
$this->registerLayouts($templateName, $templateRecord->id);
// 모듈 레이아웃 오버라이드 등록
$this->registerLayoutOverrides($templateName, $templateRecord->id);
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
$this->registerExtensionOverrides($templateName, $templateRecord->id);
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
$this->validateErrorLayouts($templateName, $template);
// 템플릿 상태 캐시 무효화
self::invalidateTemplateStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
// 훅 발행: 템플릿 설치 완료
HookManager::doAction('core.templates.installed', $templateName);
return true;
});
} catch (\Throwable $e) {
ExtensionInstallRollbackHelper::removeIfCreatedByThisInstall(
$rollbackActivePath,
$rollbackDirExisted,
$templateName,
'template',
);
// 4. 레이아웃 등록
$onProgress?->__invoke('layout', '레이아웃 등록 중...');
// 레이아웃 JSON 파일 일괄 등록
$this->registerLayouts($templateName, $templateRecord->id);
// 모듈 레이아웃 오버라이드 등록
$this->registerLayoutOverrides($templateName, $templateRecord->id);
// Extension 오버라이드 등록 (모듈/플러그인 Extension 커스터마이징)
$this->registerExtensionOverrides($templateName, $templateRecord->id);
// 에러 레이아웃 검증 (레이아웃 등록 후 수행)
$this->validateErrorLayouts($templateName, $template);
// 템플릿 상태 캐시 무효화
self::invalidateTemplateStatusCache();
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
// 훅 발행: 템플릿 설치 완료
HookManager::doAction('core.templates.installed', $templateName);
return true;
});
throw $e;
}
}
/**
@@ -731,12 +749,20 @@ class TemplateManager implements TemplateManagerInterface
*
* @param string $templateName 제거할 템플릿명 (identifier)
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param array<int, array{directory: string, archive: string}>|null $preservedBackups
* 삭제 전에 보관한 운영자 소유 디렉토리(`custom/`)의 사본 경로가 담기는 out 파라미터.
* 운영자에게 "지웠지만 사본은 여기 있다" 를 알리기 위한 것이므로 호출부가 노출해야 한다.
* @return bool 제거 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없을 때
*/
public function uninstallTemplate(string $templateName, ?\Closure $onProgress = null): bool
{
public function uninstallTemplate(
string $templateName,
?\Closure $onProgress = null,
?array &$preservedBackups = null,
): bool {
$preservedBackups = [];
// 1. 캐시 삭제
$onProgress?->__invoke('cache', '캐시 삭제 중...');
@@ -782,7 +808,10 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('files', '파일 삭제 중...');
// 활성 템플릿 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
ExtensionPendingHelper::deleteExtensionDirectory($this->templatesPath, $templateName);
$preservedBackups = ExtensionPendingHelper::deleteExtensionDirectory(
$this->templatesPath,
$templateName
);
// 메모리에서 템플릿 제거
unset($this->templates[$templateName]);
@@ -2825,7 +2854,7 @@ class TemplateManager implements TemplateManagerInterface
$tempDir = storage_path('app/temp/template_update_'.uniqid());
try {
File::ensureDirectoryExists($tempDir);
ExtensionPendingHelper::ensureUpdateTempDirectory($tempDir);
// GitHub에서 다운로드 및 추출 (코어와 동일한 폴백 체인)
$extractedDir = $this->extensionManager->downloadAndExtractFromGitHub(
@@ -3154,11 +3183,14 @@ class TemplateManager implements TemplateManagerInterface
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
// 비활성 템플릿이라 refreshTemplateLayouts()를 건너뛴 경우에만 여기서 증가
if ($previousStatus !== ExtensionStatus::Active->value) {
$this->incrementExtensionCacheVersion();
}
// 모듈(`updateModule`)·플러그인(`updatePlugin`)과 동형으로 **무조건** 올린다 —
// lang/routes/components/dist 만 바뀐 릴리스도 정적 게시본(#122)을 갱신해야 한다.
// 종전에는 활성 템플릿이면 `refreshTemplateLayouts()` 의 조건부 bump(레이아웃
// 변경 건수 > 0)에 위임했는데, 그 조건은 게시 입력 중 레이아웃 축만 보므로
// 레이아웃이 그대로인 릴리스는 게시본이 immutable 로 stale 하게 남았다(#651 F1).
// `refreshTemplateLayouts()` 내부의 조건부 bump 는 단독 호출처(refresh-layout)가
// 있어 그대로 두며, 이중 bump 는 put 1회 비용이고 terminating 게시는 1회로 병합된다.
$this->incrementExtensionCacheVersion();
self::invalidateTemplateStatusCache();
// 훅 발행: 템플릿 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
@@ -3208,6 +3240,15 @@ class TemplateManager implements TemplateManagerInterface
'updated_at' => now(),
]);
// 실패 경로도 bump 한다 — 백업 복원이 실패했거나 부분 반영된 디스크가 남을 수
// 있어, 게시본을 새 버전으로 다시 굽는 쪽이 옛 게시본을 그대로 두는 쪽보다
// 안전하다(#651 F2). 원래 예외를 가리지 않도록 bump 실패는 삼킨다.
try {
$this->incrementExtensionCacheVersion();
} catch (\Throwable) {
// 원래 예외(아래 throw)가 진짜 원인이다 — bump 실패는 로그(트레이트)로 충분
}
throw new \RuntimeException(
__('templates.errors.update_failed', [
'template' => $identifier,
+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']);
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\Cache\CoreCacheDriver;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
@@ -120,7 +121,10 @@ trait InvalidatesLayoutCache
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
// 지우므로 정합을 맞춘다.
if ($templateIdentifier) {
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
// 트레이트 게터 경유 — 이 트레이트만 조합한 클래스가 있을 수 있어 `self::` 가 아니라
// `ClearsTemplateCaches` 를 조합한 서비스 클래스를 통해 부른다(트레이트 정적 직접 호출은
// PHP 8.1+ E_DEPRECATED). 원시 키 읽기는 `cache:clear` 직후 0 을 돌려주어 실제 키를 못 지운다.
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}.meta");
}
@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CustomAssetOperationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\Extension\ReadExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\SaveExtensionCustomAssetRequest;
use App\Http\Requests\Admin\Extension\UploadExtensionCustomAssetRequest;
use App\Rules\AllowedTemplateFileType;
use App\Services\CustomAssetService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 확장 사용자 추가 에셋(`custom/`) 어드민 컨트롤러
*
* 운영자가 자기 CSS·JS·폰트·이미지를 화면에서 직접 넣고 고칠 수 있게 한다. 레이아웃
* 편집기의 [커스텀 자산] 모달이 본 API 를 호출한다.
*
* 모듈·플러그인·템플릿을 **한 엔드포인트**가 다룬다. 타입별로 나누면 같은 검증·문서·테스트가
* 세 벌로 갈리고, 그중 하나만 약해지면 그 경로가 조용한 우회로가 된다. 기존
* `extensions/{type}/{identifier}` 선례(확장 복구 API)와 같은 형태다.
*
* 권한은 라우트의 permission 미들웨어(`core.extensions.custom_assets.manage`)가 담당한다.
* 레이아웃 편집 권한과 **분리**된 이유: 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라
* 사이트 전 화면에서 실행되므로, 레이아웃을 고칠 수 있다는 것이 곧 그 권한이 될 수 없다.
*/
class AdminExtensionCustomAssetController extends AdminBaseController
{
/**
* 라우트 파라미터(단수) → 해석기 어휘(복수)
*
* 라우트는 기존 확장 공통 API 와 같은 단수형을 쓰고(`module|plugin|template`),
* 해석기·서빙은 디렉토리 이름과 같은 복수형을 쓴다. 변환을 한 곳에 모아 둔다 —
* 흩어지면 한쪽 표기만 고쳐 놓고 다른 쪽에서 조용히 빈 목록이 된다.
*/
private const TYPE_MAP = [
'module' => 'modules',
'plugin' => 'plugins',
'template' => 'templates',
];
public function __construct(
private CustomAssetService $service,
) {
parent::__construct();
}
/**
* 사용자 추가 에셋 목록 조회.
*
* @param string $type 확장 타입 (`module` | `plugin` | `template`)
* @param string $identifier 확장 식별자
* @return JsonResponse 파일 목록 + 편집기 메타(허용 확장자·크기 상한)
*/
public function index(string $type, string $identifier): JsonResponse
{
try {
$files = $this->service->list($this->resolveType($type), $identifier);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 목록 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => 'custom/']);
}
return $this->success('custom_assets.messages.listed', [
'files' => $files,
'editable_extensions' => CustomAssetService::EDITABLE_EXTENSIONS,
'uploadable_extensions' => AllowedTemplateFileType::getAllowedExtensions(),
'max_text_bytes' => CustomAssetService::MAX_TEXT_BYTES,
'max_upload_bytes' => CustomAssetService::MAX_UPLOAD_BYTES,
]);
}
/**
* 텍스트 파일 본문 조회.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 본문 응답
*/
public function show(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$file = $this->service->read($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 본문 조회 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.read_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.listed', $file);
}
/**
* 텍스트 파일 본문 저장 (없으면 생성).
*
* @param SaveExtensionCustomAssetRequest $request 검증된 요청 (`path`, `content`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 저장 결과
*/
public function store(SaveExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$saved = $this->service->save(
$this->resolveType($type),
$identifier,
$path,
(string) $request->validated('content'),
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 저장 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.saved', $saved);
}
/**
* 파일 업로드 (폰트·이미지 등 바이너리 포함).
*
* @param UploadExtensionCustomAssetRequest $request 검증된 요청 (`file`, `directory`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 업로드 결과
*/
public function upload(UploadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$directory = $request->validated('directory');
try {
$uploaded = $this->service->upload(
$this->resolveType($type),
$identifier,
$request->file('file'),
is_string($directory) ? $directory : null,
);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 업로드 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.write_failed', 500, $e, ['path' => (string) $directory]);
}
return $this->success('custom_assets.messages.uploaded', $uploaded);
}
/**
* 파일 삭제.
*
* @param ReadExtensionCustomAssetRequest $request 검증된 요청 (`path`)
* @param string $type 확장 타입
* @param string $identifier 확장 식별자
* @return JsonResponse 삭제 결과
*/
public function destroy(ReadExtensionCustomAssetRequest $request, string $type, string $identifier): JsonResponse
{
$path = (string) $request->validated('path');
try {
$this->service->delete($this->resolveType($type), $identifier, $path);
} catch (CustomAssetOperationException $e) {
return $this->error($e->errorKey, 422, null, $e->params);
} catch (\Throwable $e) {
Log::error('사용자 추가 에셋 삭제 실패', [
'type' => $type,
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return $this->error('custom_assets.errors.delete_failed', 500, $e, ['path' => $path]);
}
return $this->success('custom_assets.messages.deleted', ['path' => $path]);
}
/**
* 라우트 타입 파라미터를 해석기 어휘로 바꿉니다.
*
* 라우트 정규식이 이미 세 값으로 제한하지만, 매핑에 없으면 그대로 넘긴다 —
* 해석기가 알 수 없는 타입을 무효로 판정해 422 를 만든다(빈 목록으로 조용히
* 통과시키지 않는다).
*
* @param string $type 라우트 파라미터
* @return string 해석기 어휘
*/
private function resolveType(string $type): string
{
return self::TYPE_MAP[$type] ?? $type;
}
}
@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\Admin;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\ExtensionStaticCacheService;
use App\Services\PermissionService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
@@ -88,8 +88,10 @@ class AdminTemplateAssetController extends AdminBaseController
);
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
// 확장 캐시 버전은 트레이트 게터로만 읽는다 — 원시 키 읽기는 `cache:clear` 직후 0 을
// 돌려주어 버전 없는 URL 을 만들고, 컨테이너 바인딩은 확장 네임스페이스로 누수될 수 있다.
// 게터는 키 부재 시 재생성하므로 항상 유효 버전이다 (`AssetUrl::staticExtBase` 와 동형).
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
return $this->success(
__('templates.messages.editor_assets_retrieved'),
@@ -228,7 +230,7 @@ class AdminTemplateAssetController extends AdminBaseController
// 바인딩은 확장 컨텍스트로 누수될 수 있어(메모리 feedback_core_cache_no_container_binding)
// 코어 캐시 store 를 빗나갈 수 있다.
$cache = new CoreCacheDriver;
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
$cacheVersion = ExtensionStaticCacheService::getExtensionCacheVersion();
// 캐시 키 — 템플릿 + 확장 캐시 버전 + 파일 mtime(빌드 변경 즉시 무효화).
$mtime = (int) @filemtime($cssPath);
$cacheKey = "template.editor_css.{$identifier}.v{$cacheVersion}.m{$mtime}";
+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');
}
@@ -390,10 +390,19 @@ class ModuleController extends AdminBaseController
$moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->moduleService->uninstallModule($moduleName, $deleteData, $uninstallFailureReason);
$result = $this->moduleService->uninstallModule(
$moduleName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('module.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('module.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('module.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('modules.errors.unknown_error'),
@@ -397,10 +397,19 @@ class PluginController extends AdminBaseController
$pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData, $uninstallFailureReason);
$result = $this->pluginService->uninstallPlugin(
$pluginName,
$deleteData,
$uninstallFailureReason,
$preservedBackups
);
if ($result) {
return $this->success('plugins.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('plugins.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('plugins.uninstall_failed', 400, null, [
'error' => $uninstallFailureReason ?? __('plugins.errors.unknown_error'),
@@ -13,8 +13,11 @@ use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\ExtensionStaticCacheService;
use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use App\Support\EnvPriority;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
@@ -30,11 +33,33 @@ class SettingsController extends AdminBaseController
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester,
private DriverRegistryService $driverRegistryService,
private OutboundProxyTester $outboundProxyTester
private OutboundProxyTester $outboundProxyTester,
private ExtensionStaticCacheService $staticCacheService
) {
parent::__construct();
}
/**
* 설정 응답에 동봉할 `_meta` 를 만듭니다.
*
* 조회와 저장 두 응답이 같은 모양이어야 화면이 저장 직후에도 같은 판정을 이어갑니다 —
* 한쪽만 필드가 늘면 저장 후 잠금 표시가 조용히 사라집니다.
*
* - `limits`: 입력 한계값 (core.settings_limits)
* - `env_priority_enabled`: `.env` 우선 모드 활성 여부 (안내 배너 표시 판정)
* - `env_locked`: `.env` 로 잠긴 필드 목록 (프론트엔드 키 기준)
*
* @return array<string, mixed> 응답 메타 배열
*/
private function buildSettingsMeta(): array
{
return [
'limits' => config('core.settings_limits', []),
'env_priority_enabled' => EnvPriority::enabled(),
'env_locked' => $this->settingsService->envLockedMeta(),
];
}
/**
* 모든 시스템 설정을 조회합니다.
*
@@ -45,7 +70,7 @@ class SettingsController extends AdminBaseController
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
$settings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
@@ -73,7 +98,7 @@ class SettingsController extends AdminBaseController
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
$allSettings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.save_success', [
'settings' => $allSettings,
@@ -150,6 +175,56 @@ class SettingsController extends AdminBaseController
}
}
/**
* 신뢰 프록시(리버스 프록시) 설정 진단 결과를 조회합니다 (#124).
*
* 읽기 전용이다 — 값 편집 엔드포인트는 두지 않는다. 이 값은 "앱이 프록시 없이 도달
* 가능한가" 라는 배포 구조 지식이 있어야 정할 수 있고, 웹에서 편집 가능해지면 관리자
* 계정 탈취가 곧 X-Forwarded-For 위조 경로가 된다. 편집은 `.env` 전용이다.
*
* 판정 대상은 관리자 브라우저의 **실제 요청**이므로 현재 요청을 그대로 쓴다.
* 입력을 받지 않는 읽기 전용 조회라 FormRequest 를 두지 않는다.
*
* @return JsonResponse 진단 결과 JSON 응답
*/
public function trustedProxy(): JsonResponse
{
return $this->success('common.success', TrustedProxyDiagnostic::forRequest(request()));
}
/**
* 초기 화면 정적 파일(부트스트랩 리소스 정적 게시) 상태를 조회합니다 (#651).
*
* CLI `ext-static:status` 와 **같은 판정**(`ExtensionStaticCacheService::statusReport`)을 돌려준다.
* 읽기 전용이며 입력이 없어 FormRequest 를 두지 않는다 (`trustedProxy` 와 동형).
*
* @return JsonResponse 상태 보고서 JSON 응답
*/
public function staticCacheStatus(): JsonResponse
{
return $this->success('settings.static_cache_status_loaded', $this->staticCacheService->statusReport());
}
/**
* 초기 화면 정적 파일을 지금 다시 만듭니다 (관리자 수동 복구, #651).
*
* 캐시 버전을 올리고 현재 버전을 강제 재게시한다. 게시 실패·게시가 쓰이지 않는 환경은
* 요청 처리 실패가 아니라 **진단 결과**라 HTTP 200 으로 돌려주고 성공 여부는 페이로드
* (`republished`)가 말한다 — 연결 테스트·드라이버 테스트와 같은 규약. 사이트는 어느 경우에도
* API 폴백으로 정상이다.
*
* @return JsonResponse 재게시 결과 + 상태 보고서 JSON 응답
*/
public function republishStaticCache(): JsonResponse
{
$result = $this->staticCacheService->republish();
return $this->success(
($result['republished'] ?? false) ? 'settings.static_cache_republished' : 'settings.static_cache_republish_failed',
$result
);
}
/**
* 시스템 캐시를 정리합니다.
*
@@ -286,10 +286,14 @@ class TemplateController extends AdminBaseController
$templateName = $validated['template_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->templateService->uninstallTemplate($templateName, $deleteData);
$result = $this->templateService->uninstallTemplate($templateName, $deleteData, $preservedBackups);
if ($result) {
return $this->success('templates.uninstall_success');
// 운영자가 넣은 `custom/` 은 삭제 전에 사본을 남긴다 — 그 경로를 응답에 실어
// 알리지 않으면 로그를 뒤지지 않는 한 사본의 존재를 알 수 없다.
return $this->success('templates.uninstall_success', [
'preserved_backups' => $preservedBackups ?? [],
]);
} else {
return $this->error('templates.uninstall_failed', 400, null, [
'error' => __('templates.errors.unknown_error'),
@@ -3,13 +3,16 @@
namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
use App\Exceptions\Auth\TwoFactorDeliveryFailedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Controllers\Concerns\BuildsAuthFailureResponses;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
use App\Http\Requests\Auth\TwoFactorResendRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
@@ -18,6 +21,8 @@ use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
{
use BuildsAuthFailureResponses;
public function __construct(
private AuthService $authService
) {
@@ -27,8 +32,9 @@ class AuthController extends AuthBaseController
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
// 2단계 인증 확인·재발송은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
'resendTwoFactor',
'register',
'forgotPassword',
'resetPassword',
@@ -60,17 +66,9 @@ class AuthController extends AuthBaseController
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
return $this->lockedResponse($e);
} catch (TwoFactorDeliveryFailedException $e) {
return $this->deliveryFailedResponse();
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
@@ -98,11 +96,43 @@ class AuthController extends AuthBaseController
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
return $this->lockedResponse($e);
} catch (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 {
$data = $this->authService->resendTwoFactorChallenge(
$request->validated()['challenge_id']
);
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');
}
}
/**
* 새로운 사용자를 등록시킵니다.
*
@@ -178,7 +208,11 @@ class AuthController extends AuthBaseController
*/
public function refresh(AuthenticatedRequest $request): JsonResponse
{
$data = $this->authService->refreshToken($request->user());
try {
$data = $this->authService->refreshToken($request->user());
} catch (AccountLockedException $e) {
return $this->lockedResponse($e);
}
// 사용자 정보는 Resource로, 토큰은 그대로
if (isset($data['user'])) {
@@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller
}
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
* JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함).
*
* ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를
* 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정
* 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @return JsonResponse JSON 응답
* @return JsonResponse|Response JSON 응답 또는 304 응답
*/
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response
{
$etag = $this->generateETag($data);
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}"
: 'no-cache';
if ($status === 200 && $this->isNotModified($etag)) {
$response = $this->notModifiedResponse($etag, $maxAge);
$response->headers->set('Cache-Control', $cacheControl);
$response->headers->set('Vary', 'Accept-Encoding');
return $response;
}
return response()->json($data, $status, [
'Cache-Control' => "public, max-age={$maxAge}",
'Cache-Control' => $cacheControl,
'ETag' => $etag,
'Vary' => 'Accept-Encoding',
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
): JsonResponse|Response {
$etag = $this->generateETag($data);
// 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
// (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}"
: 'no-cache';
// 304 Not Modified 처리
if ($this->isNotModified($etag)) {
return $this->notModifiedResponse($etag, $maxAge);
$response = $this->notModifiedResponse($etag, $maxAge);
$response->headers->set('Cache-Control', $cacheControl);
return $response;
}
return response()->json([
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
'data' => $data,
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}")
->header('Cache-Control', $cacheControl)
->header('Vary', 'Accept-Encoding, Accept-Language');
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Identity;
use App\Enums\IdentityOriginType;
use App\Enums\IdentityVerificationPurpose;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Identity\CancelChallengeRequest;
@@ -96,6 +97,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
// 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
// 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
// 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
return $this->error('identity.errors.purpose_not_allowed', 403, [
'failure_code' => 'PURPOSE_NOT_ALLOWED',
]);
}
$result = $this->service->verify(
challengeId: $challenge->id,
input: $request->validated(),
@@ -142,6 +152,15 @@ class IdentityVerificationController extends PublicBaseController
*/
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
// 로그인 challenge 는 이 공개 경로로 다루지 않는다. 여기서 검증·취소되면
// 바로 뒤의 `auth/login/two-factor` 가 INVALID_STATE 로 거절해, 그 challenge 로는
// 영영 로그인할 수 없게 된다(자기 DoS). 로그인 전용 엔드포인트만 사용한다.
if ($challenge->purpose === IdentityVerificationPurpose::Login->value) {
return $this->error('identity.errors.purpose_not_allowed', 403, [
'failure_code' => 'PURPOSE_NOT_ALLOWED',
]);
}
$ok = $this->service->cancel($challenge->id);
if (! $ok) {
@@ -63,20 +63,18 @@ class PublicLayoutController extends PublicBaseController
}
try {
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
// 서버 캐시 키는 **서버 현재** 확장 캐시 버전으로만 조립한다. 클라이언트 `?v=` 는 브라우저
// HTTP 캐시 우회용 좌표일 뿐 서버 키의 근거가 아니다.
//
// 서버 캐시 키에는 **정수 버전만** 쓴다(소수 nonce 제거). 레이아웃 편집기는 같은 세션의
// 저장·버전 복원 직후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}` 형식으로
// 요청한다(클라이언트 cache-bust nonce). 그런데 `serve` 가 이 문자열을 그대로 캐시 키에
// 쓰면 키가 `...v{cacheVersion}.{nonce}.meta` 가 되는데, 저장 경로
// `LayoutService::clearPublicServingCache` 는 `(int) ext.cache_version` 으로 nonce 없는
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588).
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 종전엔 `?v` 의 정수부를 키에 썼다(#588 — nonce 제거). 그런데 레이아웃 편집기는 부팅
// 시점 `window.G7Config.cache_version` 에 nonce 만 붙여 계속 요청하고, 저장·복원은
// `ext.cache_version` 을 `time()` 으로 올리며 `clearPublicServingCache` 는 **현재** 버전
// 키만 지운다. 그래서 두 번째 bump 부터 부팅 버전 키가 영영 지워지지 않아 초기화·복원·
// 409 「최신 불러오기」가 옛 content 를 받았고(실측: 초기화 직후 lock 4 응답, DB 는 lock 7),
// 그 화면을 다시 저장하면 옛 내용이 최신을 덮을 수 있었다. 서버 버전으로 키를 고정하면
// 무효화(현재 버전 키 forget)와 굽기(현재 버전 키 remember)가 같은 키를 본다. `?v` 가 어떤
// 값이든 결과는 같고, 이전 버전 키는 bump 로 자연 이탈한다(TTL 만료).
$cacheVersion = self::getExtensionCacheVersion();
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesExtensionBundles;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\ModuleService;
@@ -17,6 +19,9 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
*/
class PublicModuleController extends PublicBaseController
{
use ServesExtensionBundles;
use ServesRewritableCssAssets;
public function __construct(
private readonly ModuleService $moduleService,
private readonly ExtensionBundleService $bundleService
@@ -27,9 +32,10 @@ class PublicModuleController extends PublicBaseController
/**
* 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다
* (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로
* 서빙(ETag/304/환경별 Cache-Control 재사용).
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
@@ -37,14 +43,7 @@ class PublicModuleController extends PublicBaseController
{
$this->logApiUsage('modules.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
return $this->bundleResponse($this->bundleService, 'module', 'js', 'text/javascript');
}
/**
@@ -56,14 +55,7 @@ class PublicModuleController extends PublicBaseController
{
$this->logApiUsage('modules.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('module', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
return $this->bundleResponse($this->bundleService, 'module', 'css', 'text/css');
}
/**
@@ -99,7 +91,14 @@ class PublicModuleController extends PublicBaseController
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'modules',
$identifier,
$path,
31536000
);
}
/**
@@ -140,9 +139,9 @@ class PublicModuleController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 모듈 식별자
* @return JsonResponse 컴포넌트 정의 응답
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
public function serveComponents(string $identifier): JsonResponse
public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesExtensionBundles;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\ExtensionBundleService;
use App\Services\PluginService;
@@ -17,6 +19,9 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
*/
class PublicPluginController extends PublicBaseController
{
use ServesExtensionBundles;
use ServesRewritableCssAssets;
public function __construct(
private readonly PluginService $pluginService,
private readonly ExtensionBundleService $bundleService
@@ -27,8 +32,10 @@ class PublicPluginController extends PublicBaseController
/**
* 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다.
*
* 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다.
* 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용).
* 병합 파일을 fileResponse 로 서빙한다(ETag/304/환경별 Cache-Control 재사용).
* 디스크 캐시가 실패하면 메모리 병합 결과로 200 을 낸다(캐시는 최적화일 뿐이다).
* 선언한 산출물이 소실·판독 불가면 503, 존재하되 비었으면(또는 선언 0) 빈 200 —
* 판정은 ServesExtensionBundles::bundleResponse() 단일 지점.
*
* @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답
*/
@@ -36,14 +43,7 @@ class PublicPluginController extends PublicBaseController
{
$this->logApiUsage('plugins.bundle', ['kind' => 'js']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'js', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/javascript');
}
return $this->fileResponse($path, 'text/javascript', 31536000);
return $this->bundleResponse($this->bundleService, 'plugin', 'js', 'text/javascript');
}
/**
@@ -55,14 +55,7 @@ class PublicPluginController extends PublicBaseController
{
$this->logApiUsage('plugins.bundle', ['kind' => 'css']);
$version = $this->bundleService->getCurrentVersion();
$path = $this->bundleService->getBundleFilePath('plugin', 'css', $version);
if ($path === '') {
return response('', 200)->header('Content-Type', 'text/css');
}
return $this->fileResponse($path, 'text/css', 31536000);
return $this->bundleResponse($this->bundleService, 'plugin', 'css', 'text/css');
}
/**
@@ -98,7 +91,14 @@ class PublicPluginController extends PublicBaseController
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'plugins',
$identifier,
$path,
31536000
);
}
/**
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse 컴포넌트 정의 응답
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
public function serveComponents(string $identifier): JsonResponse
public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('plugins.components', ['identifier' => $identifier]);
@@ -7,6 +7,7 @@ use App\Enums\ExtensionStatus;
use App\Extension\Helpers\EditorSpecAssembler;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Controllers\Concerns\ServesRewritableCssAssets;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Models\TemplateLayoutAttachment;
use App\Services\TemplateLayoutAttachmentService;
@@ -23,6 +24,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicTemplateController extends PublicBaseController
{
use ClearsTemplateCaches;
use ServesRewritableCssAssets;
public function __construct(
private TemplateService $templateService,
@@ -35,9 +37,9 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
* @return JsonResponse 라우트 정보 응답
* @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304)
*/
public function getRoutes(string $identifier): JsonResponse
public function getRoutes(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
@@ -101,6 +103,15 @@ class PublicTemplateController extends PublicBaseController
return $this->error(__('templates.errors.invalid_cache_data'), 500);
}
// 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다.
// 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D).
// 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v`
// URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다
// (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭).
if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) {
return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600);
}
return $this->success(
__('templates.messages.routes_retrieved'),
$routesData['data']
@@ -137,17 +148,26 @@ class PublicTemplateController extends PublicBaseController
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함).
// CSS 는 안의 상대 참조를 절대 자산 URL 로 치환해 내보낸다 — 확장자 없는 모드에서
// 상대 해석이 어긋나 글꼴·아이콘이 404 가 되기 때문이다.
return $this->rewritableAssetResponse(
$result['filePath'],
$result['mimeType'],
'templates',
$identifier,
$path,
31536000
);
}
/**
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답
* @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
public function serveComponents(string $identifier): JsonResponse
public function serveComponents(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
@@ -244,9 +264,9 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
* @return JsonResponse 다국어 데이터 응답
* @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304)
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
public function serveLanguage(string $identifier, string $locale): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.language', [
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Exceptions\Auth\AccountLockedException;
use Illuminate\Http\JsonResponse;
/**
* 세션을 발급하는 엔드포인트가 공유하는 실패 응답을 구성하는 트레이트.
*
* 사용자 로그인과 관리자 로그인은 서로 다른 베이스 컨트롤러를 상속하지만, 계정 잠금과
* 인증번호 발송 실패는 **같은 사건**이므로 같은 형태로 응답해야 합니다. 두 컨트롤러가
* 각자 사본을 들고 있으면 한쪽 페이로드에 필드가 추가될 때 다른 쪽이 조용히 뒤처져,
* 같은 실패인데 화면이 다르게 안내하게 됩니다.
*
* @since 7.0.11
*/
trait BuildsAuthFailureResponses
{
/**
* 계정 잠금 응답(423)을 구성합니다.
*
* @param AccountLockedException $e 잠금 예외
* @return JsonResponse 423 응답
*/
protected function lockedResponse(AccountLockedException $e): JsonResponse
{
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
}
/**
* 인증번호 발송 실패 응답(503)을 구성합니다.
*
* 자격 증명은 올바르므로 401 로 답하지 않는다 — 사용자는 비밀번호를 의심하며 같은
* 실패를 반복하고, 운영자는 메일 설정이 깨진 사실을 알 방법이 없다.
*
* @return JsonResponse 503 응답
*/
protected function deliveryFailedResponse(): JsonResponse
{
return $this->error('auth.two_factor_delivery_failed', 503);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Services\ExtensionBundleService;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 확장(모듈/플러그인) 병합 번들 서빙의 공통 판정 (#122 E1/E2).
*
* 모듈 컨트롤러와 플러그인 컨트롤러가 **같은 판정**을 해야 한다. 각자 구현하면 한쪽만
* 고쳐진 채 다른 쪽이 조용히 옛 동작으로 남는다 — 실제로 두 컨트롤러는 지금까지 동일
* 코드를 복제하고 있었다.
*
* 판정은 셋으로 갈린다:
*
* 1. 병합 결과가 있고 디스크 캐시도 성공 → 파일 응답 (ETag/304/immutable 재사용)
* 2. 병합 결과는 있는데 디스크 캐시가 실패 → **메모리 결과를 그대로 200** 으로 서빙.
* 디스크 캐시는 최적화이므로 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
* 3. 병합 결과가 비었음 →
* - 선언한 산출물이 **소실·판독 불가**면 장애(배포 중 `dist` 가 잠깐 빔, 경로
* 어긋남) → **503**. 빈 200 으로 내보내면 프론트는 404 도 오류도 받지 못한 채
* 한참 뒤 "Unknown action handler" 로 죽어, 원인이 번들이라는 사실이 드러나지 않는다.
* - 산출물이 **존재하되 비어 있으면** 정상 → 빈 200. 스타일 소스가 자리표시 주석뿐이라
* 0바이트 CSS 를 내보내는 확장은 정당한 상태다. 선언 축만으로 판정하면 그런 확장만
* 설치된 기본 구성이 통째로 503 이 되어 사용자 화면마다 실패 안내가 뜬다.
* - 선언이 **0개**여도 정상 → 빈 200
*/
trait ServesExtensionBundles
{
/**
* 확장 병합 번들을 서빙합니다.
*
* @param ExtensionBundleService $bundleService 번들 서비스
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param string $mimeType 응답 Content-Type
* @return BinaryFileResponse|Response 번들 응답
*/
protected function bundleResponse(
ExtensionBundleService $bundleService,
string $type,
string $kind,
string $mimeType
): BinaryFileResponse|Response {
$version = $bundleService->getCurrentVersion();
$path = $bundleService->getBundleFilePath($type, $kind, $version);
if ($path !== '') {
return $this->fileResponse($path, $mimeType, 31536000);
}
// 파일 경로가 비었다 — 병합 결과 자체가 비었거나, 디스크 캐시가 실패했다.
$content = $bundleService->buildBundleContent($type, $kind);
if ($content !== '') {
// 디스크 캐시 실패 (2) — 메모리 결과로 서빙한다. 캐시 헤더는 붙이지 않는다:
// 이 응답은 캐시에 실패한 상태의 산출물이라 다음 요청에서 정상 경로로
// 돌아갈 수 있어야 한다.
return response($content, 200)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'no-cache, private');
}
$missing = $bundleService->findMissingDeclaredAssets($type, $kind);
if ($missing !== []) {
// (3) 장애 — 선언한 산출물이 소실·판독 불가 (배포 중 dist 가 잠깐 빔, 경로 어긋남).
// 어느 파일이 없는지를 함께 남긴다 — 선언 수만으로는 운영자가 조치 대상을 짚을 수 없다.
Log::error('확장 번들 병합 결과가 비었습니다 — 선언된 산출물이 없거나 읽을 수 없습니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'declared_extensions' => $bundleService->countAssetDeclaringExtensions($type, $kind),
'missing' => $missing,
]);
return response('', 503)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'no-cache, private');
}
// (3) 정상 — 선언이 0 이거나, 선언된 산출물이 전부 존재하되 비어 있다
// (스타일이 비어 있는 확장). 빈 번들이 맞다.
return response('', 200)->header('Content-Type', $mimeType);
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 확장 자산 서빙에서 CSS 안의 상대 참조를 절대 자산 URL 로 바꿔 내보냅니다.
*
* 배경:
* `general.asset_url_mode` 가 `extensionless` 면 자산 URL 이
* `/api/templates/assets/{id}?file=vendor%2Fx%2Fa.css` 형태가 된다. 브라우저는 CSS 안의
* 상대 `url()` 을 스타일시트 URL 의 **디렉토리** 기준으로 푸는데, 이 형태에서는 디렉토리가
* `/api/templates/assets/` 라서 `./woff2/f.woff2` 가 엉뚱한 곳을 가리킨다. 확장자 모드나
* 정적 게시본에서는 경로 형태라 정상 해석되므로, 어긋남은 이 조합에서만 나타난다.
*
* 증상은 404 하나뿐이다 — 글꼴은 기본 서체로 대체되고 아이콘은 빈칸이 되며, 서버 로그에는
* 정상 요청으로 남는다. 그래서 운영자에게는 원인을 특정할 단서가 없다.
*
* 모드와 무관하게 항상 치환하는 이유:
* 확장자 모드에서도 결과 URL 은 브라우저가 상대 해석으로 얻던 것과 같은 주소다. 모드에
* 따라 치환 여부를 가르면 두 경로가 서로 다른 코드로 갈라져, 정작 깨지는 쪽만 검증에서
* 빠지기 쉽다. 한 경로로 두고 두 모드를 같은 테스트로 잠근다.
*
* @see AssetCssUrlRewriter 치환 규칙(대상·비대상 판정)
*/
trait ServesRewritableCssAssets
{
/**
* 확장 자산 응답을 만듭니다 — CSS 면 상대 참조를 치환해 내보냅니다.
*
* CSS 가 아니면 종전 `fileResponse()` 와 동일하게 동작합니다.
*
* @param string $filePath 실제 파일 절대 경로
* @param string $mimeType MIME 타입
* @param string $extensionType `templates` / `modules` / `plugins`
* @param string $identifier 확장 식별자
* @param string $requestedPath 확장 기준 요청 경로 (CSS 상대 참조의 해석 기준)
* @param int $maxAge 캐시 유지 시간 (초)
* @return BinaryFileResponse|Response 자산 응답 (If-None-Match 일치 시 304)
*/
protected function rewritableAssetResponse(
string $filePath,
string $mimeType,
string $extensionType,
string $identifier,
string $requestedPath,
int $maxAge = 31536000
): BinaryFileResponse|Response {
if (! $this->isCssAsset($mimeType, $requestedPath)) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
$css = @file_get_contents($filePath);
// 읽기에 실패하면 치환을 포기하고 원본을 그대로 서빙한다 — 치환은 편의 장치이므로
// 그 실패가 자산 자체를 못 내보내는 사유가 되어서는 안 된다.
if ($css === false) {
return $this->fileResponse($filePath, $mimeType, $maxAge);
}
// 서브리소스에는 CSS 자신이 받은 캐시 버전을 그대로 승계한다. 버전이 오르면 CSS URL
// 이 바뀌어 재요청되고, 그 안의 서브리소스 URL 도 같은 버전을 달고 나가므로 두 계층의
// 무효화 시점이 어긋나지 않는다.
$version = request()->query('v');
$version = is_string($version) && $version !== '' ? $version : null;
$rewritten = AssetCssUrlRewriter::rewrite(
$css,
$requestedPath,
static fn (string $path): string => AssetUrl::extensionApiAsset($extensionType, $identifier, $path, $version)
);
// ETag 는 **내보내는 본문** 기준이어야 한다. 파일 stat 기준으로 잡으면 URL 모드가
// 바뀌어 본문이 달라져도 같은 ETag 가 나와 브라우저가 옛 본문을 계속 쓴다.
$etag = md5($rewritten);
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}, immutable"
: 'no-cache';
return response($rewritten, 200, [
'Content-Type' => $mimeType,
'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT',
'ETag' => $etag,
'Cache-Control' => $cacheControl,
]);
}
/**
* 이 자산이 CSS 인지 판정합니다.
*
* MIME 과 확장자를 함께 본다 — 서빙 계층이 돌려주는 MIME 은 환경에 따라
* `text/plain` 으로 떨어질 수 있고, 그때 확장자가 유일한 단서다.
*
* @param string $mimeType MIME 타입
* @param string $path 확장 기준 요청 경로
* @return bool CSS 여부
*/
private function isCssAsset(string $mimeType, string $path): bool
{
if (str_contains(strtolower($mimeType), 'css')) {
return true;
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'css';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use App\Support\DevTools\DebugGate;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 디버그 모드가 꺼져 있으면 요청을 403 으로 차단합니다 (DevTools 엔드포인트 게이트).
*
* 왜 미들웨어인가:
* 이 게이트는 원래 `routes/devtools.php` 의 **핸들러 안**에 `if (! DebugGate::isEnabled())`
* 블록으로 들어 있었다. 그러다 보니 8개 라우트 중 POST 3종에만 붙고 GET 4종·DELETE 1종은
* 빠졌다 — 라우트를 추가할 때 게이트를 함께 적는 것을 잊으면 그 라우트만 조용히 열린다.
* 실제로 `DELETE /_boost/g7-debug/clear` 는 production·`APP_DEBUG=false` 에서도 미인증
* 200 으로 `storage/debug-dump` 전체를 지웠다(공개#128).
*
* 게이트가 하나라도 빠지면 예외도 로그도 남지 않는다 — 그 엔드포인트가 정상 응답하는 것이
* 유일한 증상이다. 그래서 판정을 그룹 미들웨어 **단일 지점**으로 올려 라우트 추가가 게이트
* 부착과 분리될 수 없게 만든다. 부착 지점은 `bootstrap/app.php` 의 devtools 래퍼 하나다.
*
* 판정 SSoT 는 `DebugGate::isEnabled()` 를 그대로 쓴다 (`config('app.debug')` 또는 관리자
* 환경설정 `debug.mode`). 응답 형태도 종전 핸들러 블록과 동일하게 유지한다 — 이미 배포된
* 브라우저 인젝션 스크립트와 MCP 도구가 이 shape 을 읽는다.
*/
class EnsureDebugMode
{
/**
* 요청을 처리합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response HTTP 응답 (디버그 모드 OFF 시 403 JSON)
*/
public function handle(Request $request, Closure $next): Response
{
if (! DebugGate::isEnabled()) {
return response()->json([
'status' => 'error',
'message' => __('devtools.debug_disabled'),
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\SafeCustomAssetPath;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 본문 조회·삭제 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당한다.
*
* 조회와 삭제가 같은 요청 클래스를 쓰는 이유: 둘 다 "존재하는 파일 하나를 경로로 지목"
* 이라는 동일한 입력이고, 검증 강도가 갈리면 약한 쪽이 우회로가 된다.
*/
class ReadExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
// 삭제는 폰트·이미지도 대상이므로 확장자 제한을 두지 않는다. 편집 가능 여부는
// 서비스가 판정한다 — 여기서 편집 확장자로 좁히면 올린 폰트를 지울 수 없다.
'path' => ['required', 'string', 'max:255', new SafeCustomAssetPath],
];
return HookManager::applyFilters('core.extension_custom_asset.read_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'path.required' => __('custom_assets.validation.path_required'),
];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\SafeCustomAssetPath;
use App\Services\CustomAssetService;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 본문 저장 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당하므로
* authorize()는 true 를 고정 반환한다.
*/
class SaveExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'path' => ['required', 'string', 'max:255', new SafeCustomAssetPath(CustomAssetService::EDITABLE_EXTENSIONS)],
// 빈 문자열 저장을 허용한다 — 운영자가 CSS 를 통째로 비우는 것은 정당한 조작이고,
// 그것을 막으면 파일을 지우는 것 말고는 되돌릴 방법이 없어진다.
'content' => ['present', 'string', 'max:'.CustomAssetService::MAX_TEXT_BYTES],
];
return HookManager::applyFilters('core.extension_custom_asset.save_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'path.required' => __('custom_assets.validation.path_required'),
'content.present' => __('custom_assets.validation.content_present'),
'content.max' => __('custom_assets.errors.too_large_to_edit', [
'limit' => (string) CustomAssetService::MAX_TEXT_BYTES,
]),
];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Requests\Admin\Extension;
use App\Extension\HookManager;
use App\Rules\AllowedTemplateFileType;
use App\Rules\SafeCustomAssetPath;
use App\Services\CustomAssetService;
use Illuminate\Foundation\Http\FormRequest;
/**
* 사용자 추가 에셋 업로드 요청 검증
*
* 권한 검사는 라우트의 permission 미들웨어(core.extensions.custom_assets.manage)가 담당한다.
*
* 허용 확장자는 자산 서빙과 **같은 목록**(`AllowedTemplateFileType`)을 쓴다. 여기만 넓히면
* 올릴 수는 있는데 서빙되지 않는 파일이 생기고, 여기만 좁히면 서빙 규칙이 사문화된다.
*/
class UploadExtensionCustomAssetRequest extends FormRequest
{
/**
* 요청 권한 확인 — 권한은 permission 미들웨어가 담당.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙
*
* @return array<string, mixed>
*/
public function rules(): array
{
$maxKilobytes = (int) (CustomAssetService::MAX_UPLOAD_BYTES / 1024);
$rules = [
'file' => [
'required',
'file',
'max:'.$maxKilobytes,
'mimes:'.implode(',', AllowedTemplateFileType::getAllowedExtensions()),
],
// 하위 디렉토리는 선택 — 없으면 `custom/` 바로 아래에 놓인다.
'directory' => ['nullable', 'string', 'max:200', new SafeCustomAssetPath],
];
return HookManager::applyFilters('core.extension_custom_asset.upload_validation_rules', $rules, $this);
}
/**
* 검증 메시지
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'file.required' => __('custom_assets.validation.file_required'),
'file.file' => __('custom_assets.validation.file_invalid'),
'file.mimes' => __('custom_assets.validation.file_mimes', [
'allowed' => implode(', ', AllowedTemplateFileType::getAllowedExtensions()),
]),
'file.max' => __('custom_assets.errors.upload_too_large', [
'limit' => (string) CustomAssetService::MAX_UPLOAD_BYTES,
]),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Auth;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 2단계 인증 코드 재발송 요청
*
* 비밀번호 확인 단계가 돌려준 challenge 로만 재발송할 수 있습니다. 이 요청은 아직 로그인 전
* 상태에서 호출되므로 인증 미들웨어를 거치지 않으며, 주체 식별은 challenge 에 기록된
* 사용자로만 이루어집니다.
*/
class TwoFactorResendRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
* @return bool 항상 true (주체 식별은 challenge 가 담당)
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'challenge_id' => ['required', 'string', 'uuid'],
];
// 확장이 자체 2단계 수단을 붙일 때 재발송 입력을 추가할 수 있도록 개방한다
return HookManager::applyFilters('core.auth.two_factor_resend_validation_rules', $rules, $this);
}
/**
* 검증 실패 메시지를 반환합니다.
*
* @return array<string, string> 검증 메시지
*/
public function messages(): array
{
return [
'challenge_id.required' => __('validation.auth.two_factor.challenge_required'),
'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'),
];
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Template;
use App\Rules\AllowedTemplateFileType;
use App\Rules\SafeTemplatePath;
use App\Support\CustomAssets;
use App\Support\Routing\DualExtensionRoute;
use Illuminate\Foundation\Http\FormRequest;
@@ -32,9 +33,19 @@ class ServeTemplateAssetRequest extends FormRequest
// 확장 가능한 "동적 필드" 가 없는 요청이라 룰의 취지(필드 확장)도 해당하지 않는다.
public function rules(): array
{
// 템플릿 식별자로부터 기준 경로 구성
// 템플릿 식별자로부터 기준 경로 구성.
//
// 컨테인먼트 기준은 **실제로 읽는 디렉토리**여야 한다. 템플릿 자산은 `dist/` 이하가
// 기본이지만 운영자 소유 디렉토리(`custom/`)만은 그 밖에 있고, 서빙측
// `TemplateService::getAssetFilePath()` 가 그 분기를 갖는다. 기준을 `dist` 로
// 고정하면 `custom/**` 은 realpath 가 실패해 문자열 접두 비교로만 통과하므로,
// 검증한 경로와 읽는 경로가 서로 다른 상태가 된다. 두 곳의 분기를 같은 조건으로
// 맞춰 둔다 — 한쪽만 바뀌면 custom 서빙이 조용히 깨지거나 검증이 헐거워진다.
$identifier = $this->route('identifier');
$basePath = base_path("templates/{$identifier}/dist");
$requestedPath = (string) $this->input('path');
$basePath = str_starts_with($requestedPath, CustomAssets::DIRECTORY.'/')
? base_path("templates/{$identifier}")
: base_path("templates/{$identifier}/dist");
return [
'identifier' => ['required', 'string'],
@@ -97,6 +97,20 @@ class SaveSettingsRequest extends FormRequest
]);
}
// 사이트 기본 OG 이미지도 동일하게 첨부 객체 배열 → 정수 ID 배열로 정규화
$ogImageDefault = $this->input('seo.og_image_default');
if (is_array($ogImageDefault)) {
$this->merge([
'seo' => array_merge($this->input('seo', []), [
'og_image_default' => array_map(
fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item,
$ogImageDefault
),
]),
]);
}
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
@@ -243,6 +257,8 @@ class SaveSettingsRequest extends FormRequest
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
'seo.og_image_default' => ['nullable', 'array'],
'seo.og_image_default.*' => ['integer', Rule::exists(Attachment::class, 'id')],
'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)],
'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)],
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
@@ -915,6 +931,7 @@ class SaveSettingsRequest extends FormRequest
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
'general.site_logo' => __('validation.attributes.site_logo'),
'seo.og_image_default' => __('validation.attributes.og_image_default'),
// mail
'mail.mailer' => __('validation.attributes.mailer'),
'mail.host' => __('validation.attributes.smtp_host'),
+13 -2
View File
@@ -4,6 +4,7 @@ namespace App\Http\Resources;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Services\ExtensionStaticCacheService;
use App\Support\TemplateExternals;
use Composer\Semver\Semver;
use Illuminate\Http\Request;
@@ -118,8 +119,18 @@ class TemplateResource extends BaseApiResource
'components' => $this->getValue('components', ['basic' => [], 'composite' => []]),
'license' => $this->getValue('license'),
'metadata' => $this->getValue('metadata', []),
// 외부 리소스 (template.json externals) — 정규화 책임은 TemplateExternals 에 위임
'externals' => TemplateExternals::normalize($this->getValue('externals', [])),
// 외부 리소스 (template.json externals) — 정규화 책임은 TemplateExternals 에 위임.
// `asset` 항목의 URL 해석에는 식별자와 캐시 버전이 필요하므로 뷰 컴포저
// (CollectsTemplateExternals) 와 같은 인자를 넘긴다 — 빠뜨리면 `asset` 항목이
// "식별자 미상" 으로 조용히 버려져 응답에서 사라진다.
'externals' => TemplateExternals::normalize(
$this->getValue('externals', []),
$this->getValue('identifier'),
// 트레이트를 사용하는 클래스 경유로 호출한다 — 트레이트 정적 메서드
// 직접 호출(`ClearsTemplateCaches::`)은 PHP 8.1+ E_DEPRECATED 라
// 템플릿 상세 응답마다 로그를 남긴다 (AssetUrl::staticExtBase 와 동형).
ExtensionStaticCacheService::getExtensionCacheVersion(),
),
// 의존성 상세 정보
'dependencies' => $this->getDetailedDependencies(),
// 타임스탬프
+8 -3
View File
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsCustomAssets;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
@@ -20,6 +21,7 @@ use Illuminate\View\View;
class TemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsCustomAssets;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
@@ -95,12 +97,13 @@ class TemplateComposer
$appConfig = [];
}
// 템플릿의 외부 리소스 정보 수집
$templateExternals = $this->collectTemplateExternals($activeTemplate);
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 템플릿의 외부 리소스 정보 수집
// (자체 제공 `asset` 항목의 URL 을 만들 때 캐시 버전이 필요해 뒤로 옮겼다)
$templateExternals = $this->collectTemplateExternals($activeTemplate, $extensionCacheVersion);
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
@@ -120,6 +123,8 @@ class TemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('customAssets', $this->collectCustomAssets($activeTemplate));
$view->with('customAssetsDisabled', $this->customAssetsDisabledByRequest());
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
@@ -0,0 +1,255 @@
<?php
namespace App\Http\View\Composers\Traits;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\ExtensionStaticCacheService;
use App\Support\CustomAssets;
use Illuminate\Support\Facades\Log;
/**
* 사용자 추가 에셋(`custom/`) 수집 Trait
*
* 활성 확장의 `custom/` 자산을 모아 프론트로 넘긴다. 로드 자체는 프론트가 하며,
* **확장 병합 번들 뒤**에 붙는다 — CSS 는 나중에 온 규칙이 이기므로, 운영자가 덧붙인
* 스타일이 확장 스타일보다 뒤에 와야 재정의가 성립한다.
*
* 확장 간 순서는 모듈 → 플러그인 → 템플릿이다. 화면 외관의 최종 책임이 템플릿에 있어,
* 템플릿 운영자의 재정의가 가장 뒤에 와야 한다.
*
* @property ModuleManagerInterface $moduleManager
* @property PluginManagerInterface $pluginManager
*/
trait CollectsCustomAssets
{
use ClearsTemplateCaches;
/**
* 활성 확장의 사용자 추가 에셋을 수집합니다.
*
* 비활성 확장은 제외한다 — 자산 서빙이 활성 확장만 응답하므로, 넣어 봐야 404 가
* 될 항목을 페이지에 실을 이유가 없다.
*
* `?custom=off` 가 붙은 요청은 목록을 비운다 — 탈출구(D33). 운영자가 넣은 CSS 한
* 줄이 관리자 화면을 조작 불능으로 만들면 그것을 고칠 화면에도 그 CSS 가 실려 있어
* 스스로 갇힌다. 서버가 목록을 비우면 자산이 페이지에 **도달하지 않으므로**, 이미
* 깨진 화면에서 자바스크립트가 돌기를 기대할 필요가 없다.
*
* @param string|null $templateIdentifier 활성 템플릿 식별자
* @return array<int, array<string, mixed>> 서술자 목록 (로드 순서대로)
*/
private function collectCustomAssets(?string $templateIdentifier): array
{
if ($this->customAssetsDisabledByRequest()) {
return [];
}
$assets = $this->resolveCustomAssets($templateIdentifier);
// 변경을 감지해 버전이 올랐다면 방금 만든 URL 은 **옛 버전**을 가리킨다.
// 그대로 내보내면 운영자가 파일을 고친 그 화면에서만 옛 CSS 가 보이고, 새로고침
// 한 번을 더 해야 반영된다 — 원인을 알 수 없는 한 박자 지연으로만 나타난다.
// 새 버전으로 다시 해석한다. 게시본은 아직 없으므로 URL 은 API 형태로 떨어지고,
// 그 응답은 디스크의 최신 내용을 그대로 준다. 다음 요청부터 정적 경로가 된다.
if ($this->syncCustomAssetCacheVersion($templateIdentifier)) {
CustomAssets::flushCache();
$assets = $this->resolveCustomAssets($templateIdentifier);
}
return $assets;
}
/**
* 활성 확장의 사용자 추가 에셋 서술자를 해석합니다.
*
* @param string|null $templateIdentifier 활성 템플릿 식별자
* @return array<int, array<string, mixed>> 서술자 목록 (로드 순서대로)
*/
private function resolveCustomAssets(?string $templateIdentifier): array
{
$assets = [];
foreach ($this->activeExtensionIdentifiers('modules') as $identifier) {
$assets = array_merge($assets, CustomAssets::forExtension('modules', $identifier));
}
foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) {
$assets = array_merge($assets, CustomAssets::forExtension('plugins', $identifier));
}
if (! empty($templateIdentifier)) {
$assets = array_merge($assets, CustomAssets::forExtension('templates', $templateIdentifier));
}
return $assets;
}
/**
* 운영자가 파일을 고쳤으면 확장 캐시 버전을 올립니다.
*
* custom 자산은 확장 자산과 **같은 메커니즘**으로 정적 게시되므로 갱신 축도 같아야
* 한다. 그런데 확장 캐시 버전은 수명주기 이벤트에서만 오르고, 운영자가 FTP 로 파일
* 하나를 바꾸는 것은 그 이벤트가 아니다. 그래서 파일 서명이 달라진 것을 여기서
* 감지해 같은 단일 지점(`incrementExtensionCacheVersion`)을 호출한다 — 그 지점이
* 재게시 예약까지 담당하므로 새로 만들 기계가 없다.
*
* 서명이 **저장된 적 없으면 올리지 않는다.** 캐시 스토어가 요청마다 비는 환경
* (array 스토어 등)에서 "매번 달라짐" 으로 읽혀 버전이 무한히 오르고 재게시가
* 끝없이 돌게 되기 때문이다. 첫 관측은 기록만 하고 다음 변화부터 반응한다.
*
* 버전을 올렸으면 `true` 를 돌려준다 — 호출자가 서술자를 다시 해석해야 한다.
* 이미 만든 URL 은 옛 버전을 가리키기 때문이다.
*
* 서명은 **렌더 스코프별로** 따로 기억한다. 수집 대상이 모듈·플러그인(양쪽 동일)에
* 더해 `resolveCustomAssets` 가 싣는 **그 렌더의 템플릿 하나**라, 관리자 렌더와
* 사용자 렌더의 서명은 파일이 그대로여도 정상적으로 다르다. 기억할 자리가 하나면
* 두 렌더가 번갈아 덮어쓰며 매번 "파일이 바뀌었다" 로 읽혀, 운영자가 아무것도
* 건드리지 않았는데 페이지를 오갈 때마다 확장 캐시 버전이 오르고 전체 재게시가
* 예약된다(모든 자산 URL 이 상시 변동 → 브라우저 캐시 무효). 예외도 화면 이상도
* 없어 로그 외에는 드러나지 않는다.
*
* 스코프를 나누되 **키는 하나로 두고 값을 맵으로** 쓴다. `CustomAssetService` 가
* 쓰기 뒤 이 키 하나를 `forget` 하는 것에 의존하므로(같은 키를 보는 두 소비자),
* 키를 쪼개면 그 소비자가 일부만 지우게 되어 같은 결함군이 재생산된다.
*
* 서명 재료는 **게시와 같은 열거자**(`CustomAssets::publishableFiles`) — 활성 모듈·플러그인과
* 이 렌더의 템플릿의 `custom/**` 전체(하위 디렉토리·글꼴·이미지 포함)의 경로·수정 시각·
* 크기다. 종전에는 로드 목록(최상위 css/js)의 mtime 만 봤는데, 게시는 재귀 복사라
* 글꼴만 교체한 변경이 영영 감지되지 않았다(#651 F7). 크기를 함께 넣는 이유는 mtime 해상도
* (1초·일부 파일시스템 2초) 안의 재작성과 mtime 보존 복사(rsync -t)를 잡기 위해서다.
*
* 스코프 키에는 **호스트명**을 붙인다. 다중 웹서버가 캐시를 공유하면 같은 파일이라도 서버마다
* 배포 시각(mtime)이 달라, 하나의 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로
* 읽혀 전체 재게시가 왕복한다(#651 F9). 서버별로 기억하면 각 서버는 자기 파일만 대조한다.
*
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
* @return bool 확장 캐시 버전을 올렸으면 true
*/
private function syncCustomAssetCacheVersion(?string $scope = null): bool
{
try {
$signature = $this->customAssetSignature($scope);
$scopeKey = ($scope ?? '').'@'.(gethostname() ?: 'host');
// 버전 키와 같은 고정 스토어·코어 네임스페이스 — 컨테이너 바인딩을 거치면 재바인딩
// 컨텍스트에서 다른 네임스페이스에 저장되어 첫 관측 규칙이 실제 변경을 삼킨다.
$cache = self::customSignatureCache();
// 구 배포본이 남긴 스칼라 값은 맵이 아니다 — 어느 스코프의 것인지 알 수 없으므로
// 서명으로 쓰지 않고 미관측으로 취급한다(첫 관측은 기록만 하니 헛된 bump 가 없다).
$storedMap = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY);
$storedMap = is_array($storedMap) ? $storedMap : [];
$stored = $storedMap[$scopeKey] ?? null;
if ($stored === $signature) {
return false;
}
$storedMap[$scopeKey] = $signature;
// 버전 키와 같은 영구 TTL — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라
// 실제 변경 1회를 놓친다.
$cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS);
if ($stored === null) {
return false;
}
Log::info('사용자 추가 에셋 변경 감지 — 확장 캐시 버전을 올립니다.', [
'scope' => $scopeKey,
'previous' => $stored,
'current' => $signature,
]);
$this->incrementExtensionCacheVersion();
return true;
} catch (\Exception $e) {
// 감지 실패가 화면을 막지 않는다 — 최악이라도 URL 이 한동안 옛 버전일 뿐이다
Log::warning('사용자 추가 에셋 변경 감지 실패', ['error' => $e->getMessage()]);
return false;
}
}
/**
* 이 렌더 스코프의 custom 파일 서명을 만듭니다.
*
* 재료 = 활성 모듈 → 활성 플러그인 → 렌더 템플릿 순으로 `publishableFiles()` 전체의
* `{type}/{id}/{relative}@{mtime}@{size}`. 정렬 뒤 md5. 파일이 하나도 없으면 `empty`.
*
* @param string|null $scope 렌더 스코프 (활성 템플릿 식별자)
* @return string 서명
*/
private function customAssetSignature(?string $scope): string
{
$parts = [];
$targets = [];
foreach ($this->activeExtensionIdentifiers('modules') as $identifier) {
$targets[] = ['modules', $identifier];
}
foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) {
$targets[] = ['plugins', $identifier];
}
if (! empty($scope)) {
$targets[] = ['templates', $scope];
}
foreach ($targets as [$type, $identifier]) {
foreach (CustomAssets::publishableFiles($type, $identifier) as $file) {
$parts[] = $type.'/'.$identifier.'/'.$file['relative'].'@'.$file['mtime'].'@'.$file['size'];
}
}
sort($parts, SORT_STRING);
return $parts === [] ? 'empty' : md5(implode('|', $parts));
}
/**
* 이번 요청이 사용자 추가 에셋을 끄도록 요구했는지 판정합니다.
*
* 판정을 수집 **맨 앞**에 두는 것이 중요하다. 뒤에 두면 빈 목록이 변경 감지
* (`syncCustomAssetCacheVersion`)에 도달해 "전부 사라짐" 으로 읽히고, 그 다음 정상
* 요청이 다시 "전부 생김" 으로 읽혀 요청마다 확장 캐시 버전이 오르내린다.
*
* @return bool `?custom=off` 이면 true
*/
private function customAssetsDisabledByRequest(): bool
{
try {
return request()->query('custom') === 'off';
} catch (\Exception $e) {
// 요청 컨텍스트가 없는 렌더(콘솔·SEO 사전 렌더 등)는 끄지 않는다
return false;
}
}
/**
* 활성 확장 식별자 목록을 돌려줍니다.
*
* @param string $extensionType `modules` | `plugins`
* @return array<int, string> 식별자 목록
*/
private function activeExtensionIdentifiers(string $extensionType): array
{
try {
$active = $extensionType === 'modules'
? $this->moduleManager->getActiveModules()
: $this->pluginManager->getActivePlugins();
return array_keys($active);
} catch (\Exception $e) {
Log::warning("Failed to collect active {$extensionType} for custom assets: ".$e->getMessage());
return [];
}
}
}
@@ -21,10 +21,14 @@ trait CollectsTemplateExternals
*
* template.json의 externals 배열만 사용하며 legacy key fallback은 제공하지 않습니다.
*
* 항목이 `asset`(템플릿이 자체 제공하는 `dist/` 이하 경로)을 선언하면 식별자와
* 캐시 버전으로 자산 URL 을 만든다 — 그래서 둘을 함께 받는다.
*
* @param string|null $templateIdentifier 템플릿 식별자
* @param int|string|null $version 캐시 무효화 버전 (`asset` 항목의 `?v`)
* @return array<int, array<string, mixed>>
*/
private function collectTemplateExternals(?string $templateIdentifier): array
private function collectTemplateExternals(?string $templateIdentifier, int|string|null $version = null): array
{
if (empty($templateIdentifier)) {
return [];
@@ -37,7 +41,7 @@ trait CollectsTemplateExternals
return [];
}
return TemplateExternals::normalize($template['externals']);
return TemplateExternals::normalize($template['externals'], $templateIdentifier, $version);
} catch (\Exception $e) {
Log::warning('Failed to collect template externals: '.$e->getMessage());
@@ -8,6 +8,7 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
use App\Http\View\Composers\Traits\CollectsCustomAssets;
use App\Http\View\Composers\Traits\CollectsExtensionAssets;
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
use App\Services\ModuleSettingsService;
@@ -25,6 +26,7 @@ use Illuminate\View\View;
class UserTemplateComposer
{
use CollectsActiveExtensionMeta;
use CollectsCustomAssets;
use CollectsExtensionAssets;
use CollectsTemplateExternals;
@@ -101,12 +103,13 @@ class UserTemplateComposer
$appConfig = [];
}
// 템플릿의 외부 리소스 정보 수집
$templateExternals = $this->collectTemplateExternals($activeTemplate);
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 템플릿의 외부 리소스 정보 수집
// (자체 제공 `asset` 항목의 URL 을 만들 때 캐시 버전이 필요해 뒤로 옮겼다)
$templateExternals = $this->collectTemplateExternals($activeTemplate, $extensionCacheVersion);
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
@@ -126,6 +129,8 @@ class UserTemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('customAssets', $this->collectCustomAssets($activeTemplate));
$view->with('customAssetsDisabled', $this->customAssetsDisabledByRequest());
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
+26
View File
@@ -130,6 +130,7 @@ class CoreActivityLogListener implements HookListenerInterface
'core.templates.after_uninstall' => ['method' => 'handleTemplateAfterUninstall', 'priority' => 20],
'core.templates.after_version_update' => ['method' => 'handleTemplateAfterVersionUpdate', 'priority' => 20],
'core.templates.after_refresh_layouts' => ['method' => 'handleTemplateAfterRefreshLayouts', 'priority' => 20],
'core.custom_assets.after_change' => ['method' => 'handleCustomAssetAfterChange', 'priority' => 20],
// ─── Layout ───
'core.layout.after_update' => ['method' => 'handleLayoutAfterUpdate', 'priority' => 20],
@@ -1103,6 +1104,31 @@ class CoreActivityLogListener implements HookListenerInterface
]);
}
/**
* 사용자 추가 에셋(`custom/`) 변경 후 로그 기록
*
* @param string $extensionType 확장 타입 (`templates` 등)
* @param string $identifier 확장 식별자
* @param string $operation 수행한 작업 (`save` | `upload` | `delete`)
* @param string $path `custom/` 기준 상대 경로
*/
public function handleCustomAssetAfterChange(
string $extensionType,
string $identifier,
string $operation,
string $path
): void {
$this->logActivity('custom_asset.'.$operation, [
'description_key' => 'activity_log.description.custom_asset_'.$operation,
'description_params' => ['identifier' => $identifier, 'path' => $path],
'properties' => [
'extension_type' => $extensionType,
'identifier' => $identifier,
'path' => $path,
],
]);
}
// ═══════════════════════════════════════════
// Layout 핸들러
// ═══════════════════════════════════════════
@@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle
*/
private function refreshRegistry(): void
{
// Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
$this->app->forgetInstance(LanguagePackRegistry::class);
$this->app->forgetInstance(LanguagePackSeedInjector::class);
// boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다.
// forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가
// 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지
// 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해
// 캡처된 참조에도 전파한다 (#597 에서 실측·교정).
$registry = $this->app->make(LanguagePackRegistry::class);
$registry->invalidate();
config([
'app.supported_locales' => $registry->getActiveCoreLocales(),
'app.locale_names' => $registry->getLocaleNames(),
@@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어
* `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어
* 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘).
*
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
*
* 정책 (계획서 §6.6, §9.4):
* 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path
* 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다:
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
* - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존)
* - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기
* - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음
* - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존)
* - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기
* (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다)
* - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음
*/
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
class SyncDatabaseTranslations
@@ -0,0 +1,122 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Helpers\TimezoneHelper;
use App\Services\ExtensionStaticCacheService;
use Carbon\Carbon;
/**
* 부트스트랩 리소스 정적 게시 실패 알림 리스너 (#122)
*
* 게시 실패는 사이트를 멈추지 않는다 — API 폴백으로 넘어가 화면은 정상이고, 서버 로그에만
* warning 이 쌓인다. 그래서 쓰기 불가 환경에서 정적 fast path 가 **영구히 꺼진 채로**
* 운영되는 상태를 아무도 눈치채지 못했다(제보 본건).
*
* 관리자 대시보드가 그 사실이 운영자에게 도달하는 통로다. 대시보드 레이아웃·컨트롤러·
* API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts` 필터 훅에 항목을 얹기만 한다.
*
* 표시 조건: 실패 마커가 존재하고 **연속 실패가 2회 이상**. 1회는 배포 중 일시적 경합일 수
* 있고, 그 경우 다음 렌더의 자가 치유가 해소한다 — 매번 알리면 알림이 소음이 된다.
*
* @since 7.0.10
*/
class StaticPublishFailureAlertListener implements HookListenerInterface
{
/**
* 알림을 띄우기 시작하는 연속 실패 횟수.
*/
private const ALERT_THRESHOLD = 2;
/**
* 구독할 훅과 메서드 매핑 반환.
*
* @return array 훅 매핑 배열
*/
public static function getSubscribedHooks(): array
{
return [
'core.dashboard.alerts' => [
'method' => 'addStaticPublishAlert',
'priority' => 15,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트 처리 (기본 핸들러).
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void
{
// 기본 핸들러는 사용하지 않음
}
/**
* 정적 게시 실패 알림을 대시보드에 추가합니다.
*
* @param array $alerts 기존 알림 배열
* @return array 알림이 추가된 배열
*/
public function addStaticPublishAlert(array $alerts): array
{
$marker = ExtensionStaticCacheService::failureMarker();
if ($marker === null) {
return $alerts;
}
$count = (int) ($marker['count'] ?? 0);
if ($count < self::ALERT_THRESHOLD) {
return $alerts;
}
$reason = (string) ($marker['reason'] ?? 'write_failed');
$alerts[] = [
'id' => 'static_publish_failure',
'type' => 'warning',
// 원인별로 조치가 다르다 — 뭉뚱그리면 운영자가 어디를 봐야 할지 알 수 없다.
'subtype' => 'static_publish_'.$reason,
'icon' => 'exclamation-triangle',
'title' => __('extensions.alerts.static_publish_failed_title'),
'message' => __('extensions.alerts.static_publish_failed_'.$this->messageKeySuffix($reason), [
'count' => $count,
]),
'time' => isset($marker['at'])
? TimezoneHelper::toUserCarbon(Carbon::parse($marker['at']))?->diffForHumans()
: null,
'read' => false,
// 알림에서 바로 복구한다 (#651 D11) — 운영자가 결함을 처음 만나는 곳이 알림이므로 그 자리에서
// 끝나야 한다. 대시보드 렌더러는 `recover_endpoint` 가 있는 알림에 버튼을 그리고 POST 한다
// (기존 재호환 알림과 같은 배선). 성공 문구는 재호환 알림의 기본 문구가 이 알림에는 맞지
// 않으므로 리스너가 함께 싣는다.
'recover_endpoint' => '/api/admin/settings/static-cache/republish',
'recover_label' => __('extensions.alerts.static_publish_recover_label'),
'recover_success_message' => __('extensions.alerts.static_publish_recovered'),
];
return $alerts;
}
/**
* 사유 코드를 다국어 키 접미사로 변환합니다.
*
* 알 수 없는 사유는 일반 문구로 떨어뜨린다 — 키가 없으면 번역기가 키 문자열을 그대로
* 화면에 내보낸다.
*
* @param string $reason 사유 코드
* @return string 다국어 키 접미사
*/
private function messageKeySuffix(string $reason): string
{
return match ($reason) {
'parent_not_writable', 'write_failed', 'lock_unavailable' => $reason,
default => 'write_failed',
};
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Support\TrustedProxyDiagnostic;
/**
* 신뢰 프록시 미설정 경고 리스너 (#124)
*
* 프록시 뒤에서 신뢰 프록시가 설정되지 않은 상태는 HTTPS 종단 구성에서는 화면 백지로 즉시
* 드러나지만, HTTP 전용 사이트가 프록시 뒤에 있으면 **화면이 완전히 정상이다.** 그 상태에서도
* webhook 403 · IP 기록 왜곡 · 로그인 제한 붕괴는 그대로 발생한다. 이 알림이 그 조용한 구성을
* 덮는 유일한 통로다.
*
* 대시보드 레이아웃·컨트롤러·API 스키마는 건드리지 않는다 — 기존 `core.dashboard.alerts`
* 필터 훅에 항목을 얹기만 한다(선례: StaticPublishFailureAlertListener).
*
* @since 7.0.10
*/
class TrustedProxyAlertListener implements HookListenerInterface
{
/**
* 구독할 훅과 메서드 매핑 반환.
*
* @return array 훅 매핑 배열
*/
public static function getSubscribedHooks(): array
{
return [
'core.dashboard.alerts' => [
'method' => 'addTrustedProxyAlert',
'priority' => 15,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트 처리 (기본 핸들러).
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void
{
// 기본 핸들러는 사용하지 않음
}
/**
* 신뢰 프록시 미설정 경고를 대시보드에 추가합니다.
*
* @param array $alerts 기존 알림 배열
* @return array 알림이 추가된 배열
*/
public function addTrustedProxyAlert(array $alerts): array
{
$diagnostic = TrustedProxyDiagnostic::forRequest(request());
if ($diagnostic['status'] !== TrustedProxyDiagnostic::STATUS_WARNING) {
return $alerts;
}
$alerts[] = [
'id' => 'trusted_proxy_missing',
'type' => 'warning',
'subtype' => 'trusted_proxy_missing',
'icon' => 'exclamation-triangle',
'title' => __('settings.trusted_proxy.alert_title'),
// 원인과 조치를 함께 담는다 — 원인만 알려 주면 운영자가 어디를 고쳐야 할지 모른다.
'message' => __('settings.trusted_proxy.alert_message', [
'headers' => implode(', ', $diagnostic['forwarded_headers']),
'ip' => (string) ($diagnostic['client_ip'] ?? '-'),
]),
'time' => null,
'read' => false,
];
return $alerts;
}
}
+11
View File
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
'is_active',
];
/**
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
* (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
*
* @var array<int, string>
*/
protected array $translatableTrackableFields = [
'subject',
'body',
];
/**
* 활동 로그 추적 필드.
*
+21 -7
View File
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
'is_active',
];
/**
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
*
* 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
* 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
* 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
*
* @var array<int, string>
*/
protected array $translatableTrackableFields = [
'subject',
'body',
];
/**
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
*/
@@ -110,13 +124,6 @@ class NotificationTemplate extends Model
return $this->belongsTo(User::class, 'updated_by');
}
/**
* 특정 채널의 템플릿 조회.
*
* @param Builder $query
* @param string $channel
* @return Builder
*/
/**
* 수신자 규칙이 설정되어 있는지 확인합니다.
*
@@ -127,6 +134,13 @@ class NotificationTemplate extends Model
return ! empty($this->recipients);
}
/**
* 특정 채널의 템플릿 조회.
*
* @param Builder $query
* @param string $channel
* @return Builder
*/
public function scopeByChannel(Builder $query, string $channel): Builder
{
return $query->where('channel', $channel);
+13 -29
View File
@@ -9,9 +9,9 @@ use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Helpers\ResponseHelper;
use App\Http\View\Composers\TemplateComposer;
use App\Http\View\Composers\UserTemplateComposer;
use App\Listeners\ExtensionCompatibilityAlertListener;
use App\Notifications\NotificationChannelManager;
use App\Services\ChannelReadinessService;
use App\Services\GeoIpService;
@@ -99,9 +99,6 @@ class AppServiceProvider extends ServiceProvider
View::composer('admin', TemplateComposer::class);
View::composer('app', UserTemplateComposer::class);
// 확장 호환성 알림 리스너 등록
$this->registerExtensionCompatibilityAlertListener();
// SQL 쿼리 로그 설정
$this->configureSqlQueryLogging();
@@ -130,34 +127,21 @@ class AppServiceProvider extends ServiceProvider
$maxPerMinute = 60;
}
return Limit::perMinute($maxPerMinute)->by($request->ip());
// 기본 응답은 영문 "Too Many Attempts." 이다 — 로그인 화면은 이 문구를
// 그대로 노출하므로 다국어 키로 갈아끼운다.
return Limit::perMinute($maxPerMinute)
->by($request->ip())
->response(function (Request $request, array $headers) {
return ResponseHelper::error(
'auth.too_many_attempts',
429,
null,
['seconds' => (int) ($headers['Retry-After'] ?? 60)]
)->withHeaders($headers);
});
});
}
/**
* 확장 호환성 알림 리스너를 등록합니다.
*
* 코어 버전 호환성 문제로 자동 비활성화된 확장에 대한
* 알림을 관리자 대시보드에 표시하기 위한 훅 리스너입니다.
*/
private function registerExtensionCompatibilityAlertListener(): void
{
$listener = new ExtensionCompatibilityAlertListener;
$subscribedHooks = ExtensionCompatibilityAlertListener::getSubscribedHooks();
foreach ($subscribedHooks as $hookName => $config) {
$method = $config['method'] ?? 'handle';
$priority = $config['priority'] ?? 10;
$type = $config['type'] ?? 'action';
if ($type === 'filter') {
HookManager::addFilter($hookName, [$listener, $method], $priority);
} else {
HookManager::addAction($hookName, [$listener, $method], $priority);
}
}
}
/**
* 아웃바운드 HTTP 프록시를 설정합니다.
*
@@ -0,0 +1,42 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
/**
* 브로드캐스트 채널 인가 정의를 로드합니다 (`routes/channels.php`).
*
* 왜 프레임워크 기본 경로를 쓰지 않는가:
* `bootstrap/app.php` 의 `withRouting(channels: ...)` 인자는 두 가지를 한꺼번에 한다 —
* ① `routes/channels.php` 로드(원하는 것) ② `Broadcast::routes()` 자동 호출(원치 않는 것).
* ②가 등록하는 `GET|POST /broadcasting/auth` 에는 어떤 게이트도 없어서, 웹소켓 사용 OFF
* (`broadcasting.default === 'null'`, 공개#50) 킬스위치를 통째로 우회하는 경로가 된다.
* 프론트(`WebSocketManager`)는 게이트된 `/api/broadcasting/auth` 만 호출하므로 그 라우트는
* 死라우트이면서 우회로이기만 했다 — production 에서 미인증 POST 가 200 을 받았다(공개#128).
*
* 그래서 `channels:` 인자를 떼어 ②의 자동 등록을 끊고, ①만 이 프로바이더가 담당한다.
* 인증 엔드포인트의 SSoT 는 `routes/api.php` 의 `api.broadcasting.auth` 하나다
* (`auth:sanctum` + 킬스위치 가드).
*
* 라우트 캐시 안전:
* `Broadcast::channel()` 은 라우트가 아니라 브로드캐스터의 인가 콜백 등록이라 라우트 캐시와
* 무관하다. 매 부팅마다 실행되어야 하며, 누락되면 예외 없이 **모든 private 채널 구독이 403**
* 이 된다(콜백이 없는 채널은 거부되므로).
*/
class BroadcastServiceProvider extends ServiceProvider
{
/**
* 채널 인가 정의를 등록합니다.
*
* @return void
*/
public function boot(): void
{
$channels = base_path('routes/channels.php');
if (is_file($channels)) {
require $channels;
}
}
}
+19 -27
View File
@@ -97,6 +97,7 @@ use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use App\Services\UniqueIdService;
use App\Support\CoreUpdateContext;
use App\Support\ExtensionSettingsMirror;
use App\Support\PrivilegedDatabaseAccounts;
use Illuminate\Support\Facades\Config;
@@ -562,7 +563,7 @@ class CoreServiceProvider extends ServiceProvider
'type' => $type,
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
]);
}
}
@@ -626,6 +627,21 @@ class CoreServiceProvider extends ServiceProvider
$cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
}
/**
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
*
* 판정은 `App\Support\CoreUpdateContext::isInProgress()` 가 단독으로 소유한다 — 같은
* 플래그가 확장 자동 비활성화 스킵 · 코어 버전의 env 우선 판독 · `bootstrap/app.php` 의
* 패키지 매니페스트 자가 치유를 함께 게이트하므로, 판정이 갈라지면 한 경로만 조용히
* 다르게 동작한다. 이 메서드는 기존 호출처(각 Manager)를 위한 위임으로 남는다.
*
* @return bool 업데이트 트리 안이면 true (version-based 자동 비활성화 스킵)
*/
public static function isCoreUpdateInProgress(): bool
{
return CoreUpdateContext::isInProgress();
}
/**
* 호환되지 않는 템플릿을 자동 비활성화합니다.
*
@@ -634,30 +650,6 @@ class CoreServiceProvider extends ServiceProvider
*
* @param TemplateManager $templateManager 템플릿 매니저
*/
/**
* 현재 프로세스가 코어 업데이트 중인지 판정합니다.
*
* 판정 조건 (OR):
* 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모 CoreUpdateCommand 가 시작 시 설정,
* spawn 자식에도 `$env` 로 전파
* 2. artisan command 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지
* 않은 극단 상황 대비 보조 판정
*
* 둘 중 하나라도 true 면 업데이트 컨텍스트로 간주하여 version-based 자동 비활성화 스킵.
*/
public static function isCoreUpdateInProgress(): bool
{
$envFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS');
if ($envFlag === '1' || $envFlag === 1 || $envFlag === true) {
return true;
}
$argv = $_SERVER['argv'] ?? [];
$command = $argv[1] ?? '';
return in_array($command, ['core:update', 'core:execute-upgrade-steps'], true);
}
protected function validateAndDeactivateIncompatibleTemplates(TemplateManager $templateManager): void
{
// 업데이트 중 자동 비활성화 스킵 (validateAndDeactivateIncompatibleExtensions 와 동일 사유)
@@ -702,7 +694,7 @@ class CoreServiceProvider extends ServiceProvider
'type' => 'templates',
'identifier' => $identifier,
'required_version' => $requiredVersion,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
]);
}
}
@@ -730,7 +722,7 @@ class CoreServiceProvider extends ServiceProvider
$alerts = $cache->get('ext.compatibility_alerts', []);
$alerts[$type] = [
'deactivated' => $deactivated,
'core_version' => config('app.version'),
'core_version' => CoreVersionChecker::getCoreVersion(),
'timestamp' => now()->toIso8601String(),
];
$cache->put('ext.compatibility_alerts', $alerts, 86400); // 24시간
+104 -18
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Repositories\JsonConfigRepository;
use App\Support\AllowedExtensions;
use App\Support\EnvPriority;
use App\Support\ExtensionSettingsMirror;
use App\Support\OutboundProxy;
use Illuminate\Support\Facades\Config;
@@ -82,6 +83,10 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드에서 `.env` 가 소유권을 가져간 키를 제거한다 — 아래 가드들이
// 제거된 키를 자연히 건너뛰므로 지점마다 조건을 심지 않는다 (스위치 OFF 면 무동작).
$mailSettings = EnvPriority::filterLocked('mail', $mailSettings);
// 메일러 설정
if (! empty($mailSettings['mailer'])) {
Config::set('mail.default', $mailSettings['mailer']);
@@ -108,8 +113,13 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('mail.mailers.smtp.encryption', $mailSettings['encryption'] ?: null);
}
// 드라이버별 설정
$mailer = $mailSettings['mailer'] ?? '';
// 드라이버별 설정 — 마스터 드라이버가 `.env` 로 잠기면 저장값이 이 배열에서 제거되어
// 게이트가 빈 문자열이 된다. 그대로 두면 어느 분기에도 들어가지 않아 mailgun/ses 하위
// 저장값(도메인·자격증명 등, 각자 잠기지 않았다)이 조용히 주입되지 않는다.
// 잠긴 경우에는 유효값(= `.env` 유래 config)으로 게이트를 보정한다.
$mailer = EnvPriority::isLocked('mail.mailer')
? (string) config('mail.default')
: ($mailSettings['mailer'] ?? '');
if ($mailer === 'mailgun') {
if (! empty($mailSettings['mailgun_domain'])) {
@@ -118,9 +128,15 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($mailSettings['mailgun_secret'])) {
Config::set('services.mailgun.secret', $mailSettings['mailgun_secret']);
}
Config::set('services.mailgun.endpoint',
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
);
// 저장값이 비어도 기본값을 박는다 — 종전 동작이다. 주입을 건너뛰는 경우는
// `.env` 우선 모드가 이 키를 가져간 때뿐이므로 잠금 여부를 직접 묻는다.
// 키 존재 여부(`array_key_exists`)로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이
// 구분되지 않아, 전자에서도 기본값 주입이 사라진다.
if (! EnvPriority::isLocked('mail.mailgun_endpoint')) {
Config::set('services.mailgun.endpoint',
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
);
}
}
if ($mailer === 'ses') {
@@ -130,9 +146,12 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($mailSettings['ses_secret'])) {
Config::set('services.ses.secret', $mailSettings['ses_secret']);
}
Config::set('services.ses.region',
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
);
// mailgun_endpoint 와 같은 사유의 가드 (위 주석 참조).
if (! EnvPriority::isLocked('mail.ses_region')) {
Config::set('services.ses.region',
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
);
}
}
// 발신자 설정
@@ -156,6 +175,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$generalSettings = EnvPriority::filterLocked('general', $generalSettings);
if (! empty($generalSettings['site_name'])) {
// site_name 이 다국어 JSON array 일 수 있으므로 현재/폴백 로케일 string 으로 정규화한다
// (공개#49). raw array 를 config('app.name') 에 넣으면 app.blade.php 의
@@ -240,6 +262,10 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyDebugConfig(JsonConfigRepository $configRepository): void
{
// G7_PLAYWRIGHT_BYPASS 는 호출자가 그 프로세스에만 부여하는 프로세스 환경변수다 —
// `.env` 파일에 적히지 않으므로 config:cache 로 박제될 대상이 아니고, env() 가
// 프로세스 환경을 그대로 읽으므로 config:cache 환경에서도 정상 판별된다.
// (`.env` 유래 값의 런타임 env() 판별이 무력해지는 함정과는 다른 축이다.)
if (app()->environment('testing') || env('G7_PLAYWRIGHT_BYPASS') === '1') {
return;
}
@@ -250,14 +276,29 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
$isDebugMode = isset($debugSettings['mode']) && (bool) $debugSettings['mode'];
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$debugSettings = EnvPriority::filterLocked('debug', $debugSettings);
$modeLocked = EnvPriority::isLocked('debug.mode');
$logLevelLocked = EnvPriority::isLocked('debug.log_level');
// 디버그 모드가 `.env` 로 잠기면 그 유효값은 config('app.debug')(= APP_DEBUG)다.
// 저장값을 읽으면 잠금으로 키가 사라져 항상 false 가 되고, 아래 두 2차 효과
// (로그 레벨 강제·프록시 게이트)가 운영자 의도와 반대로 동작한다.
$isDebugMode = $modeLocked
? (bool) config('app.debug')
: (isset($debugSettings['mode']) && (bool) $debugSettings['mode']);
if (isset($debugSettings['mode'])) {
Config::set('app.debug', $isDebugMode);
}
// debug 모드가 true이면 log_level을 debug로 강제 설정
if ($isDebugMode) {
// debug 모드가 true이면 log_level을 debug로 강제 설정.
// 단 로그 레벨이 `.env` 로 잠긴 설치에서는 강제 자체를 하지 않는다 — 그 강제는
// 저장값 경로의 편의 규칙이고, 잠긴 키의 권위는 `.env` 에 있다.
if ($logLevelLocked) {
$logLevel = null;
} elseif ($isDebugMode) {
$logLevel = 'debug';
} elseif (! empty($debugSettings['log_level'])) {
$logLevel = $debugSettings['log_level'];
@@ -279,7 +320,10 @@ class SettingsServiceProvider extends ServiceProvider
// 아웃바운드 HTTP 프록시 설정.
// 적용 여부 판정은 OutboundProxy 가 단독으로 소유한다 — 디버그 모드가 꺼져 있으면
// 저장값이 남아 있어도 null 이 되어 주입되지 않는다.
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings));
// 게이트 값(mode)은 위에서 구한 유효값으로 보정해 넘긴다 — `+` 는 존재하는 키를
// 덮지 않으므로 잠기지 않은 설치에서는 종전과 동일하다. 보정이 없으면 디버그 모드가
// `.env` 로 잠긴 설치에서 프록시가 영구 미적용된다 (오류·로그 없이).
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings + ['mode' => $isDebugMode]));
}
/**
@@ -301,10 +345,17 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
// 아래 apply*Config 들은 이 배열을 인자로 받으므로 redis/reverb 처럼 한 설정이
// 여러 config 키를 파생시키는 경우도 한 번에 처리된다.
$driverSettings = EnvPriority::filterLocked('drivers', $driverSettings);
// testing 환경에서는 drivers.json의 cache/session/queue 오버라이드 차단 — 테스트 격리 보호
// (storage/app/settings/drivers.json은 shared 파일이므로 dev의 Redis/DB 드라이버가
// testing으로 흘러들어가면 dev 캐시를 오염시킴)
$isTestingEnv = env('APP_ENV') === 'testing';
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해지므로
// 해석된 환경(app()->environment)으로 판정한다 — applyDebugConfig 와 동일 규약.
$isTestingEnv = app()->environment('testing');
// 캐시 드라이버 설정
if (! $isTestingEnv && ! empty($driverSettings['cache_driver'])) {
@@ -346,7 +397,14 @@ class SettingsServiceProvider extends ServiceProvider
// 빈 문자열도 미명시로 취급한다 — `ATTACHMENT_DISK=` 가 복사된 .env 에서
// 빈 값을 명시로 읽으면 전환이 영구 미발동한다 (config 정규화의 2차 방어).
// 기존 행은 행 disk 로 서빙되므로 신구 디스크 혼재는 안전하다.
if (($driverSettings['storage_driver'] ?? null) === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
// storage_driver 가 `.env` 로 잠긴 설치에서는 저장값이 제거되어 있으므로 유효값으로
// 판정한다. 보정하지 않으면 `FILESYSTEM_DISK=s3` 를 잠근 운영자의 첨부 업로드만
// 로컬 디스크로 되돌아간다 (기존 행은 행 disk 로 서빙되므로 오류 없이 갈라진다).
$storageDriver = EnvPriority::isLocked('drivers.storage_driver')
? config('filesystems.default')
: ($driverSettings['storage_driver'] ?? null);
if ($storageDriver === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
Config::set('attachment.disk', 's3');
}
@@ -372,7 +430,8 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
if (env('APP_ENV') === 'testing') {
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해진다.
if (app()->environment('testing')) {
return;
}
@@ -389,6 +448,10 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function injectPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
// audit:allow env-priority-filter-wiring 이 지점이 drivers 에서 읽는 키는
// public_asset_disk 하나뿐이고 그 키는 EnvPriority::EXEMPT 다 (env 대응 없음).
// 여기서 다른 drivers 키를 추가로 읽게 되면 그 키는 잠금을 우회하므로,
// 그때는 이 면제를 걷어내고 filterLocked('drivers', …) 를 배선해야 한다.
$driverSettings = $configRepository->getCategory('drivers');
$disk = (string) ($driverSettings['public_asset_disk'] ?? '');
@@ -535,6 +598,14 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyWebsocketConfig(array $driverSettings): void
{
// 마스터 토글이 `.env` 로 잠긴 설치에서는 관리자 저장값이 이 배열에서 제거되어
// 있으므로 `empty()` 가 참이 된다 — 그대로 두면 OFF 강제 3종이 오발동해
// `.env`(BROADCAST_CONNECTION=reverb)로 웹소켓을 켠 운영자에게 강제 OFF 가 걸린다.
// 잠긴 경우에는 OFF 강제도 ON 주입도 하지 않고 config 기본값(= `.env` 유래)을 서빙한다.
if (EnvPriority::isLocked('drivers.websocket_enabled')) {
return;
}
if (empty($driverSettings['websocket_enabled'])) {
Config::set('broadcasting.default', 'null');
// 프론트(admin/app.blade.php)가 @if(broadcasting.connections.reverb.key)로 연결을
@@ -562,13 +633,16 @@ class SettingsServiceProvider extends ServiceProvider
$serverHost = $driverSettings['websocket_server_host'] ?? '';
$serverPort = (int) ($driverSettings['websocket_server_port'] ?? 0);
$serverScheme = $driverSettings['websocket_server_scheme'] ?? '';
if (empty($serverHost)) {
// 잠긴 server 키에는 폴백을 태우지 않는다 — 클라이언트 endpoint 는 env 대응이 없는
// 관리자 소유 값이라, 폴백을 그대로 두면 그 값이 `.env` 가 소유한 REVERB_HOST/PORT/SCHEME
// 자리에 덮여 잠금이 무력해진다. 잠긴 키는 빈 값으로 남아 아래 주입에서 건너뛰어진다.
if (empty($serverHost) && ! EnvPriority::isLocked('drivers.websocket_server_host')) {
$serverHost = $clientHost;
}
if ($serverPort <= 0) {
if ($serverPort <= 0 && ! EnvPriority::isLocked('drivers.websocket_server_port')) {
$serverPort = $clientPort;
}
if (empty($serverScheme)) {
if (empty($serverScheme) && ! EnvPriority::isLocked('drivers.websocket_server_scheme')) {
$serverScheme = $clientScheme;
}
$serverUseTLS = $serverScheme === 'https';
@@ -632,6 +706,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$settings = EnvPriority::filterLocked('core_update', $settings);
if (! empty($settings['github_url'])) {
Config::set('app.update.github_url', $settings['github_url']);
}
@@ -658,6 +735,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$settings = EnvPriority::filterLocked('geoip', $settings);
if (isset($settings['feature_enabled'])) {
Config::set('geoip.enabled', (bool) $settings['feature_enabled']);
}
@@ -691,6 +771,9 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyIdentityConfig(JsonConfigRepository $configRepository): void
{
// audit:allow env-priority-filter-wiring identity 카테고리는 전 키가 EnvPriority::EXEMPT 다
// (env 대응이 없어 잠글 대상이 하나도 없다) — filterLocked 를 걸어도 no-op 이다.
// 이 카테고리에 env 대응 키가 생기면 EnvPriorityContractTest 의 맵 패리티가 먼저 red 가 된다.
$settings = $configRepository->getCategory('identity');
if (empty($settings)) {
@@ -711,6 +794,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$uploadSettings = EnvPriority::filterLocked('upload', $uploadSettings);
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
if (! empty($uploadSettings['max_file_size'])) {
@@ -135,10 +135,17 @@ trait CalculatesJsonContentDiff
/**
* 라인 배열 두 개의 LCS diff — 추가/삭제 라인 "수"를 반환한다.
*
* 공통 prefix/suffix 를 먼저 트리밍해 LCS DP 입력을 변경 영역으로 축소한다(큰
* content 의 작은 변경도 빠르게 처리). 라인 원문은 누적하지 않고 카운트만 세므로
* 메모리도 절약된다(저장 대상은 카운트뿐). 프론트 computeLineDiff 와 동일 전략이라
* added/removed 카운트가 일치한다.
* 공통 prefix/suffix 를 먼저 트리밍해 LCS 입력을 변경 영역으로 축소한다(큰 content 의
* 작은 변경도 빠르게 처리). 카운트는 LCS **길이**만으로 결정된다 — 추가 = 새 줄 수 − LCS,
* 삭제 = 옛 줄 수 − LCS — 이므로 표 전체를 만들어 되짚을 필요가 없고, 두 행만 쓰는
* 길이 계산으로 메모리를 변경 영역 줄 수에 비례하게 묶는다. 프론트 computeLineDiff 는
* 실제 diff 줄을 그려야 해서 전체 표를 쓰지만 같은 LCS 규칙이므로 added/removed 카운트가
* 일치한다.
*
* 트리밍은 양끝이 동시에 바뀌면 무력하다 — 편집기는 저장 시 `comment` 키를 떼어내므로
* 큰 공통 레이아웃의 첫 편집기 저장은 변경 영역이 파일 전체(2,000줄 이상)가 된다.
* 종전의 (줄 수)² PHP 배열은 그 경우 약 150MB 를 써서 PHP 기본 memory_limit(128M)
* 서버에서 저장이 500 으로 끝났다(개발 머신은 512M 이라 드러나지 않았다).
*
* @param array<string> $a 이전 라인
* @param array<string> $b 새 라인
@@ -172,43 +179,50 @@ trait CalculatesJsonContentDiff
$nb = count($midB);
// 안전 가드 — 변경 영역이 과대하면 LCS 를 생략하고 라인 집합 차집합으로 근사한다.
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 인접 버전 비교는 변경
// 영역이 작아 이 경로를 타지 않으며, 비정상적으로 큰 변경에서만 O(n·m) DP 를 회피한다.
// 프론트 lineDiff.ts 의 DIFF_MAX_LINES(4000) 와 동일 임계. 시간 O(n·m) 의 상한이며,
// 아래 길이 계산은 메모리가 두 행뿐이라 이 임계 안에서는 memory_limit 과 무관하다.
if ($na > self::DIFF_MAX_LINES || $nb > self::DIFF_MAX_LINES) {
return $this->approximateLineCounts($midA, $midB);
}
// LCS DP
$dp = array_fill(0, $na + 1, array_fill(0, $nb + 1, 0));
$lcs = $this->lcsLength($midA, $midB);
return [$nb - $lcs, $na - $lcs];
}
/**
* 두 라인 배열의 LCS(최장 공통 부분수열) 길이 — 두 행만 쓰는 DP.
*
* 전체 표(종전 `$dp[$i][$j]`)와 같은 점화식(`a[i] === b[j] ? next[j+1] + 1 : max(next[j],
* cur[j+1])`)을 행 단위로 굴려 마지막 행의 첫 칸만 남긴다. 되짚기(backtrack)가 필요한
* 프론트 diff 뷰와 달리 여기서는 길이만 쓰므로 카운트가 종전과 정확히 같다.
*
* @param array<int, string> $a 이전 라인 (0 부터 연속 인덱스)
* @param array<int, string> $b 새 라인 (0 부터 연속 인덱스)
* @return int LCS 길이
*/
private function lcsLength(array $a, array $b): int
{
$na = count($a);
$nb = count($b);
if ($na === 0 || $nb === 0) {
return 0;
}
$next = array_fill(0, $nb + 1, 0);
for ($i = $na - 1; $i >= 0; $i--) {
$cur = array_fill(0, $nb + 1, 0);
$line = $a[$i];
for ($j = $nb - 1; $j >= 0; $j--) {
$dp[$i][$j] = $midA[$i] === $midB[$j]
? $dp[$i + 1][$j + 1] + 1
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
$cur[$j] = $line === $b[$j]
? $next[$j + 1] + 1
: max($next[$j], $cur[$j + 1]);
}
$next = $cur;
}
// backtrack — 삭제/추가 라인 수만 카운트
$added = 0;
$removed = 0;
$i = 0;
$j = 0;
while ($i < $na && $j < $nb) {
if ($midA[$i] === $midB[$j]) {
$i++;
$j++;
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
$removed++;
$i++;
} else {
$added++;
$j++;
}
}
$removed += $na - $i;
$added += $nb - $j;
return [$added, $removed];
return $next[0];
}
/**
@@ -89,7 +89,13 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
}
/**
* 미소비된 검증 토큰으로 로그를 조회합니다.
* 미소비·미만료 검증 토큰으로 로그를 조회합니다.
*
* 만료 술어(expires_at > now)는 이 지점이 단일 관문이다. 정책 미들웨어·정책 서비스·
* 회원가입/비밀번호재설정 리스너·IdvTokenRule 이 모두 이 메서드를 경유하므로
* 호출부마다 만료 검사를 중복해 두지 않는다(한쪽 누락 시 그 경로가 우회로가 된다).
* expires_at 이 비어 있는 로그도 반환하지 않는다 — 모든 provider 가 challenge 생성 시
* expires_at 을 세팅하므로 NULL 은 정상 발급 산물이 아니다.
*
* @param string $token 검증 토큰
* @param string $purpose 본인인증 목적
@@ -102,6 +108,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
->where('purpose', $purpose)
->where('status', IdentityVerificationStatus::Verified->value)
->whereNull('consumed_at')
->where('expires_at', '>', Carbon::now())
->first();
}
@@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
*/
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
{
$audit = [];
$locale = $pack->locale;
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
app()->instance('user_overrides.seeding', true);
try {
$audit = [];
$locale = $pack->locale;
if (! empty($seedBundle['permissions'])) {
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
}
if (! empty($seedBundle['roles'])) {
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
}
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
}
if (! empty($seedBundle['notifications'])) {
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
}
if (! empty($seedBundle['identity_messages'])) {
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
}
if (! empty($seedBundle['manifest'])) {
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
}
if (! empty($seedBundle['permissions'])) {
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
}
if (! empty($seedBundle['roles'])) {
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
}
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
}
if (! empty($seedBundle['notifications'])) {
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
}
if (! empty($seedBundle['identity_messages'])) {
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
}
if (! empty($seedBundle['manifest'])) {
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
}
return $audit;
return $audit;
} finally {
app()->forgetInstance('user_overrides.seeding');
}
}
/**
@@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
* @return array<int, array<string, mixed>> 감사 로그 항목
*/
public function stripLocaleFromPack(LanguagePack $pack): array
{
// applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단)
app()->instance('user_overrides.seeding', true);
try {
return $this->stripLocaleFromPackBody($pack);
} finally {
app()->forgetInstance('user_overrides.seeding');
}
}
/**
* stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행).
*
* @param LanguagePack $pack 비활성화된 언어팩
* @return array<int, array<string, mixed>> 감사 로그 항목
*/
private function stripLocaleFromPackBody(LanguagePack $pack): array
{
$audit = [];
$locale = $pack->locale;
@@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
}
$hasLocaleKey = array_key_exists($locale, $current);
$isOverridden = isset($overrides[$column]);
$isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale);
if ($hasLocaleKey && $isOverridden) {
$audit[] = [
@@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
'locale' => $locale,
'reason' => 'user_overrides',
];
continue;
}
@@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
continue;
}
if (isset($overrides[$column])) {
if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
$audit[] = [
'action' => 'preserved',
'table' => $row->getTable(),
@@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
'locale' => $locale,
'reason' => 'user_overrides',
];
continue;
}
@@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
$row->saveQuietly();
}
}
/**
* 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
*
* 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
* HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
* 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
* 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
* 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
*
* 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
* 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
* 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
*
* @param array<int, string> $overrides user_overrides 리스트
* @param string $column 다국어 JSON 컬럼명
* @param string $locale 대상 로케일
* @return bool 보존 대상 여부
*/
protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool
{
return in_array($column.'.'.$locale, $overrides, true);
}
}
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutExtensionVersion;
use App\Repositories\Concerns\CalculatesJsonContentDiff;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class LayoutExtensionVersionRepository implements LayoutExtensionVersionRepositoryInterface
@@ -28,9 +29,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
* @param int $extensionId 레이아웃 확장 ID
* @param array $content 저장할 content 스냅샷
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutExtensionVersion 생성된 버전
*/
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null): TemplateLayoutExtensionVersion
public function saveVersion(int $extensionId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutExtensionVersion
{
$nextVersion = $this->getNextVersion($extensionId);
@@ -44,6 +46,8 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
'version' => $nextVersion,
'content' => $content,
'changes_summary' => $changesSummary,
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
'created_by' => $createdBy ?? Auth::id(),
]);
}
@@ -165,9 +169,10 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito
$extension = LayoutExtension::findOrFail($extensionId);
$currentContent = $extension->content;
// 3. 확장을 복원할 content로 업데이트
// 3. 확장을 복원할 content로 업데이트 — lock_version 도 올린다(레이아웃 본체와 동형).
$extension->update([
'content' => $versionToRestore->content,
'lock_version' => ((int) ($extension->lock_version ?? 0)) + 1,
]);
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용, changes_summary 는 복원
+8 -2
View File
@@ -8,6 +8,7 @@ use App\Models\TemplateLayoutVersion;
use App\Repositories\Concerns\CalculatesJsonContentDiff;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class LayoutVersionRepository implements LayoutVersionRepositoryInterface
@@ -28,9 +29,10 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
* @param int $layoutId 레이아웃 ID
* @param array $content 저장할 content 스냅샷 (이 버전이 담는 내용)
* @param array|null $previousContent 직전 버전 content (변경 요약 기준). null 이면 변경 요약 0
* @param int|null $createdBy 저장자 ID (null 이면 현재 인증 사용자)
* @return TemplateLayoutVersion 생성된 버전
*/
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null): TemplateLayoutVersion
public function saveVersion(int $layoutId, array $content, ?array $previousContent = null, ?int $createdBy = null): TemplateLayoutVersion
{
$nextVersion = $this->getNextVersion($layoutId);
@@ -44,6 +46,8 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
'version' => $nextVersion,
'content' => $content,
'changes_summary' => $changesSummary,
// 저장자 — 버전 목록의 「저장자」 표시 근거. 종전엔 기록하지 않아 항상 「알 수 없음」이었다.
'created_by' => $createdBy ?? Auth::id(),
]);
}
@@ -109,9 +113,11 @@ class LayoutVersionRepository implements LayoutVersionRepositoryInterface
$layout = TemplateLayout::findOrFail($layoutId);
$currentContent = $layout->content;
// 3. 레이아웃을 복원할 content로 업데이트
// 3. 레이아웃을 복원할 content로 업데이트 — lock_version 도 올린다. 복원 직전 화면을 열어 둔
// 다른 편집기가 옛 lock 으로 저장하면 409 가 나야 복원 결과가 조용히 덮이지 않는다.
$layout->update([
'content' => $versionToRestore->content,
'lock_version' => ((int) ($layout->lock_version ?? 0)) + 1,
]);
// 4. 복원 결과를 새 버전으로 저장 — content 는 복원된 내용(versionToRestore),
+14
View File
@@ -27,6 +27,20 @@ class AllowedModuleFileType implements ValidationRule
'woff', 'woff2', 'ttf', 'otf', 'eot',
];
/**
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
*
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
*
* @return array<string> 기본 허용 확장자 목록
*/
public static function allowedExtensions(): array
{
return self::ALLOWED_EXTENSIONS;
}
/**
* 허용된 파일 타입인지 검증
*/
+14
View File
@@ -27,6 +27,20 @@ class AllowedPluginFileType implements ValidationRule
'woff', 'woff2', 'ttf', 'otf', 'eot',
];
/**
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
*
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
*
* @return array<string> 기본 허용 확장자 목록
*/
public static function allowedExtensions(): array
{
return self::ALLOWED_EXTENSIONS;
}
/**
* 허용된 파일 타입인지 검증
*/
+14
View File
@@ -27,6 +27,20 @@ class AllowedTemplateFileType implements ValidationRule
'woff', 'woff2', 'ttf', 'otf', 'eot',
];
/**
* 환경과 무관한 기본 허용 확장자 목록을 반환합니다.
*
* `getAllowedExtensions()` 는 로컬에서 소스맵(`map`)을 덧붙이는 **환경 의존** 게터라
* 라우트 패턴처럼 정의 시점에 한 번 굳어 캐시에 박히는 소비자가 쓰면 안 된다
* (캐시를 구운 환경에 따라 패턴이 달라진다). 그런 소비자는 이 게터를 쓴다.
*
* @return array<string> 기본 허용 확장자 목록
*/
public static function allowedExtensions(): array
{
return self::ALLOWED_EXTENSIONS;
}
/**
* 허용된 파일 타입인지 검증
*/
+121 -14
View File
@@ -2,23 +2,41 @@
namespace App\Rules;
use App\Support\SiteAssetHosts;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
*
* 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:,
* javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
* 컴포넌트 props·actions·lifecycle·onComponentEvent·slots·component_layout·responsive 와
* 최상위 init_actions/initActions·modals·named_actions·errorHandling 내의 http://, https://,
* data:, javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
*
* 순회 대상은 "액션이 실행되거나 값이 sink(컴포넌트 prop)로 흘러 들어가는 자리" 다.
* 한 자리만 빠져도 그 키가 그대로 저장 우회로가 되며, 우회는 오류를 남기지 않는다.
*
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
* 차단합니다. 반면 state/computed 는 데이터 값이며, 실제 위험은 그 값이 바인딩되는
* sink(컴포넌트 prop = img src 등)에서 발생하고 그 sink 는 이미 여기서 검사됩니다 —
* 예시/안내용 URL 을 담는 정당한 용례를 깨지 않기 위해 데이터 계층은 재차단하지 않습니다.
*
* 외부의 기준: 사이트 자기 host(`app.url`)와 운영자가 선언한 공개 자산 디스크의 host
* (`SiteAssetHosts`)는 외부가 아닙니다. 레이아웃 첨부 API 가 스스로 발급하는 주소(프록시
* 서빙 URL·직접 URL)가 그 host 를 쓰므로, 여기서 차단하면 image 위젯으로 올린 파일이
* 업로드는 되고 저장은 422 가 됩니다. 판정은 정규화 뒤 host 등가 비교이며 protocol-relative
* 와 http/https 밖의 스킴은 host 가 같아도 종전대로 차단합니다.
*/
class NoExternalUrls implements ValidationRule
{
/**
* 사이트 자산 host 목록 (검증 1회당 1회 해석 후 캐시)
*
* @var array<int, string>|null
*/
private ?array $siteAssetHosts = null;
/**
* 차단할 위험 URI 스킴 목록
*/
@@ -55,55 +73,128 @@ class NoExternalUrls implements ValidationRule
$this->validateComponents($value['components'], $fail);
}
// init_actions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 로드 시점
// 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 검사한다.
if (isset($value['init_actions']) && is_array($value['init_actions'])) {
foreach ($value['init_actions'] as $i => $action) {
if (is_array($action)) {
$this->validateObject($action, "init_actions[$i]", $fail);
// init_actions / initActions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은
// 로드 시점 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게
// 검사한다. 엔진(LayoutLoader)이 두 철자를 모두 소비하므로 두 철자 모두 검사한다 —
// 한쪽만 보면 다른 철자가 그대로 우회로가 된다.
foreach (['init_actions', 'initActions'] as $initKey) {
if (isset($value[$initKey]) && is_array($value[$initKey])) {
foreach ($value[$initKey] as $i => $action) {
if (is_array($action)) {
$this->validateObject($action, "{$initKey}[$i]", $fail);
}
}
}
}
// modals: 각 항목이 컴포넌트 정의다 — 모달 안의 props/actions 도 같은 sink 이므로
// 컴포넌트와 동일하게 재귀 검사한다.
if (isset($value['modals']) && is_array($value['modals'])) {
foreach ($value['modals'] as $modalKey => $modal) {
if (! is_array($modal)) {
continue;
}
$this->validateComponents([$modal], $fail, "modals.$modalKey");
if (isset($modal['components']) && is_array($modal['components'])) {
$this->validateComponents($modal['components'], $fail, "modals.$modalKey.components");
}
}
}
// named_actions: 이름으로 호출되는 액션 정의 — 실행 시 컴포넌트 actions 와 동일한 sink.
if (isset($value['named_actions']) && is_array($value['named_actions'])) {
foreach ($value['named_actions'] as $name => $named) {
if (is_array($named)) {
$this->validateObject($named, "named_actions.$name", $fail);
}
}
}
// errorHandling: 오류 시 실행되는 액션(navigate/apiCall)을 담는다.
if (isset($value['errorHandling']) && is_array($value['errorHandling'])) {
$this->validateObject($value['errorHandling'], 'errorHandling', $fail);
}
}
/**
* components 배열을 재귀적으로 검증
*
* @param array $components 컴포넌트 정의 배열
* @param Closure $fail 실패 콜백
* @param string $basePath 오류 메시지에 실을 경로 접두 (modals/slots 경로 보존)
*/
private function validateComponents(array $components, Closure $fail): void
private function validateComponents(array $components, Closure $fail, string $basePath = 'components'): void
{
foreach ($components as $index => $component) {
if (! is_array($component)) {
continue;
}
$path = "{$basePath}[$index]";
// props 검사
if (isset($component['props']) && is_array($component['props'])) {
$this->validateObject($component['props'], "components[$index].props", $fail);
$this->validateObject($component['props'], "$path.props", $fail);
}
// actions 검사
if (isset($component['actions']) && is_array($component['actions'])) {
$this->validateActions($component['actions'], $index, $fail);
$this->validateActions($component['actions'], $path, $fail);
}
// lifecycle: 마운트/언마운트 시 자동 실행되는 액션 — init_actions 와 같은 성격이다.
if (isset($component['lifecycle']) && is_array($component['lifecycle'])) {
$this->validateObject($component['lifecycle'], "$path.lifecycle", $fail);
}
// onComponentEvent: 컴포넌트 이벤트로 발화되는 액션 배열.
if (isset($component['onComponentEvent']) && is_array($component['onComponentEvent'])) {
$this->validateObject($component['onComponentEvent'], "$path.onComponentEvent", $fail);
}
// slots: 슬롯 이름별 컴포넌트 배열 — 슬롯 안의 컴포넌트도 같은 sink 다.
if (isset($component['slots']) && is_array($component['slots'])) {
foreach ($component['slots'] as $slotName => $slotComponents) {
if (is_array($slotComponents)) {
$this->validateComponents($slotComponents, $fail, "$path.slots.$slotName");
}
}
}
// component_layout: 컴포넌트가 품는 하위 레이아웃 정의.
if (isset($component['component_layout']) && is_array($component['component_layout'])) {
$this->validateObject($component['component_layout'], "$path.component_layout", $fail);
}
// responsive: breakpoint 별 props/children 오버라이드 — 그 안의 값도 같은 sink 다.
if (isset($component['responsive']) && is_array($component['responsive'])) {
$this->validateObject($component['responsive'], "$path.responsive", $fail);
}
// children 재귀 검사
if (isset($component['children']) && is_array($component['children'])) {
$this->validateComponents($component['children'], $fail);
$this->validateComponents($component['children'], $fail, "$path.children");
}
}
}
/**
* actions 배열 검증
*
* @param array $actions 액션 정의 배열
* @param string $componentPath 컴포넌트 경로 (오류 메시지용)
* @param Closure $fail 실패 콜백
*/
private function validateActions(array $actions, int $componentIndex, Closure $fail): void
private function validateActions(array $actions, string $componentPath, Closure $fail): void
{
foreach ($actions as $actionIndex => $action) {
if (! is_array($action)) {
continue;
}
$this->validateObject($action, "components[$componentIndex].actions[$actionIndex]", $fail);
$this->validateObject($action, "{$componentPath}.actions[$actionIndex]", $fail);
}
}
@@ -126,6 +217,12 @@ class NoExternalUrls implements ValidationRule
*/
private function checkForDangerousUrl(string $value, string $path, Closure $fail): void
{
// 사이트 자기 host·선언된 공개 자산 디스크 host 의 http(s) 절대 URL 은 외부가 아니다.
// 위험 스킴·protocol-relative 는 이 판정의 대상이 아니므로 아래 검사가 그대로 적용된다.
if (SiteAssetHosts::isSiteAssetUrl($value, $this->siteAssetHosts())) {
return;
}
$lowerValue = strtolower(trim($value));
foreach (self::DANGEROUS_SCHEMES as $scheme) {
@@ -148,6 +245,16 @@ class NoExternalUrls implements ValidationRule
}
}
/**
* 사이트 자산 host 목록을 반환합니다 (검증 1회당 1회 해석 후 캐시).
*
* @return array<int, string> 소문자 host 목록
*/
private function siteAssetHosts(): array
{
return $this->siteAssetHosts ??= SiteAssetHosts::hosts();
}
/**
* 스킴별 에러 메시지 출력
*/
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 사용자 추가 에셋(`custom/`) 상대 경로 검증 규칙
*
* 판정은 **세그먼트 단위**로 한다. 문자열 포함 검사(`str_contains('..')`)는 정상 파일명
* (`v1..2.css`)을 막으면서 정작 인코딩된 탈출은 놓치고, 접두 비교(`str_starts_with`)는
* 아직 없는 파일에서 `realpath` 가 실패해 형제 디렉토리(`custom-evil/`)를 통과시킨다.
*
* 서비스 계층에도 같은 판정이 있다. 중복이 아니라 이중화다 — 검증은 사용자에게 422 로
* 사유를 알리는 자리이고, 서비스의 판정은 다른 호출부(콘솔·훅)까지 덮는 최종 방어선이다.
*/
class SafeCustomAssetPath implements ValidationRule
{
/**
* @param array<int, string>|null $allowedExtensions 허용 확장자 (null 이면 확장자 검사 생략)
*/
public function __construct(private readonly ?array $allowedExtensions = null) {}
/**
* 경로가 안전한지 검증합니다.
*
* @param string $attribute 속성명
* @param mixed $value 값
* @param Closure $fail 실패 콜백
* @return void
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('custom_assets.errors.invalid_path', ['path' => '']));
return;
}
$normalized = str_replace('\\', '/', trim($value));
if ($normalized === '' || str_starts_with($normalized, '/')) {
$fail(__('custom_assets.errors.invalid_path', ['path' => $value]));
return;
}
foreach (explode('/', $normalized) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
$fail(__('custom_assets.errors.invalid_path', ['path' => $value]));
return;
}
}
if ($this->allowedExtensions === null) {
return;
}
$extension = strtolower(pathinfo($normalized, PATHINFO_EXTENSION));
if (! in_array($extension, $this->allowedExtensions, true)) {
$fail(__('custom_assets.errors.extension_not_allowed', ['extension' => $extension]));
}
}
}
+3 -1
View File
@@ -220,7 +220,9 @@ class SafeLayoutExpressions implements ValidationRule
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
* 않으므로 그대로 통과합니다(과차단 없음).
*
* 클라이언트(`TemplateApp.isAllowedScriptSrc`)·정적 검사
* 클라이언트(`resources/js/core/support/scriptSrcPolicy.ts::isAllowedScriptSrc` — 레이아웃
* `scripts[]` 뿐 아니라 loadScript 액션·확장 핸들러 재로드·편집기 프리뷰·
* `G7Core.asset.loadScript` 가 공유하는 런타임 SSoT)·정적 검사
* (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다.
*
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측

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