Files
Gnuboard7/public/install/api/rollback-functions.php
T
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00

573 lines
19 KiB
PHP

<?php
/**
* G7 인스톨러 - 롤백 함수 모음
*
* 설치 중단 시 각 작업을 롤백하는 함수들을 제공합니다.
*/
/**
* 중단된 작업을 롤백합니다.
*
* @param string $taskId 롤백할 작업 식별자
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string] 롤백 결과
*/
function rollbackTask(string $taskId, array $state): array
{
try {
switch ($taskId) {
case 'db_migrate':
return rollbackDbMigrate();
case 'db_seed':
return rollbackDbSeed($state);
case 'complete_flag':
return rollbackCompleteFlag();
// 롤백 불필요한 작업들 (새로 생성하면 되는 것들)
case 'composer_check':
case 'composer_install':
case 'env_create':
case 'env_update':
case 'key_generate':
case 'cache_clear':
case 'create_settings_json':
return [
'success' => true,
'message' => lang('rollback_not_needed_recreatable'),
];
// 롤백 불필요한 확장 작업 (재설치/재활성화 시 덮어쓰기 가능)
case 'template_install':
case 'template_activate':
case 'module_install':
case 'module_activate':
case 'plugin_install':
case 'plugin_activate':
case 'user_template_install':
case 'user_template_activate':
return [
'success' => true,
'message' => lang('rollback_not_needed_overwritable'),
];
default:
return [
'success' => false,
'message' => lang('rollback_unknown_task', ['task' => $taskId]),
];
}
} catch (Exception $e) {
addLog(lang('rollback_error', ['task' => $taskId, 'error' => $e->getMessage()]));
return [
'success' => false,
'message' => lang('rollback_exception', ['error' => $e->getMessage()]),
];
}
}
/**
* 마이그레이션 롤백을 실행합니다 (migrate:rollback --force)
*
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function executeMigrateRollback(?string $logPrefix = null): array
{
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
try {
addLog("{$logPrefix} ".lang('rollback_migrate_start'));
$output = [];
$returnCode = 0;
$phpBin = escapeshellarg(function_exists('getPhpBinary') ? getPhpBinary() : 'php');
$command = sprintf(
'cd /d %s && %s artisan migrate:rollback --force 2>&1',
escapeshellarg(BASE_PATH),
$phpBin
);
exec($command, $output, $returnCode);
addLog("{$logPrefix} ".lang('rollback_migrate_result', ['result' => implode("\n", $output)]));
if ($returnCode === 0) {
return [
'success' => true,
'message' => lang('rollback_migrate_success'),
'output' => $output,
];
}
addLog("{$logPrefix} ".lang('rollback_migrate_failed_code', ['code' => $returnCode]));
return [
'success' => false,
'message' => lang('rollback_migrate_failed'),
'output' => $output,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => lang('rollback_migrate_error', ['error' => $e->getMessage()]),
'output' => [],
];
}
}
/**
* db_migrate 작업 롤백: migrate:rollback 실행
*
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function rollbackDbMigrate(): array
{
return executeMigrateRollback(lang('log_prefix_rollback'));
}
/**
* 시드 데이터 TRUNCATE를 실행합니다 (PDO 세션 내 FOREIGN_KEY_CHECKS 제어)
*
* @param array $config DB 연결 설정
* @param bool $force true: 무조건 TRUNCATE, false: 미완료 시에만 TRUNCATE
* @param string|null $logPrefix 로그 접두사 (null이면 lang('log_prefix_rollback') 사용)
* @return array ['success' => bool, 'message' => string]
*/
function executeSeedTruncate(array $config, bool $force = false, ?string $logPrefix = null): array
{
// state.config 에는 DB 비밀번호가 기록되지 않으므로(이슈 #465) runtime.php 에서 복원한다.
// 호출자(rollbackDbSeed / forceRollbackDbSeed) 가 state['config'] 를 그대로 넘기므로
// 각 호출자 대신 함수 진입부 1곳에서 처리한다. 롤백은 env_update 이후에만 발생하여
// runtime.php 가 항상 존재하며, 부재 시 헬퍼가 원본 config 를 그대로 반환한다.
require_once __DIR__.'/../includes/installer-runtime.php';
$config = hydrateDbSecretsFromRuntime($config);
$logPrefix = $logPrefix ?? lang('log_prefix_rollback');
if (empty($config['db_write_host']) || empty($config['db_write_database'])) {
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
// DSN 파라미터 주입 차단 — 정상 입력에는 ;/=/NUL/CRLF 가 들어갈 일이 없다.
foreach (['db_write_host', 'db_write_port', 'db_write_database'] as $field) {
$val = (string) ($config[$field] ?? '');
if ($val !== '' && preg_match('/[;=\0\r\n]/', $val)) {
addLog("{$logPrefix} ".lang('rollback_seed_no_config'));
return ['success' => true, 'message' => lang('rollback_seed_no_config')];
}
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$config['db_write_host'],
$config['db_write_port'] ?? '3306',
$config['db_write_database']
);
try {
$pdo = new PDO($dsn, $config['db_write_username'], $config['db_write_password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// 강제 모드가 아니면 완료 여부 확인
if (! $force) {
$stmt = $pdo->query("SHOW TABLES LIKE 'roles'");
if ($stmt->rowCount() > 0) {
$count = $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn();
if ($count > 0) {
addLog("{$logPrefix} ".lang('rollback_seed_already_done'));
return ['success' => true, 'message' => lang('rollback_seed_already_done')];
}
}
addLog("{$logPrefix} ".lang('rollback_seed_interrupted'));
} else {
addLog("{$logPrefix} ".lang('rollback_seed_force_truncate'));
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$tables = ['users', 'roles', 'permissions', 'role_user', 'permission_role'];
foreach ($tables as $table) {
try {
$pdo->exec("TRUNCATE TABLE `{$table}`");
addLog("{$logPrefix} ".lang('rollback_table_truncated', ['table' => $table]));
} catch (PDOException $e) {
addLog("{$logPrefix} ".lang('rollback_table_truncate_skipped', ['table' => $table, 'error' => $e->getMessage()]));
}
}
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
return ['success' => true, 'message' => lang('rollback_seed_data_deleted')];
} catch (PDOException $e) {
if ($force) {
return ['success' => false, 'message' => lang('rollback_db_connection_failed', ['error' => $e->getMessage()])];
}
addLog("{$logPrefix} ".lang('rollback_db_connection_failed_skip'));
return ['success' => true, 'message' => lang('rollback_db_connection_failed_skip')];
}
}
/**
* db_seed 작업 롤백: 중단된 경우에만 TRUNCATE
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function rollbackDbSeed(array $state): array
{
try {
return executeSeedTruncate($state['config'] ?? [], false, lang('log_prefix_rollback'));
} catch (Exception $e) {
return ['success' => false, 'message' => lang('rollback_seed_error', ['error' => $e->getMessage()])];
}
}
/**
* db_migrate 작업 강제 롤백 (설정으로 돌아갈 때 사용)
*
* @return array ['success' => bool, 'message' => string, 'output' => array]
*/
function forceRollbackDbMigrate(): array
{
return executeMigrateRollback(lang('log_prefix_force_rollback'));
}
/**
* db_seed 작업 강제 롤백 (설정으로 돌아갈 때 사용)
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string]
*/
function forceRollbackDbSeed(array $state): array
{
try {
return executeSeedTruncate($state['config'] ?? [], true, lang('log_prefix_force_rollback'));
} catch (Exception $e) {
return ['success' => false, 'message' => lang('rollback_seed_error', ['error' => $e->getMessage()])];
}
}
/**
* complete_flag 작업 롤백: 설치 완료 플래그 제거
*
* runtime.php (설치 진행 중 마커), g7_installed (Step 5 완료 마커),
* .env 의 INSTALLER_COMPLETED (finalize 완료 마커) 셋 모두 정리한다.
* 어느 단계에서 실패했는지에 따라 일부만 존재할 수 있으므로 각각 독립적으로 처리.
*/
function rollbackCompleteFlag(): array
{
try {
require_once __DIR__.'/../includes/installer-runtime.php';
$envPath = BASE_PATH.'/.env';
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
$results = [];
// runtime.php 삭제 (설치 진행 중 마커 — finalize 전에 롤백 시 존재)
if (is_file(INSTALLER_RUNTIME_PATH)) {
if (deleteInstallerRuntime()) {
addLog(lang('rollback_runtime_removed'));
$results[] = lang('rollback_runtime_removed');
}
}
// .env에서 INSTALLER_COMPLETED 제거 (finalize 후 롤백 시 존재)
if (file_exists($envPath)) {
$envContent = file_get_contents($envPath);
$envContent = preg_replace('/^INSTALLER_COMPLETED=.*$/m', '', $envContent);
$envContent = preg_replace("/\n\n+/", "\n\n", $envContent);
if (file_put_contents($envPath, $envContent) !== false) {
addLog(lang('rollback_env_flag_removed'));
$results[] = lang('rollback_env_flag_removed');
}
}
// g7_installed 파일 삭제
if (file_exists($installedFlagPath)) {
if (@unlink($installedFlagPath)) {
addLog(lang('rollback_installed_flag_removed'));
$results[] = lang('rollback_installed_flag_removed');
}
}
return [
'success' => true,
'message' => lang('rollback_complete_flag_removed', ['details' => implode(', ', $results)]),
];
} catch (Exception $e) {
return [
'success' => false,
'message' => lang('rollback_complete_flag_error', ['error' => $e->getMessage()]),
];
}
}
/**
* 디렉토리 내용을 삭제합니다.
*
* Git 추적 파일(.gitkeep 등)은 보존하고, 디렉토리 자체도 유지합니다.
* 이는 인스톨러가 프로젝트 구조에 필수적인 파일/디렉토리를
* 실수로 삭제하는 것을 방지합니다.
*
* @param string $dir 삭제할 디렉토리 경로
* @param bool $removeDir true이면 디렉토리 자체도 삭제 (기본: false, 내용만 삭제)
* @return bool 삭제 성공 여부
*/
function deleteDirectory(string $dir, bool $removeDir = false): bool
{
if (! is_dir($dir)) {
return false;
}
// 보존해야 할 파일 패턴 (Git 추적용 파일)
$preserveFiles = ['.gitkeep', '.gitignore'];
$items = array_diff(scandir($dir), ['.', '..']);
$hasPreservedFiles = false;
foreach ($items as $item) {
$path = $dir.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
deleteDirectory($path, true);
} else {
// 보존 대상 파일은 건너뛰기
if (in_array($item, $preserveFiles, true)) {
$hasPreservedFiles = true;
continue;
}
@unlink($path);
}
}
// 보존 파일이 남아있거나 removeDir이 false이면 디렉토리 유지
if ($hasPreservedFiles || ! $removeDir) {
return true;
}
return @rmdir($dir);
}
/**
* 현재 진행 중인 작업을 롤백합니다. (abort.php에서 사용)
*
* @param array $state 현재 설치 상태
* @return array ['success' => bool, 'message' => string, 'task' => string|null]
*/
function rollbackCurrentTask(array $state): array
{
$currentTask = $state['current_task'] ?? null;
if (! $currentTask) {
addLog(lang('rollback_no_current_task'));
return [
'success' => true,
'message' => lang('rollback_no_current_task'),
'task' => null,
];
}
if (in_array($currentTask, $state['completed_tasks'] ?? [])) {
addLog(lang('rollback_task_already_completed', ['task' => $currentTask]));
return [
'success' => true,
'message' => lang('rollback_task_already_completed', ['task' => $currentTask]),
'task' => $currentTask,
];
}
addLog(lang('rollback_current_task_start', ['task' => $currentTask]));
$rollbackResult = rollbackTask($currentTask, $state);
if ($rollbackResult['success']) {
addLog(lang('abort_rollback_success', ['message' => $rollbackResult['message']]));
return [
'success' => true,
'message' => lang('abort_rollback_success', ['message' => $rollbackResult['message']]),
'task' => $currentTask,
];
} else {
addLog(lang('abort_rollback_failed', ['message' => $rollbackResult['message']]));
return [
'success' => false,
'message' => lang('failed_rollback_failed', ['message' => $rollbackResult['message']]),
'task' => $currentTask,
];
}
}
/**
* 완료된 작업들을 역순으로 롤백합니다. (reset-state.php에서 사용)
*
* @param array $completedTasks 완료된 작업 목록
* @param array $rollbackableTasks 롤백 가능한 작업 목록
* @param array $state 현재 설치 상태
* @return array ['results' => array, 'errors' => array]
*/
function rollbackCompletedTasks(array $completedTasks, array $rollbackableTasks, array $state): array
{
$results = [];
$errors = [];
if (empty($completedTasks)) {
addLog(lang('rollback_no_completed_tasks'));
return ['results' => $results, 'errors' => $errors];
}
addLog(lang('rollback_checking_tasks'));
$tasksToRollback = array_reverse(
array_intersect($rollbackableTasks, $completedTasks)
);
if (empty($tasksToRollback)) {
addLog(lang('rollback_no_matching_tasks'));
return ['results' => $results, 'errors' => $errors];
}
addLog(lang('rollback_tasks_to_rollback', ['tasks' => implode(', ', $tasksToRollback)]));
foreach ($tasksToRollback as $taskId) {
addLog(lang('rollback_task_rolling_back', ['task' => $taskId]));
if ($taskId === 'db_migrate') {
$result = forceRollbackDbMigrate();
} elseif ($taskId === 'db_seed') {
$result = forceRollbackDbSeed($state);
} else {
$result = rollbackTask($taskId, $state);
}
if ($result['success']) {
$results[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_success', ['task' => $taskId]));
} else {
$errors[] = $taskId.': '.$result['message'];
addLog(lang('rollback_task_failed', ['task' => $taskId, 'error' => $result['message']]));
}
}
return ['results' => $results, 'errors' => $errors];
}
/**
* 실패한 작업에 대한 수동 명령어를 생성합니다.
*
* @param string $taskId 작업 ID
* @param string|null $target 대상 (확장 식별자)
* @return array 수동 명령어 배열
*/
function getManualCommands(string $taskId, ?string $target = null): array
{
$commands = [];
$php = getPhpBinary();
$composer = getComposerCommandForDisplay();
switch ($taskId) {
case 'composer_install':
$commands[] = "{$composer} install --no-interaction --prefer-dist --optimize-autoloader";
break;
case 'env_create':
$commands[] = getEnvCopyCommand();
break;
case 'key_generate':
$commands[] = "{$php} artisan key:generate";
break;
case 'db_migrate':
$commands[] = "{$php} artisan migrate --force";
break;
case 'db_seed':
$commands[] = "{$php} artisan db:seed --force";
break;
case 'template_install':
if ($target) {
$commands[] = "{$php} artisan template:install {$target}";
}
break;
case 'template_activate':
if ($target) {
$commands[] = "{$php} artisan template:activate {$target}";
}
break;
case 'module_install':
if ($target) {
$commands[] = "{$php} artisan module:install {$target}";
}
break;
case 'module_activate':
if ($target) {
$commands[] = "{$php} artisan module:activate {$target}";
}
break;
case 'plugin_install':
if ($target) {
$commands[] = "{$php} artisan plugin:install {$target}";
}
break;
case 'plugin_activate':
if ($target) {
$commands[] = "{$php} artisan plugin:activate {$target}";
}
break;
case 'user_template_install':
if ($target) {
$commands[] = "{$php} artisan template:install {$target}";
}
break;
case 'user_template_activate':
if ($target) {
$commands[] = "{$php} artisan template:activate {$target}";
}
break;
case 'create_settings_json':
$commands[] = '# '.lang('manual_cmd_settings_json');
break;
case 'cache_clear':
$commands[] = "{$php} artisan optimize:clear";
break;
case 'complete_flag':
if (isWindows()) {
$commands[] = 'echo INSTALLER_COMPLETED=true >> .env';
$commands[] = 'type nul > storage\\app\\g7_installed';
} else {
$commands[] = 'echo "INSTALLER_COMPLETED=true" >> .env';
$commands[] = 'touch storage/app/g7_installed';
}
break;
}
return $commands;
}