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.
1254 lines
48 KiB
PHP
1254 lines
48 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(),
|
|
'vendor_dev_packages' => $this->checkVendorDevPackages(),
|
|
'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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 기존 vendor 의 개발용(require-dev) 패키지 포함 여부 (선택 항목 — 경고만, 설치 차단 없음)
|
|
*
|
|
* 인스톨러는 vendor 가 이미 있으면 그대로 재사용한다. 그 vendor 가 `composer install`
|
|
* (옵션 없음)의 결과면 개발용 패키지가 섞여 있고, 그 목록이 패키지 매니페스트에 등재된다.
|
|
* 이후 코어 업데이트가 vendor 를 `--no-dev` 로 교체하면 매니페스트에만 남은 provider 를
|
|
* 찾다 부팅이 깨지므로, 설치 시점에 미리 알린다.
|
|
*
|
|
* `dev` 가 null 이면 판정 불가(installed.json 부재·형식 불명)로, 경고도 차단도 하지 않는다.
|
|
*
|
|
* @return array{required: bool, vendor_exists: bool, dev: bool|null, packages: array<int, string>, message: string}
|
|
*/
|
|
private function checkVendorDevPackages(): array
|
|
{
|
|
$vendorExists = is_dir(BASE_PATH.'/vendor') && file_exists(BASE_PATH.'/vendor/autoload.php');
|
|
|
|
if (! $vendorExists) {
|
|
return [
|
|
'required' => false,
|
|
'vendor_exists' => false,
|
|
'dev' => null,
|
|
'packages' => [],
|
|
'message' => lang('vendor_dev_packages_no_vendor'),
|
|
];
|
|
}
|
|
|
|
require_once __DIR__.'/../includes/vendor-bundle-installer.php';
|
|
$info = detectDevVendorInstall(BASE_PATH);
|
|
|
|
if ($info['dev'] === true) {
|
|
$message = lang('vendor_dev_packages_detected_warning', ['count' => count($info['packages'])]);
|
|
} elseif ($info['dev'] === false) {
|
|
$message = lang('vendor_dev_packages_none');
|
|
} else {
|
|
$message = lang('vendor_dev_packages_unknown');
|
|
}
|
|
|
|
return [
|
|
'required' => false, // 개발용 패키지 감지는 선택 항목 (권장 사항)
|
|
'vendor_exists' => true,
|
|
'dev' => $info['dev'],
|
|
'packages' => $info['packages'],
|
|
'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 / Composer 의존성 구성은 선택 사항이므로 검증하지 않음
|
|
// (경고만 표시하고 설치는 진행)
|
|
|
|
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();
|
|
}
|