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

1032 lines
51 KiB
PHP

<?php
/**
* Step 3: 데이터베이스 및 사이트 설정
*
* 데이터베이스 설정, 사이트 설정, 관리자 계정을 입력받습니다.
* $formData는 index.php에서 준비됨
*/
if (! isset($errors)) {
$errors = [];
}
// DB 테스트 플래그
$dbWriteTested = isset($_SESSION['db_write_tested']) && $_SESSION['db_write_tested'] === true;
$dbReadTested = isset($_SESSION['db_read_tested']) && $_SESSION['db_read_tested'] === true;
// DB 필드 해시값 계산 (변경 감지용)
$dbWriteHash = getDatabaseFieldHash($formData, 'db_write');
$dbReadHash = getDatabaseFieldHash($formData, 'db_read');
?>
<div class="installer-container installer-container-wide">
<h1 class="installer-title"><?= htmlspecialchars(lang('step_3_configuration')) ?></h1>
<?php if (! empty($errors)) { ?>
<div class="alert alert-error">
<ul class="alert-list">
<?php foreach ($errors as $error) { ?>
<li><?= htmlspecialchars($error) ?></li>
<?php } ?>
</ul>
</div>
<?php } ?>
<form method="POST" id="config-form" class="installer-form">
<!-- 데이터베이스 설정 (Write DB) -->
<div class="requirement-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('database_settings')) ?> (Write Server)</h3>
</div>
<div class="requirement-card-body">
<div class="form-grid">
<div class="form-group form-group-full">
<label class="form-label"><?= htmlspecialchars(lang('database_type')) ?></label>
<select name="db_type" class="form-select" disabled>
<option value="mysql" selected>MySQL</option>
</select>
<input type="hidden" name="db_type" value="mysql">
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_host')) ?> *</label>
<input type="text" name="db_write_host" value="<?= htmlspecialchars($formData['db_write_host']) ?>"
class="form-input" required>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_port')) ?></label>
<input type="text" name="db_write_port" value="<?= htmlspecialchars($formData['db_write_port']) ?>"
class="form-input">
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_name')) ?> *</label>
<input type="text" name="db_write_database" value="<?= htmlspecialchars($formData['db_write_database']) ?>"
class="form-input" required>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_prefix')) ?></label>
<input type="text" name="db_prefix" id="db_prefix"
value="<?= htmlspecialchars($formData['db_prefix']) ?>"
class="form-input"
placeholder="<?= htmlspecialchars(lang('db_prefix_placeholder')) ?>"
pattern="^[a-z][a-z0-9_]*$"
maxlength="<?= MAX_DB_PREFIX_LENGTH ?>"
aria-invalid="false"
aria-describedby="db_prefix-error db_prefix-hint">
<small id="db_prefix-hint" class="form-hint">
<?= htmlspecialchars(lang('db_prefix_hint')) ?>
</small>
<div id="db_prefix-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_username')) ?> *</label>
<input type="text" name="db_write_username" id="db_write_username"
value="<?= htmlspecialchars($formData['db_write_username']) ?>"
class="form-input" required
aria-invalid="false"
aria-describedby="db_write_username-error">
<div id="db_write_username-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_password')) ?></label>
<input type="password" name="db_write_password" value="<?= htmlspecialchars($formData['db_write_password']) ?>"
class="form-input">
</div>
</div>
<div class="test-db-wrapper">
<button type="button" onclick="testDatabaseConnection('write')" class="btn btn-success">
<?= htmlspecialchars(lang('test_write_db_connection')) ?>
</button>
</div>
<div id="db-write-test-result" class="test-result hidden"></div>
</div>
</div>
<!-- 데이터베이스 설정 (Read DB) -->
<div class="requirement-card">
<div class="requirement-card-header" style="cursor: pointer;">
<label for="use-read-db" style="cursor: pointer; display: flex; align-items: center; gap: var(--spacing-sm); flex: 1;">
<h3 class="requirement-card-title" style="margin: 0;"><?= htmlspecialchars(lang('use_read_db')) ?></h3>
</label>
<input type="checkbox" name="use_read_db" id="use-read-db" value="1"
<?= ! empty($formData['use_read_db']) ? 'checked' : '' ?>
class="toggle-switch">
</div>
<div id="read-db-section" class="requirement-card-body <?= empty($formData['use_read_db']) ? 'hidden' : '' ?>" style="transition: all 0.3s ease-out;">
<div class="form-grid">
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_host')) ?> *</label>
<input type="text" name="db_read_host" value="<?= htmlspecialchars($formData['db_read_host']) ?>"
class="form-input">
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_port')) ?></label>
<input type="text" name="db_read_port" value="<?= htmlspecialchars($formData['db_read_port']) ?>"
class="form-input">
</div>
<div class="form-group form-group-full">
<label class="form-label"><?= htmlspecialchars(lang('db_name')) ?> *</label>
<input type="text" name="db_read_database" value="<?= htmlspecialchars($formData['db_read_database']) ?>"
class="form-input">
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_username')) ?> *</label>
<input type="text" name="db_read_username" id="db_read_username"
value="<?= htmlspecialchars($formData['db_read_username']) ?>"
class="form-input"
aria-invalid="false"
aria-describedby="db_read_username-error">
<div id="db_read_username-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_password')) ?></label>
<input type="password" name="db_read_password" value="<?= htmlspecialchars($formData['db_read_password']) ?>"
class="form-input">
</div>
</div>
<div class="test-db-wrapper">
<button type="button" onclick="testDatabaseConnection('read')" class="btn btn-success">
<?= htmlspecialchars(lang('test_read_db_connection')) ?>
</button>
</div>
<div id="db-read-test-result" class="test-result hidden"></div>
</div>
</div>
<!-- 사이트 설정 -->
<div class="requirement-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('site_settings')) ?></h3>
</div>
<div class="requirement-card-body">
<div class="form-fields">
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('app_name')) ?> *</label>
<input type="text" name="app_name" id="app_name" value="<?= htmlspecialchars($formData['app_name']) ?>"
class="form-input" required
aria-invalid="false" aria-describedby="app_name-error">
<div id="app_name-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('app_url')) ?> *</label>
<input type="url" name="app_url" id="app_url" value="<?= htmlspecialchars($formData['app_url']) ?>"
class="form-input" required
aria-invalid="false" aria-describedby="app_url-error">
<div id="app_url-error" class="field-error" role="alert"></div>
</div>
</div>
</div>
</div>
<!-- 관리자 설정 -->
<div class="requirement-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('admin_account')) ?></h3>
</div>
<div class="requirement-card-body">
<div class="form-grid">
<div class="form-group form-group-full">
<label class="form-label"><?= htmlspecialchars(lang('admin_email')) ?> *</label>
<input type="email" name="admin_email" id="admin_email" value="<?= htmlspecialchars($formData['admin_email']) ?>"
class="form-input" required
aria-invalid="false" aria-describedby="admin_email-error">
<div id="admin_email-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('admin_name')) ?> *</label>
<input type="text" name="admin_name" id="admin_name" value="<?= htmlspecialchars($formData['admin_name']) ?>"
class="form-input" required
aria-invalid="false" aria-describedby="admin_name-error">
<div id="admin_name-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('admin_language')) ?> *</label>
<select name="admin_language" id="admin_language" class="form-select" required>
<?php
$selectedAdminLang = $formData['admin_language'] ?? getCurrentLanguage();
foreach (SUPPORTED_LANGUAGES as $code => $label) { ?>
<option value="<?= $code ?>" <?= $selectedAdminLang === $code ? 'selected' : '' ?>>
<?= htmlspecialchars($label) ?>
</option>
<?php } ?>
</select>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('admin_password')) ?> *</label>
<input type="password" name="admin_password" id="admin_password"
class="form-input" required minlength="8"
aria-invalid="false" aria-describedby="admin_password-error">
<div id="admin_password-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('admin_password_confirm')) ?> *</label>
<input type="password" name="admin_password_confirm" id="admin_password_confirm"
class="form-input" required
aria-invalid="false" aria-describedby="admin_password_confirm-error">
<div id="admin_password_confirm-error" class="field-error" role="alert"></div>
</div>
</div>
</div>
</div>
<!-- 코어 업데이트 설정 (선택) -->
<div class="requirement-card">
<div class="requirement-card-header">
<h3 class="requirement-card-title"><?= htmlspecialchars(lang('core_update_settings')) ?></h3>
</div>
<div class="requirement-card-body">
<div class="form-fields">
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('core_update_pending_path')) ?></label>
<input type="text" name="core_update_pending_path"
value="<?= htmlspecialchars($formData['core_update_pending_path'] ?? '') ?>"
class="form-input"
placeholder="storage/app/core_pending"
aria-describedby="core_update_pending_path-help">
<p id="core_update_pending_path-help" class="form-help">
<?= htmlspecialchars(lang('core_update_pending_path_help')) ?>
</p>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('asset_url_mode')) ?></label>
<select name="asset_url_mode_select" id="asset_url_mode_select" class="form-select">
<option value="extension"><?= htmlspecialchars(lang('asset_url_mode_extension')) ?></option>
<option value="extensionless"><?= htmlspecialchars(lang('asset_url_mode_extensionless')) ?></option>
</select>
<p id="asset_url_mode_status" class="form-help hidden"
data-msg-extension="<?= htmlspecialchars(lang('asset_url_mode_detected_extension')) ?>"
data-msg-extensionless="<?= htmlspecialchars(lang('asset_url_mode_detected_extensionless')) ?>"
data-msg-unavailable="<?= htmlspecialchars(lang('asset_url_mode_detected_unavailable')) ?>"></p>
<p class="form-help">
<?= htmlspecialchars(lang('asset_url_mode_help')) ?>
</p>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('core_update_github_url')) ?></label>
<input type="url" name="core_update_github_url"
value="<?= htmlspecialchars($formData['core_update_github_url'] ?? 'https://github.com/gnuboard/g7') ?>"
class="form-input"
placeholder="https://github.com/gnuboard/g7">
<p class="form-help">
<?= htmlspecialchars(lang('core_update_github_url_help')) ?>
</p>
</div>
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('core_update_github_token')) ?></label>
<input type="password" name="core_update_github_token"
value="<?= htmlspecialchars($formData['core_update_github_token'] ?? '') ?>"
class="form-input"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
autocomplete="off">
<p class="form-help">
<?= htmlspecialchars(lang('core_update_github_token_help')) ?>
</p>
</div>
</div>
<!-- 커스텀 경로 입력 시 퍼미션/소유자 체크 버튼 -->
<div id="core-pending-check-wrapper" class="test-db-wrapper" style="display:none;">
<button type="button" onclick="checkCorePendingPath()" class="btn btn-success">
<?= htmlspecialchars(lang('check_core_pending_path')) ?>
</button>
</div>
<div id="core-pending-check-result" class="test-result hidden"></div>
</div>
</div>
<!-- PHP CLI / Composer 설정 -->
<div class="requirement-card" id="php-cli-card">
<div class="requirement-card-header" style="cursor: pointer;">
<label for="show-php-cli-settings" style="cursor: pointer; display: flex; align-items: center; gap: var(--spacing-sm); flex: 1;">
<h3 class="requirement-card-title" style="margin: 0;" id="php-cli-title"><?= htmlspecialchars(lang('php_cli_settings')) ?></h3>
</label>
<input type="checkbox" id="show-php-cli-settings" value="1"
<?= (! empty($formData['php_binary']) && $formData['php_binary'] !== 'php') || ! empty($formData['composer_binary']) ? 'checked' : '' ?>
class="toggle-switch">
</div>
<div id="php-cli-section" class="requirement-card-body <?= (empty($formData['php_binary']) || $formData['php_binary'] === 'php') && empty($formData['composer_binary']) ? 'hidden' : '' ?>" style="transition: all 0.3s ease-out;">
<p class="form-help" style="margin-bottom: var(--spacing-md);" id="php-cli-help-text">
<?= htmlspecialchars(lang('php_cli_settings_help')) ?>
</p>
<!-- 검증 상태 요약 -->
<div id="cli-status-summary" class="alert alert-info" style="display: none; margin-bottom: var(--spacing-md);">
<div style="display: flex; gap: var(--spacing-lg); flex-wrap: wrap;">
<span>PHP CLI: <strong id="cli-php-status"><?= htmlspecialchars(lang('cli_status_not_verified')) ?></strong></span>
<span>Composer: <strong id="cli-composer-status"><?= htmlspecialchars(lang('cli_status_not_verified')) ?></strong></span>
</div>
</div>
<div class="form-fields">
<!-- PHP CLI 경로 -->
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('php_binary_path')) ?></label>
<div style="display: flex; gap: var(--spacing-sm);">
<input type="text" name="php_binary" id="php_binary"
value="<?= htmlspecialchars($formData['php_binary'] ?? 'php') ?>"
class="form-input" style="flex: 1;"
placeholder="php">
<button type="button" onclick="testPhpBinary()" class="btn btn-success" style="white-space: nowrap;">
<?= htmlspecialchars(lang('verify_version')) ?>
</button>
</div>
<p class="form-help">
<?= htmlspecialchars(lang('php_binary_path_help')) ?>
</p>
<div id="php-binary-test-result" class="test-result hidden"></div>
</div>
<!-- Composer 경로 -->
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('composer_binary_path')) ?></label>
<div style="display: flex; gap: var(--spacing-sm);">
<input type="text" name="composer_binary" id="composer_binary"
value="<?= htmlspecialchars($formData['composer_binary'] ?? '') ?>"
class="form-input" style="flex: 1;"
placeholder="composer">
<button type="button" onclick="testComposer()" class="btn btn-success" style="white-space: nowrap;">
<?= htmlspecialchars(lang('verify_version')) ?>
</button>
</div>
<p class="form-help">
<?= htmlspecialchars(lang('composer_binary_path_help')) ?>
</p>
<div id="composer-test-result" class="test-result hidden"></div>
</div>
</div>
<!-- Composer 미설치 시 설치 안내 -->
<div id="composer-install-guide" class="alert alert-warning" style="display: none; margin-top: var(--spacing-md);">
<div class="alert-header">
<div class="alert-icon"><?= getSvgIcon('warning') ?></div>
<h3 class="alert-title"><?= htmlspecialchars(lang('composer_install_guide_title')) ?></h3>
</div>
<p class="alert-message"><?= htmlspecialchars(lang('composer_install_guide_message')) ?></p>
<div class="alert-details" id="composer-install-commands">
<!-- JS에서 PHP 경로 반영하여 동적 생성 -->
</div>
</div>
<div class="test-db-wrapper">
<button type="button" onclick="detectPhpBinaries()" class="btn btn-secondary">
<?= htmlspecialchars(lang('auto_detect_php')) ?>
</button>
</div>
<div id="php-detect-result" class="test-result hidden"></div>
</div>
</div>
<!-- ========== Vendor 설치 방식 ========== -->
<?php
$bundleZipExists = file_exists(BASE_PATH.'/vendor-bundle.zip');
$zipArchiveAvailable = class_exists('ZipArchive');
$procOpenAvailable = function_exists('proc_open')
&& ! in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true);
$bundleAvailable = $bundleZipExists && $zipArchiveAvailable;
$composerAvailable = $procOpenAvailable;
$currentVendorMode = $formData['vendor_mode'] ?? 'auto';
?>
<div class="requirement-card">
<div class="requirement-card-header">
<h2 class="card-title"><?= htmlspecialchars(lang('vendor_mode_title')) ?></h2>
<p class="card-description">
<?= htmlspecialchars(lang('vendor_mode_description')) ?>
</p>
</div>
<div class="requirement-card-body">
<div class="vendor-mode-cards">
<!-- 자동 (권장) -->
<label class="vendor-mode-card <?= $currentVendorMode === 'auto' ? 'selected' : '' ?>">
<input type="radio" name="vendor_mode" value="auto" class="vendor-mode-card-input"
<?= $currentVendorMode === 'auto' ? 'checked' : '' ?>>
<div class="vendor-mode-card-header">
<div class="vendor-mode-card-icon"><?= getSvgIcon('magic') ?: '✨' ?></div>
<div class="vendor-mode-card-title-wrapper">
<h3 class="vendor-mode-card-title"><?= htmlspecialchars(lang('vendor_mode_auto_title')) ?></h3>
<span class="vendor-mode-card-badge vendor-mode-card-badge-recommended"><?= htmlspecialchars(lang('vendor_mode_badge_recommended')) ?></span>
</div>
<div class="vendor-mode-card-check"><?= getSvgIcon('check-circle') ?: '◉' ?></div>
</div>
<p class="vendor-mode-card-description">
<?= htmlspecialchars(lang('vendor_mode_auto_description')) ?>
</p>
<p class="vendor-mode-card-status vendor-mode-card-status-ok">
<?= getSvgIcon('check') ?: '✓' ?> <?= htmlspecialchars(lang('vendor_mode_auto_status_ok')) ?>
</p>
</label>
<!-- Composer 실행 -->
<label class="vendor-mode-card <?= $currentVendorMode === 'composer' ? 'selected' : '' ?> <?= $composerAvailable ? '' : 'disabled' ?>">
<input type="radio" name="vendor_mode" value="composer" class="vendor-mode-card-input"
<?= $currentVendorMode === 'composer' ? 'checked' : '' ?>
<?= $composerAvailable ? '' : 'disabled' ?>>
<div class="vendor-mode-card-header">
<div class="vendor-mode-card-icon"><?= getSvgIcon('terminal') ?: '⚙' ?></div>
<div class="vendor-mode-card-title-wrapper">
<h3 class="vendor-mode-card-title"><?= htmlspecialchars(lang('vendor_mode_composer_title')) ?></h3>
<span class="vendor-mode-card-badge vendor-mode-card-badge-dev"><?= htmlspecialchars(lang('vendor_mode_badge_dev')) ?></span>
</div>
<div class="vendor-mode-card-check"><?= getSvgIcon('check-circle') ?: '◉' ?></div>
</div>
<p class="vendor-mode-card-description">
<?= htmlspecialchars(lang('vendor_mode_composer_description')) ?>
</p>
<?php if (! $composerAvailable) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_composer_status_blocked')) ?>
</p>
<?php } else { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-ok">
<?= getSvgIcon('check') ?: '✓' ?> <?= htmlspecialchars(lang('vendor_mode_composer_status_ok')) ?>
</p>
<?php } ?>
</label>
<!-- 번들 Vendor 사용 -->
<label class="vendor-mode-card <?= $currentVendorMode === 'bundled' ? 'selected' : '' ?> <?= $bundleAvailable ? '' : 'disabled' ?>">
<input type="radio" name="vendor_mode" value="bundled" class="vendor-mode-card-input"
<?= $currentVendorMode === 'bundled' ? 'checked' : '' ?>
<?= $bundleAvailable ? '' : 'disabled' ?>>
<div class="vendor-mode-card-header">
<div class="vendor-mode-card-icon"><?= getSvgIcon('package') ?: '📦' ?></div>
<div class="vendor-mode-card-title-wrapper">
<h3 class="vendor-mode-card-title"><?= htmlspecialchars(lang('vendor_mode_bundled_title')) ?></h3>
<span class="vendor-mode-card-badge vendor-mode-card-badge-shared"><?= htmlspecialchars(lang('vendor_mode_badge_shared')) ?></span>
</div>
<div class="vendor-mode-card-check"><?= getSvgIcon('check-circle') ?: '◉' ?></div>
</div>
<p class="vendor-mode-card-description">
<?= htmlspecialchars(lang('vendor_mode_bundled_description')) ?>
</p>
<?php if (! $zipArchiveAvailable) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_no_ziparchive')) ?>
</p>
<?php } elseif (! $bundleZipExists) { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-error">
<?= getSvgIcon('warning') ?: '⚠' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_no_zip')) ?>
</p>
<?php } else { ?>
<p class="vendor-mode-card-status vendor-mode-card-status-ok">
<?= getSvgIcon('check') ?: '✓' ?> <?= htmlspecialchars(lang('vendor_mode_bundled_status_ok')) ?>
</p>
<?php } ?>
</label>
</div>
<p class="form-help" style="margin-top: var(--spacing-md);">
<?= lang('vendor_mode_change_later_html') ?>
</p>
<script>
// Vendor 모드 카드 — 라디오 변경 시 .selected 클래스 동기화
(function () {
const cards = document.querySelectorAll('.vendor-mode-card');
cards.forEach(function (card) {
const input = card.querySelector('.vendor-mode-card-input');
if (!input) return;
input.addEventListener('change', function () {
if (input.checked) {
cards.forEach(function (c) { c.classList.remove('selected'); });
card.classList.add('selected');
}
});
});
})();
</script>
</div>
</div>
<!-- 네비게이션 -->
<?php /* 자산 URL 방식 (이슈 #486) — 아래 스크립트가 브라우저에서 프로브를 던져 채운다.
서버측에서 자기 APP_URL 로 curl 하면 loopback 이 vhost·프록시 체인을 우회해
오판하므로 반드시 브라우저가 판정해야 한다. 판정 실패 시 빈 값으로 남고
설치는 defaults.json 기본값(extension)으로 진행된다. */ ?>
<input type="hidden" name="asset_url_mode" id="asset_url_mode" value="">
<div class="btn-group btn-group-spread">
<button type="button" onclick="goToStep(2)" class="btn btn-secondary">
<?= htmlspecialchars(lang('previous')) ?>
</button>
<button type="submit" id="submit-btn" class="btn btn-primary">
<?= htmlspecialchars(lang('next')) ?>
</button>
</div>
</form>
</div>
<script>
// Step 3 전용 데이터 전달 (DB 테스트 상태)
window.DB_TEST_FLAGS = {
write: <?= $dbWriteTested ? 'true' : 'false' ?>,
read: <?= $dbReadTested ? 'true' : 'false' ?>,
writeHash: <?= installer_json_encode($dbWriteHash) ?>,
readHash: <?= installer_json_encode($dbReadHash) ?>
};
// ========================================================================
// PHP CLI / Composer 검증 상태 관리
// ========================================================================
/**
* CLI 검증 상태 추적 객체
*/
window.CliValidator = {
phpVerified: false,
composerVerified: false,
cliRequired: false, // 기본 php 사용 불가 시 true
/**
* Composer 검증이 필수인지 여부 — vendor_mode='bundled' 에서는 선택사항.
*/
isComposerRequired() {
const mode = this.getSelectedVendorMode();
return mode !== 'bundled';
},
/**
* 현재 선택된 vendor_mode 값 (auto/composer/bundled).
*/
getSelectedVendorMode() {
const input = document.querySelector('input[name="vendor_mode"]:checked');
return input ? input.value : 'auto';
},
setPhpVerified(verified) {
this.phpVerified = verified;
this.updateStatusDisplay();
this.updateSubmitButton();
},
setComposerVerified(verified) {
this.composerVerified = verified;
this.updateStatusDisplay();
this.updateSubmitButton();
},
isValid() {
// CLI 섹션이 열려 있으면 (필수이든 선택이든) 검증 필요
const section = document.getElementById('php-cli-section');
if (section && section.classList.contains('hidden')) {
// 섹션이 닫혀 있고 필수가 아니면 검증 불필요
if (!this.cliRequired) return true;
}
if (!this.phpVerified) return false;
// Composer 는 vendor_mode='bundled' 에서는 검증 불필요
if (this.isComposerRequired() && !this.composerVerified) return false;
return true;
},
updateStatusDisplay() {
const summary = document.getElementById('cli-status-summary');
const phpStatus = document.getElementById('cli-php-status');
const composerStatus = document.getElementById('cli-composer-status');
if (!summary || !phpStatus || !composerStatus) return;
summary.style.display = '';
phpStatus.textContent = this.phpVerified
? '<?= lang('cli_status_verified') ?>'
: '<?= lang('cli_status_not_verified') ?>';
phpStatus.style.color = this.phpVerified ? 'var(--success-color)' : 'var(--error-color)';
const composerOptional = !this.isComposerRequired();
if (composerOptional) {
composerStatus.textContent = '<?= lang('cli_status_optional_bundled') ?>';
composerStatus.style.color = 'var(--text-muted-color, #888)';
} else {
composerStatus.textContent = this.composerVerified
? '<?= lang('cli_status_verified') ?>'
: '<?= lang('cli_status_not_verified') ?>';
composerStatus.style.color = this.composerVerified ? 'var(--success-color)' : 'var(--error-color)';
}
},
updateSubmitButton() {
// submit 버튼 상태는 validateStep3Form에서 최종 체크하므로 여기서는 시각적 힌트만
}
};
// vendor_mode 라디오 변경 시 CLI 검증 상태 표시 갱신 (Composer 검증 필수/선택 전환)
document.addEventListener('change', function (e) {
if (e.target && e.target.name === 'vendor_mode') {
window.CliValidator.updateStatusDisplay();
// bundled 모드 전환 시 Composer 설치 안내 숨김
if (e.target.value === 'bundled') {
const guide = document.getElementById('composer-install-guide');
if (guide) guide.style.display = 'none';
}
}
});
// PHP CLI 설정 토글
document.getElementById('show-php-cli-settings')?.addEventListener('change', function() {
const section = document.getElementById('php-cli-section');
if (this.checked) {
section.classList.remove('hidden');
// 토글을 켰을 때 자동 감지 실행
initCliDetection();
} else {
section.classList.add('hidden');
}
});
// PHP CLI / Composer 입력 변경 시 검증 상태 무효화
document.getElementById('php_binary')?.addEventListener('input', function() {
window.CliValidator.setPhpVerified(false);
// Composer도 PHP 경로에 의존하므로 (.phar 실행) 재검증 필요
window.CliValidator.setComposerVerified(false);
const phpResult = document.getElementById('php-binary-test-result');
if (phpResult) { phpResult.classList.add('hidden'); phpResult.innerHTML = ''; }
const composerResult = document.getElementById('composer-test-result');
if (composerResult) { composerResult.classList.add('hidden'); composerResult.innerHTML = ''; }
});
document.getElementById('composer_binary')?.addEventListener('input', function() {
window.CliValidator.setComposerVerified(false);
const composerResult = document.getElementById('composer-test-result');
if (composerResult) { composerResult.classList.add('hidden'); composerResult.innerHTML = ''; }
// Composer 설치 안내 숨기기
const guide = document.getElementById('composer-install-guide');
if (guide) guide.style.display = 'none';
});
/**
* 페이지 로드 시 PHP CLI / Composer 자동 감지
*/
async function initCliDetection() {
const phpStatus = document.getElementById('cli-php-status');
const composerStatus = document.getElementById('cli-composer-status');
const summary = document.getElementById('cli-status-summary');
if (summary) summary.style.display = '';
if (phpStatus) { phpStatus.textContent = '<?= lang('cli_status_checking') ?>'; phpStatus.style.color = ''; }
if (composerStatus) { composerStatus.textContent = '<?= lang('cli_status_checking') ?>'; composerStatus.style.color = ''; }
// PHP 감지 먼저 실행 (composer 감지 결과 포함) → Composer 검증
const composerData = await detectAndVerifyPhp();
await detectAndVerifyComposer(composerData);
}
/**
* PHP CLI 자동 감지 및 검증
*/
async function detectAndVerifyPhp() {
try {
const response = await fetch('api/check-configuration.php?action=detect-php');
const data = await response.json();
if (!data.default_php_available) {
// 기본 php 사용 불가 → 필수 모드로 전환
switchToRequiredMode();
// 감지된 바이너리가 있으면 첫 번째로 자동 선택
if (data.success && data.binaries && data.binaries.length > 0) {
const phpInput = document.getElementById('php_binary');
if (phpInput && (phpInput.value === 'php' || phpInput.value === '')) {
phpInput.value = data.binaries[0].path;
}
// 자동 감지 결과 표시
showDetectResult(data);
// 자동 선택된 경로로 검증
await testPhpBinary();
}
} else {
// 기본 php 사용 가능
if (data.success && data.binaries) {
showDetectResult(data);
}
// 현재 입력된 경로로 검증
await testPhpBinary();
}
return data.composer || null;
} catch (e) {
// 감지 실패 시 수동 입력 유도
switchToRequiredMode();
return null;
}
}
/**
* Composer 자동 감지 및 검증
* detect-php API 응답의 composer 결과를 사용하여 사전 입력
*/
async function detectAndVerifyComposer(composerData) {
const composerInput = document.getElementById('composer_binary');
// detect-php 응답에 composer 감지 결과가 있으면 사전 입력
if (composerData && composerData.found && composerInput) {
if (!composerInput.value.trim()) {
composerInput.value = composerData.path === 'composer' ? '' : composerData.path;
}
}
await testComposer();
}
/**
* 기본 php 미감지 시 필수 모드로 전환
*/
function switchToRequiredMode() {
window.CliValidator.cliRequired = true;
const title = document.getElementById('php-cli-title');
if (title) title.textContent = '<?= lang('php_cli_settings_required') ?>';
const helpText = document.getElementById('php-cli-help-text');
if (helpText) helpText.textContent = '<?= lang('php_cli_settings_help_required') ?>';
// 섹션 강제 오픈 + 토글 비활성화
const section = document.getElementById('php-cli-section');
if (section) section.classList.remove('hidden');
const toggle = document.getElementById('show-php-cli-settings');
if (toggle) {
toggle.checked = true;
toggle.disabled = true;
toggle.style.opacity = '0.5';
toggle.style.cursor = 'not-allowed';
}
}
// PHP 바이너리 버전 확인
async function testPhpBinary() {
const path = document.getElementById('php_binary').value.trim() || 'php';
const resultDiv = document.getElementById('php-binary-test-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=test-php-binary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
const data = await response.json();
if (data.success) {
resultDiv.className = 'test-result test-success';
resultDiv.innerHTML = data.message;
window.CliValidator.setPhpVerified(true);
} else {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = data.message;
window.CliValidator.setPhpVerified(false);
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
window.CliValidator.setPhpVerified(false);
}
}
// Composer 버전 확인
async function testComposer() {
const composerPath = document.getElementById('composer_binary').value.trim();
const phpPath = document.getElementById('php_binary').value.trim() || 'php';
const resultDiv = document.getElementById('composer-test-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('composer_checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=test-composer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: composerPath, php_path: phpPath })
});
const data = await response.json();
if (data.success) {
resultDiv.className = 'test-result test-success';
resultDiv.innerHTML = data.message;
window.CliValidator.setComposerVerified(true);
// 설치 안내 숨기기
const guide = document.getElementById('composer-install-guide');
if (guide) guide.style.display = 'none';
} else {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = data.message;
window.CliValidator.setComposerVerified(false);
showComposerInstallGuide();
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
window.CliValidator.setComposerVerified(false);
showComposerInstallGuide();
}
}
/**
* Composer 설치 안내를 PHP 경로 반영하여 동적 생성.
*
* vendor_mode='bundled' 에서는 Composer 가 불필요하므로 안내를 띄우지 않는다.
*/
function showComposerInstallGuide() {
// 번들 모드에서는 Composer 가 불필요 → 안내 생략
if (window.CliValidator && !window.CliValidator.isComposerRequired()) {
const g = document.getElementById('composer-install-guide');
if (g) g.style.display = 'none';
return;
}
const guide = document.getElementById('composer-install-guide');
if (!guide) return;
const phpPath = document.getElementById('php_binary').value.trim() || 'php';
const commandsDiv = document.getElementById('composer-install-commands');
if (!commandsDiv) return;
let html = '';
// 표준 설치 안내 (항상 표시)
html += '<p class="fix-guide-label"><?= lang('composer_install_guide_global') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_local') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -sS https://getcomposer.org/installer | php</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
// 호스팅 환경 안내 (항상 표시 — 표준 방법 실패 시 대안)
html += '<p class="fix-guide-label" style="margin-top: var(--spacing-md);"><?= lang('composer_install_guide_hosting') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">curl -o composer-setup.php https://getcomposer.org/installer</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<div class="code-box" style="margin-top: var(--spacing-xs);">';
html += '<pre class="fix-command">' + escapeHtml(phpPath) + ' -d allow_url_fopen=On composer-setup.php</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_pwd_hint') ?></p>';
html += '<div class="code-box">';
html += '<pre class="fix-command">pwd</pre>';
html += '<button type="button" class="btn-copy" onclick="copyCliCommand(this)"><?= lang('copy_command') ?></button>';
html += '</div>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_phar_hint') ?></p>';
html += '<p class="fix-guide-hint" style="margin-top: var(--spacing-sm);"><?= lang('composer_install_guide_link') ?></p>';
commandsDiv.innerHTML = html;
guide.style.display = '';
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* PHP 감지 결과 표시 (감지 결과 영역)
*/
function showDetectResult(data) {
const resultDiv = document.getElementById('php-detect-result');
if (!resultDiv) return;
if (data.success && data.binaries && data.binaries.length > 0) {
let html = '<strong>' + '<?= lang('detected_php_binaries') ?>' + '</strong><ul style="margin: var(--spacing-xs) 0; padding-left: var(--spacing-lg);">';
data.binaries.forEach(function(bin) {
html += '<li><a href="#" onclick="selectPhpBinary(\'' + bin.path.replace(/'/g, "\\'") + '\'); return false;" style="cursor: pointer;">'
+ bin.path + '</a> — PHP ' + bin.version + '</li>';
});
html += '</ul>';
resultDiv.className = 'test-result test-success';
resultDiv.innerHTML = html;
} else {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = data.message || '<?= lang('no_php_detected') ?>';
}
}
/**
* 감지된 PHP 바이너리 선택 시 자동 검증
*/
async function selectPhpBinary(path) {
document.getElementById('php_binary').value = path;
// PHP 검증 상태 초기화 후 재검증
window.CliValidator.setPhpVerified(false);
window.CliValidator.setComposerVerified(false);
await testPhpBinary();
// Composer도 PHP 경로 의존이므로 재검증
await testComposer();
}
// PHP 바이너리 자동 감지 (수동 버튼)
async function detectPhpBinaries() {
const resultDiv = document.getElementById('php-detect-result');
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('detecting_php') ?>';
try {
const response = await fetch('api/check-configuration.php?action=detect-php');
const data = await response.json();
showDetectResult(data);
if (!data.default_php_available) {
switchToRequiredMode();
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
}
}
/**
* 명령어 복사 (Composer 설치 안내)
*/
function copyCliCommand(btn) {
const command = btn.previousElementSibling.textContent;
navigator.clipboard.writeText(command).then(function() {
const originalText = btn.textContent;
btn.textContent = '<?= lang('copied') ?>';
setTimeout(function() { btn.textContent = originalText; }, 2000);
});
}
// 페이지 로드 시 CLI 섹션이 열려 있으면 자동 감지 실행
document.addEventListener('DOMContentLoaded', function() {
const section = document.getElementById('php-cli-section');
const toggle = document.getElementById('show-php-cli-settings');
if (section && !section.classList.contains('hidden')) {
initCliDetection();
} else {
// 섹션이 닫혀 있어도 기본 php/composer 사용 가능 여부는 확인해야 함
// 사용 불가 시 필수 모드로 전환되면서 섹션이 열림
initCliDetection();
}
});
// 코어 업데이트 _pending 경로 입력 시 체크 버튼 표시
document.querySelector('input[name="core_update_pending_path"]')?.addEventListener('input', function() {
const wrapper = document.getElementById('core-pending-check-wrapper');
wrapper.style.display = this.value.trim() ? 'block' : 'none';
});
// 코어 _pending 경로 퍼미션/소유자 체크
async function checkCorePendingPath() {
const path = document.querySelector('input[name="core_update_pending_path"]').value.trim();
const resultDiv = document.getElementById('core-pending-check-result');
if (!path) return;
resultDiv.className = 'test-result';
resultDiv.innerHTML = '<span class="loading-spinner"></span> ' + '<?= lang('checking') ?>';
try {
const response = await fetch('api/check-configuration.php?action=check-core-pending-path&path=' + encodeURIComponent(path));
const data = await response.json();
if (data.success) {
resultDiv.className = 'test-result test-success';
resultDiv.innerHTML = data.message;
} else {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = data.message;
}
} catch (e) {
resultDiv.className = 'test-result test-error';
resultDiv.innerHTML = '<?= lang('error_check_failed') ?>';
}
}
</script>