true]); header('Content-Type: application/json; charset=utf-8'); header('Content-Length: '.strlen($accepted)); header('Connection: close'); echo $accepted; if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { // cli-server (artisan serve) / Apache mod_php 폴백 while (ob_get_level() > 0) { @ob_end_flush(); } @flush(); } // --------------------------------------------------------------------------- // 2. 응답 송출 후 .env 머지 // --------------------------------------------------------------------------- try { $runtime = readInstallerRuntime(); // 멱등성: runtime.php 부재 → 이미 finalize 된 상태 if ($runtime === null) { return; } $envPath = BASE_PATH.'/.env'; $envBase = generateEnvContent(); if ($envBase === null) { addLog('[finalize-env] generateEnvContent() returned null — .env.example missing'); return; } // 머지 로직은 mergeRuntimeIntoEnv() 헬퍼로 분리 — 단위 테스트 가능 $envContent = mergeRuntimeIntoEnv($envBase, $runtime); // .env 단일 쓰기 — file_put_contents 는 close 시점에 mtime 1회만 갱신하므로 // 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) { $lastError = error_get_last(); addLog(sprintf( '[finalize-env] file_put_contents FAILED: path=%s, last_error=%s', $envPath, $lastError['message'] ?? 'unknown', )); return; } // 머지 후 권한·소유자·그룹 상태를 명시 기록 — 운영자 사후 검증용 $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 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 가 필요했기 때문) // // 삭제 실패 또는 DELETE_INSTALLER_AFTER_COMPLETE=false 로 파일이 남는 모든 경로에서는 // 비밀 필드를 제거한 뒤 재저장한다 (이슈 #465). .env 머지가 이미 성공한 뒤이므로 // state.config 가 더 이상 필요하지 않아 순서상 안전하다. $stateFilePath = BASE_PATH.'/storage/installer-state.json'; $stateDeleted = false; if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) { if (is_file($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 { $stateDeleted = true; addLog('[finalize-env] state.json 삭제 완료'); } } else { $stateDeleted = true; } } if (! $stateDeleted && is_file($stateFilePath)) { if (saveInstallationState(redactInstallationStateSecrets(getInstallationState()))) { addLog('[finalize-env] state.json 잔존 — 비밀 필드 redact 완료'); } else { addLog('[finalize-env] state.json redact 재저장 FAILED — 수동 삭제 권장: '.$stateFilePath); } } } catch (Throwable $e) { // 예외 시 runtime.php 보존 → Provider 가 계속 config 주입 → 앱 정상 동작. addLog('[finalize-env] unexpected exception: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine()); }