v7.0.0-beta.7 release

This commit is contained in:
HeuJung
2026-05-15 19:24:41 +09:00
parent c186d9a9c2
commit 0e7fa04b03
36 changed files with 2625 additions and 83 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.6
APP_VERSION=7.0.0-beta.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.6
APP_VERSION=7.0.0-beta.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+24
View File
@@ -4,6 +4,30 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.0-beta.7] - 2026-05-15
### Fixed
- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)
- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.
- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.
- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.
- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.
- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.
### Changed
- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)
- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)
### Notes — `.env` 권한 권장안
위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.
- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.
## [7.0.0-beta.6] - 2026-05-14
### Fixed
+176 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.6 g7
# (필요 시) mv g7-7.0.0-beta.7 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -338,6 +338,157 @@ http://도메인/install
---
## 업그레이드
이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
### 개요
그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
```bash
sudo php artisan core:update
```
이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
- 자동 백업 생성 (실패 시 자동 롤백)
- 유지보수 모드 자동 진입
- 데이터베이스 마이그레이션 실행
- 업그레이드 스텝 실행 (버전별 데이터 보정)
- 번들 확장 자동 재동기화
- 캐시 정리 및 유지보수 모드 해제
### 업그레이드 사전 준비
업그레이드 전 다음을 확인하세요.
1. **데이터베이스 백업** (필수)
```bash
mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
```
2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
```bash
tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
```
3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
### 방법 2: CLI 자동 업그레이드 (표준 절차)
대다수 환경에서 권장되는 절차입니다.
```bash
sudo php artisan core:update
```
#### `sudo` 가 권장되는 이유
업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
#### `sudo` 없이 실행해도 되는 환경
- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
```bash
# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
unzip <파일명>.zip -d /tmp/g7-new
# 2. 압축 해제된 경로를 지정하여 업데이트 실행
sudo php artisan core:update --source=/tmp/g7-new
```
`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
### 사용자군별 단계적 업그레이드
여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
| 현재 버전 | 권장 경로 |
|----------|----------|
| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
### 권한/캐시 트러블슈팅
업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
#### `.env` 읽기 실패 (`Permission denied`)
```bash
ls -l .env
# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
```
대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
#### `bootstrap/cache` 쓰기 실패
```bash
sudo chown -R www-data:www-data bootstrap/cache storage
```
소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
#### 업그레이드 도중 자동 롤백 후 부팅 실패
beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
```bash
# 진단 모드 (실제 삭제 없음)
sudo php artisan hotfix:rollback-stale-files
# 운영자 확인 후 실제 정리
sudo php artisan hotfix:rollback-stale-files --prune
```
### 업그레이드 후 확인
업그레이드가 끝나면 다음을 점검합니다.
1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
---
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
@@ -346,6 +497,30 @@ http://도메인/install
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
```bash
sudo chown www-data:www-data .env
sudo chmod 600 .env
```
- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
```bash
sudo chgrp www-data .env
sudo chmod 640 .env
```
웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.7-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/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>
@@ -459,4 +459,5 @@ trait BundledExtensionUpdatePrompt
'has_updates' => true,
];
}
}
@@ -174,4 +174,5 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -40,6 +40,45 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
$runtimePath = base_path(self::RUNTIME_PATH_RELATIVE);
if (! is_file($runtimePath)) {
// beta.7 한시 보정 — 이미 finalize 완료된 환경의 stale process ENV 회귀 차단.
//
// 배경 (이슈 #371): beta.4~6 손상 환경에서 beta.7 코어 업그레이드의 spawn 자식 1
// (core:execute-upgrade-steps) 이 .env 머지 + runtime.php 삭제를 마쳐도, 부모
// 프로세스의 process ENV (getenv / $_ENV / $_SERVER) 는 부팅 시점의 stale 값
// (.env.example fallback: DB_WRITE_USERNAME=root) 그대로 유지된다. 그 직후 부모가
// 띄우는 spawn 자식 2 (core:execute-bundled-updates) 에 stale ENV 가 그대로
// 상속되어 Laravel Dotenv::createImmutable() 이 디스크의 정합 .env 값을 보지 못해
// env('DB_WRITE_USERNAME') 이 stale 'root' 반환 → CoreServiceProvider::boot() 의
// isDatabaseConnectionValid() 가드가 트리거되어 확장 로딩 skip + ERROR 출력 →
// 번들 일괄 업데이트 전체 실패.
//
// 본 보정은 자식 2 가 부팅할 때 InstallerRuntimeServiceProvider::register() (가장 먼저
// 실행되는 ServiceProvider) 안에서 다음 4개 조건을 모두 만족하면 디스크 .env 를
// 직접 파싱하여 process ENV 를 갱신한다:
// (1) runtime.php 부재 (이미 finalize 완료된 상태 — 본 분기에 도달)
// (2) .env 존재
// (3) .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호)
// (4) .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 빈 값 아님)
// AND process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값)
//
// 정상 환경에서는 (4) 의 process ENV stale 시그니처가 false 이므로 보정 미발동.
$this->recoverStaleProcessEnvFromDiskEnv();
// 본 PR 부수효과 정리 — beta.7 업그레이드 흐름의 모든 root 권한 자식 spawn
// 부팅마다 자동으로 storage/framework/cache/data/ 하위 root 소유 디렉토리를
// 즉시 chown. 자식 1 (FinalizeOrphanedInstallerRuntime) 종료 후에 자식 2~N
// (번들 update / 후속 자식들) 이 root 권한으로 캐시 쓰기를 트리거하더라도
// 그 자식 자체의 ServiceProvider 부팅 시점에 본 보정이 발화하여 정리.
//
// 가드:
// (1) runtime.php 부재 (이미 finalize 완료 — 본 분기에 도달)
// (2) 디스크 config/app.php 의 version === '7.0.0-beta.7' (recover 와 동일 가드)
// (3) posix_geteuid() === 0 (root 권한 자식만)
//
// 정상 환경(beta.3 / beta.8+) 또는 PHP-FPM 워커(www-data 권한) 는 (2) 또는 (3)
// 가드로 자동 비활성화.
$this->normalizeRootOwnedCacheDirs();
return;
}
@@ -53,6 +92,328 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
$this->applyAppKey($runtime);
}
/**
* beta.7 한시 보정 — 디스크 .env 가 정합이고 process ENV 가 stale 일 때만 갱신.
*
* 트리거 조건이 매우 보수적이라 정상 환경 부팅 비용은 .env 의 parseEnvFile 1회 + 키 4~5개
* 비교. 손상 환경에서만 putenv/$_ENV/$_SERVER 갱신이 일어남.
*/
private function recoverStaleProcessEnvFromDiskEnv(): void
{
// beta.7 한시 가드 — 본 보정은 beta.4~6 finalize 결함 환경의 회귀 차단 전용.
// 디스크 config/app.php 의 'version' 값을 라인 매칭으로 추출 (메모리/env 무관)
// 하여 정확히 beta.7 로 업그레이드 중인 경우에만 발화. 메모리 config('app.version')
// 은 부모 stale ENV 의 APP_VERSION 을 따라가므로 신뢰할 수 없음.
$coreVersion = $this->readCoreVersionFromDisk();
if ($coreVersion !== '7.0.0-beta.7') {
return;
}
$envPath = base_path('.env');
if (! is_file($envPath)) {
return;
}
// parse_ini_file 은 .env 가 PHP INI 와 100% 호환되지 않는 케이스 (예약어 값,
// 일부 특수문자) 에서 실패 가능. 라인 단위 직접 파싱으로 호환성 함정 회피.
$parsed = $this->parseEnvFile($envPath);
if (! is_array($parsed) || $parsed === []) {
return;
}
// 조건 (3): INSTALLER_COMPLETED 가 truthy 여야 함 (finalize 완료 신호)
$completedFlagRaw = (string) ($parsed['INSTALLER_COMPLETED'] ?? '');
$completedFlag = strtolower(trim($completedFlagRaw, " \t\"'"));
if (! in_array($completedFlag, ['true', '1', 'yes'], true)) {
return;
}
// 조건 (4-a): .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 비어있지 않음)
$diskUsernameRaw = (string) ($parsed['DB_WRITE_USERNAME'] ?? '');
$diskUsername = $this->unwrapEnvValue($diskUsernameRaw);
if ($diskUsername === '' || $diskUsername === 'root') {
return;
}
// 조건 (4-b): process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값).
//
// bootstrap/app.php 의 Env::disablePutenv() 로 인해 getenv 가 빈 값일 수 있으므로,
// getenv / $_ENV / $_SERVER 셋 중 어느 하나라도 정합 (root 아님 + 빈 값 아님) 이면
// 정상 환경으로 판정하여 보정 skip. 셋 다 stale (root 또는 빈) 일 때만 진입.
$envUsername = (string) getenv('DB_WRITE_USERNAME');
$envUsernameSE = (string) ($_ENV['DB_WRITE_USERNAME'] ?? '');
$serverUsernameSE = (string) ($_SERVER['DB_WRITE_USERNAME'] ?? '');
foreach ([$envUsername, $envUsernameSE, $serverUsernameSE] as $candidate) {
if ($candidate !== '' && $candidate !== 'root') {
return;
}
}
// 4개 조건 모두 만족 — process ENV 갱신
$refreshableKeys = [
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_PREFIX', 'APP_KEY',
];
foreach ($refreshableKeys as $key) {
if (! array_key_exists($key, $parsed)) {
continue;
}
$value = $this->unwrapEnvValue((string) $parsed[$key]);
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
// Laravel Config 도 동기 갱신 — register() 시점에 LoadConfiguration 이 이미 실행되어
// process ENV 만 갱신해도 config('database.connections.mysql.*') 가 stale 값
// (root) 으로 캐시된 상태이기 때문에 직접 덮어써야 한다.
$this->applyConfigFromParsedEnv($parsed);
}
/**
* recover 분기 — 파싱된 .env 의 DB_* / APP_KEY / DB_PREFIX 를 Laravel Config 에 적용.
*
* 기존 `applyDatabaseConfig` 와 동등한 키 경로 / 구조 (`database.connections.mysql.{read,write}.*`).
*
* @param array<string, string> $parsed
*/
private function applyConfigFromParsedEnv(array $parsed): void
{
$writeHost = $parsed['DB_WRITE_HOST'] ?? null;
if ($writeHost !== null && $writeHost !== '') {
Config::set('database.connections.mysql.write.host', [$writeHost]);
Config::set('database.connections.mysql.write.port', $parsed['DB_WRITE_PORT'] ?? '3306');
Config::set('database.connections.mysql.write.database', $parsed['DB_WRITE_DATABASE'] ?? '');
Config::set('database.connections.mysql.write.username', $parsed['DB_WRITE_USERNAME'] ?? '');
Config::set('database.connections.mysql.write.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
}
$readHost = $parsed['DB_READ_HOST'] ?? null;
if ($readHost !== null && $readHost !== '') {
Config::set('database.connections.mysql.read.host', [$readHost]);
Config::set('database.connections.mysql.read.port', $parsed['DB_READ_PORT'] ?? '3306');
Config::set('database.connections.mysql.read.database', $parsed['DB_READ_DATABASE'] ?? '');
Config::set('database.connections.mysql.read.username', $parsed['DB_READ_USERNAME'] ?? '');
Config::set('database.connections.mysql.read.password', $parsed['DB_READ_PASSWORD'] ?? '');
} elseif ($writeHost !== null && $writeHost !== '') {
// read 가 별도 지정되지 않은 경우 write 값으로 동기화 (단일 DB 시나리오)
Config::set('database.connections.mysql.read.host', [$writeHost]);
Config::set('database.connections.mysql.read.port', $parsed['DB_WRITE_PORT'] ?? '3306');
Config::set('database.connections.mysql.read.database', $parsed['DB_WRITE_DATABASE'] ?? '');
Config::set('database.connections.mysql.read.username', $parsed['DB_WRITE_USERNAME'] ?? '');
Config::set('database.connections.mysql.read.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
}
if (isset($parsed['DB_PREFIX'])) {
Config::set('database.connections.mysql.prefix', $parsed['DB_PREFIX']);
}
$appKey = $parsed['APP_KEY'] ?? null;
if (is_string($appKey) && $appKey !== '') {
Config::set('app.key', $appKey);
}
// 기존 connection 인스턴스의 stale credential 캐시 폐기 — 다음 DB::connection() 호출 시
// 새 Config 로 재인스턴스화되도록 강제. config.write.username 이 stale root 로
// 남아 있던 회귀의 부속 원인 차단 (DB Manager 의 connection 캐시).
try {
\Illuminate\Support\Facades\DB::purge('mysql');
} catch (\Throwable $e) {
// non-fatal — Config 만 갱신된 상태로도 다음 connection 시 정합 값 사용
}
}
/**
* beta.7 업그레이드 자식 spawn 부팅마다 자동 발동 — storage/framework/cache/data/
* 하위 root 소유 디렉토리/파일을 정상 owner/group 으로 chown.
*
* 본 PR 의 recover 가 발화하면서 부수적으로 만들 수 있는 root 권한 캐시 디렉토리를
* 자식 자체의 부팅 시점에 자동 청소한다. 안전망 역할 — recover 가 정상 발화하여
* loadModules 가 정상 권한 캐시를 생성하면 본 메서드는 found=0 으로 no-op.
*
* 가드:
* - 디스크 config/app.php version === '7.0.0-beta.7' (recover 와 동일)
* - posix_geteuid() === 0 (root 권한 자식만 — 운영자 의도 권한 보존)
* - cache/data 자신이 정합 owner/group (chown 기준값 추정)
*/
private function normalizeRootOwnedCacheDirs(): void
{
// beta.7 한정 — recover 와 동일 가드
$coreVersion = $this->readCoreVersionFromDisk();
if ($coreVersion !== '7.0.0-beta.7') {
return;
}
if (! function_exists('posix_geteuid') || ! function_exists('chown') || ! function_exists('chgrp')) {
return;
}
// root 권한 프로세스만 (PHP-FPM 워커는 www-data → euid != 0 → skip)
if (posix_geteuid() !== 0) {
return;
}
$cacheRoot = base_path('storage/framework/cache/data');
if (! is_dir($cacheRoot)) {
return;
}
// 정상 owner/group 추정 (cache/data 자신이 SSoT)
$targetUid = @fileowner($cacheRoot);
$targetGid = @filegroup($cacheRoot);
if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
// cache/data 자신이 root 소유면 추정 실패 — bootstrap/cache fallback
$bootstrapCache = base_path('bootstrap/cache');
if (is_dir($bootstrapCache)) {
$targetUid = @fileowner($bootstrapCache);
$targetGid = @filegroup($bootstrapCache);
}
if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
return;
}
}
// 1depth 만 빠르게 점검 — root 소유 디렉토리 발견 시 재귀 chown
$handle = @opendir($cacheRoot);
if ($handle === false) {
return;
}
while (($name = readdir($handle)) !== false) {
if ($name === '.' || $name === '..') {
continue;
}
$path = $cacheRoot.DIRECTORY_SEPARATOR.$name;
$uid = @fileowner($path);
$gid = @filegroup($path);
if ($uid !== 0 && $gid !== 0) {
continue;
}
// root 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존
$this->chownRecursive($path, $targetUid, $targetGid);
}
closedir($handle);
}
/**
* 디렉토리/파일을 재귀적으로 chown + chgrp.
*
* @return bool 모든 항목 성공 시 true
*/
private function chownRecursive(string $path, int $uid, int $gid): bool
{
$ok = @chown($path, $uid) && @chgrp($path, $gid);
if (! is_dir($path) || is_link($path)) {
return $ok;
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iter as $entry) {
/** @var \SplFileInfo $entry */
$childPath = $entry->getPathname();
$ok = (@chown($childPath, $uid) && @chgrp($childPath, $gid)) && $ok;
}
return $ok;
}
/**
* 디스크 `config/app.php` 의 `'version' => ...` 라인을 정규식 매칭으로 추출.
*
* 메모리 config('app.version') 은 부모 stale ENV 의 APP_VERSION 을 따르므로
* 본 fix 의 자기 활성화 가드로 사용 불가. 디스크 SSoT (config/app.php) 직접 읽기로
* 코어 업그레이드 시점의 실제 적용 버전 확보.
*
* @return string|null 추출 실패 시 null
*/
private function readCoreVersionFromDisk(): ?string
{
$configPath = base_path('config/app.php');
if (! is_file($configPath)) {
return null;
}
$content = @file_get_contents($configPath);
if ($content === false) {
return null;
}
// 'version' => env('APP_VERSION', '7.0.0-beta.7'),
if (preg_match("/'version'\s*=>\s*env\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $content, $m)) {
return $m[1];
}
// fallback: 'version' => '7.0.0-beta.7',
if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $m)) {
return $m[1];
}
return null;
}
/**
* .env 파일을 라인 단위로 직접 파싱하여 key=>value 배열 반환.
*
* parse_ini_file 의 PHP INI 호환성 함정을 회피하기 위해 자체 파싱:
* - 빈 줄 / `#` 또는 `;` 로 시작하는 주석 줄 무시
* - 첫 `=` 기준으로 key/value 분할 (값에 `=` 포함 가능: APP_KEY=base64:...= 등)
* - 외곽 인용부호 (single/double) 제거 — unwrapEnvValue 사용
* - 키 이름은 [A-Za-z_][A-Za-z0-9_]* 만 인정
*
* @return array<string, string>
*/
private function parseEnvFile(string $envPath): array
{
$content = @file_get_contents($envPath);
if ($content === false) {
return [];
}
$lines = preg_split('/\r\n|\r|\n/', $content);
if ($lines === false) {
return [];
}
$result = [];
foreach ($lines as $line) {
$trimmed = ltrim($line);
if ($trimmed === '' || $trimmed[0] === '#' || $trimmed[0] === ';') {
continue;
}
$eqPos = strpos($trimmed, '=');
if ($eqPos === false || $eqPos === 0) {
continue;
}
$key = rtrim(substr($trimmed, 0, $eqPos));
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
continue;
}
$value = substr($trimmed, $eqPos + 1);
// 줄 끝의 행간 공백 제거 (인용된 값은 인용부호 안의 내용 보존)
$value = rtrim($value, " \t");
$value = $this->unwrapEnvValue($value);
$result[$key] = $value;
}
return $result;
}
private function unwrapEnvValue(string $value): string
{
$length = strlen($value);
if ($length >= 2) {
$first = $value[0];
$last = $value[$length - 1];
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
return substr($value, 1, $length - 2);
}
}
return $value;
}
/**
* runtime 배열의 DB 자격증명을 config('database.connections.mysql.*') 에 주입.
*
+35 -35
View File
@@ -95,7 +95,7 @@ class CoreUpdateService
$log = function (string $level, string $message) use ($logger): void {
if ($logger === null) {
Log::$level($message);
Log::channel('upgrade')->$level($message);
return;
}
@@ -109,7 +109,7 @@ class CoreUpdateService
return;
}
Log::$level($message);
Log::channel('upgrade')->$level($message);
};
$log('info', sprintf(
@@ -336,7 +336,7 @@ class CoreUpdateService
File::put($cachePath, $content);
}
} catch (\Exception $e) {
Log::warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]);
}
}
@@ -404,7 +404,7 @@ class CoreUpdateService
->connectTimeout(5)
->get($apiUrl);
} catch (ConnectionException $e) {
Log::warning(__('settings.core_update.log_api_call_failed'), [
Log::channel('upgrade')->warning(__('settings.core_update.log_api_call_failed'), [
'url' => $apiUrl,
'error' => $e->getMessage(),
]);
@@ -417,7 +417,7 @@ class CoreUpdateService
$apiMessage = is_array($data) && isset($data['message']) ? $data['message'] : '';
if ($statusCode === 401 || $statusCode === 403) {
Log::warning(__('settings.core_update.log_auth_failed'), [
Log::channel('upgrade')->warning(__('settings.core_update.log_auth_failed'), [
'url' => $apiUrl,
'status' => $statusCode,
'has_token' => $token !== '',
@@ -436,7 +436,7 @@ class CoreUpdateService
if ($repoExists) {
// 저장소는 존재하지만 릴리스가 없음
Log::info(__('settings.core_update.log_not_found'), [
Log::channel('upgrade')->info(__('settings.core_update.log_not_found'), [
'url' => $apiUrl,
'reason' => 'no_releases',
]);
@@ -445,7 +445,7 @@ class CoreUpdateService
}
// 저장소 자체를 찾을 수 없음
Log::warning(__('settings.core_update.log_not_found'), [
Log::channel('upgrade')->warning(__('settings.core_update.log_not_found'), [
'url' => $apiUrl,
'has_token' => $token !== '',
'api_message' => $apiMessage,
@@ -458,7 +458,7 @@ class CoreUpdateService
}
if ($statusCode !== 200) {
Log::warning(__('settings.core_update.log_unexpected_status'), [
Log::channel('upgrade')->warning(__('settings.core_update.log_unexpected_status'), [
'url' => $apiUrl,
'status' => $statusCode,
'api_message' => $apiMessage,
@@ -473,7 +473,7 @@ class CoreUpdateService
return ['version' => null, 'error' => __('settings.core_update.no_releases_found')];
} catch (\Exception $e) {
Log::error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]);
Log::channel('upgrade')->error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]);
return ['version' => null, 'error' => __('settings.core_update.github_api_failed')];
}
@@ -971,7 +971,7 @@ class CoreUpdateService
}
$dest = base_path($name);
Log::warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [
Log::channel('upgrade')->warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [
'name' => $name,
'reason' => 'parent process targets list did not include this path; falling back to source auto-discovery',
]);
@@ -1113,7 +1113,7 @@ class CoreUpdateService
}
if (md5_file($pendingJson) !== md5_file($baseJson)) {
Log::info('코어 업데이트: composer.json 변경 감지');
Log::channel('upgrade')->info('코어 업데이트: composer.json 변경 감지');
return false;
}
@@ -1123,20 +1123,20 @@ class CoreUpdateService
$baseLockExists = file_exists($baseLock);
if ($pendingLockExists !== $baseLockExists) {
Log::info('코어 업데이트: composer.lock 존재 여부 불일치');
Log::channel('upgrade')->info('코어 업데이트: composer.lock 존재 여부 불일치');
return false;
}
if ($pendingLockExists && $baseLockExists) {
if (md5_file($pendingLock) !== md5_file($baseLock)) {
Log::info('코어 업데이트: composer.lock 변경 감지');
Log::channel('upgrade')->info('코어 업데이트: composer.lock 변경 감지');
return false;
}
}
Log::info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능');
Log::channel('upgrade')->info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능');
return true;
}
@@ -1238,7 +1238,7 @@ class CoreUpdateService
fclose($pipes[2]);
$exitCode = proc_close($process);
Log::info('코어 업데이트: composer install 완료', [
Log::channel('upgrade')->info('코어 업데이트: composer install 완료', [
'working_dir' => $workingDir,
'exit_code' => $exitCode,
'output' => $output,
@@ -1410,7 +1410,7 @@ class CoreUpdateService
$definedRoleIdentifiers,
);
Log::info('코어 역할/권한 동기화 완료', [
Log::channel('upgrade')->info('코어 역할/권한 동기화 완료', [
'stale_permissions_deleted' => $deletedPerms,
'stale_roles_deleted' => $deletedRoles,
]);
@@ -1447,7 +1447,7 @@ class CoreUpdateService
$currentSlugs,
);
Log::info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]);
Log::channel('upgrade')->info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]);
}
/**
@@ -1486,14 +1486,14 @@ class CoreUpdateService
{
$path = config_path('core.php');
if (! File::exists($path)) {
Log::warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵');
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵');
return;
}
$fresh = require $path;
if (! is_array($fresh)) {
Log::warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵');
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵');
return;
}
@@ -1503,13 +1503,13 @@ class CoreUpdateService
try {
$this->syncCoreRolesAndPermissions();
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]);
}
try {
$this->syncCoreMenus();
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1517,7 +1517,7 @@ class CoreUpdateService
(new NotificationDefinitionSeeder())->run();
}
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1525,7 +1525,7 @@ class CoreUpdateService
(new IdentityPolicySeeder())->run();
}
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1533,7 +1533,7 @@ class CoreUpdateService
(new IdentityMessageDefinitionSeeder())->run();
}
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]);
Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]);
}
}
@@ -1584,7 +1584,7 @@ class CoreUpdateService
);
}
Log::warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
Log::channel('upgrade')->warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
}
$upgradesPath = base_path('upgrades');
@@ -1653,7 +1653,7 @@ class CoreUpdateService
foreach ($steps as $version => $step) {
$onStep?->__invoke($version);
Log::info("코어 업그레이드 스텝 실행: {$version}");
Log::channel('upgrade')->info("코어 업그레이드 스텝 실행: {$version}");
$step->run($context->withCurrentStep($version));
}
}
@@ -1715,7 +1715,7 @@ class CoreUpdateService
'--refresh' => 15,
]);
Log::info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]);
Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]);
return $secret;
}
@@ -1726,7 +1726,7 @@ class CoreUpdateService
public function disableMaintenanceMode(): void
{
Artisan::call('up');
Log::info('코어 업데이트: 유지보수 모드 비활성화');
Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 비활성화');
}
/**
@@ -2017,7 +2017,7 @@ class CoreUpdateService
}
if ($truncated) {
Log::warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [
Log::channel('upgrade')->warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [
'collected' => count($snapshot),
'paths' => $paths,
]);
@@ -2167,7 +2167,7 @@ class CoreUpdateService
$report = FilePermissionHelper::chownRecursiveDetailed($path, $owner, $group, respectPreservationMarker: true);
if ($report['changed'] > 0) {
Log::info('코어 업데이트: 소유권 복원', [
Log::channel('upgrade')->info('코어 업데이트: 소유권 복원', [
'target' => $target,
'owner' => $owner,
'group' => $group,
@@ -2226,14 +2226,14 @@ class CoreUpdateService
}
if ($groupWritableChanged > 0) {
Log::info('코어 업데이트: 그룹 쓰기 권한 정상화', [
Log::channel('upgrade')->info('코어 업데이트: 그룹 쓰기 권한 정상화', [
'targets' => $groupWritableTargets,
'changed_entries' => $groupWritableChanged,
]);
}
if ($restoredCount > 0) {
Log::info('코어 업데이트: 소유권 복원 완료', [
Log::channel('upgrade')->info('코어 업데이트: 소유권 복원 완료', [
'restored_entries_total' => $restoredCount,
'targets' => $targets,
]);
@@ -2335,7 +2335,7 @@ class CoreUpdateService
}
if ($changed > 0) {
Log::info('코어 업데이트: 항목별 정확 복원 완료', [
Log::channel('upgrade')->info('코어 업데이트: 항목별 정확 복원 완료', [
'snapshot_items' => count($detailedSnapshot),
'changed_attributes' => $changed,
]);
@@ -2348,7 +2348,7 @@ class CoreUpdateService
'failed' => $failed,
'failed_paths' => $failedPaths,
];
Log::warning('코어 업데이트: 항목별 복원 부분 실패', [
Log::channel('upgrade')->warning('코어 업데이트: 항목별 복원 부분 실패', [
'failed_count' => $failed,
'first_failed' => $failedPaths[0] ?? null,
]);
@@ -2389,7 +2389,7 @@ class CoreUpdateService
File::put($reportPath, $content);
Log::error('코어 업데이트 실패', [
Log::channel('upgrade')->error('코어 업데이트 실패', [
'from' => $fromVersion,
'to' => $toVersion,
'error' => $exception->getMessage(),
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.0-beta.6'),
'version' => env('APP_VERSION', '7.0.0-beta.7'),
/*
|--------------------------------------------------------------------------
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-05-15
### Added
- 코어 업데이트 안내 모달의 신규 텍스트 5건에 대한 일본어 번역 추가 (`sudo` 권장 안내 / 외부 업그레이드 가이드 링크 라벨 등).
## [1.0.0-beta.1] - 2026-05-11
### Added
@@ -1838,7 +1838,12 @@
"manual_update_title": "手動アップデート (ZIP 拡張未インストール環境)",
"manual_update_desc": "サーバーに PHP zip 拡張または unzip コマンドがない場合、GitHub からリリース ZIP を直接ダウンロードしてアップデートできます。",
"manual_update_step1": "# 1. GitHub リリースページから ZIP をダウンロード後、サーバーで圧縮解除",
"manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行"
"manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行",
"update_guide_cmd_comment": "# 管理者権限での実行を推奨します (Linux/macOS)",
"update_guide_cmd": "$ sudo php artisan core:update",
"manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
"upgrade_guide_link_desc": "運用環境別の権限·ユーザーグループ別の詳細な手順はアップグレードガイドを参照してください。",
"upgrade_guide_link_label": "アップグレードガイド全体を表示"
},
"modals": {
"password_confirm_title": "パスワード確認",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
+62
View File
@@ -79,3 +79,65 @@ if (!function_exists('installer_guard_or_410')) {
exit;
}
}
if (!function_exists('installer_finalize_is_completed')) {
/**
* finalize-env.php 전용 멱등 차단 신호.
*
* 본 함수는 `.env` 의 `INSTALLER_COMPLETED=true` 단독으로만 차단을 판정한다.
* `storage/app/g7_installed` 락 파일은 finalize 호출 직전 단계인
* `complete_flag` task 가 먼저 생성하므로 차단 사유에서 제외한다.
*/
function installer_finalize_is_completed(): bool
{
$basePath = installer_resolve_base_path();
$envFile = $basePath . '/.env';
if (!is_file($envFile)) {
return false;
}
$env = @parse_ini_file($envFile, false, INI_SCANNER_RAW);
if (!is_array($env)) {
return false;
}
$flag = strtolower(trim((string) ($env['INSTALLER_COMPLETED'] ?? '')));
$flag = trim($flag, "\"'");
return in_array($flag, ['true', '1', 'yes'], true);
}
}
if (!function_exists('installer_guard_finalize_or_410')) {
/**
* finalize-env.php 전용 진입 가드.
*
* 차단 조건: `.env` 의 `INSTALLER_COMPLETED=true` 단독. `g7_installed` 락 파일
* 존재는 차단 사유가 아니다 — complete_flag task 가 락 파일을 먼저 만든 직후
* 본 엔드포인트가 호출되어 `.env` 머지를 수행해야 하기 때문이다.
*
* RCE 공격 표면이 없는 finalize 전용으로 한정 — 일반 인스톨러 엔드포인트는
* 계속 `installer_guard_or_410()` 을 사용한다.
*/
function installer_guard_finalize_or_410(): void
{
if (!installer_finalize_is_completed()) {
return;
}
if (!headers_sent()) {
http_response_code(410);
header('Content-Type: application/json; charset=utf-8');
header('X-Installer: disabled');
header('Cache-Control: no-store');
}
echo json_encode([
'success' => false,
'message' => 'Finalize already completed.',
], JSON_UNESCAPED_UNICODE);
exit;
}
}
+58 -7
View File
@@ -22,7 +22,11 @@ require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/installer-runtime.php';
require_once __DIR__ . '/../includes/installer-state.php';
require_once __DIR__ . '/_guard.php';
installer_guard_or_410();
// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다.
// 일반 인스톨러 엔드포인트의 `installer_guard_or_410()` 은 `g7_installed` 락 파일도
// 차단 사유로 삼는데, 그 락 파일은 finalize 호출 직전 단계의 complete_flag task 가
// 먼저 생성하므로 자가 차단 회귀가 발생한다 (이슈 #371).
installer_guard_finalize_or_410();
// ---------------------------------------------------------------------------
// 1. 응답을 즉시 송출하여 브라우저가 완료 UI 를 유지할 수 있게 한다.
@@ -68,7 +72,7 @@ try {
$envBase = generateEnvContent();
if ($envBase === null) {
error_log('[finalize-env] generateEnvContent() returned null — .env.example missing');
addLog('[finalize-env] generateEnvContent() returned null — .env.example missing');
return;
}
@@ -79,25 +83,72 @@ try {
// ServeCommand 의 mtime watcher 가 다중 재시작을 일으키지 않는다.
// atomic rename 대신 file_put_contents 를 사용하여 부모 디렉토리(프로젝트 루트)
// 쓰기 권한 요구를 회피 — 기존 인스톨러 권한 안내 (chmod 664 .env + chgrp) 만으로 충분.
//
// 권한 정책 (#371): finalize-env.php 는 chmod 0600 시도를 수행하지 않는다.
// PHP-FPM(www-data) 가 .env 소유자(예: jjh) 와 다른 자체 구축 환경에서는 POSIX 상
// chmod 가 거부되며, 또한 인스톨러가 임의로 0600 으로 깎으면 운영자가 의도해서
// 설정한 0664/0640 권한이 손상된다. file_put_contents 는 기존 inode 의 권한을
// 변경하지 않으므로, 인스톨러 안내 단계의 권한 (chgrp www-data + chmod 664) 이
// 그대로 유지된다. 추가 보안 강화는 Step 6 완료 화면 안내 + INSTALL.md 운영
// 가이드에 위임.
if (@file_put_contents($envPath, $envContent, LOCK_EX) === false) {
error_log('[finalize-env] failed to write .env');
$lastError = error_get_last();
addLog(sprintf(
'[finalize-env] file_put_contents FAILED: path=%s, last_error=%s',
$envPath,
$lastError['message'] ?? 'unknown',
));
return;
}
@chmod($envPath, 0600);
// 머지 후 권한·소유자·그룹 상태를 명시 기록 — 운영자 사후 검증용
$envPermsAfter = fileperms($envPath) & 0777;
$envOwnerAfter = @fileowner($envPath);
$envGroupAfter = @filegroup($envPath);
$currentEuid = function_exists('posix_geteuid') ? posix_geteuid() : null;
$currentEuser = ($currentEuid !== null && function_exists('posix_getpwuid'))
? (posix_getpwuid($currentEuid)['name'] ?? (string) $currentEuid)
: 'unknown';
addLog(sprintf(
'[finalize-env] .env 머지 완료 (chmod 시도 안 함, 기존 권한 보존): path=%s, perms=%s, owner_uid=%s, group_gid=%s, process_euser=%s',
$envPath,
decoct($envPermsAfter),
$envOwnerAfter === false ? 'unknown' : (string) $envOwnerAfter,
$envGroupAfter === false ? 'unknown' : (string) $envGroupAfter,
$currentEuser,
));
// runtime.php 삭제 — Provider 가 다음 부팅부터 no-op
deleteInstallerRuntime();
if (! deleteInstallerRuntime()) {
addLog(sprintf(
'[finalize-env] deleteInstallerRuntime FAILED: path=%s, last_error=%s. '
.'runtime.php 잔존 — InstallerRuntimeServiceProvider 폴백 동작 유지되나 평문 자격증명 보안 우려.',
INSTALLER_RUNTIME_PATH,
(error_get_last()['message'] ?? 'unknown'),
));
} else {
addLog('[finalize-env] runtime.php 삭제 완료');
}
// state.json 삭제 — setInstallationCompleteSSE 가 본 단계로 위임함
// (finalize 가 generateEnvContent() 호출 시 state.config 가 필요했기 때문)
if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) {
$stateFilePath = BASE_PATH . '/storage/installer-state.json';
if (is_file($stateFilePath)) {
@unlink($stateFilePath);
if (@unlink($stateFilePath) === false) {
addLog(sprintf(
'[finalize-env] state.json unlink FAILED: path=%s, last_error=%s',
$stateFilePath,
(error_get_last()['message'] ?? 'unknown'),
));
} else {
addLog('[finalize-env] state.json 삭제 완료');
}
}
}
} catch (\Throwable $e) {
// 예외 시 runtime.php 보존 → Provider 가 계속 config 주입 → 앱 정상 동작.
error_log('[finalize-env] unexpected exception: ' . $e->getMessage());
addLog('[finalize-env] unexpected exception: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
}
+75 -15
View File
@@ -42,7 +42,9 @@ function getInstallationState(): array
// 파일 읽기 권한 체크
if (!is_readable(STATE_PATH)) {
error_log("State file is not readable: " . STATE_PATH);
$msg = "[installer-state] State file is not readable: " . STATE_PATH;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -51,7 +53,9 @@ function getInstallationState(): array
// 파일 읽기 실패 시 기본 상태 반환
if ($content === false) {
error_log("Failed to read state file: " . STATE_PATH);
$msg = "[installer-state] Failed to read state file: " . STATE_PATH;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -61,7 +65,9 @@ function getInstallationState(): array
if (json_last_error() !== JSON_ERROR_NONE) {
$contentLen = strlen($content);
$preview = substr($content, 0, 200);
error_log("Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview);
$msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -79,13 +85,17 @@ function saveInstallationState(array $state): bool
// storage 디렉토리 존재 여부 확인 (생성하지 않음)
$storageDir = BASE_PATH . '/storage';
if (!is_dir($storageDir)) {
error_log("Storage directory does not exist: {$storageDir}");
$msg = "[installer-state] Storage directory does not exist: {$storageDir}";
error_log($msg);
addLog($msg);
return false;
}
// 디렉토리 쓰기 권한 확인
if (!is_writable($storageDir)) {
error_log("Storage directory is not writable: {$storageDir}");
$msg = "[installer-state] Storage directory is not writable: {$storageDir}";
error_log($msg);
addLog($msg);
return false;
}
@@ -96,7 +106,9 @@ function saveInstallationState(array $state): bool
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($content === false) {
error_log("Failed to encode state as JSON: " . json_last_error_msg());
$msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg();
error_log($msg);
addLog($msg);
return false;
}
@@ -107,7 +119,9 @@ function saveInstallationState(array $state): bool
$result = @file_put_contents($tmpPath, $content, LOCK_EX);
if ($result === false || $result !== strlen($content)) {
error_log("Failed to write state tmp file: " . $tmpPath);
$msg = "[installer-state] Failed to write state tmp file: " . $tmpPath;
error_log($msg);
addLog($msg);
@unlink($tmpPath);
return false;
}
@@ -115,7 +129,9 @@ function saveInstallationState(array $state): bool
@chmod($tmpPath, 0664);
if (!@rename($tmpPath, STATE_PATH)) {
error_log("Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH);
$msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH;
error_log($msg);
addLog($msg);
@unlink($tmpPath);
return false;
}
@@ -379,13 +395,17 @@ function addLogBatch(array $messages): bool
if (!is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
error_log("Failed to create log directory: {$logDir}");
$msg = "[addLogBatch] Failed to create log directory: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행
return false;
}
}
if (!is_writable($logDir)) {
error_log("Log directory is not writable: {$logDir}");
$msg = "[addLogBatch] Log directory is not writable: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -412,7 +432,9 @@ function addLogBatch(array $messages): bool
$handle = @fopen($logFile, 'a');
if ($handle === false) {
error_log("Failed to open log file: {$logFile}");
$msg = "[addLogBatch] Failed to open log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
flock($handle, LOCK_EX);
@@ -425,6 +447,36 @@ function addLogBatch(array $messages): bool
}
function addLog(string $message): bool
{
// 무한 재귀 가드 — addLog 내부 폴백 경로에서 자기 자신을 다시 호출하면 안 된다.
// installer-state.php / session.php / finalize-env.php 등에서 error_log 와 addLog 를
// 병행 호출하는 경우, addLog 내부의 인프라 실패 폴백 (storage 쓰기 실패 등) 시
// 호출자가 다시 addLog 를 부르면 같은 인프라가 다시 실패하여 무한 재귀가 발생할 수 있다.
static $inAddLog = false;
if ($inAddLog) {
// 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료
error_log("[addLog reentrant] " . $message);
return false;
}
$inAddLog = true;
try {
return _addLogInternal($message);
} finally {
$inAddLog = false;
}
}
/**
* addLog 의 실제 구현 — 무한 재귀 가드를 거친 후 호출된다.
*
* 본 함수 안에서는 절대 addLog() 를 호출하지 말 것 (무한 재귀 위험).
* 내부 폴백은 error_log() 만 사용.
*
* @param string $message 기록할 메시지
* @return bool 성공 여부
*/
function _addLogInternal(string $message): bool
{
$logDir = BASE_PATH . '/storage/logs';
$logFile = $logDir . '/installation.log';
@@ -433,14 +485,18 @@ function addLog(string $message): bool
if (!is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
error_log("Failed to create log directory: {$logDir} (storage 권한 확인 필요)");
$msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)";
error_log($msg);
addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안)
return false;
}
}
// 로그 디렉토리 쓰기 권한 확인
if (!is_writable($logDir)) {
error_log("Log directory is not writable: {$logDir}");
$msg = "[addLog] Log directory is not writable: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -471,7 +527,9 @@ function addLog(string $message): bool
// 파일 핸들 열기 (append 모드)
$handle = @fopen($logFile, 'a');
if ($handle === false) {
error_log("Failed to open log file: {$logFile}");
$msg = "[addLog] Failed to open log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -492,7 +550,9 @@ function addLog(string $message): bool
clearstatcache(true, $logFile);
if ($result === false) {
error_log("Failed to write log file: {$logFile}");
$msg = "[addLog] Failed to write log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
+19 -4
View File
@@ -4,8 +4,15 @@
* 세션 관리 및 CSRF 토큰 처리
*
* 그누보드7 웹 인스톨러의 세션 관리와 CSRF 보호 기능을 제공합니다.
*
* installer-state.php 를 require 하여 addLog() 함수를 사용 가능하게 한다.
* index.php 및 일부 API 엔드포인트가 본 파일을 installer-state.php 보다 먼저
* require 하기 때문에, 본 파일 안에서 addLog 호출이 필요한 경우를 위해
* 명시적으로 의존성을 선언한다 (이슈 #371).
*/
require_once __DIR__ . '/installer-state.php';
// 세션이 이미 시작되지 않았으면 시작
if (session_status() === PHP_SESSION_NONE) {
// 세션 설정
@@ -39,7 +46,9 @@ if (session_status() === PHP_SESSION_NONE) {
if (json_last_error() === JSON_ERROR_NONE && isset($state['g7_locale'])) {
$detectedLang = $state['g7_locale'];
error_log("[세션 초기화] state.json에서 언어 복원: {$detectedLang}");
$msg = "[세션 초기화] state.json에서 언어 복원: {$detectedLang}";
error_log($msg);
addLog($msg);
}
}
}
@@ -51,15 +60,21 @@ if (session_status() === PHP_SESSION_NONE) {
if ($browserLang !== null) {
$detectedLang = $browserLang;
error_log("[세션 초기화] 브라우저에서 언어 감지: {$browserLang}");
$msg = "[세션 초기화] 브라우저에서 언어 감지: {$browserLang}";
error_log($msg);
addLog($msg);
}
}
// 3. 최종 기본값
$_SESSION['g7_locale'] = $detectedLang ?? 'ko';
error_log("[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}");
$msg = "[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}";
error_log($msg);
addLog($msg);
} else {
error_log("[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}");
$msg = "[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}";
error_log($msg);
addLog($msg);
}
}
+7
View File
@@ -138,6 +138,13 @@ return [
'recommendation_env_permission' => 'Set .env file permissions to 644 (chmod 644 .env)',
'recommendation_https' => 'Use HTTPS in production environment',
// .env permission hardening hint (Step 5 completion screen, shown when not 0600)
'env_hardening_hint_title' => '.env Permission Hardening (Optional)',
'env_hardening_hint_current_status' => 'Current .env permission is %s (owner: %s). The most secure permission is 0600 (owner read/write only). To apply additional hardening, run the appropriate command below with sudo based on your environment.',
'env_hardening_hint_option_strict' => 'CLI user = web server user (single-user environment)',
'env_hardening_hint_option_group' => 'CLI user ≠ web server user (self-hosted server, etc. — group-readable compromise)',
'env_hardening_hint_note' => 'Verify your environment before running. Applying the wrong permission may prevent the web server from reading .env and cause the site to stop responding.',
// Step 5: Complete
'installation_info' => 'Installation Information',
'installation_complete_message' => 'Gnuboard7 has been successfully installed!',
+7
View File
@@ -138,6 +138,13 @@ return [
'recommendation_env_permission' => '.env 파일 권한을 644로 설정하세요 (chmod 644 .env)',
'recommendation_https' => '프로덕션 환경에서는 반드시 HTTPS를 사용하세요',
// .env 권한 보안 강화 안내 (Step 5 완료 화면, 0600 미적용 시 조건부 노출)
'env_hardening_hint_title' => '.env 권한 보안 강화 (선택)',
'env_hardening_hint_current_status' => '현재 .env 권한은 %s 입니다 (소유자: %s). 가장 안전한 권한은 0600 (소유자만 읽기·쓰기) 이며, 다음 두 명령 중 환경에 맞는 쪽을 sudo 로 실행하여 추가 보안 강화를 적용할 수 있습니다.',
'env_hardening_hint_option_strict' => '명령줄 사용자 = 웹 서버 사용자 (단일 사용자 환경)',
'env_hardening_hint_option_group' => '명령줄 사용자 ≠ 웹 서버 사용자 (자체 구축 서버 등 — 그룹 읽기 허용 절충안)',
'env_hardening_hint_note' => '명령 실행 전 환경을 확인하세요. 잘못된 권한 적용 시 웹 서버가 .env 를 읽지 못해 사이트가 응답하지 않을 수 있습니다.',
// Step 5: 완료
'installation_info' => '설치 정보',
'installation_complete_message' => '그누보드7이 성공적으로 설치되었습니다!',
+49
View File
@@ -67,6 +67,55 @@ $config = $state['config'] ?? $_SESSION['install_config'] ?? [];
. '<strong>' . htmlspecialchars($coreVersion) . '</strong>'
. '</p>';
// .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371)
// finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한
// (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을
// 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출.
//
// OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작.
// Windows 만 명령이 무의미하므로 Windows 만 비노출.
$envPath = BASE_PATH . '/.env';
if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) {
$envPerms = fileperms($envPath) & 0777;
if ($envPerms !== 0600) {
$envOwnerUid = @fileowner($envPath);
$envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid'))
? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid)
: 'owner';
$webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid')
? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data')
: 'www-data';
$hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath));
$hardeningCommandDifferentOwner = sprintf(
'sudo chgrp %s %s && sudo chmod 640 %s',
htmlspecialchars($webGroupName),
htmlspecialchars($envPath),
htmlspecialchars($envPath)
);
$completionExtra .= '<div class="env-hardening-hint">'
. '<p class="env-hardening-hint-title">' . htmlspecialchars(lang('env_hardening_hint_title')) . '</p>'
. '<p class="env-hardening-hint-status">'
. sprintf(
htmlspecialchars(lang('env_hardening_hint_current_status')),
decoct($envPerms),
htmlspecialchars($envOwnerName)
)
. '</p>'
. '<p class="env-hardening-hint-option">'
. htmlspecialchars(lang('env_hardening_hint_option_strict'))
. '</p>'
. '<div class="code-box"><pre>' . $hardeningCommandSameOwner . '</pre></div>'
. '<p class="env-hardening-hint-option">'
. htmlspecialchars(lang('env_hardening_hint_option_group'))
. '</p>'
. '<div class="code-box"><pre>' . $hardeningCommandDifferentOwner . '</pre></div>'
. '<p class="env-hardening-hint-note">' . htmlspecialchars(lang('env_hardening_hint_note')) . '</p>'
. '</div>';
}
}
echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra);
?>
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.7] - 2026-05-15
### Changed
- 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 보강합니다. (#30 @yks118 님께서 건의해주셨습니다.)
- 위 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가하여 코어 `INSTALL.md` 의 "업그레이드" 섹션으로 연결합니다.
## [1.0.0-beta.6] - 2026-05-14
### Fixed
@@ -1834,7 +1834,12 @@
"manual_update_title": "Manual Update (No ZIP Extension)",
"manual_update_desc": "If PHP zip extension or unzip command is not available on your server, you can download the release ZIP from GitHub and update manually.",
"manual_update_step1": "# 1. Download ZIP from GitHub releases page and extract on server",
"manual_update_step2": "# 2. Run update with the extracted path"
"manual_update_step2": "# 2. Run update with the extracted path",
"update_guide_cmd_comment": "# Run with administrator privileges (Linux/macOS)",
"update_guide_cmd": "$ sudo php artisan core:update",
"manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
"upgrade_guide_link_desc": "See the upgrade guide for permission notes and per-version paths.",
"upgrade_guide_link_label": "Open the full upgrade guide"
},
"modals": {
"password_confirm_title": "Password Confirmation",
@@ -1838,7 +1838,12 @@
"manual_update_title": "수동 업데이트 (ZIP 확장 미설치 환경)",
"manual_update_desc": "서버에 PHP zip 확장이나 unzip 명령어가 없는 경우, GitHub에서 릴리스 ZIP을 직접 다운로드하여 업데이트할 수 있습니다.",
"manual_update_step1": "# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제",
"manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행"
"manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행",
"update_guide_cmd_comment": "# 관리자 권한으로 실행 권장 (Linux/macOS)",
"update_guide_cmd": "$ sudo php artisan core:update",
"manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
"upgrade_guide_link_desc": "운영 환경별 권한/사용자군별 자세한 절차는 업그레이드 가이드를 참고하세요.",
"upgrade_guide_link_label": "전체 업그레이드 가이드 보기"
},
"modals": {
"password_confirm_title": "비밀번호 확인",
@@ -38,9 +38,10 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4" },
"props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4 space-y-1" },
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update" }
{ "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.update_guide_cmd_comment" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.update_guide_cmd" }
]
},
{
@@ -49,6 +50,33 @@
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:admin.settings.info.update_guide_note"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "border-t border-gray-200 dark:border-gray-700 pt-4 mt-2" },
"children": [
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-gray-600 dark:text-gray-400 mb-2" },
"text": "$t:admin.settings.info.upgrade_guide_link_desc"
},
{
"type": "basic",
"name": "A",
"props": {
"href": "https://github.com/gnuboard/g7/blob/main/INSTALL.md#%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline"
},
"children": [
{ "type": "basic", "name": "Span", "text": "$t:admin.settings.info.upgrade_guide_link_label" },
{ "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "className": "w-3 h-3" } }
]
}
]
},
{
"type": "basic",
"name": "Div",
@@ -73,7 +101,7 @@
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step1" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step2" },
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update --source=/path/to/extracted" }
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.manual_update_cmd" }
]
}
]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -0,0 +1,545 @@
<?php
namespace Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_0_beta_7\Migrations\FinalizeOrphanedInstallerRuntime;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* beta.7 잔존 인스톨러 runtime.php 자동 finalize 회귀 가드 (이슈 #371).
*
* axis 전수:
* 1. runtime.php 존재 + .env 의 INSTALLER_COMPLETED 부재 + state.json 존재
* → .env 머지 + runtime.php 삭제 + state.json 삭제
* 2. runtime.php 존재 + INSTALLER_COMPLETED 부재 + state.json 부재 (멱등)
* → .env 머지 + runtime.php 삭제 + state.json unlink 미호출
* 3. runtime.php 부재 → no-op
* 4. runtime.php 존재 + .env 의 INSTALLER_COMPLETED=true → 보존 + warn
* 5. runtime.php 존재 + .env 부재 + .env.example 만 존재 → .env.example 기반 신규 생성
* 6. runtime.php 형식 불일치 (배열 아님) → runtime.php 보존, skip
* 7. DELETE_INSTALLER_AFTER_COMPLETE 가 false → state.json 보존
*/
class Beta7FinalizeOrphanedInstallerRuntimeTest extends TestCase
{
private string $runtimePath;
private string $envPath;
private string $envExamplePath;
private string $stateJsonPath;
private ?string $originalEnvContent = null;
private bool $originalEnvExisted = false;
private bool $originalStateExisted = false;
private ?string $originalStateContent = null;
private bool $originalRuntimeDirExisted = false;
/**
* @var array<string, string|null> axis 12 가 putenv / $_ENV / $_SERVER 를
* 덮어쓰므로 후속 테스트 / 다음 테스트 파일의 DB 자격증명이 오염되지
* 않도록 tearDown 에서 복원할 ENV 키별 원본 스냅샷.
*/
private array $originalEnvSnapshot = [];
private const ENV_KEYS_FOR_SNAPSHOT = [
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_PREFIX', 'APP_KEY',
];
protected function setUp(): void
{
parent::setUp();
$this->runtimePath = base_path('storage/installer/runtime.php');
$this->envPath = base_path('.env');
$this->envExamplePath = base_path('.env.example');
$this->stateJsonPath = base_path('storage/installer-state.json');
// 실제 운영 .env 와 state.json 보존
if (is_file($this->envPath)) {
$this->originalEnvExisted = true;
$this->originalEnvContent = file_get_contents($this->envPath);
}
if (is_file($this->stateJsonPath)) {
$this->originalStateExisted = true;
$this->originalStateContent = file_get_contents($this->stateJsonPath);
}
$this->originalRuntimeDirExisted = is_dir(dirname($this->runtimePath));
// process ENV 원본 스냅샷 — axis 12 가 putenv() 로 자격증명을 갱신하므로
// tearDown 에서 정확히 원본 상태로 복원 (다음 테스트 / 파일 오염 차단)
foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) {
$value = getenv($key);
$this->originalEnvSnapshot[$key] = $value === false ? null : $value;
}
// installer-state.php 등의 DELETE_INSTALLER_AFTER_COMPLETE 상수가 다른 테스트로
// 이미 정의되어 있을 수 있다. 본 테스트는 그 상수를 신뢰하지 않고 명시 시나리오마다
// 자체 시그널을 사용하므로 추가 정의 불필요.
// DataMigration 은 autoload 대상이 아님 — 동적 require
require_once base_path('upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php');
$this->cleanupArtifacts();
}
protected function tearDown(): void
{
$this->cleanupArtifacts();
// .env 복원
if ($this->originalEnvExisted && $this->originalEnvContent !== null) {
file_put_contents($this->envPath, $this->originalEnvContent);
} elseif (! $this->originalEnvExisted && is_file($this->envPath)) {
@unlink($this->envPath);
}
// state.json 복원
if ($this->originalStateExisted && $this->originalStateContent !== null) {
file_put_contents($this->stateJsonPath, $this->originalStateContent);
} elseif (! $this->originalStateExisted && is_file($this->stateJsonPath)) {
@unlink($this->stateJsonPath);
}
// 본 테스트가 생성한 runtime 디렉토리 정리 (원래 없었을 때만)
if (! $this->originalRuntimeDirExisted && is_dir(dirname($this->runtimePath))) {
@rmdir(dirname($this->runtimePath));
}
// process ENV 복원 — axis 12 가 putenv() 로 자격증명을 갱신했어도
// 다음 테스트 / 파일이 정확히 setUp 직전 상태에서 시작하도록 보장.
foreach ($this->originalEnvSnapshot as $key => $value) {
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
parent::tearDown();
}
private function cleanupArtifacts(): void
{
if (is_file($this->runtimePath)) {
@unlink($this->runtimePath);
}
}
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.0-beta.6', '7.0.0-beta.7', '7.0.0-beta.7');
}
private function writeRuntime(array $data): void
{
$dir = dirname($this->runtimePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$php = "<?php\n\nreturn ".var_export($data, true).";\n";
file_put_contents($this->runtimePath, $php);
}
private function sampleRuntime(): array
{
return [
'db' => [
'write' => [
'host' => '127.0.0.1',
'port' => '3306',
'database' => 'g7_test_db',
'username' => 'test_user',
'password' => 'test_pass_42',
],
'prefix' => 'g7_',
],
'app' => [
'key' => 'base64:'.base64_encode(random_bytes(32)),
],
'created_at' => date('c'),
];
}
private function writeEnv(string $content): void
{
file_put_contents($this->envPath, $content);
}
// -----------------------------------------------------------------------
// axis 1: 정상 finalize — .env 머지 + runtime 삭제 + state.json 삭제
// -----------------------------------------------------------------------
public function test_finalizes_orphaned_runtime_and_merges_env(): void
{
$runtime = $this->sampleRuntime();
$this->writeRuntime($runtime);
$this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\nDB_WRITE_PASSWORD=\nAPP_KEY=\n");
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertFileDoesNotExist($this->runtimePath, 'runtime.php 가 삭제되어야 함');
$envContent = file_get_contents($this->envPath);
$this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent);
$this->assertStringContainsString('DB_WRITE_USERNAME=test_user', $envContent);
$this->assertStringContainsString('DB_WRITE_PASSWORD="test_pass_42"', $envContent);
$this->assertStringContainsString('APP_KEY=base64:', $envContent);
$this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
}
// -----------------------------------------------------------------------
// axis 2: state.json 부재 시에도 멱등 (.env 머지 + runtime 삭제)
// -----------------------------------------------------------------------
public function test_finalizes_when_state_json_missing(): void
{
$this->writeRuntime($this->sampleRuntime());
$this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n");
if (is_file($this->stateJsonPath)) {
@unlink($this->stateJsonPath);
}
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertFileDoesNotExist($this->runtimePath);
$envContent = file_get_contents($this->envPath);
$this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
// state.json 은 부재 상태 유지 — 비정상 종료 아님
$this->assertFileDoesNotExist($this->stateJsonPath);
}
// -----------------------------------------------------------------------
// axis 3: runtime.php 부재 → no-op
// -----------------------------------------------------------------------
public function test_noop_when_runtime_absent(): void
{
if (is_file($this->runtimePath)) {
@unlink($this->runtimePath);
}
$originalEnv = "APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n";
$this->writeEnv($originalEnv);
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
// .env 가 변경되지 않아야 함 — 멱등 no-op
$this->assertSame($originalEnv, file_get_contents($this->envPath));
}
// -----------------------------------------------------------------------
// axis 4: .env 의 INSTALLER_COMPLETED=true + runtime 잔존 → 보존 + warn
// -----------------------------------------------------------------------
public function test_preserves_runtime_when_env_marked_completed(): void
{
$runtime = $this->sampleRuntime();
$this->writeRuntime($runtime);
$envContent = "APP_ENV=production\nINSTALLER_COMPLETED=true\n";
$this->writeEnv($envContent);
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
// runtime 보존 — 자동 삭제 금지
$this->assertFileExists($this->runtimePath);
// .env 변경 없음
$this->assertSame($envContent, file_get_contents($this->envPath));
}
// -----------------------------------------------------------------------
// axis 5: .env 부재 + .env.example 만 존재 → .env.example 기반 신규 생성
// -----------------------------------------------------------------------
public function test_creates_env_from_example_when_env_absent(): void
{
if (! is_file($this->envExamplePath)) {
$this->markTestSkipped('.env.example 이 프로젝트 루트에 없음 — 테스트 건너뜀');
}
$this->writeRuntime($this->sampleRuntime());
if (is_file($this->envPath)) {
@unlink($this->envPath);
}
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertFileExists($this->envPath, '.env 가 .env.example 기반으로 생성되어야 함');
$envContent = file_get_contents($this->envPath);
$this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent);
$this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
$this->assertFileDoesNotExist($this->runtimePath);
}
// -----------------------------------------------------------------------
// axis 6: runtime.php 가 배열 아닌 형식 → 보존, skip
// -----------------------------------------------------------------------
public function test_preserves_runtime_when_format_invalid(): void
{
$dir = dirname($this->runtimePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 배열을 반환하지 않는 PHP — null 반환
file_put_contents($this->runtimePath, "<?php\n\nreturn null;\n");
$originalEnv = "APP_ENV=production\n";
$this->writeEnv($originalEnv);
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertFileExists($this->runtimePath, '형식 불일치 runtime.php 는 보존되어야 함');
$this->assertSame($originalEnv, file_get_contents($this->envPath));
}
// -----------------------------------------------------------------------
// axis 7: .env 의 truthy 변형 ("1", quoted "true") 도 보존 트리거
// -----------------------------------------------------------------------
public function test_preserves_runtime_for_truthy_variants(): void
{
$runtime = $this->sampleRuntime();
$variants = [
'INSTALLER_COMPLETED=1',
'INSTALLER_COMPLETED="true"',
'INSTALLER_COMPLETED=yes',
];
foreach ($variants as $line) {
$this->writeRuntime($runtime);
$envContent = "APP_ENV=production\n{$line}\n";
$this->writeEnv($envContent);
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertFileExists(
$this->runtimePath,
sprintf('변형 %s 에서 runtime.php 가 보존되어야 함', $line)
);
$this->assertSame(
$envContent,
file_get_contents($this->envPath),
sprintf('변형 %s 에서 .env 가 변경되지 않아야 함', $line)
);
// 다음 반복을 위한 정리
@unlink($this->runtimePath);
}
}
// -----------------------------------------------------------------------
// axis 8: 기존 .env 의 권한/그룹 보존 — CLI 사용자와 PHP-FPM 사용자가 다른 환경
// (자체 구축 서버: jjh:www-data 0640)
// -----------------------------------------------------------------------
public function test_preserves_env_mode_when_env_existed(): void
{
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원 — Linux 환경에서만 검증');
}
$this->writeRuntime($this->sampleRuntime());
$this->writeEnv("APP_ENV=production\nAPP_KEY=\n");
// 자체 구축 서버 시뮬레이션 — 그룹 읽기 허용 (PHP-FPM 이 그룹 멤버로 읽음)
chmod($this->envPath, 0640);
$beforeMode = fileperms($this->envPath) & 0777;
$this->assertSame(0640, $beforeMode, 'precondition: .env 권한 0640');
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$afterMode = fileperms($this->envPath) & 0777;
$this->assertSame(
$beforeMode,
$afterMode,
sprintf('기존 .env 권한(%s) 이 머지 후에도 보존되어야 함 — PHP-FPM 이 계속 읽을 수 있도록', decoct($beforeMode))
);
$this->assertFileDoesNotExist($this->runtimePath);
}
// -----------------------------------------------------------------------
// axis 9: 0600 으로 잠긴 .env 도 그대로 보존 — 본 회귀 (#371) 의 원인은 우리가
// 0600 으로 강제 다운그레이드 한 것이었음. 운영자가 의도해서 0600 을 둔
// 경우 (단일 사용자 환경) 도 같은 정책 — 기존 권한이 SSoT
// -----------------------------------------------------------------------
public function test_preserves_strict_env_mode_unchanged(): void
{
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
}
$this->writeRuntime($this->sampleRuntime());
$this->writeEnv("APP_ENV=production\n");
chmod($this->envPath, 0600);
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$this->assertSame(
0600,
fileperms($this->envPath) & 0777,
'운영자가 의도한 0600 권한도 그대로 보존'
);
}
// -----------------------------------------------------------------------
// axis 10: .env 가 새로 생성될 때 default 권한이 0640 — 0600 으로 강제 금지
// (#371 회귀 가드 — PHP-FPM 사용자가 그룹으로만 묶인 환경 보호)
// -----------------------------------------------------------------------
public function test_new_env_uses_group_readable_default_permission(): void
{
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
}
if (! is_file($this->envExamplePath)) {
$this->markTestSkipped('.env.example 부재');
}
$this->writeRuntime($this->sampleRuntime());
if (is_file($this->envPath)) {
@unlink($this->envPath);
}
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$mode = fileperms($this->envPath) & 0777;
$this->assertSame(
0640,
$mode,
sprintf(
'.env 신규 생성 시 default 권한은 0640 이어야 함 (PHP-FPM 그룹 읽기 허용). 실제: %s',
decoct($mode)
)
);
}
// -----------------------------------------------------------------------
// axis 11: bootstrap/cache/ 의 그룹을 신규 .env 에 계승 — 자체 구축 서버의
// www-data 그룹 자동 식별. 단일 사용자 환경(카페24)에서는 동일 그룹이
// 유지되므로 회귀 없음
// -----------------------------------------------------------------------
public function test_new_env_inherits_group_from_bootstrap_cache(): void
{
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
}
if (! is_file($this->envExamplePath)) {
$this->markTestSkipped('.env.example 부재');
}
$bootstrapCache = base_path('bootstrap/cache');
if (! is_dir($bootstrapCache)) {
$this->markTestSkipped('bootstrap/cache 디렉토리 부재');
}
$expectedGid = filegroup($bootstrapCache);
if ($expectedGid === false) {
$this->markTestSkipped('bootstrap/cache filegroup 조회 실패');
}
$this->writeRuntime($this->sampleRuntime());
if (is_file($this->envPath)) {
@unlink($this->envPath);
}
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
$actualGid = filegroup($this->envPath);
// chgrp 가 실패할 수 있는 환경(권한 부족)에서는 그룹이 호출 사용자의 default
// 그룹으로 떨어질 수 있다. 실패해도 머지 자체는 성공해야 하므로 .env 존재만 단언.
$this->assertFileExists($this->envPath);
// 같은 사용자가 bootstrap/cache 의 그룹 멤버인 경우 chgrp 가 성공해 일치해야 함.
// 그렇지 않으면 운영자 수동 보정 안내가 로그에 남는 것까지가 본 axis 의 기대.
if ($actualGid !== $expectedGid) {
$this->markTestSkipped(sprintf(
'chgrp 실패 환경 (실행자가 그룹 %d 멤버 아님) — silent fallback 으로 통과',
$expectedGid
));
}
$this->assertSame($expectedGid, $actualGid, 'bootstrap/cache 그룹이 .env 에 계승되어야 함');
}
// -----------------------------------------------------------------------
// axis 12: 머지 후 부모 프로세스의 process ENV (getenv() / $_ENV / $_SERVER)
// 도 신규 자격증명으로 갱신되어야 함.
//
// 배경 (이슈 #371 후속 회귀):
// beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root,
// DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅
// 시점에 Dotenv 가 stale .env 의 ENV (DB_WRITE_USERNAME=root) 를 process ENV
// 에 적재. 마이그레이션이 .env 머지 + runtime.php 삭제 를 수행해도 process ENV
// 는 그대로. 이후 번들 일괄 업데이트 spawn 자식 (BundledExtensionUpdatePrompt 의
// proc_open 5번째 인자 $env = array_merge(getenv(), $_ENV)) 이 부모 stale ENV
// 를 그대로 상속받고, Dotenv::createImmutable() 가 이미 채워진 ENV 를 덮어쓰지
// 않으므로 자식이 stale root/패스워드YES 로 DB 연결 시도 → Access denied.
//
// 본 axis 는 마이그레이션 종료 시점에 getenv()/$_ENV/$_SERVER 가 머지된 신규
// 자격증명을 반영하는지 검증한다. 한시적 보정 — beta.8 이후 환경에서는 .env 가
// 이미 정상이므로 트리거 안 됨.
// -----------------------------------------------------------------------
public function test_refreshes_process_env_with_merged_credentials(): void
{
// .env.example 잔존 상태 시뮬레이션 (DB_WRITE_USERNAME=root, DB_WRITE_PASSWORD=)
$this->writeEnv(
"APP_ENV=production\n"
."DB_WRITE_USERNAME=root\n"
."DB_WRITE_PASSWORD=\n"
."DB_WRITE_DATABASE=laravel\n"
."DB_READ_USERNAME=root\n"
."DB_READ_PASSWORD=\n"
."DB_READ_DATABASE=laravel\n"
);
// 부모 프로세스 ENV 가 stale .env 값으로 채워진 상태 시뮬레이션
putenv('DB_WRITE_USERNAME=root');
putenv('DB_WRITE_PASSWORD=');
putenv('DB_WRITE_DATABASE=laravel');
putenv('DB_READ_USERNAME=root');
putenv('DB_READ_PASSWORD=');
putenv('DB_READ_DATABASE=laravel');
$_ENV['DB_WRITE_USERNAME'] = 'root';
$_ENV['DB_WRITE_PASSWORD'] = '';
$_ENV['DB_WRITE_DATABASE'] = 'laravel';
$_ENV['DB_READ_USERNAME'] = 'root';
$_ENV['DB_READ_PASSWORD'] = '';
$_ENV['DB_READ_DATABASE'] = 'laravel';
$this->writeRuntime($this->sampleRuntime());
(new FinalizeOrphanedInstallerRuntime)->run($this->context());
// 디스크 .env 머지는 기존 axis 1 에서 검증 — 본 axis 는 process ENV 검증
$this->assertSame(
'test_user',
getenv('DB_WRITE_USERNAME'),
'getenv(DB_WRITE_USERNAME) 이 runtime 의 신규 자격증명으로 갱신되어야 함'
);
$this->assertSame(
'test_pass_42',
getenv('DB_WRITE_PASSWORD'),
'getenv(DB_WRITE_PASSWORD) 이 runtime 의 신규 비밀번호로 갱신되어야 함'
);
$this->assertSame(
'g7_test_db',
getenv('DB_WRITE_DATABASE'),
'getenv(DB_WRITE_DATABASE) 이 runtime 의 신규 DB 이름으로 갱신되어야 함'
);
$this->assertSame(
'test_user',
$_ENV['DB_WRITE_USERNAME'] ?? null,
'$_ENV[DB_WRITE_USERNAME] 이 갱신되어야 함 — proc_open ENV 합집합 전파용'
);
$this->assertSame(
'test_pass_42',
$_ENV['DB_WRITE_PASSWORD'] ?? null,
'$_ENV[DB_WRITE_PASSWORD] 도 함께 갱신'
);
// READ 도 동일 정책 — runtime.read 미지정 시 write 값으로 동기화
$this->assertSame('test_user', getenv('DB_READ_USERNAME'));
$this->assertSame('test_pass_42', getenv('DB_READ_PASSWORD'));
}
}
+24
View File
@@ -71,6 +71,9 @@ class UpgradeStepsTest extends TestCase
'2.0.0' => $step_2_0,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -111,6 +114,9 @@ class UpgradeStepsTest extends TestCase
'1.2.0' => $step_1_2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -139,6 +145,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $callableStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -166,6 +175,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $failingStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -239,6 +251,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $otherStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -270,6 +285,9 @@ class UpgradeStepsTest extends TestCase
'1.0.0-beta.2' => $step_beta2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -307,6 +325,9 @@ class UpgradeStepsTest extends TestCase
'1.2.0' => $step_1_2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -411,6 +432,9 @@ class UpgradeStepsTest extends TestCase
'7.0.0-beta.2' => $step_beta2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
// ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
$module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
$module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
+69 -2
View File
@@ -57,8 +57,14 @@ class InstallerGuardTest extends TestCase
private function cleanup(): void
{
@unlink(BASE_PATH . '/storage/app/g7_installed');
@unlink(BASE_PATH . '/.env');
$lock = BASE_PATH . '/storage/app/g7_installed';
if (is_file($lock)) {
@unlink($lock);
}
$env = BASE_PATH . '/.env';
if (is_file($env)) {
@unlink($env);
}
}
public function test_no_signals_means_not_completed(): void
@@ -115,4 +121,65 @@ class InstallerGuardTest extends TestCase
$this->assertTrue(installer_is_completed());
}
// ------------------------------------------------------------------------
// finalize 전용 가드 — installer_finalize_is_completed()
//
// 일반 가드와 달리 g7_installed 락 파일은 차단 사유에서 제외되어야 한다
// (complete_flag task 가 락 파일을 먼저 생성한 직후 finalize 가 호출되는
// 설계상의 호출 순서 때문 — 이슈 #371 자가 차단 회귀 가드).
// ------------------------------------------------------------------------
public function test_finalize_guard_passes_with_lock_only(): void
{
// 정상 1회차 finalize 시나리오: 락 파일은 존재하나 .env 머지 아직 안 됨
file_put_contents(BASE_PATH . '/storage/app/g7_installed', '');
$this->assertFalse(
installer_finalize_is_completed(),
'g7_installed 락 파일 단독 존재 시 finalize 차단되면 자가 차단 회귀 (이슈 #371)'
);
}
public function test_finalize_guard_passes_with_no_env_file(): void
{
// .env 자체가 없는 신규 설치 시나리오
$this->assertFalse(installer_finalize_is_completed());
}
public function test_finalize_guard_blocks_when_env_completed_true(): void
{
// 이미 finalize 된 상태 — 멱등 차단
file_put_contents(BASE_PATH . '/.env', "APP_ENV=local\nINSTALLER_COMPLETED=true\n");
$this->assertTrue(installer_finalize_is_completed());
}
public function test_finalize_guard_blocks_with_lock_and_env_completed(): void
{
// 락 + .env true 동시 존재 → 차단 (멱등)
file_put_contents(BASE_PATH . '/storage/app/g7_installed', '');
file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=true' . "\n");
$this->assertTrue(installer_finalize_is_completed());
}
public function test_finalize_guard_passes_when_env_completed_false(): void
{
file_put_contents(BASE_PATH . '/.env', "INSTALLER_COMPLETED=false\n");
$this->assertFalse(installer_finalize_is_completed());
}
public function test_finalize_guard_accepts_quoted_and_alt_truthy_values(): void
{
file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED="true"' . "\n");
$this->assertTrue(installer_finalize_is_completed());
file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=1' . "\n");
$this->assertTrue(installer_finalize_is_completed());
file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=yes' . "\n");
$this->assertTrue(installer_finalize_is_completed());
}
}
@@ -73,9 +73,13 @@ class InstallerSecurityHardeningTest extends TestCase
*/
protected function tearDown(): void
{
@unlink(STATE_PATH);
if (is_file(STATE_PATH)) {
@unlink(STATE_PATH);
}
$projectStateFile = dirname(__DIR__, 3) . '/storage/installer-state.json';
@unlink($projectStateFile);
if (is_file($projectStateFile)) {
@unlink($projectStateFile);
}
parent::tearDown();
}
@@ -366,7 +370,7 @@ class InstallerSecurityHardeningTest extends TestCase
'/opt/php/bin/php',
'C:/php/php.exe',
'/nonexistent/path',
// Windows 절대경로 (백슬래시 포함) — 회귀 가드: PO 환경 (Windows 빌트인 서버)
// Windows 절대경로 (백슬래시 포함) — 회귀 가드: Windows 빌트인 서버 환경
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
'C:\\php\\php.exe',
'D:\\xampp\\php\\php.exe',
@@ -20,16 +20,44 @@ class InstallerRuntimeServiceProviderTest extends TestCase
private ?string $originalEnv = null;
private string $envPath;
private ?string $originalEnvContent = null;
private bool $originalEnvExisted = false;
/** @var array<string, string|null> */
private array $originalEnvSnapshot = [];
private const ENV_KEYS_FOR_SNAPSHOT = [
'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
'DB_PREFIX', 'APP_KEY',
];
protected function setUp(): void
{
parent::setUp();
$this->runtimePath = base_path('storage/installer/runtime.php');
$this->envPath = base_path('.env');
// 테스트 시작 시 runtime.php 정리 (이전 테스트의 잔재 제거)
if (is_file($this->runtimePath)) {
@unlink($this->runtimePath);
}
// .env 원본 보존
if (is_file($this->envPath)) {
$this->originalEnvExisted = true;
$this->originalEnvContent = file_get_contents($this->envPath);
}
// process ENV 스냅샷 (stale ENV 보정 테스트가 갱신하므로 다음 테스트 격리)
foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) {
$value = getenv($key);
$this->originalEnvSnapshot[$key] = $value === false ? null : $value;
}
}
protected function tearDown(): void
@@ -49,6 +77,25 @@ class InstallerRuntimeServiceProviderTest extends TestCase
$this->originalEnv = null;
}
// .env 복원
if ($this->originalEnvExisted && $this->originalEnvContent !== null) {
file_put_contents($this->envPath, $this->originalEnvContent);
} elseif (! $this->originalEnvExisted && is_file($this->envPath)) {
@unlink($this->envPath);
}
// process ENV 복원
foreach ($this->originalEnvSnapshot as $key => $value) {
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
parent::tearDown();
}
@@ -221,6 +268,161 @@ class InstallerRuntimeServiceProviderTest extends TestCase
$this->assertSame($originalKey, Config::get('app.key'));
}
// -----------------------------------------------------------------------
// beta.7 한시 보정 — beta.4~6 finalize 결함 환경에서 .env 머지 후 spawn 자식의
// stale process ENV (DB_WRITE_USERNAME=root 등) 회귀 차단.
//
// 트리거 조건 (모두 만족 시에만 보정):
// 1. runtime.php 부재 (이미 finalize 완료된 상태)
// 2. .env 존재 + 정합 자격증명 보유 (DB_WRITE_USERNAME 이 root 가 아니고 비어있지 않음)
// 3. process ENV 의 DB_WRITE_USERNAME 이 'root' 또는 빈 값 (stale 시그니처)
// 4. .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호)
//
// 위 4개를 모두 만족하면 .env 의 DB_* / APP_KEY 를 process ENV (getenv/$_ENV/$_SERVER)
// 에 적용. 정상 환경에서는 #3 (stale 시그니처) 가 false 라 트리거 안 됨.
// -----------------------------------------------------------------------
public function test_recovers_stale_process_env_when_disk_env_is_healthy(): void
{
$this->withProductionEnv();
// 디스크 .env 는 정합 자격증명 + INSTALLER_COMPLETED (finalize 완료 신호)
file_put_contents($this->envPath, implode("\n", [
'APP_ENV=production',
'DB_WRITE_USERNAME=g7_user',
'DB_WRITE_PASSWORD="real_pw"',
'DB_WRITE_DATABASE=g7',
'DB_READ_USERNAME=g7_user',
'DB_READ_PASSWORD="real_pw"',
'DB_READ_DATABASE=g7',
'INSTALLER_COMPLETED=true',
'',
]));
// process ENV 는 stale (Dotenv 가 부팅 시 .env.example fallback 을 적재한 상태)
putenv('DB_WRITE_USERNAME=root');
putenv('DB_WRITE_PASSWORD=');
putenv('DB_READ_USERNAME=root');
putenv('DB_READ_PASSWORD=');
$_ENV['DB_WRITE_USERNAME'] = 'root';
$_ENV['DB_WRITE_PASSWORD'] = '';
$_ENV['DB_READ_USERNAME'] = 'root';
$_ENV['DB_READ_PASSWORD'] = '';
// $_SERVER 도 stale — bootstrap/app.php 의 Env::disablePutenv() 환경에서는
// Dotenv 가 $_ENV/$_SERVER 만 채우므로 셋 다 stale 인 실서버 케이스 재현
$_SERVER['DB_WRITE_USERNAME'] = 'root';
$_SERVER['DB_WRITE_PASSWORD'] = '';
$_SERVER['DB_READ_USERNAME'] = 'root';
$_SERVER['DB_READ_PASSWORD'] = '';
$this->assertFileDoesNotExist($this->runtimePath, 'precondition: runtime.php 부재');
$provider = new InstallerRuntimeServiceProvider($this->app);
$provider->register();
$this->assertSame('g7_user', getenv('DB_WRITE_USERNAME'), 'stale root 가 .env 의 정합 값으로 갱신되어야 함');
$this->assertSame('real_pw', getenv('DB_WRITE_PASSWORD'));
$this->assertSame('g7_user', getenv('DB_READ_USERNAME'));
$this->assertSame('real_pw', getenv('DB_READ_PASSWORD'));
$this->assertSame('g7_user', $_ENV['DB_WRITE_USERNAME'] ?? null);
$this->assertSame('g7_user', $_SERVER['DB_WRITE_USERNAME'] ?? null);
}
public function test_no_recovery_when_process_env_already_healthy(): void
{
$this->withProductionEnv();
file_put_contents($this->envPath, implode("\n", [
'DB_WRITE_USERNAME=g7_user',
'DB_WRITE_PASSWORD="real_pw"',
'INSTALLER_COMPLETED=true',
'',
]));
// 정상 환경 시뮬레이션 — process ENV 가 이미 정합
putenv('DB_WRITE_USERNAME=already_correct');
putenv('DB_WRITE_PASSWORD=already_correct_pw');
$_ENV['DB_WRITE_USERNAME'] = 'already_correct';
$_ENV['DB_WRITE_PASSWORD'] = 'already_correct_pw';
$provider = new InstallerRuntimeServiceProvider($this->app);
$provider->register();
// 보정 트리거 안 됨 — 정합 ENV 보존 (beta.7 보정이 .env 의 g7_user 로 덮어쓰면 안 됨)
$this->assertSame('already_correct', getenv('DB_WRITE_USERNAME'), 'stale 시그니처 아니므로 보정 미발동');
$this->assertSame('already_correct_pw', getenv('DB_WRITE_PASSWORD'));
}
public function test_no_recovery_when_disk_env_also_stale(): void
{
$this->withProductionEnv();
// 디스크 .env 도 stale (finalize 실패 분기 시뮬레이션)
file_put_contents($this->envPath, implode("\n", [
'DB_WRITE_USERNAME=root',
'DB_WRITE_PASSWORD=',
'INSTALLER_COMPLETED=true',
'',
]));
putenv('DB_WRITE_USERNAME=root');
putenv('DB_WRITE_PASSWORD=');
$_ENV['DB_WRITE_USERNAME'] = 'root';
$_ENV['DB_WRITE_PASSWORD'] = '';
$provider = new InstallerRuntimeServiceProvider($this->app);
$provider->register();
// .env 도 stale 이므로 보정 의미 없음 — 빈 값으로 덮어쓰지 않음
$this->assertSame('root', getenv('DB_WRITE_USERNAME'), '.env 도 stale 이면 보정 미발동 (정상 ENV 손상 방지)');
}
public function test_no_recovery_when_installer_not_completed(): void
{
$this->withProductionEnv();
// INSTALLER_COMPLETED 부재 — finalize 미수행 (인스톨러 중간 상태) 시그니처
file_put_contents($this->envPath, implode("\n", [
'DB_WRITE_USERNAME=g7_user',
'DB_WRITE_PASSWORD="real_pw"',
'',
]));
putenv('DB_WRITE_USERNAME=root');
$_ENV['DB_WRITE_USERNAME'] = 'root';
$provider = new InstallerRuntimeServiceProvider($this->app);
$provider->register();
// INSTALLER_COMPLETED 부재 → 보정 미발동 (인스톨러 진행 중일 수 있음, 보수적 보호)
$this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'INSTALLER_COMPLETED 부재 시 보정 미발동');
}
public function test_no_recovery_when_runtime_php_still_present(): void
{
$this->withProductionEnv();
// runtime.php 잔존 — 기존 register() 가 Config 주입을 처리하므로 보정 불필요
$this->writeRuntime([
'db' => ['write' => ['host' => '127.0.0.1', 'username' => 'rt_user', 'password' => 'rt_pw']],
]);
file_put_contents($this->envPath, implode("\n", [
'DB_WRITE_USERNAME=g7_user',
'INSTALLER_COMPLETED=true',
'',
]));
putenv('DB_WRITE_USERNAME=root');
$_ENV['DB_WRITE_USERNAME'] = 'root';
$provider = new InstallerRuntimeServiceProvider($this->app);
$provider->register();
// runtime.php 존재 → 기존 경로 (Config 주입) 가 동작. 본 보정은 트리거 안 됨.
$this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'runtime.php 잔존 시 본 보정 미발동');
}
/**
* @param array<string, mixed> $data
*/
@@ -21,11 +21,24 @@ axes:
env_state: [example_only, partially_filled, fully_finalized]
finalize_call: [success, browser_aborted, retry_after_failure]
rollback_trigger: [none, mid_install, post_finalize]
# 이슈 #371 — finalize 가드 자가 차단 회귀 axis
g7_installed_lock_state: [absent, present_before_finalize]
finalize_guard_signal: [lock_only, env_completed_true_only, lock_and_env_completed_true, neither]
orphaned_recovery_path: [none, upgrade_step_auto_finalize, upgrade_step_warn_manual_review]
# 이슈 #371 — 웹 서버 사용자/그룹 정합 axis (Upgrade Step 의 권한 조정)
cli_vs_phpfpm_user: [same_user, different_user_phpfpm_group_member, different_user_no_group_link]
env_preexisting_mode: [absent, 0600_strict, 0640_group_readable, 0664_group_writable]
bootstrap_cache_owner_group: [phpfpm_user, root_with_phpfpm_group, missing]
# 이슈 #371 — 부모 프로세스 stale ENV 가 spawn 자식에 전파되어
# Access denied 가 발생하던 회귀 차단 axis (한시적 보정 — beta.8 이후 트리거 안 됨)
parent_process_env_state: [matches_runtime, stale_from_env_example_root, stale_partial]
exclusions:
- { runtime_state: absent, env_state: example_only, finalize_call: success, reason: "runtime 부재 시 finalize 는 멱등적 no-op" }
- { runtime_state: present_corrupted, finalize_call: success, reason: "손상된 runtime 은 머지 미수행, runtime 보존" }
- { rollback_trigger: post_finalize, runtime_state: present_with_db_and_key, reason: "finalize 후 rollback 시점에는 runtime.php 가 이미 삭제된 상태" }
- { g7_installed_lock_state: present_before_finalize, finalize_guard_signal: lock_only, finalize_call: success, reason: "이슈 #371 회귀 가드 — 락 단독 시 finalize 통과해야 함 (자가 차단 금지)" }
- { orphaned_recovery_path: upgrade_step_warn_manual_review, env_state: fully_finalized, runtime_state: absent, reason: "정상 finalize 완료 환경은 warn 경로 자체가 트리거되지 않음" }
effects:
- install_step5_sse_does_not_modify_env_file
@@ -88,10 +101,41 @@ effects:
- rollback_complete_flag_removes_g7_installed_marker_when_present
- artisan_serve_workers_restart_at_most_once_after_completion_ui_shown
- non_serve_environments_observe_zero_extra_restart
# 이슈 #371 — finalize 가드 자가 차단 회귀 차단 + 잔존 환경 자동 복구
- finalize_guard_passes_when_only_g7_installed_lock_is_present
- finalize_guard_passes_when_env_lacks_installer_completed_flag
- finalize_guard_blocks_when_env_has_installer_completed_true_idempotent
- finalize_guard_blocks_when_env_has_installer_completed_true_with_quoted_value
- finalize_guard_accepts_alternative_truthy_values_1_and_yes
- upgrade_step_auto_finalizes_orphaned_runtime_when_env_not_marked_completed
- upgrade_step_skips_when_runtime_php_is_absent_idempotent_noop
- upgrade_step_warns_when_env_marked_completed_but_runtime_php_still_exists_no_auto_delete
- upgrade_step_falls_back_to_env_example_when_env_file_is_absent
- upgrade_step_preserves_runtime_php_when_env_write_fails_safe_fallback
- upgrade_step_logs_skip_when_runtime_php_returns_non_array_format_mismatch
- upgrade_step_deletes_state_json_after_merge_when_DELETE_INSTALLER_AFTER_COMPLETE_set
- upgrade_step_preserves_state_json_when_DELETE_INSTALLER_AFTER_COMPLETE_unset
# 이슈 #371 — 웹 서버 권한 자동 조정
- upgrade_step_preserves_existing_env_mode_uid_gid_when_env_existed_before_merge
- upgrade_step_preserves_strict_0600_env_mode_when_owner_intent_present
- upgrade_step_uses_0640_default_permission_for_newly_created_env_not_0600
- upgrade_step_detects_web_server_group_from_bootstrap_cache_for_new_env
- upgrade_step_falls_back_to_storage_logs_when_bootstrap_cache_absent
- upgrade_step_skips_chown_when_lacking_root_privilege_silent_with_log_hint
- upgrade_step_skips_uid_when_bootstrap_cache_owner_is_root_to_avoid_root_owned_env
- upgrade_step_inherits_group_only_when_bootstrap_cache_owner_is_root_but_group_is_not
# 이슈 #371 — 부모 프로세스 process ENV 도 머지된 자격증명으로 갱신하여 spawn 자식 전파 보장
- upgrade_step_refreshes_parent_process_env_with_merged_db_write_credentials
- upgrade_step_refreshes_parent_process_env_with_merged_db_read_credentials
- upgrade_step_skips_env_refresh_when_runtime_credential_keys_are_missing
- upgrade_step_does_not_overwrite_app_key_env_when_runtime_value_lacks_base64_prefix
test_files:
- tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
- tests/Unit/Installer/InstallerRuntimeHelperTest.php
- tests/Unit/Installer/InstallerGuardTest.php
- tests/Feature/Console/Core/FinalizeOrphanedInstallerRuntimeTest.php
- tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php
manual_verification:
- scenario: A_artisan_serve
@@ -9,7 +9,7 @@ description: |
비표준 포트 + 일부 브라우저 정책 조합)을 사전 감지하여 운영자에게 명시
안내한다.
본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (PO 결정: 경고만).
본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (정책: 경고만).
검증 시점은 Step 0 1회 한정. 또한 `session.cookie_samesite` 기본값을
Strict → Lax 로 완화하여 차단 케이스 자체를 줄인다 — 진단은 잔여 케이스
대비 안내 역할.
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* 코어 7.0.0-beta.7 업그레이드 스텝
*
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.0-beta.7/` 안에 격리된다:
*
* - migrations/
* 01_FinalizeOrphanedInstallerRuntime.php
* beta.4~beta.6 의 finalize-env.php 자가 차단 회귀로 잔존한 storage/installer/runtime.php
* 를 감지하여 .env 머지 + runtime.php 삭제 + state.json 정리를 인라인 수행 (이슈 #371)
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
* @upgrade-path 모든 경로 (beta.4 / beta.5 / beta.6 사용자가 beta.7 로 업그레이드)
*
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.0-beta.7/migrations/` 의 버전 namespace
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
* 100% 동일 보장" invariant 가 성립.
*
* 상세: docs/extension/upgrade-step-guide.md §12 "버전별 데이터 스냅샷"
*/
class Upgrade_7_0_0_beta_7 extends AbstractUpgradeStep
{
// 모든 로직 위임 — data/7.0.0-beta.7/ 가 SSoT.
}
@@ -0,0 +1,757 @@
<?php
namespace App\Upgrades\Data\V7_0_0_beta_7\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Log;
/**
* beta.4~beta.6 인스톨러 finalize 자가 차단 결함의 잔존 환경 자동 복구.
*
* 결함 (이슈 #371):
* finalize-env.php 가 `_guard.php` 의 installer_guard_or_410() 에 의해
* `storage/app/g7_installed` 락 파일 존재만으로 410 차단됨. 그 결과 `.env`
* 머지와 `storage/installer/runtime.php` 삭제가 영구 누락된 상태로 운영 중일
* 가능성이 있다 (운영 자체는 InstallerRuntimeServiceProvider 의 메모리 주입
* 폴백으로 정상 동작 — 평문 자격증명이 runtime.php 에 영구 보존되는 안전
* 폴백 상태).
*
* 본 마이그레이션은 그 폴백 상태를 감지하여 finalize 로직을 인라인 수행한다:
* 1. `.env` 머지 (DB 자격증명 + APP_KEY + INSTALLER_COMPLETED=true)
* 2. `storage/installer/runtime.php` 삭제
* 3. `storage/installer-state.json` 삭제 (DELETE_INSTALLER_AFTER_COMPLETE 분기)
*
* 멱등성:
* - runtime.php 부재 → no-op (이미 정상 finalize 됨)
* - .env 의 INSTALLER_COMPLETED=true 인데 runtime.php 잔존 → 자동 삭제 금지,
* 운영자 수동 검토 안내 (.env 머지는 이미 끝났으나 runtime.php 정합 이상 신호)
* - state.json 부재 → unlink 호출 안 함
*
* 실패 시 안전 폴백: runtime.php 보존. 운영은 InstallerRuntimeServiceProvider
* 의 메모리 주입으로 계속 정상 동작.
*
* 격리 원칙 (docs/extension/upgrade-step-guide.md §12):
* - 외부 헬퍼 (installer-runtime.php) 의 로직을 본 클래스 안에 중복 구현
* - functions.php / config.php / installer-state.php require 금지
* (BASE_PATH 상수 충돌 위험 + V-1 안전 격리)
* - mergeRuntimeIntoEnv 와 동일한 머지 결과를 생성하지만 단순화 (escapeEnvValue
* polyfill 만 로컬 보유)
*/
final class FinalizeOrphanedInstallerRuntime implements DataMigration
{
private const RUNTIME_RELATIVE = 'storage/installer/runtime.php';
private const STATE_JSON_RELATIVE = 'storage/installer-state.json';
/**
* `.env` 가 새로 생성될 때 적용할 기본 권한 (소유자 rw + 그룹 r).
*
* 0600 은 PHP-FPM 사용자(www-data) 가 CLI 사용자(jjh/root) 와 다른 운영 환경에서
* 읽기 차단을 일으키므로 사용 금지. 0640 은 그룹 멤버에게만 읽기 허용 — 그룹을
* 웹 서버 그룹으로 맞추면 비밀번호 노출 없이 웹 서버가 읽기 가능.
*/
private const DEFAULT_ENV_PERMISSIONS = 0640;
public function name(): string
{
return 'FinalizeOrphanedInstallerRuntime';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
$context->logger->warning(sprintf(
'[7.0.0-beta.7] FinalizeOrphanedInstallerRuntime 실패 (runtime.php 보존, 계속 진행): %s',
$e->getMessage(),
));
Log::warning('beta.7 FinalizeOrphanedInstallerRuntime 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$basePath = base_path();
$runtimePath = $basePath.DIRECTORY_SEPARATOR.self::RUNTIME_RELATIVE;
$envPath = $basePath.DIRECTORY_SEPARATOR.'.env';
$envExamplePath = $basePath.DIRECTORY_SEPARATOR.'.env.example';
if (! is_file($runtimePath)) {
$context->logger->info('[7.0.0-beta.7] runtime.php 부재 — 정상 finalize 완료 상태로 판정, skip');
return;
}
if ($this->envInstallerCompletedIsTrue($envPath)) {
$context->logger->warning(sprintf(
'[7.0.0-beta.7] .env 의 INSTALLER_COMPLETED=true 이지만 %s 가 잔존. '
.'평문 자격증명 보안 우려 — 수동 검토 후 삭제 권장',
self::RUNTIME_RELATIVE,
));
return;
}
$runtime = $this->readRuntime($runtimePath);
if ($runtime === null) {
$context->logger->info('[7.0.0-beta.7] runtime.php 형식 불일치 (배열 아님) — runtime.php 보존, skip');
return;
}
$envBase = $this->loadEnvBase($envPath, $envExamplePath);
if ($envBase === null) {
$context->logger->error('[7.0.0-beta.7] .env / .env.example 모두 읽기 실패 — runtime.php 보존');
return;
}
// 머지 *전에* .env 의 기존 권한/소유자/그룹 스냅샷을 떠둔다 — 머지 후 그대로 복원해
// CLI 실행 사용자(jjh/root) 와 PHP-FPM 사용자(www-data) 가 다른 환경에서도
// PHP-FPM 이 계속 .env 를 읽을 수 있도록 한다.
$envExisted = is_file($envPath);
$preservedStat = $envExisted ? $this->snapshotFileStat($envPath) : null;
$merged = $this->mergeRuntimeIntoEnv($envBase, $runtime);
if (@file_put_contents($envPath, $merged, LOCK_EX) === false) {
$context->logger->error('[7.0.0-beta.7] .env 쓰기 실패 — runtime.php 보존');
return;
}
$this->applyEnvPermissions($envPath, $preservedStat, $basePath, $context);
// 부모 프로세스의 process ENV 도 머지된 자격증명으로 갱신 (이슈 #371 후속 회귀 차단)
//
// 배경: beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root,
// DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅
// 시점에 Dotenv 가 stale .env 의 값을 process ENV (getenv() / $_ENV / $_SERVER)
// 에 적재한 상태이며, Config 메모리만 InstallerRuntimeServiceProvider 가 정상값으로
// 주입해두었기 때문에 부모 자체는 정상 동작한다.
//
// 이 시점에 본 마이그레이션이 .env 머지 + runtime.php 삭제를 수행해도 부모의 process
// ENV 는 stale 한 채 유지된다. 그 직후 BundledExtensionUpdatePrompt 의 번들 일괄
// 업데이트가 proc_open 으로 spawn 자식을 띄울 때 5번째 인자에 array_merge(getenv(),
// $_ENV) 를 전달하므로 자식이 stale 값(root / 빈 비밀번호)을 그대로 상속받는다.
// 자식의 Laravel 부팅 시 Dotenv::createImmutable() 은 이미 채워진 ENV 를 덮어쓰지
// 않으므로 자식이 머지된 .env 의 정상 값을 보지 못하고 root@localhost (using
// password: YES — 환경에 따라 다른 stale 비밀번호) 로 DB 연결 시도 → Access denied.
//
// beta.8 이후 정상 .env 환경에서는 부팅 시점부터 process ENV 가 올바르므로 본
// 보정 로직이 트리거되어도 동일 값으로 재기록되는 멱등 동작이며 부작용이 없다.
$this->refreshProcessEnvFromRuntime($runtime);
@unlink($runtimePath);
if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) {
$stateFilePath = $basePath.DIRECTORY_SEPARATOR.self::STATE_JSON_RELATIVE;
if (is_file($stateFilePath)) {
@unlink($stateFilePath);
}
}
// 본 PR 부수효과 정리 — beta.7 업그레이드 도중 자식 spawn 프로세스 (root 권한)
// 가 부팅하면서 InstallerRuntimeServiceProvider 의 recover 가 발화 → Config
// 보정 → CoreServiceProvider DB 가드 통과 → loadModules() → CachesModuleStatus
// 의 캐시 쓰기 흐름이 진행되어, storage/framework/cache/data/<hash>/ 디렉토리가
// root:root 권한으로 생성됨. 이후 일반 PHP-FPM 워커(www-data 그룹)가 그
// 디렉토리에 캐시 파일 쓰기 시도 시 Permission denied → 사이트 500 회귀.
//
// 정상 운영 디렉토리 (storage/, bootstrap/cache 등) 의 소유자/그룹을 기준으로
// storage/framework/cache/data/ 하위의 root 소유 항목만 일괄 chown.
$this->normalizeCachePermissions($basePath, $context);
$context->logger->info(
'[7.0.0-beta.7] 잔존 runtime.php 자동 finalize 완료 — .env 머지 + runtime.php 삭제 + state.json 정리'
);
}
/**
* beta.7 업그레이드 부수효과 보정 — storage/framework/cache/data/ 하위의 root 소유
* 디렉토리/파일을 정상 소유자로 chown.
*
* 정상 소유자/그룹 결정:
* - 1순위: storage/framework/cache/data/ 자신의 owner/group (이미 정상값을 보유)
* - 2순위: bootstrap/cache/ 의 owner/group
* - 3순위: 그 외 — skip (안전한 fallback 없으면 강제 변경 안 함)
*
* @param string $basePath 프로젝트 base path
*/
private function normalizeCachePermissions(string $basePath, UpgradeContext $context): void
{
if (! function_exists('chown') || ! function_exists('posix_geteuid')) {
return;
}
$cacheRoot = $basePath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'framework'.DIRECTORY_SEPARATOR.'cache'.DIRECTORY_SEPARATOR.'data';
if (! is_dir($cacheRoot)) {
return;
}
// 정상 owner/group 추정
[$targetUid, $targetGid] = $this->inferCacheOwnership($basePath, $cacheRoot);
if ($targetUid === null || $targetGid === null) {
$context->logger->info('[7.0.0-beta.7] 캐시 권한 보정 skip — 정상 owner/group 추정 실패');
return;
}
$changed = 0;
$failed = 0;
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($cacheRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iter as $entry) {
/** @var \SplFileInfo $entry */
$path = $entry->getPathname();
$currentUid = @fileowner($path);
$currentGid = @filegroup($path);
// 이미 정상이면 skip
if ($currentUid === $targetUid && $currentGid === $targetGid) {
continue;
}
// root (uid=0) 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존
if ($currentUid !== 0 && $currentGid !== 0) {
continue;
}
$okOwner = @chown($path, $targetUid);
$okGroup = @chgrp($path, $targetGid);
if ($okOwner && $okGroup) {
$changed++;
} else {
$failed++;
}
}
$context->logger->info(sprintf(
'[7.0.0-beta.7] 캐시 권한 보정 완료 — root 소유 항목 changed=%d failed=%d (uid=%d gid=%d)',
$changed,
$failed,
$targetUid,
$targetGid,
));
}
/**
* 캐시 디렉토리의 정상 owner/group 추정.
*
* @return array{0:int|null, 1:int|null}
*/
private function inferCacheOwnership(string $basePath, string $cacheRoot): array
{
// 1순위: cache/data 자신의 owner/group (chown 대상 자식이 root 라도 부모는 정상값일 가능성)
$uid = @fileowner($cacheRoot);
$gid = @filegroup($cacheRoot);
if (is_int($uid) && $uid !== 0 && is_int($gid)) {
return [$uid, $gid];
}
// 2순위: bootstrap/cache
$bootstrapCache = $basePath.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'cache';
if (is_dir($bootstrapCache)) {
$uid = @fileowner($bootstrapCache);
$gid = @filegroup($bootstrapCache);
if (is_int($uid) && $uid !== 0 && is_int($gid)) {
return [$uid, $gid];
}
}
// 3순위 fallback 없음
return [null, null];
}
/**
* `.env` 의 INSTALLER_COMPLETED 가 truthy 값인지 판정.
*
* `_guard.php` 의 installer_finalize_is_completed() 와 동일 정책.
*/
private function envInstallerCompletedIsTrue(string $envPath): bool
{
if (! is_file($envPath)) {
return false;
}
$parsed = @parse_ini_file($envPath, false, INI_SCANNER_RAW);
if (! is_array($parsed)) {
return false;
}
$flag = strtolower(trim((string) ($parsed['INSTALLER_COMPLETED'] ?? '')));
$flag = trim($flag, "\"'");
return in_array($flag, ['true', '1', 'yes'], true);
}
/**
* runtime.php 를 읽어 배열 반환. 부재/형식 불일치 시 null.
*
* installer-runtime.php 의 readInstallerRuntime() 와 동일 로직 — 본 클래스
* 안에 중복 구현하여 BASE_PATH 상수 의존 회피 (V-1 안전 격리).
*/
private function readRuntime(string $runtimePath): ?array
{
if (! is_file($runtimePath)) {
return null;
}
$data = @include $runtimePath;
return is_array($data) ? $data : null;
}
/**
* `.env` 가 존재하면 그 내용을, 아니면 `.env.example` 을 base 로 반환.
*
* 둘 다 없으면 null.
*/
private function loadEnvBase(string $envPath, string $envExamplePath): ?string
{
if (is_file($envPath)) {
$content = @file_get_contents($envPath);
return $content === false ? null : $content;
}
if (is_file($envExamplePath)) {
$content = @file_get_contents($envExamplePath);
return $content === false ? null : $content;
}
return null;
}
/**
* runtime 배열을 .env 본문에 머지.
*
* installer-runtime.php 의 mergeRuntimeIntoEnv() 와 동일 결과 — escapeEnvValue
* / replaceEnvLine 을 로컬 private 메서드로 인라인 구현.
*/
private function mergeRuntimeIntoEnv(string $envContent, array $runtime): string
{
$write = $runtime['db']['write'] ?? null;
if (is_array($write)) {
$envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', $this->escapeEnvValue((string) ($write['password'] ?? '')));
}
$read = $runtime['db']['read'] ?? $write;
if (is_array($read)) {
$envContent = $this->replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
$envContent = $this->replaceEnvLine($envContent, 'DB_READ_PASSWORD', $this->escapeEnvValue((string) ($read['password'] ?? '')));
}
if (isset($runtime['db']['prefix'])) {
$envContent = $this->replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
}
$appKey = $runtime['app']['key'] ?? null;
if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
$envContent = $this->replaceEnvLine($envContent, 'APP_KEY', $appKey);
}
if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) {
$envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
}
return $envContent;
}
/**
* 부모 프로세스의 process ENV 를 runtime 자격증명으로 갱신.
*
* getenv() / $_ENV / $_SERVER 3곳 모두 갱신 — proc_open 의 ENV 합집합 전파와
* Dotenv::createImmutable() 의 덮어쓰기 차단 정책 모두에 대응.
*
* 트리거 가드는 호출자에서 이미 보장됨:
* - runtime.php 부재 (beta.3 정상 환경 포함) → runInternal 진입 직후 return
* - .env 의 INSTALLER_COMPLETED=true (이미 finalize 완료) → 별도 return
* - 따라서 본 메서드 도달 = beta.4~6 손상 환경의 머지 성공 분기
*
* 본 메서드 자체도 빈 값 가드: runtime 의 username/password 키가 부재하거나
* 빈 문자열이면 그 키에 대한 ENV 갱신을 skip 하여 정상 ENV 를 빈 값으로
* 덮어쓰는 회귀를 차단.
*
* @param array<string, mixed> $runtime
*/
private function refreshProcessEnvFromRuntime(array $runtime): void
{
$write = $runtime['db']['write'] ?? null;
if (! is_array($write)) {
return;
}
$read = $runtime['db']['read'] ?? $write;
if (! is_array($read)) {
$read = $write;
}
$pairs = [
'DB_WRITE_HOST' => $write['host'] ?? null,
'DB_WRITE_PORT' => $write['port'] ?? null,
'DB_WRITE_DATABASE' => $write['database'] ?? null,
'DB_WRITE_USERNAME' => $write['username'] ?? null,
'DB_WRITE_PASSWORD' => $write['password'] ?? null,
'DB_READ_HOST' => $read['host'] ?? null,
'DB_READ_PORT' => $read['port'] ?? null,
'DB_READ_DATABASE' => $read['database'] ?? null,
'DB_READ_USERNAME' => $read['username'] ?? null,
'DB_READ_PASSWORD' => $read['password'] ?? null,
];
if (isset($runtime['db']['prefix'])) {
$pairs['DB_PREFIX'] = (string) $runtime['db']['prefix'];
}
$appKey = $runtime['app']['key'] ?? null;
if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
$pairs['APP_KEY'] = $appKey;
}
foreach ($pairs as $key => $value) {
// null / 빈 문자열은 skip — 정상 ENV 를 빈 값으로 덮어쓰는 회귀 차단
// (단, 비밀번호는 의도적으로 빈 문자열일 수 있으므로 password 만 예외 허용)
if ($value === null) {
continue;
}
$stringValue = (string) $value;
if ($stringValue === '' && ! in_array($key, ['DB_WRITE_PASSWORD', 'DB_READ_PASSWORD', 'DB_PREFIX'], true)) {
continue;
}
putenv($key.'='.$stringValue);
$_ENV[$key] = $stringValue;
$_SERVER[$key] = $stringValue;
}
}
private function escapeEnvValue(string $value): string
{
if ($value !== '') {
$value = str_replace(["\r", "\n"], '', $value);
}
if ($value === '') {
return '""';
}
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"'.$escaped.'"';
}
private function replaceEnvLine(string $envContent, string $key, string $value): string
{
$line = $key.'='.$value;
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
$replaced = preg_replace($pattern, $line, $envContent, 1, $count);
if ($count === 0) {
return rtrim($envContent)."\n".$line."\n";
}
return $replaced;
}
// ---------------------------------------------------------------------
// 권한·소유자·그룹 자동 조정 — 코어/인스톨러 헬퍼 비의존 (V-1 안전 격리)
// ---------------------------------------------------------------------
/**
* 파일의 권한/소유자/그룹을 스냅샷.
*
* @return array{mode:int|null, uid:int|null, gid:int|null}|null 파일 부재/stat 실패 시 null
*/
private function snapshotFileStat(string $path): ?array
{
if (! is_file($path)) {
return null;
}
$perms = @fileperms($path);
$uid = @fileowner($path);
$gid = @filegroup($path);
if ($perms === false && $uid === false && $gid === false) {
return null;
}
return [
'mode' => $perms === false ? null : ($perms & 0777),
'uid' => $uid === false ? null : $uid,
'gid' => $gid === false ? null : $gid,
];
}
/**
* 머지된 .env 에 적절한 권한·소유자·그룹을 적용.
*
* 정책:
* 1. 기존 .env 가 있었으면 스냅샷 그대로 복원 — 운영자가 의도해서 설정한 권한 보존
* (PHP-FPM 사용자가 그룹으로 묶여 있는 자체 구축 환경 / 단일 사용자 카페24 환경
* 모두 자동 보존)
* 2. 신규 생성된 경우 → 웹 서버 컨텍스트 (소유자/그룹) 를 추정하여 적용
* - 추정: bootstrap/cache/ 의 소유자/그룹 (PHP-FPM 이 쓰는 영역)
* - 권한: 0640 (소유자 rw + 그룹 r) — 비밀번호 노출 차단 + 웹 서버 그룹 읽기 허용
*
* 모든 chown/chgrp/chmod 호출은 실패해도 진행 (운영자가 수동 보정 가능하도록 로그 안내).
*
* @param array{mode:int|null, uid:int|null, gid:int|null}|null $preservedStat
* 머지 직전 .env 가 존재했다면 그 stat. 부재였으면 null.
*/
private function applyEnvPermissions(
string $envPath,
?array $preservedStat,
string $basePath,
UpgradeContext $context,
): void {
if ($preservedStat !== null) {
// 기존 .env 의 권한/소유자/그룹 복원
$this->restoreFileStat($envPath, $preservedStat, $context);
return;
}
// 신규 생성 .env — 웹 서버 컨텍스트 추정 + 안전한 기본 권한 적용
$webContext = $this->detectWebServerContext($basePath);
$processUser = $this->describeProcessUser();
$context->logger->info(sprintf(
'[7.0.0-beta.7] .env 신규 생성 시작 — 프로세스 사용자=%s, 웹 서버 컨텍스트 추정: uid=%s, gid=%s',
$processUser,
$webContext['uid'] !== null ? (string) $webContext['uid'] : 'unknown',
$webContext['gid'] !== null ? (string) $webContext['gid'] : 'unknown',
));
if ($webContext['uid'] !== null) {
$this->tryChown($envPath, $webContext['uid'], $context);
} else {
$context->logger->info('[7.0.0-beta.7] chown 시도 skip — 웹 서버 사용자 추정 실패 (bootstrap/cache, storage/logs 모두 신호 없음)');
}
if ($webContext['gid'] !== null) {
$this->tryChgrp($envPath, $webContext['gid'], $context);
} else {
$context->logger->info('[7.0.0-beta.7] chgrp 시도 skip — 웹 서버 그룹 추정 실패');
}
$chmodResult = @chmod($envPath, self::DEFAULT_ENV_PERMISSIONS);
$actualMode = fileperms($envPath) & 0777;
if ($chmodResult === false) {
$lastError = error_get_last();
$context->logger->warning(sprintf(
'[7.0.0-beta.7] chmod(.env, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s. '
.'운영자 수동 보정: chmod %s %s',
decoct(self::DEFAULT_ENV_PERMISSIONS),
decoct($actualMode),
$processUser,
$lastError['message'] ?? 'unknown',
decoct(self::DEFAULT_ENV_PERMISSIONS),
$envPath,
));
} else {
$context->logger->info(sprintf(
'[7.0.0-beta.7] chmod(.env, %s) 성공 — 실제 권한: %s, 프로세스=%s',
decoct(self::DEFAULT_ENV_PERMISSIONS),
decoct($actualMode),
$processUser,
));
}
$context->logger->info(sprintf(
'[7.0.0-beta.7] .env 신규 생성 완료 — 최종 mode=%s, owner_uid=%s, group_gid=%s',
decoct($actualMode),
(string) (@fileowner($envPath) ?: 'unknown'),
(string) (@filegroup($envPath) ?: 'unknown'),
));
}
/**
* 스냅샷 stat 을 파일에 그대로 복원.
*
* 우선순위: chmod → chown → chgrp. chown 은 root 권한 필요할 수 있어 실패 흔함 — 실패해도 진행.
*/
private function restoreFileStat(string $path, array $stat, UpgradeContext $context): void
{
$processUser = $this->describeProcessUser();
if ($stat['mode'] !== null) {
$chmodResult = @chmod($path, $stat['mode']);
$actualMode = fileperms($path) & 0777;
if ($chmodResult === false) {
$lastError = error_get_last();
$context->logger->warning(sprintf(
'[7.0.0-beta.7] chmod(%s, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s',
basename($path),
decoct($stat['mode']),
decoct($actualMode),
$processUser,
$lastError['message'] ?? 'unknown',
));
}
}
if ($stat['uid'] !== null) {
$this->tryChown($path, $stat['uid'], $context);
}
if ($stat['gid'] !== null) {
$this->tryChgrp($path, $stat['gid'], $context);
}
$context->logger->info(sprintf(
'[7.0.0-beta.7] .env 기존 권한 보존 시도 — 요청 mode=%s, uid=%s, gid=%s / 실제 mode=%s, uid=%s, gid=%s, 프로세스=%s',
$stat['mode'] !== null ? decoct($stat['mode']) : 'unchanged',
$stat['uid'] !== null ? (string) $stat['uid'] : 'unchanged',
$stat['gid'] !== null ? (string) $stat['gid'] : 'unchanged',
decoct(fileperms($path) & 0777),
(string) (@fileowner($path) ?: 'unknown'),
(string) (@filegroup($path) ?: 'unknown'),
$processUser,
));
}
/**
* 현재 프로세스 사용자 ("name(uid)") 를 문자열로 반환 — 로그 가독성용.
*/
private function describeProcessUser(): string
{
if (! function_exists('posix_geteuid')) {
return 'unknown(no-posix)';
}
$euid = posix_geteuid();
$name = function_exists('posix_getpwuid') ? (posix_getpwuid($euid)['name'] ?? null) : null;
return ($name ?? 'uid') . '(' . $euid . ')';
}
/**
* 웹 서버 컨텍스트 (PHP-FPM/Apache 가 쓰는 사용자/그룹) 를 디스크 신호로 추정.
*
* `getWebServerUser()` 같은 인스톨러 헬퍼 (현재 PHP 프로세스 = web user 가정) 는
* CLI 컨텍스트에서 동작하지 않으므로 사용 금지. 대신 PHP-FPM 이 실제로 *쓴 파일*
* 의 소유자/그룹을 신호로 사용.
*
* 우선순위:
* 1. bootstrap/cache/*.php (services.php / packages.php) — Laravel 부팅 시 PHP-FPM
* 이 직접 쓰는 파일. 가장 신뢰도 높음
* 2. bootstrap/cache/ 디렉토리 자체
* 3. storage/logs/ 디렉토리 (PHP-FPM 의 또 다른 쓰기 영역)
* 4. 모두 실패 시 null — 호출자가 chown/chgrp skip
*
* @return array{uid:int|null, gid:int|null}
*/
private function detectWebServerContext(string $basePath): array
{
$candidates = [
$basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/services.php',
$basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/packages.php',
$basePath.DIRECTORY_SEPARATOR.'bootstrap/cache',
$basePath.DIRECTORY_SEPARATOR.'storage/logs',
];
foreach ($candidates as $candidate) {
if (! file_exists($candidate)) {
continue;
}
$uid = @fileowner($candidate);
$gid = @filegroup($candidate);
if ($uid === false && $gid === false) {
continue;
}
// root (uid=0) 단독 신호는 회피 — sudo 로 캐시가 생성된 환경일 수 있음.
// 그룹은 root 라도 사용 가능 (그룹이 web user group 인 경우 흔함).
if ($uid === 0 && $gid !== false && $gid !== 0) {
return ['uid' => null, 'gid' => $gid];
}
return [
'uid' => $uid === false ? null : $uid,
'gid' => $gid === false ? null : $gid,
];
}
return ['uid' => null, 'gid' => null];
}
/**
* chown 시도. 시도/성공/실패 모두 로그에 명시.
*/
private function tryChown(string $path, int $uid, UpgradeContext $context): void
{
if (! function_exists('chown')) {
$context->logger->info('[7.0.0-beta.7] chown skip — chown() 함수 사용 불가 (PHP 빌드 옵션)');
return;
}
$beforeUid = @fileowner($path);
$result = @chown($path, $uid);
$afterUid = @fileowner($path);
if ($result === false) {
$lastError = error_get_last();
$context->logger->info(sprintf(
'[7.0.0-beta.7] chown(%s, uid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. '
.'원인 추정: 비-root 사용자는 다른 사용자로 chown 불가. 운영자 수동 보정: sudo chown %d %s',
basename($path),
$uid,
$beforeUid === false ? 'unknown' : (string) $beforeUid,
$afterUid === false ? 'unknown' : (string) $afterUid,
$this->describeProcessUser(),
$lastError['message'] ?? 'unknown',
$uid,
$path,
));
} else {
$context->logger->info(sprintf(
'[7.0.0-beta.7] chown(%s, uid=%d) 성공 — before=%s → after=%s',
basename($path),
$uid,
$beforeUid === false ? 'unknown' : (string) $beforeUid,
$afterUid === false ? 'unknown' : (string) $afterUid,
));
}
}
/**
* chgrp 시도. 시도/성공/실패 모두 로그에 명시.
*/
private function tryChgrp(string $path, int $gid, UpgradeContext $context): void
{
if (! function_exists('chgrp')) {
$context->logger->info('[7.0.0-beta.7] chgrp skip — chgrp() 함수 사용 불가');
return;
}
$beforeGid = @filegroup($path);
$result = @chgrp($path, $gid);
$afterGid = @filegroup($path);
if ($result === false) {
$lastError = error_get_last();
$context->logger->info(sprintf(
'[7.0.0-beta.7] chgrp(%s, gid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. '
.'운영자 수동 보정: sudo chgrp %d %s',
basename($path),
$gid,
$beforeGid === false ? 'unknown' : (string) $beforeGid,
$afterGid === false ? 'unknown' : (string) $afterGid,
$this->describeProcessUser(),
$lastError['message'] ?? 'unknown',
$gid,
$path,
));
} else {
$context->logger->info(sprintf(
'[7.0.0-beta.7] chgrp(%s, gid=%d) 성공 — before=%s → after=%s',
basename($path),
$gid,
$beforeGid === false ? 'unknown' : (string) $beforeGid,
$afterGid === false ? 'unknown' : (string) $afterGid,
));
}
}
}