diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bba4494..dede7c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다. - 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.) +- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다. ## [7.0.1] - 2026-07-03 diff --git a/public/install/includes/functions.php b/public/install/includes/functions.php index 7d705628..07c11d08 100644 --- a/public/install/includes/functions.php +++ b/public/install/includes/functions.php @@ -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, found: array, missing: array} */ 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 */ -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 번역 배열, 파일이 없으면 빈 배열 */ 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 ''; - echo ''; + echo ''; echo ''; echo ''; echo ''; - echo '' . $title . ''; + echo ''.$title.''; echo ''; echo ''; echo ''; echo ''; echo ''; @@ -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 ''; echo lang('error'); echo ''; - echo '

' . lang('error_step_file_not_found') . '

'; + echo '

'.lang('error_step_file_not_found').'

'; echo ''; echo ''; } @@ -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' ? '
' - : '

' . htmlspecialchars(lang($messageKey)) . '

'; + : '

'.htmlspecialchars(lang($messageKey)).'

'; return << @@ -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 .= '
'; + $html .= '
'; } $html .= ''; + 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 $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) + * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) * @return array{all_passed: bool, results: array}>} */ 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 권한이 없는 하위 디렉토리 정보 배열 */ -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 diff --git a/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php b/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php new file mode 100644 index 00000000..d867eb78 --- /dev/null +++ b/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php @@ -0,0 +1,134 @@ +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)가 변경 없이 동작해야 한다.' + ); + } +}