한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면 invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다. echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도 남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다. 2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차), 응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에 남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다. 정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub 단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다. 같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 500 이 되던 것도 함께 막았다.
173 lines
7.4 KiB
PHP
173 lines
7.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 인스톨러 최종 단계 — runtime.php 의 동적 설정을 .env 에 머지
|
|
*
|
|
* Step 6 완료 UI 노출 직후 브라우저가 fire-and-forget 으로 호출한다.
|
|
* 응답을 즉시 반환한 뒤 .env 작성을 수행하여, php artisan serve 의
|
|
* mtime 워처가 트리거하는 워커 재시작이 사용자에게 영향을 주지 않도록 한다.
|
|
*
|
|
* 멱등성: runtime.php 부재 시 즉시 정상 응답 (이미 finalize 됨).
|
|
* 실패 시: runtime.php 보존 → InstallerRuntimeServiceProvider 가 계속 동작
|
|
* → 앱은 정상 (관리자 재호출 또는 다음 부팅 시 재시도 경로 확보).
|
|
*
|
|
* @see https://github.com/gnuboard/g7/issues/23
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__.'/../includes/config.php';
|
|
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';
|
|
// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다.
|
|
// 일반 인스톨러 엔드포인트의 `installer_guard_or_410()` 은 `g7_installed` 락 파일도
|
|
// 차단 사유로 삼는데, 그 락 파일은 finalize 호출 직전 단계의 complete_flag task 가
|
|
// 먼저 생성하므로 자가 차단 회귀가 발생한다 (이슈 #371).
|
|
installer_guard_finalize_or_410();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. 응답을 즉시 송출하여 브라우저가 완료 UI 를 유지할 수 있게 한다.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
session_write_close();
|
|
}
|
|
|
|
ignore_user_abort(true);
|
|
|
|
$accepted = installer_json_encode(['accepted' => 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());
|
|
}
|