emit($event, $data); } } if (! function_exists('bestEffortFailureMessage')) { /** * best-effort 작업 실패 안내 문구를 만듭니다. * * 종전에는 작업 종류와 무관하게 언어팩 전용 문구(`warning_language_pack_install_partial`)를 재사용해, * 대상(`target`)이 없는 `static_publish`·`config_cache` 실패가 "언어팩 일부 설치에 실패했습니다: (계속 진행)" * 으로 찍혔다(#651 F20). 작업 라벨(`task_{id}`)에 대상이 있으면 괄호로 덧붙인다. * * @param string $taskId 작업 식별자 (예: static_publish) * @param string $target 작업 대상 (없으면 빈 문자열) * @return string 안내 문구 */ function bestEffortFailureMessage(string $taskId, string $target): string { $taskName = lang("task_{$taskId}"); $display = $target !== '' ? "{$taskName} ({$target})" : $taskName; return lang('warning_best_effort_task_failed', ['task' => $display]); } } if (! function_exists('sendRollbackOutputSSE')) { /** * 롤백 실행 결과를 로그 이벤트로 출력합니다. * * @param array $rollbackResult rollbackDbMigrate() 등의 반환값 */ function sendRollbackOutputSSE(array $rollbackResult): void { if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { foreach ($rollbackResult['output'] as $line) { if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } } } } if (! function_exists('checkAbortStatusSSE')) { /** * 설치 중단 여부 확인 (SSE/폴링 공용). * * SSE 모드: 브라우저 연결 끊김 체크 + state.json aborted 체크. * 폴링 모드: state.json aborted 체크만 (연결 끊김은 항상 false). */ function checkAbortStatusSSE(): bool { // 1. 브라우저 연결 확인 (SSE 모드 전용, NullEmitter는 항상 false 반환) if (getProgressEmitter()->isConnectionAborted()) { addLog(lang('abort_connection_lost')); $state = getInstallationState(); $currentTask = $state['current_task'] ?? null; if ($currentTask && ! in_array($currentTask, $state['completed_tasks'] ?? [])) { addLog(lang('abort_rollback_start', ['task' => $currentTask])); $rollbackResult = rollbackTask($currentTask, $state); if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { $outputStr = implode("\n", $rollbackResult['output']); addLog($outputStr); } if ($rollbackResult['success']) { addLog(lang('abort_rollback_success', ['message' => $rollbackResult['message']])); } else { addLog(lang('abort_rollback_failed', ['message' => $rollbackResult['message']])); $dbTasks = ['db_migrate', 'db_seed']; if (in_array($currentTask, $dbTasks)) { addLog(lang('failed_rollback_manual_cleanup')); addLog(lang('failed_rollback_manual_cleanup_detail')); } else { addLog(lang('failed_rollback_retry')); addLog(lang('failed_rollback_retry_detail')); } } } else { addLog(lang('abort_no_rollback_needed')); } $state['installation_status'] = 'aborted'; $state['current_task'] = null; $state['abort_reason'] = 'Connection aborted unexpectedly'; $state['aborted_at'] = date('Y-m-d H:i:s'); if (isset($rollbackResult) && ! $rollbackResult['success']) { $state['rollback_failure'] = [ 'task' => $currentTask, 'message' => $rollbackResult['message'] ?? null, 'message_key' => 'failed_rollback_manual_cleanup', 'detail_key' => 'failed_rollback_manual_cleanup_detail', ]; } saveInstallationState($state); return true; } // 2. state.json에서 중단 여부 확인 $state = getInstallationState(); if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') { $currentTask = $state['current_task'] ?? 'unknown'; addLog(lang('abort_by_user', ['task' => $currentTask])); return true; } return false; } } // ============================================================================ // Task 함수 정의 (install-worker.php에서 이관됨 — 동작 동일) // ============================================================================ // 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 동일 규칙을 공유한다. require_once __DIR__.'/binary-path-policy.php'; if (! function_exists('getPhpBinary')) { /** * 설치 상태에 저장된 PHP 실행 경로를 돌려준다. * * 이 값은 실제 명령의 실행 바이너리가 되므로(설치 워커의 artisan 호출 등), * 형태 규칙을 통과하지 못하면 시스템 기본값으로 폴백한다 — 설치 흐름은 유지하되 * 사용자 입력이 인자 자리로 흘러가지 않도록 한다. */ function getPhpBinary(): string { $state = getInstallationState(); $phpBinary = (string) ($state['config']['php_binary'] ?? ''); if ($phpBinary === '' || $phpBinary === 'php') { return 'php'; } return installer_binary_path_shape_ok($phpBinary) ? $phpBinary : 'php'; } } if (! function_exists('isInstallerExecutablePath')) { /** * 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다. * * 판정은 공용 정책(binary-path-policy.php)이 소유한다 — 인스톨러 API 와 설치 워커가 * 서로 다른 규칙을 쓰면 한쪽이 다른 쪽의 우회로가 된다. */ function isInstallerExecutablePath(string $path): bool { return installer_binary_path_shape_ok($path); } } if (! function_exists('splitInstallerPhpComposerTokens')) { /** * 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해. * * 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 의 * 운영 의도를 지원한다. 자리별 규칙은 공용 정책이 담당한다. * * @return array{php: string, composer: string}|null 분해 실패 시 null */ function splitInstallerPhpComposerTokens(string $path): ?array { return installer_resolve_php_composer_pair($path); } } if (! function_exists('getComposerCommand')) { function getComposerCommand(): string { $state = getInstallationState(); $composerBinary = (string) ($state['config']['composer_binary'] ?? ''); if ($composerBinary === '') { return 'composer'; } // 공백 분리 입력 — 두 토큰으로 분해 후 각각 검증/escape. // 멀티 PHP 환경에서 특정 PHP 인터프리터로 composer 를 실행하려는 운영 의도 지원. if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null) { return 'composer'; } return escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']); } // 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되 // 사용자 입력이 셸 명령이나 인자 자리로 흘러가지 않도록 차단. if (! installer_is_composer_binary_path($composerBinary)) { return 'composer'; } if (str_ends_with(strtolower($composerBinary), '.phar')) { $phpBinary = getPhpBinary(); $phpArg = ($phpBinary !== 'php' && isInstallerExecutablePath($phpBinary)) ? escapeshellarg($phpBinary) : escapeshellarg('php'); return $phpArg.' '.escapeshellarg($composerBinary); } return escapeshellarg($composerBinary); } } if (! function_exists('getComposerCommandForDisplay')) { function getComposerCommandForDisplay(): string { $state = getInstallationState(); $composerBinary = (string) ($state['config']['composer_binary'] ?? ''); if ($composerBinary === '') { return 'composer'; } // 공백 분리 입력 — 토큰 검증 통과 시 사람 친화적 표기로 그대로 노출. if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null) { return 'composer'; } return $tokens['php'].' '.$tokens['composer']; } if (! installer_is_composer_binary_path($composerBinary)) { return 'composer'; } if (str_ends_with(strtolower($composerBinary), '.phar')) { return getPhpBinary().' '.$composerBinary; } return $composerBinary; } } if (! function_exists('vendorModeOptionFromState')) { /** * 인스톨러 state 의 vendor_mode 를 artisan 옵션 문자열로 변환합니다. * * 사용자가 Step 3 에서 선택한 vendor_mode 를 module:install/plugin:install * 등 확장 설치 커맨드로 일관되게 전파하기 위함. * * @return string ' --vendor-mode=bundled' 같은 문자열 (선행 공백 포함). 미설정 시 빈 문자열. */ function vendorModeOptionFromState(): string { $state = getInstallationState(); $mode = $state['config']['vendor_mode'] ?? ''; if (! in_array($mode, ['auto', 'composer', 'bundled'], true)) { return ''; } return ' --vendor-mode='.escapeshellarg($mode); } } if (! function_exists('checkComposerSSE')) { /** * Composer 가용성 확인. * * vendor_mode 분기: * - bundled → 즉시 스킵 (composer 불필요) * - composer → 검사 실패 시 에러 (사용자가 명시적으로 composer 모드 선택) * - auto → 검사 실패 시 경고만 (installComposerDependenciesSSE 가 폴백 처리) */ function checkComposerSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_composer_check'); updateCurrentTask('composer_check'); sendSSEEvent('task_start', ['task' => 'composer_check', 'name' => $taskName]); $state = getInstallationState(); $vendorMode = $state['config']['vendor_mode'] ?? 'auto'; // bundled 모드: composer 불필요 — 즉시 스킵 if ($vendorMode === 'bundled') { sendSSEEvent('log', ['message' => lang('log_composer_check_skipped_bundled')]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_check'); sendSSEEvent('task_complete', ['task' => 'composer_check', 'message' => lang('log_composer_check_skipped_bundled')]); return ['success' => true]; } sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); $composerHome = BASE_PATH.'/storage/composer'; if (! is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } putenv('COMPOSER_HOME='.$composerHome); putenv('HOME='.$composerHome); applyInstallerComposerEnvVars(); $output = []; $returnCode = 0; $composerCmd = getComposerCommand(); exec($composerCmd.' --version 2>&1', $output, $returnCode); if ($returnCode !== 0) { $errorMessage = implode("\n", $output); // auto 모드: composer 미설치 시 번들 폴백 가능하면 비치명적 처리 if ($vendorMode === 'auto' && file_exists(BASE_PATH.'/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => lang('log_composer_check_auto_fallback')]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_check'); sendSSEEvent('task_complete', ['task' => 'composer_check', 'message' => lang('log_composer_check_auto_fallback')]); return ['success' => true]; } sendSSEEvent('log', ['message' => lang('log_error_occurred', ['error' => $errorMessage])]); logInstallationError(lang('error_composer_not_installed')); return [ 'success' => false, 'message' => lang('error_composer_not_installed'), 'message_key' => 'error_composer_not_installed', 'detail' => $errorMessage, ]; } foreach ($output as $line) { sendSSEEvent('log', ['message' => $line]); } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_check'); sendSSEEvent('task_complete', ['task' => 'composer_check', 'message' => lang('log_composer_check_success')]); return ['success' => true]; } } if (! function_exists('installVendorBundleSSE')) { /** * vendor-bundle.zip 을 추출하여 vendor/ 를 구성합니다 (bundled 모드용). */ function installVendorBundleSSE(): array { require_once __DIR__.'/vendor-bundle-installer.php'; if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = 'Vendor 번들 추출'; updateCurrentTask('composer_install'); sendSSEEvent('task_start', ['task' => 'composer_install', 'name' => $taskName]); sendSSEEvent('log', ['message' => 'Vendor 번들 추출을 시작합니다...']); $integrity = verifyVendorBundle(BASE_PATH); if (! $integrity['valid']) { $errorList = implode(', ', $integrity['errors']); sendSSEEvent('log', ['message' => "번들 무결성 검증 실패: {$errorList}"]); logInstallationError("vendor-bundle.zip 무결성 검증 실패: {$errorList}"); return [ 'success' => false, 'message' => "vendor-bundle.zip 무결성 검증 실패: {$errorList}", ]; } $packageCount = (int) ($integrity['meta']['package_count'] ?? 0); sendSSEEvent('log', ['message' => "vendor-bundle.zip 검증 통과 ({$packageCount} packages)"]); sendSSEEvent('log', ['message' => 'vendor/ 디렉토리에 추출 중...']); $result = extractVendorBundle(BASE_PATH, BASE_PATH); if (! $result['success']) { $error = $result['error'] ?? 'unknown'; sendSSEEvent('log', ['message' => "vendor 번들 추출 실패: {$error}"]); logInstallationError("vendor 번들 추출 실패: {$error}"); return [ 'success' => false, 'message' => "vendor 번들 추출 실패: {$error}", ]; } sendSSEEvent('log', ['message' => "vendor 번들 추출 완료 ({$result['package_count']} packages)"]); // 이전 환경(특히 dev) 의 stale 캐시 정리 — 미정리 시 제거된 dev 패키지의 // ServiceProvider 를 후속 artisan 호출(key:generate 등) 에서 참조하다가 실패한다. $cleared = clearLaravelCompiledCache(BASE_PATH); if (! empty($cleared)) { sendSSEEvent('log', ['message' => lang('log_composer_cache_cleared')]); } sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_install'); sendSSEEvent('task_complete', ['task' => 'composer_install', 'message' => 'vendor 번들 추출 완료']); return ['success' => true, 'mode' => 'bundled']; } } if (! function_exists('installComposerDependenciesSSE')) { function installComposerDependenciesSSE(): array { // Laravel compiled cache 정리 헬퍼(clearLaravelCompiledCache)가 정의된 shim require_once __DIR__.'/vendor-bundle-installer.php'; // Vendor 모드에 따라 분기: bundled → vendor-bundle.zip 추출 $state = getInstallationState(); $vendorMode = $state['config']['vendor_mode'] ?? 'auto'; if ($vendorMode === 'bundled') { return installVendorBundleSSE(); } if ($vendorMode === 'auto') { // composer 가능 여부에 따라 자동 분기 $composerBinary = $state['config']['composer_binary'] ?? ''; $phpBinary = $state['config']['php_binary'] ?? 'php'; if (! canExecuteComposerForInstall($composerBinary, $phpBinary)) { // composer 사용 불가 → 번들 zip 존재 여부 확인 후 폴백 if (file_exists(BASE_PATH.'/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => 'Composer 실행 불가 환경 감지 → 번들 vendor 모드로 자동 전환']); return installVendorBundleSSE(); } } } if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_composer_install'); updateCurrentTask('composer_install'); sendSSEEvent('task_start', ['task' => 'composer_install', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); $vendorExists = is_dir(BASE_PATH.'/vendor') && file_exists(BASE_PATH.'/vendor/autoload.php'); $lockExists = file_exists(BASE_PATH.'/composer.lock'); if ($vendorExists && $lockExists) { // 이미 준비된 vendor 를 그대로 쓴다. 그 vendor 가 개발용(require-dev 포함) 설치면 // 설치는 계속하되 운영자에게 알린다 — 그대로 두면 이후 코어 업데이트가 vendor 를 // --no-dev 로 교체할 때 이전 매니페스트에만 남은 provider 를 찾다 부팅이 깨진다. $devInfo = detectDevVendorInstall(BASE_PATH); if ($devInfo['dev'] === true) { sendSSEEvent('log', ['message' => lang('log_composer_dev_packages_detected', [ 'count' => count($devInfo['packages']), 'packages' => implode(', ', array_slice($devInfo['packages'], 0, 5)).(count($devInfo['packages']) > 5 ? ' …' : ''), ])]); // sendSSEEvent('log') 는 progress emitter 안에서 installation.log 에도 기록한다 // (SSE·폴링 두 모드 공통) — 여기서 addLog 를 또 부르면 로그에 같은 줄이 두 번 남는다. sendSSEEvent('log', ['message' => lang('warning_composer_dev_packages_kept')]); } else { sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]); } // 재사용 경로에서도 이전 환경의 컴파일 캐시를 정리한다 — 종전에는 정리 없이 // return 해, key_generate 가 이미 완료된 재개 설치에서는 어디서도 정리되지 않았다. if (! empty(clearLaravelCompiledCache(BASE_PATH))) { sendSSEEvent('log', ['message' => lang('log_composer_cache_cleared')]); } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_install'); sendSSEEvent('task_complete', ['task' => 'composer_install', 'message' => lang('log_composer_already_installed')]); return ['success' => true]; } if ($vendorExists && ! $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_without_lock')]); sendSSEEvent('log', ['message' => lang('log_composer_removing_vendor')]); $deleted = deleteDirectory(BASE_PATH.'/vendor'); if (! $deleted) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_delete_failed')]); } else { sendSSEEvent('log', ['message' => lang('log_composer_vendor_deleted')]); } } if (! $vendorExists && $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_installing_from_lock')]); } if (! $vendorExists && ! $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_fresh_install')]); } // packages.php / services.php / config.php 모두 정리 — 이전 환경의 캐시가 남아있으면 // composer post-autoload-dump 단계의 package:discover 가 부팅 중 stale provider 를 // 참조하여 실패한다. $cleared = clearLaravelCompiledCache(BASE_PATH); if (! empty($cleared)) { sendSSEEvent('log', ['message' => lang('log_composer_cache_cleared')]); } chdir(BASE_PATH); $composerHome = BASE_PATH.'/storage/composer'; if (! is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } $env = []; foreach (['PATH', 'SystemRoot', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE'] as $key) { $value = getenv($key); if ($value !== false) { $env[$key] = $value; } } $env['COMPOSER_HOME'] = $composerHome; $env['HOME'] = $composerHome; // root/super user 환경 + 비대화형 컨텍스트 대응 (Synology DSM 등 PHP-FPM root 실행) $env = array_merge($env, buildInstallerComposerEnv()); if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) { $tempDir = BASE_PATH.'/storage/temp'; if (! is_dir($tempDir)) { @mkdir($tempDir, 0755, true); } $env['TEMP'] = $tempDir; $env['TMP'] = $tempDir; } } $descriptorspec = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $composerCmd = getComposerCommand(); $process = proc_open( $composerCmd.' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1', $descriptorspec, $pipes, BASE_PATH, $env ); if (! is_resource($process)) { logInstallationError(lang('error_composer_install_failed')); return [ 'success' => false, 'message' => lang('error_composer_install_failed'), 'message_key' => 'error_composer_install_failed', 'detail' => lang('error_composer_process_failed'), ]; } fclose($pipes[0]); stream_set_blocking($pipes[1], false); while (! feof($pipes[1])) { if (checkAbortStatusSSE()) { proc_terminate($process); proc_close($process); return ['success' => false, 'aborted' => true]; } $line = fgets($pipes[1]); if ($line !== false) { $line = trim($line); // composer stdout 은 시스템 코드페이지(Windows CP949 등)로 출력되고, // 진행바 갱신이 non-blocking 읽기와 겹치면 임의 바이트 경계에서 잘려 // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter)과 // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 전송 전 정규화한다. // // 정규화는 복원 가능한 코드페이지 출력(한글 경로·메시지)을 먼저 되살리므로, // 이전의 mb_scrub 단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다. // (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망) $line = installer_utf8_normalize($line); if (! empty($line)) { sendSSEEvent('log', ['message' => $line]); } } usleep(100000); } fclose($pipes[1]); fclose($pipes[2]); $returnCode = proc_close($process); if ($returnCode !== 0) { logInstallationError(lang('error_composer_install_failed')); return [ 'success' => false, 'message' => lang('error_composer_install_failed'), 'message_key' => 'error_composer_install_failed', 'detail' => lang('error_composer_exit_code', ['code' => $returnCode]), ]; } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_install'); sendSSEEvent('task_complete', ['task' => 'composer_install', 'message' => lang('log_composer_install_success')]); return ['success' => true]; } } if (! function_exists('updateEnvFileSSE')) { /** * 동적 설정(DB 자격증명) 을 storage/installer/runtime.php 에 작성한다. * * php artisan serve 의 .env mtime 워처가 진행 중 재시작을 일으키지 않도록 * 설치 진행 중에는 .env 를 직접 수정하지 않는다. runtime.php 는 부팅 시 * InstallerRuntimeServiceProvider 가 읽어 config 에 주입하므로 mysql * 마이그레이션/시더가 정상 동작한다. * * 설치 완료 UI 노출 후 finalize-env.php 가 runtime.php → .env 머지를 1회 수행한다. * * @return array{success: bool, message?: string, message_key?: string} */ function updateEnvFileSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_env_update'); updateCurrentTask('env_update'); sendSSEEvent('task_start', ['task' => 'env_update', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); // .env.example 존재 여부만 확인 — finalize 단계에서 generateEnvContent() 가 사용 $envExamplePath = BASE_PATH.'/.env.example'; if (! file_exists($envExamplePath)) { logInstallationError(lang('error_env_example_not_found')); return [ 'success' => false, 'message' => lang('error_env_example_not_found'), 'message_key' => 'error_env_example_not_found', ]; } $state = getInstallationState(); $stateConfig = is_array($state['config'] ?? null) ? $state['config'] : []; $runtime = buildInstallerRuntimeFromState($stateConfig); if (! writeInstallerRuntime($runtime)) { logInstallationError(lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH])); return [ 'success' => false, 'message' => lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH]), 'message_key' => 'error_env_write_failed', ]; } sendSSEEvent('log', ['message' => lang('log_env_update_success')]); sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('env_update'); sendSSEEvent('task_complete', ['task' => 'env_update', 'message' => lang('log_env_update_success')]); return ['success' => true]; } } if (! function_exists('generateApplicationKeySSE')) { /** * APP_KEY 를 pure PHP 로 생성하여 runtime.php 에 기록한다. * * 기존: php artisan key:generate --force (artisan 이 .env 직접 수정). * 신규: random_bytes(32) + base64 → runtime.php 의 'app.key' 키로 기록. * Laravel 의 Encrypter::generateKey('AES-256-CBC') 와 동일. * * runtime.php 에 이미 키가 있으면 보존 (재시도 시 키 변경 방지). * * @return array{success: bool, message?: string, message_key?: string} */ function generateApplicationKeySSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } // 재시도 경로 방어 — composer_install 재진입 시 stale bootstrap/cache 정리 require_once __DIR__.'/vendor-bundle-installer.php'; clearLaravelCompiledCache(BASE_PATH); $taskName = lang('task_key_generate'); updateCurrentTask('key_generate'); sendSSEEvent('task_start', ['task' => 'key_generate', 'name' => $taskName]); // runtime.php 에 키가 이미 있으면 그대로 둠 (재시도 안전성) $runtime = readInstallerRuntime(); $existingKey = $runtime['app']['key'] ?? null; if (is_string($existingKey) && str_starts_with($existingKey, 'base64:') && strlen($existingKey) >= 47) { sendSSEEvent('log', ['message' => lang('log_already_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('key_generate'); sendSSEEvent('task_complete', ['task' => 'key_generate', 'message' => lang('log_key_generate_success')]); return ['success' => true]; } sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); // pure PHP 키 생성 — .env 무수정 $key = generateAppKeyInline(); $runtime = is_array($runtime) ? $runtime : []; $runtime['app']['key'] = $key; $runtime['created_at'] = $runtime['created_at'] ?? date('c'); if (! writeInstallerRuntime($runtime)) { logInstallationError(lang('error_key_generate_failed')); return [ 'success' => false, 'message' => lang('error_key_generate_failed'), 'message_key' => 'error_key_generate_failed', ]; } sendSSEEvent('log', ['message' => lang('log_key_generate_success')]); sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('key_generate'); sendSSEEvent('task_complete', ['task' => 'key_generate', 'message' => lang('log_key_generate_success')]); return ['success' => true]; } } if (! function_exists('dependencyPrecheckSSE')) { /** * 의존성 사전 검증 (백엔드 안전망). * * 선택된 모든 확장의 manifest를 직접 읽어 의존성 그래프를 검증합니다. * 프론트엔드 검증을 우회한 경우에도 마이그레이션/활성화 작업 시작 전에 차단합니다. */ function dependencyPrecheckSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_dependency_precheck'); updateCurrentTask('dependency_precheck'); sendSSEEvent('task_start', ['task' => 'dependency_precheck', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); $selected = getSelectedExtensions(); $selectedIds = array_merge( $selected['admin_templates'] ?? [], $selected['user_templates'] ?? [], $selected['modules'] ?? [], $selected['plugins'] ?? [] ); $selectedIdSet = array_flip($selectedIds); $missing = []; // 확장 매니페스트 읽기 (모듈/플러그인 — 템플릿은 dependencies 사용 빈도 낮음) $scanTargets = [ 'modules' => BASE_PATH.'/modules/_bundled', 'plugins' => BASE_PATH.'/plugins/_bundled', 'templates' => BASE_PATH.'/templates/_bundled', ]; foreach ($scanTargets as $type => $baseDir) { if (! is_dir($baseDir)) { continue; } $dirs = scandir($baseDir) ?: []; foreach ($dirs as $dir) { if ($dir === '.' || $dir === '..') { continue; } $manifestFile = null; if ($type === 'modules' && file_exists("{$baseDir}/{$dir}/module.json")) { $manifestFile = "{$baseDir}/{$dir}/module.json"; } elseif ($type === 'plugins' && file_exists("{$baseDir}/{$dir}/plugin.json")) { $manifestFile = "{$baseDir}/{$dir}/plugin.json"; } elseif ($type === 'templates' && file_exists("{$baseDir}/{$dir}/template.json")) { $manifestFile = "{$baseDir}/{$dir}/template.json"; } if (! $manifestFile) { continue; } $data = json_decode((string) @file_get_contents($manifestFile), true); if (! is_array($data)) { continue; } $identifier = $data['identifier'] ?? $dir; // 선택된 확장만 검증 대상 if (! isset($selectedIdSet[$identifier])) { continue; } $deps = $data['dependencies'] ?? []; if (! is_array($deps)) { continue; } // 객체형 (modules/plugins 키) 또는 배열형 처리 $check = []; if (isset($deps['modules']) && is_array($deps['modules'])) { foreach ($deps['modules'] as $depId => $_) { $check[] = is_int($depId) ? $_ : $depId; } } if (isset($deps['plugins']) && is_array($deps['plugins'])) { foreach ($deps['plugins'] as $depId => $_) { $check[] = is_int($depId) ? $_ : $depId; } } if (! isset($deps['modules']) && ! isset($deps['plugins'])) { foreach ($deps as $depId) { if (is_string($depId)) { $check[] = $depId; } } } foreach ($check as $depId) { if (! isset($selectedIdSet[$depId])) { $missing[] = "{$identifier} → {$depId}"; } } } } if (! empty($missing)) { $errorMessage = lang('dependency_precheck_failed'); foreach ($missing as $line) { sendSSEEvent('log', ['message' => ' - '.$line]); } logInstallationError($errorMessage); return [ 'success' => false, 'message' => $errorMessage, 'message_key' => 'dependency_precheck_failed', 'detail' => implode("\n", $missing), ]; } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('dependency_precheck'); sendSSEEvent('task_complete', ['task' => 'dependency_precheck', 'message' => $taskName]); return ['success' => true]; } } if (! function_exists('cleanupExistingTablesSSE')) { /** * 기존 DB 테이블 정리. * * state.json의 existing_db_action 값에 따라 기존 테이블을 삭제합니다. * - skip (또는 null): 작업 없이 건너뛰기 * - drop_tables: FOREIGN_KEY_CHECKS=0 → db_prefix 로 시작하는 테이블만 DROP → FOREIGN_KEY_CHECKS=1 * * prefix 없는 타 테이블까지 삭제하던 결함 수정. db_prefix 로 시작하는 * 테이블만 선별 삭제하며, 빈 prefix 는 데이터 손실 방어로 삭제를 수행하지 않는다. */ function cleanupExistingTablesSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_db_cleanup'); updateCurrentTask('db_cleanup'); sendSSEEvent('task_start', ['task' => 'db_cleanup', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); $state = getInstallationState(); $action = $state['existing_db_action'] ?? 'skip'; if ($action !== 'drop_tables') { sendSSEEvent('log', ['message' => lang('log_db_cleanup_skipped')]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('db_cleanup'); sendSSEEvent('task_complete', ['task' => 'db_cleanup', 'message' => lang('log_db_cleanup_skipped')]); return ['success' => true]; } // state.config 에는 DB 비밀번호가 기록되지 않으므로(이슈 #465) runtime.php 에서 // 자격증명을 복원한다. db_cleanup 은 env_update 이후 실행되어 runtime 이 항상 존재. $config = hydrateDbSecretsFromRuntime($state['config'] ?? []); try { $pdo = getDatabaseConnection($config, false); $database = $config['db_write_database'] ?? ''; $prefix = (string) ($config['db_prefix'] ?? 'g7_'); $stmt = $pdo->query('SHOW TABLES'); $allTables = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : []; // 입력한 db_prefix 로 시작하는 테이블만 삭제 대상으로 선별. // prefix 없는 타 애플리케이션/이전 설치 테이블은 동일 DB 라도 보존한다. $tables = filterTablesByPrefix($allTables, $prefix); if (empty($tables)) { sendSSEEvent('log', ['message' => lang('log_db_cleanup_empty')]); } else { sendSSEEvent('log', ['message' => lang('log_db_cleanup_dropping', ['count' => count($tables)])]); $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); foreach ($tables as $table) { $quoted = '`'.str_replace('`', '``', $table).'`'; $pdo->exec("DROP TABLE IF EXISTS {$quoted}"); sendSSEEvent('log', ['message' => " - DROP TABLE {$table}"]); } $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); sendSSEEvent('log', ['message' => lang('log_db_cleanup_done', ['count' => count($tables)])]); } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('db_cleanup'); sendSSEEvent('task_complete', ['task' => 'db_cleanup', 'message' => $taskName]); return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_db_cleanup_failed'), $e); return [ 'success' => false, 'message' => lang('error_db_cleanup_failed'), 'message_key' => 'error_db_cleanup_failed', 'detail' => $e->getMessage(), ]; } } } if (! function_exists('runDatabaseMigrationSSE')) { function runDatabaseMigrationSSE(): array { return executeDbCommandSSE( artisanCommand: 'migrate --force', taskId: 'db_migrate', taskNameKey: 'task_db_migrate', successMsgKey: 'log_db_migrate_success', errorMsgKey: 'error_db_migrate_failed' ); } } if (! function_exists('runDatabaseSeedingSSE')) { function runDatabaseSeedingSSE(): array { $state = getInstallationState(); $config = $state['config'] ?? []; // 관리자 비밀번호는 runtime.php(0600) 가 SSoT (이슈 #465). state.config 폴백은 // 이 수정 이전에 시작된 설치(레거시 state) 를 위한 한시 경로. $adminPassword = readInstallerRuntime()['admin']['password'] ?? ($config['admin_password'] ?? ''); if (empty($config['admin_email']) || empty($adminPassword)) { return ['success' => false, 'error' => '관리자 이메일과 비밀번호가 설정되지 않았습니다.']; } // 이름/이메일/언어는 비밀이 아니므로 state.config 에 그대로 둔다. putenv('INSTALLER_ADMIN_NAME='.($config['admin_name'] ?? 'Administrator')); putenv('INSTALLER_ADMIN_EMAIL='.$config['admin_email']); putenv('INSTALLER_ADMIN_PASSWORD='.$adminPassword); putenv('INSTALLER_ADMIN_LANGUAGE='.($config['admin_language'] ?? $state['g7_locale'] ?? 'ko')); $result = executeDbCommandSSE( artisanCommand: 'db:seed --force', taskId: 'db_seed', taskNameKey: 'task_db_seed', successMsgKey: 'log_db_seed_success', errorMsgKey: 'error_db_seed_failed' ); if (($result['success'] ?? false) === true) { purgeAdminPasswordAfterSeeding(); } return $result; } } if (! function_exists('purgeAdminPasswordAfterSeeding')) { /** * db_seed 성공 직후 관리자 비밀번호의 모든 잔존처를 제거한다 (이슈 #465). * * 시딩이 끝나면 관리자 계정이 DB 에 해시로 존재하므로 평문은 더 이상 필요 없다. * 노출 창을 최소화하기 위해 소비 즉시 삭제한다: * * 1. runtime.php 의 admin 섹션 (0600 이지만 finalize 까지 잔존) * 2. state.json 의 비밀 4종 (이 수정 이전에 시작된 레거시 설치 대비) * 3. process ENV — 해제하지 않으면 이후 모든 exec() 자식(확장 설치 artisan 전부) * 이 평문을 상속받는다 * * 재시도 안전성: db_seed 는 completed_tasks 마커로 스킵된다. drop_tables 재시도로 * 마커가 제거되는 경로는 반드시 install-process.php 를 다시 거치므로 세션 또는 * runtime 에서 비밀번호가 재주입된다. */ function purgeAdminPasswordAfterSeeding(): void { $runtime = readInstallerRuntime(); if (is_array($runtime) && isset($runtime['admin'])) { unset($runtime['admin']); writeInstallerRuntime($runtime); } // executeDbCommandSSE 가 markTaskCompleted 로 state 를 갱신했으므로 재로드 후 // redact 한다 (스냅샷을 덮어쓰면 completed 마커가 유실됨). saveInstallationState(redactInstallationStateSecrets(getInstallationState())); // 값 없이 키만 전달 → 해제. 자식 프로세스 ENV 상속 차단. putenv('INSTALLER_ADMIN_PASSWORD'); putenv('INSTALLER_ADMIN_NAME'); putenv('INSTALLER_ADMIN_EMAIL'); putenv('INSTALLER_ADMIN_LANGUAGE'); } } if (! function_exists('getSelectedExtensions')) { function getSelectedExtensions(): array { $state = getInstallationState(); return $state['selected_extensions'] ?? [ 'admin_templates' => [], 'user_templates' => [], 'modules' => [], 'plugins' => [], 'language_packs' => [], ]; } } if (! function_exists('reserveCommandOutputFile')) { /** * artisan 명령 stdout/stderr redirect 용 임시 로그 파일 경로 예약. * * pipe deadlock 회피를 위해 PHP exec() 의 child output 을 pipe 가 아닌 파일로 보낸다. * 파일은 권한이 보장된 디렉토리에 생성 — 인스톨러 환경에서 항상 writable 인 곳: * 1) storage/logs/ (installation.log 와 같은 검증된 디렉토리, REQUIRED_DIRECTORIES 의 storage 하위) * 2) storage/installer/ (runtime.php 와 같은 디렉토리) * 3) sys_get_temp_dir() (최후 fallback) * * @return string 예약된 임시 파일 경로 (호출자가 redirect 후 read + unlink) */ function reserveCommandOutputFile(): string { $candidates = [ BASE_PATH.'/storage/logs', BASE_PATH.'/storage/installer', sys_get_temp_dir(), ]; foreach ($candidates as $dir) { if (! is_dir($dir)) { @mkdir($dir, 0755, true); } if (is_dir($dir) && is_writable($dir)) { return $dir.DIRECTORY_SEPARATOR.'installer-cmd-output-'.uniqid('', true).'.log'; } } // 모든 fallback 실패 — 마지막 시도로 sys_get_temp_dir 의 파일 경로 그대로 반환 // (해당 디렉토리가 존재할 가능성이 매우 높지만 is_writable 가 false 였다면 redirect 가 실패할 것) return sys_get_temp_dir().DIRECTORY_SEPARATOR.'installer-cmd-output-'.uniqid('', true).'.log'; } } if (! function_exists('executeArtisanCommandSSE')) { function executeArtisanCommandSSE( string $artisanCommand, string $taskId, string $taskNameKey, string $successMsgKey, string $errorMsgKey ): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang($taskNameKey); updateCurrentTask($taskId); sendSSEEvent('task_start', ['task' => $taskId, 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); chdir(BASE_PATH); $output = []; $returnCode = 0; $phpBin = escapeshellarg(getPhpBinary()); $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); if (file_exists($cmdLogFile)) { $output = file($cmdLogFile, FILE_IGNORE_NEW_LINES) ?: []; @unlink($cmdLogFile); } foreach ($output as $line) { if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } if ($returnCode !== 0) { $errorMessage = implode("\n", $output); logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); return [ 'success' => false, 'message' => lang($errorMsgKey), 'message_key' => $errorMsgKey, 'detail' => $errorMessage, ]; } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted($taskId); sendSSEEvent('task_complete', ['task' => $taskId, 'message' => lang($successMsgKey)]); return ['success' => true]; } } if (! function_exists('executeDbCommandSSE')) { function executeDbCommandSSE( string $artisanCommand, string $taskId, string $taskNameKey, string $successMsgKey, string $errorMsgKey ): array { $state = getInstallationState(); $wasAborted = isset($state['installation_status']) && $state['installation_status'] === 'aborted'; if ($wasAborted) { addLog(lang('db_task_abort_detected_before_start')); return ['success' => false, 'aborted' => true]; } $taskName = lang($taskNameKey); updateCurrentTask($taskId); sendSSEEvent('task_start', ['task' => $taskId, 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); chdir(BASE_PATH); $output = []; $returnCode = 0; $phpBin = escapeshellarg(getPhpBinary()); $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); if (file_exists($cmdLogFile)) { $output = file($cmdLogFile, FILE_IGNORE_NEW_LINES) ?: []; @unlink($cmdLogFile); } foreach ($output as $line) { if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } if ($returnCode !== 0) { $errorMessage = implode("\n", $output); logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); addLog(lang('db_task_failed_rollback_start', ['task' => $taskId])); sendSSEEvent('log', ['message' => lang('failed_rollback_start', ['task' => $taskName])]); $rollbackResult = rollbackDbMigrate(); $rollbackFailure = null; sendRollbackOutputSSE($rollbackResult); if ($rollbackResult['success']) { sendSSEEvent('log', ['message' => lang('failed_rollback_success', ['message' => $rollbackResult['message']])]); removeTaskCompleted('db_migrate'); removeTaskCompleted('db_seed'); sendSSEEvent('log', ['message' => lang('failed_rollback_db_restart')]); } else { sendSSEEvent('log', ['message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']])]); $rollbackFailure = [ 'message_key' => 'failed_rollback_manual_cleanup', 'detail_key' => 'failed_rollback_manual_cleanup_detail', ]; sendSSEEvent('rollback_failed', [ 'message' => lang('failed_rollback_manual_cleanup'), 'detail' => lang('failed_rollback_manual_cleanup_detail'), ]); } return [ 'success' => false, 'message' => lang($errorMsgKey), 'message_key' => $errorMsgKey, 'detail' => $errorMessage, 'rollback_done' => true, 'rollback_failure' => $rollbackFailure, ]; } markTaskCompleted($taskId); $state = getInstallationState(); $isAborted = isset($state['installation_status']) && $state['installation_status'] === 'aborted'; $connectionLost = getProgressEmitter()->isConnectionAborted(); if ($isAborted || $connectionLost) { $reason = $connectionLost ? lang('db_task_abort_reason_connection') : lang('db_task_abort_reason_user'); addLog(lang('db_task_completed_abort_detected', ['task' => $taskName, 'reason' => $reason])); sendSSEEvent('log', ['message' => lang('db_task_completed_rollback_start', ['task' => $taskName])]); $rollbackResult = rollbackDbMigrate(); sendRollbackOutputSSE($rollbackResult); if ($rollbackResult['success']) { sendSSEEvent('log', ['message' => lang('abort_rollback_success', ['message' => $rollbackResult['message']])]); removeTaskCompleted('db_migrate'); removeTaskCompleted('db_seed'); sendSSEEvent('log', ['message' => lang('failed_rollback_db_restart')]); } else { sendSSEEvent('log', ['message' => lang('abort_rollback_failed', ['message' => $rollbackResult['message']])]); sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup')]); } return ['success' => false, 'aborted' => true]; } sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); sendSSEEvent('task_complete', ['task' => $taskId, 'message' => lang($successMsgKey)]); return ['success' => true]; } } if (! function_exists('executeExtensionCommandSSE')) { function executeExtensionCommandSSE( string $artisanCommand, string $taskId, ?string $target, string $taskNameKey, string $successMsgKey, string $errorMsgKey ): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang($taskNameKey); $targetDisplayName = $target; if ($target) { $state = getInstallationState(); $extensionNames = $state['extension_names'] ?? []; $lang = $state['g7_locale'] ?? 'ko'; if (isset($extensionNames[$target])) { $name = $extensionNames[$target]; if (is_array($name)) { $targetDisplayName = $name[$lang] ?? $name['ko'] ?? $name['en'] ?? $target; } else { $targetDisplayName = $name; } } } $displayName = $target ? "{$targetDisplayName} {$taskName} ({$target})" : $taskName; updateCurrentTask($taskId); sendSSEEvent('task_start', ['task' => $taskId, 'target' => $target, 'name' => $displayName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $displayName])]); chdir(BASE_PATH); $output = []; $returnCode = 0; $phpBin = escapeshellarg(getPhpBinary()); $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); if (file_exists($cmdLogFile)) { $output = file($cmdLogFile, FILE_IGNORE_NEW_LINES) ?: []; @unlink($cmdLogFile); } // 폴링 모드(NullEmitter)는 batch write — Windows file IO 라인당 ~5ms 누적 회피. // SSE 모드는 stream emit 으로 사용자에게 실시간 진행 표시. $emitter = getProgressEmitter(); if ($emitter instanceof NullEmitter) { $nonEmpty = []; foreach ($output as $line) { if (trim($line) !== '') { $nonEmpty[] = $line; } } if (! empty($nonEmpty)) { addLogBatch($nonEmpty); } // heartbeat 갱신 1회 (라인당 호출 시 state.json IO 병목) $emitter->emit('heartbeat', []); } else { foreach ($output as $line) { if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } } $outputText = implode("\n", $output); $alreadyExistsPatterns = ['already installed', 'already active', '이미 설치', '이미 활성화']; $isAlreadyExists = false; foreach ($alreadyExistsPatterns as $pattern) { if (stripos($outputText, $pattern) !== false) { $isAlreadyExists = true; break; } } if ($returnCode !== 0 && ! $isAlreadyExists) { $errorMessage = $outputText; logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); return [ 'success' => false, 'message' => lang($errorMsgKey), 'message_key' => $errorMsgKey, 'detail' => $errorMessage, 'target' => $target, ]; } if ($isAlreadyExists) { sendSSEEvent('log', ['message' => lang('log_task_skipped', ['task' => $displayName])]); } else { sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $displayName])]); } sendSSEEvent('log', ['message' => lang('log_separator')]); $completedTaskKey = $target ? "{$taskId}:{$target}" : $taskId; markTaskCompleted($completedTaskKey); sendSSEEvent('task_complete', [ 'task' => $taskId, 'target' => $target, 'message' => lang($successMsgKey), ]); return ['success' => true]; } } if (! function_exists('installAdminTemplateSSE')) { function installAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( artisanCommand: "template:install {$templateId}", taskId: 'template_install', target: $templateId, taskNameKey: 'task_template_install', successMsgKey: 'log_template_install_success', errorMsgKey: 'error_template_install_failed' ); } } if (! function_exists('activateAdminTemplateSSE')) { function activateAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( artisanCommand: "template:activate {$templateId}", taskId: 'template_activate', target: $templateId, taskNameKey: 'task_template_activate', successMsgKey: 'log_template_activate_success', errorMsgKey: 'error_template_activate_failed' ); } } if (! function_exists('installModuleSSE')) { function installModuleSSE(string $moduleId): array { $vendorModeOpt = vendorModeOptionFromState(); return executeExtensionCommandSSE( artisanCommand: "module:install {$moduleId}{$vendorModeOpt}", taskId: 'module_install', target: $moduleId, taskNameKey: 'task_module_install', successMsgKey: 'log_module_install_success', errorMsgKey: 'error_module_install_failed' ); } } if (! function_exists('activateModuleSSE')) { function activateModuleSSE(string $moduleId): array { return executeExtensionCommandSSE( artisanCommand: "module:activate {$moduleId}", taskId: 'module_activate', target: $moduleId, taskNameKey: 'task_module_activate', successMsgKey: 'log_module_activate_success', errorMsgKey: 'error_module_activate_failed' ); } } if (! function_exists('installPluginSSE')) { function installPluginSSE(string $pluginId): array { $vendorModeOpt = vendorModeOptionFromState(); return executeExtensionCommandSSE( artisanCommand: "plugin:install {$pluginId}{$vendorModeOpt}", taskId: 'plugin_install', target: $pluginId, taskNameKey: 'task_plugin_install', successMsgKey: 'log_plugin_install_success', errorMsgKey: 'error_plugin_install_failed' ); } } if (! function_exists('activatePluginSSE')) { function activatePluginSSE(string $pluginId): array { return executeExtensionCommandSSE( artisanCommand: "plugin:activate {$pluginId}", taskId: 'plugin_activate', target: $pluginId, taskNameKey: 'task_plugin_activate', successMsgKey: 'log_plugin_activate_success', errorMsgKey: 'error_plugin_activate_failed' ); } } if (! function_exists('installUserTemplateSSE')) { function installUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( artisanCommand: "template:install {$templateId}", taskId: 'user_template_install', target: $templateId, taskNameKey: 'task_user_template_install', successMsgKey: 'log_user_template_install_success', errorMsgKey: 'error_user_template_install_failed' ); } } if (! function_exists('activateUserTemplateSSE')) { function activateUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( artisanCommand: "template:activate {$templateId}", taskId: 'user_template_activate', target: $templateId, taskNameKey: 'task_user_template_activate', successMsgKey: 'log_user_template_activate_success', errorMsgKey: 'error_user_template_activate_failed' ); } } if (! function_exists('installLanguagePackSSE')) { /** * 번들 언어팩 설치 (best-effort). * * `php artisan language-pack:install {id} --source=bundled` 호출. 자동 활성화 default. * 1건 실패는 전체 설치를 중단하지 않으며, 호출자(runInstallationTasks)는 * task 정의에 `best_effort: true` 를 두어 실패 시 rollback 우회 + 경고 로그 처리. * * @param string $packId 번들 언어팩 식별자 (예: g7-core-ja) * @return array{success: bool, ...} */ function installLanguagePackSSE(string $packId): array { return executeExtensionCommandSSE( artisanCommand: "language-pack:install {$packId} --source=bundled", taskId: 'language_pack_install', target: $packId, taskNameKey: 'task_language_pack_install', successMsgKey: 'log_language_pack_install_success', errorMsgKey: 'error_language_pack_install_failed' ); } } if (! function_exists('clearCacheSSE')) { function clearCacheSSE(): array { return executeArtisanCommandSSE( artisanCommand: 'optimize:clear', taskId: 'cache_clear', taskNameKey: 'task_cache_clear', successMsgKey: 'log_cache_clear_success', errorMsgKey: 'error_cache_clear_failed' ); } } if (! function_exists('optimizeConfigCacheSSE')) { /** * 설치 완료 직후 config 캐시를 최초 생성한다. * * complete_flag 이후에 실행되어 installer_completed=true 상태에서 캐시를 만들며, * 이 지점이 없으면 설치는 config:cache 가 꺼진(매 요청 config 재파싱) 상태로 종료된다. * best_effort — 실패해도 설치 자체는 완료 처리한다(비캐시 부팅은 정상 동작). * * @return array 태스크 실행 결과 */ function optimizeConfigCacheSSE(): array { return executeArtisanCommandSSE( artisanCommand: 'config:cache', taskId: 'config_cache', taskNameKey: 'task_config_cache', successMsgKey: 'log_config_cache_success', errorMsgKey: 'error_config_cache_failed' ); } } if (! function_exists('publishStaticCacheSSE')) { /** * 설치 완료 직후 부트스트랩 리소스 정적 게시(bake)를 최초 수행한다 (#122). * * 이 지점이 없으면 첫 방문자가 blade 자가 치유(1회 API 폴백)를 겪는다. * best_effort — 실패해도 설치는 완료 처리한다(사이트는 API 폴백으로 정상). * * @return array 태스크 실행 결과 */ function publishStaticCacheSSE(): array { return executeArtisanCommandSSE( artisanCommand: 'ext-static:publish --force', taskId: 'static_publish', taskNameKey: 'task_static_publish', successMsgKey: 'log_static_publish_success', errorMsgKey: 'error_static_publish_failed' ); } } if (! function_exists('createSettingsJsonSSE')) { function createSettingsJsonSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_create_settings_json'); updateCurrentTask('create_settings_json'); sendSSEEvent('task_start', ['task' => 'create_settings_json', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_creating_settings')]); try { $settingsDir = BASE_PATH.'/storage/app/settings'; if (! is_dir($settingsDir)) { mkdir($settingsDir, 0755, true); } $defaultsFile = BASE_PATH.'/config/settings/defaults.json'; if (! file_exists($defaultsFile)) { throw new Exception('defaults.json file not found: '.$defaultsFile); } $defaultsContent = file_get_contents($defaultsFile); $defaultsData = json_decode($defaultsContent, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new Exception('defaults.json JSON parsing failed: '.json_last_error_msg()); } $defaults = $defaultsData['defaults'] ?? []; $categories = $defaultsData['_meta']['categories'] ?? array_keys($defaults); if (empty($defaults)) { throw new Exception('defaults.json does not contain defaults section'); } $state = getInstallationState(); $config = $state['config'] ?? []; if (! empty($config['app_name'])) { $defaults['general']['site_name'] = $config['app_name']; $defaults['mail']['from_name'] = $config['app_name']; } if (! empty($config['app_url'])) { $defaults['general']['site_url'] = $config['app_url']; } if (! empty($config['admin_email'])) { $defaults['general']['admin_email'] = $config['admin_email']; $defaults['mail']['from_address'] = $config['admin_email']; } if (! empty($config['core_update_github_url'])) { $defaults['core_update']['github_url'] = $config['core_update_github_url']; } if (! empty($config['core_update_github_token'])) { $defaults['core_update']['github_token'] = $config['core_update_github_token']; } $defaults['general']['language'] = getCurrentLanguage(); // 자산 URL 방식 (이슈 #486) — 설치 화면이 브라우저에서 프로브를 던져 판정한 결과. // 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버는 확장자 붙은 // 동적 응답이 PHP 에 도달하지 못하므로 확장자 없는 형태로 설치를 마쳐야 한다. // 미판정(구버전 설치 화면·프로브 실패)이면 defaults.json 의 기본값을 그대로 둔다. if (in_array($config['asset_url_mode'] ?? null, ['extension', 'extensionless'], true)) { $defaults['general']['asset_url_mode'] = $config['asset_url_mode']; } foreach ($categories as $category) { if (! isset($defaults[$category])) { sendSSEEvent('log', ['message' => " - {$category}.json skipped (no defaults)"]); continue; } $settings = $defaults[$category]; $data = [ '_meta' => [ 'version' => '1.0.0', 'updated_at' => date('c'), ], ]; $data = array_merge($data, $settings); $json = installer_json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $filePath = $settingsDir.'/'.$category.'.json'; file_put_contents($filePath, $json, LOCK_EX); sendSSEEvent('log', ['message' => " - {$category}.json created"]); } sendSSEEvent('log', ['message' => lang('log_settings_json_created')]); sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('create_settings_json'); sendSSEEvent('task_complete', ['task' => 'create_settings_json', 'message' => lang('log_settings_json_created')]); return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_settings_json_failed'), $e); return [ 'success' => false, 'message' => lang('error_settings_json_failed'), 'message_key' => 'error_settings_json_failed', 'detail' => $e->getMessage(), ]; } } } if (! function_exists('setInstallationCompleteSSE')) { function setInstallationCompleteSSE(): array { if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; } $taskName = lang('task_complete_flag'); updateCurrentTask('complete_flag'); sendSSEEvent('task_start', ['task' => 'complete_flag', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); try { // .env 의 INSTALLER_COMPLETED=true 는 finalize-env.php 단계에서 작성된다. // 본 단계에서는 .env 를 건드리지 않아 php artisan serve 의 mtime 워처가 // 진행 중 재시작을 일으키지 않도록 한다. g7_installed 파일과 state.json // 의 completed 마커만으로 "Step 5 완료" 시점을 보장. $installedFlagPath = BASE_PATH.'/storage/app/g7_installed'; $installedFlagDir = dirname($installedFlagPath); if (! is_dir($installedFlagDir)) { @mkdir($installedFlagDir, 0775, true); } file_put_contents($installedFlagPath, date('Y-m-d H:i:s')); @chmod($installedFlagPath, 0644); sendSSEEvent('log', ['message' => lang('log_installed_flag_created')]); // 인스톨러 작업 중 composer install 등이 storage/temp 에 남긴 Symfony Process // sf_proc_*.{out,err,lock} 잔여 파일 정리. 디렉토리 자체는 보존 (다음 사용 대비). $tempDir = BASE_PATH.'/storage/temp'; if (is_dir($tempDir)) { $entries = @scandir($tempDir); if (is_array($entries)) { foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $entryPath = $tempDir.'/'.$entry; if (is_file($entryPath)) { @unlink($entryPath); } } } } $state = getInstallationState(); $state['current_step'] = 5; $state['step_status']['5'] = 'completed'; $state['installation_status'] = 'completed'; $state['installation_completed_at'] = date('Y-m-d\TH:i:s\Z'); // 완료 시점 이중 방어 (이슈 #465) — Phase 1/2 가 정상 동작하면 no-op. // 레거시 state 로 시작된 설치가 이 코드로 완료되는 경우를 커버한다. $state = redactInstallationStateSecrets($state); saveInstallationState($state); sendSSEEvent('log', ['message' => lang('log_state_updated')]); sendSSEEvent('log', ['message' => lang('log_task_completed', ['task' => $taskName])]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('complete_flag'); sendSSEEvent('task_complete', ['task' => 'complete_flag', 'message' => lang('log_installation_completed')]); // state.json 삭제는 finalize-env.php 로 위임됨. // 본 단계에서 즉시 삭제하면 finalize 가 generateEnvContent() 호출 시 // state.config 를 읽지 못해 .env.example 의 placeholder 값으로 .env 가 // 작성되는 회귀 발생. finalize 가 .env 머지 성공 후 state 삭제. return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_complete_flag_failed'), $e); return [ 'success' => false, 'message' => lang('error_complete_flag_failed'), 'message_key' => 'error_complete_flag_failed', 'detail' => $e->getMessage(), ]; } } } // ============================================================================ // 메인 실행 로직 // ============================================================================ if (! function_exists('runInstallationTasks')) { /** * 설치 작업 전체 실행. * * 진입점(install-worker.php / install-process.php 폴링 인라인)에서 * ProgressEmitter 등록 후 이 함수를 호출합니다. */ function runInstallationTasks(): void { // 연결 성공 이벤트 (SSE 전용 — NullEmitter는 no-op) getProgressEmitter()->emit('connected', ['message' => lang('sse_connection_established')]); try { $state = getInstallationState(); $completedTasks = $state['completed_tasks'] ?? []; $selectedExtensions = getSelectedExtensions(); $tasks = []; // 1. 환경 설정 작업 $tasks[] = ['id' => 'composer_check', 'function' => 'checkComposerSSE']; $tasks[] = ['id' => 'composer_install', 'function' => 'installComposerDependenciesSSE']; $tasks[] = ['id' => 'env_update', 'function' => 'updateEnvFileSSE']; $tasks[] = ['id' => 'key_generate', 'function' => 'generateApplicationKeySSE']; // 2. 의존성 사전 검증 (프론트엔드 우회 방어) $tasks[] = ['id' => 'dependency_precheck', 'function' => 'dependencyPrecheckSSE']; // 3. 기존 DB 테이블 정리 (existing_db_action === 'drop_tables'인 경우만 동작) $tasks[] = ['id' => 'db_cleanup', 'function' => 'cleanupExistingTablesSSE']; // 4. 데이터베이스 작업 $tasks[] = ['id' => 'db_migrate', 'function' => 'runDatabaseMigrationSSE']; $tasks[] = ['id' => 'db_seed', 'function' => 'runDatabaseSeedingSSE']; // 3. 관리자 템플릿 foreach ($selectedExtensions['admin_templates'] ?? [] as $templateId) { $tasks[] = ['id' => 'template_install', 'target' => $templateId, 'function' => 'installAdminTemplateSSE', 'args' => [$templateId]]; $tasks[] = ['id' => 'template_activate', 'target' => $templateId, 'function' => 'activateAdminTemplateSSE', 'args' => [$templateId]]; } // 4. 모듈 foreach ($selectedExtensions['modules'] ?? [] as $moduleId) { $tasks[] = ['id' => 'module_install', 'target' => $moduleId, 'function' => 'installModuleSSE', 'args' => [$moduleId]]; $tasks[] = ['id' => 'module_activate', 'target' => $moduleId, 'function' => 'activateModuleSSE', 'args' => [$moduleId]]; } // 5. 플러그인 foreach ($selectedExtensions['plugins'] ?? [] as $pluginId) { $tasks[] = ['id' => 'plugin_install', 'target' => $pluginId, 'function' => 'installPluginSSE', 'args' => [$pluginId]]; $tasks[] = ['id' => 'plugin_activate', 'target' => $pluginId, 'function' => 'activatePluginSSE', 'args' => [$pluginId]]; } // 6. 사용자 템플릿 foreach ($selectedExtensions['user_templates'] ?? [] as $templateId) { $tasks[] = ['id' => 'user_template_install', 'target' => $templateId, 'function' => 'installUserTemplateSSE', 'args' => [$templateId]]; $tasks[] = ['id' => 'user_template_activate', 'target' => $templateId, 'function' => 'activateUserTemplateSSE', 'args' => [$templateId]]; } // 7. 번들 언어팩 (best-effort — 1건 실패는 전체 중단하지 않음) // 코어/모듈/플러그인/템플릿 install·activate 가 모두 끝난 뒤 실행해야 // target_identifier 매칭 + DB 마이그레이션·시드가 완료된 상태에서 활성화 가능. foreach ($selectedExtensions['language_packs'] ?? [] as $packId) { $tasks[] = [ 'id' => 'language_pack_install', 'target' => $packId, 'function' => 'installLanguagePackSSE', 'args' => [$packId], 'best_effort' => true, ]; } // 8. 마무리 $tasks[] = ['id' => 'create_settings_json', 'function' => 'createSettingsJsonSSE']; $tasks[] = ['id' => 'cache_clear', 'function' => 'clearCacheSSE']; $tasks[] = ['id' => 'complete_flag', 'function' => 'setInstallationCompleteSSE']; // complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야 // 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료. $tasks[] = ['id' => 'config_cache', 'function' => 'optimizeConfigCacheSSE', 'best_effort' => true]; // 부트스트랩 리소스 정적 게시(bake) 최초 수행 — 없으면 첫 방문자가 // 자가 치유(1회 API 폴백)를 겪는다. best_effort — 실패해도 설치는 완료. $tasks[] = ['id' => 'static_publish', 'function' => 'publishStaticCacheSSE', 'best_effort' => true]; foreach ($tasks as $task) { if (checkAbortStatusSSE()) { sendSSEEvent('aborted', [ 'message' => lang('abort_installation_stopped'), 'task' => $task['id'], 'target' => $task['target'] ?? null, ]); addLog(lang('abort_installation_stopped')); return; } $taskId = $task['id']; $target = $task['target'] ?? null; $functionName = $task['function']; $args = $task['args'] ?? []; $bestEffort = ! empty($task['best_effort']); $completedKey = $target ? "{$taskId}:{$target}" : $taskId; if (in_array($completedKey, $completedTasks)) { $taskNameKey = "task_{$taskId}"; $taskName = lang($taskNameKey); $displayName = $target ? "{$taskName} ({$target})" : $taskName; sendSSEEvent('task_complete', [ 'task' => $taskId, 'target' => $target, 'message' => lang('log_already_completed', ['task' => $displayName]), ]); continue; } $result = empty($args) ? $functionName() : $functionName(...$args); if (isset($result['aborted']) && $result['aborted']) { sendSSEEvent('aborted', [ 'message' => lang('abort_installation_stopped'), 'task' => $taskId, 'target' => $target, ]); addLog(lang('abort_installation_stopped')); return; } if (! $result['success']) { // best-effort task (예: 번들 언어팩 설치) — 실패 시 경고 로그만 남기고 계속 진행 if ($bestEffort) { $warnMsg = bestEffortFailureMessage((string) $taskId, (string) $target); sendSSEEvent('log', ['message' => $warnMsg]); addLog($warnMsg); // task_complete 이벤트는 보내지 않고 다음 task 로 진행 (재시도 시 다시 시도 가능) continue; } $state = getInstallationState(); $taskNameKey = "task_{$taskId}"; $taskName = lang($taskNameKey); $displayName = $target ? "{$taskName} ({$target})" : $taskName; if (empty($result['rollback_done'])) { sendSSEEvent('log', ['message' => lang('failed_rollback_start', ['task' => $displayName])]); $rollbackResult = rollbackTask($taskId, $state); sendRollbackOutputSSE($rollbackResult); if ($rollbackResult['success']) { sendSSEEvent('log', ['message' => lang('failed_rollback_success', ['message' => $rollbackResult['message']])]); } else { sendSSEEvent('log', ['message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']])]); $dbTasks = ['db_migrate', 'db_seed']; if (in_array($taskId, $dbTasks)) { sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup')]); sendSSEEvent('log', ['message' => lang('failed_rollback_manual_cleanup_detail')]); $rollbackFailure = [ 'message_key' => 'failed_rollback_manual_cleanup', 'detail_key' => 'failed_rollback_manual_cleanup_detail', ]; sendSSEEvent('rollback_failed', [ 'message' => lang('failed_rollback_manual_cleanup'), 'detail' => lang('failed_rollback_manual_cleanup_detail'), ]); } else { sendSSEEvent('log', ['message' => lang('failed_rollback_retry')]); sendSSEEvent('log', ['message' => lang('failed_rollback_retry_detail')]); $rollbackFailure = [ 'message_key' => 'failed_rollback_retry', 'detail_key' => 'failed_rollback_retry_detail', ]; sendSSEEvent('rollback_failed', [ 'message' => lang('failed_rollback_retry'), 'detail' => lang('failed_rollback_retry_detail'), ]); } } } $state['installation_status'] = 'failed'; $state['failed_task'] = $completedKey; $state['failed_task_target'] = $target; $state['error_message_key'] = $result['message_key'] ?? null; $state['error_detail'] = $result['detail'] ?? null; if (! empty($result['rollback_done'])) { $state['rollback_failure'] = $result['rollback_failure'] ?? null; } else { $state['rollback_failure'] = $rollbackFailure ?? null; } $manualCommands = getManualCommands($taskId, $target); $state['manual_commands'] = $manualCommands; saveInstallationState($state); addLog(lang('log_installation_task_failed', [ 'task' => $displayName, 'message' => $result['message'] ?? lang('log_installation_failed'), ])); if (! empty($manualCommands)) { sendSSEEvent('log', ['message' => lang('log_separator')]); sendSSEEvent('log', ['message' => lang('manual_commands_guide')]); foreach ($manualCommands as $cmd) { sendSSEEvent('log', ['message' => " $ {$cmd}"]); } sendSSEEvent('log', ['message' => lang('log_separator')]); } sendSSEEvent('error', [ 'message' => $result['message'] ?? lang('log_installation_failed'), 'message_key' => $result['message_key'] ?? 'log_installation_failed', 'error' => $result['detail'] ?? null, 'task' => $taskId, 'target' => $target, 'manual_commands' => $manualCommands, ]); return; } } // 모든 작업 성공 if (isset($_SESSION)) { $_SESSION['installer_current_step'] = 5; } sendSSEEvent('completed', [ 'message' => lang('log_installation_completed'), 'redirect' => '/install/', ]); addLog(lang('log_all_tasks_completed')); } catch (Exception $e) { logInstallationError(lang('error_worker_exception'), $e); $state = getInstallationState(); $state['installation_status'] = 'failed'; $state['failed_task'] = $state['current_task'] ?? null; $state['error_message_key'] = 'error_unexpected_exception'; $state['error_detail'] = $e->getMessage(); saveInstallationState($state); addLog(lang('log_installation_exception', ['error' => $e->getMessage()])); sendSSEEvent('error', [ 'message' => lang('log_installation_failed'), 'message_key' => 'error_unexpected_exception', 'error' => $e->getMessage(), ]); } } }