- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다 - config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단 - custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장 - 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제) - 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비 - sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트 - 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2090 lines
84 KiB
PHP
2090 lines
84 KiB
PHP
<?php
|
|
|
|
/**
|
|
* 그누보드7 웹 인스톨러 - 설치 작업 실행 코어 (SSE/폴링 공용)
|
|
*
|
|
* 설치 task 정의와 실행 로직을 모드와 무관하게 제공합니다.
|
|
* 진입점(install-worker.php SSE 모드, install-process.php 폴링 모드)이
|
|
* ProgressEmitter를 등록한 뒤 runInstallationTasks()를 호출합니다.
|
|
*/
|
|
|
|
require_once __DIR__.'/config.php';
|
|
require_once __DIR__.'/functions.php';
|
|
require_once __DIR__.'/installer-state.php';
|
|
require_once __DIR__.'/installer-runtime.php';
|
|
require_once __DIR__.'/progress-emitter.php';
|
|
require_once __DIR__.'/../api/rollback-functions.php';
|
|
|
|
// ============================================================================
|
|
// Emitter 호환 래퍼 — 기존 task 함수의 sendSSEEvent() 호출을 그대로 유지하면서
|
|
// 내부적으로 현재 등록된 ProgressEmitter로 delegate합니다.
|
|
// ============================================================================
|
|
|
|
if (! function_exists('sendSSEEvent')) {
|
|
/**
|
|
* 이벤트 송출 (SSE 모드/폴링 모드 공통).
|
|
*
|
|
* @param string $event 이벤트 타입
|
|
* @param array $data 이벤트 데이터
|
|
*/
|
|
function sendSSEEvent(string $event, array $data): void
|
|
{
|
|
getProgressEmitter()->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) {
|
|
sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]);
|
|
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(),
|
|
]);
|
|
}
|
|
}
|
|
}
|