$host, 'port' => $port, 'database' => $database, ] as $field => $val) { $val = (string) $val; if ($val === '' && $field !== 'database') { continue; } if (preg_match('/[;=\0\r\n]/', $val)) { throw new PDOException(lang('error_db_invalid_parameter', ['field' => $field])); } } $dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4"; try { $pdo = new PDO($dsn, $username, $password, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); return $pdo; } catch (PDOException $e) { throw new PDOException(lang('error_db_connection_failed', ['error' => $e->getMessage()]), (int) $e->getCode(), $e); } } /** * 데이터베이스 권한 검증 * * @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 { // Read DB는 SELECT만 필요 $requiredPrivileges = $isReadDb ? ['SELECT'] : ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'INDEX']; try { // 현재 사용자의 권한 조회 $stmt = $pdo->query('SHOW GRANTS FOR CURRENT_USER()'); $grants = $stmt->fetchAll(PDO::FETCH_COLUMN); $foundPrivileges = []; foreach ($grants as $grant) { $grant = strtoupper($grant); // ALL PRIVILEGES 체크 if (strpos($grant, 'ALL PRIVILEGES') !== false) { $foundPrivileges = $requiredPrivileges; break; } // 개별 권한 체크 foreach ($requiredPrivileges as $privilege) { if (strpos($grant, $privilege) !== false) { $foundPrivileges[] = $privilege; } } } // 중복 제거 $foundPrivileges = array_unique($foundPrivileges); // 누락된 권한 확인 $missingPrivileges = array_diff($requiredPrivileges, $foundPrivileges); return [ 'has_all' => empty($missingPrivileges), 'required' => $requiredPrivileges, 'found' => array_values($foundPrivileges), 'missing' => array_values($missingPrivileges), ]; } catch (PDOException $e) { logInstallationError(lang('error_privilege_check_failed'), $e); return [ 'has_all' => false, 'required' => $requiredPrivileges, 'found' => [], 'missing' => $requiredPrivileges, ]; } } /** * 현재 PHP 프로세스에 composer root/non-interactive 환경변수를 적용합니다. * * exec() 자식 프로세스는 부모의 환경변수를 상속하므로 putenv() 시점에 설정해두면 * 후속 composer 자식 프로세스가 root 환경에서도 정상 동작합니다. * * Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응. */ if (! function_exists('applyInstallerComposerEnvVars')) { function applyInstallerComposerEnvVars(): void { putenv('COMPOSER_ALLOW_SUPERUSER=1'); putenv('COMPOSER_NO_INTERACTION=1'); } } /** * proc_open() 5번째 인자에 병합할 composer 환경변수 배열을 반환합니다. * * 호출자는 기존 $env 와 array_merge 하여 사용합니다. * * @return array */ if (! function_exists('buildInstallerComposerEnv')) { function buildInstallerComposerEnv(): array { return [ 'COMPOSER_ALLOW_SUPERUSER' => '1', 'COMPOSER_NO_INTERACTION' => '1', ]; } } /** * 인스톨러 에러 로깅 * * @param string $message 에러 메시지 * @param Throwable|null $exception 예외 객체 (선택) */ function logInstallationError(string $message, ?Throwable $exception = null): void { $logDir = BASE_PATH.'/storage/logs'; $logFile = $logDir.'/installer.log'; // 로그 디렉토리 생성 if (! is_dir($logDir)) { @mkdir($logDir, 0755, true); } // 로그 메시지 구성 $timestamp = date('Y-m-d H:i:s'); $logMessage = "[{$timestamp}] {$message}"; if ($exception !== null) { $logMessage .= "\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"; // 로그 파일에 기록 @file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX); } /** * 브라우저의 Accept-Language 헤더에서 선호 언어를 감지합니다. * * @return string|null 감지된 언어 코드 (ko 또는 en), 감지 실패 시 null */ function detectBrowserLanguage(): ?string { // HTTP_ACCEPT_LANGUAGE 헤더가 없으면 null 반환 if (! isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { return null; } $acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE']; // 지원 언어 목록 $supportedLanguages = ['ko', 'en']; // Accept-Language 헤더 파싱 (예: "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7") $languages = []; foreach (explode(',', $acceptLanguage) as $lang) { // q값과 언어 코드 분리 $parts = explode(';', trim($lang)); $langCode = strtolower(trim($parts[0])); // q값 추출 (없으면 1.0) $q = 1.0; if (isset($parts[1]) && strpos($parts[1], 'q=') === 0) { $q = (float) substr($parts[1], 2); } // 언어 코드에서 메인 부분만 추출 (ko-KR -> ko) $mainLang = explode('-', $langCode)[0]; $languages[$mainLang] = $q; } // q값 기준으로 정렬 (내림차순) arsort($languages); // 지원하는 언어 중에서 가장 우선순위가 높은 언어 반환 foreach (array_keys($languages) as $lang) { if (in_array($lang, $supportedLanguages)) { return $lang; } } return null; } /** * 현재 설정된 언어 코드를 반환합니다. * * 세션에 저장된 g7_locale 값을 반환하며, 설정되지 않은 경우 기본값으로 'ko'를 반환합니다. */ function getCurrentLanguage(): string { return $_SESSION['g7_locale'] ?? 'ko'; } /** * 지정된 언어의 번역 파일을 로드합니다. * * @param string $lang 언어 코드 (예: 'ko', 'en') * @return array 번역 배열, 파일이 없으면 빈 배열 */ function loadTranslations(string $lang): array { $langFile = __DIR__."/../lang/{$lang}.php"; if (! file_exists($langFile)) { return []; } $translations = require $langFile; return is_array($translations) ? $translations : []; } /** * 번역 키에 해당하는 번역 문자열을 반환합니다. * * global $translations 배열에서 키에 해당하는 값을 찾아 반환합니다. * 키가 존재하지 않으면 키 자체를 반환합니다. * * @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)) { return $key; } $message = $translations[$key] ?? $key; // 플레이스홀더 치환 (:placeholder 형식) foreach ($replace as $placeholder => $value) { $message = str_replace(":{$placeholder}", $value, $message); } return $message; } } /** * 설치 단계 이름을 반환합니다. * * @param int $step 단계 번호 (0~5, 6은 예약됨) * @return string 단계명 번역 키 */ function getStepName(int $step): string { $stepNames = [ 0 => 'step_0_welcome', 1 => 'step_1_license', 2 => 'step_2_requirements', 3 => 'step_3_configuration', 4 => 'step_4_extension_selection', 5 => 'step_5_installation', 6 => 'step_6_complete', ]; return $stepNames[$step] ?? 'step_unknown'; } /** * 설치 작업 이름을 반환합니다. * * @param string $task 작업 ID (예: 'composer_check', 'env_create') * @return string 작업명 번역 키 */ function getTaskName(string $task): string { $taskNames = [ // 환경 설정 그룹 'composer_check' => 'task_composer_check', 'composer_install' => 'task_composer_install', 'env_create' => 'task_env_create', 'env_update' => 'task_env_update', 'key_generate' => 'task_key_generate', // 데이터베이스 그룹 'db_migrate' => 'task_db_migrate', 'db_seed' => 'task_db_seed', // 관리자 템플릿 그룹 'template_install' => 'task_template_install', 'template_activate' => 'task_template_activate', // 모듈 그룹 'module_install' => 'task_module_install', 'module_activate' => 'task_module_activate', // 플러그인 그룹 'plugin_install' => 'task_plugin_install', 'plugin_activate' => 'task_plugin_activate', // 사용자 템플릿 그룹 'user_template_install' => 'task_user_template_install', 'user_template_activate' => 'task_user_template_activate', // 마무리 그룹 'create_settings_json' => 'task_create_settings_json', 'cache_clear' => 'task_cache_clear', 'complete_flag' => 'task_complete_flag', ]; return $taskNames[$task] ?? 'task_unknown'; } /** * 지정한 Step으로 리다이렉트합니다. * * @param int $step 이동할 단계 번호 */ function redirectToStep(int $step): void { $_SESSION['installer_current_step'] = $step; header('Location: '.INSTALLER_BASE_URL.'/'); exit; } /** * 언어를 변경하고 세션 및 state.json에 저장합니다. * * 저장 위치: * - 세션: PHP 페이지 렌더링 시 사용 * - state.json: 다른 브라우저에서 재개 시 사용, 설치 완료 후 Laravel 본체에 전달 * - localStorage: JavaScript에서 직접 관리 (0-welcome.php에서 처리) * * @param string $lang 언어 코드 ('ko' 또는 'en') */ function handleLanguageChange(string $lang): void { // 지원하는 언어만 허용 if (! array_key_exists($lang, SUPPORTED_LANGUAGES)) { return; } // 세션에 저장 $_SESSION['g7_locale'] = $lang; // state.json에도 저장 (다른 브라우저에서 재개 지원 + 설치 완료 후 본체 전달) $state = getInstallationState(); $state['g7_locale'] = $lang; saveInstallationState($state); } /** * 현재 단계의 상태를 업데이트합니다. * * @param int $step 완료할 단계 번호 * @param int $nextStep 다음 단계 번호 * @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data]) */ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): void { $state = getInstallationState(); $state['current_step'] = $nextStep; $state['step_status'][$step] = 'completed'; if (! empty($additionalData)) { $state = array_merge($state, $additionalData); } saveInstallationState($state); } /** * 설치가 진행 중인지 확인합니다. */ function isInstallationRunning(): bool { $state = getInstallationState(); return isset($state['installation_status']) && $state['installation_status'] === 'running'; } /** * 설치 진행 중 알럿을 표시하고 Step 5로 리다이렉트합니다. */ function showInstallationRunningAlert(): void { global $translations; // 번역이 로드되지 않은 경우 로드 if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } $_SESSION['installer_current_step'] = 5; showAlertAndRedirect( lang('installation_in_progress'), lang('installation_in_progress_message'), INSTALLER_BASE_URL.'/' ); } /** * 설치 완료 알럿을 표시하고 홈으로 리다이렉트합니다. */ function showInstallationCompletedAlert(): void { global $translations; // 번역이 로드되지 않은 경우 로드 if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } showAlertAndRedirect( lang('installation_already_completed'), lang('installation_already_completed_message'), '../' ); } /** * 알럿을 표시하고 지정된 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)) { $translations = loadTranslations(getCurrentLanguage()); } $lang = htmlspecialchars(getCurrentLanguage()); $title = htmlspecialchars($title); $message = htmlspecialchars($message, ENT_QUOTES); $redirectUrl = htmlspecialchars($redirectUrl); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''.$title.''; echo ''; echo ''; echo ''; echo ''; echo ''; exit; } /** * 단계 파일을 찾을 수 없을 때 에러 페이지를 표시합니다. * * @param int $currentStep 현재 단계 번호 */ function showStepFileNotFoundError(int $currentStep): void { $previousStep = max(0, $currentStep - 1); $_SESSION['installer_current_step'] = $previousStep; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo lang('error'); echo ''; echo ''.lang('error_step_file_not_found').''; echo ''; echo '← '.lang('previous').''; echo ''; echo ''; } /** * 데이터베이스 필드 해시값을 계산합니다. * * DB 연결 정보가 변경되었는지 감지하기 위해 사용됩니다. * * @param array $config 폼 데이터 배열 * @param string $prefix DB 접두사 ('db_write' 또는 'db_read') * @return string MD5 해시값 */ function getDatabaseFieldHash(array $config, string $prefix): string { // json-encode:allow — 캐시 키 계산용 md5 입력, 응답으로 나가지 않는다. // 플래그를 바꾸면 해시값이 달라져 "필드가 바뀌었다" 는 오판이 생기므로 그대로 둔다. return md5(json_encode([ $config["{$prefix}_host"] ?? '', $config["{$prefix}_port"] ?? '', $config["{$prefix}_database"] ?? '', $config["{$prefix}_username"] ?? '', ])); } /** * SVG 아이콘을 반환합니다. * * @param string $type 아이콘 타입 ('success', 'warning', 'error') * @return string SVG HTML */ function getSvgIcon(string $type): string { $icons = [ 'success' => ' ', 'warning' => ' ', 'error' => ' ', ]; return $icons[$type] ?? ''; } /** * 설치 결과 섹션 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 된 상태로 전달) * @return string 결과 섹션 HTML */ function renderInstallResultSection(string $sectionId, string $iconType, string $titleKey, string $messageKey, string $buttonsHtml, string $extraHtml = ''): string { $icon = getSvgIcon($iconType); $title = htmlspecialchars(lang($titleKey)); // failure는 동적 메시지이므로 번역하지 않음 $message = $sectionId === 'failure' ? '' : ''.htmlspecialchars(lang($messageKey)).''; return << {$icon} {$title} {$message} {$extraHtml} {$buttonsHtml} HTML; } /** * 코어 애플리케이션 버전을 추출합니다. * * 우선순위: .env(설치 후) → .env.example(설치 전) → 'unknown' * 인스톨러는 Laravel 부팅 전이므로 config('app.version') 을 쓸 수 없습니다. * * @return string 코어 버전 문자열 (예: '7.0.0-beta.4') */ function getCoreAppVersion(): string { foreach (['/.env', '/.env.example'] as $relative) { $path = BASE_PATH.$relative; if (! is_readable($path)) { continue; } $content = @file_get_contents($path); if ($content === false) { continue; } if (preg_match('/^APP_VERSION=(.+)$/m', $content, $matches)) { return trim($matches[1], "\"' \t\r\n"); } } return 'unknown'; } /** * Progress indicator HTML을 렌더링합니다. * * @param int $currentStep 현재 단계 번호 * @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6) * @return string Progress indicator HTML */ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string { $html = ''; for ($i = 0; $i < $totalSteps; $i++) { $class = 'progress-step'; if ($i < $currentStep) { $class .= ' completed'; } elseif ($i === $currentStep) { $class .= ' active'; } $html .= ''; } $html .= ''; return $html; } /** * 현재 PHP 프로세스를 실행 중인 웹 서버 사용자명을 감지합니다. * * posix 확장 → exec('whoami') → 환경변수 순서로 시도하며, * 모두 실패하면 null을 반환합니다. * * @return string|null 웹 서버 사용자명 (예: 'www-data', 'nginx', 'apache') 또는 null */ function getWebServerUser(): ?string { // 1. posix 확장 (가장 신뢰도 높음) if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) { $info = posix_getpwuid(posix_geteuid()); if ($info && ! empty($info['name'])) { return $info['name']; } } // 2. exec('whoami') // // whoami 는 UTF-8 이 아니라 그 명령을 실행한 콘솔의 코드페이지로 출력한다. // 한국어 Windows(OEM 949)에서 계정명에 한글이 있으면 invalid UTF-8 바이트가 들어오고, // 그 값이 응답에 실리면 json_encode 가 false 를 반환해 빈 본문(HTTP 200)이 나간다 // (gnuboard/g7#62). 반환 전에 반드시 정규화한다. if (function_exists('exec')) { $user = @exec('whoami'); if (! empty($user)) { return installer_normalize_process_output(trim($user), 'whoami'); } } // 3. 환경변수 $envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null; if (! empty($envUser)) { return installer_normalize_process_output($envUser, 'env:USER'); } return null; } /** * 현재 PHP 프로세스를 실행 중인 웹 서버 그룹명을 감지합니다. * * @return string|null 웹 서버 그룹명 (예: 'www-data', 'nginx') 또는 null */ function getWebServerGroup(): ?string { if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) { $info = posix_getgrgid(posix_getegid()); if ($info && ! empty($info['name'])) { return $info['name']; } } $envGroup = getenv('APACHE_RUN_GROUP') ?: null; if (! empty($envGroup)) { return installer_normalize_process_output($envGroup, 'env:APACHE_RUN_GROUP'); } return null; } /** * 파일/디렉토리의 소유자 이름을 반환합니다. * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * * @param string $path 파일/디렉토리 경로 * @return string|null 소유자 이름 또는 null */ function getFileOwnerName(string $path): ?string { $uid = @fileowner($path); if ($uid === false) { return null; } if (function_exists('posix_getpwuid')) { $info = @posix_getpwuid($uid); if ($info && ! empty($info['name'])) { return $info['name']; } } // posix 확장이 없으면 UID를 문자열로 반환 return (string) $uid; } /** * 파일/디렉토리의 소유 그룹 이름을 반환합니다. * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * * @param string $path 파일/디렉토리 경로 * @return string|null 그룹 이름 또는 null */ function getFileGroupName(string $path): ?string { $gid = @filegroup($path); if ($gid === false) { return null; } if (function_exists('posix_getgrgid')) { $info = @posix_getgrgid($gid); if ($info && ! empty($info['name'])) { return $info['name']; } } // posix 확장이 없으면 GID를 문자열로 반환 return (string) $gid; } /** * chown 명령어에 사용할 user:group 문자열을 반환합니다. * * 감지 실패 시 'www-data:www-data'를 기본값으로 사용합니다. * * @return string 'user:group' 형식 문자열 */ function getChownTarget(): string { $user = getWebServerUser() ?? 'www-data'; $group = getWebServerGroup() ?? $user; return $user.':'.$group; } /** * 디렉토리 권한을 체크합니다. * * Step 0과 Step 2에서 재사용 가능한 공통 함수입니다. * 디렉토리를 생성하지 않고 존재 여부와 권한만 체크합니다. * * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) * @return array{all_passed: bool, results: array}>} */ function checkDirectoryPermissions(array $directories): array { // 파일 시스템 캐시 클리어 (권한 체크 결과가 캐싱되는 것을 방지) clearstatcache(true); $results = []; $allPassed = true; $requiredPermsDisplay = REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY; // 웹서버 실행 사용자 (ownership_mismatch 판별용) $webServerUser = getWebServerUser(); foreach ($directories as $path => $checkRecursive) { $fullPath = BASE_PATH.'/'.$path; // 디렉토리 존재 여부 확인 (생성하지 않음) $exists = is_dir($fullPath); // 실제 동작 기준 검증: 비트 값이 아닌 is_writable/is_readable로 판정 // 업계 표준에 따라 어떤 권한 조합이든 실제 동작하면 통과 (0755/0775/0770/0777 등) $writable = $exists && is_writable($fullPath); $readable = $exists && is_readable($fullPath); // 권한 코드는 정보 표시용으로만 유지 $permissions = 'N/A'; $permsOctal = 0; if ($exists) { $perms = fileperms($fullPath); $permissions = substr(sprintf('%o', $perms), -3); $permsOctal = octdec($permissions); } // 소유자/소유그룹 정보 가져오기 $owner = null; $group = null; if ($exists) { $owner = getFileOwnerName($fullPath); $group = getFileGroupName($fullPath); } // 재귀 체크 여부 확인 $failedSubdirectories = []; $hasSubdirectoryIssues = false; if ($checkRecursive && $exists) { // 디렉토리가 존재하면 하위 체크 (writable 여부와 무관하게) $failedSubdirectories = checkSubdirectoriesRecursive($fullPath); $hasSubdirectoryIssues = count($failedSubdirectories) > 0; } // 상위 디렉토리 자체의 통과 여부 — is_writable && is_readable만 충족하면 OK $parentPassed = $writable && $readable; // 최종 통과 여부: 상위 + 하위 모두 통과해야 함 $passed = $parentPassed && ! $hasSubdirectoryIssues; // 에러 타입 구분 // ownership_mismatch: 권한 비트는 0755 이상이지만 소유자가 웹서버 사용자와 달라 // 실제 쓰기 불가 (전통적 Apache: 파일 소유자 != www-data) $errorType = null; if (! $exists) { $errorType = 'not_exists'; } elseif (! $writable) { // 권한 비트가 충분(0755+)한데도 쓰기 불가 → 소유권 불일치 가능성 if ($permsOctal >= 0755 && $webServerUser && $owner && $owner !== $webServerUser) { $errorType = 'ownership_mismatch'; } else { $errorType = 'not_writable'; } } elseif (! $readable) { $errorType = 'not_readable'; } elseif ($hasSubdirectoryIssues) { // 상위는 OK, 하위 디렉토리만 문제 $errorType = 'subdirectory_issues'; } $results[$path] = [ 'exists' => $exists, 'writable' => $writable, 'readable' => $readable, 'permissions' => $permissions, 'required_permissions' => $requiredPermsDisplay, 'owner' => $owner, 'group' => $group, 'web_server_user' => $webServerUser, 'passed' => $passed, 'error_type' => $errorType, 'has_subdirectory_issues' => $hasSubdirectoryIssues, 'failed_subdirectories' => $failedSubdirectories, ]; if (! $passed) { $allPassed = false; } } return [ 'all_passed' => $allPassed, 'results' => $results, ]; } /** * 디렉토리의 하위를 재귀적으로 체크하여 권한이 없는 경로와 권한 정보 반환 * * 깊이 제한(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, int $maxDepth = 1, int $currentDepth = 0): array { $failedPaths = []; if (! is_dir($path)) { return $failedPaths; } // scandir를 사용하여 디렉토리 목록 가져오기 (읽을 수 없으면 빈 배열 반환) try { $items = @scandir($path); if ($items === false) { // 디렉토리를 읽을 수 없으면 빈 배열 반환 return $failedPaths; } } catch (Exception $e) { return $failedPaths; } foreach ($items as $item) { // . 및 .. 제외 if ($item === '.' || $item === '..') { continue; } $itemPath = $path.DIRECTORY_SEPARATOR.$item; // 디렉토리인지 확인 (심볼릭 링크 제외) if (! is_dir($itemPath) || is_link($itemPath)) { continue; } // 절대 경로를 상대 경로로 변환 $relativePath = str_replace(BASE_PATH.'/', '', $itemPath); $relativePath = str_replace('\\', '/', $relativePath); // 쓰기 권한 체크 $isWritable = is_writable($itemPath); if (! $isWritable) { // 권한 코드 가져오기 $perms = fileperms($itemPath); $permissions = substr(sprintf('%o', $perms), -3); $failedPaths[] = [ 'path' => $relativePath, 'permissions' => $permissions, ]; } // 재귀적으로 하위 디렉토리 체크 (권한 없어도 계속 진행) // 깊이 제한 도달 시 더 내려가지 않는다 — 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) { // 하위 디렉토리 체크 실패해도 계속 진행 } } } return $failedPaths; } /** * 라이선스 파일 내용을 로드합니다. * * 루트 LICENSE 파일을 읽습니다. (한국어 번역 + 영문 원문 통합) * * @param string $lang 언어 코드 (미사용, 하위 호환성 유지) * @return string 라이선스 파일 내용 */ function loadLicenseFile(string $lang): string { $licenseFile = __DIR__.'/../../../LICENSE'; if (file_exists($licenseFile)) { return file_get_contents($licenseFile); } return lang('license_not_found'); } if (! function_exists('escapeEnvValue')) { /** * .env 값 이스케이프 * * 특수 문자가 포함된 값을 .env 파일에 안전하게 기록할 수 있도록 큰따옴표로 감싸고 * 내부 큰따옴표와 백슬래시를 이스케이프합니다. * * installer-runtime.php 가 동일 함수를 polyfill 로 선언하므로 function_exists * 가드가 필수다. 가드가 없으면 installer-runtime.php 가 functions.php 보다 먼저 * 로드되는 엔드포인트(install-process.php)에서 "Cannot redeclare" fatal 이 발생해 * JSON 응답이 통째로 유실된다 (설치 시작 즉시 실패). * * @param string $value 이스케이프할 값 * @return string 큰따옴표로 감싸고 내부 큰따옴표와 백슬래시를 이스케이프한 값 */ function escapeEnvValue(string $value): string { // 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042). // 이 이름은 하위호환 별칭일 뿐이며, 개행을 조용히 지우던 종전 동작은 남기지 않는다 — // 남겨 두면 다음 호출자가 그 경로로 같은 결함을 다시 들여온다. return serializeEnvValue($value); } } /** * state.json의 config 값을 기반으로 .env 내용을 생성합니다. * * .env.example 템플릿의 플레이스홀더를 사용자 입력값으로 치환합니다. * APP_KEY는 치환하지 않음 — artisan key:generate로 별도 생성합니다. * * @return string|null .env 내용 문자열, .env.example 없으면 null */ function generateEnvContent(): ?string { $envExamplePath = BASE_PATH.'/.env.example'; if (! file_exists($envExamplePath)) { return null; } $envContent = file_get_contents($envExamplePath); $state = getInstallationState(); $config = $state['config'] ?? []; // 데이터베이스 설정 치환 $replacements = [ 'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql', 'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.serializeEnvValue((string) ($config['db_write_host'] ?? '127.0.0.1')), 'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.serializeEnvValue((string) ($config['db_write_port'] ?? '3306')), 'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.serializeEnvValue((string) ($config['db_write_database'] ?? 'g7')), 'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.serializeEnvValue((string) ($config['db_write_username'] ?? 'root')), 'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.serializeEnvValue((string) ($config['db_write_password'] ?? '')), 'DB_PREFIX=g7_' => 'DB_PREFIX='.serializeEnvValue((string) ($config['db_prefix'] ?? 'g7_')), ]; // Read DB 설정 if (! empty($config['use_read_db']) && $config['use_read_db']) { $replacements['DB_READ_HOST='] = 'DB_READ_HOST='.serializeEnvValue((string) ($config['db_read_host'] ?? '127.0.0.1')); $replacements['DB_READ_PORT='] = 'DB_READ_PORT='.serializeEnvValue((string) ($config['db_read_port'] ?? '3306')); $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.serializeEnvValue((string) ($config['db_read_database'] ?? 'g7')); $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.serializeEnvValue((string) ($config['db_read_username'] ?? 'root')); $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.serializeEnvValue((string) ($config['db_read_password'] ?? '')); } else { // Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다. // config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로 // .env 에 write 값을 중복 기록하지 않는다 (write 변경 시 read stale 방지). (이슈 #63) $replacements['DB_READ_HOST='] = 'DB_READ_HOST='; $replacements['DB_READ_PORT='] = 'DB_READ_PORT='; $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='; $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='; $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='; } // 앱 설정 치환 $replacements['APP_NAME=그누보드7'] = 'APP_NAME='.serializeEnvValue((string) ($config['app_name'] ?? '그누보드7')); $replacements['APP_ENV=production'] = 'APP_ENV='.serializeEnvValue((string) ($config['app_env'] ?? 'production')); $replacements['APP_URL=http://localhost'] = 'APP_URL='.serializeEnvValue((string) ($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'); if (isset($config['app_env']) && $config['app_env'] !== 'production') { $replacements['APP_DEBUG=false'] = 'APP_DEBUG=true'; } // 코어 업데이트 설정 치환 $corePendingPath = $config['core_update_pending_path'] ?? ''; $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='.serializeEnvValue((string) $corePendingPath); $replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.serializeEnvValue((string) $coreGithubUrl); $replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.serializeEnvValue((string) $coreGithubToken); // PHP CLI / Composer 바이너리 경로 치환 $phpBinary = $config['php_binary'] ?? 'php'; $composerBinary = $config['composer_binary'] ?? ''; $replacements['PHP_BINARY=php'] = 'PHP_BINARY='.serializeEnvValue((string) $phpBinary); $replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.serializeEnvValue((string) $composerBinary); foreach ($replacements as $search => $replace) { $envContent = str_replace($search, $replace, $envContent); } return $envContent; } /** * 현재 서버가 Windows 환경인지 확인합니다. * * @return bool Windows인 경우 true */ function isWindows(): bool { return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; } /** * OS에 맞는 .env 파일 복사 명령어를 반환합니다. * * @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용) * @return string 복사 명령어 */ function getEnvCopyCommand(string $basePath = ''): string { if (isWindows()) { $source = $basePath ? $basePath.'\\.env.example' : '.env.example'; $dest = $basePath ? $basePath.'\\.env' : '.env'; return 'copy '.str_replace('/', '\\', $source).' '.str_replace('/', '\\', $dest); } $source = $basePath ? $basePath.'/.env.example' : '.env.example'; $dest = $basePath ? $basePath.'/.env' : '.env'; return 'cp '.$source.' '.$dest; } /** * OS에 맞는 권한 수정 명령어를 반환합니다. * * 업계 표준(WordPress/Drupal/Joomla/Laravel)에 맞춰 디렉토리 755, 파일 644로 통일. * chown/setgid 의존성을 제거하여 공유호스팅 호환성 확보. * 실제 통과 기준은 is_writable() && is_readable()이므로 비트 값은 참고용. * * @param string $pathList 대상 경로 (공백 구분) * @param string $mode 'ownership' (디렉토리) 또는 'file' (파일) * @return string 권한 수정 명령어 */ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): string { if (isWindows()) { $winPath = str_replace('/', '\\', $pathList); if ($mode === 'file') { return 'icacls '.$winPath.' /grant Everyone:F'; } return 'icacls '.$winPath.' /grant Everyone:(OI)(CI)F /T'; } if ($mode === 'file') { return 'chmod 644 '.$pathList; } return 'chmod -R 755 '.$pathList; } /** * 기존 DB 테이블 감지. * * Write DB에 테이블이 존재하는지 확인하고, G7 시그니처 테이블과 비교하여 * 설치 진행 가능 여부를 판정합니다. * * severity: * - 'empty' : 빈 DB (정상 진행) * - 'g7_existing' : G7 시그니처 4개 모두 존재 (기존 설치 감지) * - 'mixed' : G7 일부 + 기타 혼재 * - 'foreign_data' : G7 시그니처 없음 + 기타 테이블 존재 * * @param PDO $pdo 연결된 PDO 인스턴스 * @param string $database 대상 데이터베이스명 */ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ''): array { try { $stmt = $pdo->query('SHOW TABLES'); $tables = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; } catch (PDOException $e) { return [ 'has_tables' => false, 'is_g7_install' => false, 'g7_tables_found' => [], 'other_tables_count' => 0, 'all_tables' => [], 'severity' => 'empty', 'error' => $e->getMessage(), ]; } if (empty($tables)) { return [ 'has_tables' => false, 'is_g7_install' => false, 'g7_tables_found' => [], 'other_tables_count' => 0, 'all_tables' => [], 'severity' => 'empty', ]; } // G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단 // 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등) $coreSignatures = ['users', 'migrations', 'roles', 'permissions']; $g7Signatures = array_map(fn ($t) => $tablePrefix.$t, $coreSignatures); $g7TablesFound = array_values(array_intersect($g7Signatures, $tables)); $severity = 'foreign_data'; if (count($g7TablesFound) >= 4) { $severity = 'g7_existing'; } elseif (count($g7TablesFound) > 0) { $severity = 'mixed'; } return [ 'has_tables' => true, 'is_g7_install' => $severity === 'g7_existing', 'g7_tables_found' => $g7TablesFound, 'other_tables_count' => max(0, count($tables) - count($g7TablesFound)), 'all_tables' => $tables, 'severity' => $severity, ]; } if (! function_exists('filterTablesByPrefix')) { /** * 기존 테이블 목록을 db_prefix 로 필터링합니다. * * drop_tables 동의의 의미는 "이 prefix 로 설치되는 G7 테이블을 지우고 다시 설치"이므로, * 입력한 prefix 로 시작하는 테이블만 삭제 대상으로 선별합니다. prefix 가 없는 * 다른 애플리케이션/이전 설치의 테이블은 동일 DB 를 공유하더라도 보존합니다. * * 빈 prefix 는 전체 일치가 되어 모든 테이블이 삭제되는 위험이 있으므로, * 데이터 손실 방어를 위해 빈 목록(삭제 대상 없음)을 반환합니다. * * @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록 * @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_') * @return array prefix 로 시작하는 테이블만 담은 목록 */ function filterTablesByPrefix(array $tables, string $prefix): array { if ($prefix === '') { return []; } $prefixLength = strlen($prefix); return array_values(array_filter( $tables, fn ($table) => strncmp((string) $table, $prefix, $prefixLength) === 0 )); } }
'.lang('error_step_file_not_found').'
'.htmlspecialchars(lang($messageKey)).'