Files
HeuJung 01d319e74b fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면
invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다.
echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도
남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다.

2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차),
응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에
남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다.

정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub
단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.

같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가
500 이 되던 것도 함께 막았다.
2026-08-29 15:13:14 +09:00

321 lines
14 KiB
PHP

<?php
/**
* Step 5: 설치 진행
*
* Composer 의존성 설치, .env 파일 생성, Artisan 명령 실행, 관리자 계정 생성,
* 선택한 확장 기능(템플릿, 모듈, 플러그인) 설치를 수행합니다.
* 진행률 표시와 실시간 로그를 통해 사용자에게 피드백을 제공합니다.
*/
// 설정 정보 가져오기 (state.json에서 로드 - 다른 브라우저에서도 접근 가능)
$config = $state['config'] ?? $_SESSION['install_config'] ?? [];
?>
<!-- 필수 파일 생성 안내 섹션 (JS에서 제어, 기본 숨김) -->
<div id="env-setup-section" class="installer-container installer-container-wide hidden">
<div class="requirement-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('file_setup_title')) ?></h3>
</div>
<div class="requirement-card-body">
<p><?= htmlspecialchars(lang('file_setup_description')) ?></p>
<!-- 누락 파일 목록 (JS에서 동적 표시) -->
<ul id="missing-files-list" class="missing-files"></ul>
<!-- 명령어 안내 (절대경로) -->
<p><?= htmlspecialchars(lang('file_setup_guide')) ?></p>
<div class="code-box">
<pre id="file-setup-command"></pre>
<button class="btn-copy" onclick="copySetupCommand()"><?= htmlspecialchars(lang('copy_command')) ?></button>
</div>
<!-- 상대경로 명령어 -->
<p class="fix-guide-hint" id="file-setup-relative-hint" style="display:none;"><?= htmlspecialchars(lang('or_relative_path')) ?></p>
<div class="code-box" id="file-setup-relative-box" style="display:none;">
<pre id="file-setup-command-relative"></pre>
<button class="btn-copy" onclick="copySetupCommandRelative()"><?= htmlspecialchars(lang('copy_command')) ?></button>
</div>
<!-- 확인 버튼 -->
<div class="env-check-actions">
<button id="env-recheck-btn" class="btn btn-primary" onclick="recheckFiles()"><?= htmlspecialchars(lang('file_check_button')) ?></button>
<span id="env-check-status" class="hidden"></span>
</div>
</div>
</div>
</div>
<div class="installer-container installer-container-wide">
<div class="installer-header-with-abort">
<h1 id="installer-title" class="installer-title"><?= htmlspecialchars(lang('installation_title')) ?></h1>
<button id="abort-installation-btn" class="btn-abort" onclick="abortInstallation()" style="display: none;">
<svg class="abort-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<?= htmlspecialchars(lang('abort_installation')) ?>
</button>
</div>
<!-- 완료 섹션 -->
<?php
$completionButtons = '<a href="../admin/login" class="btn btn-success">'.htmlspecialchars(lang('go_to_admin_login')).'</a>';
$coreVersion = getCoreAppVersion();
$completionExtra = '<p class="result-version">'
.htmlspecialchars(lang('installed_version_label')).' '
.'<strong>'.htmlspecialchars($coreVersion).'</strong>'
.'</p>';
// .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371)
// finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한
// (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을
// 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출.
//
// OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작.
// Windows 만 명령이 무의미하므로 Windows 만 비노출.
$envPath = BASE_PATH.'/.env';
if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) {
$envPerms = fileperms($envPath) & 0777;
if ($envPerms !== 0600) {
$envOwnerUid = @fileowner($envPath);
$envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid'))
? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid)
: 'owner';
$webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid')
? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data')
: 'www-data';
$hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath));
$hardeningCommandDifferentOwner = sprintf(
'sudo chgrp %s %s && sudo chmod 640 %s',
htmlspecialchars($webGroupName),
htmlspecialchars($envPath),
htmlspecialchars($envPath)
);
$completionExtra .= '<div class="env-hardening-hint">'
.'<p class="env-hardening-hint-title">'.htmlspecialchars(lang('env_hardening_hint_title')).'</p>'
.'<p class="env-hardening-hint-status">'
.sprintf(
htmlspecialchars(lang('env_hardening_hint_current_status')),
decoct($envPerms),
htmlspecialchars($envOwnerName)
)
.'</p>'
.'<p class="env-hardening-hint-option">'
.htmlspecialchars(lang('env_hardening_hint_option_strict'))
.'</p>'
.'<div class="code-box"><pre>'.$hardeningCommandSameOwner.'</pre></div>'
.'<p class="env-hardening-hint-option">'
.htmlspecialchars(lang('env_hardening_hint_option_group'))
.'</p>'
.'<div class="code-box"><pre>'.$hardeningCommandDifferentOwner.'</pre></div>'
.'<p class="env-hardening-hint-note">'.htmlspecialchars(lang('env_hardening_hint_note')).'</p>'
.'</div>';
}
}
echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra);
?>
<!-- 중단 섹션 -->
<?php
$abortedButtons = '<button onclick="resumeInstallationFromAborted()" class="btn btn-primary">'.htmlspecialchars(lang('resume_continue')).'</button>
<button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">'.htmlspecialchars(lang('back_to_settings')).'</button>';
echo renderInstallResultSection('aborted', 'warning', 'installation_aborted', 'installation_aborted_message', $abortedButtons);
?>
<!-- 실패 섹션 -->
<?php
$failureButtons = '<button onclick="retryInstallation()" class="btn btn-primary">'.htmlspecialchars(lang('retry_installation')).'</button>
<button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">'.htmlspecialchars(lang('back_to_settings')).'</button>';
echo renderInstallResultSection('failure', 'error', 'installation_failed', '', $failureButtons);
?>
<!-- 설치 진행 방식 선택 (SSE / 폴링) -->
<div class="requirement-card installation-mode-card" id="installation-mode-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('installation_mode_label')) ?></h3>
</div>
<div class="requirement-card-body">
<div class="installation-mode-options">
<label class="installation-mode-option">
<input type="radio" name="installation_mode" value="sse" checked>
<div class="installation-mode-text">
<strong><?= htmlspecialchars(lang('installation_mode_sse_title')) ?></strong>
<span class="installation-mode-desc"><?= htmlspecialchars(lang('installation_mode_sse_desc')) ?></span>
</div>
</label>
<label class="installation-mode-option">
<input type="radio" name="installation_mode" value="polling">
<div class="installation-mode-text">
<strong><?= htmlspecialchars(lang('installation_mode_polling_title')) ?></strong>
<span class="installation-mode-desc"><?= htmlspecialchars(lang('installation_mode_polling_desc')) ?></span>
</div>
</label>
</div>
</div>
</div>
<!-- 설치 시작 버튼 (초기 상태, 설치 시작 후 숨김) -->
<div id="installation-start-section" class="installation-start-section" style="display: none;">
<button id="start-installation-btn" class="btn btn-primary btn-lg" type="button" onclick="onStartInstallationClick()">
<?= htmlspecialchars(lang('start_installation_button')) ?>
</button>
</div>
<div class="alert alert-warning" id="install-warning" style="display: none;">
⚠ <?= htmlspecialchars(lang('do_not_close_page')) ?>
</div>
<!-- 설치 진행 상황 카드 -->
<div class="requirement-card" id="installation-progress-card" style="display: none;">
<div class="requirement-card-header" id="installation-card-header" onclick="toggleInstallationCard()">
<h3 class="requirement-card-title">
<?= htmlspecialchars(lang('progress_status')) ?>
<span id="install-toggle-icon" class="toggle-icon-inline hidden">▼</span>
</h3>
</div>
<div id="installation-card-body" class="requirement-card-body">
<!-- 전체 진행률 -->
<div class="progress-section">
<div class="progress-header">
<span class="progress-label"><?= htmlspecialchars(lang('overall_progress')) ?></span>
<span id="overall-percentage" class="progress-percentage">0%</span>
</div>
<div class="progress-bar-bg">
<div id="overall-progress-bar" class="progress-bar-fill" style="width: 0%"></div>
</div>
<p id="current-task" class="progress-status"><?= htmlspecialchars(lang('preparing')) ?></p>
</div>
<!-- 작업 목록 -->
<div id="task-list" class="task-list">
<!-- JavaScript로 동적 생성 -->
</div>
<!-- 로그 섹션 헤더 -->
<h4 class="log-section-title"><?= htmlspecialchars(lang('installation_log')) ?></h4>
<!-- 로그 출력 -->
<div class="install-log" id="install-log">
<div class="log-placeholder"><?= htmlspecialchars(lang('waiting_installation')) ?></div>
</div>
</div>
</div>
</div>
<?php
// 선택된 확장 기능 가져오기
$selectedExtensions = $state['selected_extensions'] ?? [];
$adminTemplates = $selectedExtensions['admin_templates'] ?? [];
$userTemplates = $selectedExtensions['user_templates'] ?? [];
$modules = $selectedExtensions['modules'] ?? [];
$plugins = $selectedExtensions['plugins'] ?? [];
$languagePacks = $selectedExtensions['language_packs'] ?? [];
?>
<script>
// Step 5 전용 데이터 전달 (INSTALLER_BASE_URL과 INSTALLER_LANG는 footer.php에서 처리)
window.INSTALLER_CONFIG = <?= installer_json_encode($config) ?>;
window.INSTALLER_SELECTED_EXTENSIONS = <?= installer_json_encode($selectedExtensions) ?>;
window.INSTALLER_EXTENSION_NAMES = <?= installer_json_encode($state['extension_names'] ?? []) ?>;
// 작업 그룹 정의
window.INSTALLER_TASK_GROUPS = [
{
id: 'environment',
labelKey: 'task_group_environment',
tasks: [
{ id: 'composer_check' },
{ id: 'composer_install' },
{ id: 'env_update' },
{ id: 'key_generate' }
]
},
{
id: 'database',
labelKey: 'task_group_database',
tasks: [
{ id: 'db_migrate' },
{ id: 'db_seed' }
]
},
{
id: 'admin_templates',
labelKey: 'task_group_admin_templates',
tasks: <?= installer_json_encode(array_map(function ($tpl) {
return [
['id' => 'template_install', 'target' => $tpl],
['id' => 'template_activate', 'target' => $tpl],
];
}, $adminTemplates)) ?>
},
{
id: 'modules',
labelKey: 'task_group_modules',
tasks: <?= installer_json_encode(array_map(function ($mod) {
return [
['id' => 'module_install', 'target' => $mod],
['id' => 'module_activate', 'target' => $mod],
];
}, $modules)) ?>
},
{
id: 'plugins',
labelKey: 'task_group_plugins',
tasks: <?= installer_json_encode(array_map(function ($plg) {
return [
['id' => 'plugin_install', 'target' => $plg],
['id' => 'plugin_activate', 'target' => $plg],
];
}, $plugins)) ?>
},
{
id: 'user_templates',
labelKey: 'task_group_user_templates',
tasks: <?= installer_json_encode(array_map(function ($tpl) {
return [
['id' => 'user_template_install', 'target' => $tpl],
['id' => 'user_template_activate', 'target' => $tpl],
];
}, $userTemplates)) ?>
},
{
id: 'language_packs',
labelKey: 'task_group_language_packs',
tasks: <?= installer_json_encode(array_map(function ($pack) {
return ['id' => 'language_pack_install', 'target' => $pack];
}, $languagePacks)) ?>
},
{
id: 'finalize',
labelKey: 'task_group_finalize',
tasks: [
{ id: 'create_settings_json' },
{ id: 'cache_clear' },
{ id: 'complete_flag' }
]
}
];
// 평탄화된 작업 목록 생성 (하위 호환성 유지)
window.INSTALLER_TASKS = (function() {
const tasks = [];
INSTALLER_TASK_GROUPS.forEach(group => {
if (!group.tasks || group.tasks.length === 0) return;
group.tasks.forEach(taskOrArray => {
if (Array.isArray(taskOrArray)) {
taskOrArray.forEach(t => tasks.push({ ...t, group: group.id }));
} else {
tasks.push({ ...taskOrArray, group: group.id });
}
});
});
return tasks;
})();
</script>