$allowedMaxStep) { showInvalidStepAccessAlert($allowedMaxStep); } } /** * 잘못된 단계 접근 시 알림 표시 * * @param int $correctStep 올바른 단계 번호 */ function showInvalidStepAccessAlert(int $correctStep): void { global $translations; // 번역이 로드되지 않은 경우 로드 if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } // 세션에 올바른 단계 설정 $_SESSION['installer_current_step'] = $correctStep; showAlertAndRedirect( lang('error_invalid_step_access'), lang('error_invalid_step_access_message'), INSTALLER_BASE_URL.'/' ); } /** * Step 0 POST 처리 (환영 화면) */ function handleStep0Post(): void { if (isset($_POST['language'])) { handleLanguageChange($_POST['language']); } // 다음 버튼 클릭 시 (언어 선택 변경이 아닌 경우) if (! isset($_POST['language_change_only'])) { unset($_SESSION['license_agreed']); updateStepStatus(0, 1); redirectToStep(1); } redirectToStep(0); } /** * Step 1 POST 처리 (라이선스 동의) * * @param string $currentLang 현재 언어 * @param string|null &$error 에러 메시지 (참조) */ function handleStep1Post(string $currentLang, ?string &$error = null): void { $translations = loadTranslations($currentLang); if (! isset($_POST['agree']) || $_POST['agree'] !== '1') { $error = lang('must_agree'); } else { $_SESSION['license_agreed'] = true; updateStepStatus(1, 2); redirectToStep(2); } } /** * Step 2 POST 처리 (요구사항 검증) */ function handleStep2Post(): void { if (isset($_POST['proceed'])) { updateStepStatus(2, 3); redirectToStep(3); } } /** * DB 사용자명을 검증해 에러 배열에 결과를 기록합니다. * * 빈 값(설정 누락)과 DB 최고권한 계정을 모두 거부합니다. 최고권한 계정 자격증명이 * 유출되면 데이터베이스 전체가 위험해지므로 설치 단계에서 차단합니다. * 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT 입니다. * * @param string $username 입력된 DB 사용자명 * @param string $field 에러 배열에 사용할 필드명 (db_write_username 등) * @param array &$errors 에러 배열 (참조) */ function validateDbUsername(string $username, string $field, array &$errors): void { $username = trim($username); if ($username === '') { $errors[$field] = lang('error_db_username_required'); return; } if (PrivilegedDatabaseAccounts::isBlocked($username)) { $errors[$field] = lang('error_db_username_privileged', ['username' => $username]); } } /** * Step 3 POST 처리 (데이터베이스 및 사이트 설정) * * @param string $currentLang 현재 언어 * @param array &$formData 폼 데이터 (참조) * @param array &$errors 에러 배열 (참조) */ function handleStep3Post(string $currentLang, array &$formData, array &$errors): void { $translations = loadTranslations($currentLang); $formData = array_merge($formData, $_POST); $formData['use_read_db'] = isset($_POST['use_read_db']) ? true : false; // Write DB 검증 if (empty($formData['db_write_host'])) { $errors['db_write_host'] = lang('error_db_host_required'); } if (empty($formData['db_write_database'])) { $errors['db_write_database'] = lang('error_db_name_required'); } validateDbUsername($formData['db_write_username'] ?? '', 'db_write_username', $errors); // Read DB 검증 (사용하는 경우) if (! empty($formData['use_read_db'])) { if (empty($formData['db_read_host'])) { $errors['db_read_host'] = lang('error_db_host_required'); } if (empty($formData['db_read_database'])) { $errors['db_read_database'] = lang('error_db_name_required'); } validateDbUsername($formData['db_read_username'] ?? '', 'db_read_username', $errors); } // 관리자 정보 검증 if (empty($formData['admin_email']) || ! filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) { $errors['admin_email'] = lang('error_admin_email_invalid'); } if (! array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) { $errors['admin_language'] = lang('error_admin_language_invalid'); } if (empty($formData['admin_password']) || strlen($formData['admin_password']) < 8) { $errors['admin_password'] = lang('error_admin_password_min'); } if ($formData['admin_password'] !== $formData['admin_password_confirm']) { $errors['admin_password_confirm'] = lang('error_password_mismatch'); } // PHP CLI / Composer 경로 처리 — 저장 시점에 허용 형태를 강제한다. // 이 값들은 설치 워커에서 실제 명령의 실행 바이너리가 되므로, 형태 위반 값이 // 애초에 .env / 설치 상태에 기록되지 않게 여기서 막는다. require_once __DIR__.'/binary-path-policy.php'; $phpBinary = trim($formData['php_binary'] ?? 'php'); $phpBinary = $phpBinary !== '' ? $phpBinary : 'php'; if ($phpBinary !== 'php' && ! installer_binary_path_shape_ok($phpBinary)) { $errors['php_binary'] = lang('error_php_binary_path_not_allowed', ['path' => $phpBinary]); } $formData['php_binary'] = $phpBinary; $composerBinary = trim($formData['composer_binary'] ?? ''); if ($composerBinary !== '' && $composerBinary !== 'composer') { // 단일 토큰이면 Composer 자리 규칙, 공백 분리 입력이면 (PHP, Composer) 쌍 해석. $allowed = str_contains($composerBinary, ' ') ? installer_resolve_php_composer_pair($composerBinary) !== null : installer_is_composer_binary_path($composerBinary); if (! $allowed) { $errors['composer_binary'] = lang('error_composer_binary_path_not_allowed', ['path' => $composerBinary]); } } $formData['composer_binary'] = $composerBinary; // Vendor 설치 모드 처리 (auto|composer|bundled) $vendorMode = trim($formData['vendor_mode'] ?? 'auto'); if (! in_array($vendorMode, ['auto', 'composer', 'bundled'], true)) { $vendorMode = 'auto'; } $formData['vendor_mode'] = $vendorMode; // 자산 URL 방식 (이슈 #486) — Step 3 의 브라우저 프로브가 채운 hidden 필드. // 정적 최적화 블록이 있는 서버는 확장자 붙은 동적 응답이 PHP 에 도달하지 못하므로 // 설치 시점에 확장자 없는 형태로 확정해야 첫 화면부터 정상 동작한다. // 판정 불가(프로브 실패·JS 미실행)면 키를 비워 defaults.json 기본값을 따르게 한다. $assetUrlMode = trim($formData['asset_url_mode'] ?? ''); $formData['asset_url_mode'] = in_array($assetUrlMode, ['extension', 'extensionless'], true) ? $assetUrlMode : ''; // .env 로 기록되는 사용자 입력의 사전 거부 (KVE-2026-2042) // 직렬화기가 최종 관문이지만, 그 단계의 실패는 설치 진행 중에 예외로 드러나 운영자가 // 어느 입력이 문제인지 알기 어렵다. 여기서 필드별로 거부해 화면에 사유를 표시한다. require_once __DIR__.'/env-value.php'; foreach (['app_name', 'app_url', 'core_update_github_url'] as $envField) { if (! installer_env_value_is_single_line((string) ($formData[$envField] ?? ''))) { $errors[$envField] = lang('error_env_value_line_break'); } } // URL 필드는 형태까지 확인한다 — 개행이 없어도 스킴이 없는 값이 그대로 기록되면 // 배포 후 절대 URL 생성이 어긋난다. foreach (['app_url', 'core_update_github_url'] as $urlField) { $urlValue = trim((string) ($formData[$urlField] ?? '')); if ($urlValue === '' || isset($errors[$urlField])) { continue; } $scheme = strtolower((string) parse_url($urlValue, PHP_URL_SCHEME)); if (! filter_var($urlValue, FILTER_VALIDATE_URL) || ! in_array($scheme, ['http', 'https'], true)) { $errors[$urlField] = lang('error_env_value_invalid_url', ['value' => $urlValue]); } } // 코어 업데이트 _pending 경로 검증 (입력된 경우만) $corePendingPath = trim($formData['core_update_pending_path'] ?? ''); if ($corePendingPath !== '') { // 절대 경로 변환 $absolutePath = str_starts_with($corePendingPath, '/') ? $corePendingPath : BASE_PATH.'/'.$corePendingPath; if (file_exists($absolutePath) && ! is_dir($absolutePath)) { $errors['core_update_pending_path'] = lang('error_core_pending_not_directory'); } } // DB 테스트 완료 확인 if (empty($errors) && ! isset($_SESSION['db_write_tested'])) { $errors['db_test'] = lang('error_db_not_tested'); } if (empty($errors) && ! empty($formData['use_read_db']) && ! isset($_SESSION['db_read_tested'])) { $errors['db_test'] = lang('error_db_not_tested'); } // 검증 통과 시 다음 단계로 이동 if (empty($errors)) { // 세션에는 비밀번호를 유지한다 (install-process.php 가 runtime.php 로 이송). // 단 admin_password_confirm 은 검증 이후 소비처가 전무한 순수 잔여물이므로 제거. $sessionConfig = $formData; unset($sessionConfig['admin_password_confirm']); $_SESSION['install_config'] = $sessionConfig; // state.json 에는 비밀(DB/관리자 비밀번호) 을 저장하지 않는다 (이슈 #465). // read 필드 정리(이슈 #63 2단계 방어) 도 sanitizeConfigForState 가 함께 수행. $safeFormData = sanitizeConfigForState($formData); updateStepStatus(3, 4, [ 'config' => $safeFormData, 'installation_status' => 'ready', ]); redirectToStep(4); } } /** * POST 요청 라우팅 * * @param int $currentStep 현재 단계 * @param string $currentLang 현재 언어 * @param array &$formData 폼 데이터 (참조) * @param array &$errors 에러 배열 (참조) * @param string|null &$error 에러 메시지 (참조) */ function handlePostRequest(int $currentStep, string $currentLang, array &$formData = [], array &$errors = [], ?string &$error = null): void { // 단계 이동 처리 (go_to_step POST 파라미터) if (isset($_POST['go_to_step'])) { $step = (int) $_POST['go_to_step']; if ($step >= 0 && $step <= 5) { $_SESSION['installer_current_step'] = $step; } header('Location: '.INSTALLER_BASE_URL.'/'); exit; } // 구 방식 언어 전환 처리 (호환성 유지) if (isset($_POST['change_language'])) { $lang = $_POST['lang'] ?? 'ko'; handleLanguageChange($lang); redirectToStep($currentStep); } // Step별 POST 처리 match ($currentStep) { 0 => handleStep0Post(), 1 => handleStep1Post($currentLang, $error), 2 => handleStep2Post(), 3 => handleStep3Post($currentLang, $formData, $errors), default => null }; }