diff --git a/.env.example b/.env.example
index 9365347d..2057cd34 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.5
+APP_VERSION=7.0.0-beta.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 3ada5e9b..509844ba 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.5
+APP_VERSION=7.0.0-beta.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/AGENTS.md b/AGENTS.md
index ded1df82..0583722f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -380,8 +380,19 @@ Keep a Changelog 표준:
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
-| Breaking Change | 이슈 번호 |
+| Breaking Change | 내부 작업 이슈 번호 단독 (예: `refs #347`) |
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
+| **공개 제보자 attribution** (`(#N @login 님께서 제보해주셨습니다.)`) | — |
+| **KISA 등 공식 보안 채널** (`(KISA 측에서 제보해주셨습니다 — KVE-XXXX-XXXXX)`) | — |
+
+### 공개 제보자 attribution
+
+공개 저장소(GitHub) 이슈로 제보·건의된 항목이 출시 CHANGELOG 에 반영되면, 항목 끝에 공개 이슈 번호와 제보자 GitHub 핸들 멘션을 부착합니다.
+
+- 형식: `- (본문) (#N @login 님께서 제보해주셨습니다.)` 또는 `... 건의해주셨습니다.`
+- 톤: 버그 리포트는 "제보", 제안형 개선 요청은 "건의"
+- 다중 매칭: `(#A @x, #B @y 님께서 제보해주셨습니다.)` / 혼재: `(#A @x 님께서 제보해주시고, #B @y 님께서 건의해주셨습니다.)`
+- KISA 등 공식 보안 채널: GitHub 멘션 없이 텍스트 "KISA 측에서" + 공개 가능한 식별자만
### 신규 기능의 버그 수정 제외 규칙
@@ -787,6 +798,7 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
+php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내/수동 복구용 — 사전·사후 단계 자동 수행)
# 모듈
php artisan module:list
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96b79495..50e0e3a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,11 +4,31 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.0-beta.6] - 2026-05-14
+
+### Fixed
+
+- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
+- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.
+- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.
+- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.
+- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.
+- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.
+- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\Program Files\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)
+- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.
+- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 "PHP 절대경로 + Composer 절대경로" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.
+- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 "설치하기" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.
+
+### Added
+
+- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.
+- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 "정의 추가" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.
+
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
-beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다.
+beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
@@ -73,8 +93,8 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
-- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단
-- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드)
+- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)
+- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
@@ -89,7 +109,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
-- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함)
+- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
@@ -111,7 +131,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### Generator 메타 태그
-- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능
+- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
@@ -162,7 +182,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
-- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리
+- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
@@ -265,10 +285,10 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Changed
-- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용
+- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
-- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공
+- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
@@ -277,14 +297,14 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Security
-- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단
-- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화
-- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬
-- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음
-- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단
-- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단
-- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증
-- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거
+- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
+- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
### Fixed
@@ -314,14 +334,16 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
-- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2)
+- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
+- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)
+- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)
## [7.0.0-beta.3] - 2026-04-23
### Fixed
-- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선
+- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
@@ -361,17 +383,17 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### GeoIP 및 타임존
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
-- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
+- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
#### 인스톨러 개선
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
-- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
+- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
-- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
-- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
+- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)
+- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
@@ -384,7 +406,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### 어드민 UI
-- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
+- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
- DataGrid 컴포넌트에 `id` prop 추가
@@ -480,6 +502,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
+- 관리자 모듈/플러그인 목록의 "작성자" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)
### Removed
diff --git a/INSTALL.md b/INSTALL.md
index 69a9c9d8..b67b5651 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.0-beta.5 g7
+# (필요 시) mv g7-7.0.0-beta.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.md b/README.md
index ce6b3fc9..6ff3f43e 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php
index b935c416..78d49a44 100644
--- a/app/Console/Commands/Core/CoreUpdateCommand.php
+++ b/app/Console/Commands/Core/CoreUpdateCommand.php
@@ -331,6 +331,44 @@ class CoreUpdateCommand extends Command
}
}
+ // ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
+ //
+ // applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
+ // _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
+ // `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
+ // `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
+ // 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
+ // 있으므로 manifest 에서 제외되어 보존된다.
+ //
+ // `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
+ // 자체가 불가능한 모드이므로 기존 동작 유지.
+ if ($backupPath !== null) {
+ $bar->setMessage('신규 파일 manifest 생성 중...');
+ $log('신규 파일 manifest 생성 시작');
+ try {
+ $manifestStats = CoreBackupHelper::writeNewFilesManifest(
+ $backupPath,
+ $pendingPath,
+ (array) config('app.update.targets', []),
+ (array) config('app.update.protected_paths', []),
+ (array) config('app.update.excludes', []),
+ $fromVersion,
+ $toVersion,
+ );
+ $log(sprintf(
+ '신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
+ $manifestStats['new_files_count'],
+ $manifestStats['new_dirs_count'],
+ ));
+ } catch (\Throwable $manifestError) {
+ // manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
+ $log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
+ Log::warning('코어 업데이트: manifest 작성 실패', [
+ 'error' => $manifestError->getMessage(),
+ ]);
+ }
+ }
+
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
@@ -581,6 +619,17 @@ class CoreUpdateCommand extends Command
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
+
+ // 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
+ // 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
+ // 캐시 정리 실패는 fatal 아님 — warning 후 진행.
+ try {
+ $service->clearAllCaches();
+ $log('롤백 후 캐시 정리 완료');
+ } catch (\Throwable $cacheError) {
+ $log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
+ $this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
+ }
}
// _pending 정리 (실패 시에도)
@@ -682,6 +731,17 @@ class CoreUpdateCommand extends Command
if ($force) {
$command[] = '--force';
}
+ // 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
+ // Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
+ // prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
+ // (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
+ // gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
+ // 가 단일 명령으로 통합되어 단독 안전성 보장.
+ $command[] = '--skip-migrations';
+ $command[] = '--skip-resync';
+ $command[] = '--skip-version-env';
+ $command[] = '--skip-cache-clear';
+ $command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
index 2a61896b..f6905b19 100644
--- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
+++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
@@ -2,7 +2,12 @@
namespace App\Console\Commands\Core;
+use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
+use App\Extension\ModuleManager;
+use App\Extension\PluginManager;
+use App\Extension\TemplateManager;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -18,15 +23,28 @@ use Illuminate\Support\Facades\Log;
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
+ *
+ * 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
+ * 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
+ * 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
+ * CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
*/
class ExecuteUpgradeStepsCommand extends Command
{
+ use BundledExtensionUpdatePrompt;
+ use HasUnifiedConfirm;
+
protected $signature = 'core:execute-upgrade-steps
{--from= : 시작 버전}
{--to= : 대상 버전}
- {--force : 동일 버전 강제 실행}';
+ {--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
+ {--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
+ {--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
+ {--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
+ {--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
+ {--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
- protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
+ protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행합니다.';
/**
* 커맨드를 실행합니다.
@@ -91,6 +109,26 @@ class ExecuteUpgradeStepsCommand extends Command
]);
}
+ // 사전 단계 (단독 실행 안전성 보장).
+ //
+ // CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
+ // 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
+ // 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
+ // migration / permission / menu / seeder 누락 차단.
+ if (! $this->option('skip-migrations')) {
+ $this->info('마이그레이션 실행');
+ $service->runMigrations();
+ } else {
+ $this->info('[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
+ }
+
+ if (! $this->option('skip-resync')) {
+ $this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
+ $service->reloadCoreConfigAndResync();
+ } else {
+ $this->info('[spawn] resync 스킵 — 부모가 이미 실행');
+ }
+
$stepsExecuted = 0;
try {
@@ -149,6 +187,39 @@ class ExecuteUpgradeStepsCommand extends Command
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
+ // 사후 단계 (단독 실행 안전성 보장).
+ //
+ // CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
+ // 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
+ // --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
+ // 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
+ // (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
+ if (! $this->option('skip-version-env')) {
+ $this->info(".env APP_VERSION={$to} 갱신");
+ $service->updateVersionInEnv($to);
+ } else {
+ $this->info('[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
+ }
+
+ if (! $this->option('skip-cache-clear')) {
+ $this->info('캐시 정리 (config/route/view/services/packages)');
+ $service->clearAllCaches();
+ } else {
+ $this->info('[spawn] 캐시 정리 스킵 — 부모가 처리');
+ }
+
+ if (! $this->option('skip-bundled-updates')) {
+ $this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
+ $this->runBundledExtensionUpdatePrompt(
+ app(ModuleManager::class),
+ app(PluginManager::class),
+ app(TemplateManager::class),
+ $force,
+ );
+ } else {
+ $this->info('[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
+ }
+
return self::SUCCESS;
}
}
diff --git a/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php b/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php
new file mode 100644
index 00000000..412451b7
--- /dev/null
+++ b/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php
@@ -0,0 +1,246 @@
+.log` 기록
+ *
+ * @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
+ * 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
+ */
+class RollbackStaleFilesCommand extends Command
+{
+ use HasUnifiedConfirm;
+
+ protected $signature = 'hotfix:rollback-stale-files
+ {--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
+ {--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
+
+ protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
+
+ public function handle(): int
+ {
+ $backupPath = $this->resolveBackupPath();
+ if ($backupPath === null) {
+ $this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
+
+ return Command::SUCCESS;
+ }
+
+ $this->info("백업 디렉토리: {$backupPath}");
+
+ $manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
+ $manifestAvailable = File::exists($manifestPath);
+
+ if (! $manifestAvailable) {
+ $this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
+ $this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
+ $this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
+ $this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
+ $this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
+ $this->newLine();
+ }
+
+ $candidates = $this->collectCandidates($backupPath, $manifestAvailable);
+
+ if ($candidates['files'] === [] && $candidates['dirs'] === []) {
+ $this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->newLine();
+ $this->info(sprintf(
+ '잔존 후보: 파일 %d개, 디렉토리 %d개',
+ count($candidates['files']),
+ count($candidates['dirs']),
+ ));
+ foreach (array_slice($candidates['files'], 0, 20) as $f) {
+ $this->line(" · 파일: {$f}");
+ }
+ if (count($candidates['files']) > 20) {
+ $this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
+ }
+ foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
+ $this->line(" · 디렉토리: {$d}");
+ }
+ if (count($candidates['dirs']) > 10) {
+ $this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
+ }
+
+ if (! $this->option('prune')) {
+ $this->newLine();
+ $this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
+
+ return Command::SUCCESS;
+ }
+
+ if (! $manifestAvailable) {
+ $this->newLine();
+ $this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
+ $this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
+
+ return Command::FAILURE;
+ }
+
+ $this->newLine();
+ if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
+ $this->info('취소되었습니다.');
+
+ return Command::SUCCESS;
+ }
+
+ $protectedPaths = (array) config('app.update.protected_paths', []);
+ $result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
+
+ $this->newLine();
+ $this->info(sprintf(
+ '정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
+ $result['removed_files'],
+ $result['removed_dirs'],
+ $result['protected_count'],
+ $result['symlink_skipped'],
+ $result['failed_count'],
+ ));
+
+ $logPath = storage_path(sprintf(
+ 'logs/hotfix_rollback_stale_files_%s.log',
+ date('Ymd_His'),
+ ));
+ @file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
+ $this->line("로그: {$logPath}");
+
+ Log::info('hotfix:rollback-stale-files 정리 완료', [
+ 'backup_path' => $backupPath,
+ 'result' => $result,
+ ]);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
+ */
+ private function resolveBackupPath(): ?string
+ {
+ $explicit = $this->option('backup');
+ if ($explicit !== null && $explicit !== '') {
+ $real = realpath($explicit);
+ if ($real === false || ! is_dir($real)) {
+ $this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
+
+ return null;
+ }
+
+ return $real;
+ }
+
+ $backups = CoreBackupHelper::listBackups();
+ if ($backups === []) {
+ return null;
+ }
+ // listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
+ usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
+
+ return $backups[0]['path'] ?? null;
+ }
+
+ /**
+ * 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
+ *
+ * @return array{files:array, dirs:array}
+ */
+ private function collectCandidates(string $backupPath, bool $manifestAvailable): array
+ {
+ if (! $manifestAvailable) {
+ return ['files' => [], 'dirs' => []];
+ }
+
+ $manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
+ $raw = @file_get_contents($manifestPath);
+ if ($raw === false) {
+ return ['files' => [], 'dirs' => []];
+ }
+
+ $manifest = json_decode($raw, true);
+ if (! is_array($manifest)) {
+ return ['files' => [], 'dirs' => []];
+ }
+
+ $files = [];
+ foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
+ if (! is_string($rel) || $rel === '') {
+ continue;
+ }
+ $absolute = base_path($rel);
+ if (file_exists($absolute) || is_link($absolute)) {
+ $files[] = $rel;
+ }
+ }
+
+ $dirs = [];
+ foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
+ if (! is_string($rel) || $rel === '') {
+ continue;
+ }
+ $absolute = base_path($rel);
+ if (is_dir($absolute) && ! is_link($absolute)) {
+ $dirs[] = $rel;
+ }
+ }
+
+ return ['files' => $files, 'dirs' => $dirs];
+ }
+
+ /**
+ * 진단/정리 결과를 로그 텍스트로 빌드.
+ */
+ private function buildLogEntry(string $backupPath, array $result, array $candidates): string
+ {
+ $lines = [
+ '=== hotfix:rollback-stale-files 실행 로그 ===',
+ '날짜: '.date('Y-m-d H:i:s'),
+ "백업 디렉토리: {$backupPath}",
+ '',
+ '결과:',
+ sprintf(' 파일 제거: %d', $result['removed_files']),
+ sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
+ sprintf(' 보호 경로 skip: %d', $result['protected_count']),
+ sprintf(' symlink skip: %d', $result['symlink_skipped']),
+ sprintf(' 실패: %d', $result['failed_count']),
+ '',
+ '후보 목록 (실행 직전 스냅샷):',
+ ];
+ foreach ($candidates['files'] as $f) {
+ $lines[] = " · 파일: {$f}";
+ }
+ foreach ($candidates['dirs'] as $d) {
+ $lines[] = " · 디렉토리: {$d}";
+ }
+
+ return implode("\n", $lines)."\n";
+ }
+}
diff --git a/app/Extension/Helpers/CoreBackupHelper.php b/app/Extension/Helpers/CoreBackupHelper.php
index d6f4cbdc..7998bf35 100644
--- a/app/Extension/Helpers/CoreBackupHelper.php
+++ b/app/Extension/Helpers/CoreBackupHelper.php
@@ -2,11 +2,26 @@
namespace App\Extension\Helpers;
+use FilesystemIterator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
class CoreBackupHelper
{
+ /**
+ * 백업 디렉토리에 기록되는 manifest 파일명.
+ */
+ public const NEW_FILES_MANIFEST = '_new_files_manifest.json';
+
+ /**
+ * Manifest 스키마 버전. DataMigration (Upgrade_7_0_0_beta_6) 의 사후 작성본과
+ * 바이트 단위 호환 invariant — 스키마 변경 시 양쪽 동시 갱신 필수.
+ */
+ public const MANIFEST_SCHEMA_VERSION = 1;
+
/**
* 코어 파일을 선택적으로 백업합니다.
*
@@ -51,6 +66,10 @@ class CoreBackupHelper
* 백업 파일을 복원하되 현재 파일의 퍼미션은 유지합니다.
* 개별 target 복원 실패 시에도 나머지 target 복원을 계속 진행합니다.
*
+ * 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 복사 직후 신 버전 신규 파일을
+ * 정리하는 prune 단계를 추가 실행한다. manifest 부재 시 기존 overlay 복사만 수행
+ * (기존 동작 유지).
+ *
* @param string $backupPath 백업 디렉토리 경로
* @param array $targets 복원 대상 경로 목록
* @param \Closure|null $onProgress 진행 콜백
@@ -86,6 +105,28 @@ class CoreBackupHelper
}
}
+ // Manifest 가 있으면 신 버전 신규 파일 prune. 부재 시 silent skip (기존 동작).
+ $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
+ if (File::exists($manifestPath)) {
+ try {
+ $protected = (array) config('app.update.protected_paths', []);
+ $pruneResult = self::pruneNewFiles($backupPath, base_path(), $protected, $onProgress);
+ Log::info('코어 자동 롤백 prune 완료', [
+ 'backup_path' => $backupPath,
+ 'removed_files' => $pruneResult['removed_files'],
+ 'removed_dirs' => $pruneResult['removed_dirs'],
+ 'protected_count' => $pruneResult['protected_count'],
+ 'symlink_skipped' => $pruneResult['symlink_skipped'],
+ 'failed_count' => $pruneResult['failed_count'],
+ ]);
+ } catch (\Throwable $e) {
+ Log::warning('코어 자동 롤백 prune 실패 (overlay 복사는 완료)', [
+ 'backup_path' => $backupPath,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
if (empty($failedTargets)) {
Log::info('코어 백업 복원 완료', ['backup_path' => $backupPath]);
} else {
@@ -148,4 +189,371 @@ class CoreBackupHelper
return $backups;
}
+
+ /**
+ * 신 버전이 추가하는 파일/디렉토리 목록을 백업 디렉토리에 manifest 로 기록합니다.
+ *
+ * 비교 기준:
+ * - 백업 디렉토리 (`$backupPath/$target`) = 활성 디렉토리의 사전 스냅샷 (= 현재 디스크의 구버전)
+ * - 소스 디렉토리 (`$sourcePath/$target`) = 신 버전 _pending 소스
+ * - 신규 항목 정의 = `_pending` 에는 존재 + 백업에는 없는 파일/디렉토리
+ *
+ * 자동 롤백 시 `restoreFromBackup()` 이 본 manifest 를 참조하여 활성 디렉토리에서
+ * 정확히 그 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
+ * 있으므로 manifest 에서 제외되어 보존된다.
+ *
+ * `protectedPaths` 와 `excludes` 하위는 manifest 에서 사전 제외 (방어 깊이). `pruneNewFiles`
+ * 도 동일 가드를 재실행하므로 이중 방어.
+ *
+ * @param string $backupPath 백업 디렉토리 경로 (= 활성 사전 스냅샷)
+ * @param string $sourcePath 소스 디렉토리 경로 (= _pending 신 버전)
+ * @param array $targets 처리할 target 경로 목록 (`app.update.targets`)
+ * @param array $protectedPaths 보호 경로 목록 (manifest 에서 제외)
+ * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git'])
+ * @param string $fromVersion 시작 버전 (manifest 기록용)
+ * @param string $toVersion 대상 버전 (manifest 기록용)
+ * @return array{new_files_count:int, new_dirs_count:int}
+ */
+ public static function writeNewFilesManifest(
+ string $backupPath,
+ string $sourcePath,
+ array $targets,
+ array $protectedPaths,
+ array $excludes,
+ string $fromVersion,
+ string $toVersion,
+ ): array {
+ $newFiles = [];
+ $newDirs = [];
+
+ $protectedSet = self::normalizeProtectedSet($protectedPaths);
+ $excludeSet = array_values(array_filter(array_map('trim', $excludes)));
+
+ foreach ($targets as $target) {
+ $target = trim($target);
+ if ($target === '') {
+ continue;
+ }
+
+ // target 자체가 보호 경로면 스킵
+ if (self::isWithinProtectedPath($target, $protectedSet)) {
+ continue;
+ }
+
+ $sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target;
+ if (! file_exists($sourceTargetPath)) {
+ continue;
+ }
+
+ // 단일 파일 target
+ if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) {
+ $backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target;
+ if (! file_exists($backupFilePath)) {
+ $newFiles[] = self::normalizeRelative($target);
+ }
+
+ continue;
+ }
+
+ if (! is_dir($sourceTargetPath)) {
+ continue;
+ }
+
+ // 디렉토리 target — 재귀 비교
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::SELF_FIRST,
+ );
+
+ foreach ($iterator as $item) {
+ /** @var SplFileInfo $item */
+ $absolute = $item->getPathname();
+ $relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/'));
+
+ if (self::matchesExcludes($relative, $excludeSet)) {
+ continue;
+ }
+ if (self::isWithinProtectedPath($relative, $protectedSet)) {
+ continue;
+ }
+
+ $backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
+
+ if ($item->isDir() && ! $item->isLink()) {
+ if (! is_dir($backupItemPath)) {
+ $newDirs[] = $relative;
+ }
+ } elseif ($item->isFile()) {
+ if (! file_exists($backupItemPath)) {
+ $newFiles[] = $relative;
+ }
+ }
+ // symlink 는 manifest 에 등재하지 않음 — prune 도 어차피 skip
+ }
+ }
+
+ sort($newFiles, SORT_STRING);
+ sort($newDirs, SORT_STRING);
+
+ $manifest = [
+ 'version' => self::MANIFEST_SCHEMA_VERSION,
+ 'created_at' => date('c'),
+ 'from_version' => $fromVersion,
+ 'to_version' => $toVersion,
+ 'new_files' => $newFiles,
+ 'new_dirs' => $newDirs,
+ ];
+
+ File::ensureDirectoryExists($backupPath);
+ $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
+ File::put($manifestPath, json_encode(
+ $manifest,
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
+ ));
+
+ Log::info('코어 신규 파일 manifest 작성 완료', [
+ 'manifest_path' => $manifestPath,
+ 'new_files_count' => count($newFiles),
+ 'new_dirs_count' => count($newDirs),
+ 'from_version' => $fromVersion,
+ 'to_version' => $toVersion,
+ ]);
+
+ return [
+ 'new_files_count' => count($newFiles),
+ 'new_dirs_count' => count($newDirs),
+ ];
+ }
+
+ /**
+ * 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다.
+ *
+ * 안전 가드:
+ * - symlink 는 manifest 에 있더라도 무조건 skip (`public/storage` 등 운영 데이터 보호)
+ * - protected_paths 하위 항목은 prune 시점에 재검증되어 skip (이중 방어)
+ * - manifest 부재/JSON 파싱 실패 시 noop + manifest_loaded:false 반환
+ * - 디렉토리는 깊이 역순으로 rmdir 시도. 빈 디렉토리만 제거 (사용자 파일이 있으면 유지)
+ * - 개별 항목 삭제 실패는 fatal 이 아닌 warning + failed_count 누적
+ *
+ * @param string $backupPath 백업 디렉토리 경로 (manifest 위치)
+ * @param string $activePath 활성(= 정리 대상) 디렉토리 경로
+ * @param array $protectedPaths 보호 경로 목록 (이중 가드)
+ * @param \Closure|null $onProgress 진행 콜백 (현재 미사용 — 호출 시그니처 호환용)
+ * @return array{removed_files:int, removed_dirs:int, protected_count:int, symlink_skipped:int, failed_count:int, manifest_loaded:bool}
+ */
+ public static function pruneNewFiles(
+ string $backupPath,
+ string $activePath,
+ array $protectedPaths,
+ ?\Closure $onProgress = null,
+ ): array {
+ $result = [
+ 'removed_files' => 0,
+ 'removed_dirs' => 0,
+ 'protected_count' => 0,
+ 'symlink_skipped' => 0,
+ 'failed_count' => 0,
+ 'manifest_loaded' => false,
+ ];
+
+ $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
+ if (! File::exists($manifestPath)) {
+ return $result;
+ }
+
+ $raw = @file_get_contents($manifestPath);
+ if ($raw === false) {
+ Log::warning('코어 prune: manifest 읽기 실패 — skip', ['path' => $manifestPath]);
+
+ return $result;
+ }
+
+ $manifest = json_decode($raw, true);
+ if (! is_array($manifest) || ! isset($manifest['new_files']) || ! isset($manifest['new_dirs'])) {
+ Log::warning('코어 prune: manifest JSON 파싱 실패 — skip', ['path' => $manifestPath]);
+
+ return $result;
+ }
+
+ $result['manifest_loaded'] = true;
+
+ $protectedSet = self::normalizeProtectedSet($protectedPaths);
+
+ // 신규 파일 정리
+ foreach ((array) $manifest['new_files'] as $rel) {
+ if (! is_string($rel) || $rel === '') {
+ continue;
+ }
+ $rel = self::normalizeRelative($rel);
+
+ if (self::isWithinProtectedPath($rel, $protectedSet)) {
+ $result['protected_count']++;
+
+ continue;
+ }
+
+ $absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
+
+ if (is_link($absolute)) {
+ $result['symlink_skipped']++;
+
+ continue;
+ }
+
+ if (! file_exists($absolute)) {
+ continue; // 이미 부재 — skip
+ }
+
+ if (is_dir($absolute) && ! is_link($absolute)) {
+ // 파일로 등재되었으나 실제는 디렉토리 — 안전 우선, skip
+ continue;
+ }
+
+ if (@unlink($absolute)) {
+ $result['removed_files']++;
+ } else {
+ $result['failed_count']++;
+ Log::warning('코어 prune: 파일 삭제 실패', ['path' => $absolute]);
+ }
+ }
+
+ // 신규 디렉토리 정리 — 깊이 역순으로 빈 디렉토리만 rmdir
+ $dirs = array_values(array_filter((array) $manifest['new_dirs'], 'is_string'));
+ usort($dirs, fn ($a, $b) => substr_count($b, '/') <=> substr_count($a, '/'));
+
+ foreach ($dirs as $rel) {
+ $rel = self::normalizeRelative($rel);
+
+ if (self::isWithinProtectedPath($rel, $protectedSet)) {
+ $result['protected_count']++;
+
+ continue;
+ }
+
+ $absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
+
+ if (is_link($absolute)) {
+ $result['symlink_skipped']++;
+
+ continue;
+ }
+
+ if (! is_dir($absolute)) {
+ continue;
+ }
+
+ // 빈 디렉토리만 rmdir — 사용자 파일이 남아있으면 유지
+ if (self::isEmptyDirectory($absolute)) {
+ if (@rmdir($absolute)) {
+ $result['removed_dirs']++;
+ } else {
+ $result['failed_count']++;
+ Log::warning('코어 prune: 디렉토리 삭제 실패', ['path' => $absolute]);
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * protected_paths 배열을 정규화된 비교 집합으로 변환합니다.
+ *
+ * 슬래시 정규화 + 좌우 공백 제거 + 빈 항목 제거 + 최상위/하위 prefix 매칭에 사용.
+ *
+ * @param array $paths
+ * @return array
+ */
+ private static function normalizeProtectedSet(array $paths): array
+ {
+ $out = [];
+ foreach ($paths as $p) {
+ $p = trim((string) $p);
+ if ($p === '') {
+ continue;
+ }
+ $out[] = self::normalizeRelative($p);
+ }
+
+ return $out;
+ }
+
+ /**
+ * 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다.
+ */
+ private static function isWithinProtectedPath(string $relative, array $protectedSet): bool
+ {
+ $relative = self::normalizeRelative($relative);
+ foreach ($protectedSet as $p) {
+ if ($p === '') {
+ continue;
+ }
+ if ($relative === $p) {
+ return true;
+ }
+ if (str_starts_with($relative, $p.'/')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 상대 경로가 excludes 패턴(이름 또는 슬래시 포함 경로) 에 매칭되는지 검사합니다.
+ *
+ * 단순 이름(슬래시 미포함) 은 경로의 어떤 세그먼트와도 매칭 — `node_modules` 처럼
+ * 깊이 무관 제외 패턴 의도와 일치.
+ */
+ private static function matchesExcludes(string $relative, array $excludes): bool
+ {
+ $segments = explode('/', $relative);
+ foreach ($excludes as $exclude) {
+ if ($exclude === '') {
+ continue;
+ }
+ if (str_contains($exclude, '/')) {
+ if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) {
+ return true;
+ }
+ } else {
+ if (in_array($exclude, $segments, true)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 경로 문자열을 슬래시 정규화 + 좌측 슬래시 제거.
+ */
+ private static function normalizeRelative(string $path): string
+ {
+ $p = str_replace('\\', '/', $path);
+ $p = ltrim($p, '/');
+ // 연속 슬래시 제거
+ while (str_contains($p, '//')) {
+ $p = str_replace('//', '/', $p);
+ }
+
+ return $p;
+ }
+
+ /**
+ * 디렉토리가 비어있는지 검사 (`.` `..` 제외).
+ */
+ private static function isEmptyDirectory(string $path): bool
+ {
+ if (! is_dir($path)) {
+ return true;
+ }
+ $entries = @scandir($path);
+ if ($entries === false) {
+ return false;
+ }
+
+ return count(array_diff($entries, ['.', '..'])) === 0;
+ }
}
diff --git a/app/Extension/Vendor/EnvironmentDetector.php b/app/Extension/Vendor/EnvironmentDetector.php
index 0170f261..574d8abf 100644
--- a/app/Extension/Vendor/EnvironmentDetector.php
+++ b/app/Extension/Vendor/EnvironmentDetector.php
@@ -98,12 +98,13 @@ class EnvironmentDetector
}
// composer.phar 폴백
+ // @is_file 로 open_basedir warning 억제 (BASE_PATH/getcwd 가 화이트리스트 밖일 가능성)
$pharCandidates = [
base_path('composer.phar'),
getcwd().DIRECTORY_SEPARATOR.'composer.phar',
];
foreach ($pharCandidates as $phar) {
- if (is_file($phar)) {
+ if (@is_file($phar)) {
return $this->cachedComposerBinary = $phar;
}
}
@@ -275,6 +276,11 @@ class EnvironmentDetector
/**
* PATH 환경변수에서 composer 검색.
+ *
+ * stat (is_file) 은 open_basedir 같은 PHP 런타임 제약 환경에서 false negative
+ * 를 일으키므로 보조 신호로만 쓴다. stat 통과 후보가 있으면 우선 반환하고,
+ * 그렇지 않은 경우 메타문자 없는 첫 후보를 반환해 canExecuteComposer 의
+ * proc_open 결과로 최종 판정한다.
*/
private function searchComposerInPath(): ?string
{
@@ -289,20 +295,35 @@ class EnvironmentDetector
? ['composer.bat', 'composer.exe', 'composer.phar', 'composer']
: ['composer', 'composer.phar'];
+ $fallback = null;
+
foreach ($paths as $dir) {
foreach ($names as $name) {
$candidate = rtrim($dir, '/\\').DIRECTORY_SEPARATOR.$name;
- if (is_file($candidate)) {
+
+ // stat 통과 후보가 있으면 우선 반환 (가장 신뢰도 높음)
+ if (@is_file($candidate)) {
return $candidate;
}
+
+ // stat 실패 후보는 첫 번째만 fallback 으로 보관.
+ // open_basedir 환경에서는 정상 binary 도 is_file false 가 되므로
+ // proc_open 결과로 최종 판정할 기회를 남긴다.
+ if ($fallback === null) {
+ $fallback = $candidate;
+ }
}
}
- return null;
+ return $fallback;
}
/**
* 후보 경로가 실행 가능한 파일인지 확인.
+ *
+ * stat (is_file) 의존을 제거해 open_basedir 환경의 false negative 를 피한다.
+ * 단일 토큰의 셸 메타문자만 차단하고, 실제 실행 가능 여부는 canExecuteComposer
+ * 의 proc_open 결과로 최종 판정한다.
*/
private function isExecutableCandidate(?string $candidate): bool
{
@@ -310,11 +331,18 @@ class EnvironmentDetector
return false;
}
- // 공백 포함 시 전체 커맨드로 간주 — 파일 존재 검사 스킵
+ // 공백 포함 시 전체 커맨드로 간주 — 외부 신뢰된 config/.env 값을 그대로 사용.
+ // buildComposerCommand 의 공백 분기와 동일한 신뢰 모델 (운영자 자기 책임 영역).
if (str_contains($candidate, ' ')) {
return true;
}
- return is_file($candidate);
+ // 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
+ // 셸 인젝션 차단은 호출자의 escapeshellarg/buildComposerCommand 가 담당.
+ if (preg_match('/[;`$|<>"\'&\x00-\x1F]/', $candidate)) {
+ return false;
+ }
+
+ return true;
}
}
diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
index 826b47ee..55e43ca4 100644
--- a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
+++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
@@ -16,6 +16,7 @@ class IdentityMessageDefinitionCollection extends BaseApiCollection
protected function abilityMap(): array
{
return [
+ 'can_create' => 'core.admin.identity.messages.update',
'can_update' => 'core.admin.identity.messages.update',
];
}
diff --git a/config/app.php b/config/app.php
index e5c9d748..d5dfd02e 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.0-beta.5'),
+ 'version' => env('APP_VERSION', '7.0.0-beta.6'),
/*
|--------------------------------------------------------------------------
diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md
index 8692e3d1..7380763e 100644
--- a/docs/backend/core-update-system.md
+++ b/docs/backend/core-update-system.md
@@ -215,6 +215,8 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> **spawn 자식 진입 시 PSR-4 autoload 갱신 (engine-v / beta.4 이후)**: `core:execute-upgrade-steps` (Step 10) 와 `core:execute-bundled-updates` (Step 12) 의 spawn 자식은 `handle()` 진입 직후 `app(ExtensionManager::class)->updateComposerAutoload()` 를 1회 호출한다. 부모 프로세스의 `bootstrap/cache/autoload-extensions.php` 가 stale 한 경우 자식이 그 매핑을 그대로 로드 → upgrade step 또는 bundled update 안에서 모듈/플러그인의 `Models`/`Services` 같은 다른 클래스를 lazy autoload 시 "Class not found" 발생. 진입 시점 1회 호출로 모든 후속 작업이 fresh autoload 환경에서 실행됨을 보장한다 (개별 step 마다 호출할 필요 없음). 본 진입점들은 자체가 spawn 자식 (별개 PHP 프로세스) 이라 디스크의 fresh `ExtensionManager` 클래스를 메모리에 로드한 상태 — 직접 메서드 호출도 stale 가능성 없음.
+> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
+
### Step 11: 마무리
```text
diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md
index 7c3cd28b..2a2d0fb2 100644
--- a/docs/cheatsheet.md
+++ b/docs/cheatsheet.md
@@ -122,6 +122,7 @@ php artisan migrate:rollback
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--vendor-mode=auto|composer|bundled]
+php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내 또는 수동 복구용 — 단독 호출 시 migration·resync·.env·캐시·번들 확장 일괄 업데이트 자동 수행. CoreUpdateCommand 내부 spawn 은 --skip-* 5개 옵션 자동 전달)
# 모듈
php artisan module:list
@@ -174,6 +175,19 @@ php artisan extension:composer-install # 모든 모듈+플러그인
php artisan extension:update-autoload
```
+### 단발성 결함 보정 (hotfix)
+
+`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
+
+```bash
+# 코어 자동 롤백 후 활성 디렉토리에 잔존한 신 파일 진단/정리 (7.0.0-beta.6 신설)
+php artisan hotfix:rollback-stale-files # 진단 모드 (실제 삭제 없음)
+php artisan hotfix:rollback-stale-files --prune # 정리 (확인 프롬프트 동반)
+php artisan hotfix:rollback-stale-files --backup=<경로> # 특정 백업 디렉토리 지정
+```
+
+진단 결과 / 정리 로그는 `storage/logs/hotfix_rollback_stale_files_.log` 에 기록.
+
### Vendor 번들 Artisan (공유 호스팅용 vendor/ 선탑재)
```bash
@@ -239,6 +253,7 @@ php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
+php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내 또는 수동 복구용)
# CLI (Artisan 커맨드)
php artisan module:check-updates [identifier?] # 모듈 업데이트 확인
diff --git a/docs/extension/upgrade-step-guide.md b/docs/extension/upgrade-step-guide.md
index 5461484a..37ea7e4a 100644
--- a/docs/extension/upgrade-step-guide.md
+++ b/docs/extension/upgrade-step-guide.md
@@ -513,6 +513,23 @@ CoreUpdateCommand::handle └─ disableMaintenanceMode
대신 사용자에게는 **스텝 전용** 명령 (`php artisan core:execute-upgrade-steps --from= --to= --force`) 만 실행하도록 안내한다. 이 명령은 재다운로드·vendor 재설치 없이 남은 upgrade step 만 실행한다.
+#### 단독 실행 시 자동 수행되는 보조 단계 (beta.6+)
+
+`core:execute-upgrade-steps` 가 운영자에 의해 직접 호출 (HANDOFF 안내 또는 수동 복구) 되면, 부모 `CoreUpdateCommand` 가 평소 수행하던 다음 단계를 자동으로 함께 수행한다 — 단독 실행자가 별도 명령을 잇따라 실행할 필요가 없다.
+
+- 사전 단계: `runMigrations()`, `reloadCoreConfigAndResync()` (config/core.php 재로드 + 권한/메뉴/시더 동기화)
+- 사후 단계: `updateVersionInEnv($toVersion)`, `clearAllCaches()`, `runBundledExtensionUpdatePrompt()` (모듈/플러그인/템플릿/언어팩 일괄 업데이트)
+
+부모 `CoreUpdateCommand` 가 spawn 호출하는 경로에서는 다음 5개 옵션을 모두 자식 명령 라인에 추가해 중복 회피한다 — 부모는 이미 Step 9 / Step 11 / 번들 prompt 를 자식 종료 후 수행하기 때문이다.
+
+- `--skip-migrations`
+- `--skip-resync`
+- `--skip-version-env`
+- `--skip-cache-clear`
+- `--skip-bundled-updates`
+
+수동 복구 시나리오에서 사용자가 부분 단계만 제어하고 싶다면 위 옵션을 선택적으로 조합한다. 옵션을 모두 부여하면 부모 spawn 시나리오와 등가 — 본 명령은 upgrade step 만 실행한다.
+
### 사용 시점
upgrade step 파일에서 아래 조건이 모두 성립할 때 사용한다:
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 8becf797..ba4c79ef 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -49,9 +49,9 @@
### Fixed
-- 파티션 스키마 폐지 후에도 잔여 파티션 DDL 호출로 인해 신규 게시판을 만들지 못하던 문제 수정
+- 파티션 스키마 폐지 후에도 잔여 파티션 DDL 호출로 인해 신규 게시판을 만들지 못하던 문제 수정 (#12 @laelbe 님께서 제보해주셨습니다.)
- 일부 권한 설정에서 비밀글·접근 제한 게시글의 이전/다음 조회 시 500 오류가 발생하던 문제 수정 — 옆 글을 찾지 못하면 빈 값을 반환하도록 변경
-- 큐 워커가 실행 중이지 않은 환경에서 댓글/게시글/첨부 수가 즉시 반영되지 않던 문제 수정
+- 큐 워커가 실행 중이지 않은 환경에서 댓글/게시글/첨부 수가 즉시 반영되지 않던 문제 수정 (#11 @laelbe 님께서 제보해주셨습니다.)
- CKEditor 등 임시 업로드를 거친 첨부가 게시글에 연결되어도 첨부 수가 늘어나지 않던 문제 수정
- 내가 댓글 단 게시글 활동(`activity_type=commented`) 조회 시 쿼리 오류로 500 응답을 반환하던 문제 수정
- 삭제된 게시글에 달았던 댓글이 활동 통계(`total_comments`)에 계속 포함되던 문제 수정
@@ -89,9 +89,9 @@
#### 성능 최적화
-- 이전글/다음글 조회를 독립 API로 분리하여 게시글 상세 페이지 초기 로딩 쿼리 감소
-- 게시글·댓글 수 등 집계 정보를 사전 계산 컬럼으로 관리하도록 개선 — 목록 조회 시 COUNT 쿼리 제거
-- 게시글 본문 검색 및 통합검색에 FULLTEXT 인덱스 적용 — 대용량 데이터에서도 빠른 검색 가능
+- 이전글/다음글 조회를 독립 API로 분리하여 게시글 상세 페이지 초기 로딩 쿼리 감소 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
+- 게시글·댓글 수 등 집계 정보를 사전 계산 컬럼으로 관리하도록 개선 — 목록 조회 시 COUNT 쿼리 제거 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
+- 게시글 본문 검색 및 통합검색에 FULLTEXT 인덱스 적용 — 대용량 데이터에서도 빠른 검색 가능 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
### Changed
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index a5db3b4a..2c67f808 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -8,8 +8,8 @@
### Added
-- 페이지 수정 시 슬러그를 변경할 수 있도록 개선 (중복 확인 후 저장)
-- 관리자 목록에서 슬러그 및 URL 텍스트 클릭 시 유저 화면으로 이동하는 링크 추가 (새 탭으로 열림)
+- 페이지 수정 시 슬러그를 변경할 수 있도록 개선 (중복 확인 후 저장) (#16 @movielee2020 님께서 건의해주셨습니다.)
+- 관리자 목록에서 슬러그 및 URL 텍스트 클릭 시 유저 화면으로 이동하는 링크 추가 (새 탭으로 열림) (#16 @movielee2020 님께서 건의해주셨습니다.)
- 활동 로그의 페이지 액션 라벨을 페이지 모듈 다국어 파일에서 관리하도록 정리 — 모듈 라벨이 자기 영역에서 자기설명되며, 일본어 등 추가 언어팩이 키 누락 없이 동기화
### Fixed
@@ -27,7 +27,7 @@
### Fixed
-- 사용자 화면 버전 배지가 잘못된 형식으로 표시되던 문제 수정
+- 사용자 화면 버전 배지가 잘못된 형식으로 표시되던 문제 수정 (#4 @laelbe 님께서 제보해주셨습니다.)
- 존재하지 않는 컬럼을 참조하던 draft/archived 배지 코드 제거
## [1.0.0-beta.1] - 2026-04-01
diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
index 41162974..71789943 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
@@ -14,7 +14,7 @@
### Fixed
-- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정
+- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정 (#17 @laelbe 님께서 제보해주셨습니다.)
## [1.0.0-beta.1] - 2026-04-09
diff --git a/public/install/api/check-configuration.php b/public/install/api/check-configuration.php
index cdd715c3..a6a91734 100644
--- a/public/install/api/check-configuration.php
+++ b/public/install/api/check-configuration.php
@@ -193,7 +193,7 @@ class ValidationApi
}
// 에러 로깅
- logInstallationError(lang('error_db_connection_failed'), $e);
+ logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e);
// 에러 응답 (200 OK + success: false)
echo json_encode([
@@ -778,6 +778,55 @@ class ValidationApi
], JSON_UNESCAPED_UNICODE);
}
+ /**
+ * 셸 인자로 전달하기 안전한 단일 토큰인지 검증.
+ *
+ * 셸 메타문자(공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등) 와 제어문자(NUL/CR/LF 등)
+ * 가 없어야 함. 백슬래시(`\`) 는 Windows 경로 구분자이므로 차단 대상이 아니다 —
+ * 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당 (Windows 는 큰따옴표 wrapping,
+ * Unix 는 작은따옴표 wrapping).
+ *
+ * 파일 시스템 stat 은 호출하지 않는다 — open_basedir 등 PHP 런타임 제약
+ * 환경에서 정상 절대경로가 false negative 로 거부되는 회귀를 피하기 위함.
+ * 실제 실행 가능 여부는 exec/proc_open 결과로 최종 판정.
+ */
+ private function isInstallerSafePathArg(string $path): bool
+ {
+ if ($path === '') {
+ return false;
+ }
+
+ return !preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
+ }
+
+ /**
+ * 공백 분리 입력을 두 토큰(PHP 인터프리터 + Composer 바이너리) 으로 분해.
+ *
+ * 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 에서
+ * `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다.
+ * 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정.
+ *
+ * @return array{php: string, composer: string}|null 분해 실패 시 null
+ */
+ private function splitPhpComposerTokens(string $path): ?array
+ {
+ if (!str_contains($path, ' ')) {
+ return null;
+ }
+
+ $tokens = preg_split('/\s+/', trim($path), 2);
+ if (!is_array($tokens) || count($tokens) !== 2) {
+ return null;
+ }
+
+ [$php, $composer] = $tokens;
+ if ($php === '' || $composer === '') {
+ return null;
+ }
+
+ return ['php' => $php, 'composer' => $composer];
+ }
+
/**
* PHP 바이너리 경로 유효성 검증 헬퍼
*
@@ -790,9 +839,10 @@ class ValidationApi
return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')];
}
- // 'php' 기본값이 아니면 반드시 실제 실행 가능한 단일 파일 경로여야 함
- // (사용자 입력을 그대로 shell 에 전달하던 경로 차단)
- if ($path !== 'php' && (!is_file($path) || !is_executable($path))) {
+ // 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
+ // open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
+ // 생략하고, exec 결과로 최종 판정한다.
+ if ($path !== 'php' && !$this->isInstallerSafePathArg($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
}
@@ -837,10 +887,49 @@ class ValidationApi
// 빈 문자열이면 시스템 기본 composer 사용
$effectivePath = $composerPath ?: 'composer';
- // 보안: 입력값을 "전체 실행 명령어" 로 허용하던 공백 분기 제거.
- // 시스템 기본('composer') 가 아니면 반드시 실행 가능한 단일 파일 경로여야 하며,
- // 모든 분기에서 escapeshellarg 를 강제한다.
- if ($effectivePath !== 'composer' && (!is_file($effectivePath) || !is_executable($effectivePath))) {
+ // 공백 분리 입력은 "PHP 절대경로 + Composer 절대경로" 의 멀티 PHP 운영 패턴.
+ // 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다.
+ // 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음.
+ if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
+ $tokens = $this->splitPhpComposerTokens($effectivePath);
+ if ($tokens === null
+ || !$this->isInstallerSafePathArg($tokens['php'])
+ || !$this->isInstallerSafePathArg($tokens['composer'])
+ ) {
+ return [
+ 'valid' => false,
+ 'version' => null,
+ 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
+ ];
+ }
+
+ $command = escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']) . ' --version 2>&1';
+
+ $output = [];
+ $returnCode = -1;
+ applyInstallerComposerEnvVars();
+ exec($command, $output, $returnCode);
+
+ if ($returnCode !== 0) {
+ return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])];
+ }
+
+ $outputStr = implode("\n", $output);
+ if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
+ $version = $matches[1];
+ return [
+ 'valid' => true,
+ 'version' => $version,
+ 'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]),
+ ];
+ }
+
+ return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
+ }
+
+ // 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
+ // 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
+ if ($effectivePath !== 'composer' && !$this->isInstallerSafePathArg($effectivePath)) {
return [
'valid' => false,
'version' => null,
@@ -850,8 +939,8 @@ class ValidationApi
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with($effectivePath, '.phar')) {
- // phpPath 도 동일한 가드 — 'php' 기본값이 아니면 실행 가능 파일이어야 함
- if ($phpPath !== 'php' && (!is_file($phpPath) || !is_executable($phpPath))) {
+ // phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
+ if ($phpPath !== 'php' && !$this->isInstallerSafePathArg($phpPath)) {
return [
'valid' => false,
'version' => null,
diff --git a/public/install/api/session-probe.php b/public/install/api/session-probe.php
new file mode 100644
index 00000000..fe017821
--- /dev/null
+++ b/public/install/api/session-probe.php
@@ -0,0 +1,92 @@
+ 'set',
+ 'nonce' => $nonce,
+ ];
+}
+
+/**
+ * verify 액션 — 세션의 nonce 를 반환하고 세션에서 제거
+ *
+ * 세션에 nonce 가 보존되어 있으면 matched=true. 세션이 빈 상태(쿠키 round-trip
+ * 실패) 면 matched=false. 한 번 verify 한 nonce 는 재사용 방지를 위해 세션에서
+ * 제거한다.
+ *
+ * @return array{action: string, matched: bool, nonce?: string}
+ */
+function sessionProbeVerify(): array
+{
+ if (! isset($_SESSION['_installer_session_probe'])) {
+ return [
+ 'action' => 'verify',
+ 'matched' => false,
+ ];
+ }
+
+ $nonce = $_SESSION['_installer_session_probe'];
+ unset($_SESSION['_installer_session_probe']);
+
+ return [
+ 'action' => 'verify',
+ 'matched' => true,
+ 'nonce' => $nonce,
+ ];
+}
+
+// 라이브러리 모드: 테스트 등에서 함수 정의만 로드. SESSION_PROBE_LIBRARY 상수가 정의되어 있으면 즉시 종료.
+$sessionProbeLibraryMode = defined('SESSION_PROBE_LIBRARY') && constant('SESSION_PROBE_LIBRARY');
+
+if (! $sessionProbeLibraryMode) {
+ // 정식 진입점 — config / session / functions / guard 로드
+ require_once __DIR__ . '/../includes/config.php';
+ require_once __DIR__ . '/../includes/session.php';
+ require_once __DIR__ . '/../includes/functions.php';
+ require_once __DIR__ . '/_guard.php';
+ installer_guard_or_410();
+
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store, no-cache, must-revalidate');
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+ http_response_code(405);
+ echo json_encode(['error' => 'GET method required'], JSON_UNESCAPED_UNICODE);
+ exit;
+ }
+
+ $action = $_GET['action'] ?? '';
+
+ if ($action === 'set') {
+ echo json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE);
+ } elseif ($action === 'verify') {
+ echo json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE);
+ } else {
+ http_response_code(400);
+ echo json_encode(['error' => 'Invalid action — use ?action=set or ?action=verify'], JSON_UNESCAPED_UNICODE);
+ }
+}
diff --git a/public/install/assets/js/installer.js b/public/install/assets/js/installer.js
index a1151012..bb7ae55f 100644
--- a/public/install/assets/js/installer.js
+++ b/public/install/assets/js/installer.js
@@ -385,7 +385,7 @@
// 6. HTTPS 카드
const httpsStatusClass = data.https.enabled ? 'status-pass' : 'status-warning';
html += renderSingleItemCard(
- lang('https_enabled'),
+ lang('https'),
httpsStatusClass,
data.https.enabled ? lang('enabled') : lang('not_enabled'),
'',
diff --git a/public/install/includes/session.php b/public/install/includes/session.php
index f607eded..b17c432a 100644
--- a/public/install/includes/session.php
+++ b/public/install/includes/session.php
@@ -11,7 +11,11 @@ if (session_status() === PHP_SESSION_NONE) {
// 세션 설정
ini_set('session.cookie_httponly', '1');
ini_set('session.use_strict_mode', '1');
- ini_set('session.cookie_samesite', 'Strict');
+ // SameSite=Lax — top-level navigation 의 동일 사이트 POST 에 쿠키 동반 전송.
+ // CSRF 방어는 별도 csrf_token 검증이 담당하므로 Strict 대신 Lax 로 완화하여
+ // 비표준 포트 / dynamic DNS 도메인 / 일부 브라우저 정책 조합에서 PHPSESSID
+ // 가 차단되어 Step 0 무한 루프에 빠지던 회귀를 차단.
+ ini_set('session.cookie_samesite', 'Lax');
// HTTPS 환경에서는 secure 쿠키 사용
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php
index cc7ff0c1..2bfd3c72 100644
--- a/public/install/includes/task-runner.php
+++ b/public/install/includes/task-runner.php
@@ -140,22 +140,54 @@ if (!function_exists('getPhpBinary')) {
if (!function_exists('isInstallerExecutablePath')) {
/**
- * 인스톨러가 exec 에 전달하기 안전한 단일 실행 파일 경로인지 검증한다.
+ * 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다.
*
* - 빈 문자열은 호출자가 시스템 기본값을 쓰겠다는 신호이므로 별도 처리.
* - 공백/세미콜론/백틱/`$` 등 셸 메타문자가 포함된 입력은 거부.
- * - 실제로 존재하고 실행 가능한 파일이어야 함.
+ * - 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
+ * false negative 를 피하기 위해 생략. 실제 실행 가능 여부는 exec 결과로 판정.
*/
function isInstallerExecutablePath(string $path): bool
{
if ($path === '') {
return false;
}
- // 셸 메타문자 차단 — 공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등
- if (preg_match('/[\s;`$|<>"\'\\\\&]/', $path)) {
+ // 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
+ // 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당.
+ if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) {
return false;
}
- return is_file($path) && is_executable($path);
+ return true;
+ }
+}
+
+if (!function_exists('splitInstallerPhpComposerTokens')) {
+ /**
+ * 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해.
+ *
+ * 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 의
+ * 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야
+ * 정상 입력으로 간주.
+ *
+ * @return array{php: string, composer: string}|null 분해 실패 시 null
+ */
+ function splitInstallerPhpComposerTokens(string $path): ?array
+ {
+ if (!str_contains($path, ' ')) {
+ return null;
+ }
+
+ $tokens = preg_split('/\s+/', trim($path), 2);
+ if (!is_array($tokens) || count($tokens) !== 2) {
+ return null;
+ }
+
+ [$php, $composer] = $tokens;
+ if ($php === '' || $composer === '') {
+ return null;
+ }
+
+ return ['php' => $php, 'composer' => $composer];
}
}
@@ -169,6 +201,19 @@ if (!function_exists('getComposerCommand')) {
return 'composer';
}
+ // 공백 분리 입력 — 두 토큰으로 분해 후 각각 검증/escape.
+ // 멀티 PHP 환경에서 특정 PHP 인터프리터로 composer 를 실행하려는 운영 의도 지원.
+ if (str_contains($composerBinary, ' ')) {
+ $tokens = splitInstallerPhpComposerTokens($composerBinary);
+ if ($tokens === null
+ || !isInstallerExecutablePath($tokens['php'])
+ || !isInstallerExecutablePath($tokens['composer'])
+ ) {
+ return 'composer';
+ }
+ return escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']);
+ }
+
// 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되
// 사용자 입력이 셸 명령으로 흘러가지 않도록 차단.
if (!isInstallerExecutablePath($composerBinary)) {
@@ -193,7 +238,23 @@ if (!function_exists('getComposerCommandForDisplay')) {
$state = getInstallationState();
$composerBinary = (string) ($state['config']['composer_binary'] ?? '');
- if ($composerBinary === '' || !isInstallerExecutablePath($composerBinary)) {
+ if ($composerBinary === '') {
+ return 'composer';
+ }
+
+ // 공백 분리 입력 — 토큰 검증 통과 시 사람 친화적 표기로 그대로 노출.
+ if (str_contains($composerBinary, ' ')) {
+ $tokens = splitInstallerPhpComposerTokens($composerBinary);
+ if ($tokens === null
+ || !isInstallerExecutablePath($tokens['php'])
+ || !isInstallerExecutablePath($tokens['composer'])
+ ) {
+ return 'composer';
+ }
+ return $tokens['php'] . ' ' . $tokens['composer'];
+ }
+
+ if (!isInstallerExecutablePath($composerBinary)) {
return 'composer';
}
diff --git a/public/install/lang/en.php b/public/install/lang/en.php
index ff21e8dc..2dfd6ae5 100644
--- a/public/install/lang/en.php
+++ b/public/install/lang/en.php
@@ -51,7 +51,7 @@ return [
'php_modules' => 'PHP Modules',
'directory_permissions' => 'Directory Permissions',
'disk_space' => 'Disk Space',
- 'https_enabled' => 'HTTPS Enabled',
+ 'https' => 'HTTPS',
'required' => 'Required',
'enabled' => 'Enabled',
'not_enabled' => 'Not Enabled',
@@ -164,7 +164,6 @@ return [
'error_db_name_required' => 'Database name is required.',
'error_db_username_required' => 'Database username is required.',
'error_db_credentials_required' => 'Database name and username are required.',
- 'error_db_connection_failed' => 'Database connection failed.',
'error_db_privileges_insufficient' => 'Insufficient database privileges.',
'error_db_not_tested' => 'Please test the database connection first.',
'error_write_db_not_tested' => 'Please test Write DB connection first.',
@@ -606,7 +605,6 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_db_cleanup_consent_required' => 'You must consent to dropping existing tables before proceeding to the next step.',
'db_force_proceed_drop' => 'Drop all existing tables and install',
'db_force_proceed_confirmed' => 'Force install mode (existing tables will be dropped)',
- 'cancel' => 'Cancel',
'log_db_cleanup_skipped' => 'Skipping existing table cleanup (no action)',
'log_db_cleanup_empty' => 'No existing tables. Skipping cleanup.',
'log_db_cleanup_dropping' => 'Dropping {count} existing tables...',
@@ -764,7 +762,6 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'abort_api_status_change' => '[Abort API] Changing installation_status to "aborted".',
'abort_api_save_result' => '[Abort API] state.json save result: :result',
'abort_api_verify_status' => '[Abort API] Verify after save - installation_status: :status',
- 'api_method_not_allowed' => 'Method not allowed.',
'error_state_management' => 'State management error',
'error_log_prefix' => '[Error] :error',
@@ -856,6 +853,14 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_composer_path_not_exists' => 'File does not exist: :path',
'error_composer_exec_failed' => 'Composer execution failed: :path',
'error_composer_version_parse_failed' => 'Failed to parse Composer version.',
+ 'error_check_failed' => 'Check request failed. Please verify network or server status.',
+ 'session_cookie_blocked_title' => 'Session Cookie Blocked',
+ 'session_cookie_blocked_description' => 'Your browser is not preserving the installer session cookie (PHPSESSID), which may cause the installation to stall. You may proceed but it is not recommended — please try one of the workarounds below.',
+ 'session_cookie_blocked_remedy_1' => 'Try a different browser. Incognito/Private windows usually have stricter cookie policies — a regular window is recommended.',
+ 'session_cookie_blocked_remedy_2' => 'Disable cookie/site-data blocking in your browser and refresh the page.',
+ 'session_cookie_blocked_remedy_3' => 'Access via IP or localhost instead of a domain name. Example: http://localhost:port/install/',
+ 'session_cookie_blocked_technical_detail' => 'Technical detail: Installer session cookie round-trip verification failed. (Likely caused by SameSite policy, non-standard port, browser tracking protection, or similar combination.)',
+ 'session_cookie_blocked_dismiss' => 'Dismiss warning',
'success_composer_version' => ':path — Composer :version ✓',
'composer_install_guide_title' => 'Composer Installation Guide',
'composer_install_guide_message' => 'Composer is not installed. You can install it using the following methods:',
diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php
index 3db574a3..b7e34025 100644
--- a/public/install/lang/ko.php
+++ b/public/install/lang/ko.php
@@ -51,7 +51,7 @@ return [
'php_modules' => 'PHP 모듈',
'directory_permissions' => '디렉토리 권한',
'disk_space' => '디스크 공간',
- 'https_enabled' => 'HTTPS 활성화',
+ 'https' => 'HTTPS',
'required' => '필수',
'enabled' => '활성화됨',
'not_enabled' => '비활성화됨',
@@ -164,7 +164,6 @@ return [
'error_db_name_required' => '데이터베이스 이름은 필수입니다.',
'error_db_username_required' => '데이터베이스 사용자명을 입력해주세요.',
'error_db_credentials_required' => '데이터베이스명과 사용자명은 필수입니다.',
- 'error_db_connection_failed' => '데이터베이스 연결에 실패했습니다.',
'error_db_privileges_insufficient' => '데이터베이스에 필요한 권한이 부족합니다.',
'error_db_not_tested' => '데이터베이스 연결 테스트를 먼저 수행해주세요.',
'error_write_db_not_tested' => 'Write DB 연결 테스트를 먼저 수행해주세요.',
@@ -606,7 +605,6 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_db_cleanup_consent_required' => '기존 테이블 삭제에 동의하셔야 다음 단계로 진행할 수 있습니다.',
'db_force_proceed_drop' => '기존 테이블 모두 삭제 후 설치',
'db_force_proceed_confirmed' => '강제 진행 모드 (설치 시 기존 테이블 삭제)',
- 'cancel' => '취소',
'log_db_cleanup_skipped' => '기존 테이블 정리 건너뛰기 (액션 없음)',
'log_db_cleanup_empty' => '기존 테이블이 없습니다. 정리 건너뛰기',
'log_db_cleanup_dropping' => '기존 테이블 {count}개 삭제 시작...',
@@ -764,7 +762,6 @@ ini_set(\'zlib.output_compression\', \'off\');
'abort_api_status_change' => '[중단 API] installation_status를 "aborted"로 변경합니다.',
'abort_api_save_result' => '[중단 API] state.json 저장 결과: :result',
'abort_api_verify_status' => '[중단 API] 저장 후 확인 - installation_status: :status',
- 'api_method_not_allowed' => '허용되지 않는 메서드입니다.',
'error_state_management' => '상태 관리 오류',
'error_log_prefix' => '[오류] :error',
@@ -856,6 +853,14 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_composer_path_not_exists' => '파일이 존재하지 않습니다: :path',
'error_composer_exec_failed' => 'Composer 실행 실패: :path',
'error_composer_version_parse_failed' => 'Composer 버전을 파싱할 수 없습니다.',
+ 'error_check_failed' => '확인 요청에 실패했습니다. 네트워크 또는 서버 상태를 확인해 주세요.',
+ 'session_cookie_blocked_title' => '세션 쿠키 차단 감지',
+ 'session_cookie_blocked_description' => '브라우저가 인스톨러의 세션 쿠키(PHPSESSID)를 보존하지 않아 설치 도중 진행이 멈출 수 있습니다. 그대로 진행은 가능하지만 권장하지 않으며, 아래 회피 방법 중 하나를 시도해 주세요.',
+ 'session_cookie_blocked_remedy_1' => '다른 브라우저로 다시 접속해 주세요. 시크릿/프라이빗 창은 보통 쿠키 정책이 더 엄격하므로 일반 창 사용을 권장합니다.',
+ 'session_cookie_blocked_remedy_2' => '브라우저의 쿠키/사이트 데이터 차단 설정을 해제한 뒤 새로고침 해 주세요.',
+ 'session_cookie_blocked_remedy_3' => '도메인 대신 IP 또는 localhost 로 접속해 보세요. 예: http://localhost:포트/install/',
+ 'session_cookie_blocked_technical_detail' => '기술 상세: 인스톨러 세션 쿠키 round-trip 검증이 실패했습니다. (SameSite 정책, 비표준 포트, 브라우저 추적 보호 등의 조합으로 인한 차단 추정)',
+ 'session_cookie_blocked_dismiss' => '경고 닫기',
'success_composer_version' => ':path — Composer :version ✓',
'composer_install_guide_title' => 'Composer 설치 안내',
'composer_install_guide_message' => 'Composer가 설치되어 있지 않습니다. 아래 방법으로 설치할 수 있습니다:',
diff --git a/public/install/views/0-welcome.php b/public/install/views/0-welcome.php
index 6320810d..c0f3ddd7 100644
--- a/public/install/views/0-welcome.php
+++ b/public/install/views/0-welcome.php
@@ -169,6 +169,28 @@ $storageResult = $storageCheck['results']['storage'] ?? null;
+
+
+
+
= lang('session_cookie_blocked_description') ?>
+
+ - = lang('session_cookie_blocked_remedy_1') ?>
+ - = lang('session_cookie_blocked_remedy_2') ?>
+ - = lang('session_cookie_blocked_remedy_3') ?>
+
+
= lang('session_cookie_blocked_technical_detail') ?>
+
+