Files
Gnuboard7/public/install/includes/functions.php
T
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

1311 lines
45 KiB
PHP

<?php
/**
* 그누보드7 웹 인스톨러 유틸리티 함수
*
* HTML/JavaScript 이스케이프, 데이터베이스 연결, 권한 검증, 에러 로깅 등의
* 공통 유틸리티 함수를 제공합니다.
*/
// UTF-8 정규화 / JSON 출력 헬퍼 (js_escape · getWebServerUser 가 사용)
require_once __DIR__.'/utf8.php';
require_once __DIR__.'/env-value.php';
/**
* HTML 이스케이프 함수
*
* XSS 공격을 방지하기 위해 HTML 특수 문자를 이스케이프합니다.
* Laravel 환경에서는 이미 e()가 선언되어 있으므로 중복 선언을 방지합니다.
*/
if (! function_exists('e')) {
function e(?string $value): string
{
if ($value === null) {
return '';
}
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false);
}
}
/**
* JavaScript 이스케이프 함수
*
* JavaScript 컨텍스트에서 안전하게 사용할 수 있도록 값을 이스케이프합니다.
*/
function js_escape(mixed $value): string
{
$json = installer_json_encode(
$value,
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE
);
if ($json === '') {
return '""';
}
return $json;
}
/**
* 데이터베이스 연결 생성
*
* @param array $config 데이터베이스 연결 정보 배열
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
*
* @throws PDOException 연결 실패 시
*/
function getDatabaseConnection(array $config, bool $isReadDb = false): PDO
{
$prefix = $isReadDb ? 'db_read' : 'db_write';
$host = $config["{$prefix}_host"] ?? 'localhost';
$port = $config["{$prefix}_port"] ?? '3306';
$database = $config["{$prefix}_database"] ?? '';
$username = $config["{$prefix}_username"] ?? '';
$password = $config["{$prefix}_password"] ?? '';
if (empty($database) || empty($username)) {
throw new PDOException(lang('error_db_name_username_required'));
}
// DSN 키-밸류 구분자(`;`, `=`) 와 NUL 바이트가 포함된 입력을 거부 — DSN 파라미터 주입 차단.
// 호스트/포트/DB 이름은 정상 입력에서 이 문자가 들어갈 일이 없다.
foreach ([
'host' => $host,
'port' => $port,
'database' => $database,
] as $field => $val) {
$val = (string) $val;
if ($val === '' && $field !== 'database') {
continue;
}
if (preg_match('/[;=\0\r\n]/', $val)) {
throw new PDOException(lang('error_db_invalid_parameter', ['field' => $field]));
}
}
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";
try {
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
throw new PDOException(lang('error_db_connection_failed', ['error' => $e->getMessage()]), (int) $e->getCode(), $e);
}
}
/**
* 데이터베이스 권한 검증
*
* @param PDO $pdo PDO 연결 객체
* @param string $database 데이터베이스명
* @param bool $isReadDb Read DB 여부 (true: read, false: write)
* @return array{has_all: bool, required: array<string>, found: array<string>, missing: array<string>}
*/
function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = false): array
{
// Read DB는 SELECT만 필요
$requiredPrivileges = $isReadDb
? ['SELECT']
: ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'INDEX'];
try {
// 현재 사용자의 권한 조회
$stmt = $pdo->query('SHOW GRANTS FOR CURRENT_USER()');
$grants = $stmt->fetchAll(PDO::FETCH_COLUMN);
$foundPrivileges = [];
foreach ($grants as $grant) {
$grant = strtoupper($grant);
// ALL PRIVILEGES 체크
if (strpos($grant, 'ALL PRIVILEGES') !== false) {
$foundPrivileges = $requiredPrivileges;
break;
}
// 개별 권한 체크
foreach ($requiredPrivileges as $privilege) {
if (strpos($grant, $privilege) !== false) {
$foundPrivileges[] = $privilege;
}
}
}
// 중복 제거
$foundPrivileges = array_unique($foundPrivileges);
// 누락된 권한 확인
$missingPrivileges = array_diff($requiredPrivileges, $foundPrivileges);
return [
'has_all' => empty($missingPrivileges),
'required' => $requiredPrivileges,
'found' => array_values($foundPrivileges),
'missing' => array_values($missingPrivileges),
];
} catch (PDOException $e) {
logInstallationError(lang('error_privilege_check_failed'), $e);
return [
'has_all' => false,
'required' => $requiredPrivileges,
'found' => [],
'missing' => $requiredPrivileges,
];
}
}
/**
* 현재 PHP 프로세스에 composer root/non-interactive 환경변수를 적용합니다.
*
* exec() 자식 프로세스는 부모의 환경변수를 상속하므로 putenv() 시점에 설정해두면
* 후속 composer 자식 프로세스가 root 환경에서도 정상 동작합니다.
*
* Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응.
*/
if (! function_exists('applyInstallerComposerEnvVars')) {
function applyInstallerComposerEnvVars(): void
{
putenv('COMPOSER_ALLOW_SUPERUSER=1');
putenv('COMPOSER_NO_INTERACTION=1');
}
}
/**
* proc_open() 5번째 인자에 병합할 composer 환경변수 배열을 반환합니다.
*
* 호출자는 기존 $env 와 array_merge 하여 사용합니다.
*
* @return array<string, string>
*/
if (! function_exists('buildInstallerComposerEnv')) {
function buildInstallerComposerEnv(): array
{
return [
'COMPOSER_ALLOW_SUPERUSER' => '1',
'COMPOSER_NO_INTERACTION' => '1',
];
}
}
/**
* 인스톨러 에러 로깅
*
* @param string $message 에러 메시지
* @param Throwable|null $exception 예외 객체 (선택)
*/
function logInstallationError(string $message, ?Throwable $exception = null): void
{
$logDir = BASE_PATH.'/storage/logs';
$logFile = $logDir.'/installer.log';
// 로그 디렉토리 생성
if (! is_dir($logDir)) {
@mkdir($logDir, 0755, true);
}
// 로그 메시지 구성
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[{$timestamp}] {$message}";
if ($exception !== null) {
$logMessage .= "\n";
$logMessage .= 'Exception: '.get_class($exception)."\n";
$logMessage .= "Message: {$exception->getMessage()}\n";
$logMessage .= "File: {$exception->getFile()}:{$exception->getLine()}\n";
$logMessage .= "Trace:\n{$exception->getTraceAsString()}\n";
}
$logMessage .= "\n".str_repeat('-', 80)."\n\n";
// 로그 파일에 기록
@file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX);
}
/**
* 브라우저의 Accept-Language 헤더에서 선호 언어를 감지합니다.
*
* @return string|null 감지된 언어 코드 (ko 또는 en), 감지 실패 시 null
*/
function detectBrowserLanguage(): ?string
{
// HTTP_ACCEPT_LANGUAGE 헤더가 없으면 null 반환
if (! isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
return null;
}
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
// 지원 언어 목록
$supportedLanguages = ['ko', 'en'];
// Accept-Language 헤더 파싱 (예: "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
$languages = [];
foreach (explode(',', $acceptLanguage) as $lang) {
// q값과 언어 코드 분리
$parts = explode(';', trim($lang));
$langCode = strtolower(trim($parts[0]));
// q값 추출 (없으면 1.0)
$q = 1.0;
if (isset($parts[1]) && strpos($parts[1], 'q=') === 0) {
$q = (float) substr($parts[1], 2);
}
// 언어 코드에서 메인 부분만 추출 (ko-KR -> ko)
$mainLang = explode('-', $langCode)[0];
$languages[$mainLang] = $q;
}
// q값 기준으로 정렬 (내림차순)
arsort($languages);
// 지원하는 언어 중에서 가장 우선순위가 높은 언어 반환
foreach (array_keys($languages) as $lang) {
if (in_array($lang, $supportedLanguages)) {
return $lang;
}
}
return null;
}
/**
* 현재 설정된 언어 코드를 반환합니다.
*
* 세션에 저장된 g7_locale 값을 반환하며, 설정되지 않은 경우 기본값으로 'ko'를 반환합니다.
*/
function getCurrentLanguage(): string
{
return $_SESSION['g7_locale'] ?? 'ko';
}
/**
* 지정된 언어의 번역 파일을 로드합니다.
*
* @param string $lang 언어 코드 (예: 'ko', 'en')
* @return array<string, mixed> 번역 배열, 파일이 없으면 빈 배열
*/
function loadTranslations(string $lang): array
{
$langFile = __DIR__."/../lang/{$lang}.php";
if (! file_exists($langFile)) {
return [];
}
$translations = require $langFile;
return is_array($translations) ? $translations : [];
}
/**
* 번역 키에 해당하는 번역 문자열을 반환합니다.
*
* global $translations 배열에서 키에 해당하는 값을 찾아 반환합니다.
* 키가 존재하지 않으면 키 자체를 반환합니다.
*
* @param string $key 번역 키 (예: 'welcome_title', 'next_step')
* @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2'])
*/
if (! function_exists('lang')) {
function lang(string $key, array $replace = []): string
{
global $translations;
if (! isset($translations) || ! is_array($translations)) {
return $key;
}
$message = $translations[$key] ?? $key;
// 플레이스홀더 치환 (:placeholder 형식)
foreach ($replace as $placeholder => $value) {
$message = str_replace(":{$placeholder}", $value, $message);
}
return $message;
}
}
/**
* 설치 단계 이름을 반환합니다.
*
* @param int $step 단계 번호 (0~5, 6은 예약됨)
* @return string 단계명 번역 키
*/
function getStepName(int $step): string
{
$stepNames = [
0 => 'step_0_welcome',
1 => 'step_1_license',
2 => 'step_2_requirements',
3 => 'step_3_configuration',
4 => 'step_4_extension_selection',
5 => 'step_5_installation',
6 => 'step_6_complete',
];
return $stepNames[$step] ?? 'step_unknown';
}
/**
* 설치 작업 이름을 반환합니다.
*
* @param string $task 작업 ID (예: 'composer_check', 'env_create')
* @return string 작업명 번역 키
*/
function getTaskName(string $task): string
{
$taskNames = [
// 환경 설정 그룹
'composer_check' => 'task_composer_check',
'composer_install' => 'task_composer_install',
'env_create' => 'task_env_create',
'env_update' => 'task_env_update',
'key_generate' => 'task_key_generate',
// 데이터베이스 그룹
'db_migrate' => 'task_db_migrate',
'db_seed' => 'task_db_seed',
// 관리자 템플릿 그룹
'template_install' => 'task_template_install',
'template_activate' => 'task_template_activate',
// 모듈 그룹
'module_install' => 'task_module_install',
'module_activate' => 'task_module_activate',
// 플러그인 그룹
'plugin_install' => 'task_plugin_install',
'plugin_activate' => 'task_plugin_activate',
// 사용자 템플릿 그룹
'user_template_install' => 'task_user_template_install',
'user_template_activate' => 'task_user_template_activate',
// 마무리 그룹
'create_settings_json' => 'task_create_settings_json',
'cache_clear' => 'task_cache_clear',
'complete_flag' => 'task_complete_flag',
];
return $taskNames[$task] ?? 'task_unknown';
}
/**
* 지정한 Step으로 리다이렉트합니다.
*
* @param int $step 이동할 단계 번호
*/
function redirectToStep(int $step): void
{
$_SESSION['installer_current_step'] = $step;
header('Location: '.INSTALLER_BASE_URL.'/');
exit;
}
/**
* 언어를 변경하고 세션 및 state.json에 저장합니다.
*
* 저장 위치:
* - 세션: PHP 페이지 렌더링 시 사용
* - state.json: 다른 브라우저에서 재개 시 사용, 설치 완료 후 Laravel 본체에 전달
* - localStorage: JavaScript에서 직접 관리 (0-welcome.php에서 처리)
*
* @param string $lang 언어 코드 ('ko' 또는 'en')
*/
function handleLanguageChange(string $lang): void
{
// 지원하는 언어만 허용
if (! array_key_exists($lang, SUPPORTED_LANGUAGES)) {
return;
}
// 세션에 저장
$_SESSION['g7_locale'] = $lang;
// state.json에도 저장 (다른 브라우저에서 재개 지원 + 설치 완료 후 본체 전달)
$state = getInstallationState();
$state['g7_locale'] = $lang;
saveInstallationState($state);
}
/**
* 현재 단계의 상태를 업데이트합니다.
*
* @param int $step 완료할 단계 번호
* @param int $nextStep 다음 단계 번호
* @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data])
*/
function updateStepStatus(int $step, int $nextStep, array $additionalData = []): void
{
$state = getInstallationState();
$state['current_step'] = $nextStep;
$state['step_status'][$step] = 'completed';
if (! empty($additionalData)) {
$state = array_merge($state, $additionalData);
}
saveInstallationState($state);
}
/**
* 설치가 진행 중인지 확인합니다.
*/
function isInstallationRunning(): bool
{
$state = getInstallationState();
return isset($state['installation_status']) && $state['installation_status'] === 'running';
}
/**
* 설치 진행 중 알럿을 표시하고 Step 5로 리다이렉트합니다.
*/
function showInstallationRunningAlert(): void
{
global $translations;
// 번역이 로드되지 않은 경우 로드
if (! isset($translations)) {
$translations = loadTranslations(getCurrentLanguage());
}
$_SESSION['installer_current_step'] = 5;
showAlertAndRedirect(
lang('installation_in_progress'),
lang('installation_in_progress_message'),
INSTALLER_BASE_URL.'/'
);
}
/**
* 설치 완료 알럿을 표시하고 홈으로 리다이렉트합니다.
*/
function showInstallationCompletedAlert(): void
{
global $translations;
// 번역이 로드되지 않은 경우 로드
if (! isset($translations)) {
$translations = loadTranslations(getCurrentLanguage());
}
showAlertAndRedirect(
lang('installation_already_completed'),
lang('installation_already_completed_message'),
'../'
);
}
/**
* 알럿을 표시하고 지정된 URL로 리다이렉트합니다.
*
* @param string $title 페이지 제목
* @param string $message 알럿 메시지
* @param string $redirectUrl 리다이렉트 URL
*/
function showAlertAndRedirect(string $title, string $message, string $redirectUrl): void
{
global $translations;
if (! isset($translations)) {
$translations = loadTranslations(getCurrentLanguage());
}
$lang = htmlspecialchars(getCurrentLanguage());
$title = htmlspecialchars($title);
$message = htmlspecialchars($message, ENT_QUOTES);
$redirectUrl = htmlspecialchars($redirectUrl);
echo '<!DOCTYPE html>';
echo '<html lang="'.$lang.'">';
echo '<head>';
echo '<meta charset="UTF-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
echo '<title>'.$title.'</title>';
echo '</head>';
echo '<body>';
echo '<script>';
echo 'alert("'.$message.'");';
echo 'window.location.href = "'.$redirectUrl.'";';
echo '</script>';
echo '</body>';
echo '</html>';
exit;
}
/**
* 단계 파일을 찾을 수 없을 때 에러 페이지를 표시합니다.
*
* @param int $currentStep 현재 단계 번호
*/
function showStepFileNotFoundError(int $currentStep): void
{
$previousStep = max(0, $currentStep - 1);
$_SESSION['installer_current_step'] = $previousStep;
echo '<div class="installer-container">';
echo '<h1 class="installer-title">';
echo '<span class="error-icon">';
echo '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">';
echo '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />';
echo '</svg>';
echo '</span>';
echo lang('error');
echo '</h1>';
echo '<p class="installer-description">'.lang('error_step_file_not_found').'</p>';
echo '<div class="btn-group">';
echo '<a href="'.INSTALLER_BASE_URL.'/" class="btn btn-secondary">← '.lang('previous').'</a>';
echo '</div>';
echo '</div>';
}
/**
* 데이터베이스 필드 해시값을 계산합니다.
*
* DB 연결 정보가 변경되었는지 감지하기 위해 사용됩니다.
*
* @param array $config 폼 데이터 배열
* @param string $prefix DB 접두사 ('db_write' 또는 'db_read')
* @return string MD5 해시값
*/
function getDatabaseFieldHash(array $config, string $prefix): string
{
// json-encode:allow — 캐시 키 계산용 md5 입력, 응답으로 나가지 않는다.
// 플래그를 바꾸면 해시값이 달라져 "필드가 바뀌었다" 는 오판이 생기므로 그대로 둔다.
return md5(json_encode([
$config["{$prefix}_host"] ?? '',
$config["{$prefix}_port"] ?? '',
$config["{$prefix}_database"] ?? '',
$config["{$prefix}_username"] ?? '',
]));
}
/**
* SVG 아이콘을 반환합니다.
*
* @param string $type 아이콘 타입 ('success', 'warning', 'error')
* @return string SVG HTML
*/
function getSvgIcon(string $type): string
{
$icons = [
'success' => '<svg class="result-icon result-icon-success" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
<circle class="result-icon-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="result-icon-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
</svg>',
'warning' => '<svg class="result-icon result-icon-warning" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="color:#f59e0b">
<circle class="result-icon-circle" cx="26" cy="26" r="25" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M26 12 L26 28" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none"/>
<circle cx="26" cy="36" r="2.5" fill="currentColor"/>
</svg>',
'error' => '<svg class="result-icon result-icon-error" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
<circle class="result-icon-circle" cx="26" cy="26" r="25" fill="none"/>
<path class="result-icon-x" fill="none" d="M16 16 L36 36 M36 16 L16 36"/>
</svg>',
];
return $icons[$type] ?? '';
}
/**
* 설치 결과 섹션 HTML을 렌더링합니다.
*
* @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure')
* @param string $iconType 아이콘 타입 ('success', 'warning', 'error')
* @param string $titleKey 제목 번역 키
* @param string $messageKey 메시지 번역 키 (failure인 경우 동적)
* @param string $buttonsHtml 버튼 HTML
* @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달)
* @return string 결과 섹션 HTML
*/
function renderInstallResultSection(string $sectionId, string $iconType, string $titleKey, string $messageKey, string $buttonsHtml, string $extraHtml = ''): string
{
$icon = getSvgIcon($iconType);
$title = htmlspecialchars(lang($titleKey));
// failure는 동적 메시지이므로 번역하지 않음
$message = $sectionId === 'failure'
? '<div class="result-message" id="failure-message"></div>'
: '<p class="result-message">'.htmlspecialchars(lang($messageKey)).'</p>';
return <<<HTML
<div id="{$sectionId}-section" class="result-section hidden">
<div class="result-content">
<div class="result-left">
{$icon}
<div class="result-text">
<h3 class="result-title">{$title}</h3>
{$message}
{$extraHtml}
</div>
</div>
<div class="result-button-group">
{$buttonsHtml}
</div>
</div>
</div>
HTML;
}
/**
* 코어 애플리케이션 버전을 추출합니다.
*
* 우선순위: .env(설치 후) → .env.example(설치 전) → 'unknown'
* 인스톨러는 Laravel 부팅 전이므로 config('app.version') 을 쓸 수 없습니다.
*
* @return string 코어 버전 문자열 (예: '7.0.0-beta.4')
*/
function getCoreAppVersion(): string
{
foreach (['/.env', '/.env.example'] as $relative) {
$path = BASE_PATH.$relative;
if (! is_readable($path)) {
continue;
}
$content = @file_get_contents($path);
if ($content === false) {
continue;
}
if (preg_match('/^APP_VERSION=(.+)$/m', $content, $matches)) {
return trim($matches[1], "\"' \t\r\n");
}
}
return 'unknown';
}
/**
* Progress indicator HTML을 렌더링합니다.
*
* @param int $currentStep 현재 단계 번호
* @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6)
* @return string Progress indicator HTML
*/
function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string
{
$html = '<div class="progress-indicator">';
for ($i = 0; $i < $totalSteps; $i++) {
$class = 'progress-step';
if ($i < $currentStep) {
$class .= ' completed';
} elseif ($i === $currentStep) {
$class .= ' active';
}
$html .= '<div class="'.$class.'"></div>';
}
$html .= '</div>';
return $html;
}
/**
* 현재 PHP 프로세스를 실행 중인 웹 서버 사용자명을 감지합니다.
*
* posix 확장 → exec('whoami') → 환경변수 순서로 시도하며,
* 모두 실패하면 null을 반환합니다.
*
* @return string|null 웹 서버 사용자명 (예: 'www-data', 'nginx', 'apache') 또는 null
*/
function getWebServerUser(): ?string
{
// 1. posix 확장 (가장 신뢰도 높음)
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
$info = posix_getpwuid(posix_geteuid());
if ($info && ! empty($info['name'])) {
return $info['name'];
}
}
// 2. exec('whoami')
//
// whoami 는 UTF-8 이 아니라 그 명령을 실행한 콘솔의 코드페이지로 출력한다.
// 한국어 Windows(OEM 949)에서 계정명에 한글이 있으면 invalid UTF-8 바이트가 들어오고,
// 그 값이 응답에 실리면 json_encode 가 false 를 반환해 빈 본문(HTTP 200)이 나간다
// (gnuboard/g7#62). 반환 전에 반드시 정규화한다.
if (function_exists('exec')) {
$user = @exec('whoami');
if (! empty($user)) {
return installer_normalize_process_output(trim($user), 'whoami');
}
}
// 3. 환경변수
$envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null;
if (! empty($envUser)) {
return installer_normalize_process_output($envUser, 'env:USER');
}
return null;
}
/**
* 현재 PHP 프로세스를 실행 중인 웹 서버 그룹명을 감지합니다.
*
* @return string|null 웹 서버 그룹명 (예: 'www-data', 'nginx') 또는 null
*/
function getWebServerGroup(): ?string
{
if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) {
$info = posix_getgrgid(posix_getegid());
if ($info && ! empty($info['name'])) {
return $info['name'];
}
}
$envGroup = getenv('APACHE_RUN_GROUP') ?: null;
if (! empty($envGroup)) {
return installer_normalize_process_output($envGroup, 'env:APACHE_RUN_GROUP');
}
return null;
}
/**
* 파일/디렉토리의 소유자 이름을 반환합니다.
*
* posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다.
*
* @param string $path 파일/디렉토리 경로
* @return string|null 소유자 이름 또는 null
*/
function getFileOwnerName(string $path): ?string
{
$uid = @fileowner($path);
if ($uid === false) {
return null;
}
if (function_exists('posix_getpwuid')) {
$info = @posix_getpwuid($uid);
if ($info && ! empty($info['name'])) {
return $info['name'];
}
}
// posix 확장이 없으면 UID를 문자열로 반환
return (string) $uid;
}
/**
* 파일/디렉토리의 소유 그룹 이름을 반환합니다.
*
* posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다.
*
* @param string $path 파일/디렉토리 경로
* @return string|null 그룹 이름 또는 null
*/
function getFileGroupName(string $path): ?string
{
$gid = @filegroup($path);
if ($gid === false) {
return null;
}
if (function_exists('posix_getgrgid')) {
$info = @posix_getgrgid($gid);
if ($info && ! empty($info['name'])) {
return $info['name'];
}
}
// posix 확장이 없으면 GID를 문자열로 반환
return (string) $gid;
}
/**
* chown 명령어에 사용할 user:group 문자열을 반환합니다.
*
* 감지 실패 시 'www-data:www-data'를 기본값으로 사용합니다.
*
* @return string 'user:group' 형식 문자열
*/
function getChownTarget(): string
{
$user = getWebServerUser() ?? 'www-data';
$group = getWebServerGroup() ?? $user;
return $user.':'.$group;
}
/**
* 디렉토리 권한을 체크합니다.
*
* Step 0과 Step 2에서 재사용 가능한 공통 함수입니다.
* 디렉토리를 생성하지 않고 존재 여부와 권한만 체크합니다.
*
* @param array<string, bool> $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부)
* @return array{all_passed: bool, results: array<string, array{exists: bool, writable: bool, permissions: string, required_permissions: string, passed: bool, has_subdirectory_issues: bool, failed_subdirectories: array<string>}>}
*/
function checkDirectoryPermissions(array $directories): array
{
// 파일 시스템 캐시 클리어 (권한 체크 결과가 캐싱되는 것을 방지)
clearstatcache(true);
$results = [];
$allPassed = true;
$requiredPermsDisplay = REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY;
// 웹서버 실행 사용자 (ownership_mismatch 판별용)
$webServerUser = getWebServerUser();
foreach ($directories as $path => $checkRecursive) {
$fullPath = BASE_PATH.'/'.$path;
// 디렉토리 존재 여부 확인 (생성하지 않음)
$exists = is_dir($fullPath);
// 실제 동작 기준 검증: 비트 값이 아닌 is_writable/is_readable로 판정
// 업계 표준에 따라 어떤 권한 조합이든 실제 동작하면 통과 (0755/0775/0770/0777 등)
$writable = $exists && is_writable($fullPath);
$readable = $exists && is_readable($fullPath);
// 권한 코드는 정보 표시용으로만 유지
$permissions = 'N/A';
$permsOctal = 0;
if ($exists) {
$perms = fileperms($fullPath);
$permissions = substr(sprintf('%o', $perms), -3);
$permsOctal = octdec($permissions);
}
// 소유자/소유그룹 정보 가져오기
$owner = null;
$group = null;
if ($exists) {
$owner = getFileOwnerName($fullPath);
$group = getFileGroupName($fullPath);
}
// 재귀 체크 여부 확인
$failedSubdirectories = [];
$hasSubdirectoryIssues = false;
if ($checkRecursive && $exists) {
// 디렉토리가 존재하면 하위 체크 (writable 여부와 무관하게)
$failedSubdirectories = checkSubdirectoriesRecursive($fullPath);
$hasSubdirectoryIssues = count($failedSubdirectories) > 0;
}
// 상위 디렉토리 자체의 통과 여부 — is_writable && is_readable만 충족하면 OK
$parentPassed = $writable && $readable;
// 최종 통과 여부: 상위 + 하위 모두 통과해야 함
$passed = $parentPassed && ! $hasSubdirectoryIssues;
// 에러 타입 구분
// ownership_mismatch: 권한 비트는 0755 이상이지만 소유자가 웹서버 사용자와 달라
// 실제 쓰기 불가 (전통적 Apache: 파일 소유자 != www-data)
$errorType = null;
if (! $exists) {
$errorType = 'not_exists';
} elseif (! $writable) {
// 권한 비트가 충분(0755+)한데도 쓰기 불가 → 소유권 불일치 가능성
if ($permsOctal >= 0755 && $webServerUser && $owner && $owner !== $webServerUser) {
$errorType = 'ownership_mismatch';
} else {
$errorType = 'not_writable';
}
} elseif (! $readable) {
$errorType = 'not_readable';
} elseif ($hasSubdirectoryIssues) {
// 상위는 OK, 하위 디렉토리만 문제
$errorType = 'subdirectory_issues';
}
$results[$path] = [
'exists' => $exists,
'writable' => $writable,
'readable' => $readable,
'permissions' => $permissions,
'required_permissions' => $requiredPermsDisplay,
'owner' => $owner,
'group' => $group,
'web_server_user' => $webServerUser,
'passed' => $passed,
'error_type' => $errorType,
'has_subdirectory_issues' => $hasSubdirectoryIssues,
'failed_subdirectories' => $failedSubdirectories,
];
if (! $passed) {
$allPassed = false;
}
}
return [
'all_passed' => $allPassed,
'results' => $results,
];
}
/**
* 디렉토리의 하위를 재귀적으로 체크하여 권한이 없는 경로와 권한 정보 반환
*
* 깊이 제한(maxDepth)을 둔다 (dev-g7#445 후속): storage 처럼 하위에 수만 개
* 디렉토리가 누적되는 경로(예: storage/framework/testing/)를 무한 재귀로 순회하면
* 인스톨러 2단계(요구사항 검증) 응답이 수 초 이상 지연된다. 쓰기 권한 문제는 트리
* 상위 얕은 깊이에서 결정되므로(상위가 쓰기 가능하면 하위도 통상 상속), 제한 깊이까지만
* 검사해 성능과 안내 유효성을 모두 확보한다.
*
* @param string $path 체크할 디렉토리 경로
* @param int $maxDepth 재귀 최대 깊이 (0 = 현재 디렉토리의 직속 하위만). 기본 1
* @param int $currentDepth 내부 재귀용 현재 깊이 (호출자는 지정하지 않음)
* @return array<array{path: string, permissions: string}> 권한이 없는 하위 디렉토리 정보 배열
*/
function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $currentDepth = 0): array
{
$failedPaths = [];
if (! is_dir($path)) {
return $failedPaths;
}
// scandir를 사용하여 디렉토리 목록 가져오기 (읽을 수 없으면 빈 배열 반환)
try {
$items = @scandir($path);
if ($items === false) {
// 디렉토리를 읽을 수 없으면 빈 배열 반환
return $failedPaths;
}
} catch (Exception $e) {
return $failedPaths;
}
foreach ($items as $item) {
// . 및 .. 제외
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $path.DIRECTORY_SEPARATOR.$item;
// 디렉토리인지 확인 (심볼릭 링크 제외)
if (! is_dir($itemPath) || is_link($itemPath)) {
continue;
}
// 절대 경로를 상대 경로로 변환
$relativePath = str_replace(BASE_PATH.'/', '', $itemPath);
$relativePath = str_replace('\\', '/', $relativePath);
// 쓰기 권한 체크
$isWritable = is_writable($itemPath);
if (! $isWritable) {
// 권한 코드 가져오기
$perms = fileperms($itemPath);
$permissions = substr(sprintf('%o', $perms), -3);
$failedPaths[] = [
'path' => $relativePath,
'permissions' => $permissions,
];
}
// 재귀적으로 하위 디렉토리 체크 (권한 없어도 계속 진행)
// 깊이 제한 도달 시 더 내려가지 않는다 — storage/framework/testing 등에 누적된
// 수만 개 하위 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단 (dev-g7#445 후속).
if ($currentDepth < $maxDepth) {
try {
$subFailed = checkSubdirectoriesRecursive($itemPath, $maxDepth, $currentDepth + 1);
$failedPaths = array_merge($failedPaths, $subFailed);
} catch (Exception $e) {
// 하위 디렉토리 체크 실패해도 계속 진행
}
}
}
return $failedPaths;
}
/**
* 라이선스 파일 내용을 로드합니다.
*
* 루트 LICENSE 파일을 읽습니다. (한국어 번역 + 영문 원문 통합)
*
* @param string $lang 언어 코드 (미사용, 하위 호환성 유지)
* @return string 라이선스 파일 내용
*/
function loadLicenseFile(string $lang): string
{
$licenseFile = __DIR__.'/../../../LICENSE';
if (file_exists($licenseFile)) {
return file_get_contents($licenseFile);
}
return lang('license_not_found');
}
if (! function_exists('escapeEnvValue')) {
/**
* .env 값 이스케이프
*
* 특수 문자가 포함된 값을 .env 파일에 안전하게 기록할 수 있도록 큰따옴표로 감싸고
* 내부 큰따옴표와 백슬래시를 이스케이프합니다.
*
* installer-runtime.php 가 동일 함수를 polyfill 로 선언하므로 function_exists
* 가드가 필수다. 가드가 없으면 installer-runtime.php 가 functions.php 보다 먼저
* 로드되는 엔드포인트(install-process.php)에서 "Cannot redeclare" fatal 이 발생해
* JSON 응답이 통째로 유실된다 (설치 시작 즉시 실패).
*
* @param string $value 이스케이프할 값
* @return string 큰따옴표로 감싸고 내부 큰따옴표와 백슬래시를 이스케이프한 값
*/
function escapeEnvValue(string $value): string
{
// 직렬화 정책은 serializeEnvValue 단일 관문이 소유한다 (KVE-2026-2042).
// 이 이름은 하위호환 별칭일 뿐이며, 개행을 조용히 지우던 종전 동작은 남기지 않는다 —
// 남겨 두면 다음 호출자가 그 경로로 같은 결함을 다시 들여온다.
return serializeEnvValue($value);
}
}
/**
* state.json의 config 값을 기반으로 .env 내용을 생성합니다.
*
* .env.example 템플릿의 플레이스홀더를 사용자 입력값으로 치환합니다.
* APP_KEY는 치환하지 않음 — artisan key:generate로 별도 생성합니다.
*
* @return string|null .env 내용 문자열, .env.example 없으면 null
*/
function generateEnvContent(): ?string
{
$envExamplePath = BASE_PATH.'/.env.example';
if (! file_exists($envExamplePath)) {
return null;
}
$envContent = file_get_contents($envExamplePath);
$state = getInstallationState();
$config = $state['config'] ?? [];
// 데이터베이스 설정 치환
$replacements = [
'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql',
'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.serializeEnvValue((string) ($config['db_write_host'] ?? '127.0.0.1')),
'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.serializeEnvValue((string) ($config['db_write_port'] ?? '3306')),
'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.serializeEnvValue((string) ($config['db_write_database'] ?? 'g7')),
'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.serializeEnvValue((string) ($config['db_write_username'] ?? 'root')),
'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.serializeEnvValue((string) ($config['db_write_password'] ?? '')),
'DB_PREFIX=g7_' => 'DB_PREFIX='.serializeEnvValue((string) ($config['db_prefix'] ?? 'g7_')),
];
// Read DB 설정
if (! empty($config['use_read_db']) && $config['use_read_db']) {
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.serializeEnvValue((string) ($config['db_read_host'] ?? '127.0.0.1'));
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.serializeEnvValue((string) ($config['db_read_port'] ?? '3306'));
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.serializeEnvValue((string) ($config['db_read_database'] ?? 'g7'));
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.serializeEnvValue((string) ($config['db_read_username'] ?? 'root'));
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.serializeEnvValue((string) ($config['db_read_password'] ?? ''));
} else {
// Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다.
// config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로
// .env 에 write 값을 중복 기록하지 않는다 (write 변경 시 read stale 방지). (이슈 #63)
$replacements['DB_READ_HOST='] = 'DB_READ_HOST=';
$replacements['DB_READ_PORT='] = 'DB_READ_PORT=';
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=';
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=';
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=';
}
// 앱 설정 치환
$replacements['APP_NAME=그누보드7'] = 'APP_NAME='.serializeEnvValue((string) ($config['app_name'] ?? '그누보드7'));
$replacements['APP_ENV=production'] = 'APP_ENV='.serializeEnvValue((string) ($config['app_env'] ?? 'production'));
$replacements['APP_URL=http://localhost'] = 'APP_URL='.serializeEnvValue((string) ($config['app_url'] ?? 'http://localhost'));
// 언어 설정
$currentLang = getCurrentLanguage();
$replacements['APP_LOCALE=ko'] = 'APP_LOCALE='.$currentLang;
$replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE='.($currentLang === 'ko' ? 'ko_KR' : 'en_US');
if (isset($config['app_env']) && $config['app_env'] !== 'production') {
$replacements['APP_DEBUG=false'] = 'APP_DEBUG=true';
}
// 코어 업데이트 설정 치환
$corePendingPath = $config['core_update_pending_path'] ?? '';
$coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7';
$coreGithubToken = $config['core_update_github_token'] ?? '';
$replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.serializeEnvValue((string) $corePendingPath);
$replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.serializeEnvValue((string) $coreGithubUrl);
$replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.serializeEnvValue((string) $coreGithubToken);
// PHP CLI / Composer 바이너리 경로 치환
$phpBinary = $config['php_binary'] ?? 'php';
$composerBinary = $config['composer_binary'] ?? '';
$replacements['PHP_BINARY=php'] = 'PHP_BINARY='.serializeEnvValue((string) $phpBinary);
$replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.serializeEnvValue((string) $composerBinary);
foreach ($replacements as $search => $replace) {
$envContent = str_replace($search, $replace, $envContent);
}
return $envContent;
}
/**
* 현재 서버가 Windows 환경인지 확인합니다.
*
* @return bool Windows인 경우 true
*/
function isWindows(): bool
{
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
}
/**
* OS에 맞는 .env 파일 복사 명령어를 반환합니다.
*
* @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용)
* @return string 복사 명령어
*/
function getEnvCopyCommand(string $basePath = ''): string
{
if (isWindows()) {
$source = $basePath ? $basePath.'\\.env.example' : '.env.example';
$dest = $basePath ? $basePath.'\\.env' : '.env';
return 'copy '.str_replace('/', '\\', $source).' '.str_replace('/', '\\', $dest);
}
$source = $basePath ? $basePath.'/.env.example' : '.env.example';
$dest = $basePath ? $basePath.'/.env' : '.env';
return 'cp '.$source.' '.$dest;
}
/**
* OS에 맞는 권한 수정 명령어를 반환합니다.
*
* 업계 표준(WordPress/Drupal/Joomla/Laravel)에 맞춰 디렉토리 755, 파일 644로 통일.
* chown/setgid 의존성을 제거하여 공유호스팅 호환성 확보.
* 실제 통과 기준은 is_writable() && is_readable()이므로 비트 값은 참고용.
*
* @param string $pathList 대상 경로 (공백 구분)
* @param string $mode 'ownership' (디렉토리) 또는 'file' (파일)
* @return string 권한 수정 명령어
*/
function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): string
{
if (isWindows()) {
$winPath = str_replace('/', '\\', $pathList);
if ($mode === 'file') {
return 'icacls '.$winPath.' /grant Everyone:F';
}
return 'icacls '.$winPath.' /grant Everyone:(OI)(CI)F /T';
}
if ($mode === 'file') {
return 'chmod 644 '.$pathList;
}
return 'chmod -R 755 '.$pathList;
}
/**
* 기존 DB 테이블 감지.
*
* Write DB에 테이블이 존재하는지 확인하고, G7 시그니처 테이블과 비교하여
* 설치 진행 가능 여부를 판정합니다.
*
* severity:
* - 'empty' : 빈 DB (정상 진행)
* - 'g7_existing' : G7 시그니처 4개 모두 존재 (기존 설치 감지)
* - 'mixed' : G7 일부 + 기타 혼재
* - 'foreign_data' : G7 시그니처 없음 + 기타 테이블 존재
*
* @param PDO $pdo 연결된 PDO 인스턴스
* @param string $database 대상 데이터베이스명
*/
function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ''): array
{
try {
$stmt = $pdo->query('SHOW TABLES');
$tables = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
} catch (PDOException $e) {
return [
'has_tables' => false,
'is_g7_install' => false,
'g7_tables_found' => [],
'other_tables_count' => 0,
'all_tables' => [],
'severity' => 'empty',
'error' => $e->getMessage(),
];
}
if (empty($tables)) {
return [
'has_tables' => false,
'is_g7_install' => false,
'g7_tables_found' => [],
'other_tables_count' => 0,
'all_tables' => [],
'severity' => 'empty',
];
}
// G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단
// 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등)
$coreSignatures = ['users', 'migrations', 'roles', 'permissions'];
$g7Signatures = array_map(fn ($t) => $tablePrefix.$t, $coreSignatures);
$g7TablesFound = array_values(array_intersect($g7Signatures, $tables));
$severity = 'foreign_data';
if (count($g7TablesFound) >= 4) {
$severity = 'g7_existing';
} elseif (count($g7TablesFound) > 0) {
$severity = 'mixed';
}
return [
'has_tables' => true,
'is_g7_install' => $severity === 'g7_existing',
'g7_tables_found' => $g7TablesFound,
'other_tables_count' => max(0, count($tables) - count($g7TablesFound)),
'all_tables' => $tables,
'severity' => $severity,
];
}
if (! function_exists('filterTablesByPrefix')) {
/**
* 기존 테이블 목록을 db_prefix 로 필터링합니다.
*
* drop_tables 동의의 의미는 "이 prefix 로 설치되는 G7 테이블을 지우고 다시 설치"이므로,
* 입력한 prefix 로 시작하는 테이블만 삭제 대상으로 선별합니다. prefix 가 없는
* 다른 애플리케이션/이전 설치의 테이블은 동일 DB 를 공유하더라도 보존합니다.
*
* 빈 prefix 는 전체 일치가 되어 모든 테이블이 삭제되는 위험이 있으므로,
* 데이터 손실 방어를 위해 빈 목록(삭제 대상 없음)을 반환합니다.
*
* @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록
* @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_')
* @return array prefix 로 시작하는 테이블만 담은 목록
*/
function filterTablesByPrefix(array $tables, string $prefix): array
{
if ($prefix === '') {
return [];
}
$prefixLength = strlen($prefix);
return array_values(array_filter(
$tables,
fn ($table) => strncmp((string) $table, $prefix, $prefixLength) === 0
));
}
}