setJsonHeaders(); // HTTP 메서드 및 action 파라미터 확인 $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ($method === 'GET' ? 'requirements' : null); try { // action에 따라 적절한 메서드 호출 match ($action) { 'requirements' => $this->checkRequirements(), 'test-db' => $this->testDbConnection(), 'detect-php' => $this->detectPhpBinaries(), 'test-php-binary' => $this->testPhpBinary(), 'test-composer' => $this->testComposer(), 'check-core-pending-path' => $this->checkCorePendingPath(), default => $this->error400('Invalid action parameter'), }; } catch (Throwable $e) { $this->error500($e); } } /** * JSON 응답 헤더 설정 */ private function setJsonHeaders(): void { header('Content-Type: application/json; charset=utf-8'); } /** * GET ?action=requirements * 서버 요구사항 검증 * * PHP 버전, 확장 모듈, 디스크 공간, 디렉토리 권한, HTTPS 등을 검증합니다. */ private function checkRequirements(): void { // 요구사항 검증 결과 배열 $requirements = [ 'php_version' => $this->checkPhpVersion(), 'php_extensions' => $this->checkPhpExtensions(), 'disabled_functions' => $this->checkDisabledFunctions(), 'php_cli_version' => $this->checkPhpCliVersion(), 'disk_space' => $this->checkDiskSpace(), 'directories' => $this->checkDirectoryPermissions(), 'required_files' => $this->checkRequiredFiles(), 'https' => $this->checkHttps(), 'opcache' => $this->checkOpcache(), 'asset_url_mode' => $this->checkAssetUrlMode(), ]; // 모든 필수 요구사항 통과 여부 $requirements['all_required_passed'] = $this->isAllRequiredPassed($requirements); // OS 정보 (프론트엔드에서 명령어 분기용) $requirements['is_windows'] = isWindows(); // JSON 응답 반환 echo json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * POST ?action=test-db * 데이터베이스 연결 테스트 * * POST로 전달받은 데이터베이스 연결 정보를 사용하여 * 연결 테스트 및 권한 검증을 수행합니다. */ private function testDbConnection(): void { // POST 메서드만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo json_encode([ 'success' => false, 'message' => lang('api_method_not_allowed'), ], JSON_UNESCAPED_UNICODE); exit; } try { // POST 데이터 파싱 $input = json_decode(file_get_contents('php://input'), true); if (! $input || ! is_array($input)) { throw new Exception(lang('api_invalid_request')); } // DB 타입 확인 ('write' 또는 'read') $type = $input['type'] ?? 'write'; $isReadDb = ($type === 'read'); // DB 연결 정보 구성 $config = []; if ($isReadDb) { $config['db_read_host'] = $input['host'] ?? 'localhost'; $config['db_read_port'] = $input['port'] ?? '3306'; $config['db_read_database'] = $input['database'] ?? ''; $config['db_read_username'] = $input['username'] ?? ''; $config['db_read_password'] = $input['password'] ?? ''; } else { $config['db_write_host'] = $input['host'] ?? 'localhost'; $config['db_write_port'] = $input['port'] ?? '3306'; $config['db_write_database'] = $input['database'] ?? ''; $config['db_write_username'] = $input['username'] ?? ''; $config['db_write_password'] = $input['password'] ?? ''; } // 필수 필드 검증 $database = $isReadDb ? $config['db_read_database'] : $config['db_write_database']; $username = $isReadDb ? $config['db_read_username'] : $config['db_write_username']; if (empty($database) || empty($username)) { throw new Exception(lang('error_db_credentials_required')); } // DB 최고권한 계정 차단 (Write/Read 공통). // 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로 // 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT. if (PrivilegedDatabaseAccounts::isBlocked($username)) { throw new Exception( lang('error_db_username_privileged', ['username' => $username]) ); } // DB 테이블 접두사 길이 검증 (Write DB 입력에만 적용) // 접두사가 길면 자동 생성 인덱스명이 MySQL identifier 한도(64자)를 초과해 // 일부 확장 설치 시 마이그레이션이 실패하므로 연결 시도 전에 차단한다. if (! $isReadDb) { $tablePrefix = (string) ($input['db_prefix'] ?? ''); if (strlen($tablePrefix) > MAX_DB_PREFIX_LENGTH) { throw new Exception( lang('error_db_prefix_too_long', [ 'max' => MAX_DB_PREFIX_LENGTH, 'current' => strlen($tablePrefix), ]) ); } } // 데이터베이스 연결 시도 $pdo = getDatabaseConnection($config, $isReadDb); // 권한 검증 $privileges = checkDatabasePrivileges($pdo, $database, $isReadDb); // 권한 부족 확인 if (! $privileges['has_all'] && ! empty($privileges['missing'])) { $missingPrivs = implode(', ', $privileges['missing']); $dbTypeLabel = $isReadDb ? 'Read' : 'Write'; throw new Exception( lang('error_db_privileges_insufficient_detail', ['type' => $dbTypeLabel, 'missing' => $missingPrivs]) ); } // 연결 및 권한 검증 성공 플래그 (Write/Read 구분) if ($isReadDb) { $_SESSION['db_read_tested'] = true; $message = lang('success_db_read_connected'); } else { $_SESSION['db_write_tested'] = true; $message = lang('success_db_write_connected'); } // 기존 테이블 감지 (Write DB만 수행) // 사용자가 입력한 db_prefix를 g7 시그니처에 적용하여 정확히 비교 $existingTables = null; if (! $isReadDb) { $detectPrefix = ($input['db_prefix'] ?? '') !== '' ? (string) $input['db_prefix'] : 'g7_'; $existingTables = checkExistingTables($pdo, $database, $detectPrefix); } // 성공 응답 echo json_encode([ 'success' => true, 'message' => $message, 'type' => $type, 'privileges' => [ 'has_all' => $privileges['has_all'], 'found' => $privileges['found'], 'required' => $privileges['required'], ], 'existing_tables' => $existingTables, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } catch (PDOException $e) { // 연결 실패 플래그 $isReadDb = isset($type) && $type === 'read'; if ($isReadDb) { $_SESSION['db_read_tested'] = false; } else { $_SESSION['db_write_tested'] = false; } // 에러 로깅 logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e); // 에러 응답 (200 OK + success: false) echo json_encode([ 'success' => false, 'message' => lang('error_db_connection_failed_detail', ['type' => ($isReadDb ? 'Read' : 'Write'), 'error' => $e->getMessage()]), 'type' => $type ?? 'write', ], JSON_UNESCAPED_UNICODE); } catch (Exception $e) { // 권한 검증 실패 또는 기타 에러 $isReadDb = isset($type) && $type === 'read'; if (isset($type)) { if ($isReadDb) { $_SESSION['db_read_tested'] = false; } else { $_SESSION['db_write_tested'] = false; } } // 에러 로깅 logInstallationError(lang('error_db_test_failed'), $e); // 에러 응답 (200 OK + success: false) echo json_encode([ 'success' => false, 'message' => $e->getMessage(), 'type' => $type ?? 'write', ], JSON_UNESCAPED_UNICODE); } } // ======================================== // 서버 요구사항 검증 헬퍼 메서드 // ======================================== /** * PHP 버전 검증 */ private function checkPhpVersion(): array { $currentVersion = PHP_VERSION; $minVersion = MIN_PHP_VERSION; $passed = version_compare($currentVersion, $minVersion, '>='); return [ 'required' => true, 'min_version' => $minVersion, 'current_version' => $currentVersion, 'passed' => $passed, 'message' => $passed ? lang('success_php_version_detail', ['current' => $currentVersion, 'min' => $minVersion]) : lang('error_php_version_insufficient_detail', ['current' => $currentVersion, 'min' => $minVersion]), ]; } /** * PHP 확장 모듈 검증 * * 필수 및 선택적 확장 모듈을 검증합니다. * 선택적 모듈은 설치 여부만 표시하며 전체 검증 통과에 영향을 주지 않습니다. */ private function checkPhpExtensions(): array { $requiredExtensions = REQUIRED_EXTENSIONS; $optionalExtensions = defined('OPTIONAL_EXTENSIONS') ? OPTIONAL_EXTENSIONS : []; $installed = []; $allRequiredPassed = true; // 필수 확장 검증 foreach ($requiredExtensions as $extension) { $isLoaded = extension_loaded($extension); $installed[$extension] = [ 'required' => true, 'installed' => $isLoaded, ]; if (! $isLoaded) { $allRequiredPassed = false; } } // 선택적 확장 검증 (설치 여부만 표시) $optionalInstalled = []; foreach ($optionalExtensions as $extension) { $optionalInstalled[$extension] = [ 'required' => false, 'installed' => extension_loaded($extension), ]; } return [ 'required' => $requiredExtensions, 'optional' => $optionalExtensions, 'installed' => $installed, 'optional_installed' => $optionalInstalled, 'all_required_passed' => $allRequiredPassed, 'message' => $allRequiredPassed ? lang('success_php_extensions') : lang('error_php_extensions_missing'), ]; } /** * 디스크 공간 검증 */ private function checkDiskSpace(): array { $minSpaceMb = MIN_DISK_SPACE_MB; $freeSpaceBytes = @disk_free_space(BASE_PATH); // disk_free_space() 실패 시 if ($freeSpaceBytes === false) { return [ 'required' => true, 'min_mb' => $minSpaceMb, 'current_mb' => 0, 'passed' => false, 'message' => lang('error_disk_space_unknown'), ]; } $freeSpaceMb = round($freeSpaceBytes / 1024 / 1024, 2); $passed = $freeSpaceMb >= $minSpaceMb; return [ 'required' => true, 'min_mb' => $minSpaceMb, 'current_mb' => $freeSpaceMb, 'passed' => $passed, 'message' => $passed ? lang('success_disk_space_detail', ['current' => $freeSpaceMb, 'min' => $minSpaceMb]) : lang('error_disk_space_insufficient_detail', ['current' => $freeSpaceMb, 'min' => $minSpaceMb]), ]; } /** * 디렉토리 권한 검증 * * functions.php의 공통 함수를 사용하여 권한을 체크합니다. */ private function checkDirectoryPermissions(): array { // 공통 함수 호출 $check = checkDirectoryPermissions(REQUIRED_DIRECTORIES); // API 응답 형식에 맞게 변환 $results = []; foreach ($check['results'] as $dir => $result) { $results[$dir] = [ 'path' => $dir, 'full_path' => BASE_PATH.'/'.$dir, 'relative_path' => './'.$dir, 'exists' => $result['exists'], 'writable' => $result['writable'], 'readable' => $result['readable'], 'permissions' => $result['permissions'], 'owner' => $result['owner'], 'group' => $result['group'], 'passed' => $result['passed'], 'error_type' => $result['error_type'], 'has_subdirectory_issues' => $result['has_subdirectory_issues'], 'failed_subdirectories' => $result['failed_subdirectories'], ]; } return [ 'required' => true, 'paths' => array_keys(REQUIRED_DIRECTORIES), 'results' => $results, 'required_permissions' => REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY, 'web_server_group' => getWebServerGroup() ?? getWebServerUser() ?? 'www-data', 'web_server_user' => getWebServerUser(), 'all_passed' => $check['all_passed'], 'message' => $check['all_passed'] ? lang('success_directories_writable') : lang('error_directory_not_writable_detail', ['permissions' => REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY]), ]; } /** * 필수 파일 존재 여부 검증 * * .env 파일이 프로젝트 루트에 존재하는지 확인합니다. */ private function checkRequiredFiles(): array { $basePath = BASE_PATH; $filePaths = [ '.env' => $basePath.'/.env', ]; $createCommands = [ '.env' => getEnvCopyCommand($basePath), ]; $webServerUser = getWebServerUser(); $files = []; $allPassed = true; $hasNotWritable = false; foreach ($filePaths as $name => $fullPath) { $exists = file_exists($fullPath); $writable = $exists && is_writable($fullPath); // 통과 조건: 존재 + 쓰기 가능 $passed = $exists && $writable; if (! $passed) { $allPassed = false; } // 권한 비트/소유자 정보 수집 (ownership_mismatch 판별용) $permissions = 'N/A'; $permsOctal = 0; $owner = null; if ($exists) { $perms = fileperms($fullPath); $permissions = substr(sprintf('%o', $perms), -3); $permsOctal = octdec($permissions); $owner = getFileOwnerName($fullPath); } // 에러 타입 결정 // ownership_mismatch: 파일이 존재하고 권한이 0644 이상인데도 쓰기 불가 // → 소유자와 웹서버 실행 사용자 불일치 $errorType = null; if (! $exists) { $errorType = 'not_exists'; } elseif (! $writable) { if ($permsOctal >= 0644 && $webServerUser && $owner && $owner !== $webServerUser) { $errorType = 'ownership_mismatch'; } else { $errorType = 'not_writable'; } $hasNotWritable = true; } $files[$name] = [ 'exists' => $exists, 'writable' => $writable, 'passed' => $passed, 'error_type' => $errorType, 'permissions' => $permissions, 'owner' => $owner, 'web_server_user' => $webServerUser, 'command' => $createCommands[$name], ]; } // base_path 소유자 정보 — .env 파일 생성 시 chgrp 포함 여부 판단용 // 프로젝트 루트 소유자가 웹서버 실행 사용자와 다른 경우, 생성 직후 ownership_mismatch가 // 재현되므로 복사 명령 자체에 chgrp + chmod 664를 미리 포함시키기 위함 $basePathOwner = function_exists('posix_getpwuid') && is_dir($basePath) ? (posix_getpwuid(fileowner($basePath))['name'] ?? null) : null; $basePathOwnerMatchesWebUser = $basePathOwner && $webServerUser && $basePathOwner === $webServerUser; return [ 'required' => true, 'files' => $files, 'all_passed' => $allPassed, 'has_not_writable' => $hasNotWritable, 'web_server_group' => getWebServerGroup() ?? $webServerUser ?? 'www-data', 'web_server_user' => $webServerUser, 'base_path' => $basePath, 'base_path_owner' => $basePathOwner, 'base_path_owner_matches_web_user' => $basePathOwnerMatchesWebUser, 'relative_base_path' => '.', 'message' => $allPassed ? lang('success_required_files') : lang('error_required_files_missing'), ]; } /** * HTTPS 사용 여부 확인 * * 이 검사는 저장소에서 유일하게 X-Forwarded-Proto 를 실제로 읽는 지점이었다. 그래서 * 설치 마법사는 "HTTPS 정상" 이라고 보고하는데 그 직후 앱은 http:// 절대 URL 을 만드는 * 비대칭이 있었다 — 운영자 입장에서 원인 추적이 사실상 불가능한 조합이다 (#124). * * 프록시 헤더가 감지되면 신뢰 프록시 설정이 필요하다는 안내를 결과에 덧붙인다. * 설치를 차단하지는 않는다 (HTTPS 항목이 `required = false` 인 기존 정책과 동일). */ private function checkHttps(): array { $isHttps = isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on'; // HTTP_X_FORWARDED_PROTO 헤더도 확인 (프록시 환경) if (! $isHttps && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) { $isHttps = strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'; } $forwardedHeaders = $this->detectForwardedHeaders(); $behindProxy = $forwardedHeaders !== []; $message = $isHttps ? lang('https_enabled') : lang('https_disabled'); // HTTP 전용 사이트가 프록시 뒤에 있는 구성도 대상이다 — 화면은 정상 렌더되지만 // 방문자 IP·결제 통보 수신은 그대로 어긋난다. HTTPS 여부로 가르지 않는다. if ($behindProxy) { $message .= ' '.lang('https_behind_proxy'); } return [ 'required' => false, // HTTPS는 선택 사항 'enabled' => $isHttps, 'behind_proxy' => $behindProxy, 'forwarded_headers' => $forwardedHeaders, 'message' => $message, ]; } /** * 수신 중인 X-Forwarded-* 계열 헤더 이름 목록을 반환합니다 (#124). * * 설치 마법사는 순수 PHP 영역이라 Laravel 헬퍼를 쓸 수 없다. 목록은 * App\Support\TrustedProxyDiagnostic::FORWARDED_HEADERS 와 같은 집합을 유지한다. * * @return array 수신 중인 헤더 이름 목록 */ private function detectForwardedHeaders(): array { $headers = [ 'X-Forwarded-For' => 'HTTP_X_FORWARDED_FOR', 'X-Forwarded-Proto' => 'HTTP_X_FORWARDED_PROTO', 'X-Forwarded-Host' => 'HTTP_X_FORWARDED_HOST', 'X-Forwarded-Port' => 'HTTP_X_FORWARDED_PORT', 'X-Forwarded-Prefix' => 'HTTP_X_FORWARDED_PREFIX', 'X-Forwarded-Aws-Elb' => 'HTTP_X_FORWARDED_AWS_ELB', 'Forwarded' => 'HTTP_FORWARDED', ]; $present = []; foreach ($headers as $name => $serverKey) { if (isset($_SERVER[$serverKey])) { $present[] = $name; } } return $present; } /** * OPcache 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음) * * 확장 로드 여부만으로는 부족하다. `Zend OPcache` 가 로드돼 있어도 * `opcache.enable=0` 이면 실제로는 동작하지 않으므로 지시자까지 확인한다. * 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 코어 관리자 화면과 같은 답을 낸다. * * `enabled` 가 null 이면 "확인 불가"(ini_get 차단 환경)로, 경고도 차단도 하지 않는다. */ private function checkOpcache(): array { $status = OpcacheStatus::probe(); if ($status['enabled'] === null) { $message = lang('opcache_unknown'); } else { $message = $status['enabled'] ? lang('opcache_enabled') : lang('opcache_disabled_warning'); } return [ 'required' => false, // OPcache는 선택 사항 (성능 권장) 'loaded' => $status['loaded'], 'enabled' => $status['enabled'], 'message' => $message, ]; } /** * 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음) * * 판정 자체는 **서버가 할 수 없다.** 서버에서 자기 APP_URL 로 curl 하면 loopback 이 * nginx vhost·SSL·프록시 체인을 우회하거나 다른 vhost 를 타서, 실제 방문자가 겪는 * 것과 다른 답을 낸다. 그래서 여기서는 프로브 경로만 내려주고 실제 판정은 * 브라우저가 수행한다 (`installer.js::probeAssetUrlMode`). * * `detected` 가 항상 null 인 것은 미구현이 아니라 위 이유에 따른 설계다. * 프론트가 이 값을 받아 카드를 "확인 중" 상태로 먼저 그리고, 프로브가 끝나면 갱신한다. */ private function checkAssetUrlMode(): array { return [ 'required' => false, // 어느 방식이든 정상 — 통과/실패 게이트가 아니다 'detected' => null, // 브라우저 프로브가 채운다 (서버 판정 불가) 'probe_extension' => '/api/system/asset-probe.js', 'probe_extensionless' => '/api/system/asset-probe', ]; } /** * 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증 */ private function checkDisabledFunctions(): array { $requiredFunctions = ['exec', 'proc_open', 'shell_exec']; $disabledStr = ini_get('disable_functions'); $disabledList = array_map('trim', explode(',', $disabledStr)); $disabled = []; foreach ($requiredFunctions as $func) { if (in_array($func, $disabledList, true)) { $disabled[] = $func; } } $passed = empty($disabled); return [ 'required' => true, 'checked_functions' => $requiredFunctions, 'disabled' => $disabled, 'passed' => $passed, 'message' => $passed ? lang('success_required_functions') : lang('error_disabled_functions', ['functions' => implode(', ', $disabled)]), ]; } /** * PHP CLI 버전과 웹 PHP 버전 일치 여부 확인 */ private function checkPhpCliVersion(): array { $webVersion = PHP_VERSION; $cliVersion = null; $cliPath = 'php'; // exec 사용 가능 여부 먼저 확인 $disabledStr = ini_get('disable_functions'); $disabledList = array_map('trim', explode(',', $disabledStr)); if (in_array('exec', $disabledList, true)) { return [ 'required' => false, 'web_version' => $webVersion, 'cli_version' => null, 'cli_path' => null, 'matched' => null, 'message' => lang('php_cli_version_check_skipped'), ]; } $output = []; $returnCode = -1; exec('php --version 2>&1', $output, $returnCode); if ($returnCode === 0 && ! empty($output)) { $outputStr = implode("\n", $output); if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) { $cliVersion = $matches[1]; } } // 메이저.마이너 버전 비교 (패치 차이는 무시) $webMajorMinor = implode('.', array_slice(explode('.', $webVersion), 0, 2)); $cliMajorMinor = $cliVersion ? implode('.', array_slice(explode('.', $cliVersion), 0, 2)) : null; $matched = $cliMajorMinor !== null && $webMajorMinor === $cliMajorMinor; return [ 'required' => false, 'web_version' => $webVersion, 'cli_version' => $cliVersion, 'cli_path' => $cliPath, 'matched' => $matched, 'message' => $cliVersion === null ? lang('php_cli_version_unknown') : ($matched ? lang('php_cli_version_matched', ['web' => $webVersion, 'cli' => $cliVersion]) : lang('php_cli_version_mismatch', ['web' => $webVersion, 'cli' => $cliVersion])), ]; } /** * GET ?action=detect-php * 서버에서 사용 가능한 PHP 바이너리 자동 탐색 */ private function detectPhpBinaries(): void { $commonPaths = [ '/usr/local/php84/bin/php', '/usr/local/php83/bin/php', '/usr/local/php82/bin/php', '/usr/bin/php8.4', '/usr/bin/php8.3', '/usr/bin/php8.2', '/usr/local/bin/php', '/usr/bin/php', ]; $found = []; $checkedPaths = []; $minVersion = MIN_PHP_VERSION; // PHP_BINARY 상수 if (defined('PHP_BINARY') && PHP_BINARY !== '' && ! in_array(PHP_BINARY, $checkedPaths, true)) { $checkedPaths[] = PHP_BINARY; $result = $this->validatePhpPath(PHP_BINARY); if ($result['valid']) { $found[] = ['path' => PHP_BINARY, 'version' => $result['version']]; } } // 공통 경로 스캔 foreach ($commonPaths as $path) { if (in_array($path, $checkedPaths, true)) { continue; } $checkedPaths[] = $path; if (! file_exists($path)) { continue; } $result = $this->validatePhpPath($path); if ($result['valid']) { $found[] = ['path' => $path, 'version' => $result['version']]; } } // 시스템 PATH의 'php' $defaultPhpAvailable = false; if (! in_array('php', $checkedPaths, true)) { $result = $this->validatePhpPath('php'); if ($result['valid']) { $found[] = ['path' => 'php', 'version' => $result['version']]; $defaultPhpAvailable = true; } } else { // 이미 체크된 경우 found 배열에서 확인 foreach ($found as $bin) { if ($bin['path'] === 'php') { $defaultPhpAvailable = true; break; } } } // Composer 자동 감지 (감지된 PHP 중 첫 번째로 검증) $phpForComposer = ! empty($found) ? $found[0]['path'] : 'php'; $composerResult = $this->detectComposerBinary($phpForComposer); echo json_encode([ 'success' => ! empty($found), 'binaries' => $found, 'default_php_available' => $defaultPhpAvailable, 'composer' => $composerResult, 'message' => empty($found) ? lang('no_php_detected') : lang('php_detected_count', ['count' => count($found)]), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * Composer 바이너리 자동 감지 * * @param string $phpPath .phar 실행 시 사용할 PHP 경로 * @return array{found: bool, path: string|null, version: string|null} */ private function detectComposerBinary(string $phpPath = 'php'): array { // 1. 시스템 PATH의 composer $result = $this->validateComposerPath('composer', $phpPath); if ($result['valid']) { return ['found' => true, 'path' => 'composer', 'version' => $result['version']]; } // 2. 프로젝트 루트의 composer.phar $pharPath = realpath(__DIR__.'/../../..').'/composer.phar'; if (file_exists($pharPath)) { $result = $this->validateComposerPath($pharPath, $phpPath); if ($result['valid']) { return ['found' => true, 'path' => $pharPath, 'version' => $result['version']]; } } // 3. 현재 디렉토리(public/install/api)의 composer.phar $localPhar = realpath(__DIR__).'/composer.phar'; if (file_exists($localPhar)) { $result = $this->validateComposerPath($localPhar, $phpPath); if ($result['valid']) { return ['found' => true, 'path' => $localPhar, 'version' => $result['version']]; } } return ['found' => false, 'path' => null, 'version' => null]; } /** * POST ?action=test-php-binary * 지정된 PHP 바이너리 경로의 유효성 검증 */ private function testPhpBinary(): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->error400('POST method required'); } $input = json_decode(file_get_contents('php://input'), true); $path = $input['path'] ?? 'php'; $result = $this->validatePhpPath($path); echo json_encode([ 'success' => $result['valid'], 'version' => $result['version'], 'message' => $result['message'], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * POST ?action=test-composer * Composer 바이너리 경로의 유효성 검증 */ private function testComposer(): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $this->error400('POST method required'); } $input = json_decode(file_get_contents('php://input'), true); $composerPath = trim($input['path'] ?? ''); $phpPath = trim($input['php_path'] ?? 'php'); $result = $this->validateComposerPath($composerPath, $phpPath); echo json_encode([ 'success' => $result['valid'], 'version' => $result['version'], 'message' => $result['message'], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** * GET ?action=check-core-pending-path * 코어 업데이트 _pending 경로 퍼미션/소유자 체크 */ private function checkCorePendingPath(): void { $path = $_GET['path'] ?? ''; if (empty($path)) { echo json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE); return; } // 경로 traversal 및 NUL 바이트 거부. // 정상 사용자는 BASE_PATH 하위의 _pending 또는 자기 디스크의 절대경로만 입력하므로 // 상대경로 부모 추적이 필요한 시나리오가 없다. $hasParentSegment = preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $path) === 1; if ($hasParentSegment || str_contains($path, "\0")) { echo json_encode([ 'success' => false, 'message' => lang('error_core_pending_path_invalid'), ], JSON_UNESCAPED_UNICODE); return; } $candidatePath = (str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[/\\\\]#', $path) === 1) ? $path : BASE_PATH.DIRECTORY_SEPARATOR.$path; $resolved = @realpath($candidatePath); if ($resolved === false || ! is_dir($resolved)) { // 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여 // 임의 경로 enumeration 신호 차단. echo json_encode([ 'success' => false, 'message' => lang('error_core_pending_path_invalid'), ], JSON_UNESCAPED_UNICODE); return; } $writable = is_writable($resolved); echo json_encode([ 'success' => $writable, 'message' => $writable ? lang('success_core_pending_path') : lang('error_core_pending_not_writable'), ], JSON_UNESCAPED_UNICODE); } /** * 셸 인자로 전달하기 안전한 단일 토큰인지 검증. * * 셸 메타문자(공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등) 와 제어문자(NUL/CR/LF 등) * 가 없어야 함. 백슬래시(`\`) 는 Windows 경로 구분자이므로 차단 대상이 아니다 — * 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당 (Windows 는 큰따옴표 wrapping, * Unix 는 작은따옴표 wrapping). * * 파일 시스템 stat 은 호출하지 않는다 — open_basedir 등 PHP 런타임 제약 * 환경에서 정상 절대경로가 false negative 로 거부되는 회귀를 피하기 위함. * 실제 실행 가능 여부는 exec/proc_open 결과로 최종 판정. */ private function isInstallerSafePathArg(string $path): bool { return installer_binary_path_shape_ok($path); } /** * 공백 분리 입력을 두 토큰(PHP 인터프리터 + Composer 바이너리) 으로 분해. * * 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 에서 * `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다. * 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정. * * @return array{php: string, composer: string}|null 분해 실패 시 null */ private function splitPhpComposerTokens(string $path): ?array { return installer_resolve_php_composer_pair($path); } /** * PHP 바이너리 경로 유효성 검증 헬퍼 * * @param string $path PHP 바이너리 경로 * @return array{valid: bool, version: string|null, message: string} */ private function validatePhpPath(string $path): array { if (empty($path)) { return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')]; } // 'php' 기본값이 아니면 실행 경로 형태 규칙을 적용한다(이름은 제한하지 않는다 — // 이 자리는 인자가 `--version` 으로 고정되어 있고 설치 환경마다 이름이 다르다). // 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의 // false negative 를 피하기 위해 생략하고, exec 결과로 최종 판정한다. if ($path !== 'php' && ! installer_binary_path_shape_ok($path)) { return ['valid' => false, 'version' => null, 'message' => lang('error_php_binary_path_not_allowed', ['path' => $path])]; } $command = escapeshellarg($path).' --version 2>&1'; $output = []; $returnCode = -1; exec($command, $output, $returnCode); if ($returnCode !== 0) { return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])]; } $outputStr = implode("\n", $output); if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) { $version = $matches[1]; if (version_compare($version, MIN_PHP_VERSION, '>=')) { return [ 'valid' => true, 'version' => $version, 'message' => lang('success_php_binary_version', ['path' => $path, 'version' => $version]), ]; } return [ 'valid' => false, 'version' => $version, 'message' => lang('error_php_version_too_low', ['path' => $path, 'version' => $version, 'min' => MIN_PHP_VERSION]), ]; } return ['valid' => false, 'version' => null, 'message' => lang('error_php_version_parse_failed')]; } /** * Composer 바이너리 경로 유효성 검증 헬퍼 * * @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer') * @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용) * @return array{valid: bool, version: string|null, message: string} */ private function validateComposerPath(string $composerPath, string $phpPath = 'php'): array { // 빈 문자열이면 시스템 기본 composer 사용 $effectivePath = $composerPath ?: 'composer'; // 공백 분리 입력은 "PHP 절대경로 + Composer 절대경로" 의 멀티 PHP 운영 패턴. // 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다. // 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음. if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) { // 자리별 규칙(PHP 자리=형태만, Composer 자리=이름 형태까지)은 공용 정책이 담당한다. $tokens = $this->splitPhpComposerTokens($effectivePath); if ($tokens === null) { return [ 'valid' => false, 'version' => null, 'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]), ]; } $command = escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']).' --version 2>&1'; $output = []; $returnCode = -1; applyInstallerComposerEnvVars(); exec($command, $output, $returnCode); if ($returnCode !== 0) { return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])]; } $outputStr = implode("\n", $output); if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) { $version = $matches[1]; return [ 'valid' => true, 'version' => $version, 'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]), ]; } return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')]; } // 단일 토큰 — 시스템 기본('composer') 가 아니면 Composer 자리 규칙을 적용한다. // 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략. if ($effectivePath !== 'composer' && ! installer_is_composer_binary_path($effectivePath)) { return [ 'valid' => false, 'version' => null, 'message' => lang('error_composer_binary_path_not_allowed', ['path' => $effectivePath]), ]; } // .phar 파일이면 PHP 바이너리와 결합 if (str_ends_with(strtolower($effectivePath), '.phar')) { // phpPath 는 실행 파일 자리 — 이름은 제한하지 않고 형태 규칙만 적용한다. if ($phpPath !== 'php' && ! installer_binary_path_shape_ok($phpPath)) { return [ 'valid' => false, 'version' => null, 'message' => lang('error_php_binary_path_not_allowed', ['path' => $phpPath]), ]; } $command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1'; } else { $command = escapeshellarg($effectivePath).' --version 2>&1'; } $output = []; $returnCode = -1; // root/super user 환경 + 비대화형 컨텍스트에서 composer interactive 경고로 인한 // 비정상 종료를 차단 (Synology DSM 등 PHP-FPM root 실행 환경 대응) applyInstallerComposerEnvVars(); exec($command, $output, $returnCode); if ($returnCode !== 0) { return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])]; } $outputStr = implode("\n", $output); if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) { $version = $matches[1]; return [ 'valid' => true, 'version' => $version, 'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]), ]; } return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')]; } /** * 모든 필수 요구사항 통과 여부 확인 */ private function isAllRequiredPassed(array $requirements): bool { // PHP 버전 확인 if (! $requirements['php_version']['passed']) { return false; } // PHP 확장 확인 if (! $requirements['php_extensions']['all_required_passed']) { return false; } // 필수 함수 비활성화 확인 if (isset($requirements['disabled_functions']) && ! $requirements['disabled_functions']['passed']) { return false; } // 디스크 공간 확인 if (! $requirements['disk_space']['passed']) { return false; } // 디렉토리 권한 확인 if (! $requirements['directories']['all_passed']) { return false; } // 필수 파일 확인 if (! $requirements['required_files']['all_passed']) { return false; } // HTTPS / OPcache 는 선택 사항이므로 검증하지 않음 (경고만 표시하고 설치는 진행) return true; } // ======================================== // 공통 에러 처리 메서드 // ======================================== /** * 400 Bad Request 응답 */ private function error400(string $message): void { http_response_code(400); echo json_encode([ 'success' => false, 'error' => 'Bad Request', 'message' => $message, ], JSON_UNESCAPED_UNICODE); exit; } /** * 500 Internal Server Error 응답 */ private function error500(Throwable $e): void { // 에러 로깅 if (function_exists('logInstallationError')) { logInstallationError('Validation API error', $e); } // 에러 응답 http_response_code(500); echo json_encode([ 'success' => false, 'error' => 'Internal Server Error', 'message' => $e->getMessage(), 'details' => $e->getMessage(), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } } // ======================================== // 실행 부분 // ======================================== // 라이브러리 모드 — 단위 테스트가 ValidationApi 클래스 정의만 로드할 때 메인 실행을 건너뛴다. $checkConfigurationLibraryMode = defined('CHECK_CONFIGURATION_LIBRARY') && constant('CHECK_CONFIGURATION_LIBRARY'); if (! $checkConfigurationLibraryMode) { // 필수 파일 로드 (config.php가 BASE_PATH를 정의함) require_once __DIR__.'/../includes/config.php'; require_once __DIR__.'/../includes/session.php'; require_once __DIR__.'/../includes/functions.php'; // 설치 완료 시 인스톨러 비즈니스 로직 진입 차단 require_once __DIR__.'/_guard.php'; installer_guard_or_410(); // 다국어 로드 $currentLang = getCurrentLanguage(); $translations = loadTranslations($currentLang); // API 인스턴스 생성 및 요청 처리 $api = new ValidationApi; $api->handleRequest(); }