Files
Gnuboard7/public/install/assets/js/installer.js
T
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지
않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를
--no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는
`composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를
찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상
종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다.
(sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10)

3계층으로 막는다.

 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear
 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다.
 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전
 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다.
 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로
 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을
 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI
 자체에 도달할 수 없다).

업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고
CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의
복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다.

실측 중 드러난 결함 2건을 함께 고쳤다.

 - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아
 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다.
 - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다
 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다).

인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인
카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를
정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다.
코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3).

코어 7.0.10 → 7.0.11.
2026-09-08 09:46:57 +09:00

5453 lines
228 KiB
JavaScript

/**
* G7 Installer - Vanilla JavaScript
* 모든 클라이언트 사이드 인터랙션 처리
*/
// =========================================================================
// 로케일 초기화 (깜빡임 방지를 위해 최상단에서 즉시 실행)
// =========================================================================
(function initializeLocale() {
'use strict';
const STORAGE_KEY = 'g7_locale';
const SUPPORTED_LOCALES = ['ko', 'en'];
const DEFAULT_LOCALE = 'ko';
/**
* 저장된 로케일 또는 서버/브라우저 언어 기반 로케일 가져오기
*
* 우선순위: localStorage > state.json (서버 전달) > 브라우저 언어 > 기본값
*/
function getPreferredLocale() {
// 1. localStorage 확인 (최우선)
const storedLocale = localStorage.getItem(STORAGE_KEY);
if (storedLocale && SUPPORTED_LOCALES.includes(storedLocale)) {
return storedLocale;
}
// 2. state.json 값 확인 (서버에서 전달된 값)
if (window.INSTALLER_STATE_LOCALE && SUPPORTED_LOCALES.includes(window.INSTALLER_STATE_LOCALE)) {
return window.INSTALLER_STATE_LOCALE;
}
// 3. 브라우저 언어 감지
const browserLang = navigator.language || navigator.userLanguage;
if (browserLang) {
// 언어-지역 형태에서 언어만 추출 (예: ko-KR -> ko)
const lang = browserLang.split('-')[0].toLowerCase();
if (SUPPORTED_LOCALES.includes(lang)) {
return lang;
}
}
// 4. 기본값
return DEFAULT_LOCALE;
}
/**
* localStorage에 로케일 저장
*/
function saveLocale(locale) {
try {
localStorage.setItem(STORAGE_KEY, locale);
} catch (error) {
console.warn('[Installer] Failed to save locale to localStorage:', error);
}
}
// 초기 로케일 설정 및 저장
const initialLocale = getPreferredLocale();
saveLocale(initialLocale);
// 전역 함수로 노출 (언어 선택 UI에서 사용)
window.installerLocale = {
get: function() {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_LOCALE;
},
set: function(locale) {
if (SUPPORTED_LOCALES.includes(locale)) {
saveLocale(locale);
return true;
}
return false;
}
};
})();
// =========================================================================
// 메인 인스톨러 로직
// =========================================================================
(function() {
'use strict';
// =========================================================================
// 다국어 헬퍼 함수
// =========================================================================
/**
* 다국어 번역 조회
*
* @param {string} key 번역 키
* @return {string} 번역된 문자열
*/
function lang(key) {
return window.INSTALLER_LANG && window.INSTALLER_LANG[key] ? window.INSTALLER_LANG[key] : key;
}
// =========================================================================
// Step 1: 라이선스 동의 체크박스
// =========================================================================
/**
* 라이선스 동의 체크박스 이벤트 초기화
*/
function initLicenseAgreement() {
const licenseCheckbox = document.getElementById('license-agree');
const licenseForm = licenseCheckbox ? licenseCheckbox.closest('form') : null;
if (licenseForm && licenseCheckbox) {
licenseForm.addEventListener('submit', function(e) {
if (!licenseCheckbox.checked) {
e.preventDefault();
alert(lang('must_agree'));
licenseCheckbox.focus();
}
});
}
}
// =========================================================================
// Step 2: 요구사항 검증
// =========================================================================
/**
* 서버 요구사항 검증 실행
*/
async function checkRequirements() {
try {
// 절대 URL 생성 (origin + base path)
const apiUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/check-configuration.php?action=requirements`;
const response = await fetch(apiUrl);
// HTTP 응답 상태 확인
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 빈 본문(HTTP 200 + Content-Length 0)을 response.json() 에 넘기면
// "Unexpected end of JSON input" 이라는, 원인도 조치도 알 수 없는 문구가 화면에 뜬다.
// (gnuboard/g7#62 — 한글 계정명 환경에서 실제로 발생했다)
// 사람이 읽고 조치할 수 있는 안내로 바꾼다.
const text = await response.text();
if (text.trim() === '') {
throw new Error(lang('error_empty_server_response'));
}
let data;
try {
data = JSON.parse(text);
} catch (parseError) {
throw new Error(lang('error_invalid_server_response'));
}
const resultHtml = renderRequirements(data);
document.getElementById('requirements-result').innerHTML = resultHtml;
document.getElementById('navigation-buttons').classList.remove('hidden');
// 자산 URL 방식은 브라우저만 판정할 수 있어 카드 삽입 후 비동기로 채운다.
// await 하지 않는다 — 프로브가 늦거나 실패해도 요구사항 화면을 막지 않는다.
refreshAssetUrlModeCard();
// 버튼 표시 제어
const recheckBtn = document.getElementById('recheck-btn');
const nextBtn = document.getElementById('next-btn');
if (data.all_required_passed) {
// 필수 요구사항 통과: 다음 버튼만 표시
recheckBtn.classList.add('hidden');
nextBtn.classList.remove('hidden');
nextBtn.disabled = false;
} else {
// 필수 요구사항 미통과: 다시검증 버튼만 표시
recheckBtn.classList.remove('hidden');
nextBtn.classList.add('hidden');
}
} catch (error) {
document.getElementById('requirements-result').innerHTML = `
<div class="alert alert-error">
${lang('requirements_check_failed')}: ${error.message}
</div>
`;
}
}
/**
* 검증 결과를 HTML로 렌더링 (카드 형태)
*/
function renderRequirements(data) {
let html = '<div class="requirements-result">';
let failedRequirements = [];
// API 응답에서 권한 설정값 가져오기 (카드 헤더 표시용)
const requiredPermissions = data.directories?.required_permissions || '755';
// OS 정보 (Windows 여부)
const isWindows = data.is_windows || false;
// 1. PHP 버전 카드
const phpStatusClass = data.php_version.passed ? 'status-pass' : 'status-fail';
if (!data.php_version.passed) {
failedRequirements.push(`${lang('error_php_version')}: ${data.php_version.current_version} (${lang('minimum')} ${data.php_version.min_version} ${lang('required_text')})`);
}
html += renderSingleItemCard(
lang('php_version'),
phpStatusClass,
`PHP ${data.php_version.current_version}`,
`${data.php_version.min_version}+`,
true
);
// 2. 디스크 공간 카드
const diskSpaceGB = (data.disk_space.current_mb / 1024).toFixed(2);
const minSpaceGB = (data.disk_space.min_mb / 1024).toFixed(2);
const diskStatusClass = data.disk_space.passed ? 'status-pass' : 'status-fail';
if (!data.disk_space.passed) {
failedRequirements.push(`${lang('error_disk_space')}: ${diskSpaceGB} GB (${lang('minimum')} ${minSpaceGB} GB ${lang('required_text')})`);
}
html += renderSingleItemCard(
lang('disk_space'),
diskStatusClass,
`${diskSpaceGB} GB`,
`${minSpaceGB} GB`,
true
);
// 3. PHP 모듈 카드
const allRequiredPassed = data.php_extensions.required.every(ext => data.php_extensions.installed[ext]?.installed);
if (!allRequiredPassed) {
const missing = data.php_extensions.required.filter(ext => !data.php_extensions.installed[ext]?.installed);
failedRequirements.push(`${lang('error_missing_extensions')}: ${missing.join(', ')}`);
}
let requiredExtHtml = '<div class="extension-grid">';
data.php_extensions.required.forEach(ext => {
const info = data.php_extensions.installed[ext];
requiredExtHtml += renderExtensionItem(ext, info?.installed || false, true);
});
requiredExtHtml += '</div>';
const requiredStatusClass = allRequiredPassed ? 'status-pass' : 'status-fail';
html += renderMultiItemCard(
lang('php_modules'),
requiredStatusClass,
requiredExtHtml,
true,
''
);
// 4. 디렉토리 권한 카드
const allDirsPassed = Object.values(data.directories.results).every(dir => dir.passed);
const failedDirPaths = []; // chmod 대상 (쓰기/읽기 불가)
const ownershipMismatchItems = []; // 소유권 불일치 대상 (chgrp/chown/777 옵션 안내)
const notExistsPaths = []; // mkdir 대상 (not_exists)
const failedDirRelPaths = []; // 상대경로
const notExistsRelPaths = []; // 상대경로 (not_exists)
if (!allDirsPassed) {
// 계층적 디렉토리 에러 메시지 생성
let dirErrorHtml = `${lang('error_directory_permissions')}<ul style="margin-top: 0.5rem;">`;
Object.entries(data.directories.results)
.filter(([, info]) => !info.passed)
.forEach(([path, info]) => {
const fullPath = info.full_path || path;
const relPath = info.relative_path || ('./' + path);
if (info.error_type === 'not_exists') {
notExistsPaths.push(fullPath);
notExistsRelPaths.push(relPath);
} else if (info.error_type === 'ownership_mismatch') {
ownershipMismatchItems.push({
fullPath,
relPath,
owner: info.owner || 'unknown',
webUser: info.web_server_user || 'www-data',
permissions: info.permissions || '755',
});
} else {
// not_writable, not_readable, subdirectory_issues 등 모두 chmod 대상
failedDirPaths.push(fullPath);
failedDirRelPaths.push(relPath);
}
dirErrorHtml += `<li>${path}`;
// 하위 디렉토리 문제가 있으면 중첩 리스트로 표시 (부모 경로 제거)
if (info.has_subdirectory_issues && info.failed_subdirectories && info.failed_subdirectories.length > 0) {
dirErrorHtml += '<ul>';
info.failed_subdirectories.forEach(subdir => {
// subdir는 {path: '...', permissions: '...'} 형태의 객체
const subdirPath = typeof subdir === 'string' ? subdir : subdir.path;
// 부모 경로 제거 (예: storage/framework/cache -> framework/cache)
const relativePath = subdirPath.replace(path + '/', '');
dirErrorHtml += `<li>${escapeHtml(relativePath)}</li>`;
});
dirErrorHtml += '</ul>';
}
dirErrorHtml += '</li>';
});
dirErrorHtml += '</ul>';
failedRequirements.push(dirErrorHtml);
}
let dirHtml = '<div class="directory-grid">';
for (const [path, info] of Object.entries(data.directories.results)) {
dirHtml += renderDirectoryItem(path, info);
}
dirHtml += '</div>';
const dirStatusClass = allDirsPassed ? 'status-pass' : 'status-fail';
html += renderMultiItemCard(
lang('directory_permissions'),
dirStatusClass,
dirHtml,
true,
requiredPermissions
);
// 5. 필수 파일 카드
const missingFileCommands = []; // 파일 생성 대상 (not_exists)
const notWritableFilePaths = []; // 쓰기 불가 + 권한 비트 부족 (chmod 644로 해결)
const fileOwnershipMismatchItems = []; // 권한 충분하나 소유자 불일치 (chgrp/chown/666)
if (data.required_files) {
const allFilesPassed = data.required_files.all_passed;
if (!allFilesPassed) {
// 상위 에러 메시지: 파일 상태별로 다른 메시지 구성
const missingNames = [];
const notWritableNames = [];
const ownershipMismatchNames = [];
Object.entries(data.required_files.files).forEach(([name, info]) => {
if (!info.passed) {
if (info.error_type === 'not_exists') {
missingNames.push(name);
} else if (info.error_type === 'ownership_mismatch') {
ownershipMismatchNames.push(name);
} else {
notWritableNames.push(name);
}
}
});
if (missingNames.length > 0) {
failedRequirements.push(`${lang('error_required_files_missing_label')}: ${missingNames.join(', ')}`);
}
if (notWritableNames.length > 0) {
failedRequirements.push(`${lang('error_required_files_not_writable_label')}: ${notWritableNames.join(', ')}`);
}
if (ownershipMismatchNames.length > 0) {
failedRequirements.push(`${lang('error_required_files_ownership_mismatch_label')}: ${ownershipMismatchNames.join(', ')}`);
}
// 파일별 대상 배열 분류
Object.entries(data.required_files.files).forEach(([name, info]) => {
const fullPath = data.required_files.base_path + '/' + name;
if (info.error_type === 'not_exists' && info.command) {
missingFileCommands.push(info.command);
} else if (info.error_type === 'ownership_mismatch') {
fileOwnershipMismatchItems.push({
name,
fullPath,
owner: info.owner || 'unknown',
webUser: info.web_server_user || 'www-data',
permissions: info.permissions || '644',
});
} else if (info.error_type === 'not_writable') {
notWritableFilePaths.push(fullPath);
}
});
}
let fileHtml = '<div class="directory-grid">';
Object.entries(data.required_files.files).forEach(([name, info]) => {
const fileStatusClass = info.passed ? 'status-pass' : 'status-fail';
const fileStatusIcon = getStatusIcon(fileStatusClass);
let fileStatusText = lang('file_exists');
if (!info.exists) {
fileStatusText = lang('file_missing');
} else if (!info.writable) {
fileStatusText = lang('file_not_writable');
}
fileHtml += `
<div class="directory-item-wrapper">
<div class="directory-item ${fileStatusClass}">
<div class="directory-left">
${fileStatusIcon}
<span class="directory-name">${escapeHtml(name)}</span>
</div>
<div class="directory-right">
<span class="directory-permission">${fileStatusText}</span>
</div>
</div>
</div>
`;
});
fileHtml += '</div>';
const fileStatusClass = allFilesPassed ? 'status-pass' : 'status-fail';
html += renderMultiItemCard(
lang('required_files'),
fileStatusClass,
fileHtml,
true
);
}
// 6. HTTPS 카드
const httpsStatusClass = data.https.enabled ? 'status-pass' : 'status-warning';
html += renderSingleItemCard(
lang('https'),
httpsStatusClass,
data.https.enabled ? lang('enabled') : lang('not_enabled'),
'',
false
);
// 7. 필수 함수 (exec, proc_open, shell_exec) 카드
if (data.disabled_functions) {
const dfPassed = data.disabled_functions.passed;
const dfStatusClass = dfPassed ? 'status-pass' : 'status-fail';
const dfDisabledList = data.disabled_functions.disabled || [];
if (!dfPassed) {
failedRequirements.push(
lang('error_disabled_functions').replace(':functions', dfDisabledList.join(', '))
);
}
html += renderSingleItemCard(
lang('required_functions'),
dfStatusClass,
dfPassed
? lang('required_functions_available')
: lang('required_functions_disabled').replace(':count', dfDisabledList.length),
'exec, proc_open, shell_exec',
true
);
}
// 8. PHP CLI 버전 일치 확인 카드 (경고만, 필수 아님)
if (data.php_cli_version) {
const cliData = data.php_cli_version;
let cliStatusClass, cliStatusText;
if (cliData.matched === null && cliData.cli_version === null) {
// exec 비활성화 또는 CLI 버전 파악 불가
cliStatusClass = 'status-warning';
cliStatusText = cliData.cli_path === null
? lang('php_cli_version_check_skipped')
: lang('php_cli_version_unknown');
} else if (cliData.matched) {
cliStatusClass = 'status-pass';
cliStatusText = lang('php_cli_version_matched')
.replace(':web', cliData.web_version)
.replace(':cli', cliData.cli_version);
} else {
cliStatusClass = 'status-warning';
cliStatusText = lang('php_cli_version_mismatch')
.replace(':web', cliData.web_version)
.replace(':cli', cliData.cli_version || '?');
}
html += renderSingleItemCard(
lang('php_cli_version'),
cliStatusClass,
cliStatusText,
'',
false
);
}
// 9. OPcache 카드 (성능 권장, 필수 아님)
// failedRequirements.push() 를 하지 않는다 — 비활성이어도 설치는 진행된다.
if (data.opcache) {
let opcacheStatusClass, opcacheStatusText;
if (data.opcache.enabled === null) {
// ini_get 이 차단된 환경 — 확인 불가 (경고도 차단도 아님)
opcacheStatusClass = 'status-warning';
opcacheStatusText = lang('opcache_unknown');
} else if (data.opcache.enabled) {
opcacheStatusClass = 'status-pass';
opcacheStatusText = lang('enabled');
} else {
// 단순히 '비활성화됨' 만 보이면 경고의 실질(성능 저하)이 전달되지 않는다.
opcacheStatusClass = 'status-warning';
opcacheStatusText = lang('opcache_disabled_short');
}
html += renderSingleItemCard(
lang('opcache'),
opcacheStatusClass,
opcacheStatusText,
'',
false
);
}
// 9-1. Composer 의존성 구성 카드 (권장 사항, 필수 아님)
// failedRequirements.push() 를 하지 않는다 — 개발용 패키지가 섞여 있어도 설치는 진행된다.
// vendor 자체가 없으면 마법사가 운영용 구성으로 자동 설치하므로 카드를 그리지 않는다.
if (data.vendor_dev_packages && data.vendor_dev_packages.vendor_exists) {
const vendorInfo = data.vendor_dev_packages;
let vendorStatusClass, vendorStatusText, vendorHint = '';
if (vendorInfo.dev === true) {
// 단순히 '개발용 포함' 만 보이면 이후 코어 업데이트가 깨진다는 실질이 전달되지 않는다.
vendorStatusClass = 'status-warning';
vendorStatusText = lang('vendor_dev_packages_detected_short')
.replace(':count', (vendorInfo.packages || []).length);
vendorHint = lang('vendor_dev_packages_detected_warning')
.replace(':count', (vendorInfo.packages || []).length);
} else if (vendorInfo.dev === false) {
vendorStatusClass = 'status-pass';
vendorStatusText = lang('vendor_dev_packages_none_short');
} else {
// installed.json 부재·형식 불명 — 확인 불가 (경고도 차단도 아님)
vendorStatusClass = 'status-pass';
vendorStatusText = lang('vendor_dev_packages_unknown');
}
html += renderSingleItemCard(
lang('vendor_dev_packages'),
vendorStatusClass,
vendorStatusText,
'',
false
);
if (vendorHint) {
html += `<p class="fix-guide-hint" style="margin-top: 0.75rem;">${vendorHint}</p>`;
}
}
// 10. 자산 URL 방식 카드 (안내 항목, 필수 아님)
// 서버는 판정할 수 없으므로(loopback 이 vhost·프록시를 우회) "확인 중" 으로 먼저
// 그리고 refreshAssetUrlModeCard() 가 브라우저 프로브 결과로 갱신한다.
// failedRequirements.push() 를 하지 않는다 — 어느 방식이든 정상 동작이다.
if (data.asset_url_mode) {
html += `<div id="asset-url-mode-card">${renderSingleItemCard(
lang('asset_url_mode'),
'status-warning',
lang('asset_url_mode_checking'),
'',
false
)}</div>`;
}
html += '</div>';
// 필수 조건 미충족 시 에러 박스 표시
if (failedRequirements.length > 0) {
html += '<div class="requirements-error-box">';
html += '<div class="error-box-header">';
html += '<span class="error-icon">';
html += '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">';
html += '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />';
html += '</svg>';
html += '</span>';
html += `<h3 class="error-box-title">${lang('requirements_not_met')}</h3>`;
html += '</div>';
html += '<ul class="error-box-list">';
failedRequirements.forEach(error => {
html += `<li>${error}</li>`;
});
html += '</ul>';
// 디렉토리 미존재 (not_exists): mkdir 명령어 안내
if (notExistsPaths.length > 0) {
html += '<div class="permission-fix-guide">';
html += `<p class="fix-guide-label">${lang('directory_create_guide')}</p>`;
html += '<div class="code-box">';
if (isWindows) {
const winMkdirCommands = notExistsPaths.map(p => `mkdir "${p.replace(/\//g, '\\\\')}"`);
html += `<pre class="fix-command">${escapeHtml(winMkdirCommands.join('\n'))}</pre>`;
} else {
const mkdirCommand = `mkdir -p ${notExistsPaths.join(' ')}`;
html += `<pre class="fix-command">${escapeHtml(mkdirCommand)}</pre>`;
}
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
if (isWindows) {
const winRelMkdirCommands = notExistsRelPaths.map(p => `mkdir "${p.replace(/\//g, '\\\\')}"`);
html += `<pre class="fix-command">${escapeHtml(winRelMkdirCommands.join('\n'))}</pre>`;
} else {
const relMkdirCommand = `mkdir -p ${notExistsRelPaths.join(' ')}`;
html += `<pre class="fix-command">${escapeHtml(relMkdirCommand)}</pre>`;
}
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += '</div>';
}
// 소유권 불일치 (ownership_mismatch): 파일 소유자 != 웹서버 실행 사용자
// 3가지 해결 옵션 제시 (그룹 공유 권장 > 소유자 변경 > 777 최후 수단)
if (ownershipMismatchItems.length > 0) {
// 대상 경로를 공백으로 join (동일 webUser 기준 — 현재는 모두 동일)
const owner = ownershipMismatchItems[0].owner;
const webUser = ownershipMismatchItems[0].webUser;
const mismatchPaths = ownershipMismatchItems.map(i => i.fullPath).join(' ');
// 그룹 쓰기 비트(0o020) 이미 설정되어 있으면 chmod 생략, chgrp만 안내
// 모든 대상이 이미 그룹 쓰기 가능한 경우에만 chmod 생략 (부분 일치 시 안전하게 포함)
const allGroupWritable = ownershipMismatchItems.every(i => {
const p = parseInt(i.permissions, 8);
return !isNaN(p) && (p & 0o020) !== 0;
});
const groupCommand = allGroupWritable
? `sudo chgrp -R ${webUser} ${mismatchPaths}`
: `sudo chgrp -R ${webUser} ${mismatchPaths} && sudo chmod -R 775 ${mismatchPaths}`;
html += '<div class="permission-fix-guide">';
html += `<p class="fix-guide-label">${lang('ownership_mismatch_option_group')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(groupCommand)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-label" style="margin-top: 1rem;">${lang('ownership_mismatch_option_chown')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`sudo chown -R ${webUser}:${webUser} ${mismatchPaths}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-label" style="margin-top: 1rem;">${lang('ownership_mismatch_option_777')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod -R 777 ${mismatchPaths}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
const hintText = lang('ownership_mismatch_hint')
.replace(':owner', escapeHtml(owner))
.replace(':web_user', escapeHtml(webUser));
html += `<p class="fix-guide-hint" style="margin-top: 0.75rem;">${hintText}</p>`;
html += '</div>';
}
// 디렉토리 권한 문제 (쓰기/읽기 불가): chmod 755 단일 명령어 안내
// 업계 표준(WordPress/Drupal/Joomla/Laravel)에 맞춰 chown 의존성 제거, 비트 강요 제거
if (failedDirPaths.length > 0) {
html += '<div class="permission-fix-guide">';
if (isWindows) {
const winDirPaths = failedDirPaths.map(p => p.replace(/\//g, '\\'));
const winDirCommands = winDirPaths.map(p => `icacls "${p}" /grant Everyone:(OI)(CI)F /T`);
html += `<p class="fix-guide-label">${lang('permission_fix_guide')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(winDirCommands.join('\n'))}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
const winDirRelPaths = failedDirRelPaths.map(p => p.replace(/\//g, '\\'));
const winDirRelCommands = winDirRelPaths.map(p => `icacls "${p}" /grant Everyone:(OI)(CI)F /T`);
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(winDirRelCommands.join('\n'))}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('permission_windows_hint')}</p>`;
} else {
const dirPathList = failedDirPaths.join(' ');
const dirRelPathList = failedDirRelPaths.join(' ');
html += `<p class="fix-guide-label">${lang('permission_fix_guide')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod -R 755 ${dirPathList}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod -R 755 ${dirRelPathList}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
}
html += '</div>';
}
// 필수 파일 누락: 생성 + 권한 수정 통합 안내
// base_path 소유자가 웹서버 사용자와 다른 경우, 생성 직후 ownership_mismatch가
// 재현되므로 복사 명령에 chgrp + chmod 664를 미리 포함시켜 2단계 안내를 방지
if (missingFileCommands.length > 0) {
let fileFixCommand;
let fileFixRelCommand;
const relBasePath = data.required_files.relative_base_path || '.';
if (isWindows) {
// Windows: 복사 명령만 (권한 문제 거의 없음)
fileFixCommand = missingFileCommands.join(' && ');
fileFixRelCommand = 'copy .env.example .env';
} else {
// Linux: 복사 + chmod (소유자 일치 여부에 따라 chgrp 포함)
const basePath = data.required_files.base_path || '';
const envPath = basePath + '/.env';
const relEnvPath = relBasePath + '/.env';
const webUser = data.required_files.web_server_user || 'www-data';
const ownerMatchesWebUser = data.required_files.base_path_owner_matches_web_user === true;
if (ownerMatchesWebUser) {
// 소유자 일치 → chmod 644만 필요
fileFixCommand = missingFileCommands.join(' && ') + ` && chmod 644 ${envPath}`;
fileFixRelCommand = `cp ${relBasePath}/.env.example ${relEnvPath} && chmod 644 ${relEnvPath}`;
} else {
// 소유자 불일치 → 복사 + chgrp + chmod 664 통합
fileFixCommand = missingFileCommands.join(' && ') + ` && sudo chgrp ${webUser} ${envPath} && sudo chmod 664 ${envPath}`;
fileFixRelCommand = `cp ${relBasePath}/.env.example ${relEnvPath} && sudo chgrp ${webUser} ${relEnvPath} && sudo chmod 664 ${relEnvPath}`;
}
}
html += '<div class="permission-fix-guide">';
html += `<p class="fix-guide-label">${isWindows ? lang('required_files_fix_guide') : lang('required_files_fix_guide_combined')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(fileFixCommand)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(fileFixRelCommand)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += '</div>';
}
// 필수 파일 쓰기 불가: chmod 644 단일 명령어 안내
if (notWritableFilePaths.length > 0) {
const notWritableRelPaths = notWritableFilePaths.map(p => {
const basePath = data.required_files.base_path || '';
return basePath ? p.replace(basePath, '.') : p;
});
html += '<div class="permission-fix-guide">';
if (isWindows) {
const winFilePaths = notWritableFilePaths.map(p => p.replace(/\//g, '\\'));
const winFileCommands = winFilePaths.map(p => `icacls "${p}" /grant Everyone:F`);
html += `<p class="fix-guide-label">${lang('permission_fix_guide')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(winFileCommands.join('\n'))}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
const winRelFilePaths = notWritableRelPaths.map(p => p.replace(/\//g, '\\'));
const winRelFileCommands = winRelFilePaths.map(p => `icacls "${p}" /grant Everyone:F`);
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(winRelFileCommands.join('\n'))}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('permission_windows_hint')}</p>`;
} else {
const filePathList = notWritableFilePaths.join(' ');
const relFilePathList = notWritableRelPaths.join(' ');
html += `<p class="fix-guide-label">${lang('permission_fix_guide')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod 644 ${filePathList}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-hint">${lang('or_relative_path')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod 644 ${relFilePathList}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
}
html += '</div>';
}
// 파일 소유권 불일치: 3가지 해결 옵션 제시
// (파일 소유자 ≠ 웹서버 실행 사용자, 권한 비트는 0644 이상이지만 실제 쓰기 불가)
if (fileOwnershipMismatchItems.length > 0) {
const fileOwner = fileOwnershipMismatchItems[0].owner;
const fileWebUser = fileOwnershipMismatchItems[0].webUser;
const mismatchFilePaths = fileOwnershipMismatchItems.map(i => i.fullPath).join(' ');
// 그룹 쓰기 비트(0o020) 이미 설정되어 있으면 chmod 생략
const allFileGroupWritable = fileOwnershipMismatchItems.every(i => {
const p = parseInt(i.permissions, 8);
return !isNaN(p) && (p & 0o020) !== 0;
});
const fileGroupCommand = allFileGroupWritable
? `sudo chgrp ${fileWebUser} ${mismatchFilePaths}`
: `sudo chgrp ${fileWebUser} ${mismatchFilePaths} && sudo chmod 664 ${mismatchFilePaths}`;
html += '<div class="permission-fix-guide">';
html += `<p class="fix-guide-label">${lang('ownership_mismatch_option_group')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(fileGroupCommand)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-label" style="margin-top: 1rem;">${lang('ownership_mismatch_option_chown')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`sudo chown ${fileWebUser}:${fileWebUser} ${mismatchFilePaths}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
html += `<p class="fix-guide-label" style="margin-top: 1rem;">${lang('ownership_mismatch_option_666')}</p>`;
html += '<div class="code-box">';
html += `<pre class="fix-command">${escapeHtml(`chmod 666 ${mismatchFilePaths}`)}</pre>`;
html += `<button class="btn-copy" onclick="copyPermissionCommand(this)">${lang('copy_command')}</button>`;
html += '</div>';
const fileHintText = lang('ownership_mismatch_hint')
.replace(':owner', escapeHtml(fileOwner))
.replace(':web_user', escapeHtml(fileWebUser));
html += `<p class="fix-guide-hint" style="margin-top: 0.75rem;">${fileHintText}</p>`;
html += '</div>';
}
html += '</div>';
}
return html;
}
/**
* 단일 항목 카드 렌더링 (PHP 버전, 디스크 공간, HTTPS)
*/
function renderSingleItemCard(title, statusClass, currentValue, requiredValue, isRequired) {
const statusIcon = getStatusIcon(statusClass);
const badgeText = isRequired ? lang('badge_required') : lang('badge_optional');
const badgeClass = isRequired ? 'badge-required' : 'badge-optional';
// 상태를 아이콘에만 반영하면 통과/경고 카드가 같은 색으로 보여 경각심이 전달되지 않는다.
// 카드 자체에 수식자를 부여해 스캔만으로 문제 항목이 드러나게 한다.
const cardModifier = statusClass === 'status-warning'
? ' requirement-card--warning'
: (statusClass === 'status-fail' ? ' requirement-card--fail' : '');
return `
<div class="requirement-card${cardModifier}">
<div class="requirement-card-header header-single">
<div class="header-left">
${statusIcon}
<h3 class="requirement-card-title">${title}</h3>
${requiredValue ? `<span class="requirement-text">${requiredValue} ${lang('or_above')}</span>` : ''}
</div>
<div class="header-right">
<div class="current-value">
<span class="value-text">${currentValue}</span>
</div>
<div class="requirement-badge ${badgeClass}">${badgeText}</div>
</div>
</div>
</div>
`;
}
/**
* 다중 항목 카드 렌더링 (PHP 확장, 디렉토리 권한)
*/
function renderMultiItemCard(title, statusClass, bodyHtml, isRequired, requiredPermission) {
const statusIcon = getStatusIcon(statusClass);
const badgeText = isRequired ? lang('badge_required') : lang('badge_optional');
const badgeClass = isRequired ? 'badge-required' : 'badge-optional';
return `
<div class="requirement-card">
<div class="requirement-card-header">
<div class="header-left">
${statusIcon}
<h3 class="requirement-card-title">${title}</h3>
${requiredPermission ? `<span class="requirement-text">${requiredPermission}</span>` : ''}
</div>
<div class="requirement-badge ${badgeClass}">${badgeText}</div>
</div>
<div class="requirement-card-body">
${bodyHtml}
</div>
</div>
`;
}
/**
* 상태 아이콘 생성 (SVG 아이콘 사용)
*/
function getStatusIcon(statusClass) {
let iconType = '';
if (statusClass === 'status-pass') {
iconType = 'completed';
} else if (statusClass === 'status-fail') {
iconType = 'failed';
} else if (statusClass === 'status-warning') {
iconType = 'warning';
}
return `<span class="status-icon ${statusClass}">${getIconSvg(iconType)}</span>`;
}
/**
* PHP 확장 항목 렌더링
*/
function renderExtensionItem(name, installed, required) {
const statusClass = installed ? 'status-pass' : (required ? 'status-fail' : 'status-warning');
const statusIcon = getStatusIcon(statusClass);
return `
<div class="extension-item ${statusClass}">
${statusIcon}
<span class="extension-name">${name}</span>
</div>
`;
}
/**
* 디렉토리 권한 항목 렌더링
*/
function renderDirectoryItem(path, info) {
const statusClass = info.passed ? 'status-pass' : 'status-fail';
const statusIcon = getStatusIcon(statusClass);
let permissionDisplay = '';
let subdirectoryErrorHtml = '';
if (info.passed) {
// 통과한 경우
permissionDisplay = info.permissions;
} else {
// 실패한 경우
// 부모 디렉토리 권한 표시
if (info.error_type && info.error_type !== 'subdirectory_issues') {
// 부모에 문제가 있으면 부모 옆에 에러 아이콘 표시
permissionDisplay = `${info.permissions} <span class="permission-error-icon" data-tooltip="${lang('fix_permission')}">ⓘ</span>`;
} else {
// 부모는 OK, 하위만 문제
permissionDisplay = info.permissions;
}
// 하위 디렉토리 문제 확인
if (info.has_subdirectory_issues && info.failed_subdirectories && info.failed_subdirectories.length > 0) {
// 각 하위 디렉토리를 개별 div로 표시 (부모 경로 제거하여 상대 경로로 표시)
const subdirItems = info.failed_subdirectories.map(subdir => {
// subdir는 이제 {path: '...', permissions: '...'} 형태
const subdirPath = typeof subdir === 'string' ? subdir : subdir.path;
const subdirPerms = typeof subdir === 'string' ? '' : subdir.permissions;
// 부모 경로 제거 (예: storage/framework/cache -> framework/cache)
const relativePath = subdirPath.replace(path + '/', '');
return `
<div class="directory-subdirectory-item">
<span class="subdir-name">⤷ ${escapeHtml(relativePath)}</span>
<span class="subdir-permission">
${subdirPerms} <span class="permission-error-icon" data-tooltip="${lang('fix_permission')}">ⓘ</span>
</span>
</div>
`;
}).join('');
subdirectoryErrorHtml = `
<div class="directory-subdirectory-error">
${subdirItems}
</div>
`;
}
}
// 소유자:소유그룹 표시 (posix 미지원 환경에서는 null이므로 생략)
let ownerDisplay = '';
if (info.owner || info.group) {
const owner = info.owner || '?';
const group = info.group || '?';
ownerDisplay = `<span class="directory-owner">${escapeHtml(owner)}:${escapeHtml(group)}</span>`;
}
return `
<div class="directory-item-wrapper">
<div class="directory-item ${statusClass}">
<div class="directory-left">
${statusIcon}
<span class="directory-name">${path}</span>
</div>
<div class="directory-right">
${ownerDisplay}
<span class="directory-permission">${permissionDisplay}</span>
</div>
</div>
${subdirectoryErrorHtml}
</div>
`;
}
/**
* 요구사항 재검증
*/
function recheckRequirements() {
// 페이지 리로드로 캐시 문제 해결
window.location.reload();
}
/**
* 요구사항 검증 자동 실행
*/
function initRequirementsCheck() {
if (document.getElementById('requirements-result')) {
checkRequirements();
}
}
// =========================================================================
// Step 3: DB 연결 테스트
// =========================================================================
/**
* Read DB 필드 토글
*/
function toggleReadDbFields() {
const useReadDb = document.getElementById('use-read-db');
const readDbSection = document.getElementById('read-db-section');
if (useReadDb && readDbSection) {
if (useReadDb.checked) {
// Read DB 필드 표시
readDbSection.classList.remove('hidden');
} else {
// Read DB 필드 숨김
readDbSection.classList.add('hidden');
// 입력값 초기화
const readDbInputs = readDbSection.querySelectorAll('input');
readDbInputs.forEach(input => {
input.value = '';
});
// Read DB 테스트 플래그 제거
sessionStorage.removeItem('db_read_tested');
sessionStorage.removeItem('db_read_hash');
// FormValidator 업데이트
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbTestValid('read', false);
}
// 결과 메시지 숨김
const readResultDiv = document.getElementById('db-read-test-result');
if (readResultDiv) {
readResultDiv.classList.add('hidden');
readResultDiv.innerHTML = '';
}
}
}
}
/**
* DB 필드 해시 계산 (간단한 해시 함수)
*
* @param {Object} fields {host, port, database, username}
* @return {string} 해시 문자열
*/
function calculateDbHash(fields) {
const str = JSON.stringify([
fields.host || '',
fields.port || '',
fields.database || '',
fields.username || ''
]);
// 간단한 해시 함수 (PHP의 md5와 동일하지 않지만 변경 감지용으로 충분)
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(36);
}
/**
* DB 연결 테스트 (Write 또는 Read)
*/
async function testDatabaseConnection(type) {
const form = document.getElementById('config-form');
if (!form) return;
const formData = new FormData(form);
const prefix = type === 'write' ? 'db_write' : 'db_read';
const dbData = {
type: type,
host: formData.get(`${prefix}_host`),
port: formData.get(`${prefix}_port`),
database: formData.get(`${prefix}_database`),
username: formData.get(`${prefix}_username`),
password: formData.get(`${prefix}_password`),
// 기존 테이블 감지 시 g7 시그니처 비교에 사용 (Write DB만 의미 있음)
db_prefix: formData.get('db_prefix') || 'g7_',
};
const resultDiv = document.getElementById(`db-${type}-test-result`);
if (!resultDiv) return;
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `<div class="loading-text">${lang('testing_connection')}</div>`;
try {
// 절대 URL 생성
const apiUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/check-configuration.php?action=test-db`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dbData)
});
const result = await response.json();
if (result.success) {
// Write DB 기존 테이블 감지: 인라인 상세 카드로 표시
// 요구사항: 모달 대신 페이지 본문에 즉시 노출, 백업 동의 체크박스로
// 다음 단계 진행 여부 제어. 실제 삭제는 Step 5의 db_cleanup task에서 수행.
let existingCardHtml = '';
if (type === 'write' && result.existing_tables && result.existing_tables.has_tables) {
const info = result.existing_tables;
const sev = info.severity;
const titleKey = {
g7_existing: 'db_existing_g7_title',
foreign_data: 'db_existing_foreign_title',
mixed: 'db_existing_mixed_title',
}[sev] || 'db_existing_generic_title';
const descKey = {
g7_existing: 'db_existing_g7_desc',
foreign_data: 'db_existing_foreign_desc',
mixed: 'db_existing_mixed_desc',
}[sev] || 'db_existing_generic_desc';
const tablesList = (info.all_tables || []).slice(0, 20).join(', ');
const dbHost = document.querySelector('input[name="db_write_host"]')?.value || 'localhost';
const dbName = document.querySelector('input[name="db_write_database"]')?.value || '';
const dbUser = document.querySelector('input[name="db_write_username"]')?.value || 'root';
// -p 단독으로 사용해 mysqldump가 비밀번호 prompt를 띄우도록 함 (값 노출 방지)
// --databases 옵션으로 db 이름을 명확히 분리하여 -p와 혼동 방지
const backupCmd = `mysqldump -h ${dbHost} -u ${dbUser} -p --databases ${dbName} > backup_$(date +%Y%m%d_%H%M%S).sql`;
// 현재 동의 상태 복원 (DB 필드 변경 없이 테스트 재실행 시 체크 유지)
const alreadyConsented = sessionStorage.getItem('existing_db_action') === 'drop_tables';
existingCardHtml = `
<div class="alert alert-warning db-existing-tables-card" data-severity="${escapeHtml(sev)}">
<div class="db-existing-header">
<strong>⚠ ${escapeHtml(lang(titleKey))}</strong>
</div>
<p class="db-existing-desc">${escapeHtml(lang(descKey))}</p>
<p class="db-existing-tables-label"><strong>${escapeHtml(lang('db_existing_tables_list'))}</strong></p>
<pre class="db-existing-tables-list">${escapeHtml(tablesList)}</pre>
<p class="db-existing-backup-label"><strong>${escapeHtml(lang('db_backup_guide'))}</strong></p>
<div class="code-box">
<pre>${escapeHtml(backupCmd)}</pre>
<button type="button" class="btn-copy" onclick="copyPermissionCommand(this)">${escapeHtml(lang('copy_command'))}</button>
</div>
<label class="db-backup-confirm-checkbox">
<input type="checkbox" id="db-backup-confirmed" onchange="onDbBackupConsentChange(this)" ${alreadyConsented ? 'checked' : ''}>
${escapeHtml(lang('db_backup_confirmed'))}
</label>
</div>
`;
window.__g7_existing_tables = info;
// FormValidator에 동의 요구 등록
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbCleanupRequired(true);
FormValidator.setDbCleanupConsented(alreadyConsented);
}
} else if (type === 'write') {
// 빈 DB → 동의 요구 해제 + sessionStorage 정리
window.__g7_existing_tables = null;
sessionStorage.removeItem('existing_db_action');
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbCleanupRequired(false);
FormValidator.setDbCleanupConsented(false);
}
}
resultDiv.innerHTML = `
<div class="alert alert-success">
${getIconSvg('completed', '24px', 'test-result-icon')}
<span>${escapeHtml(result.message)}</span>
</div>
${existingCardHtml}
`;
// 현재 DB 필드 해시 계산
const currentHash = calculateDbHash({
host: dbData.host,
port: dbData.port,
database: dbData.database,
username: dbData.username
});
// DB 테스트 성공 시 sessionStorage에 저장 (필드 해시 포함)
if (type === 'write') {
sessionStorage.setItem('db_write_tested', 'true');
sessionStorage.setItem('db_write_hash', currentHash);
// 기존 테이블 감지 정보도 sessionStorage에 저장 (Step 5까지 보존)
if (result.existing_tables) {
sessionStorage.setItem('db_existing_tables', JSON.stringify(result.existing_tables));
} else {
sessionStorage.removeItem('db_existing_tables');
}
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbTestValid('write', true);
}
} else if (type === 'read') {
sessionStorage.setItem('db_read_tested', 'true');
sessionStorage.setItem('db_read_hash', currentHash);
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbTestValid('read', true);
}
}
} else {
resultDiv.innerHTML = `
<div class="alert alert-error">
${getIconSvg('failed', '24px', 'test-result-icon')}
<span>${escapeHtml(result.message)}</span>
</div>
`;
// DB 테스트 실패 시 FormValidator 업데이트
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbTestValid(type, false);
}
}
} catch (error) {
resultDiv.innerHTML = `
<div class="alert alert-error">
${getIconSvg('failed', '24px', 'test-result-icon')}
<span>${lang('connection_failed_prefix')} ${escapeHtml(error.message)}</span>
</div>
`;
// DB 테스트 실패 시 FormValidator 업데이트
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbTestValid(type, false);
}
}
}
/**
* DB 테스트 관련 이벤트 초기화
*/
function initDatabaseTest() {
const useReadDb = document.getElementById('use-read-db');
if (useReadDb) {
useReadDb.addEventListener('change', toggleReadDbFields);
}
// 전역 함수로 노출 (인라인 onclick에서 사용)
window.testDatabaseConnection = testDatabaseConnection;
window.toggleReadDbFields = toggleReadDbFields;
}
// =========================================================================
// Step 3: 실시간 필드 검증
// =========================================================================
/**
* FormValidator - 필드 검증 상태 추적 객체
*/
const FormValidator = {
fields: {
app_name: false,
app_url: false,
admin_email: false,
admin_name: false,
admin_password: false,
admin_password_confirm: false
},
dbTests: {
write: false,
read: false
},
// 기존 DB 테이블 감지 시 삭제 동의 추적
dbCleanup: {
required: false, // Write DB 테스트 결과 기존 테이블 감지 여부
consented: false, // 사용자가 백업 완료 + 삭제 동의 체크 여부
},
/**
* 필드 검증 상태 설정
*/
setFieldValid(fieldName, isValid) {
this.fields[fieldName] = isValid;
this.updateSubmitButton();
},
/**
* DB 테스트 상태 설정
*/
setDbTestValid(type, isValid) {
this.dbTests[type] = isValid;
this.updateSubmitButton();
},
/**
* 기존 DB 테이블 감지 상태 설정
*/
setDbCleanupRequired(required) {
this.dbCleanup.required = !!required;
this.updateSubmitButton();
},
/**
* 백업 완료 + 삭제 동의 상태 설정
*/
setDbCleanupConsented(consented) {
this.dbCleanup.consented = !!consented;
this.updateSubmitButton();
},
/**
* Step 3 제출 버튼의 disabled 상태를 폼 유효성에 맞춰 동기화.
* isFormValid()가 false면 버튼 비활성화 + 적절한 title 안내.
*/
updateSubmitButton() {
const submitBtn = document.getElementById('submit-btn');
if (!submitBtn) return;
// Step 4(확장 선택)에서도 같은 id를 쓰는데, 그쪽은 자체 검증 함수가 처리하므로
// Step 3 (config-form) 컨텍스트에서만 동작
const configForm = document.getElementById('config-form');
if (!configForm) return;
const valid = this.isFormValid();
submitBtn.disabled = !valid;
if (!valid) {
if (!this.isDbCleanupConsentSatisfied()) {
submitBtn.title = lang('error_db_cleanup_consent_required');
} else if (!this.areDbTestsValid()) {
submitBtn.title = lang('error_write_db_not_tested');
} else {
submitBtn.title = lang('validation_incomplete_alert');
}
} else {
submitBtn.title = '';
}
},
/**
* 모든 필드가 유효한지 확인
*/
areAllFieldsValid() {
return Object.values(this.fields).every(valid => valid);
},
/**
* DB 테스트 완료 여부 확인
*/
areDbTestsValid() {
const useReadDb = document.getElementById('use-read-db');
const writeValid = this.dbTests.write;
const readValid = useReadDb && useReadDb.checked ? this.dbTests.read : true;
return writeValid && readValid;
},
/**
* 기존 DB 테이블 삭제 동의 충족 여부
* required=false면 항상 통과, required=true면 consented=true 필요
*/
isDbCleanupConsentSatisfied() {
return !this.dbCleanup.required || this.dbCleanup.consented;
},
/**
* 전체 폼이 유효한지 확인
*/
isFormValid() {
return this.areAllFieldsValid() && this.areDbTestsValid() && this.isDbCleanupConsentSatisfied();
},
/**
* 초기 검증 상태 로드 (PHP 세션과 동기화)
*/
loadInitialState() {
// PHP 세션에서 전달된 DB 테스트 플래그 사용 (window.DB_TEST_FLAGS)
const phpFlags = window.DB_TEST_FLAGS || { write: false, read: false, writeHash: '', readHash: '' };
// Write DB: PHP 세션 플래그 확인 및 필드 변경 감지
if (phpFlags.write) {
// sessionStorage의 해시와 PHP에서 계산한 해시 비교
const storedHash = sessionStorage.getItem('db_write_hash');
if (storedHash && storedHash === phpFlags.writeHash) {
// 필드가 변경되지 않았음 - 테스트 유효
this.dbTests.write = true;
sessionStorage.setItem('db_write_tested', 'true');
} else {
// 필드가 변경되었거나 해시가 없음 - 테스트 무효화
this.dbTests.write = false;
sessionStorage.removeItem('db_write_tested');
sessionStorage.removeItem('db_write_hash');
}
} else {
// PHP 세션에 플래그가 없음 - 테스트 무효화
this.dbTests.write = false;
sessionStorage.removeItem('db_write_tested');
sessionStorage.removeItem('db_write_hash');
}
// Read DB: PHP 세션 플래그 확인 및 필드 변경 감지
if (phpFlags.read) {
const storedHash = sessionStorage.getItem('db_read_hash');
if (storedHash && storedHash === phpFlags.readHash) {
// 필드가 변경되지 않았음 - 테스트 유효
this.dbTests.read = true;
sessionStorage.setItem('db_read_tested', 'true');
} else {
// 필드가 변경되었거나 해시가 없음 - 테스트 무효화
this.dbTests.read = false;
sessionStorage.removeItem('db_read_tested');
sessionStorage.removeItem('db_read_hash');
}
} else {
// PHP 세션에 플래그가 없음 - 테스트 무효화
this.dbTests.read = false;
sessionStorage.removeItem('db_read_tested');
sessionStorage.removeItem('db_read_hash');
}
// 초기 필드 값이 있으면 검증
Object.keys(this.fields).forEach(fieldName => {
const field = document.getElementById(fieldName);
if (field && field.value.trim()) {
validateField(field);
}
});
// DB 필드 변경 감지 리스너 추가
this.addDbFieldChangeListeners();
// 초기 submit 버튼 disabled 상태 동기화
this.updateSubmitButton();
},
/**
* DB 필드 변경 감지 리스너 추가
*/
addDbFieldChangeListeners() {
// Write DB 필드
const writeFields = ['db_write_host', 'db_write_port', 'db_write_database', 'db_write_username', 'db_write_password'];
writeFields.forEach(fieldName => {
const field = document.querySelector(`[name="${fieldName}"]`);
if (field) {
field.addEventListener('input', () => {
// Write DB 필드가 변경되면 테스트 플래그 무효화
this.setDbTestValid('write', false);
sessionStorage.removeItem('db_write_tested');
sessionStorage.removeItem('db_write_hash');
// 테스트 결과 메시지 숨김
const resultDiv = document.getElementById('db-write-test-result');
if (resultDiv) {
resultDiv.classList.add('hidden');
resultDiv.innerHTML = '';
}
});
}
});
// Read DB 필드
const readFields = ['db_read_host', 'db_read_port', 'db_read_database', 'db_read_username', 'db_read_password'];
readFields.forEach(fieldName => {
const field = document.querySelector(`[name="${fieldName}"]`);
if (field) {
field.addEventListener('input', () => {
// Read DB 필드가 변경되면 테스트 플래그 무효화
this.setDbTestValid('read', false);
sessionStorage.removeItem('db_read_tested');
sessionStorage.removeItem('db_read_hash');
// 테스트 결과 메시지 숨김
const resultDiv = document.getElementById('db-read-test-result');
if (resultDiv) {
resultDiv.classList.add('hidden');
resultDiv.innerHTML = '';
}
});
}
});
}
};
/**
* 필드 에러 메시지 표시
*/
function showFieldError(field, message) {
const errorDiv = document.getElementById(field.id + '-error');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.classList.add('active');
}
field.classList.add('error');
field.setAttribute('aria-invalid', 'true');
FormValidator.setFieldValid(field.id, false);
}
/**
* 필드 에러 메시지 제거
*/
function clearFieldError(field) {
const errorDiv = document.getElementById(field.id + '-error');
if (errorDiv) {
errorDiv.textContent = '';
errorDiv.classList.remove('active');
}
field.classList.remove('error');
field.setAttribute('aria-invalid', 'false');
FormValidator.setFieldValid(field.id, true);
}
/**
* DB 최고권한 계정명 여부 판정
*
* 목록은 서버(App\Support\PrivilegedDatabaseAccounts::BLOCKED)에서 내려온 값을
* 그대로 쓴다. JS 에 목록을 중복 정의하면 서버와 어긋날 수 있다.
* 비교는 서버와 동일하게 소문자·트림 기준.
*
* @param {string} username 검사할 DB 사용자명
* @returns {boolean} 최고권한 계정이면 true
*/
function isPrivilegedDbAccount(username) {
var blocked = window.INSTALLER_BLOCKED_DB_ACCOUNTS;
// 서버 목록이 없으면 클라이언트 판정을 포기한다 (서버 검증이 최종 방어선).
if (!Array.isArray(blocked)) {
return false;
}
return blocked.indexOf(String(username || '').trim().toLowerCase()) !== -1;
}
/**
* 최고권한 계정 차단 메시지를 생성한다.
*
* JS 의 lang() 은 서버 lang() 과 달리 치환을 지원하지 않으므로 호출부에서 수동 치환한다.
*
* @param {string} username 입력된 DB 사용자명
* @returns {string} 치환된 메시지
*/
function privilegedDbAccountMessage(username) {
return getLangMessage('error_db_username_privileged').replace(':username', username);
}
/**
* 필드 검증
*/
function validateField(field) {
const fieldName = field.name;
const value = field.value.trim();
// 필수 필드 검증
if (field.hasAttribute('required') && !value) {
showFieldError(field, getLangMessage('error_field_required'));
return false;
}
// 이메일 검증
if (fieldName === 'admin_email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
showFieldError(field, getLangMessage('error_admin_email_invalid'));
return false;
}
}
// 비밀번호 최소 길이 검증
if (fieldName === 'admin_password') {
if (value.length < 8) {
showFieldError(field, getLangMessage('error_admin_password_min'));
return false;
}
}
// 비밀번호 확인 검증
if (fieldName === 'admin_password_confirm') {
const passwordField = document.getElementById('admin_password');
if (passwordField && value !== passwordField.value) {
showFieldError(field, getLangMessage('error_password_mismatch'));
return false;
}
}
// 사이트 이름 검증
if (fieldName === 'app_name' && !value) {
showFieldError(field, getLangMessage('error_app_name_required'));
return false;
}
// 사이트 URL 검증
if (fieldName === 'app_url' && !value) {
showFieldError(field, getLangMessage('error_app_url_required'));
return false;
}
// DB 사용자명 검증 — 최고권한 계정은 보안상 사용할 수 없다.
if ((fieldName === 'db_write_username' || fieldName === 'db_read_username') && value) {
if (isPrivilegedDbAccount(value)) {
showFieldError(field, privilegedDbAccountMessage(value));
return false;
}
}
// DB Prefix 검증 (선택 사항이지만, 입력 시 형식 검증)
if (fieldName === 'db_prefix' && value) {
// 영문 소문자로 시작해야 함
if (!/^[a-z]/.test(value)) {
showFieldError(field, getLangMessage('validation_starts_with_alpha').replace(':field', getLangMessage('fields.db_prefix')));
return false;
}
// 영문 소문자, 숫자, 언더스코어만 허용
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
showFieldError(field, getLangMessage('validation_alpha_num_underscore').replace(':field', getLangMessage('fields.db_prefix')));
return false;
}
// 접두사 길이 제한 — 길면 자동 생성 인덱스명이 DB 한도(64자)를 초과한다.
// 서버 MAX_DB_PREFIX_LENGTH 와 동일한 값을 유지한다.
var maxPrefixLength = field.maxLength && field.maxLength > 0 ? field.maxLength : 6;
if (value.length > maxPrefixLength) {
showFieldError(field, getLangMessage('error_db_prefix_too_long')
.replace(':max', maxPrefixLength)
.replace(':current', value.length));
return false;
}
}
// 관리자 이름 검증
if (fieldName === 'admin_name' && !value) {
showFieldError(field, getLangMessage('error_admin_name_required'));
return false;
}
// 검증 통과
clearFieldError(field);
return true;
}
/**
* 다국어 메시지 가져오기
*/
function getLangMessage(key) {
return lang(key);
}
/**
* 실시간 검증 초기화
*/
/**
* 자산 URL 방식을 브라우저에서 판정한다 (이슈 #486 §6·§7).
*
* Step 2 요구사항 카드와 Step 3 설정 폼이 **같은 함수를 쓴다.** 판정 로직을 두 벌
* 두면 한쪽만 고쳐져 화면마다 다른 답을 내놓는다 (이 이슈에서 이미 URL 생성부가
* 그렇게 어긋났다).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라 프리픽스
* location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 응답이
* PHP 에 도달하지 못한 채 404 가 된다. 설치 시점에 판정해 두지 않으면 설치 직후
* 첫 화면부터 백지가 된다.
*
* 판정은 **쌍으로** 던진다. 단일 프로브는 "PHP 자체가 죽음" 과 구분되지 않는다.
*
* probe.js 실패 + probe 성공 = 정적 블록 가로채기 확정 → extensionless
* 둘 다 성공 = extension
* 둘 다 실패 = PHP/라우팅 문제 (모드 문제 아님) → 판정 보류
*
* 성공 판정은 상태코드가 아니라 **본문의 매직 토큰**으로 한다. 상태코드만 보면
* "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서
* 영원히 오판한다.
*/
async function probeAssetUrlMode(base) {
const TOKEN = 'G7_ASSET_PROBE_OK';
const root = (base || '').replace(/\/+$/, '');
/**
* 프로브 1건을 던져 매직 토큰 포함 여부를 반환한다.
*/
const probe = async (path) => {
try {
const res = await fetch(`${root}${path}`, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) return false;
// Content-Type 도 함께 본다 (L6). 토큰만 검사해도 200+HTML 오판은
// 걸러지지만, 계획서는 두 신호를 모두 요구한다.
const contentType = res.headers.get('content-type') || '';
if (!/javascript|ecmascript/i.test(contentType)) return false;
return (await res.text()).includes(TOKEN);
} catch (e) {
return false;
}
};
const [withExt, withoutExt] = await Promise.all([
probe('/api/system/asset-probe.js'),
probe('/api/system/asset-probe'),
]);
if (withExt && withoutExt) {
return 'extension';
}
if (!withExt && withoutExt) {
return 'extensionless';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 문자열로 판정 보류.
return '';
}
/**
* Laravel 앱 루트 경로를 반환한다 (`INSTALLER_BASE_URL` 은 항상 `/install` 로 끝난다).
*
* Step 2 에는 `app_url` 입력이 아직 없으므로 현재 origin 기준으로 조립한다.
*/
function getAppRootUrl() {
const base = (window.INSTALLER_BASE_URL || '').replace(/\/install$/, '');
return `${window.location.origin}${base}`;
}
/**
* Step 2 요구사항 카드의 자산 URL 방식 항목을 프로브 결과로 갱신한다 (§7).
*
* 서버는 이 값을 판정할 수 없어(loopback 이 vhost·프록시를 우회) 카드가 먼저
* "확인 중" 으로 그려진 뒤 여기서 채워진다. 통과/실패 게이트가 아니라 안내 항목이라
* `failedRequirements` 에 넣지 않는다 — 어느 방식이든 정상 동작이다.
*/
async function refreshAssetUrlModeCard() {
const slot = document.getElementById('asset-url-mode-card');
if (!slot) return;
const detected = await probeAssetUrlMode(getAppRootUrl());
// 확장자 미사용은 "서버가 확장자 주소를 가로채고 있다" 는 신호이므로 눈에 띄게 둔다.
// 판정 불가도 마찬가지 — 설치 후 백지 화면의 예고일 수 있다.
const statusClass = detected === 'extension' ? 'status-pass' : 'status-warning';
const label = detected === 'extension'
? lang('asset_url_mode_extension')
: (detected === 'extensionless' ? lang('asset_url_mode_extensionless') : lang('asset_url_mode_unknown'));
// 경고 상태(확장자 미사용 자동 선택 / 확인 불가)는 "왜 그런지" 를 설명 문구로
// 함께 보인다 — 짧은 라벨("확인 불가")만으로는 어떤 상황인지 알 수 없다.
// "확인 불가" 는 두 프로브가 모두 실패한 상태라 에셋 방식 문제가 아니라
// 앱 미응답·프록시·CSP 등 다른 원인일 수 있으므로 그 내용을 상세히 안내한다.
let note = '';
if (detected === 'extensionless') {
note = lang('asset_url_mode_detected_extensionless');
} else if (detected === '') {
note = lang('asset_url_mode_detected_unavailable');
}
const noteHtml = note ? `<p class="fix-guide-hint">${note}</p>` : '';
// 카드 내부를 부분 수정하지 않고 통째로 다시 그린다 — 아이콘·수식자 클래스가
// renderSingleItemCard 안에서 statusClass 로 함께 결정되므로, 밖에서 일부만
// 건드리면 아이콘과 카드 색이 어긋난다.
slot.innerHTML = renderSingleItemCard(lang('asset_url_mode'), statusClass, label, '', false) + noteHtml;
}
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
const detected = await probeAssetUrlMode(base);
field.value = detected;
// 수동 override — 감지 결과를 기본 선택으로 채우고 관리자가 바꿀 수 있게 한다.
// 프로브가 CSP/프록시 등으로 둘 다 실패하면 자동 판정이 불가하므로,
// 손댈 수단이 없으면 설치를 마친 뒤에야 문제를 알게 된다.
const select = document.getElementById('asset_url_mode_select');
const status = document.getElementById('asset_url_mode_status');
if (select) {
select.value = detected || 'extension';
select.addEventListener('change', function () {
field.value = this.value;
});
// 감지 실패 시에도 select 값이 hidden 에 반영되도록 초기 동기화
field.value = select.value;
}
if (status) {
status.textContent = status.getAttribute(
detected ? `data-msg-${detected}` : 'data-msg-unavailable'
) || '';
status.classList.remove('hidden');
}
}
function initRealTimeValidation() {
// Step 3 (config-form)이 있는 경우에만 실행
const configForm = document.getElementById('config-form');
if (!configForm) return;
// 자산 URL 방식 자동 감지 (비차단 — 실패해도 설치 진행에 지장 없음)
detectAssetUrlMode();
const fieldsToValidate = [
'app_name',
'app_url',
'db_prefix',
'admin_email',
'admin_name',
'admin_password',
'admin_password_confirm'
];
fieldsToValidate.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
validateField(this);
});
// 비밀번호 확인 필드는 비밀번호 필드 변경 시에도 재검증
if (fieldId === 'admin_password') {
field.addEventListener('input', function() {
const confirmField = document.getElementById('admin_password_confirm');
if (confirmField && confirmField.value) {
validateField(confirmField);
}
});
}
}
});
// 초기 상태 로드
FormValidator.loadInitialState();
// 버튼 클릭 시 검증
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', function(e) {
// 폼이 유효하지 않으면 제출 방지 및 경고 표시
if (!FormValidator.isFormValid()) {
e.preventDefault();
showValidationWarning();
}
});
}
}
/**
* 검증 실패 경고 표시
*/
function showValidationWarning() {
const errors = [];
let firstInvalidField = null;
// 필드 검증 상태 확인 (순서대로)
Object.keys(FormValidator.fields).forEach(fieldName => {
if (!FormValidator.fields[fieldName]) {
const field = document.getElementById(fieldName);
if (field) {
// 첫 번째 실패 필드 저장
if (!firstInvalidField) {
firstInvalidField = field;
}
const label = field.closest('.form-group').querySelector('.form-label');
errors.push(label ? label.textContent.replace(' *', '') : fieldName);
}
}
});
// DB 테스트 상태 확인
if (!FormValidator.dbTests.write) {
errors.push(getLangMessage('error_write_db_not_tested'));
// Write DB 테스트가 안 되었으면 첫 번째 Write DB 필드로 포커스
if (!firstInvalidField) {
firstInvalidField = document.querySelector('[name="db_write_host"]');
}
}
const useReadDb = document.getElementById('use-read-db');
if (useReadDb && useReadDb.checked && !FormValidator.dbTests.read) {
errors.push(getLangMessage('error_read_db_not_tested'));
// Read DB 테스트가 안 되었고 다른 실패 필드가 없으면 Read DB 필드로 포커스
if (!firstInvalidField && FormValidator.dbTests.write) {
firstInvalidField = document.querySelector('[name="db_read_host"]');
}
}
// 기존 DB 테이블 감지 동의 미체크 시 경고
if (!FormValidator.isDbCleanupConsentSatisfied()) {
errors.push(lang('error_db_cleanup_consent_required'));
if (!firstInvalidField) {
firstInvalidField = document.getElementById('db-backup-confirmed');
}
}
// Alert 표시 (간단한 메시지만)
alert(lang('validation_incomplete_alert'));
// 경고 박스 표시 (기존 alert 박스가 있다면 사용, 없으면 생성)
let warningBox = document.querySelector('.validation-warning');
if (!warningBox) {
warningBox = document.createElement('div');
warningBox.className = 'alert alert-error validation-warning';
warningBox.style.marginBottom = '1.5rem';
const form = document.getElementById('config-form');
if (form) {
form.insertBefore(warningBox, form.firstChild);
}
}
warningBox.innerHTML = `
<strong>${lang('validation_incomplete_title')}</strong>
<ul class="alert-list">
${errors.map(error => `<li>${escapeHtml(error)}</li>`).join('')}
</ul>
`;
// 페이지 타이틀로 스크롤 (경고 박스가 잘 보이도록)
const titleElement = document.querySelector('.installer-title');
if (titleElement) {
titleElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// 첫 번째 실패 필드로 포커스 이동
if (firstInvalidField) {
setTimeout(() => {
firstInvalidField.focus();
}, 400); // 스크롤 완료 후 포커스
}
}
/**
* Step 3 폼 유효성 검증
*/
function validateStep3Form() {
const form = document.getElementById('config-form');
if (!form) return true;
const errors = [];
// Write DB 필수 필드 확인
const dbWriteHost = form.querySelector('[name="db_write_host"]');
const dbWriteDatabase = form.querySelector('[name="db_write_database"]');
const dbWriteUsername = form.querySelector('[name="db_write_username"]');
if (!dbWriteHost || !dbWriteHost.value.trim()) {
errors.push({ field: dbWriteHost, message: lang('error_db_host_required') });
}
if (!dbWriteDatabase || !dbWriteDatabase.value.trim()) {
errors.push({ field: dbWriteDatabase, message: lang('error_db_name_required') });
}
if (!dbWriteUsername || !dbWriteUsername.value.trim()) {
errors.push({ field: dbWriteUsername, message: lang('error_db_username_required') });
} else if (isPrivilegedDbAccount(dbWriteUsername.value)) {
errors.push({
field: dbWriteUsername,
message: privilegedDbAccountMessage(dbWriteUsername.value.trim()),
});
}
// Read DB 사용 시 Read DB 필드 확인
const useReadDb = document.getElementById('use-read-db');
if (useReadDb && useReadDb.checked) {
const dbReadHost = form.querySelector('[name="db_read_host"]');
const dbReadDatabase = form.querySelector('[name="db_read_database"]');
const dbReadUsername = form.querySelector('[name="db_read_username"]');
if (!dbReadHost || !dbReadHost.value.trim()) {
errors.push({ field: dbReadHost, message: lang('error_db_host_required') });
}
if (!dbReadDatabase || !dbReadDatabase.value.trim()) {
errors.push({ field: dbReadDatabase, message: lang('error_db_name_required') });
}
if (!dbReadUsername || !dbReadUsername.value.trim()) {
errors.push({ field: dbReadUsername, message: lang('error_db_username_required') });
} else if (isPrivilegedDbAccount(dbReadUsername.value)) {
errors.push({
field: dbReadUsername,
message: privilegedDbAccountMessage(dbReadUsername.value.trim()),
});
}
}
// 사이트 설정 필수 필드 확인
const appName = form.querySelector('[name="app_name"]');
const appUrl = form.querySelector('[name="app_url"]');
if (!appName || !appName.value.trim()) {
errors.push({ field: appName, message: lang('error_app_name_required') });
}
if (!appUrl || !appUrl.value.trim()) {
errors.push({ field: appUrl, message: lang('error_app_url_required') });
}
// 관리자 계정 필수 필드 확인
const adminName = form.querySelector('[name="admin_name"]');
const adminEmail = form.querySelector('[name="admin_email"]');
const adminPassword = form.querySelector('[name="admin_password"]');
const adminPasswordConfirm = form.querySelector('[name="admin_password_confirm"]');
if (!adminName || !adminName.value.trim()) {
errors.push({ field: adminName, message: lang('error_admin_name_required') });
}
// 이메일 형식 검증
if (!adminEmail || !adminEmail.value.trim()) {
errors.push({ field: adminEmail, message: lang('error_admin_email_invalid') });
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(adminEmail.value.trim())) {
errors.push({ field: adminEmail, message: lang('error_admin_email_invalid') });
}
}
// 비밀번호 최소 8자 확인
if (!adminPassword || !adminPassword.value) {
errors.push({ field: adminPassword, message: lang('error_admin_password_required') });
} else if (adminPassword.value.length < 8) {
errors.push({ field: adminPassword, message: lang('error_admin_password_min') });
}
// 비밀번호 일치 여부 확인
if (!adminPasswordConfirm || !adminPasswordConfirm.value) {
errors.push({ field: adminPasswordConfirm, message: lang('error_admin_password_confirm_required') });
} else if (adminPassword && adminPassword.value !== adminPasswordConfirm.value) {
errors.push({ field: adminPasswordConfirm, message: lang('error_password_mismatch') });
}
// DB 연결 테스트 완료 확인
const writeTestPassed = sessionStorage.getItem('db_write_tested') === 'true';
if (!writeTestPassed) {
errors.push({ field: null, message: lang('error_db_not_tested') });
}
// Read DB 사용 시 Read DB 연결 테스트 완료 확인
if (useReadDb && useReadDb.checked) {
const readTestPassed = sessionStorage.getItem('db_read_tested') === 'true';
if (!readTestPassed) {
errors.push({ field: null, message: lang('error_read_db_not_tested') });
}
}
// PHP CLI / Composer 검증 확인
if (window.CliValidator) {
const cliSection = document.getElementById('php-cli-section');
const cliRequired = window.CliValidator.cliRequired;
const cliSectionVisible = cliSection && !cliSection.classList.contains('hidden');
if (cliRequired || cliSectionVisible) {
if (!window.CliValidator.phpVerified) {
errors.push({ field: document.getElementById('php_binary'), message: lang('error_php_cli_not_verified') });
}
// vendor_mode='bundled' 에서는 composer 실행이 불필요하므로 검증 스킵
if (window.CliValidator.isComposerRequired() && !window.CliValidator.composerVerified) {
errors.push({ field: document.getElementById('composer_binary'), message: lang('error_composer_not_verified') });
}
}
}
// 에러가 있으면 표시하고 제출 방지
if (errors.length > 0) {
alert(errors.map(e => e.message).join('\n'));
// 첫 번째 에러 필드에 포커스
if (errors[0].field) {
errors[0].field.focus();
}
return false;
}
return true;
}
/**
* Step 3 폼 제출 이벤트 초기화
*/
function initStep3FormSubmit() {
const form = document.getElementById('config-form');
if (form) {
form.addEventListener('submit', function(e) {
if (!validateStep3Form()) {
e.preventDefault();
return false;
}
});
}
}
// =========================================================================
// Step 4: 확장 선택
// =========================================================================
let extensionsData = null;
let selectedExtensions = {
admin_templates: [],
user_templates: [],
modules: [],
plugins: [],
language_packs: []
};
/** 의존성에 의해 자동 선택된 확장 identifier 집합 */
const autoSelectedSet = new Set();
async function loadExtensions() {
const form = document.getElementById('extension-form');
const loadingEl = document.getElementById('extensions-loading');
const errorEl = document.getElementById('extensions-error');
const errorMsgEl = document.getElementById('extensions-error-message');
// 각 섹션의 그리드 요소
const adminGrid = document.getElementById('admin-templates-list');
const userGrid = document.getElementById('user-templates-list');
const modulesGrid = document.getElementById('modules-list');
const pluginsGrid = document.getElementById('plugins-list');
// empty 메시지 요소
const userEmpty = document.getElementById('user-templates-empty');
const modulesEmpty = document.getElementById('modules-empty');
const pluginsEmpty = document.getElementById('plugins-empty');
if (loadingEl) loadingEl.classList.remove('hidden');
if (errorEl) errorEl.classList.add('hidden');
if (form) form.classList.add('hidden');
try {
// 캐시 방지를 위해 타임스탬프 추가
const response = await fetch(INSTALLER_BASE_URL + '/api/scan-extensions.php?action=get', {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (!response.ok) throw new Error('HTTP ' + response.status);
const result = await response.json();
if (!result.success) throw new Error(result.error_message || result.error || window.EXTENSION_LABELS?.extension_load_failed || 'Failed to load extensions');
extensionsData = result.data;
if (loadingEl) loadingEl.classList.add('hidden');
if (form) form.classList.remove('hidden');
// 각 그리드에 카드 렌더링
renderExtensionCards(adminGrid, 'admin_templates', extensionsData.admin_templates || [], true);
renderExtensionCards(userGrid, 'user_templates', extensionsData.user_templates || [], false, userEmpty);
renderExtensionCards(modulesGrid, 'modules', extensionsData.modules || [], false, modulesEmpty);
renderExtensionCards(pluginsGrid, 'plugins', extensionsData.plugins || [], false, pluginsEmpty);
// 언어팩 — locale 별 서브헤딩으로 그룹 렌더
renderLanguagePackGroups(
document.getElementById('language-packs-locale-groups'),
extensionsData.language_packs || [],
document.getElementById('language-packs-empty')
);
restoreSelectionState();
updateSelectionSummary();
autoSelectMissingDependencies();
validateAdminTemplate();
applyLanguagePackCascadeState();
attachBulkToggleHandlers();
updateBulkToggleStates();
} catch (error) {
if (loadingEl) loadingEl.classList.add('hidden');
if (errorEl) {
errorEl.classList.remove('hidden');
if (errorMsgEl) errorMsgEl.textContent = error.message;
}
}
}
/**
* 확장 기능 카드들을 그리드에 렌더링
* @param {HTMLElement} grid - 그리드 요소
* @param {string} type - 확장 타입 (admin_templates, user_templates, modules, plugins)
* @param {Array} items - 확장 기능 목록
* @param {boolean} _isRequired - 필수 선택 여부 (admin_templates) - 미사용, type으로 판별
* @param {HTMLElement} emptyEl - 비어있을 때 표시할 요소
*/
function renderExtensionCards(grid, type, items, _isRequired, emptyEl) {
if (!grid) return;
grid.innerHTML = '';
if (!items || items.length === 0) {
if (emptyEl) emptyEl.classList.remove('hidden');
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
// 템플릿은 단일 선택 (라디오 버튼 방식), 모듈/플러그인은 다중 선택
const isSingleSelect = (type === 'admin_templates' || type === 'user_templates');
items.forEach(function(item, index) {
// admin_templates의 첫 번째 항목은 기본 선택 (필수이므로)
if (type === 'admin_templates' && selectedExtensions.admin_templates.length === 0 && index === 0) {
selectedExtensions.admin_templates = [item.identifier];
}
// user_templates는 선택사항이므로 기본 선택 없음
grid.appendChild(createExtensionCard(type, item, isSingleSelect));
});
}
/**
* 다국어 값에서 현재 언어에 맞는 텍스트 추출
* @param {string|object} value - 문자열 또는 {ko: '...', en: '...'} 객체
* @returns {string}
*/
function getLocalizedText(value) {
if (!value) return '';
if (typeof value === 'string') return value;
// 현재 언어 또는 한국어 또는 영어 우선
const currentLang = window.installerLocale?.get() || 'ko';
return value[currentLang] || value.ko || value.en || '';
}
/**
* identifier로 확장 기능 찾기 (의존성 표시용)
* @param {string} identifier - 확장 식별자
* @returns {object|null} 확장 정보 또는 null
*/
function findExtensionById(identifier) {
if (!extensionsData) return null;
// 모듈에서 검색
var modules = extensionsData.modules || [];
for (var i = 0; i < modules.length; i++) {
if (modules[i].identifier === identifier) {
return { ...modules[i], type: 'module' };
}
}
// 플러그인에서 검색
var plugins = extensionsData.plugins || [];
for (var j = 0; j < plugins.length; j++) {
if (plugins[j].identifier === identifier) {
return { ...plugins[j], type: 'plugin' };
}
}
// 관리자 템플릿에서 검색
var adminTpls = extensionsData.admin_templates || [];
for (var k = 0; k < adminTpls.length; k++) {
if (adminTpls[k].identifier === identifier) {
return { ...adminTpls[k], type: 'template' };
}
}
// 사용자 템플릿에서 검색
var userTpls = extensionsData.user_templates || [];
for (var l = 0; l < userTpls.length; l++) {
if (userTpls[l].identifier === identifier) {
return { ...userTpls[l], type: 'template' };
}
}
return null;
}
function createExtensionCard(type, item, isSingleSelect) {
const card = document.createElement('div');
card.className = 'extension-card';
card.dataset.type = type;
card.dataset.identifier = item.identifier;
const isSelected = selectedExtensions[type] && selectedExtensions[type].indexOf(item.identifier) !== -1;
if (isSelected) card.classList.add('selected');
if (isSingleSelect) card.classList.add('single-select');
// 아이콘 설정
let iconClass = 'fa-puzzle-piece';
if (type === 'admin_templates') iconClass = 'fa-shield-alt';
else if (type === 'user_templates') iconClass = 'fa-palette';
else if (type === 'modules') iconClass = 'fa-cube';
else if (type === 'plugins') iconClass = 'fa-plug';
const name = getLocalizedText(item.name) || item.identifier;
const description = getLocalizedText(item.description);
const version = item.version || '1.0.0';
const author = item.author || '';
// 의존성 표시 (identifier → name 변환, 모듈/플러그인 아이콘 추가)
let depsHtml = '';
if (item.dependencies && item.dependencies.length > 0) {
const depsText = item.dependencies.map(function(depId) {
const depInfo = findExtensionById(depId);
const depName = depInfo ? getLocalizedText(depInfo.name) : depId;
const depIcon = depInfo ? (depInfo.type === 'plugin' ? 'fa-plug' : 'fa-cube') : 'fa-cube';
return '<span class="dep-tag"><i class="fas ' + depIcon + '"></i> ' + escapeHtml(depName) + '</span>';
}).join(' ');
depsHtml = '<div class="extension-card-footer"><div class="extension-card-dependencies">' +
'<span class="dep-label">' + (window.EXTENSION_LABELS?.dependencies || lang('dependencies')) + ':</span> ' + depsText + '</div></div>';
}
// 선택 표시 아이콘 (단일 선택은 라디오, 다중 선택은 체크박스)
const selectIcon = isSingleSelect
? (isSelected ? '<i class="fas fa-check-circle"></i>' : '<i class="far fa-circle"></i>')
: (isSelected ? '<i class="fas fa-check-square"></i>' : '<i class="far fa-square"></i>');
card.innerHTML =
'<div class="extension-card-header">' +
'<div class="extension-card-icon"><i class="fas ' + iconClass + '"></i></div>' +
'<div class="extension-card-info">' +
'<h4 class="extension-card-name">' + escapeHtml(name) + '</h4>' +
'<div class="extension-card-meta">v' + escapeHtml(version) + (author ? ' · ' + escapeHtml(author) : '') + '</div>' +
'</div>' +
'<div class="extension-card-select">' + selectIcon + '</div>' +
'</div>' +
(description ? '<p class="extension-card-description">' + escapeHtml(description) + '</p>' : '') +
depsHtml;
card.addEventListener('click', function() {
toggleExtension(type, item.identifier, isSingleSelect);
});
return card;
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 번들 언어팩 — locale 별 서브헤딩 + extension-grid 렌더링
* @param {HTMLElement} container - 컨테이너 요소
* @param {Array} items - 번들 언어팩 목록 (scan-extensions.php 응답)
* @param {HTMLElement} emptyEl - 비어있을 때 표시할 요소
*/
function renderLanguagePackGroups(container, items, emptyEl) {
if (!container) return;
container.innerHTML = '';
if (!items || items.length === 0) {
if (emptyEl) emptyEl.classList.remove('hidden');
return;
}
if (emptyEl) emptyEl.classList.add('hidden');
// locale 키로 그룹핑 (Map) — 입력 순서 유지
const groups = new Map();
items.forEach(function(item) {
const locale = item.locale || '';
if (!groups.has(locale)) groups.set(locale, []);
groups.get(locale).push(item);
});
// locale 알파벳 오름차순 정렬
const sortedLocales = Array.from(groups.keys()).sort();
sortedLocales.forEach(function(locale) {
const groupItems = groups.get(locale);
const native = groupItems[0].locale_native_name || groupItems[0].locale_name || locale;
const heading = document.createElement('h4');
heading.className = 'lp-locale-heading';
heading.textContent = native + ' (' + locale + ')';
container.appendChild(heading);
const grid = document.createElement('div');
grid.className = 'extension-grid lp-locale-grid';
groupItems.forEach(function(item) {
grid.appendChild(createLanguagePackCard(item));
});
container.appendChild(grid);
});
}
/**
* 언어팩 카드 DOM 생성 — extension-card 패턴 + scope/target 데이터 속성
* @param {object} item - 번들 언어팩 매니페스트 데이터
* @returns {HTMLElement}
*/
function createLanguagePackCard(item) {
const card = document.createElement('div');
card.className = 'extension-card';
card.dataset.type = 'language_packs';
card.dataset.identifier = item.identifier;
card.dataset.scope = item.scope || 'core';
card.dataset.targetIdentifier = item.target_identifier || '';
card.dataset.locale = item.locale || '';
const isSelected = selectedExtensions.language_packs.indexOf(item.identifier) !== -1;
if (isSelected) card.classList.add('selected');
const name = (typeof item.name === 'string') ? item.name : (getLocalizedText(item.name) || item.identifier);
const description = getLocalizedText(item.description);
const version = item.version || '1.0.0';
const author = item.author || '';
const scope = item.scope || 'core';
const scopeLabelKey = 'language_pack_scope_' + scope;
const scopeLabel = (window.EXTENSION_LABELS && window.EXTENSION_LABELS[scopeLabelKey]) || scope;
// 종속 확장의 실제 이름(현재 locale) + 식별자 동시 표시 — 미발견 시 식별자만
let targetText = '';
if (item.target_identifier) {
const targetInfo = findExtensionById(item.target_identifier);
const targetName = targetInfo ? getLocalizedText(targetInfo.name) : '';
if (targetName && targetName !== item.target_identifier) {
targetText = ' · ' + escapeHtml(targetName) + ' (' + escapeHtml(item.target_identifier) + ')';
} else {
targetText = ' · ' + escapeHtml(item.target_identifier);
}
}
const scopeBadgeHtml =
'<span class="lp-scope-badge lp-scope-' + escapeHtml(scope) + '">' +
escapeHtml(scopeLabel) +
'</span>' +
(targetText ? '<span class="lp-target-text">' + targetText + '</span>' : '');
const selectIcon = isSelected
? '<i class="fas fa-check-square"></i>'
: '<i class="far fa-square"></i>';
card.innerHTML =
'<div class="extension-card-header">' +
'<div class="extension-card-icon"><i class="fas fa-language"></i></div>' +
'<div class="extension-card-info">' +
'<h4 class="extension-card-name">' + escapeHtml(name) + '</h4>' +
'<div class="extension-card-meta">v' + escapeHtml(version) + (author ? ' · ' + escapeHtml(author) : '') + '</div>' +
'</div>' +
'<div class="extension-card-select">' + selectIcon + '</div>' +
'</div>' +
(description ? '<p class="extension-card-description">' + escapeHtml(description) + '</p>' : '') +
'<div class="extension-card-footer"><div class="extension-card-dependencies">' + scopeBadgeHtml + '</div></div>';
card.addEventListener('click', function() {
if (card.classList.contains('disabled')) return;
toggleExtension('language_packs', item.identifier, false);
});
return card;
}
/**
* 언어팩 cascade 상태 갱신 — 선택된 확장과 매칭 검사 후 disabled/자동 해제
*
* 규칙:
* - scope === 'core' → 항상 활성
* - scope === 'module' → selectedExtensions.modules 에 target_identifier 가 있어야 활성
* - scope === 'plugin' → selectedExtensions.plugins 에 있어야 활성
* - scope === 'template' → admin_templates 또는 user_templates 에 있어야 활성
* - 비활성 진입 시 selectedExtensions.language_packs 에서 자동 제거
*
* 호출 시점: loadExtensions, toggleExtension, restoreSelectionState 마지막
*/
function applyLanguagePackCascadeState() {
const cards = document.querySelectorAll('.extension-card[data-type="language_packs"]');
let changed = false;
const reasonText = (window.EXTENSION_LABELS && window.EXTENSION_LABELS.language_pack_disabled_by_extension) || '';
cards.forEach(function(card) {
const scope = card.dataset.scope || 'core';
const target = card.dataset.targetIdentifier || '';
const id = card.dataset.identifier;
let disabled = false;
if (scope !== 'core' && target) {
let matched = false;
if (scope === 'module') {
matched = selectedExtensions.modules.indexOf(target) !== -1;
} else if (scope === 'plugin') {
matched = selectedExtensions.plugins.indexOf(target) !== -1;
} else if (scope === 'template') {
matched = (selectedExtensions.admin_templates.indexOf(target) !== -1) ||
(selectedExtensions.user_templates.indexOf(target) !== -1);
}
if (!matched) disabled = true;
}
if (disabled) {
card.classList.add('disabled');
card.title = reasonText;
const idx = selectedExtensions.language_packs.indexOf(id);
if (idx !== -1) {
selectedExtensions.language_packs.splice(idx, 1);
changed = true;
}
card.classList.remove('selected');
const sel = card.querySelector('.extension-card-select');
if (sel) sel.innerHTML = '<i class="far fa-square"></i>';
} else {
card.classList.remove('disabled');
card.title = '';
}
});
if (changed) {
updateSelectionSummary();
}
}
/**
* 일괄 토글 가능한 다중 선택 카테고리 — admin_templates 는 단일 선택 + 필수 이므로 제외
*/
const BULK_TOGGLE_CATEGORIES = ['user_templates', 'modules', 'plugins', 'language_packs'];
/**
* 카테고리 내에서 현재 선택 가능한 항목(disabled 제외) 식별자 목록 반환
* @param {string} category
* @returns {Array<string>}
*/
function getSelectableIdentifiers(category) {
if (!extensionsData) return [];
const items = extensionsData[category] || [];
const ids = [];
items.forEach(function(item) {
// language_packs 는 disabled 카드 제외
if (category === 'language_packs') {
const card = document.querySelector(
'.extension-card[data-type="language_packs"][data-identifier="' + item.identifier + '"]'
);
if (card && card.classList.contains('disabled')) return;
}
ids.push(item.identifier);
});
return ids;
}
/**
* 섹션 단위 일괄 토글 — 가능 항목 모두 선택 / 모두 해제
* @param {string} category
* @param {boolean} select - true=전체 선택, false=전체 해제
*/
function bulkToggleSection(category, select) {
if (BULK_TOGGLE_CATEGORIES.indexOf(category) === -1) return;
// user_templates 는 단일 선택 — 일괄 선택 시 마지막 항목 1개만 선택, 일괄 해제는 정상
const isSingleSelect = (category === 'user_templates');
const targetIds = getSelectableIdentifiers(category);
if (select) {
if (isSingleSelect) {
// 첫 번째 가능 항목 선택 (이미 선택되어 있지 않다면)
if (targetIds.length > 0 && selectedExtensions[category].indexOf(targetIds[0]) === -1) {
toggleExtension(category, targetIds[0], true);
}
} else {
targetIds.forEach(function(id) {
if (selectedExtensions[category].indexOf(id) === -1) {
toggleExtension(category, id, false);
}
});
}
} else {
// 전체 해제 — 의존성에 의해 차단된 항목은 toggleExtension 가 차단하므로 정상 동작
const currentSelected = selectedExtensions[category].slice();
currentSelected.forEach(function(id) {
toggleExtension(category, id, isSingleSelect);
});
}
updateBulkToggleStates();
}
/**
* 마스터 토글 — 모든 다중 선택 카테고리에 일괄 적용
* 순서: 모듈/플러그인 먼저 선택 → cascade 갱신 → 언어팩 선택 (disabled 풀린 후 적용 가능)
*/
function bulkToggleMaster(select) {
// 선택 시: 모듈/플러그인/사용자템플릿 먼저, language_packs 는 cascade 갱신 후 마지막
// 해제 시: language_packs 먼저, 그 다음 다른 카테고리 (역순)
const order = select
? ['user_templates', 'modules', 'plugins', 'language_packs']
: ['language_packs', 'plugins', 'modules', 'user_templates'];
order.forEach(function(cat) {
bulkToggleSection(cat, select);
});
}
/**
* 모든 일괄 토글 체크박스의 표시 상태를 현재 selectedExtensions 기준으로 갱신
*/
function updateBulkToggleStates() {
BULK_TOGGLE_CATEGORIES.forEach(function(cat) {
const cb = document.querySelector('.bulk-toggle-checkbox[data-bulk-target="' + cat + '"]');
if (!cb) return;
const selectable = getSelectableIdentifiers(cat);
if (selectable.length === 0) {
cb.checked = false;
cb.indeterminate = false;
cb.disabled = true;
return;
}
cb.disabled = false;
const selected = selectedExtensions[cat] || [];
const allSelected = selectable.every(function(id) { return selected.indexOf(id) !== -1; });
const anySelected = selectable.some(function(id) { return selected.indexOf(id) !== -1; });
cb.checked = allSelected;
cb.indeterminate = !allSelected && anySelected;
});
// 마스터 토글 — 모든 카테고리가 전체 선택 상태일 때만 ON, 일부면 indeterminate
const master = document.getElementById('bulk-toggle-all');
if (master) {
const allFull = BULK_TOGGLE_CATEGORIES.every(function(cat) {
const cb = document.querySelector('.bulk-toggle-checkbox[data-bulk-target="' + cat + '"]');
return cb && cb.checked;
});
const anyOn = BULK_TOGGLE_CATEGORIES.some(function(cat) {
const cb = document.querySelector('.bulk-toggle-checkbox[data-bulk-target="' + cat + '"]');
return cb && (cb.checked || cb.indeterminate);
});
master.checked = allFull;
master.indeterminate = !allFull && anyOn;
}
}
/**
* 일괄 토글 체크박스에 change 핸들러 부착 — 한 번만 부착되도록 데이터 속성으로 가드
*/
function attachBulkToggleHandlers() {
document.querySelectorAll('.bulk-toggle-checkbox[data-bulk-target]').forEach(function(cb) {
if (cb.dataset.handlerAttached === '1') return;
cb.dataset.handlerAttached = '1';
cb.addEventListener('change', function() {
bulkToggleSection(cb.dataset.bulkTarget, cb.checked);
});
});
const master = document.getElementById('bulk-toggle-all');
if (master && master.dataset.handlerAttached !== '1') {
master.dataset.handlerAttached = '1';
master.addEventListener('change', function() {
bulkToggleMaster(master.checked);
});
}
}
window.toggleExtension = function(type, identifier, isSingleSelect, _isAutoSelect) {
const card = document.querySelector('.extension-card[data-type="' + type + '"][data-identifier="' + identifier + '"]');
if (!card) return;
const isCurrentlySelected = selectedExtensions[type].indexOf(identifier) !== -1;
const isRequired = (type === 'admin_templates'); // 관리자 템플릿만 필수
// 선택 해제 시도 시 — 다른 선택된 확장이 이 항목을 의존하면 차단
if (isCurrentlySelected && !_isAutoSelect) {
const dependents = computeDependents(identifier);
if (dependents.length > 0) {
const names = dependents.map(function(d) { return d.name; }).join(', ');
showDependencyLockToast(identifier, names);
return; // 해제 차단
}
}
if (isSingleSelect) {
// 단일 선택 모드 — 이전 템플릿 해제 시 자동 선택 항목도 정리
if (isCurrentlySelected && !isRequired) {
// 사용자 템플릿: 이미 선택된 항목 클릭 시 선택 해제
clearAutoSelectedByOwner(type, identifier);
selectedExtensions[type] = [];
card.classList.remove('selected');
const selectEl = card.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="far fa-circle"></i>';
} else {
// 다른 항목 선택 해제 전 이전 템플릿의 자동 선택 항목 정리
const prevId = selectedExtensions[type][0];
if (prevId && prevId !== identifier) {
clearAutoSelectedByOwner(type, prevId);
}
const allCards = document.querySelectorAll('.extension-card[data-type="' + type + '"]');
allCards.forEach(function(c) {
c.classList.remove('selected');
const selectEl = c.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="far fa-circle"></i>';
});
selectedExtensions[type] = [identifier];
card.classList.add('selected');
const selectEl = card.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="fas fa-check-circle"></i>';
}
} else {
// 다중 선택: 토글 방식
const index = selectedExtensions[type].indexOf(identifier);
if (index === -1) {
selectedExtensions[type].push(identifier);
card.classList.add('selected');
const selectEl = card.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="fas fa-check-square"></i>';
} else {
// 해제 시 자동 선택 표시 제거
autoSelectedSet.delete(identifier);
removeAutoSelectedUI(identifier);
selectedExtensions[type].splice(index, 1);
card.classList.remove('selected');
const selectEl = card.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="far fa-square"></i>';
}
}
updateSelectionSummary();
validateAdminTemplate();
// 확장 선택 변경 시 언어팩 cascade 상태 갱신 (admin/user/module/plugin 변동 영향)
if (type !== 'language_packs') {
applyLanguagePackCascadeState();
}
// 선택 변경 시 의존성 즉시 자동 선택 (자동 선택에 의한 재귀 호출 방지)
if (!_isAutoSelect) {
autoSelectMissingDependencies();
}
// 일괄 토글 체크박스 상태 동기화
updateBulkToggleStates();
};
function restoreSelectionState() {
if (window.INSTALLER_SELECTED_EXTENSIONS) {
var saved = window.INSTALLER_SELECTED_EXTENSIONS;
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach(function(type) {
if (saved[type] && Array.isArray(saved[type])) {
selectedExtensions[type] = saved[type].slice();
}
});
Object.keys(selectedExtensions).forEach(function(type) {
selectedExtensions[type].forEach(function(identifier) {
var card = document.querySelector('.extension-card[data-type="' + type + '"][data-identifier="' + identifier + '"]');
if (card && !card.classList.contains('required')) {
card.classList.add('selected');
}
});
});
}
if (extensionsData) {
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach(function(type) {
var items = extensionsData[type] || [];
items.forEach(function(item) {
if (item.is_required && selectedExtensions[type].indexOf(item.identifier) === -1) {
selectedExtensions[type].push(item.identifier);
}
});
});
}
// 복원 후 언어팩 cascade 상태 동기화 (확장 선택 복원 결과 반영)
applyLanguagePackCascadeState();
}
function updateSelectionSummary() {
// PHP 뷰 구조에 맞는 개별 요약 요소 업데이트
const adminCount = document.getElementById('summary-admin-templates');
const userCount = document.getElementById('summary-user-templates');
const modulesCount = document.getElementById('summary-modules');
const pluginsCount = document.getElementById('summary-plugins');
const lpCount = document.getElementById('summary-language-packs');
if (adminCount) adminCount.textContent = selectedExtensions.admin_templates.length;
if (userCount) userCount.textContent = selectedExtensions.user_templates.length;
if (modulesCount) modulesCount.textContent = selectedExtensions.modules.length;
if (pluginsCount) pluginsCount.textContent = selectedExtensions.plugins.length;
if (lpCount) lpCount.textContent = selectedExtensions.language_packs.length;
}
/**
* 관리자 템플릿 + 의존성 검증.
*
* - 관리자 템플릿 최소 1개 선택 필수
* - 선택된 모든 확장의 의존성이 충족되어야 함 (누락 시 설치 버튼 비활성화 + 경고)
*/
function validateAdminTemplate() {
return validateExtensionSelection();
}
/**
* 선택된 확장 목록에서 누락된 의존성을 계산합니다.
*
* @returns {Array} 누락 목록 [{ownerType, ownerIdentifier, ownerName, depType, depIdentifier}, ...]
*/
function computeMissingDependencies() {
if (!extensionsData) return [];
const selectedIdSet = new Set();
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(selectedExtensions[cat] || []).forEach((id) => selectedIdSet.add(id));
});
// 모든 확장의 identifier → version 매핑 (버전 검증용)
const availableVersions = {};
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(extensionsData[cat] || []).forEach((item) => {
availableVersions[item.identifier] = item.version || '0.0.0';
});
});
const missing = [];
const checkOne = (category, item) => {
if (!selectedIdSet.has(item.identifier)) return;
const deps = item.dependencies_detailed || [];
deps.forEach((dep) => {
if (!dep || !dep.identifier) return;
if (!selectedIdSet.has(dep.identifier)) {
missing.push({
ownerType: category,
ownerIdentifier: item.identifier,
ownerName: (item.name && (item.name.ko || item.name.en)) || item.identifier,
depType: dep.type || 'unknown',
depIdentifier: dep.identifier,
depVersion: dep.version || '*',
issue: 'missing',
});
} else if (dep.version && dep.version !== '*') {
// 선택되어 있지만 버전 제약 미충족 여부 확인
const availVer = availableVersions[dep.identifier];
if (availVer && !satisfiesVersionConstraint(availVer, dep.version)) {
missing.push({
ownerType: category,
ownerIdentifier: item.identifier,
ownerName: (item.name && (item.name.ko || item.name.en)) || item.identifier,
depType: dep.type || 'unknown',
depIdentifier: dep.identifier,
depVersion: dep.version,
availableVersion: availVer,
issue: 'version_mismatch',
});
}
}
});
};
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(extensionsData[cat] || []).forEach((item) => checkOne(cat, item));
});
return missing;
}
/**
* 누락된 의존성을 자동으로 selectedExtensions에 추가하고 UI에 반영합니다.
* 추이적(transitive) 의존성도 자동 해결될 수 있도록 반복 실행됩니다.
*/
function autoSelectMissingDependencies() {
if (!extensionsData) return;
// 카테고리별 identifier → category 역인덱스 구축
const idToCategory = {};
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(extensionsData[cat] || []).forEach((item) => {
idToCategory[item.identifier] = cat;
});
});
// 고정점(fixed-point)까지 반복 — 추이적 의존성 자동 해결
let changed = true;
let iteration = 0;
while (changed && iteration < 10) {
changed = false;
iteration++;
const missing = computeMissingDependencies();
if (missing.length === 0) break;
missing.forEach((m) => {
// 의존성 type을 카테고리로 매핑 (modules → modules, plugins → plugins)
// detailed에 타입이 없는 레거시인 경우 identifier 역인덱스에서 조회
let targetCat = null;
if (m.depType === 'modules') targetCat = 'modules';
else if (m.depType === 'plugins') targetCat = 'plugins';
else if (idToCategory[m.depIdentifier]) targetCat = idToCategory[m.depIdentifier];
if (targetCat && !selectedExtensions[targetCat].includes(m.depIdentifier)) {
// 자동 선택 추적
autoSelectedSet.add(m.depIdentifier);
// toggleExtension을 호출하여 selectedExtensions 배열 추가와 카드 UI
// (selected 클래스 + extension-card-select 아이콘)을 한 번에 동기화.
const isSingleSelect = (targetCat === 'admin_templates' || targetCat === 'user_templates');
window.toggleExtension(targetCat, m.depIdentifier, isSingleSelect, true);
// 자동 선택 시각적 표시 추가
applyAutoSelectedUI(m.depIdentifier, m.ownerName);
changed = true;
}
});
}
updateSelectionSummary();
validateExtensionSelection();
}
window.autoSelectMissingDependencies = autoSelectMissingDependencies;
/**
* 관리자 템플릿 + 의존성 그래프 검증.
*/
function validateExtensionSelection() {
const submitBtn = document.getElementById('submit-btn');
const warningBox = document.getElementById('dependency-warning');
const warningList = document.getElementById('dependency-warning-list');
const hasAdminTemplate = selectedExtensions.admin_templates.length > 0;
const missing = computeMissingDependencies();
// 경고 UI 업데이트
if (warningBox && warningList) {
if (missing.length > 0) {
warningList.innerHTML = '';
// 소유자별로 중복 제거
const seen = new Set();
missing.forEach((m) => {
const key = `${m.ownerIdentifier}→${m.depIdentifier}`;
if (seen.has(key)) return;
seen.add(key);
const li = document.createElement('li');
if (m.issue === 'version_mismatch') {
li.innerHTML = '<i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(m.ownerName) + ' → ' + escapeHtml(m.depIdentifier) +
' <span class="dep-version-conflict">(' +
escapeHtml(lang('dep_version_required')) + ': ' + escapeHtml(m.depVersion) +
', ' + escapeHtml(lang('dep_version_available')) + ': ' + escapeHtml(m.availableVersion) +
')</span>';
} else {
li.textContent = `${m.ownerName} (${m.ownerIdentifier}) → ${m.depIdentifier}`;
}
warningList.appendChild(li);
});
warningBox.classList.remove('hidden');
} else {
warningBox.classList.add('hidden');
}
}
if (!submitBtn) return;
const valid = hasAdminTemplate && missing.length === 0;
submitBtn.disabled = !valid;
if (!hasAdminTemplate) {
submitBtn.title = window.EXTENSION_LABELS?.admin_template_required || lang('error_admin_template_required');
} else if (missing.length > 0) {
submitBtn.title = lang('dependency_missing_tooltip');
} else {
submitBtn.title = '';
}
}
window.validateExtensionSelection = validateExtensionSelection;
/**
* 지정된 identifier를 의존하는 현재 선택된 확장 목록을 반환합니다.
* 선택 해제 잠금 판단에 사용합니다.
*
* @param {string} identifier - 해제하려는 확장의 식별자
* @returns {Array} [{identifier, name, type}, ...] 해당 identifier에 의존하는 선택된 확장 목록
*/
function computeDependents(identifier) {
if (!extensionsData) return [];
const dependents = [];
const selectedIdSet = new Set();
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(selectedExtensions[cat] || []).forEach((id) => selectedIdSet.add(id));
});
['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'].forEach((cat) => {
(extensionsData[cat] || []).forEach((item) => {
if (!selectedIdSet.has(item.identifier)) return;
const deps = item.dependencies_detailed || [];
deps.forEach((dep) => {
if (dep && dep.identifier === identifier) {
dependents.push({
identifier: item.identifier,
name: getLocalizedText(item.name) || item.identifier,
type: cat
});
}
});
});
});
return dependents;
}
/**
* 자동 선택된 카드에 시각적 표시(배지 + CSS 클래스)를 적용합니다.
*
* @param {string} identifier - 자동 선택된 확장 식별자
* @param {string} ownerName - 이 항목을 요구하는 확장의 이름
*/
function applyAutoSelectedUI(identifier, ownerName) {
const card = document.querySelector('.extension-card[data-identifier="' + identifier + '"]');
if (!card || card.classList.contains('dep-auto-selected')) return;
card.classList.add('dep-auto-selected');
// 배지 추가
const badge = document.createElement('span');
badge.className = 'dep-auto-badge';
badge.innerHTML = '<i class="fas fa-link"></i> ' +
escapeHtml(lang('dep_auto_badge_label')) +
' <span class="dep-auto-badge-owner">(' + escapeHtml(ownerName) + ')</span>';
const infoEl = card.querySelector('.extension-card-info');
if (infoEl) {
infoEl.appendChild(badge);
}
}
/**
* 자동 선택 시각적 표시를 제거합니다.
*
* @param {string} identifier - 대상 확장 식별자
*/
function removeAutoSelectedUI(identifier) {
const card = document.querySelector('.extension-card[data-identifier="' + identifier + '"]');
if (!card) return;
card.classList.remove('dep-auto-selected');
const badge = card.querySelector('.dep-auto-badge');
if (badge) badge.remove();
}
/**
* 특정 소유자(템플릿)의 의존성으로 자동 선택된 항목들을 정리합니다.
* 템플릿 변경/해제 시 이전 템플릿의 자동 선택 항목을 해제합니다.
* 단, 다른 선택된 확장이 여전히 의존하는 항목은 유지합니다.
*
* @param {string} ownerType - 소유자 카테고리
* @param {string} ownerIdentifier - 소유자 식별자
*/
function clearAutoSelectedByOwner(ownerType, ownerIdentifier) {
if (!extensionsData) return;
// 해제되는 소유자의 의존성 목록 수집
const ownerItem = (extensionsData[ownerType] || []).find(e => e.identifier === ownerIdentifier);
if (!ownerItem) return;
const ownerDeps = (ownerItem.dependencies_detailed || []).map(d => d.identifier);
ownerDeps.forEach((depId) => {
if (!autoSelectedSet.has(depId)) return;
// 다른 선택된 확장이 여전히 이 항목을 의존하는지 확인
const otherDependents = computeDependents(depId).filter(d => d.identifier !== ownerIdentifier);
if (otherDependents.length > 0) return; // 다른 의존자 존재 → 유지
// 자동 선택 해제
autoSelectedSet.delete(depId);
removeAutoSelectedUI(depId);
// 카테고리를 찾아 선택 해제
['modules', 'plugins'].forEach((cat) => {
const idx = selectedExtensions[cat].indexOf(depId);
if (idx !== -1) {
selectedExtensions[cat].splice(idx, 1);
const card = document.querySelector('.extension-card[data-type="' + cat + '"][data-identifier="' + depId + '"]');
if (card) {
card.classList.remove('selected');
const selectEl = card.querySelector('.extension-card-select');
if (selectEl) selectEl.innerHTML = '<i class="far fa-square"></i>';
}
}
});
});
}
/**
* 의존성 잠금으로 인해 선택 해제가 차단될 때 토스트 메시지를 표시합니다.
*
* @param {string} identifier - 해제 시도된 확장 식별자
* @param {string} dependentNames - 의존하는 확장 이름 목록 (콤마 구분)
*/
function showDependencyLockToast(identifier, dependentNames) {
// 기존 토스트 제거
const existing = document.querySelector('.dep-lock-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'dep-lock-toast';
toast.innerHTML = '<i class="fas fa-lock"></i> ' +
escapeHtml(lang('dep_lock_message').replace(':names', dependentNames));
document.body.appendChild(toast);
// 애니메이션 후 자동 제거
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Semver 버전 제약 조건을 만족하는지 확인합니다.
*
* @param {string} available - 사용 가능한 버전 (예: "1.2.3")
* @param {string} constraint - 제약 조건 (예: ">=1.0.0", "^1.2.0", "*")
* @returns {boolean} 제약 조건 충족 여부
*/
function satisfiesVersionConstraint(available, constraint) {
if (!constraint || constraint === '*') return true;
// 버전 문자열을 숫자 배열로 파싱
function parseVersion(v) {
const clean = v.replace(/^[v=]/, '');
const parts = clean.split('.').map(Number);
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
}
// 두 버전 비교: -1(a<b), 0(같음), 1(a>b)
function compareVersions(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
return 0;
}
const avail = parseVersion(available);
// 연산자 + 버전 분리
const match = constraint.match(/^([><=^~!]*)\s*(.+)$/);
if (!match) return true;
const op = match[1] || '>=';
const req = parseVersion(match[2]);
const cmp = compareVersions(avail, req);
switch (op) {
case '>=': return cmp >= 0;
case '>': return cmp > 0;
case '<=': return cmp <= 0;
case '<': return cmp < 0;
case '=':
case '==': return cmp === 0;
case '!=': return cmp !== 0;
case '^':
// ^1.2.3 → >=1.2.3, <2.0.0 (major 고정)
if (cmp < 0) return false;
return avail[0] === req[0];
case '~':
// ~1.2.3 → >=1.2.3, <1.3.0 (minor 고정)
if (cmp < 0) return false;
return avail[0] === req[0] && avail[1] === req[1];
default:
return cmp >= 0;
}
}
/**
* 확장 식별자로 이름 가져오기 (extensionsData에서)
* @param {string} category 카테고리 (admin_templates, modules 등)
* @param {string} identifier 확장 식별자
* @returns {Object|null} 이름 객체 {ko: '...', en: '...'}
*/
function getExtensionNameFromData(category, identifier) {
if (!extensionsData) return null;
const list = extensionsData[category] || [];
const ext = list.find(e => e.identifier === identifier);
if (!ext) return null;
// 언어팩의 name 은 단일 string — {ko, en} 객체 형태로 정규화 (state.json 다운스트림 호환)
if (typeof ext.name === 'string') {
return { ko: ext.name, en: ext.name };
}
return ext.name;
}
async function saveExtensionsAndProceed() {
const btn = document.getElementById('submit-btn');
if (btn) {
btn.disabled = true;
btn.textContent = window.EXTENSION_LABELS?.saving || lang('saving');
}
// 관리자 템플릿 필수 검증
if (selectedExtensions.admin_templates.length === 0) {
alert(window.EXTENSION_LABELS?.admin_template_required || lang('error_admin_template_required'));
if (btn) btn.disabled = false;
return;
}
// 확장 이름 매핑 수집 (identifier → {ko: '...', en: '...'})
const extensionNames = {};
const categories = ['admin_templates', 'user_templates', 'modules', 'plugins', 'language_packs'];
categories.forEach(category => {
(selectedExtensions[category] || []).forEach(identifier => {
const name = getExtensionNameFromData(category, identifier);
if (name) {
extensionNames[identifier] = name;
}
});
});
try {
const response = await fetch(INSTALLER_BASE_URL + '/api/save-extensions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...selectedExtensions,
extension_names: extensionNames
})
});
const result = await response.json();
if (!result.success) throw new Error(result.error_message || result.error || window.EXTENSION_LABELS?.save_failed || 'Save failed');
window.location.href = INSTALLER_BASE_URL + '?step=5';
} catch (error) {
alert(error.message);
if (btn) {
btn.disabled = false;
btn.textContent = window.EXTENSION_LABELS?.next || lang('next');
}
}
}
window.saveExtensionsAndProceed = saveExtensionsAndProceed;
window.loadExtensions = loadExtensions;
function initExtensionSelection() {
const form = document.getElementById('extension-form');
if (!form) return;
loadExtensions();
form.addEventListener('submit', function(e) {
e.preventDefault();
saveExtensionsAndProceed();
});
}
// =========================================================================
// Step 5: 설치 진행
// =========================================================================
// 설치 작업 목록 (PHP에서 window.INSTALLER_TASKS로 전달됨)
const INSTALLATION_TASKS = window.INSTALLER_TASKS || [];
let installationStarted = false;
let installationCompleted = false;
let eventSource = null; // deprecated (남아있는 외부 참조 호환용)
let currentMonitor = null; // SSE/폴링 모니터 인스턴스
let lastRollbackFailure = null;
/**
* 저장된 확장 이름 가져오기 (state.json에서 로드된 데이터 사용)
*
* @param {string} identifier 확장 식별자
* @returns {string|null} 다국어 이름 또는 null
*/
function getStoredExtensionName(identifier) {
const extensionNames = window.INSTALLER_EXTENSION_NAMES || {};
if (!extensionNames[identifier]) {
return null;
}
const name = extensionNames[identifier];
const currentLang = window.installerLocale?.get() || 'ko';
// 다국어 객체인 경우
if (typeof name === 'object') {
return name[currentLang] || name['ko'] || name['en'] || identifier;
}
// 문자열인 경우
return name;
}
/**
* 번역 키 생성 유틸리티
*
* @param {string} prefix - 접두사 (예: 'task', 'error')
* @param {string} id - ID (예: 'composer_check')
* @returns {string} 번역 키 (예: 'task_composer_check')
*/
function getTranslationKey(prefix, id) {
return prefix + '_' + id;
}
/**
* message_key로 안전하게 번역 처리
*
* @param {string|null} messageKey - 번역 키
* @param {string} fallback - 폴백 메시지
* @returns {string} 번역된 메시지
*/
function translateWithKey(messageKey, fallback = '') {
if (!messageKey) {
return fallback;
}
return lang(messageKey);
}
/**
* Task ID를 번역된 이름으로 변환
*
* @param {string} taskId - 작업 ID (예: 'composer_check', 'env_create')
* @returns {string} 번역된 작업 이름
*/
function getTaskName(taskId) {
const translationKey = getTranslationKey('task', taskId);
return lang(translationKey);
}
/**
* State Management API URL 생성
*
* @param {string} action - API 액션 ('get', 'reset', 'abort')
* @returns {string} 완전한 API URL
*/
function getStateApiUrl(action) {
return `${window.location.origin}${window.INSTALLER_BASE_URL}/api/state-management.php?action=${action}`;
}
/**
* 설치 상태 가져오기
*
* @returns {Promise<Object>} 설치 상태 객체
*/
async function fetchInstallationState() {
const apiUrl = getStateApiUrl('get');
const response = await fetch(apiUrl);
return await response.json();
}
/**
* 기존 DB 테이블 경고 모달 표시.
*
* DB 테스트 배지를 클릭하면 호출되며, 사용자에게 백업 안내 + 강제 진행 옵션을 제공합니다.
* "기존 테이블 모두 삭제 후 설치"를 선택하면 sessionStorage에 existing_db_action=drop_tables 저장.
*/
function showExistingTablesModal() {
const info = window.__g7_existing_tables || null;
if (!info || !info.has_tables) return;
const sev = info.severity;
const titleKey = {
g7_existing: 'db_existing_g7_title',
foreign_data: 'db_existing_foreign_title',
mixed: 'db_existing_mixed_title',
}[sev] || 'db_existing_generic_title';
const descKey = {
g7_existing: 'db_existing_g7_desc',
foreign_data: 'db_existing_foreign_desc',
mixed: 'db_existing_mixed_desc',
}[sev] || 'db_existing_generic_desc';
const tablesList = (info.all_tables || []).slice(0, 20).join(', ');
const dbHost = document.querySelector('input[name="db_write_host"]')?.value || 'localhost';
const dbName = document.querySelector('input[name="db_write_database"]')?.value || '';
const dbUser = document.querySelector('input[name="db_write_username"]')?.value || 'root';
const backupCmd = `mysqldump -h ${dbHost} -u ${dbUser} -p --databases ${dbName} > backup_$(date +%Y%m%d_%H%M%S).sql`;
const html = `
<div class="modal-backdrop" onclick="closeExistingTablesModal()"></div>
<div class="modal-content">
<h3 class="modal-title">⚠ ${escapeHtml(lang(titleKey))}</h3>
<div class="modal-body">
<p>${escapeHtml(lang(descKey))}</p>
<p class="modal-tables-label">${escapeHtml(lang('db_existing_tables_list'))}</p>
<pre class="modal-tables-list">${escapeHtml(tablesList)}</pre>
<p class="modal-backup-label"><strong>${escapeHtml(lang('db_backup_guide'))}</strong></p>
<div class="code-box">
<pre>${escapeHtml(backupCmd)}</pre>
</div>
<label class="modal-confirm-checkbox">
<input type="checkbox" id="db-backup-confirmed">
${escapeHtml(lang('db_backup_confirmed'))}
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeExistingTablesModal()">
${escapeHtml(lang('cancel'))}
</button>
<button type="button" class="btn btn-danger" id="db-force-proceed-btn" disabled onclick="confirmDropTables()">
${escapeHtml(lang('db_force_proceed_drop'))}
</button>
</div>
</div>
`;
let modal = document.getElementById('existing-tables-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'existing-tables-modal';
modal.className = 'modal modal-existing-tables';
document.body.appendChild(modal);
}
modal.innerHTML = html;
modal.classList.add('modal-open');
// 체크박스 → 버튼 활성화 연결
const cb = modal.querySelector('#db-backup-confirmed');
const btn = modal.querySelector('#db-force-proceed-btn');
if (cb && btn) {
cb.addEventListener('change', () => {
btn.disabled = !cb.checked;
});
}
}
window.showExistingTablesModal = showExistingTablesModal;
function closeExistingTablesModal() {
const modal = document.getElementById('existing-tables-modal');
if (modal) {
modal.classList.remove('modal-open');
modal.innerHTML = '';
}
}
window.closeExistingTablesModal = closeExistingTablesModal;
function confirmDropTables() {
sessionStorage.setItem('existing_db_action', 'drop_tables');
closeExistingTablesModal();
// 사용자에게 선택 결과 피드백
const resultDiv = document.getElementById('db-write-test-result');
if (resultDiv) {
const existing = resultDiv.querySelector('.db-existing-tables-badge');
if (existing) {
existing.innerHTML = `✓ ${escapeHtml(lang('db_force_proceed_confirmed'))}`;
existing.classList.remove('alert-warning');
existing.classList.add('alert-info');
}
}
}
/**
* 기존 테이블 삭제 동의 체크박스 핸들러 (인라인 카드)
* 체크 시 sessionStorage에 existing_db_action=drop_tables 저장 + FormValidator 갱신.
*/
function onDbBackupConsentChange(checkbox) {
const consented = !!checkbox.checked;
if (consented) {
sessionStorage.setItem('existing_db_action', 'drop_tables');
} else {
sessionStorage.removeItem('existing_db_action');
}
if (typeof FormValidator !== 'undefined') {
FormValidator.setDbCleanupConsented(consented);
}
}
window.onDbBackupConsentChange = onDbBackupConsentChange;
window.confirmDropTables = confirmDropTables;
/**
* SSE 호환성 사전 체크 (streaming 검증).
*
* sse-probe.php 가 두 phase 의 probe 이벤트를 sleep(2초) 간격으로 송신한다.
* 클라이언트는 두 메시지의 client 측 도착 timestamp 차이로 streaming 호환성을 판정:
* - 차이 ≥ 1500ms → streaming 가능 (호환) → SSE 모드
* - 차이 < 1500ms → buffer 후 일괄 도착 (비호환) → 폴링 모드
* - timeout / 에러 → 비호환
*
* 단순 응답 수신 검증 (single-phase) 은 cgi-fcgi/mod_fcgid 환경에서 false positive
* 가능 (응답 종료 시점에 한 번에 flush 되어도 onmessage 발화). timing 검증으로
* 실제 streaming 가능성을 실증한다.
*
* @param {number} timeoutMs probe 응답 대기 timeout (default 5000 — sleep 2s + 여유)
* @returns {Promise<boolean>} 호환 여부
*/
function detectSseCompatibility(timeoutMs = 5000) {
return new Promise((resolve) => {
const probeUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/sse-probe.php`;
const STREAMING_THRESHOLD_MS = 1500;
let settled = false;
let source = null;
let phase1ClientTs = null;
const finish = (compatible) => {
if (settled) return;
settled = true;
if (source) {
try { source.close(); } catch (e) { /* ignore */ }
}
resolve(compatible);
};
try {
source = new EventSource(probeUrl);
} catch (e) {
finish(false);
return;
}
source.addEventListener('probe', (e) => {
let payload = null;
try { payload = JSON.parse(e.data); } catch (_) { /* malformed */ }
if (!payload) return;
if (payload.phase === 1) {
phase1ClientTs = Date.now();
} else if (payload.phase === 2 && phase1ClientTs !== null) {
const gap = Date.now() - phase1ClientTs;
finish(gap >= STREAMING_THRESHOLD_MS);
}
});
source.onerror = () => finish(false);
setTimeout(() => finish(false), timeoutMs);
});
}
/**
* 설치 시작 (SSE / 폴링 듀얼 모드)
*/
async function startInstallation(modeOverride = null) {
if (installationStarted) return;
installationStarted = true;
// 시작 대기 UI 숨김, 진행 UI 표시 (모드 선택 카드 + 시작 버튼 → 경고 + 진행 카드)
hideInstallationStartSection();
showInstallationProgressSection();
// 중단 버튼 표시
showAbortButton();
// 작업 목록 렌더링
renderTaskList();
// 모드 결정: 인자(명시 override) > SSE 호환성 사전 체크 > Step 5 라디오 값 > 'sse' 기본
//
// SSE 사전 체크 — sse-probe.php 의 두 phase 메시지 timing 으로 streaming 호환성 판정.
// 비호환 시 사용자에게 폴링 모드 진행 여부를 명시적으로 확인 (자동 진행 안 함).
let installationMode = modeOverride;
if (!installationMode) {
const modeRadio = document.querySelector('input[name="installation_mode"]:checked');
const radioMode = modeRadio ? modeRadio.value : null;
if (radioMode === 'polling') {
// 사용자가 명시 폴링 선택 — probe 생략
installationMode = 'polling';
} else {
// SSE 호환성 probe (5초 timeout — 서버 sleep 2초 + 여유)
const sseCompatible = await detectSseCompatibility(5000);
if (sseCompatible) {
installationMode = 'sse';
} else {
// 비호환 — 사용자에게 폴링 모드 진행 여부 명시 confirm
const proceedWithPolling = window.confirm(lang('sse_probe_incompat_use_polling'));
if (!proceedWithPolling) {
// 사용자 취소 — 시작 화면 복원 (진행 미시작)
installationStarted = false;
hideAbortButton();
showInstallationStartSection();
return;
}
installationMode = 'polling';
}
// 결정된 모드를 라디오에 반영 (시각적 일관성)
const reflectedRadio = document.querySelector(`input[name="installation_mode"][value="${installationMode}"]`);
if (reflectedRadio) {
reflectedRadio.checked = true;
}
}
}
try {
// 설정 데이터 저장 (POST 요청) + 설치 모드 전달
const configData = window.INSTALLER_CONFIG || {};
const saveConfigUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/install-process.php`;
const existingDbAction = sessionStorage.getItem('existing_db_action') || 'skip';
const response = await fetch(saveConfigUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: configData,
installation_mode: installationMode,
existing_db_action: existingDbAction,
}),
});
const result = await response.json();
if (!result.success) {
if (result.env_required) {
installationStarted = false;
hideAbortButton();
showFileSetupSection(result.missing_files || [], null, null, result.base_path, result.is_windows || false);
return;
}
showError(result.message || lang('installation_start_failed'));
return;
}
// 공통 모니터 콜백 정의
const stateUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/state-management.php?action=get`;
const callbacks = {
onConnected: () => {},
onTaskStart: (taskId, name, target) => {
updateTaskStatus(taskId, 'active', name, target || null);
},
onTaskComplete: (taskId, message, target) => {
updateTaskStatus(taskId, 'completed', message, target || null);
},
onLog: (message) => {
addLogMessage(message);
},
onComplete: (data) => {
installationCompleted = true;
hideAbortButton();
fetch(stateUrl, { cache: 'no-store' })
.then((res) => res.json())
.then((stateData) => showCompletionSection(stateData))
.catch(() => {
if (data && data.redirect) {
window.location.href = data.redirect;
}
});
},
onAbort: () => {
installationCompleted = true;
hideAbortButton();
fetchInstallationState()
.then((state) => {
showAbortedInstallationSection(state);
})
.catch(() => {
window.location.reload();
});
},
onRollbackFailed: (data) => {
lastRollbackFailure = {
message: (data && data.message) || lang('failed_rollback_manual_cleanup'),
detail: (data && data.detail) || lang('failed_rollback_manual_cleanup_detail'),
};
},
onError: (data) => {
const errorMessage = translateWithKey(
data.message_key,
data.message || lang('installation_error_occurred')
);
const errorDetail = data.error || null;
const failedTaskId = data.task || null;
showError(errorMessage, errorDetail, failedTaskId);
},
onConnectionTimeout: () => {
// SSE 모드에서 연결 timeout 시 — 사전 체크 통과 후의 예외 케이스라
// 자동 폴링 fallback 대신 사용자에게 명시적 에러 노출. 사용자가 페이지를
// 새로고침하면 sse-probe 재실행으로 모드 재결정.
if (installationCompleted) return;
showError(lang('sse_connection_timeout'), lang('sse_server_config_guide'));
},
};
// 모드별 monitor 생성
const MonitorNS = window.G7InstallationMonitor;
if (!MonitorNS) {
showError('installation-monitor.js not loaded');
return;
}
if (installationMode === 'polling') {
currentMonitor = new MonitorNS.PollingMonitor(callbacks, {
stateUrl: stateUrl,
intervalMs: 1000,
});
} else {
const workerUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/install-worker.php`;
currentMonitor = new MonitorNS.SseMonitor(callbacks, {
workerUrl: workerUrl,
connectionTimeoutMs: 5000,
});
}
currentMonitor.start();
} catch (error) {
showError(`${lang('installation_start_error')}: ${error.message}`);
}
}
/**
* 작업 상태 업데이트
* @param {string} taskId 작업 ID
* @param {string} status 상태
* @param {string} message 메시지
* @param {string|null} target 확장 식별자 (옵션)
*/
function updateTaskStatus(taskId, status, message, target = null) {
// 통합 함수 사용 (target 전달)
setTaskStatus(taskId, status, target);
if (status === 'active') {
// 현재 작업명 업데이트
const currentTaskText = document.getElementById('current-task');
if (currentTaskText) {
// target이 있으면 확장 이름과 조합 (예: "게시판 모듈 설치 (sirsoft-board)")
let taskName = getTaskName(taskId);
if (target) {
const extName = getStoredExtensionName(target);
taskName = extName
? `${extName} ${taskName} (${target})`
: `${taskName} (${target})`;
}
currentTaskText.textContent = taskName || message || '';
}
} else if (status === 'completed') {
// 진행률 업데이트
updateProgressBar();
}
}
/**
* 진행률 바 업데이트
*/
function updateProgressBar() {
const completedTasks = document.querySelectorAll('.task-item.task-completed').length;
const totalTasks = document.querySelectorAll('.task-item').length || INSTALLATION_TASKS.length;
const percentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
const progressBar = document.getElementById('overall-progress-bar');
const progressPercentage = document.getElementById('overall-percentage');
if (progressBar) {
progressBar.style.width = percentage + '%';
}
if (progressPercentage) {
progressPercentage.textContent = percentage + '%';
}
}
/**
* 로그 메시지 추가
*/
function addLogMessage(message) {
const logDiv = document.getElementById('install-log');
if (!logDiv) return;
// 로그 플레이스홀더 제거
const placeholder = logDiv.querySelector('.log-placeholder');
if (placeholder) {
placeholder.remove();
}
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.textContent = message;
logDiv.appendChild(logEntry);
// 자동 스크롤
logDiv.scrollTop = logDiv.scrollHeight;
}
/**
* 기존 로그 표시 (페이지 로드 시)
*
* @param {Array} logs 로그 배열 [{timestamp, message}, ...] 또는 문자열 배열
*/
function displayLogs(logs) {
const logDiv = document.getElementById('install-log');
if (!logDiv) return;
// 로그 플레이스홀더 제거
const placeholder = logDiv.querySelector('.log-placeholder');
if (placeholder) {
placeholder.remove();
}
// 로그가 없으면 플레이스홀더 유지
if (!logs || logs.length === 0) {
return;
}
// 로그 메시지 추가
logs.forEach(log => {
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
// 로그가 객체인 경우 message 속성 사용, 문자열인 경우 그대로 사용
if (typeof log === 'object' && log.message) {
logEntry.textContent = log.message;
} else if (typeof log === 'string') {
logEntry.textContent = log;
} else {
logEntry.textContent = JSON.stringify(log);
}
logDiv.appendChild(logEntry);
});
// 자동 스크롤
logDiv.scrollTop = logDiv.scrollHeight;
}
/**
* 그룹 레이블 번역
* @param {string} labelKey 번역 키
* @returns {string} 번역된 레이블
*/
function getGroupLabel(labelKey) {
return lang(labelKey);
}
/**
* 작업 표시 이름 생성 (target이 있는 경우 조합)
* 예: "게시판 모듈 설치 (sirsoft-board)"
* @param {Object} task 작업 객체
* @returns {string} 표시 이름
*/
function getTaskDisplayName(task) {
const baseName = getTaskName(task.id);
if (task.target) {
// 확장 이름 조회 (저장된 이름 사용)
const extName = getStoredExtensionName(task.target);
// 형식: "게시판 모듈 설치 (sirsoft-board)"
return extName
? `${extName} ${baseName} (${task.target})`
: `${baseName} (${task.target})`;
}
return baseName;
}
/**
* 작업 DOM ID 생성 (target이 있는 경우 조합)
* @param {Object} task 작업 객체
* @returns {string} DOM ID
*/
function getTaskDomId(task) {
if (task.target) {
return `task-${task.id}-${task.target}`;
}
return `task-${task.id}`;
}
/**
* 작업 목록 렌더링 (그룹화된 아코디언 UI)
*/
function renderTaskList() {
const taskListDiv = document.getElementById('task-list');
if (!taskListDiv) return;
const taskGroups = window.INSTALLER_TASK_GROUPS || [];
// 그룹이 없으면 기존 평면 렌더링
if (taskGroups.length === 0) {
const html = INSTALLATION_TASKS.map(task => {
const taskName = task.id ? getTaskName(task.id) : (task.name || '');
return `
<div id="task-${task.id}" class="task-item task-pending">
<span class="task-icon">⏳</span>
<span class="task-name">${escapeHtml(taskName)}</span>
</div>
`;
}).join('');
taskListDiv.innerHTML = html;
return;
}
// 그룹별 렌더링
const groupsHtml = taskGroups.map(group => {
// 그룹 내 작업 목록 평탄화
const tasks = [];
if (group.tasks && group.tasks.length > 0) {
group.tasks.forEach(taskOrArray => {
if (Array.isArray(taskOrArray)) {
taskOrArray.forEach(t => tasks.push(t));
} else {
tasks.push(taskOrArray);
}
});
}
// 작업이 없는 그룹은 건너뛰기
if (tasks.length === 0) return '';
const groupLabel = getGroupLabel(group.labelKey);
const tasksHtml = tasks.map(task => {
const taskDomId = getTaskDomId(task);
const taskName = getTaskDisplayName(task);
return `
<div id="${taskDomId}" class="task-item task-pending" data-task-id="${task.id}" data-target="${task.target || ''}">
<span class="task-icon"><svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/></svg></span>
<span class="task-name">${escapeHtml(taskName)}</span>
</div>
`;
}).join('');
return `
<div class="task-group" data-group-id="${group.id}">
<div class="task-group-header" onclick="toggleTaskGroup('${group.id}')">
<span class="task-group-toggle">▼</span>
<span class="task-group-label">${escapeHtml(groupLabel)}</span>
<span class="task-group-status status-pending">${lang('status_pending')}</span>
</div>
<div class="task-group-items">
${tasksHtml}
</div>
</div>
`;
}).join('');
taskListDiv.innerHTML = groupsHtml;
}
/**
* 완료된 작업 표시 (페이지 로드 시)
*
* @param {Array} completedTasks 완료된 작업 ID 배열 (예: ["db_migrate", "template_install:sirsoft-admin_basic"])
*/
function displayCompletedTasks(completedTasks) {
completedTasks.forEach(taskIdWithTarget => {
// taskId:target 형식 파싱 (예: "template_install:sirsoft-admin_basic")
const [taskId, target] = taskIdWithTarget.includes(':')
? taskIdWithTarget.split(':')
: [taskIdWithTarget, null];
// 통합 함수 사용
setTaskStatus(taskId, 'completed', target);
});
// 진행률 업데이트
updateProgressBar();
}
/**
* 결과/안내 섹션을 뷰포트로 부드럽게 스크롤합니다.
*
* 완료/실패/중단 안내는 페이지 최상단에 있는데, 설치 진행 중에는 사용자가 로그를 보느라
* 화면이 하단에 머물러 있는 경우가 많다. 그대로 두면 안내가 표시되어도 눈에 들어오지
* 않는다. 이미 최상단이면 스크롤은 no-op 이라 새로고침 복원 경로에서도 무해하다.
*
* 헤더 바가 sticky 이므로 섹션 상단이 가려지지 않도록 CSS 의 scroll-margin-top 이
* 여백을 확보한다 (installer.css 의 .result-section, #env-setup-section).
*
* @param {HTMLElement|null} sectionEl 스크롤 대상 섹션
*/
function scrollResultIntoView(sectionEl) {
if (!sectionEl) return;
// 모션 최소화를 선호하는 사용자는 즉시 이동
const reduceMotion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
sectionEl.scrollIntoView({
behavior: reduceMotion ? 'auto' : 'smooth',
block: 'start',
});
}
/**
* 완료 섹션 표시
*/
function showCompletionSection(data) {
installationCompleted = true; // 완료 플래그 설정 (beforeunload 경고 비활성화)
// 대기 UI 숨기고 진행 카드 표시 (새로고침 후 복원 경로 대응)
hideInstallationStartSection();
showInstallationProgressSection();
// 중단 버튼 숨김
hideAbortButton();
// 타이틀 숨기기
const titleEl = document.getElementById('installer-title');
if (titleEl) {
titleEl.style.display = 'none';
}
// 경고 메시지 숨기기
const warningAlert = document.getElementById('install-warning');
if (warningAlert) {
warningAlert.style.display = 'none';
}
// 진행률 100% 설정 (wrapper는 숨기기 전에)
const progressBar = document.getElementById('overall-progress-bar');
const progressPercentage = document.getElementById('overall-percentage');
if (progressBar) {
progressBar.style.width = '100%';
}
if (progressPercentage) {
progressPercentage.textContent = '100%';
}
// 현재 작업 텍스트를 "설치 완료"로 변경
const currentTaskEl = document.getElementById('current-task');
if (currentTaskEl) {
currentTaskEl.textContent = lang('installation_completed');
currentTaskEl.style.color = '#10b981';
currentTaskEl.style.fontWeight = 'bold';
}
// 설치 카드 body 숨기기 (기본적으로 접힌 상태)
const installationCardBody = document.getElementById('installation-card-body');
const installToggleIcon = document.getElementById('install-toggle-icon');
if (installationCardBody) {
installationCardBody.style.display = 'none';
}
if (installToggleIcon) {
installToggleIcon.classList.remove('hidden');
installToggleIcon.textContent = '▶';
}
// 완료 섹션 표시 (타이틀 영역에, fadeIn 효과)
const completionSection = document.getElementById('completion-section');
if (completionSection) {
completionSection.classList.remove('hidden');
completionSection.style.opacity = '0';
completionSection.style.transition = 'opacity 0.5s ease-in';
// 약간의 지연 후 페이드인
setTimeout(() => {
completionSection.style.opacity = '1';
}, 100);
}
// .env 최종 머지 — fire-and-forget. 응답을 기다리지 않으며,
// php artisan serve 의 .env mtime 워처가 트리거하는 워커 재시작은
// 완료 화면 노출 후 발생하므로 사용자 경험에 영향이 없다.
// keepalive: 탭 닫힘에도 요청 유지. catch: 워커 재시작으로 인한
// connection abort 를 에러로 표시하지 않는다 (의도된 동작).
try {
fetch('/install/api/finalize-env.php', {
method: 'POST',
keepalive: true,
headers: { 'X-Installer-Finalize': '1' },
}).catch(() => { /* 의도된 abort */ });
} catch (_) {
// fetch 자체가 실패해도 무시 — runtime.php 가 보존되어 다음 부팅 시 앱 정상 동작
}
// 완료 안내로 스크롤 (타이틀 숨김·카드 접기로 문서 높이가 바뀐 뒤에 호출)
scrollResultIntoView(completionSection);
}
/**
* 에러 표시
*
* @param {string} errorMessage 메인 오류 메시지
* @param {string|null} errorDetail 상세 오류 메시지 (선택)
* @param {string|null} failedTaskId 실패한 작업 ID (선택)
*/
function showError(errorMessage, errorDetail = null, failedTaskId = null) {
installationCompleted = true; // 완료 플래그 설정 (beforeunload 경고 비활성화)
// 대기 UI 숨기고 진행 카드 표시 (실패 경로 — 작업 목록/로그 노출)
hideInstallationStartSection();
showInstallationProgressSection();
// 중단 버튼 숨김
hideAbortButton();
// 타이틀 숨기기
const titleEl = document.getElementById('installer-title');
if (titleEl) {
titleEl.style.display = 'none';
}
// 경고 메시지 숨기기
const warningAlert = document.getElementById('install-warning');
if (warningAlert) {
warningAlert.style.display = 'none';
}
// 토글 아이콘 보이기 및 카드 헤더 활성화
const installToggleIcon = document.getElementById('install-toggle-icon');
const installationCardHeader = document.getElementById('installation-card-header');
const installationCardBody = document.getElementById('installation-card-body');
if (installToggleIcon) {
installToggleIcon.classList.remove('hidden');
}
if (installationCardHeader) {
installationCardHeader.style.cursor = 'pointer';
}
// 설치 카드 body 표시 (작업 목록과 로그 보이기)
if (installationCardBody) {
installationCardBody.style.display = 'block';
}
// failedTaskId가 파라미터로 제공되지 않은 경우, DOM에서 추출 시도
if (!failedTaskId) {
// 현재 진행 중인 작업(task-active)에서 추출
const activeTasks = document.querySelectorAll('.task-item.task-active');
activeTasks.forEach(taskEl => {
if (!failedTaskId) {
// task element의 ID에서 task ID 추출 (예: 'task-composer_install' -> 'composer_install')
const taskId = taskEl.id.replace('task-', '');
if (taskId) {
failedTaskId = taskId;
}
}
});
}
// Task ID를 번역된 이름으로 변환
const failedTaskName = failedTaskId ? getTaskName(failedTaskId) : null;
// 현재 진행 중인 작업(task-active)을 모두 실패로 표시
const activeTasks = document.querySelectorAll('.task-item.task-active');
activeTasks.forEach(taskEl => {
const taskId = taskEl.id.replace('task-', '');
// 통합 함수 사용
setTaskStatus(taskId, 'failed');
});
// 실패 섹션 표시
const failureSection = document.getElementById('failure-section');
const failureMessage = document.getElementById('failure-message');
const failureTitle = failureSection?.querySelector('.result-title');
if (failureSection) {
// 타이틀에 실패 단계 추가
if (failureTitle && failedTaskName) {
const baseTitle = lang('installation_failed');
failureTitle.textContent = `${baseTitle} - ${failedTaskName}`;
}
// 오류 메시지 설정 (구조화된 형식)
if (failureMessage) {
let messageHtml = '';
// 메인 오류 메시지
messageHtml += `
<div class="failure-main-message">
${escapeHtml(errorMessage)}
</div>
`;
// 상세 오류 (있을 경우만 - 아코디언 형식)
if (errorDetail) {
const accordionId = 'error-detail-' + Date.now();
messageHtml += `
<div class="failure-error-detail">
<button
class="error-detail-toggle"
type="button"
aria-expanded="false"
aria-controls="${accordionId}"
onclick="toggleErrorDetail(this, '${accordionId}')"
>
<span class="error-detail-toggle-icon">▶</span>
<strong>${lang('view_error_details')}</strong>
</button>
<div id="${accordionId}" class="error-detail-content">
<pre class="error-stack">${escapeHtml(errorDetail)}</pre>
</div>
</div>
`;
}
// 롤백 실패 안내 (있을 경우)
if (lastRollbackFailure) {
messageHtml += `
<div class="failure-rollback-notice">
<strong>${escapeHtml(lastRollbackFailure.message)}</strong>
<p>${escapeHtml(lastRollbackFailure.detail)}</p>
</div>
`;
lastRollbackFailure = null;
}
failureMessage.innerHTML = messageHtml;
}
// 실패 섹션 표시 (페이드인 효과)
failureSection.classList.remove('hidden');
failureSection.style.opacity = '0';
failureSection.style.transition = 'opacity 0.5s ease-in';
setTimeout(() => {
failureSection.style.opacity = '1';
}, 100);
}
// 실패 안내로 스크롤 (카드 body 를 펼친 뒤에 호출)
scrollResultIntoView(failureSection);
}
/**
* 설치 계속 진행 (실패/중단 공통 함수)
*/
function continueInstallation() {
// 타이틀 다시 표시 및 초기화
const titleEl = document.getElementById('installer-title');
if (titleEl) {
titleEl.style.display = 'block';
titleEl.textContent = lang('installation_title');
titleEl.style.color = ''; // 기본 색상
}
// 경고 메시지 다시 표시
const warningAlert = document.getElementById('install-warning');
if (warningAlert) {
warningAlert.style.display = 'block';
}
// 모든 결과 섹션 숨기기
['aborted-section', 'failure-section', 'completion-section'].forEach(sectionId => {
const section = document.getElementById(sectionId);
if (section) {
section.classList.add('hidden');
}
});
// 설치 카드 다시 표시 및 초기화
const installationCardBody = document.getElementById('installation-card-body');
const installationCardHeader = document.getElementById('installation-card-header');
const installToggleIcon = document.getElementById('install-toggle-icon');
const logDiv = document.getElementById('install-log');
if (installationCardBody) {
installationCardBody.style.display = 'block';
}
if (installationCardHeader) {
installationCardHeader.style.cursor = 'default';
}
if (installToggleIcon) {
installToggleIcon.classList.add('hidden');
}
if (logDiv) {
// 기존 로그 유지, 플레이스홀더만 제거
const placeholder = logDiv.querySelector('.log-placeholder');
if (placeholder) {
placeholder.remove();
}
// 구분선 추가 (기존 로그와 새 로그 구분)
const separator = document.createElement('div');
separator.className = 'log-entry log-separator';
separator.textContent = '--- ' + lang('installation_resuming') + ' ---';
logDiv.appendChild(separator);
}
// 상태 초기화
installationStarted = false;
installationCompleted = false;
if (currentMonitor) {
currentMonitor.stop();
currentMonitor = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
// 설치 재시작
startInstallation();
}
/**
* 설치 재시도 (continueInstallation 래퍼)
*/
function retryInstallation() {
continueInstallation();
}
/**
* 재진입 감지 및 확인
*/
async function checkAndStartInstallation() {
try {
// 현재 설치 상태 확인
const state = await fetchInstallationState();
// 설치가 이미 완료된 경우
if (state.status === 'completed') {
showCompletionSection(state);
return;
}
// 설치가 실패한 경우 - 실패 섹션 표시
if (state.status === 'failed') {
// 페이지 로드 시 완료된 작업 표시
renderTaskList();
displayCompletedTasks(state.completed_tasks || []);
// 로그 표시
displayLogs(state.logs || []);
// 실패 정보 가져오기 (state.json에서 복원)
const errorMessage = translateWithKey(
state.error_message_key,
state.error || lang('unknown_error_occurred')
);
const errorDetail = state.error_detail || null;
const failedTaskId = state.failed_task;
// ✅ showError() 호출 전에 먼저 task를 failed로 마크
if (failedTaskId) {
setTaskStatus(failedTaskId, 'failed');
}
// 롤백 실패 정보 복원 (state.json에서 새로고침 후에도 표시)
if (state.rollback_failure) {
lastRollbackFailure = {
message: lang(state.rollback_failure.message_key) || lang('failed_rollback_manual_cleanup'),
detail: lang(state.rollback_failure.detail_key) || lang('failed_rollback_manual_cleanup_detail'),
};
}
// ✅ failedTaskId를 명시적으로 전달 (함수 내부에서 번역됨)
showError(errorMessage, errorDetail, failedTaskId);
return;
}
// 설치가 중단된 경우 - 중단 섹션 표시
if (state.status === 'aborted') {
showAbortedInstallationSection(state);
return;
}
// 설치가 진행 중이었던 경우 (재진입) - alert 없이 바로 중단 섹션 표시
if (state.status === 'running') {
// installation_status를 aborted로 변경
const abortUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/state-management.php?action=abort`;
fetch(abortUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
}).then(() => {
// 상태를 다시 가져와서 중단 섹션 표시
fetch(apiUrl)
.then(res => res.json())
.then(updatedState => {
showAbortedInstallationSection(updatedState);
});
}).catch(err => {
// 실패해도 중단 섹션은 표시
showAbortedInstallationSection(state);
});
} else {
// 처음 시작하는 경우 — 필수 파일 체크 후 "설치 시작" 버튼 표시 (자동 시작 금지)
// 운영자가 모드(SSE/폴링)를 선택한 뒤 명시적으로 시작할 수 있도록 대기
const filesReady = await checkFilesBeforeInstall();
if (filesReady) {
showInstallationStartSection();
}
}
} catch (error) {
// 오류 발생 시에도 시작 버튼 노출 (자동 시작 금지)
showInstallationStartSection();
}
}
/**
* 설치 시작 대기 UI 표시 — 모드 선택 카드 + "설치 시작" 버튼을 노출한다.
* 진행 UI(경고 alert, 진행 카드)는 숨겨진 상태 유지.
*/
function showInstallationStartSection() {
const startSection = document.getElementById('installation-start-section');
const modeCard = document.getElementById('installation-mode-card');
const warning = document.getElementById('install-warning');
const progressCard = document.getElementById('installation-progress-card');
if (startSection) startSection.style.display = '';
if (modeCard) modeCard.style.display = '';
if (warning) warning.style.display = 'none';
if (progressCard) progressCard.style.display = 'none';
// 모드 선택 라디오 활성화 (사전 체크 취소 후 재시도 가능하도록)
document.querySelectorAll('input[name="installation_mode"]').forEach(input => {
input.disabled = false;
});
}
/**
* 설치 시작 대기 UI 숨김 — 설치 진행 시작 시 호출.
* 모드 선택 카드는 비활성화(변경 불가) 상태로 유지하되 숨기지는 않는다.
*/
function hideInstallationStartSection() {
const startSection = document.getElementById('installation-start-section');
if (startSection) startSection.style.display = 'none';
// 모드 선택 라디오 비활성화 (진행 중 변경 방지)
document.querySelectorAll('input[name="installation_mode"]').forEach(input => {
input.disabled = true;
});
}
/**
* 설치 진행 UI 표시 — 경고 alert + 진행 카드 노출.
*/
function showInstallationProgressSection() {
const warning = document.getElementById('install-warning');
const progressCard = document.getElementById('installation-progress-card');
if (warning) warning.style.display = '';
if (progressCard) progressCard.style.display = '';
}
/**
* "설치 시작" 버튼 클릭 핸들러 — 사용자가 모드 선택 후 명시적으로 시작.
*/
function onStartInstallationClick() {
// startInstallation()이 라디오 값을 읽어 모드 결정
startInstallation();
}
window.onStartInstallationClick = onStartInstallationClick;
/**
* 중단된 설치 섹션 표시 (Step 4)
*
* @param {Object} state 설치 상태 정보
*/
function showAbortedInstallationSection(state) {
installationCompleted = true; // 완료 플래그 설정 (beforeunload 경고 비활성화)
// 대기 UI 숨기고 진행 카드 표시 (중단 경로 — 작업 목록/로그 노출)
hideInstallationStartSection();
showInstallationProgressSection();
// 중단 버튼 숨김
hideAbortButton();
// 타이틀 숨기기
const titleEl = document.getElementById('installer-title');
if (titleEl) {
titleEl.style.display = 'none';
}
// 경고 메시지 숨기기
const warningAlert = document.getElementById('install-warning');
if (warningAlert) {
warningAlert.style.display = 'none';
}
// 중단 섹션 표시
const abortedSection = document.getElementById('aborted-section');
if (abortedSection) {
// 롤백 실패 정보가 있으면 경고 메시지 추가
if (state.rollback_failure) {
const resultText = abortedSection.querySelector('.result-text');
if (resultText) {
const rollbackAlert = document.createElement('div');
rollbackAlert.className = 'alert alert-danger';
rollbackAlert.style.marginTop = '0.75rem';
rollbackAlert.style.fontSize = '0.875rem';
const msg = lang(state.rollback_failure.message_key) || lang('failed_rollback_manual_cleanup');
const detail = lang(state.rollback_failure.detail_key) || lang('failed_rollback_manual_cleanup_detail');
rollbackAlert.innerHTML = '<strong>' + escapeHtml(msg) + '</strong><br>' + escapeHtml(detail);
resultText.appendChild(rollbackAlert);
}
}
abortedSection.classList.remove('hidden');
abortedSection.style.opacity = '0';
abortedSection.style.transition = 'opacity 0.5s ease-in';
setTimeout(() => {
abortedSection.style.opacity = '1';
}, 100);
} else {
}
// 작업 목록 렌더링
renderTaskList();
// 완료된 작업 표시
const completedTasks = state.completed_tasks || [];
const currentTask = state.current_task;
displayCompletedTasks(completedTasks);
// 로그 표시
displayLogs(state.logs || []);
// 설치 카드 body 표시 (작업 목록 보이기)
const installationCardBody = document.getElementById('installation-card-body');
if (installationCardBody) {
installationCardBody.style.display = 'block';
}
// 현재 작업을 중단으로 표시
if (currentTask) {
// taskId:target 형식 파싱 (예: "module_install:sirsoft-board")
const [taskId, target] = currentTask.includes(':')
? currentTask.split(':')
: [currentTask, null];
// 통합 함수 사용
setTaskStatus(taskId, 'aborted', target);
} else {
// current_task가 null인 경우, 마지막 완료된 작업 다음 작업을 중단으로 표시
const allTasks = window.INSTALLER_TASKS || [];
// completedTasks의 마지막 항목에서 taskId와 target 분리
const lastCompleted = completedTasks[completedTasks.length - 1] || '';
const [lastTaskId, lastTarget] = lastCompleted.includes(':')
? lastCompleted.split(':')
: [lastCompleted, null];
// INSTALLER_TASKS에서 해당 작업 찾기 (target 포함 매칭)
const lastCompletedIndex = allTasks.findIndex(t => {
if (lastTarget) {
return t.id === lastTaskId && t.target === lastTarget;
}
return t.id === lastTaskId && !t.target;
});
if (lastCompletedIndex >= 0 && lastCompletedIndex < allTasks.length - 1) {
const nextTask = allTasks[lastCompletedIndex + 1];
// 통합 함수 사용
setTaskStatus(nextTask.id, 'aborted', nextTask.target || null);
}
}
// 중단 안내로 스크롤 (로그 렌더링으로 문서 높이가 늘어난 뒤에 호출)
scrollResultIntoView(abortedSection);
}
/**
* 중단된 설치에서 계속 진행 (continueInstallation 래퍼)
*/
function resumeInstallationFromAborted() {
continueInstallation();
}
/**
* 설정 페이지로 이동 (확인 후)
*/
async function goToSettingsWithConfirm() {
const confirmed = confirm(lang('confirm_go_to_settings_simple'));
if (confirmed) {
try {
// 상태 초기화 API 호출
const response = await fetch(getStateApiUrl('reset'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const result = await response.json();
if (result.success) {
// 설정 페이지로 이동 (세션 기반)
window.location.href = `${window.INSTALLER_BASE_URL}/`;
} else {
alert(lang('error_state_reset_failed').replace(':message', result.message));
}
} catch (error) {
// 오류가 발생해도 이동은 허용 (강제 이동)
const forceMove = confirm(lang('confirm_force_go_to_settings'));
if (forceMove) {
window.location.href = `${window.INSTALLER_BASE_URL}/`;
}
}
}
}
/**
* 설치 페이지 자동 시작
*/
function initInstallation() {
if (document.getElementById('task-list')) {
// 페이지 이탈 경고 설정
window.addEventListener('beforeunload', function(e) {
// 설치가 시작되었고, 아직 완료되지 않은 경우에만 경고
if (installationStarted && !installationCompleted) {
const message = lang('confirm_leave_installation');
e.preventDefault();
e.returnValue = message; // Chrome에서 필요
return message; // 일부 브라우저에서 필요
}
});
// 페이지 언로드 시 abort 신호 전송 (브라우저 종료/새로고침)
window.addEventListener('pagehide', function(e) {
// 설치가 시작되었고, 아직 완료되지 않은 경우에만 abort 전송
if (installationStarted && !installationCompleted) {
const apiUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/state-management.php?action=abort`;
// sendBeacon은 페이지 언로드 중에도 전송을 보장
navigator.sendBeacon(apiUrl, new Blob([JSON.stringify({})], {type: 'application/json'}));
}
});
// 페이지 로드 시 상태 확인 후 시작
checkAndStartInstallation();
}
}
// =========================================================================
// 설치 중단 기능
// =========================================================================
/**
* 설치 중단
*/
async function abortInstallation() {
// 설치가 이미 완료되었으면 중단 불가
if (installationCompleted) {
alert(lang('installation_already_completed_message'));
return;
}
// 버튼 중복 클릭 방지
const abortBtn = document.getElementById('abort-installation-btn');
if (abortBtn && abortBtn.disabled) {
return;
}
// 확인 다이얼로그 표시
const confirmed = confirm(
lang('confirm_abort_installation')
);
if (!confirmed) {
return;
}
// 중단 처리 시작 - beforeunload 경고 방지
installationCompleted = true;
// 중단 버튼 비활성화 및 로딩 표시
if (abortBtn) {
abortBtn.disabled = true;
abortBtn.innerHTML = `
<svg class="abort-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="spinner" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
${lang('aborting')}
`;
}
try {
// Abort API 호출
const response = await fetch(getStateApiUrl('abort'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
// SSE 연결 닫기
if (eventSource) {
eventSource.close();
eventSource = null;
}
// 즉시 중단 버튼 숨김 (UI 즉각 반응)
hideAbortButton();
// state.json 동기화를 위해 짧은 지연 후 상태 조회
setTimeout(() => {
fetchInstallationState()
.then(state => {
showAbortedInstallationSection(state);
})
.catch(err => {
// 페이지 리로드로 폴백 (세션 기반)
window.location.href = `${window.INSTALLER_BASE_URL}/`;
});
}, 300);
} else {
// 중단 실패 시 플래그 복원
installationCompleted = false;
alert((window.INSTALLER_LANG?.abort_failed || 'Abort failed') + ': ' + (result.message || window.INSTALLER_LANG?.unknown_error_occurred || 'Unknown error'));
// 버튼 복원
if (abortBtn) {
abortBtn.disabled = false;
abortBtn.innerHTML = `
<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>
${lang('abort_installation')}
`;
}
}
} catch (error) {
// 중단 실패 시 플래그 복원
installationCompleted = false;
alert(lang('abort_error_occurred') + ': ' + error.message);
// 버튼 복원
if (abortBtn) {
abortBtn.disabled = false;
abortBtn.innerHTML = `
<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>
${lang('abort_installation')}
`;
}
}
}
/**
* 중단 버튼 표시 (초기 상태로 리셋)
*/
function showAbortButton() {
const abortBtn = document.getElementById('abort-installation-btn');
if (abortBtn) {
// 버튼을 초기 상태로 리셋
abortBtn.disabled = false;
abortBtn.innerHTML = `
<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>
${lang('abort_installation')}
`;
abortBtn.style.display = 'flex';
}
}
/**
* 중단 버튼 숨김
*/
function hideAbortButton() {
const abortBtn = document.getElementById('abort-installation-btn');
if (abortBtn) {
abortBtn.style.display = 'none';
}
}
// =========================================================================
// 재진입/재개 다이얼로그
// =========================================================================
/**
* 재개 다이얼로그 표시
*
* @param {Object} state 설치 상태 데이터
*/
// =========================================================================
// 다크모드 토글 기능
// =========================================================================
const THEME_STORAGE_KEY = 'g7_color_scheme';
/**
* 현재 테마 가져오기
*/
function getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') || 'light';
}
/**
* 테마 설정 및 저장
*/
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
updateThemeIcon(theme);
}
/**
* 테마 토글
*/
function toggleTheme() {
const currentTheme = getCurrentTheme();
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
// 버튼에 페이드 애니메이션 추가
const toggleBtn = document.getElementById('theme-toggle-btn');
if (toggleBtn) {
toggleBtn.classList.add('transitioning');
setTimeout(() => {
toggleBtn.classList.remove('transitioning');
}, 500);
}
setTheme(newTheme);
}
/**
* 테마 아이콘 업데이트
*/
function updateThemeIcon(theme) {
const iconElement = document.getElementById('theme-toggle-icon');
if (iconElement) {
// SVG 아이콘으로 변경
if (theme === 'dark') {
// 라이트 모드로 전환할 수 있는 태양 아이콘
iconElement.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
`;
} else {
// 다크 모드로 전환할 수 있는 달 아이콘
iconElement.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
`;
}
}
}
/**
* 다크모드 토글 버튼 초기화
* 참고: 테마는 이미 HTML에서 적용됨 (FOUC 방지)
*/
function initThemeToggle() {
const toggleBtn = document.getElementById('theme-toggle-btn');
if (toggleBtn) {
// 현재 테마에 맞는 아이콘으로 업데이트
const currentTheme = getCurrentTheme();
updateThemeIcon(currentTheme);
// 토글 버튼 클릭 이벤트 등록
toggleBtn.addEventListener('click', toggleTheme);
}
// 시스템 다크모드 설정 변경 감지
if (window.matchMedia) {
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkModeQuery.addEventListener('change', (e) => {
// localStorage에 저장된 값이 없을 때만 시스템 설정 따라감
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
const newTheme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
updateThemeIcon(newTheme);
}
});
}
}
// =========================================================================
// 유틸리티 함수
// =========================================================================
/**
* 설치 카드 토글
* 완료/실패 시에만 작동
*/
function toggleInstallationCard() {
// 설치 진행 중에는 토글 불가
if (!installationCompleted) {
return;
}
const cardBody = document.getElementById('installation-card-body');
const toggleIcon = document.getElementById('install-toggle-icon');
if (!cardBody) {
return;
}
const isHidden = cardBody.style.display === 'none';
if (isHidden) {
// 카드 body 보이기
cardBody.style.display = 'block';
if (toggleIcon) toggleIcon.textContent = '▼';
} else {
// 카드 body 숨기기
cardBody.style.display = 'none';
if (toggleIcon) toggleIcon.textContent = '▶';
}
}
/**
* 오류 상세 아코디언 토글
*/
function toggleErrorDetail(button, contentId) {
const content = document.getElementById(contentId);
const isExpanded = button.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
// 닫기
button.setAttribute('aria-expanded', 'false');
content.classList.remove('show');
} else {
// 열기
button.setAttribute('aria-expanded', 'true');
content.classList.add('show');
}
}
/**
* HTML 이스케이프
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* AJAX 요청 헬퍼 함수
*
* @param {string} url 요청 URL
* @param {Object} options Fetch API 옵션
* @return {Promise<Object>} JSON 응답
*/
async function fetchJson(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
throw error;
}
}
/**
* 작업 상태를 통합 관리 (아이콘 + 클래스)
*
* @param {string} taskId 작업 ID
* @param {string} status 상태 (active, completed, failed, aborted)
* @param {string|null} target 확장 식별자 (옵션)
*/
function setTaskStatus(taskId, status, target = null) {
// target이 있으면 조합된 ID, 없으면 기본 ID
const domId = target ? `task-${taskId}-${target}` : `task-${taskId}`;
const taskEl = document.getElementById(domId);
if (!taskEl) return;
const icon = taskEl.querySelector('.task-icon');
// 기존 상태 클래스 제거
taskEl.classList.remove('task-pending', 'task-active', 'task-completed', 'task-failed', 'task-aborted');
// 새로운 상태 적용
switch(status) {
case 'active':
taskEl.classList.add('task-active');
if (icon) {
icon.innerHTML = getIconSvg('spinner');
}
break;
case 'completed':
taskEl.classList.add('task-completed');
if (icon) {
icon.innerHTML = getIconSvg('completed');
}
break;
case 'failed':
taskEl.classList.add('task-failed');
if (icon) {
icon.innerHTML = getIconSvg('failed');
icon.style.color = '#ef4444';
}
break;
case 'aborted':
taskEl.classList.add('task-aborted');
if (icon) {
icon.innerHTML = getIconSvg('warning');
}
break;
}
// 그룹 상태 업데이트
updateGroupStatus(taskEl);
}
/**
* 그룹 상태 배지 업데이트
*
* 작업 상태가 변경될 때마다 해당 그룹의 상태 배지를 업데이트합니다.
* - 대기 중: 회색 "대기"
* - 진행 중: 파란색 "n/m" (완료/전체)
* - 완료: 초록색 "완료" (자동 접힘)
* - 실패: 빨간색 "실패"
* - 중단: 주황색 "중단"
*
* @param {HTMLElement} taskEl 작업 요소
*/
function updateGroupStatus(taskEl) {
const group = taskEl.closest('.task-group');
if (!group) return;
const statusEl = group.querySelector('.task-group-status');
if (!statusEl) return;
const tasks = group.querySelectorAll('.task-item');
const completedCount = group.querySelectorAll('.task-item.task-completed').length;
const failedCount = group.querySelectorAll('.task-item.task-failed').length;
const abortedCount = group.querySelectorAll('.task-item.task-aborted').length;
const activeCount = group.querySelectorAll('.task-item.task-active').length;
const totalCount = tasks.length;
// 상태 클래스 초기화
statusEl.classList.remove('status-pending', 'status-in-progress', 'status-completed', 'status-error', 'status-aborted');
if (failedCount > 0) {
statusEl.textContent = lang('status_failed');
statusEl.classList.add('status-error');
} else if (abortedCount > 0) {
statusEl.textContent = lang('status_aborted');
statusEl.classList.add('status-aborted');
} else if (completedCount === totalCount) {
statusEl.textContent = lang('status_completed');
statusEl.classList.add('status-completed');
// 완료된 그룹은 자동으로 접기
group.classList.add('collapsed');
} else if (activeCount > 0 || completedCount > 0) {
statusEl.textContent = `${completedCount}/${totalCount}`;
statusEl.classList.add('status-in-progress');
// 진행 중인 그룹은 펼치기
group.classList.remove('collapsed');
} else {
statusEl.textContent = lang('status_pending');
statusEl.classList.add('status-pending');
}
}
/**
* 작업 그룹 토글 (아코디언)
*
* @param {string} groupId 그룹 ID
*/
window.toggleTaskGroup = function(groupId) {
const group = document.querySelector(`.task-group[data-group-id="${groupId}"]`);
if (group) {
group.classList.toggle('collapsed');
}
};
/**
* 공통 아이콘 SVG 생성
*
* @param {string} type 아이콘 타입 (completed, failed, warning, spinner)
* @param {string} size 아이콘 크기 (기본값: 20px, 옵션: 24px 등)
* @param {string} className 추가 CSS 클래스
* @return {string} SVG HTML 문자열
*/
function getIconSvg(type, size = '20px', className = '') {
const svgs = {
completed: `
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: ${size}; height: ${size};" class="${className}">
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M6 10l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
failed: `
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: ${size}; height: ${size};" class="${className}">
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`,
warning: `
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" style="width: ${size}; height: ${size};" class="${className}">
<path d="M10 2L2 17h16L10 2z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" fill="none"/>
<path d="M10 8v4M10 14v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`,
spinner: '<div class="task-icon-spinner"></div>'
};
return svgs[type] || '';
}
// =========================================================================
// 단계 이동 (세션 기반)
// =========================================================================
/**
* 지정된 단계로 이동
*
* @param {number} step 이동할 단계 번호 (0-5)
*/
function goToStep(step) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '';
const stepInput = document.createElement('input');
stepInput.type = 'hidden';
stepInput.name = 'go_to_step';
stepInput.value = step.toString();
form.appendChild(stepInput);
document.body.appendChild(form);
form.submit();
}
/**
* 전역 함수 노출
*/
function exposeGlobalFunctions() {
// 다국어 함수
window.lang = lang;
// 유틸리티 함수
window.escapeHtml = escapeHtml;
window.fetchJson = fetchJson;
// 단계 이동
window.goToStep = goToStep;
// Step 2
window.recheckRequirements = recheckRequirements;
window.copyPermissionCommand = copyPermissionCommand;
// Step 3
window.testDatabaseConnection = testDatabaseConnection;
window.toggleReadDbFields = toggleReadDbFields;
// Step 4
window.retryInstallation = retryInstallation;
window.toggleInstallationCard = toggleInstallationCard;
window.toggleErrorDetail = toggleErrorDetail;
window.abortInstallation = abortInstallation;
window.resumeInstallationFromAborted = resumeInstallationFromAborted;
window.goToSettingsWithConfirm = goToSettingsWithConfirm;
// Step 5: 파일 체크
window.recheckFiles = recheckFiles;
window.copySetupCommand = copySetupCommand;
window.copySetupCommandRelative = copySetupCommandRelative;
}
// =========================================================================
// 전역 중단 감지 (모든 스텝)
// =========================================================================
/**
* 설치 중단 여부 확인 및 Step 4로 리다이렉트
* 모든 스텝에서 페이지 로드 시 자동 실행
*/
/**
* 설치 중단/실패 여부 확인 및 올바른 Step으로 리다이렉트
* 모든 스텝에서 페이지 로드 시 자동 실행
*
* Step 체계 (2026-02-06 업데이트):
* - Step 3: Configuration (DB 설정)
* - Step 4: Extension Selection (확장 선택)
* - Step 5: Installation (설치 진행)
*/
async function checkAbortedInstallation() {
try {
const apiUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/state-management.php?action=get`;
const response = await fetch(apiUrl);
const result = await response.json();
if (result && result.status) {
const status = result.status;
const currentStep = parseInt(window.CURRENT_STEP || '0', 10);
const stateStep = parseInt(result.current_step || '0', 10);
// Step 5 (Installation)에서는 checkAndStartInstallation()이 처리하므로 여기서는 무시
if (currentStep === 5) {
return;
}
// aborted 또는 failed 상태일 때, state의 current_step과 현재 화면이 다르면 리다이렉트
// 예: 설치 실패 후 Step 4 (확장 선택) 또는 Step 3 (DB 설정)으로 돌아가야 할 때
if ((status === 'aborted' || status === 'failed') && currentStep !== stateStep) {
window.location.href = `${window.INSTALLER_BASE_URL}/`;
}
}
} catch (error) {
// 에러 시 무시 (네트워크 문제 등)
}
}
// =========================================================================
// Step 2: 권한 수정 안내 (B11-c)
// =========================================================================
/**
* 권한 수정 명령어 클립보드 복사
*
* @param {HTMLButtonElement} btn 클릭된 복사 버튼
*/
function copyPermissionCommand(btn) {
const pre = btn.previousElementSibling;
if (!pre) return;
const command = pre.textContent;
const showCopied = () => {
const originalText = btn.textContent;
btn.textContent = lang('copied');
setTimeout(() => { btn.textContent = originalText; }, 2000);
};
const fallback = () => {
const range = document.createRange();
range.selectNodeContents(pre);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('copy');
selection.removeAllRanges();
showCopied();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command).then(showCopied).catch(fallback);
} else {
fallback();
}
}
// =========================================================================
// Step 5: 필수 파일 체크 (B11-a)
// =========================================================================
/**
* Step 5 시작 전 필수 파일(.env) 존재 여부 확인
*
* @returns {Promise<boolean>} 파일이 모두 존재하면 true
*/
async function checkFilesBeforeInstall() {
try {
const checkUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/check-env.php`;
const response = await fetch(checkUrl);
const check = await response.json();
const missingFiles = [];
const commands = [];
const relCommands = [];
const basePath = check.path || '';
const relativePath = check.relative_path || '.';
const isWin = check.is_windows || false;
if (!check.env_exists) {
missingFiles.push('.env');
if (isWin) {
const winBase = basePath.replace(/\//g, '\\');
commands.push(`copy ${winBase}\\.env.example ${winBase}\\.env`);
const winRel = relativePath.replace(/\//g, '\\');
relCommands.push(`copy ${winRel}\\.env.example ${winRel}\\.env`);
} else {
commands.push(`cp ${basePath}/.env.example ${basePath}/.env`);
relCommands.push(`cp ${relativePath}/.env.example ${relativePath}/.env`);
}
}
if (missingFiles.length > 0) {
showFileSetupSection(missingFiles, commands, relCommands, basePath, isWin);
return false;
}
return true;
} catch (error) {
// 체크 API 실패 시 설치 진행 허용 (install-process.php에서 재검증)
return true;
}
}
/**
* 필수 파일 생성 안내 섹션 표시
*
* @param {string[]} missingFiles 누락된 파일명 배열
* @param {string[]} [commands] 실행할 명령어 배열 (없으면 missingFiles로 생성)
*/
function showFileSetupSection(missingFiles, commands, relCommands, basePath, isWin) {
const section = document.getElementById('env-setup-section');
if (!section) return;
// 명령어가 전달되지 않은 경우 (install-process.php의 env_required 응답에서 호출)
if (!commands || commands.length === 0) {
const p = basePath || '';
commands = [];
relCommands = relCommands || [];
if (missingFiles.includes('.env')) {
if (isWin) {
const wp = p ? p.replace(/\//g, '\\') : '';
commands.push(wp ? `copy ${wp}\\.env.example ${wp}\\.env` : 'copy .env.example .env');
relCommands.push('copy .\\.env.example .\\.env');
} else {
commands.push(p ? `cp ${p}/.env.example ${p}/.env` : 'cp .env.example .env');
relCommands.push('cp ./.env.example ./.env');
}
}
}
// 누락 파일 목록 표시
const list = document.getElementById('missing-files-list');
if (list) {
list.innerHTML = missingFiles.map(f => `<li><code>${escapeHtml(f)}</code></li>`).join('');
}
// 절대경로 명령어 표시
const commandEl = document.getElementById('file-setup-command');
if (commandEl) {
commandEl.textContent = commands.join(' && ');
}
// 상대경로 명령어 표시
const relHint = document.getElementById('file-setup-relative-hint');
const relBox = document.getElementById('file-setup-relative-box');
const relCommandEl = document.getElementById('file-setup-command-relative');
if (relCommands && relCommands.length > 0 && relHint && relBox && relCommandEl) {
relCommandEl.textContent = relCommands.join(' && ');
relHint.style.display = '';
relBox.style.display = '';
}
// 섹션 표시
section.classList.remove('hidden');
// 버튼 상태 초기화
const btn = document.getElementById('env-recheck-btn');
if (btn) {
btn.disabled = false;
btn.textContent = lang('file_check_button');
}
// 상태 메시지 숨기기
const statusEl = document.getElementById('env-check-status');
if (statusEl) {
statusEl.classList.add('hidden');
}
// 안내 섹션으로 스크롤 (목록·명령어를 채운 뒤에 호출)
scrollResultIntoView(section);
}
/**
* 필수 파일 재확인 (확인 버튼 클릭 시)
*/
async function recheckFiles() {
const btn = document.getElementById('env-recheck-btn');
const statusEl = document.getElementById('env-check-status');
if (btn) {
btn.disabled = true;
btn.textContent = lang('file_checking');
}
try {
const checkUrl = `${window.location.origin}${window.INSTALLER_BASE_URL}/api/check-env.php`;
const response = await fetch(checkUrl);
const check = await response.json();
if (check.env_exists) {
// 파일 확인 완료 — 안내 섹션 숨기고 설치 시작
const section = document.getElementById('env-setup-section');
if (section) {
section.classList.add('hidden');
}
startInstallation();
} else {
// 아직 파일 미생성 — 안내 유지
if (btn) {
btn.disabled = false;
btn.textContent = lang('file_check_button');
}
// 여전히 누락된 파일 목록 갱신
const stillMissing = [];
if (!check.env_exists) stillMissing.push('.env');
const list = document.getElementById('missing-files-list');
if (list) {
list.innerHTML = stillMissing.map(f => `<li><code>${escapeHtml(f)}</code></li>`).join('');
}
if (statusEl) {
statusEl.textContent = lang('files_not_found_yet');
statusEl.classList.remove('hidden');
}
}
} catch (error) {
if (btn) {
btn.disabled = false;
btn.textContent = lang('file_check_button');
}
if (statusEl) {
statusEl.textContent = lang('files_not_found_yet');
statusEl.classList.remove('hidden');
}
}
}
/**
* 파일 생성 명령어 클립보드 복사
*/
function copySetupCommand() {
const commandEl = document.getElementById('file-setup-command');
if (!commandEl) return;
const command = commandEl.textContent;
const showCopied = () => {
const copyBtn = commandEl.closest('.code-box')?.querySelector('.btn-copy');
if (copyBtn) {
const originalText = copyBtn.textContent;
copyBtn.textContent = lang('copied');
setTimeout(() => { copyBtn.textContent = originalText; }, 2000);
}
};
const fallback = () => {
const range = document.createRange();
range.selectNodeContents(commandEl);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('copy');
selection.removeAllRanges();
showCopied();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command).then(showCopied).catch(fallback);
} else {
fallback();
}
}
/**
* 상대경로 명령어 클립보드 복사
*/
function copySetupCommandRelative() {
const commandEl = document.getElementById('file-setup-command-relative');
if (!commandEl) return;
const command = commandEl.textContent;
const showCopied = () => {
const copyBtn = commandEl.closest('.code-box')?.querySelector('.btn-copy');
if (copyBtn) {
const originalText = copyBtn.textContent;
copyBtn.textContent = lang('copied');
setTimeout(() => { copyBtn.textContent = originalText; }, 2000);
}
};
const fallback = () => {
const range = document.createRange();
range.selectNodeContents(commandEl);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('copy');
selection.removeAllRanges();
showCopied();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command).then(showCopied).catch(fallback);
} else {
fallback();
}
}
// =========================================================================
// 초기화
// =========================================================================
/**
* DOMContentLoaded 이벤트에서 초기화
*/
document.addEventListener('DOMContentLoaded', function() {
// 전역 함수 노출
exposeGlobalFunctions();
// 다크모드 토글 초기화 (모든 스텝)
initThemeToggle();
// 전역 중단 감지 (모든 스텝)
checkAbortedInstallation();
// Step 1: 라이선스 동의
initLicenseAgreement();
// Step 2: 요구사항 검증
initRequirementsCheck();
// Step 3: DB 테스트 및 실시간 검증
initDatabaseTest();
initRealTimeValidation();
initStep3FormSubmit();
// Step 4: 확장 선택
initExtensionSelection();
// Step 5: 설치 진행
initInstallation();
});
})();