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

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

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

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

1206 lines
46 KiB
PHP

<?php
use App\Support\OpcacheStatus;
use App\Support\PrivilegedDatabaseAccounts;
/**
* G7 인스톨러 - 검증 통합 API
*
* 서버 요구사항 검증 및 데이터베이스 연결 테스트 기능을 하나의 API로 통합
*
* 엔드포인트:
* - GET ?action=requirements : 서버 요구사항 검증 (action 생략 시 기본값)
* - POST ?action=test-db : 데이터베이스 연결 테스트
*/
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다.
// 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다.
require_once __DIR__.'/../includes/utf8.php';
require_once __DIR__.'/../includes/binary-path-policy.php';
/**
* 검증 API 클래스
*/
class ValidationApi
{
/**
* 요청 처리 메인 메서드
*/
public function handleRequest(): void
{
// JSON 헤더 설정
$this->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 installer_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 installer_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 installer_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 installer_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 installer_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<int, string> 수신 중인 헤더 이름 목록
*/
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 installer_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 installer_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 installer_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 installer_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 installer_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 installer_json_encode([
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
$writable = is_writable($resolved);
echo installer_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 installer_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 installer_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();
}