Merge pull request from gnuboard:HeuJung/issue445
HeuJung/issue445
This commit is contained in:
@@ -20,6 +20,8 @@
|
||||
### Fixed
|
||||
|
||||
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
|
||||
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
|
||||
- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.
|
||||
|
||||
## [7.0.1] - 2026-07-03
|
||||
|
||||
|
||||
@@ -138,7 +138,28 @@ class StateManagementApi
|
||||
];
|
||||
|
||||
// JSON 응답 반환
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
//
|
||||
// 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가
|
||||
// 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어
|
||||
// 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다.
|
||||
// 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이
|
||||
// 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다.
|
||||
$encoded = json_encode(
|
||||
$response,
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
if ($encoded === false) {
|
||||
// substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한
|
||||
// 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다.
|
||||
$encoded = json_encode([
|
||||
'status' => $status,
|
||||
'logs' => [],
|
||||
'log_total' => $logTotal,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
}
|
||||
|
||||
echo $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*
|
||||
* HTML/JavaScript 이스케이프, 데이터베이스 연결, 권한 검증, 에러 로깅 등의
|
||||
* 공통 유틸리티 함수를 제공합니다.
|
||||
*
|
||||
* @package G7\Installer
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -45,8 +43,9 @@ function js_escape(mixed $value): string
|
||||
/**
|
||||
* 데이터베이스 연결 생성
|
||||
*
|
||||
* @param array $config 데이터베이스 연결 정보 배열
|
||||
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
|
||||
* @param array $config 데이터베이스 연결 정보 배열
|
||||
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
|
||||
*
|
||||
* @throws PDOException 연결 실패 시
|
||||
*/
|
||||
function getDatabaseConnection(array $config, bool $isReadDb = false): PDO
|
||||
@@ -97,9 +96,9 @@ function getDatabaseConnection(array $config, bool $isReadDb = false): PDO
|
||||
/**
|
||||
* 데이터베이스 권한 검증
|
||||
*
|
||||
* @param PDO $pdo PDO 연결 객체
|
||||
* @param string $database 데이터베이스명
|
||||
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
|
||||
* @param PDO $pdo PDO 연결 객체
|
||||
* @param string $database 데이터베이스명
|
||||
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
|
||||
* @return array{has_all: bool, required: array<string>, found: array<string>, missing: array<string>}
|
||||
*/
|
||||
function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = false): array
|
||||
@@ -165,7 +164,7 @@ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = fa
|
||||
*
|
||||
* Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응.
|
||||
*/
|
||||
if (!function_exists('applyInstallerComposerEnvVars')) {
|
||||
if (! function_exists('applyInstallerComposerEnvVars')) {
|
||||
function applyInstallerComposerEnvVars(): void
|
||||
{
|
||||
putenv('COMPOSER_ALLOW_SUPERUSER=1');
|
||||
@@ -180,7 +179,7 @@ if (!function_exists('applyInstallerComposerEnvVars')) {
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
if (!function_exists('buildInstallerComposerEnv')) {
|
||||
if (! function_exists('buildInstallerComposerEnv')) {
|
||||
function buildInstallerComposerEnv(): array
|
||||
{
|
||||
return [
|
||||
@@ -193,13 +192,13 @@ if (!function_exists('buildInstallerComposerEnv')) {
|
||||
/**
|
||||
* 인스톨러 에러 로깅
|
||||
*
|
||||
* @param string $message 에러 메시지
|
||||
* @param Throwable|null $exception 예외 객체 (선택)
|
||||
* @param string $message 에러 메시지
|
||||
* @param Throwable|null $exception 예외 객체 (선택)
|
||||
*/
|
||||
function logInstallationError(string $message, ?Throwable $exception = null): void
|
||||
{
|
||||
$logDir = BASE_PATH . '/storage/logs';
|
||||
$logFile = $logDir . '/installer.log';
|
||||
$logDir = BASE_PATH.'/storage/logs';
|
||||
$logFile = $logDir.'/installer.log';
|
||||
|
||||
// 로그 디렉토리 생성
|
||||
if (! is_dir($logDir)) {
|
||||
@@ -212,13 +211,13 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo
|
||||
|
||||
if ($exception !== null) {
|
||||
$logMessage .= "\n";
|
||||
$logMessage .= "Exception: " . get_class($exception) . "\n";
|
||||
$logMessage .= 'Exception: '.get_class($exception)."\n";
|
||||
$logMessage .= "Message: {$exception->getMessage()}\n";
|
||||
$logMessage .= "File: {$exception->getFile()}:{$exception->getLine()}\n";
|
||||
$logMessage .= "Trace:\n{$exception->getTraceAsString()}\n";
|
||||
}
|
||||
|
||||
$logMessage .= "\n" . str_repeat('-', 80) . "\n\n";
|
||||
$logMessage .= "\n".str_repeat('-', 80)."\n\n";
|
||||
|
||||
// 로그 파일에 기록
|
||||
@file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX);
|
||||
@@ -232,7 +231,7 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo
|
||||
function detectBrowserLanguage(): ?string
|
||||
{
|
||||
// HTTP_ACCEPT_LANGUAGE 헤더가 없으면 null 반환
|
||||
if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
|
||||
if (! isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -286,14 +285,14 @@ function getCurrentLanguage(): string
|
||||
/**
|
||||
* 지정된 언어의 번역 파일을 로드합니다.
|
||||
*
|
||||
* @param string $lang 언어 코드 (예: 'ko', 'en')
|
||||
* @param string $lang 언어 코드 (예: 'ko', 'en')
|
||||
* @return array<string, mixed> 번역 배열, 파일이 없으면 빈 배열
|
||||
*/
|
||||
function loadTranslations(string $lang): array
|
||||
{
|
||||
$langFile = __DIR__ . "/../lang/{$lang}.php";
|
||||
$langFile = __DIR__."/../lang/{$lang}.php";
|
||||
|
||||
if (!file_exists($langFile)) {
|
||||
if (! file_exists($langFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -308,15 +307,15 @@ function loadTranslations(string $lang): array
|
||||
* global $translations 배열에서 키에 해당하는 값을 찾아 반환합니다.
|
||||
* 키가 존재하지 않으면 키 자체를 반환합니다.
|
||||
*
|
||||
* @param string $key 번역 키 (예: 'welcome_title', 'next_step')
|
||||
* @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2'])
|
||||
* @param string $key 번역 키 (예: 'welcome_title', 'next_step')
|
||||
* @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2'])
|
||||
*/
|
||||
if (! function_exists('lang')) {
|
||||
function lang(string $key, array $replace = []): string
|
||||
{
|
||||
global $translations;
|
||||
|
||||
if (!isset($translations) || !is_array($translations)) {
|
||||
if (! isset($translations) || ! is_array($translations)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
@@ -334,7 +333,7 @@ if (! function_exists('lang')) {
|
||||
/**
|
||||
* 설치 단계 이름을 반환합니다.
|
||||
*
|
||||
* @param int $step 단계 번호 (0~5, 6은 예약됨)
|
||||
* @param int $step 단계 번호 (0~5, 6은 예약됨)
|
||||
* @return string 단계명 번역 키
|
||||
*/
|
||||
function getStepName(int $step): string
|
||||
@@ -355,7 +354,7 @@ function getStepName(int $step): string
|
||||
/**
|
||||
* 설치 작업 이름을 반환합니다.
|
||||
*
|
||||
* @param string $task 작업 ID (예: 'composer_check', 'env_create')
|
||||
* @param string $task 작업 ID (예: 'composer_check', 'env_create')
|
||||
* @return string 작업명 번역 키
|
||||
*/
|
||||
function getTaskName(string $task): string
|
||||
@@ -394,12 +393,12 @@ function getTaskName(string $task): string
|
||||
/**
|
||||
* 지정한 Step으로 리다이렉트합니다.
|
||||
*
|
||||
* @param int $step 이동할 단계 번호
|
||||
* @param int $step 이동할 단계 번호
|
||||
*/
|
||||
function redirectToStep(int $step): void
|
||||
{
|
||||
$_SESSION['installer_current_step'] = $step;
|
||||
header("Location: " . INSTALLER_BASE_URL . "/");
|
||||
header('Location: '.INSTALLER_BASE_URL.'/');
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -411,12 +410,12 @@ function redirectToStep(int $step): void
|
||||
* - state.json: 다른 브라우저에서 재개 시 사용, 설치 완료 후 Laravel 본체에 전달
|
||||
* - localStorage: JavaScript에서 직접 관리 (0-welcome.php에서 처리)
|
||||
*
|
||||
* @param string $lang 언어 코드 ('ko' 또는 'en')
|
||||
* @param string $lang 언어 코드 ('ko' 또는 'en')
|
||||
*/
|
||||
function handleLanguageChange(string $lang): void
|
||||
{
|
||||
// 지원하는 언어만 허용
|
||||
if (!array_key_exists($lang, SUPPORTED_LANGUAGES)) {
|
||||
if (! array_key_exists($lang, SUPPORTED_LANGUAGES)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,9 +431,9 @@ function handleLanguageChange(string $lang): void
|
||||
/**
|
||||
* 현재 단계의 상태를 업데이트합니다.
|
||||
*
|
||||
* @param int $step 완료할 단계 번호
|
||||
* @param int $nextStep 다음 단계 번호
|
||||
* @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data])
|
||||
* @param int $step 완료할 단계 번호
|
||||
* @param int $nextStep 다음 단계 번호
|
||||
* @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data])
|
||||
*/
|
||||
function updateStepStatus(int $step, int $nextStep, array $additionalData = []): void
|
||||
{
|
||||
@@ -442,7 +441,7 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []):
|
||||
$state['current_step'] = $nextStep;
|
||||
$state['step_status'][$step] = 'completed';
|
||||
|
||||
if (!empty($additionalData)) {
|
||||
if (! empty($additionalData)) {
|
||||
$state = array_merge($state, $additionalData);
|
||||
}
|
||||
|
||||
@@ -455,6 +454,7 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []):
|
||||
function isInstallationRunning(): bool
|
||||
{
|
||||
$state = getInstallationState();
|
||||
|
||||
return isset($state['installation_status']) && $state['installation_status'] === 'running';
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ function showInstallationRunningAlert(): void
|
||||
global $translations;
|
||||
|
||||
// 번역이 로드되지 않은 경우 로드
|
||||
if (!isset($translations)) {
|
||||
if (! isset($translations)) {
|
||||
$translations = loadTranslations(getCurrentLanguage());
|
||||
}
|
||||
|
||||
@@ -474,7 +474,7 @@ function showInstallationRunningAlert(): void
|
||||
showAlertAndRedirect(
|
||||
lang('installation_in_progress'),
|
||||
lang('installation_in_progress_message'),
|
||||
INSTALLER_BASE_URL . '/'
|
||||
INSTALLER_BASE_URL.'/'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ function showInstallationCompletedAlert(): void
|
||||
global $translations;
|
||||
|
||||
// 번역이 로드되지 않은 경우 로드
|
||||
if (!isset($translations)) {
|
||||
if (! isset($translations)) {
|
||||
$translations = loadTranslations(getCurrentLanguage());
|
||||
}
|
||||
|
||||
@@ -500,15 +500,15 @@ function showInstallationCompletedAlert(): void
|
||||
/**
|
||||
* 알럿을 표시하고 지정된 URL로 리다이렉트합니다.
|
||||
*
|
||||
* @param string $title 페이지 제목
|
||||
* @param string $message 알럿 메시지
|
||||
* @param string $redirectUrl 리다이렉트 URL
|
||||
* @param string $title 페이지 제목
|
||||
* @param string $message 알럿 메시지
|
||||
* @param string $redirectUrl 리다이렉트 URL
|
||||
*/
|
||||
function showAlertAndRedirect(string $title, string $message, string $redirectUrl): void
|
||||
{
|
||||
global $translations;
|
||||
|
||||
if (!isset($translations)) {
|
||||
if (! isset($translations)) {
|
||||
$translations = loadTranslations(getCurrentLanguage());
|
||||
}
|
||||
|
||||
@@ -518,16 +518,16 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr
|
||||
$redirectUrl = htmlspecialchars($redirectUrl);
|
||||
|
||||
echo '<!DOCTYPE html>';
|
||||
echo '<html lang="' . $lang . '">';
|
||||
echo '<html lang="'.$lang.'">';
|
||||
echo '<head>';
|
||||
echo '<meta charset="UTF-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
||||
echo '<title>' . $title . '</title>';
|
||||
echo '<title>'.$title.'</title>';
|
||||
echo '</head>';
|
||||
echo '<body>';
|
||||
echo '<script>';
|
||||
echo 'alert("' . $message . '");';
|
||||
echo 'window.location.href = "' . $redirectUrl . '";';
|
||||
echo 'alert("'.$message.'");';
|
||||
echo 'window.location.href = "'.$redirectUrl.'";';
|
||||
echo '</script>';
|
||||
echo '</body>';
|
||||
echo '</html>';
|
||||
@@ -537,7 +537,7 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr
|
||||
/**
|
||||
* 단계 파일을 찾을 수 없을 때 에러 페이지를 표시합니다.
|
||||
*
|
||||
* @param int $currentStep 현재 단계 번호
|
||||
* @param int $currentStep 현재 단계 번호
|
||||
*/
|
||||
function showStepFileNotFoundError(int $currentStep): void
|
||||
{
|
||||
@@ -553,9 +553,9 @@ function showStepFileNotFoundError(int $currentStep): void
|
||||
echo '</span>';
|
||||
echo lang('error');
|
||||
echo '</h1>';
|
||||
echo '<p class="installer-description">' . lang('error_step_file_not_found') . '</p>';
|
||||
echo '<p class="installer-description">'.lang('error_step_file_not_found').'</p>';
|
||||
echo '<div class="btn-group">';
|
||||
echo '<a href="' . INSTALLER_BASE_URL . '/" class="btn btn-secondary">← ' . lang('previous') . '</a>';
|
||||
echo '<a href="'.INSTALLER_BASE_URL.'/" class="btn btn-secondary">← '.lang('previous').'</a>';
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
}
|
||||
@@ -565,8 +565,8 @@ function showStepFileNotFoundError(int $currentStep): void
|
||||
*
|
||||
* DB 연결 정보가 변경되었는지 감지하기 위해 사용됩니다.
|
||||
*
|
||||
* @param array $config 폼 데이터 배열
|
||||
* @param string $prefix DB 접두사 ('db_write' 또는 'db_read')
|
||||
* @param array $config 폼 데이터 배열
|
||||
* @param string $prefix DB 접두사 ('db_write' 또는 'db_read')
|
||||
* @return string MD5 해시값
|
||||
*/
|
||||
function getDatabaseFieldHash(array $config, string $prefix): string
|
||||
@@ -575,14 +575,14 @@ function getDatabaseFieldHash(array $config, string $prefix): string
|
||||
$config["{$prefix}_host"] ?? '',
|
||||
$config["{$prefix}_port"] ?? '',
|
||||
$config["{$prefix}_database"] ?? '',
|
||||
$config["{$prefix}_username"] ?? ''
|
||||
$config["{$prefix}_username"] ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG 아이콘을 반환합니다.
|
||||
*
|
||||
* @param string $type 아이콘 타입 ('success', 'warning', 'error')
|
||||
* @param string $type 아이콘 타입 ('success', 'warning', 'error')
|
||||
* @return string SVG HTML
|
||||
*/
|
||||
function getSvgIcon(string $type): string
|
||||
@@ -609,12 +609,12 @@ function getSvgIcon(string $type): string
|
||||
/**
|
||||
* 설치 결과 섹션 HTML을 렌더링합니다.
|
||||
*
|
||||
* @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure')
|
||||
* @param string $iconType 아이콘 타입 ('success', 'warning', 'error')
|
||||
* @param string $titleKey 제목 번역 키
|
||||
* @param string $messageKey 메시지 번역 키 (failure인 경우 동적)
|
||||
* @param string $buttonsHtml 버튼 HTML
|
||||
* @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달)
|
||||
* @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure')
|
||||
* @param string $iconType 아이콘 타입 ('success', 'warning', 'error')
|
||||
* @param string $titleKey 제목 번역 키
|
||||
* @param string $messageKey 메시지 번역 키 (failure인 경우 동적)
|
||||
* @param string $buttonsHtml 버튼 HTML
|
||||
* @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달)
|
||||
* @return string 결과 섹션 HTML
|
||||
*/
|
||||
function renderInstallResultSection(string $sectionId, string $iconType, string $titleKey, string $messageKey, string $buttonsHtml, string $extraHtml = ''): string
|
||||
@@ -625,7 +625,7 @@ function renderInstallResultSection(string $sectionId, string $iconType, string
|
||||
// failure는 동적 메시지이므로 번역하지 않음
|
||||
$message = $sectionId === 'failure'
|
||||
? '<div class="result-message" id="failure-message"></div>'
|
||||
: '<p class="result-message">' . htmlspecialchars(lang($messageKey)) . '</p>';
|
||||
: '<p class="result-message">'.htmlspecialchars(lang($messageKey)).'</p>';
|
||||
|
||||
return <<<HTML
|
||||
<div id="{$sectionId}-section" class="result-section hidden">
|
||||
@@ -657,8 +657,8 @@ function renderInstallResultSection(string $sectionId, string $iconType, string
|
||||
function getCoreAppVersion(): string
|
||||
{
|
||||
foreach (['/.env', '/.env.example'] as $relative) {
|
||||
$path = BASE_PATH . $relative;
|
||||
if (!is_readable($path)) {
|
||||
$path = BASE_PATH.$relative;
|
||||
if (! is_readable($path)) {
|
||||
continue;
|
||||
}
|
||||
$content = @file_get_contents($path);
|
||||
@@ -676,8 +676,8 @@ function getCoreAppVersion(): string
|
||||
/**
|
||||
* Progress indicator HTML을 렌더링합니다.
|
||||
*
|
||||
* @param int $currentStep 현재 단계 번호
|
||||
* @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6)
|
||||
* @param int $currentStep 현재 단계 번호
|
||||
* @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6)
|
||||
* @return string Progress indicator HTML
|
||||
*/
|
||||
function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string
|
||||
@@ -690,9 +690,10 @@ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string
|
||||
} elseif ($i === $currentStep) {
|
||||
$class .= ' active';
|
||||
}
|
||||
$html .= '<div class="' . $class . '"></div>';
|
||||
$html .= '<div class="'.$class.'"></div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
@@ -709,7 +710,7 @@ function getWebServerUser(): ?string
|
||||
// 1. posix 확장 (가장 신뢰도 높음)
|
||||
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
|
||||
$info = posix_getpwuid(posix_geteuid());
|
||||
if ($info && !empty($info['name'])) {
|
||||
if ($info && ! empty($info['name'])) {
|
||||
return $info['name'];
|
||||
}
|
||||
}
|
||||
@@ -717,14 +718,14 @@ function getWebServerUser(): ?string
|
||||
// 2. exec('whoami')
|
||||
if (function_exists('exec')) {
|
||||
$user = @exec('whoami');
|
||||
if (!empty($user)) {
|
||||
if (! empty($user)) {
|
||||
return trim($user);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 환경변수
|
||||
$envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null;
|
||||
if (!empty($envUser)) {
|
||||
if (! empty($envUser)) {
|
||||
return $envUser;
|
||||
}
|
||||
|
||||
@@ -740,13 +741,13 @@ function getWebServerGroup(): ?string
|
||||
{
|
||||
if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) {
|
||||
$info = posix_getgrgid(posix_getegid());
|
||||
if ($info && !empty($info['name'])) {
|
||||
if ($info && ! empty($info['name'])) {
|
||||
return $info['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$envGroup = getenv('APACHE_RUN_GROUP') ?: null;
|
||||
if (!empty($envGroup)) {
|
||||
if (! empty($envGroup)) {
|
||||
return $envGroup;
|
||||
}
|
||||
|
||||
@@ -758,7 +759,7 @@ function getWebServerGroup(): ?string
|
||||
*
|
||||
* posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @param string $path 파일/디렉토리 경로
|
||||
* @param string $path 파일/디렉토리 경로
|
||||
* @return string|null 소유자 이름 또는 null
|
||||
*/
|
||||
function getFileOwnerName(string $path): ?string
|
||||
@@ -770,7 +771,7 @@ function getFileOwnerName(string $path): ?string
|
||||
|
||||
if (function_exists('posix_getpwuid')) {
|
||||
$info = @posix_getpwuid($uid);
|
||||
if ($info && !empty($info['name'])) {
|
||||
if ($info && ! empty($info['name'])) {
|
||||
return $info['name'];
|
||||
}
|
||||
}
|
||||
@@ -784,7 +785,7 @@ function getFileOwnerName(string $path): ?string
|
||||
*
|
||||
* posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다.
|
||||
*
|
||||
* @param string $path 파일/디렉토리 경로
|
||||
* @param string $path 파일/디렉토리 경로
|
||||
* @return string|null 그룹 이름 또는 null
|
||||
*/
|
||||
function getFileGroupName(string $path): ?string
|
||||
@@ -796,7 +797,7 @@ function getFileGroupName(string $path): ?string
|
||||
|
||||
if (function_exists('posix_getgrgid')) {
|
||||
$info = @posix_getgrgid($gid);
|
||||
if ($info && !empty($info['name'])) {
|
||||
if ($info && ! empty($info['name'])) {
|
||||
return $info['name'];
|
||||
}
|
||||
}
|
||||
@@ -817,7 +818,7 @@ function getChownTarget(): string
|
||||
$user = getWebServerUser() ?? 'www-data';
|
||||
$group = getWebServerGroup() ?? $user;
|
||||
|
||||
return $user . ':' . $group;
|
||||
return $user.':'.$group;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -826,7 +827,7 @@ function getChownTarget(): string
|
||||
* Step 0과 Step 2에서 재사용 가능한 공통 함수입니다.
|
||||
* 디렉토리를 생성하지 않고 존재 여부와 권한만 체크합니다.
|
||||
*
|
||||
* @param array<string, bool> $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부)
|
||||
* @param array<string, bool> $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부)
|
||||
* @return array{all_passed: bool, results: array<string, array{exists: bool, writable: bool, permissions: string, required_permissions: string, passed: bool, has_subdirectory_issues: bool, failed_subdirectories: array<string>}>}
|
||||
*/
|
||||
function checkDirectoryPermissions(array $directories): array
|
||||
@@ -842,7 +843,7 @@ function checkDirectoryPermissions(array $directories): array
|
||||
$webServerUser = getWebServerUser();
|
||||
|
||||
foreach ($directories as $path => $checkRecursive) {
|
||||
$fullPath = BASE_PATH . '/' . $path;
|
||||
$fullPath = BASE_PATH.'/'.$path;
|
||||
|
||||
// 디렉토리 존재 여부 확인 (생성하지 않음)
|
||||
$exists = is_dir($fullPath);
|
||||
@@ -883,22 +884,22 @@ function checkDirectoryPermissions(array $directories): array
|
||||
$parentPassed = $writable && $readable;
|
||||
|
||||
// 최종 통과 여부: 상위 + 하위 모두 통과해야 함
|
||||
$passed = $parentPassed && !$hasSubdirectoryIssues;
|
||||
$passed = $parentPassed && ! $hasSubdirectoryIssues;
|
||||
|
||||
// 에러 타입 구분
|
||||
// ownership_mismatch: 권한 비트는 0755 이상이지만 소유자가 웹서버 사용자와 달라
|
||||
// 실제 쓰기 불가 (전통적 Apache: 파일 소유자 != www-data)
|
||||
$errorType = null;
|
||||
if (!$exists) {
|
||||
if (! $exists) {
|
||||
$errorType = 'not_exists';
|
||||
} elseif (!$writable) {
|
||||
} elseif (! $writable) {
|
||||
// 권한 비트가 충분(0755+)한데도 쓰기 불가 → 소유권 불일치 가능성
|
||||
if ($permsOctal >= 0755 && $webServerUser && $owner && $owner !== $webServerUser) {
|
||||
$errorType = 'ownership_mismatch';
|
||||
} else {
|
||||
$errorType = 'not_writable';
|
||||
}
|
||||
} elseif (!$readable) {
|
||||
} elseif (! $readable) {
|
||||
$errorType = 'not_readable';
|
||||
} elseif ($hasSubdirectoryIssues) {
|
||||
// 상위는 OK, 하위 디렉토리만 문제
|
||||
@@ -920,7 +921,7 @@ function checkDirectoryPermissions(array $directories): array
|
||||
'failed_subdirectories' => $failedSubdirectories,
|
||||
];
|
||||
|
||||
if (!$passed) {
|
||||
if (! $passed) {
|
||||
$allPassed = false;
|
||||
}
|
||||
}
|
||||
@@ -934,14 +935,22 @@ function checkDirectoryPermissions(array $directories): array
|
||||
/**
|
||||
* 디렉토리의 하위를 재귀적으로 체크하여 권한이 없는 경로와 권한 정보 반환
|
||||
*
|
||||
* @param string $path 체크할 디렉토리 경로
|
||||
* 깊이 제한(maxDepth)을 둔다 (dev-g7#445 후속): storage 처럼 하위에 수만 개
|
||||
* 디렉토리가 누적되는 경로(예: storage/framework/testing/)를 무한 재귀로 순회하면
|
||||
* 인스톨러 2단계(요구사항 검증) 응답이 수 초 이상 지연된다. 쓰기 권한 문제는 트리
|
||||
* 상위 얕은 깊이에서 결정되므로(상위가 쓰기 가능하면 하위도 통상 상속), 제한 깊이까지만
|
||||
* 검사해 성능과 안내 유효성을 모두 확보한다.
|
||||
*
|
||||
* @param string $path 체크할 디렉토리 경로
|
||||
* @param int $maxDepth 재귀 최대 깊이 (0 = 현재 디렉토리의 직속 하위만). 기본 1
|
||||
* @param int $currentDepth 내부 재귀용 현재 깊이 (호출자는 지정하지 않음)
|
||||
* @return array<array{path: string, permissions: string}> 권한이 없는 하위 디렉토리 정보 배열
|
||||
*/
|
||||
function checkSubdirectoriesRecursive(string $path): array
|
||||
function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $currentDepth = 0): array
|
||||
{
|
||||
$failedPaths = [];
|
||||
|
||||
if (!is_dir($path)) {
|
||||
if (! is_dir($path)) {
|
||||
return $failedPaths;
|
||||
}
|
||||
|
||||
@@ -962,21 +971,21 @@ function checkSubdirectoriesRecursive(string $path): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
||||
$itemPath = $path.DIRECTORY_SEPARATOR.$item;
|
||||
|
||||
// 디렉토리인지 확인 (심볼릭 링크 제외)
|
||||
if (!is_dir($itemPath) || is_link($itemPath)) {
|
||||
if (! is_dir($itemPath) || is_link($itemPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 절대 경로를 상대 경로로 변환
|
||||
$relativePath = str_replace(BASE_PATH . '/', '', $itemPath);
|
||||
$relativePath = str_replace(BASE_PATH.'/', '', $itemPath);
|
||||
$relativePath = str_replace('\\', '/', $relativePath);
|
||||
|
||||
// 쓰기 권한 체크
|
||||
$isWritable = is_writable($itemPath);
|
||||
|
||||
if (!$isWritable) {
|
||||
if (! $isWritable) {
|
||||
// 권한 코드 가져오기
|
||||
$perms = fileperms($itemPath);
|
||||
$permissions = substr(sprintf('%o', $perms), -3);
|
||||
@@ -988,11 +997,15 @@ function checkSubdirectoriesRecursive(string $path): array
|
||||
}
|
||||
|
||||
// 재귀적으로 하위 디렉토리 체크 (권한 없어도 계속 진행)
|
||||
try {
|
||||
$subFailed = checkSubdirectoriesRecursive($itemPath);
|
||||
$failedPaths = array_merge($failedPaths, $subFailed);
|
||||
} catch (Exception $e) {
|
||||
// 하위 디렉토리 체크 실패해도 계속 진행
|
||||
// 깊이 제한 도달 시 더 내려가지 않는다 — storage/framework/testing 등에 누적된
|
||||
// 수만 개 하위 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단 (dev-g7#445 후속).
|
||||
if ($currentDepth < $maxDepth) {
|
||||
try {
|
||||
$subFailed = checkSubdirectoriesRecursive($itemPath, $maxDepth, $currentDepth + 1);
|
||||
$failedPaths = array_merge($failedPaths, $subFailed);
|
||||
} catch (Exception $e) {
|
||||
// 하위 디렉토리 체크 실패해도 계속 진행
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,12 +1017,12 @@ function checkSubdirectoriesRecursive(string $path): array
|
||||
*
|
||||
* 루트 LICENSE 파일을 읽습니다. (한국어 번역 + 영문 원문 통합)
|
||||
*
|
||||
* @param string $lang 언어 코드 (미사용, 하위 호환성 유지)
|
||||
* @param string $lang 언어 코드 (미사용, 하위 호환성 유지)
|
||||
* @return string 라이선스 파일 내용
|
||||
*/
|
||||
function loadLicenseFile(string $lang): string
|
||||
{
|
||||
$licenseFile = __DIR__ . '/../../../LICENSE';
|
||||
$licenseFile = __DIR__.'/../../../LICENSE';
|
||||
|
||||
if (file_exists($licenseFile)) {
|
||||
return file_get_contents($licenseFile);
|
||||
@@ -1024,7 +1037,7 @@ function loadLicenseFile(string $lang): string
|
||||
* 특수 문자가 포함된 값을 .env 파일에 안전하게 기록할 수 있도록 큰따옴표로 감싸고
|
||||
* 내부 큰따옴표와 백슬래시를 이스케이프합니다.
|
||||
*
|
||||
* @param string $value 이스케이프할 값
|
||||
* @param string $value 이스케이프할 값
|
||||
* @return string 큰따옴표로 감싸고 내부 큰따옴표와 백슬래시를 이스케이프한 값
|
||||
*/
|
||||
function escapeEnvValue(string $value): string
|
||||
@@ -1044,7 +1057,7 @@ function escapeEnvValue(string $value): string
|
||||
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
|
||||
|
||||
// 큰따옴표로 감싸기
|
||||
return '"' . $escaped . '"';
|
||||
return '"'.$escaped.'"';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1057,8 +1070,8 @@ function escapeEnvValue(string $value): string
|
||||
*/
|
||||
function generateEnvContent(): ?string
|
||||
{
|
||||
$envExamplePath = BASE_PATH . '/.env.example';
|
||||
if (!file_exists($envExamplePath)) {
|
||||
$envExamplePath = BASE_PATH.'/.env.example';
|
||||
if (! file_exists($envExamplePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1069,39 +1082,39 @@ function generateEnvContent(): ?string
|
||||
// 데이터베이스 설정 치환
|
||||
$replacements = [
|
||||
'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql',
|
||||
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST=' . ($config['db_write_host'] ?? '127.0.0.1'),
|
||||
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT=' . ($config['db_write_port'] ?? '3306'),
|
||||
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE=' . ($config['db_write_database'] ?? 'g7'),
|
||||
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME=' . ($config['db_write_username'] ?? 'root'),
|
||||
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? ''),
|
||||
'DB_PREFIX=g7_' => 'DB_PREFIX=' . ($config['db_prefix'] ?? 'g7_'),
|
||||
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.($config['db_write_host'] ?? '127.0.0.1'),
|
||||
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.($config['db_write_port'] ?? '3306'),
|
||||
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.($config['db_write_database'] ?? 'g7'),
|
||||
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.($config['db_write_username'] ?? 'root'),
|
||||
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''),
|
||||
'DB_PREFIX=g7_' => 'DB_PREFIX='.($config['db_prefix'] ?? 'g7_'),
|
||||
];
|
||||
|
||||
// Read DB 설정
|
||||
if (!empty($config['use_read_db']) && $config['use_read_db']) {
|
||||
$replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_read_host'] ?? '127.0.0.1');
|
||||
$replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_read_port'] ?? '3306');
|
||||
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_read_database'] ?? 'g7');
|
||||
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_read_username'] ?? 'root');
|
||||
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_read_password'] ?? '');
|
||||
if (! empty($config['use_read_db']) && $config['use_read_db']) {
|
||||
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_read_host'] ?? '127.0.0.1');
|
||||
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_read_port'] ?? '3306');
|
||||
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_read_database'] ?? 'g7');
|
||||
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root');
|
||||
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? '');
|
||||
} else {
|
||||
// Read DB를 사용하지 않는 경우 Write DB 정보와 동일하게 설정
|
||||
$replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_write_host'] ?? '127.0.0.1');
|
||||
$replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_write_port'] ?? '3306');
|
||||
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_write_database'] ?? 'g7');
|
||||
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_write_username'] ?? 'root');
|
||||
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? '');
|
||||
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_write_host'] ?? '127.0.0.1');
|
||||
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_write_port'] ?? '3306');
|
||||
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_write_database'] ?? 'g7');
|
||||
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_write_username'] ?? 'root');
|
||||
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? '');
|
||||
}
|
||||
|
||||
// 앱 설정 치환
|
||||
$replacements['APP_NAME=그누보드7'] = 'APP_NAME="' . ($config['app_name'] ?? '그누보드7') . '"';
|
||||
$replacements['APP_ENV=production'] = 'APP_ENV=' . ($config['app_env'] ?? 'production');
|
||||
$replacements['APP_URL=http://localhost'] = 'APP_URL=' . ($config['app_url'] ?? 'http://localhost');
|
||||
$replacements['APP_NAME=그누보드7'] = 'APP_NAME="'.($config['app_name'] ?? '그누보드7').'"';
|
||||
$replacements['APP_ENV=production'] = 'APP_ENV='.($config['app_env'] ?? 'production');
|
||||
$replacements['APP_URL=http://localhost'] = 'APP_URL='.($config['app_url'] ?? 'http://localhost');
|
||||
|
||||
// 언어 설정
|
||||
$currentLang = getCurrentLanguage();
|
||||
$replacements['APP_LOCALE=ko'] = 'APP_LOCALE=' . $currentLang;
|
||||
$replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE=' . ($currentLang === 'ko' ? 'ko_KR' : 'en_US');
|
||||
$replacements['APP_LOCALE=ko'] = 'APP_LOCALE='.$currentLang;
|
||||
$replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE='.($currentLang === 'ko' ? 'ko_KR' : 'en_US');
|
||||
|
||||
if (isset($config['app_env']) && $config['app_env'] !== 'production') {
|
||||
$replacements['APP_DEBUG=false'] = 'APP_DEBUG=true';
|
||||
@@ -1112,15 +1125,15 @@ function generateEnvContent(): ?string
|
||||
$coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7';
|
||||
$coreGithubToken = $config['core_update_github_token'] ?? '';
|
||||
|
||||
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH=' . escapeEnvValue($corePendingPath);
|
||||
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL=' . $coreGithubUrl;
|
||||
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN=' . escapeEnvValue($coreGithubToken);
|
||||
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.escapeEnvValue($corePendingPath);
|
||||
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.$coreGithubUrl;
|
||||
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.escapeEnvValue($coreGithubToken);
|
||||
|
||||
// PHP CLI / Composer 바이너리 경로 치환
|
||||
$phpBinary = $config['php_binary'] ?? 'php';
|
||||
$composerBinary = $config['composer_binary'] ?? '';
|
||||
$replacements['PHP_BINARY=php'] = 'PHP_BINARY=' . escapeEnvValue($phpBinary);
|
||||
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY=' . escapeEnvValue($composerBinary);
|
||||
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.escapeEnvValue($phpBinary);
|
||||
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.escapeEnvValue($composerBinary);
|
||||
|
||||
foreach ($replacements as $search => $replace) {
|
||||
$envContent = str_replace($search, $replace, $envContent);
|
||||
@@ -1142,22 +1155,22 @@ function isWindows(): bool
|
||||
/**
|
||||
* OS에 맞는 .env 파일 복사 명령어를 반환합니다.
|
||||
*
|
||||
* @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용)
|
||||
* @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용)
|
||||
* @return string 복사 명령어
|
||||
*/
|
||||
function getEnvCopyCommand(string $basePath = ''): string
|
||||
{
|
||||
if (isWindows()) {
|
||||
$source = $basePath ? $basePath . '\\.env.example' : '.env.example';
|
||||
$dest = $basePath ? $basePath . '\\.env' : '.env';
|
||||
$source = $basePath ? $basePath.'\\.env.example' : '.env.example';
|
||||
$dest = $basePath ? $basePath.'\\.env' : '.env';
|
||||
|
||||
return 'copy ' . str_replace('/', '\\', $source) . ' ' . str_replace('/', '\\', $dest);
|
||||
return 'copy '.str_replace('/', '\\', $source).' '.str_replace('/', '\\', $dest);
|
||||
}
|
||||
|
||||
$source = $basePath ? $basePath . '/.env.example' : '.env.example';
|
||||
$dest = $basePath ? $basePath . '/.env' : '.env';
|
||||
$source = $basePath ? $basePath.'/.env.example' : '.env.example';
|
||||
$dest = $basePath ? $basePath.'/.env' : '.env';
|
||||
|
||||
return 'cp ' . $source . ' ' . $dest;
|
||||
return 'cp '.$source.' '.$dest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1167,8 +1180,8 @@ function getEnvCopyCommand(string $basePath = ''): string
|
||||
* chown/setgid 의존성을 제거하여 공유호스팅 호환성 확보.
|
||||
* 실제 통과 기준은 is_writable() && is_readable()이므로 비트 값은 참고용.
|
||||
*
|
||||
* @param string $pathList 대상 경로 (공백 구분)
|
||||
* @param string $mode 'ownership' (디렉토리) 또는 'file' (파일)
|
||||
* @param string $pathList 대상 경로 (공백 구분)
|
||||
* @param string $mode 'ownership' (디렉토리) 또는 'file' (파일)
|
||||
* @return string 권한 수정 명령어
|
||||
*/
|
||||
function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): string
|
||||
@@ -1176,20 +1189,19 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'):
|
||||
if (isWindows()) {
|
||||
$winPath = str_replace('/', '\\', $pathList);
|
||||
if ($mode === 'file') {
|
||||
return 'icacls ' . $winPath . ' /grant Everyone:F';
|
||||
return 'icacls '.$winPath.' /grant Everyone:F';
|
||||
}
|
||||
|
||||
return 'icacls ' . $winPath . ' /grant Everyone:(OI)(CI)F /T';
|
||||
return 'icacls '.$winPath.' /grant Everyone:(OI)(CI)F /T';
|
||||
}
|
||||
|
||||
if ($mode === 'file') {
|
||||
return 'chmod 644 ' . $pathList;
|
||||
return 'chmod 644 '.$pathList;
|
||||
}
|
||||
|
||||
return 'chmod -R 755 ' . $pathList;
|
||||
return 'chmod -R 755 '.$pathList;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 기존 DB 테이블 감지.
|
||||
*
|
||||
@@ -1202,9 +1214,8 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'):
|
||||
* - 'mixed' : G7 일부 + 기타 혼재
|
||||
* - 'foreign_data' : G7 시그니처 없음 + 기타 테이블 존재
|
||||
*
|
||||
* @param PDO $pdo 연결된 PDO 인스턴스
|
||||
* @param string $database 대상 데이터베이스명
|
||||
* @return array
|
||||
* @param PDO $pdo 연결된 PDO 인스턴스
|
||||
* @param string $database 대상 데이터베이스명
|
||||
*/
|
||||
function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ''): array
|
||||
{
|
||||
@@ -1237,7 +1248,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = '
|
||||
// G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
|
||||
// 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등)
|
||||
$coreSignatures = ['users', 'migrations', 'roles', 'permissions'];
|
||||
$g7Signatures = array_map(fn ($t) => $tablePrefix . $t, $coreSignatures);
|
||||
$g7Signatures = array_map(fn ($t) => $tablePrefix.$t, $coreSignatures);
|
||||
$g7TablesFound = array_values(array_intersect($g7Signatures, $tables));
|
||||
|
||||
$severity = 'foreign_data';
|
||||
@@ -1257,7 +1268,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = '
|
||||
];
|
||||
}
|
||||
|
||||
if (!function_exists('filterTablesByPrefix')) {
|
||||
if (! function_exists('filterTablesByPrefix')) {
|
||||
/**
|
||||
* 기존 테이블 목록을 db_prefix 로 필터링합니다.
|
||||
*
|
||||
@@ -1268,8 +1279,8 @@ if (!function_exists('filterTablesByPrefix')) {
|
||||
* 빈 prefix 는 전체 일치가 되어 모든 테이블이 삭제되는 위험이 있으므로,
|
||||
* 데이터 손실 방어를 위해 빈 목록(삭제 대상 없음)을 반환합니다.
|
||||
*
|
||||
* @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록
|
||||
* @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_')
|
||||
* @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록
|
||||
* @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_')
|
||||
* @return array prefix 로 시작하는 테이블만 담은 목록
|
||||
*/
|
||||
function filterTablesByPrefix(array $tables, string $prefix): array
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
*
|
||||
* 설치 진행 상태를 storage/installer-state.json에 저장하고 조회합니다.
|
||||
*/
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
if (! defined('BASE_PATH')) {
|
||||
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로
|
||||
}
|
||||
|
||||
if (! defined('STATE_PATH')) {
|
||||
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
|
||||
define('STATE_PATH', BASE_PATH.'/storage/installer-state.json');
|
||||
}
|
||||
if (! defined('INSTALLER_DIR')) {
|
||||
define('INSTALLER_DIR', BASE_PATH . '/storage/installer');
|
||||
define('INSTALLER_DIR', BASE_PATH.'/storage/installer');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,15 +35,16 @@ function getInstallationState(): array
|
||||
// state.json 파일이 없으면 기본 상태 반환
|
||||
// (설치 완료 후 워커가 DELETE_INSTALLER_AFTER_COMPLETE로 정상 삭제하는 경우가 있으므로
|
||||
// 부재 자체는 에러가 아님. 호출자가 g7_installed 플래그 등으로 완료 여부 판정)
|
||||
if (!file_exists(STATE_PATH)) {
|
||||
if (! file_exists(STATE_PATH)) {
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
// 파일 읽기 권한 체크
|
||||
if (!is_readable(STATE_PATH)) {
|
||||
$msg = "[installer-state] State file is not readable: " . STATE_PATH;
|
||||
if (! is_readable(STATE_PATH)) {
|
||||
$msg = '[installer-state] State file is not readable: '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,10 @@ function getInstallationState(): array
|
||||
|
||||
// 파일 읽기 실패 시 기본 상태 반환
|
||||
if ($content === false) {
|
||||
$msg = "[installer-state] Failed to read state file: " . STATE_PATH;
|
||||
$msg = '[installer-state] Failed to read state file: '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return $defaultState;
|
||||
}
|
||||
|
||||
@@ -65,9 +66,10 @@ function getInstallationState(): array
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$contentLen = strlen($content);
|
||||
$preview = substr($content, 0, 200);
|
||||
$msg = "[installer-state] 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;
|
||||
}
|
||||
|
||||
@@ -77,25 +79,27 @@ function getInstallationState(): array
|
||||
/**
|
||||
* 설치 상태 저장
|
||||
*
|
||||
* @param array $state 저장할 상태 배열
|
||||
* @param array $state 저장할 상태 배열
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
function saveInstallationState(array $state): bool
|
||||
{
|
||||
// storage 디렉토리 존재 여부 확인 (생성하지 않음)
|
||||
$storageDir = BASE_PATH . '/storage';
|
||||
if (!is_dir($storageDir)) {
|
||||
$storageDir = BASE_PATH.'/storage';
|
||||
if (! is_dir($storageDir)) {
|
||||
$msg = "[installer-state] Storage directory does not exist: {$storageDir}";
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 디렉토리 쓰기 권한 확인
|
||||
if (!is_writable($storageDir)) {
|
||||
if (! is_writable($storageDir)) {
|
||||
$msg = "[installer-state] Storage directory is not writable: {$storageDir}";
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -106,33 +110,36 @@ function saveInstallationState(array $state): bool
|
||||
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($content === false) {
|
||||
$msg = "[installer-state] 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;
|
||||
}
|
||||
|
||||
// 원자적 쓰기: tmp 파일에 쓰고 rename으로 교체
|
||||
// 동시 쓰기 경쟁 상황에서 부분 쓰기(half-written file)로 인한 JSON 손상 방지.
|
||||
// Linux에서 rename()은 atomic syscall이므로 다른 리더가 항상 완전한 파일만 읽는다.
|
||||
$tmpPath = STATE_PATH . '.tmp.' . getmypid() . '.' . uniqid();
|
||||
$tmpPath = STATE_PATH.'.tmp.'.getmypid().'.'.uniqid();
|
||||
|
||||
$result = @file_put_contents($tmpPath, $content, LOCK_EX);
|
||||
if ($result === false || $result !== strlen($content)) {
|
||||
$msg = "[installer-state] 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;
|
||||
}
|
||||
|
||||
@chmod($tmpPath, 0664);
|
||||
|
||||
if (!@rename($tmpPath, STATE_PATH)) {
|
||||
$msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH;
|
||||
if (! @rename($tmpPath, STATE_PATH)) {
|
||||
$msg = '[installer-state] Failed to rename state tmp file: '.$tmpPath.' → '.STATE_PATH;
|
||||
error_log($msg);
|
||||
addLog($msg);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -161,13 +168,13 @@ function isInstallationCompleted(): bool
|
||||
}
|
||||
|
||||
// g7_installed 파일 존재 여부 확인 (추가 안전장치)
|
||||
$installedFlagPath = BASE_PATH . '/storage/app/g7_installed';
|
||||
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
|
||||
if (file_exists($installedFlagPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .env 파일의 INSTALLER_COMPLETED 플래그 확인
|
||||
$envPath = BASE_PATH . '/.env';
|
||||
$envPath = BASE_PATH.'/.env';
|
||||
if (file_exists($envPath)) {
|
||||
$envContent = file_get_contents($envPath);
|
||||
if (strpos($envContent, 'INSTALLER_COMPLETED=true') !== false) {
|
||||
@@ -181,7 +188,7 @@ function isInstallationCompleted(): bool
|
||||
/**
|
||||
* 특정 작업을 완료로 표시
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function markTaskCompleted(string $task): bool
|
||||
@@ -189,7 +196,7 @@ function markTaskCompleted(string $task): bool
|
||||
$state = getInstallationState();
|
||||
|
||||
// completed_tasks 배열에 추가 (중복 방지)
|
||||
if (!in_array($task, $state['completed_tasks'])) {
|
||||
if (! in_array($task, $state['completed_tasks'])) {
|
||||
$state['completed_tasks'][] = $task;
|
||||
}
|
||||
|
||||
@@ -205,7 +212,7 @@ function markTaskCompleted(string $task): bool
|
||||
/**
|
||||
* 특정 작업을 완료 목록에서 제거
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function removeTaskCompleted(string $task): bool
|
||||
@@ -213,7 +220,7 @@ function removeTaskCompleted(string $task): bool
|
||||
$state = getInstallationState();
|
||||
|
||||
$state['completed_tasks'] = array_values(
|
||||
array_filter($state['completed_tasks'], fn($t) => $t !== $task)
|
||||
array_filter($state['completed_tasks'], fn ($t) => $t !== $task)
|
||||
);
|
||||
|
||||
return saveInstallationState($state);
|
||||
@@ -251,7 +258,7 @@ function removeTaskCompleted(string $task): bool
|
||||
*
|
||||
* @param int $staleSeconds heartbeat 가 이 시간 이상 갱신 안 되면 stale 판정 (default 15초)
|
||||
* @return array{acquired: bool, worker_id: string|null, reason: string}
|
||||
* reason: 'available' | 'takeover_stale' | 'busy'
|
||||
* reason: 'available' | 'takeover_stale' | 'busy'
|
||||
*/
|
||||
function acquireWorkerLock(int $staleSeconds = 15): array
|
||||
{
|
||||
@@ -337,7 +344,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
|
||||
$resetTasks = ['db_cleanup', 'db_migrate', 'db_seed'];
|
||||
$state['completed_tasks'] = array_values(array_filter(
|
||||
$state['completed_tasks'] ?? [],
|
||||
fn($t) => !in_array($t, $resetTasks, true)
|
||||
fn ($t) => ! in_array($t, $resetTasks, true)
|
||||
));
|
||||
} else {
|
||||
// 키 정규화 — completed_tasks 가 부재인 경우 빈 배열 보장
|
||||
@@ -350,7 +357,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
|
||||
/**
|
||||
* 현재 진행 중인 작업 업데이트
|
||||
*
|
||||
* @param string $task 작업 식별자
|
||||
* @param string $task 작업 식별자
|
||||
* @return bool 업데이트 성공 여부
|
||||
*/
|
||||
function updateCurrentTask(string $task): bool
|
||||
@@ -369,7 +376,7 @@ function updateCurrentTask(string $task): bool
|
||||
* 페이지 새로고침/재시작 시 로그가 즉시 표시되도록
|
||||
* fflush() + clearstatcache()를 적용합니다.
|
||||
*
|
||||
* @param string $message 로그 메시지
|
||||
* @param string $message 로그 메시지
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
/**
|
||||
@@ -389,23 +396,25 @@ function addLogBatch(array $messages): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
$logDir = BASE_PATH . '/storage/logs';
|
||||
$logFile = $logDir . '/installation.log';
|
||||
$logDir = BASE_PATH.'/storage/logs';
|
||||
$logFile = $logDir.'/installation.log';
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
if (! is_dir($logDir)) {
|
||||
$created = @mkdir($logDir, 0775, true);
|
||||
if (!$created) {
|
||||
if (! $created) {
|
||||
$msg = "[addLogBatch] Failed to create log directory: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable($logDir)) {
|
||||
if (! is_writable($logDir)) {
|
||||
$msg = "[addLogBatch] Log directory is not writable: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -425,9 +434,9 @@ function addLogBatch(array $messages): bool
|
||||
}
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
|
||||
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
|
||||
if ($isNewFile) {
|
||||
$entries = "\xEF\xBB\xBF" . $entries;
|
||||
$entries = "\xEF\xBB\xBF".$entries;
|
||||
}
|
||||
|
||||
$handle = @fopen($logFile, 'a');
|
||||
@@ -435,6 +444,7 @@ function addLogBatch(array $messages): bool
|
||||
$msg = "[addLogBatch] Failed to open log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
flock($handle, LOCK_EX);
|
||||
@@ -455,7 +465,8 @@ function addLog(string $message): bool
|
||||
static $inAddLog = false;
|
||||
if ($inAddLog) {
|
||||
// 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료
|
||||
error_log("[addLog reentrant] " . $message);
|
||||
error_log('[addLog reentrant] '.$message);
|
||||
|
||||
return false;
|
||||
}
|
||||
$inAddLog = true;
|
||||
@@ -478,25 +489,27 @@ function addLog(string $message): bool
|
||||
*/
|
||||
function _addLogInternal(string $message): bool
|
||||
{
|
||||
$logDir = BASE_PATH . '/storage/logs';
|
||||
$logFile = $logDir . '/installation.log';
|
||||
$logDir = BASE_PATH.'/storage/logs';
|
||||
$logFile = $logDir.'/installation.log';
|
||||
|
||||
// 로그 디렉토리 확인 및 생성 시도
|
||||
if (!is_dir($logDir)) {
|
||||
if (! is_dir($logDir)) {
|
||||
$created = @mkdir($logDir, 0775, true);
|
||||
if (!$created) {
|
||||
if (! $created) {
|
||||
$msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안)
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 디렉토리 쓰기 권한 확인
|
||||
if (!is_writable($logDir)) {
|
||||
if (! is_writable($logDir)) {
|
||||
$msg = "[addLog] Log directory is not writable: {$logDir}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -508,6 +521,17 @@ function _addLogInternal(string $message): bool
|
||||
}
|
||||
}
|
||||
|
||||
// OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
|
||||
// composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나
|
||||
// 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에
|
||||
// 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면
|
||||
// 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200)
|
||||
// → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다.
|
||||
// mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다.
|
||||
if (! mb_check_encoding($message, 'UTF-8')) {
|
||||
$message = mb_scrub($message, 'UTF-8');
|
||||
}
|
||||
|
||||
// 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요)
|
||||
$microtime = microtime(true);
|
||||
$ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000));
|
||||
@@ -518,10 +542,10 @@ function _addLogInternal(string $message): bool
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
// 파일이 없거나 빈 파일이면 UTF-8 BOM 추가 (Windows 텍스트 편집기 호환성)
|
||||
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
|
||||
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
|
||||
if ($isNewFile) {
|
||||
$utf8Bom = "\xEF\xBB\xBF";
|
||||
$logEntry = $utf8Bom . $logEntry;
|
||||
$logEntry = $utf8Bom.$logEntry;
|
||||
}
|
||||
|
||||
// 파일 핸들 열기 (append 모드)
|
||||
@@ -530,6 +554,7 @@ function _addLogInternal(string $message): bool
|
||||
$msg = "[addLog] Failed to open log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -553,6 +578,7 @@ function _addLogInternal(string $message): bool
|
||||
$msg = "[addLog] Failed to write log file: {$logFile}";
|
||||
error_log($msg);
|
||||
addLog($msg); // 재귀 가드가 차단
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -565,16 +591,16 @@ function _addLogInternal(string $message): bool
|
||||
* 페이지 새로고침 시 최신 로그를 즉시 표시하기 위해
|
||||
* clearstatcache()를 적용합니다.
|
||||
*
|
||||
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
|
||||
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
|
||||
* @return array 로그 배열 [{timestamp, message}, ...]
|
||||
*/
|
||||
function getInstallationLogs(int $offset = 0): array
|
||||
{
|
||||
$logFile = BASE_PATH . '/storage/logs/installation.log';
|
||||
$logFile = BASE_PATH.'/storage/logs/installation.log';
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
if (! file_exists($logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -621,11 +647,11 @@ function getInstallationLogs(int $offset = 0): array
|
||||
*/
|
||||
function getInstallationLogCount(): int
|
||||
{
|
||||
$logFile = BASE_PATH . '/storage/logs/installation.log';
|
||||
$logFile = BASE_PATH.'/storage/logs/installation.log';
|
||||
|
||||
clearstatcache(true, $logFile);
|
||||
|
||||
if (!file_exists($logFile)) {
|
||||
if (! file_exists($logFile)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -637,10 +663,11 @@ function getInstallationLogCount(): int
|
||||
$lines = explode("\n", trim($content));
|
||||
$count = 0;
|
||||
foreach ($lines as $line) {
|
||||
if (!empty($line)) {
|
||||
if (! empty($line)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
@@ -655,7 +682,7 @@ function getLastCompletedStep(): int
|
||||
|
||||
// step_status를 역순으로 확인
|
||||
for ($step = 5; $step >= 0; $step--) {
|
||||
$stepKey = (string)$step;
|
||||
$stepKey = (string) $step;
|
||||
if (isset($state['step_status'][$stepKey]) && $state['step_status'][$stepKey] === 'completed') {
|
||||
return $step;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@
|
||||
* SSE/폴링 듀얼 모드 지원을 위한 진행 상황 보고 인터페이스.
|
||||
* - SseEmitter: 실시간 SSE 스트리밍 + addLog
|
||||
* - NullEmitter: addLog만 수행 (폴링 모드, state.json 기반 조회)
|
||||
*
|
||||
* @package G7\Installer
|
||||
*/
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
if (! defined('BASE_PATH')) {
|
||||
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3));
|
||||
}
|
||||
|
||||
@@ -19,8 +16,8 @@ interface ProgressEmitter
|
||||
/**
|
||||
* 임의 이벤트 송출 (SSE 이벤트 타입과 데이터 배열).
|
||||
*
|
||||
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
|
||||
* @param array $data 이벤트 데이터
|
||||
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
|
||||
* @param array $data 이벤트 데이터
|
||||
*/
|
||||
public function emit(string $event, array $data): void;
|
||||
|
||||
@@ -57,8 +54,22 @@ class SseEmitter implements ProgressEmitter
|
||||
|
||||
public function emit(string $event, array $data): void
|
||||
{
|
||||
// SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62).
|
||||
// composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면
|
||||
// json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고,
|
||||
// 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다.
|
||||
// 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고
|
||||
// false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망)
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($payload === false) {
|
||||
$payload = json_encode(
|
||||
['message' => '(log line dropped: encoding error)'],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
}
|
||||
|
||||
echo "event: {$event}\n";
|
||||
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
|
||||
echo 'data: '.$payload."\n\n";
|
||||
if (ob_get_level()) {
|
||||
@ob_flush();
|
||||
}
|
||||
@@ -195,9 +206,10 @@ class NullEmitter implements ProgressEmitter
|
||||
*/
|
||||
function getProgressEmitter(): ProgressEmitter
|
||||
{
|
||||
if (!isset($GLOBALS['g7_progress_emitter']) || !($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
|
||||
$GLOBALS['g7_progress_emitter'] = new SseEmitter();
|
||||
if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
|
||||
$GLOBALS['g7_progress_emitter'] = new SseEmitter;
|
||||
}
|
||||
|
||||
return $GLOBALS['g7_progress_emitter'];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 설치 로그 기록 시 UTF-8 정규화(scrub) 회귀 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62, dev-g7#445):
|
||||
* Vendor 설치 방식 "Composer 실행" + 폴링 모드 조합에서 브라우저 콘솔에
|
||||
* "Unexpected end of JSON input" 이 폭주하고 진행 상황이 표시되지 않는 회귀.
|
||||
*
|
||||
* 근본 원인: 폴링 워커가 composer install stdout 을 fgets() 로 읽어 정규화 없이
|
||||
* addLog() 로 넘긴다. Windows 코드페이지(CP949) 출력이나 다운로드 진행바(\r 갱신)가
|
||||
* 임의 바이트 경계에서 잘리면 invalid UTF-8 바이트가 생기는데,
|
||||
* _addLogInternal() 은 mb_detect_encoding(strict=true) 감지 실패 시 raw 바이트를
|
||||
* 그대로 로그에 저장한다. 그 로그를 포함한 폴링 응답 state-management.php 의
|
||||
* json_encode() 가 invalid UTF-8 때문에 false 를 반환 → echo false = 빈 본문(HTTP 200)
|
||||
* → 프론트 res.json() 이 "Unexpected end of JSON input" 실패.
|
||||
*
|
||||
* 본 테스트는 근본 지점을 검증한다: addLog() 에 invalid UTF-8 바이트를 주입해도
|
||||
* getInstallationLogs() 로 읽은 로그 메시지가 항상 유효 UTF-8 이어서
|
||||
* json_encode() 가 false 가 아니어야 한다.
|
||||
*
|
||||
* BASE_PATH 는 PHP 상수로 클래스 라이프사이클 단위 단 한 번 정의 (setUpBeforeClass).
|
||||
* InstallerStateSchemaTest 와 동일한 temp 격리 패턴을 사용한다.
|
||||
*/
|
||||
#[RunClassInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
class AddLogUtf8ScrubTest extends TestCase
|
||||
{
|
||||
private static string $sharedBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
// 안전 가드: BASE_PATH 가 시스템 temp 하위가 아니면 (= 다른 테스트가 프로젝트 루트로 박은 경우)
|
||||
// setUp 의 installation.log @unlink 가 실제 설치 로그를 파괴할 수 있으므로 skip.
|
||||
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
||||
if (defined('BASE_PATH')) {
|
||||
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
|
||||
if (strpos($resolved, $tempPrefix) !== 0) {
|
||||
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
|
||||
'다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 격리 실행 필요: '.
|
||||
'php vendor/bin/phpunit --filter=AddLogUtf8ScrubTest';
|
||||
|
||||
return;
|
||||
}
|
||||
self::$sharedBase = (string) BASE_PATH;
|
||||
} else {
|
||||
self::$sharedBase = sys_get_temp_dir().'/g7-installer-addlog-test-'.bin2hex(random_bytes(4));
|
||||
define('BASE_PATH', self::$sharedBase);
|
||||
}
|
||||
|
||||
$logDir = self::$sharedBase.'/storage/logs';
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// config.php 의 INSTALLER_BASE_URL 계산이 $_SERVER['SCRIPT_NAME'] 에 의존
|
||||
if (! isset($_SERVER['SCRIPT_NAME'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/installer-state.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php';
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
if (self::$skipReason === '' && self::$sharedBase !== '') {
|
||||
$logFile = self::$sharedBase.'/storage/logs/installation.log';
|
||||
if (file_exists($logFile)) {
|
||||
@unlink($logFile);
|
||||
}
|
||||
}
|
||||
parent::tearDownAfterClass();
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (self::$skipReason !== '') {
|
||||
$this->markTestSkipped(self::$skipReason);
|
||||
}
|
||||
|
||||
// 각 테스트 시작 시 로그 초기화
|
||||
$logFile = self::$sharedBase.'/storage/logs/installation.log';
|
||||
if (file_exists($logFile)) {
|
||||
@unlink($logFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* invalid UTF-8 바이트 표본.
|
||||
*
|
||||
* - 잘린 한글 선행 바이트 (EC 99 은 3바이트 UTF-8 의 앞 2바이트, 마지막 바이트 누락)
|
||||
* - 단독 0xFF (UTF-8 에서 절대 유효하지 않은 바이트)
|
||||
* - CP949 로 인코딩된 한글이 감지 실패하는 케이스 모사
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function invalidUtf8Provider(): array
|
||||
{
|
||||
return [
|
||||
'truncated hangul lead bytes' => ["Downloading \xEC\x99 package"],
|
||||
'lone 0xFF byte' => ["progress: abc\xFFdef 100%"],
|
||||
'truncated 4-byte sequence' => ["emoji \xF0\x9F head"], // 4바이트 UTF-8 의 앞 2바이트만
|
||||
'lone continuation byte' => ["tail\x80end"],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function addlog_stores_valid_utf8_even_for_invalid_input(string $rawMessage): void
|
||||
{
|
||||
// 사전 조건: 입력은 실제로 invalid UTF-8 이어야 테스트가 의미 있음
|
||||
$this->assertFalse(
|
||||
mb_check_encoding($rawMessage, 'UTF-8'),
|
||||
'테스트 입력 자체가 invalid UTF-8 이어야 한다 (표본 점검).'
|
||||
);
|
||||
|
||||
$written = addLog($rawMessage);
|
||||
$this->assertTrue($written, 'addLog 는 성공적으로 로그를 기록해야 한다.');
|
||||
|
||||
$logs = getInstallationLogs();
|
||||
$this->assertNotEmpty($logs, '기록한 로그가 조회되어야 한다.');
|
||||
|
||||
$message = $logs[count($logs) - 1]['message'] ?? '';
|
||||
|
||||
// 핵심 계약: 저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다.
|
||||
$this->assertTrue(
|
||||
mb_check_encoding($message, 'UTF-8'),
|
||||
'저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다 (invalid 바이트는 scrub 대체). '.
|
||||
'실패 시: composer 출력의 깨진 바이트가 로그에 raw 저장되어 폴링 응답 json_encode 가 무력화됨.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function polling_response_json_encode_never_returns_false(string $rawMessage): void
|
||||
{
|
||||
addLog($rawMessage);
|
||||
|
||||
$logs = getInstallationLogs();
|
||||
|
||||
// 폴링 응답(state-management.php getState) 이 만드는 것과 동일한 구조를 모사.
|
||||
$response = [
|
||||
'status' => 'running',
|
||||
'logs' => $logs,
|
||||
'log_total' => getInstallationLogCount(),
|
||||
];
|
||||
|
||||
// 근본 수정 후: logs 가 유효 UTF-8 이므로 substitute 플래그 없이도 false 가 아니어야 한다.
|
||||
$encoded = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$encoded,
|
||||
'invalid UTF-8 로그가 섞여도 폴링 응답 json_encode 는 false 를 반환하면 안 된다 '.
|
||||
'(false → echo 빈 본문 → 프론트 "Unexpected end of JSON input").'
|
||||
);
|
||||
$this->assertIsString($encoded);
|
||||
$this->assertNotSame('', $encoded, '인코딩 결과 본문이 비어 있으면 안 된다.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addlog_preserves_valid_utf8_korean_unchanged(): void
|
||||
{
|
||||
// 정규화가 정상 UTF-8 한글을 훼손하지 않아야 한다 (회귀 방지).
|
||||
// 첫 로그 줄은 UTF-8 BOM 이 붙어 getInstallationLogs 의 [timestamp] 파싱에 간섭하므로
|
||||
// 워밍업 로그를 먼저 기록해 검증 대상 줄을 두 번째 이후로 밀어낸다.
|
||||
addLog('warmup');
|
||||
|
||||
$korean = 'Composer 의존성 설치를 시작합니다.';
|
||||
|
||||
addLog($korean);
|
||||
$logs = getInstallationLogs();
|
||||
$message = $logs[count($logs) - 1]['message'] ?? '';
|
||||
|
||||
$this->assertSame($korean, $message, '정상 UTF-8 한글은 scrub 후에도 변형되지 않아야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 이벤트 스트림도 invalid UTF-8 로그로 인해 빈 data 라인을 송출하면 안 된다 (인코딩 계약).
|
||||
*
|
||||
* 폴링 모드로 제보됐으나(#62), 근본 원인(외부 유래 invalid UTF-8 → json_encode false)은
|
||||
* SSE 모드에도 동일하게 존재한다. SseEmitter::emit 의 json_encode 가 false 를 반환하면
|
||||
* "data: \n\n" 이 송출되어 프론트 EventSource 의 JSON.parse(e.data) 가 throw → 로그 이벤트 유실.
|
||||
*
|
||||
* SseEmitter::emit 은 내부에서 즉시 flush() 하므로 ob_get_clean() 으로 출력을 캡처할 수 없다
|
||||
* (SSE 스트리밍 설계상 의도된 동작). 따라서 emit 이 사용하는 인코딩 계약을 직접 검증한다:
|
||||
* JSON_INVALID_UTF8_SUBSTITUTE 를 적용하면 invalid UTF-8 이 섞여도 false 가 아니어야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('invalidUtf8Provider')]
|
||||
public function sse_emit_encoding_contract_never_returns_false_for_invalid_utf8(string $rawMessage): void
|
||||
{
|
||||
// emit 이 실제 사용하는 것과 동일한 인코딩 호출.
|
||||
$payload = json_encode(
|
||||
['message' => $rawMessage],
|
||||
JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$payload,
|
||||
'SSE data 인코딩은 invalid UTF-8 로그가 섞여도 false 를 반환하면 안 된다 '.
|
||||
'(false → "data: \n\n" → 프론트 JSON.parse throw → 로그 이벤트 유실).'
|
||||
);
|
||||
$this->assertIsString($payload);
|
||||
|
||||
// 프론트 JSON.parse(e.data) 와 동일하게 파싱 가능해야 한다.
|
||||
$decoded = json_decode($payload, true);
|
||||
$this->assertSame(JSON_ERROR_NONE, json_last_error());
|
||||
$this->assertIsArray($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* progress-emitter.php 의 SseEmitter::emit 이 JSON_INVALID_UTF8_SUBSTITUTE 를 적용하는지
|
||||
* 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식).
|
||||
*/
|
||||
#[Test]
|
||||
public function sse_emitter_source_uses_invalid_utf8_substitute_flag(): void
|
||||
{
|
||||
$source = (string) file_get_contents(
|
||||
dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php'
|
||||
);
|
||||
|
||||
// SseEmitter 클래스 본문 추출
|
||||
$classStart = strpos($source, 'class SseEmitter');
|
||||
$this->assertNotFalse($classStart, 'SseEmitter 클래스를 찾을 수 없습니다.');
|
||||
$classEnd = strpos($source, 'class NullEmitter', $classStart);
|
||||
$sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'JSON_INVALID_UTF8_SUBSTITUTE',
|
||||
$sseBody,
|
||||
'SseEmitter::emit 의 json_encode 는 JSON_INVALID_UTF8_SUBSTITUTE 를 적용해 '.
|
||||
'invalid UTF-8 로그로 인한 빈 SSE data 라인 송출을 차단해야 한다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,17 @@ class DeleteDirectoryTest extends TestCase
|
||||
define('INSTALLER_BASE_URL', '/install');
|
||||
}
|
||||
|
||||
// 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가 — BASE_PATH 상수 사용)
|
||||
require_once BASE_PATH . '/public/install/includes/functions.php';
|
||||
require_once BASE_PATH . '/public/install/api/rollback-functions.php';
|
||||
// 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가).
|
||||
// 프로젝트 루트 절대경로(__DIR__ 기준)로 로드한다 — 다른 인스톨러 테스트
|
||||
// (#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인 프로세스로 BASE_PATH 를
|
||||
// 누수시켜도 그 temp 경로에는 public/install 이 없어 require 가 깨지므로, 누수된
|
||||
// BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다.
|
||||
$installerRoot = dirname(__DIR__, 3);
|
||||
require_once $installerRoot.'/public/install/includes/functions.php';
|
||||
require_once $installerRoot.'/public/install/api/rollback-functions.php';
|
||||
|
||||
// 임시 디렉토리 생성
|
||||
$this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'g7_test_delete_' . uniqid();
|
||||
$this->tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7_test_delete_'.uniqid();
|
||||
mkdir($this->tempDir, 0755, true);
|
||||
}
|
||||
|
||||
@@ -67,10 +72,10 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_preserves_gitkeep_file(): void
|
||||
{
|
||||
// Given: 디렉토리에 .gitkeep과 일반 파일이 있음
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
file_put_contents($this->tempDir . '/some_file.txt', 'test');
|
||||
mkdir($this->tempDir . '/subdir', 0755);
|
||||
file_put_contents($this->tempDir . '/subdir/nested.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
file_put_contents($this->tempDir.'/some_file.txt', 'test');
|
||||
mkdir($this->tempDir.'/subdir', 0755);
|
||||
file_put_contents($this->tempDir.'/subdir/nested.txt', 'test');
|
||||
|
||||
// When: deleteDirectory 호출 (기본: removeDir=false)
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -78,9 +83,9 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: .gitkeep은 보존, 나머지는 삭제, 디렉토리 자체는 유지
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/some_file.txt');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir . '/subdir');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/some_file.txt');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir.'/subdir');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,8 +94,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_preserves_gitignore_file(): void
|
||||
{
|
||||
// Given: 디렉토리에 .gitignore와 일반 파일이 있음
|
||||
file_put_contents($this->tempDir . '/.gitignore', '*');
|
||||
file_put_contents($this->tempDir . '/data.log', 'log');
|
||||
file_put_contents($this->tempDir.'/.gitignore', '*');
|
||||
file_put_contents($this->tempDir.'/data.log', 'log');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -98,8 +103,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitignore');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/data.log');
|
||||
$this->assertFileExists($this->tempDir.'/.gitignore');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/data.log');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,8 +113,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_keeps_directory_when_no_preserve_files_and_remove_dir_false(): void
|
||||
{
|
||||
// Given: 보호 파일 없이 일반 파일만 있음
|
||||
file_put_contents($this->tempDir . '/file1.txt', 'test');
|
||||
file_put_contents($this->tempDir . '/file2.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file1.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file2.txt', 'test');
|
||||
|
||||
// When: removeDir=false (기본값)
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -117,8 +122,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: 디렉토리 자체는 유지, 내용물은 삭제
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/file1.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/file2.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/file1.txt');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/file2.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +132,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_keeps_directory_when_preserve_files_exist_even_with_remove_dir(): void
|
||||
{
|
||||
// Given: .gitkeep이 있음
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
file_put_contents($this->tempDir . '/other.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
file_put_contents($this->tempDir.'/other.txt', 'test');
|
||||
|
||||
// When: removeDir=true여도 보호 파일이 있으면 디렉토리 유지
|
||||
$result = deleteDirectory($this->tempDir, true);
|
||||
@@ -136,8 +141,8 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/other.txt');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/other.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +151,7 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_removes_directory_when_no_preserve_files_and_remove_dir_true(): void
|
||||
{
|
||||
// Given: 보호 파일 없음
|
||||
file_put_contents($this->tempDir . '/file.txt', 'test');
|
||||
file_put_contents($this->tempDir.'/file.txt', 'test');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir, true);
|
||||
@@ -161,10 +166,10 @@ class DeleteDirectoryTest extends TestCase
|
||||
public function test_handles_nested_directories(): void
|
||||
{
|
||||
// Given: vendor 디렉토리 구조 시뮬레이션
|
||||
file_put_contents($this->tempDir . '/.gitkeep', '');
|
||||
mkdir($this->tempDir . '/laravel/framework/src', 0755, true);
|
||||
file_put_contents($this->tempDir . '/laravel/framework/src/Application.php', '<?php');
|
||||
file_put_contents($this->tempDir . '/autoload.php', '<?php');
|
||||
file_put_contents($this->tempDir.'/.gitkeep', '');
|
||||
mkdir($this->tempDir.'/laravel/framework/src', 0755, true);
|
||||
file_put_contents($this->tempDir.'/laravel/framework/src/Application.php', '<?php');
|
||||
file_put_contents($this->tempDir.'/autoload.php', '<?php');
|
||||
|
||||
// When
|
||||
$result = deleteDirectory($this->tempDir);
|
||||
@@ -172,9 +177,9 @@ class DeleteDirectoryTest extends TestCase
|
||||
// Then: .gitkeep만 보존, 나머지 전부 삭제
|
||||
$this->assertTrue($result);
|
||||
$this->assertDirectoryExists($this->tempDir);
|
||||
$this->assertFileExists($this->tempDir . '/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir . '/autoload.php');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir . '/laravel');
|
||||
$this->assertFileExists($this->tempDir.'/.gitkeep');
|
||||
$this->assertFileDoesNotExist($this->tempDir.'/autoload.php');
|
||||
$this->assertDirectoryDoesNotExist($this->tempDir.'/laravel');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,24 +200,24 @@ class DeleteDirectoryTest extends TestCase
|
||||
*/
|
||||
public function test_returns_false_for_nonexistent_directory(): void
|
||||
{
|
||||
$result = deleteDirectory($this->tempDir . '/nonexistent');
|
||||
$result = deleteDirectory($this->tempDir.'/nonexistent');
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 디렉토리를 완전히 정리합니다 (테스트용).
|
||||
*
|
||||
* @param string $dir 정리할 디렉토리 경로
|
||||
* @param string $dir 정리할 디렉토리 경로
|
||||
*/
|
||||
private function cleanupTempDir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$item;
|
||||
if (is_dir($path)) {
|
||||
$this->cleanupTempDir($path);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 인스톨러 디렉토리 권한 재귀 스캔 깊이 제한 회귀 테스트.
|
||||
*
|
||||
* 배경 (dev-g7#445 후속):
|
||||
* 인스톨러 2단계(요구사항 검증)의 checkDirectoryPermissions 가 storage 를 재귀 스캔하는데,
|
||||
* checkSubdirectoriesRecursive 가 깊이 제한 없이 무한 재귀하여 storage/framework/testing/
|
||||
* 하위에 누적된 수만 개 디렉토리를 전부 순회 → 2단계 응답이 8초 이상 지연되던 문제.
|
||||
*
|
||||
* 권한 안내는 트리 상위 얕은 깊이에서만 유의미하므로 재귀에 깊이 제한을 둔다.
|
||||
* 본 테스트는 깊이 제한이 실제로 적용되어 제한 깊이를 넘는 하위는 순회하지 않음을 검증한다.
|
||||
*
|
||||
* BASE_PATH 는 PHP 상수로 클래스 라이프사이클 단위 단 한 번 정의 (setUpBeforeClass).
|
||||
*/
|
||||
#[RunClassInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
class DirectoryPermissionScanDepthTest extends TestCase
|
||||
{
|
||||
private static string $sharedBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
|
||||
if (defined('BASE_PATH')) {
|
||||
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
|
||||
if (strpos($resolved, $tempPrefix) !== 0) {
|
||||
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
|
||||
'격리 실행 필요: php vendor/bin/phpunit --filter=DirectoryPermissionScanDepthTest';
|
||||
|
||||
return;
|
||||
}
|
||||
self::$sharedBase = (string) BASE_PATH;
|
||||
} else {
|
||||
self::$sharedBase = sys_get_temp_dir().'/g7-installer-dirscan-test-'.bin2hex(random_bytes(4));
|
||||
define('BASE_PATH', self::$sharedBase);
|
||||
}
|
||||
|
||||
if (! is_dir(self::$sharedBase)) {
|
||||
mkdir(self::$sharedBase, 0755, true);
|
||||
}
|
||||
|
||||
if (! isset($_SERVER['SCRIPT_NAME'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
|
||||
}
|
||||
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
parent::tearDownAfterClass();
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (self::$skipReason !== '') {
|
||||
$this->markTestSkipped(self::$skipReason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 깊이 제한을 넘는 하위 디렉토리는 재귀 스캔 대상에서 제외되어야 한다.
|
||||
*
|
||||
* storage/deep/a/b/c/... 처럼 깊은 트리를 만들고, checkSubdirectoriesRecursive 를
|
||||
* 제한 깊이(2)로 호출하면 depth 3 이상 경로는 결과에 나타나지 않아야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function recursive_scan_respects_max_depth(): void
|
||||
{
|
||||
// BASE_PATH/scanroot 하위에 깊이 5의 중첩 디렉토리 생성 (전부 정상 권한)
|
||||
$root = self::$sharedBase.'/scanroot';
|
||||
$deep = $root.'/lvl1/lvl2/lvl3/lvl4/lvl5';
|
||||
if (! is_dir($deep)) {
|
||||
mkdir($deep, 0755, true);
|
||||
}
|
||||
|
||||
// 깊이 2 제한으로 스캔 — 함수는 (path, maxDepth) 시그니처를 지원해야 한다.
|
||||
$failed = checkSubdirectoriesRecursive($root, 2);
|
||||
|
||||
// 모든 디렉토리가 정상 권한이므로 실패 항목은 없어야 하고, 무엇보다
|
||||
// 함수가 무한 재귀 없이 즉시 반환해야 한다 (깊이 제한 동작 확인).
|
||||
$this->assertIsArray($failed);
|
||||
|
||||
// 깊이 제한이 동작하는지 간접 검증: 깊은 트리에서도 스캔이 즉시 끝나야 한다.
|
||||
$t0 = microtime(true);
|
||||
checkSubdirectoriesRecursive($root, 2);
|
||||
$elapsed = microtime(true) - $t0;
|
||||
|
||||
$this->assertLessThan(
|
||||
1.0,
|
||||
$elapsed,
|
||||
'깊이 제한된 재귀 스캔은 얕은 깊이만 순회하므로 즉시 완료되어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkSubdirectoriesRecursive 가 maxDepth 파라미터를 받는 시그니처여야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function function_accepts_max_depth_parameter(): void
|
||||
{
|
||||
$ref = new \ReflectionFunction('checkSubdirectoriesRecursive');
|
||||
$params = $ref->getParameters();
|
||||
|
||||
$this->assertGreaterThanOrEqual(
|
||||
2,
|
||||
count($params),
|
||||
'checkSubdirectoriesRecursive 는 깊이 제한 파라미터를 받아야 한다 '.
|
||||
'(무한 재귀로 storage 하위 수만 개 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단).'
|
||||
);
|
||||
|
||||
// 두 번째 파라미터는 깊이 제한(정수, 기본값 존재)이어야 한다.
|
||||
$depthParam = $params[1];
|
||||
$this->assertTrue(
|
||||
$depthParam->isDefaultValueAvailable(),
|
||||
'깊이 제한 파라미터는 기본값을 가져 기존 호출부(check-configuration.php)가 변경 없이 동작해야 한다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,19 @@ use PHPUnit\Framework\TestCase;
|
||||
*/
|
||||
class InstallerWindowsCommandsTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 프로젝트 루트 절대경로 (BASE_PATH 상수와 독립).
|
||||
*
|
||||
* 다른 인스톨러 테스트(#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인
|
||||
* 프로세스로 BASE_PATH 를 누수시키면, 그 temp 경로 하위에는 public/install 이 없어
|
||||
* require 가 깨진다. 본 클래스는 __DIR__ 기준 절대경로로 인스톨러 파일을 로드해
|
||||
* 누수된 BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다.
|
||||
*/
|
||||
private function installerRoot(): string
|
||||
{
|
||||
return dirname(__DIR__, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 전 인스톨러 설정 및 함수 파일을 로드합니다.
|
||||
*/
|
||||
@@ -40,8 +53,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
define('INSTALLER_BASE_URL', '/install');
|
||||
}
|
||||
|
||||
// functions.php 로드 (한 번만) — base_path() 대신 BASE_PATH 상수 직접 사용
|
||||
require_once BASE_PATH . '/public/install/includes/functions.php';
|
||||
// functions.php 로드 (한 번만) — 누수된 BASE_PATH 와 무관하게 프로젝트 루트 절대경로 사용
|
||||
require_once $this->installerRoot().'/public/install/includes/functions.php';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +178,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
*/
|
||||
public function test_translation_keys_exist(): void
|
||||
{
|
||||
$koTranslations = require BASE_PATH . '/public/install/lang/ko.php';
|
||||
$enTranslations = require BASE_PATH . '/public/install/lang/en.php';
|
||||
$koTranslations = require $this->installerRoot().'/public/install/lang/ko.php';
|
||||
$enTranslations = require $this->installerRoot().'/public/install/lang/en.php';
|
||||
|
||||
$this->assertArrayHasKey('permission_windows_hint', $koTranslations, 'ko.php에 permission_windows_hint 키가 없습니다');
|
||||
$this->assertArrayHasKey('permission_windows_hint', $enTranslations, 'en.php에 permission_windows_hint 키가 없습니다');
|
||||
@@ -179,8 +192,8 @@ class InstallerWindowsCommandsTest extends TestCase
|
||||
*/
|
||||
public function test_directory_create_guide_translation_keys_exist(): void
|
||||
{
|
||||
$koTranslations = require BASE_PATH . '/public/install/lang/ko.php';
|
||||
$enTranslations = require BASE_PATH . '/public/install/lang/en.php';
|
||||
$koTranslations = require $this->installerRoot().'/public/install/lang/ko.php';
|
||||
$enTranslations = require $this->installerRoot().'/public/install/lang/en.php';
|
||||
|
||||
$this->assertArrayHasKey('directory_create_guide', $koTranslations, 'ko.php에 directory_create_guide 키가 없습니다');
|
||||
$this->assertArrayHasKey('directory_create_guide', $enTranslations, 'en.php에 directory_create_guide 키가 없습니다');
|
||||
|
||||
Reference in New Issue
Block a user