diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5536fc..35ba5408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ - 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) +- 설치 마법사 2단계(설치 환경 확인)가 Windows 사용자 계정 이름에 한글이 포함되어 있으면 빈 응답을 받아 `Unexpected end of JSON input` 오류로 더 진행되지 않던 문제를 수정했습니다. 계정 이름을 영문으로 바꾸지 않아도 설치할 수 있습니다. (sir.kr 커뮤니티의 FreeMax 님께서 제보해주셨습니다.) +- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.) +- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.) +- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다. ## [7.0.9] - 2026-08-24 diff --git a/INSTALL.md b/INSTALL.md index 6d3d7c47..c26aa256 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -368,6 +368,28 @@ http://도메인/install --- +## 설치 트러블슈팅 + +### 2단계에서 "Unexpected end of JSON input" 오류가 표시되는 경우 + +**증상**: 설치 마법사 2단계(설치 환경 확인)에서 요구사항 카드가 표시되지 않고 +`요구사항 검증 실패: ... Unexpected end of JSON input` 이 표시됩니다. +서버 로그에는 아무 오류도 남지 않습니다. + +**원인**: Windows 사용자 계정 이름에 한글(또는 서버가 쓰는 문자 인코딩 밖의 문자)이 +포함되어 있으면, 설치 마법사가 계정 이름을 조회하는 과정에서 응답을 만들지 못합니다. + +**해결**: + +- **7.0.10 이상**: 수정되었습니다. 별도 조치가 필요하지 않습니다. +- **7.0.9 이하**: 계정 이름이 영문인 Windows 계정으로 웹 서버를 실행하거나, + 코어를 7.0.10 이상으로 올린 뒤 설치를 진행하세요. + +설치 과정에서 문자 인코딩 관련 조치가 있었다면 `storage/logs/installation.log` 에 +`[env] whoami output was not UTF-8 — normalized` 형태로 기록됩니다. + +--- + ## 설치 후 확인 설치가 완료되면 아래 페이지에 접근할 수 있습니다. diff --git a/README.ko.md b/README.ko.md index 38398263..70098fc3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -525,8 +525,8 @@ cp .env.example .env abc101 hwaryeon1234 koojunho - yks118 kitrio + yks118 movielee2020 ChoDongHyeon comtylove-netizen diff --git a/README.md b/README.md index 94b08c18..907e300d 100644 --- a/README.md +++ b/README.md @@ -539,8 +539,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped — abc101 hwaryeon1234 koojunho - yks118 kitrio + yks118 movielee2020 ChoDongHyeon comtylove-netizen diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index c19904b7..66b2e678 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -11,6 +11,7 @@ use App\Seo\Contracts\SeoCacheManagerInterface; use App\Support\ConfigCacheHelper; use App\Support\ExtensionSettingsMirror; use App\Support\OpcacheStatus; +use App\Support\ProcessOutputEncoding; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -1496,7 +1497,10 @@ class SettingsService if (PHP_OS_FAMILY === 'Windows') { $output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1'); if ($output) { - $name = trim($output); + // 2>&1 로 합쳐진 오류 문장은 시스템 코드페이지(한국어 Windows = CP949)로 출력된다. + // 정규화하지 않으면 이 값이 시스템 정보 API 응답에 실려 JsonResponse 직렬화가 + // Malformed UTF-8 로 500 을 낸다 (gnuboard/g7#62 와 동형). + $name = trim(ProcessOutputEncoding::normalize($output)); if ($name !== '' && ! str_contains(strtolower($name), 'error')) { return $name; } @@ -1504,7 +1508,7 @@ class SettingsService $output = @shell_exec('wmic cpu get name 2>&1'); if ($output) { - $lines = explode("\n", trim($output)); + $lines = explode("\n", trim(ProcessOutputEncoding::normalize($output))); if (isset($lines[1]) && trim($lines[1]) !== '') { return trim($lines[1]); } diff --git a/app/Support/ProcessOutputEncoding.php b/app/Support/ProcessOutputEncoding.php new file mode 100644 index 00000000..f08a03da --- /dev/null +++ b/app/Support/ProcessOutputEncoding.php @@ -0,0 +1,140 @@ + + */ + private const DETECT_ORDER = ['EUC-KR', 'CP949']; + + /** + * 외부 프로세스 출력·환경값 하나를 항상 유효한 UTF-8 로 만듭니다. + * + * @param string $value 정규화할 원본 문자열 + * @return string 항상 유효한 UTF-8 문자열 + */ + public static function normalize(string $value): string + { + if ($value === '' || mb_check_encoding($value, 'UTF-8')) { + return $value; + } + + // (1) 한국어 코드페이지 확정 감지 — OS 무관(테스트·Linux 에서도 같은 답). + $detected = mb_detect_encoding($value, self::DETECT_ORDER, true); + if ($detected !== false) { + $converted = @mb_convert_encoding($value, 'UTF-8', $detected); + if (is_string($converted) && mb_check_encoding($converted, 'UTF-8')) { + return $converted; + } + } + + // (2) Windows: 그 출력을 만든 콘솔(OEM)·시스템(ANSI) 코드페이지로 변환. + // 일본어(932)·중국어(936/950)·서유럽(437/850) 등을 덮는다. + if (PHP_OS_FAMILY === 'Windows' && function_exists('sapi_windows_cp_conv') && function_exists('sapi_windows_cp_get')) { + foreach ([sapi_windows_cp_get('oem'), sapi_windows_cp_get('ansi')] as $codepage) { + if ($codepage === 0 || $codepage === 65001) { + continue; + } + + $converted = @sapi_windows_cp_conv($codepage, 65001, $value); + if (is_string($converted) && $converted !== '' && mb_check_encoding($converted, 'UTF-8')) { + return $converted; + } + } + } + + // (3) 최종 방어 — 의미는 잃어도 응답은 살린다. + return mb_scrub($value, 'UTF-8'); + } + + /** + * 배열 트리 전체(키 포함)에 정규화를 적용합니다. + * + * 문자열이 아닌 스칼라(int/float/bool/null)와 객체는 그대로 둡니다. + * + * @param mixed $value 정규화할 값 (배열이면 재귀) + * @return mixed 정규화된 값 + */ + public static function normalizeDeep(mixed $value): mixed + { + if (is_string($value)) { + return self::normalize($value); + } + + if (! is_array($value)) { + return $value; + } + + $normalized = []; + foreach ($value as $key => $item) { + $normalizedKey = is_string($key) ? self::normalize($key) : $key; + $normalized[$normalizedKey] = self::normalizeDeep($item); + } + + return $normalized; + } + + /** + * invalid UTF-8 문자열이 남아 있는 키 경로 목록을 반환합니다. + * + * 인코딩 실패를 로그로 남길 때 "어느 필드가 원인인가" 를 운영자에게 알려주는 용도입니다. + * 반환 형태는 `directories.web_server_user` 같은 점 구분 경로이며, + * 스칼라 루트가 invalid 이면 빈 문자열 경로 하나를 돌려줍니다. + * + * @param mixed $value 검사할 값 + * @param string $prefix 현재까지의 키 경로 (재귀용) + * @return list invalid UTF-8 이 발견된 키 경로 목록 + */ + public static function invalidPaths(mixed $value, string $prefix = ''): array + { + if (is_string($value)) { + return mb_check_encoding($value, 'UTF-8') ? [] : [$prefix]; + } + + if (! is_array($value)) { + return []; + } + + $paths = []; + foreach ($value as $key => $item) { + $keyLabel = (string) $key; + $path = $prefix === '' ? $keyLabel : $prefix.'.'.$keyLabel; + + // 키 자체가 invalid 이면 그 키도 encode 를 실패시킨다. + if (is_string($key) && ! mb_check_encoding($key, 'UTF-8')) { + $paths[] = $path.' (key)'; + } + + foreach (self::invalidPaths($item, $path) as $nested) { + $paths[] = $nested; + } + } + + return $paths; + } +} diff --git a/public/install/api/check-configuration.php b/public/install/api/check-configuration.php index 0918ab07..85b88911 100644 --- a/public/install/api/check-configuration.php +++ b/public/install/api/check-configuration.php @@ -15,6 +15,7 @@ use App\Support\PrivilegedDatabaseAccounts; // 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다. // 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다. +require_once __DIR__.'/../includes/utf8.php'; require_once __DIR__.'/../includes/binary-path-policy.php'; /** @@ -87,7 +88,7 @@ class ValidationApi $requirements['is_windows'] = isWindows(); // JSON 응답 반환 - echo json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + echo installer_json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } /** @@ -102,7 +103,7 @@ class ValidationApi // POST 메서드만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('api_method_not_allowed'), ], JSON_UNESCAPED_UNICODE); @@ -204,7 +205,7 @@ class ValidationApi } // 성공 응답 - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'message' => $message, 'type' => $type, @@ -229,7 +230,7 @@ class ValidationApi logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e); // 에러 응답 (200 OK + success: false) - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_db_connection_failed_detail', ['type' => ($isReadDb ? 'Read' : 'Write'), 'error' => $e->getMessage()]), 'type' => $type ?? 'write', @@ -250,7 +251,7 @@ class ValidationApi logInstallationError(lang('error_db_test_failed'), $e); // 에러 응답 (200 OK + success: false) - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => $e->getMessage(), 'type' => $type ?? 'write', @@ -774,7 +775,7 @@ class ValidationApi $phpForComposer = ! empty($found) ? $found[0]['path'] : 'php'; $composerResult = $this->detectComposerBinary($phpForComposer); - echo json_encode([ + echo installer_json_encode([ 'success' => ! empty($found), 'binaries' => $found, 'default_php_available' => $defaultPhpAvailable, @@ -833,7 +834,7 @@ class ValidationApi $result = $this->validatePhpPath($path); - echo json_encode([ + echo installer_json_encode([ 'success' => $result['valid'], 'version' => $result['version'], 'message' => $result['message'], @@ -856,7 +857,7 @@ class ValidationApi $result = $this->validateComposerPath($composerPath, $phpPath); - echo json_encode([ + echo installer_json_encode([ 'success' => $result['valid'], 'version' => $result['version'], 'message' => $result['message'], @@ -871,7 +872,7 @@ class ValidationApi { $path = $_GET['path'] ?? ''; if (empty($path)) { - echo json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE); + echo installer_json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE); return; } @@ -881,7 +882,7 @@ class ValidationApi // 상대경로 부모 추적이 필요한 시나리오가 없다. $hasParentSegment = preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $path) === 1; if ($hasParentSegment || str_contains($path, "\0")) { - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_core_pending_path_invalid'), ], JSON_UNESCAPED_UNICODE); @@ -898,7 +899,7 @@ class ValidationApi if ($resolved === false || ! is_dir($resolved)) { // 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여 // 임의 경로 enumeration 신호 차단. - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_core_pending_path_invalid'), ], JSON_UNESCAPED_UNICODE); @@ -907,7 +908,7 @@ class ValidationApi } $writable = is_writable($resolved); - echo json_encode([ + echo installer_json_encode([ 'success' => $writable, 'message' => $writable ? lang('success_core_pending_path') @@ -1147,7 +1148,7 @@ class ValidationApi private function error400(string $message): void { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => 'Bad Request', 'message' => $message, @@ -1167,7 +1168,7 @@ class ValidationApi // 에러 응답 http_response_code(500); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => 'Internal Server Error', 'message' => $e->getMessage(), diff --git a/public/install/api/check-env.php b/public/install/api/check-env.php index 8f851ea1..10408932 100644 --- a/public/install/api/check-env.php +++ b/public/install/api/check-env.php @@ -5,14 +5,12 @@ * * Step 5 진입 시 JavaScript에서 호출하여 .env 파일의 * 존재 여부 및 쓰기 가능 여부를 확인합니다. - * - * @package G7\Installer */ // 필수 파일 include -require_once __DIR__ . '/../includes/config.php'; -require_once __DIR__ . '/../includes/functions.php'; -require_once __DIR__ . '/_guard.php'; +require_once __DIR__.'/../includes/config.php'; +require_once __DIR__.'/../includes/functions.php'; +require_once __DIR__.'/_guard.php'; installer_guard_or_410(); // JSON 응답 헤더 @@ -21,14 +19,14 @@ header('Content-Type: application/json; charset=utf-8'); // GET 요청만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => 'Only GET requests are allowed.', ], JSON_UNESCAPED_UNICODE); exit; } -$envPath = BASE_PATH . '/.env'; +$envPath = BASE_PATH.'/.env'; // 소유자 === 웹 서버 사용자 여부 판별 (chgrp/sudo 생략 기준) $webUser = getWebServerUser(); @@ -37,7 +35,7 @@ $baseOwner = function_exists('posix_getpwuid') && function_exists('posix_getuid' : null; $ownerIsWebUser = $webUser && $baseOwner && $webUser === $baseOwner; -echo json_encode([ +echo installer_json_encode([ 'env_exists' => file_exists($envPath), 'env_writable' => file_exists($envPath) && is_writable($envPath), 'path' => BASE_PATH, diff --git a/public/install/api/finalize-env.php b/public/install/api/finalize-env.php index 992b02e5..07e7bfe8 100644 --- a/public/install/api/finalize-env.php +++ b/public/install/api/finalize-env.php @@ -37,7 +37,7 @@ if (session_status() === PHP_SESSION_ACTIVE) { ignore_user_abort(true); -$accepted = json_encode(['accepted' => true]); +$accepted = installer_json_encode(['accepted' => true]); header('Content-Type: application/json; charset=utf-8'); header('Content-Length: '.strlen($accepted)); diff --git a/public/install/api/install-process.php b/public/install/api/install-process.php index 03e8c8f3..56b42d88 100644 --- a/public/install/api/install-process.php +++ b/public/install/api/install-process.php @@ -36,7 +36,7 @@ header('Content-Type: application/json; charset=UTF-8'); */ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_method_not_allowed'), ], JSON_UNESCAPED_UNICODE); @@ -58,7 +58,7 @@ try { // state.json에도 config가 없으면 에러 if (empty($config)) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_config_not_in_session'), ], JSON_UNESCAPED_UNICODE); @@ -108,7 +108,7 @@ try { if (! empty($missingFields)) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_required_fields_missing', ['fields' => implode(', ', $missingFields)]), ], JSON_UNESCAPED_UNICODE); @@ -125,7 +125,7 @@ try { if (! empty($missingRequiredFiles)) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_env_not_found'), 'env_required' => true, @@ -250,7 +250,7 @@ try { /** * 응답 JSON 준비 (echo 이전에 미리 문자열화하여 Content-Length 계산용) */ - $responseJson = json_encode([ + $responseJson = installer_json_encode([ 'success' => true, 'status' => 'started', 'message' => lang('success_installation_started'), @@ -374,7 +374,7 @@ try { // 에러 응답 http_response_code(500); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('error_installation_start_exception', ['error' => $e->getMessage()]), ], JSON_UNESCAPED_UNICODE); diff --git a/public/install/api/install-worker.php b/public/install/api/install-worker.php index 460c644c..91870cd3 100644 --- a/public/install/api/install-worker.php +++ b/public/install/api/install-worker.php @@ -31,16 +31,14 @@ * 적용하지 않음 — SSE 호환성 사전 체크(sse-probe.php) 가 buffered 환경을 감지하면 * 클라이언트가 폴링 모드로 fallback. * ============================================================================ - * - * @package G7\Installer */ -require_once __DIR__ . '/../includes/config.php'; -require_once __DIR__ . '/../includes/functions.php'; -require_once __DIR__ . '/../includes/installer-state.php'; -require_once __DIR__ . '/../includes/progress-emitter.php'; -require_once __DIR__ . '/../includes/task-runner.php'; -require_once __DIR__ . '/_guard.php'; +require_once __DIR__.'/../includes/config.php'; +require_once __DIR__.'/../includes/functions.php'; +require_once __DIR__.'/../includes/installer-state.php'; +require_once __DIR__.'/../includes/progress-emitter.php'; +require_once __DIR__.'/../includes/task-runner.php'; +require_once __DIR__.'/_guard.php'; installer_guard_or_410(); // SSE는 세션을 사용하지 않음 (세션 잠금 방지) @@ -52,7 +50,7 @@ $translations = loadTranslations($lang); if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); header('Content-Type: application/json'); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('sse_method_not_allowed'), ], JSON_UNESCAPED_UNICODE); @@ -94,14 +92,14 @@ error_reporting(E_ALL); // Worker 시작 로그 (디버깅용) addLog('=== Install Worker SSE Started ==='); -addLog('Client IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); +addLog('Client IP: '.($_SERVER['REMOTE_ADDR'] ?? 'unknown')); // 워커 lock 획득 — 다른 워커가 활동 중이면 진입 거부 (race 차단). // SSE 응답으로 거부 사유 전달 후 종료. $lockResult = acquireWorkerLock(15); if (! $lockResult['acquired']) { addLog('=== SSE Worker rejected — another worker is active ==='); - setProgressEmitter(new SseEmitter()); + setProgressEmitter(new SseEmitter); sendSSEEvent('aborted', [ 'message' => lang('error_worker_busy'), 'reason' => 'busy', @@ -110,7 +108,7 @@ if (! $lockResult['acquired']) { } $workerId = $lockResult['worker_id']; -addLog('Worker lock acquired: ' . $workerId . ' (reason: ' . $lockResult['reason'] . ')'); +addLog('Worker lock acquired: '.$workerId.' (reason: '.$lockResult['reason'].')'); // 워커 종료 시 lock 자동 해제 register_shutdown_function('releaseWorkerLock', $workerId); diff --git a/public/install/api/save-extensions.php b/public/install/api/save-extensions.php index a629757d..5e53ce68 100644 --- a/public/install/api/save-extensions.php +++ b/public/install/api/save-extensions.php @@ -6,6 +6,7 @@ * 사용자가 선택한 확장 기능 목록을 state.json에 저장합니다. * * @method POST + * * @body { * "admin_templates": ["sirsoft-admin_basic"], * "user_templates": ["sirsoft-basic"], @@ -26,11 +27,11 @@ header('Content-Type: application/json; charset=utf-8'); define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // 세션 및 설정 포함 -require_once dirname(__DIR__) . '/includes/session.php'; -require_once dirname(__DIR__) . '/includes/config.php'; -require_once dirname(__DIR__) . '/includes/installer-state.php'; -require_once dirname(__DIR__) . '/includes/functions.php'; -require_once __DIR__ . '/_guard.php'; +require_once dirname(__DIR__).'/includes/session.php'; +require_once dirname(__DIR__).'/includes/config.php'; +require_once dirname(__DIR__).'/includes/installer-state.php'; +require_once dirname(__DIR__).'/includes/functions.php'; +require_once __DIR__.'/_guard.php'; installer_guard_or_410(); // 다국어 로드 @@ -40,7 +41,7 @@ $translations = loadTranslations($currentLang); // POST 요청만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => lang('api_method_not_allowed'), ], JSON_UNESCAPED_UNICODE); @@ -52,7 +53,7 @@ $input = json_decode(file_get_contents('php://input'), true); if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => lang('api_invalid_request'), ], JSON_UNESCAPED_UNICODE); @@ -88,7 +89,7 @@ $extensionNames = isset($input['extension_names']) && is_array($input['extension // 관리자 템플릿은 최소 1개 필수 if (empty($adminTemplates)) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => lang('error_admin_template_required'), ], JSON_UNESCAPED_UNICODE); @@ -114,9 +115,9 @@ $identifierGroups = [ ]; foreach ($identifierGroups as $groupKey => $ids) { foreach ($ids as $id) { - if (!preg_match($identifierPattern, $id)) { + if (! preg_match($identifierPattern, $id)) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => lang('error_invalid_extension_identifier', ['identifier' => $id, 'group' => $groupKey]), ], JSON_UNESCAPED_UNICODE); @@ -153,7 +154,7 @@ try { // 상태 저장 $saved = saveInstallationState($state); - if (!$saved) { + if (! $saved) { throw new Exception(lang('state_save_failed')); } @@ -167,7 +168,7 @@ try { ])); // 성공 응답 - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'message' => lang('log_extensions_saved'), 'data' => [ @@ -178,7 +179,7 @@ try { } catch (Throwable $e) { http_response_code(500); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => $e->getMessage(), ], JSON_UNESCAPED_UNICODE); diff --git a/public/install/api/scan-extensions.php b/public/install/api/scan-extensions.php index 5953ea71..61736a8e 100644 --- a/public/install/api/scan-extensions.php +++ b/public/install/api/scan-extensions.php @@ -458,7 +458,7 @@ function extractDependenciesDetailedFromJson(array $data): array $rawDeps = $data['dependencies'] ?? []; $detailed = []; - if (!is_array($rawDeps)) { + if (! is_array($rawDeps)) { return $detailed; } @@ -519,7 +519,7 @@ try { // Admin 템플릿 필수 검증 if (empty($templates['admin'])) { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => 'no_admin_template', 'error_message' => 'Admin template is required but not found. Please ensure at least one admin template exists in the templates/_bundled directory.', @@ -528,7 +528,7 @@ try { } // 결과 반환 - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'data' => [ 'admin_templates' => $templates['admin'], @@ -541,7 +541,7 @@ try { } catch (Throwable $e) { http_response_code(500); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => $e->getMessage(), ], JSON_UNESCAPED_UNICODE); diff --git a/public/install/api/session-probe.php b/public/install/api/session-probe.php index fe017821..83aa01f7 100644 --- a/public/install/api/session-probe.php +++ b/public/install/api/session-probe.php @@ -64,10 +64,10 @@ $sessionProbeLibraryMode = defined('SESSION_PROBE_LIBRARY') && constant('SESSION if (! $sessionProbeLibraryMode) { // 정식 진입점 — config / session / functions / guard 로드 - require_once __DIR__ . '/../includes/config.php'; - require_once __DIR__ . '/../includes/session.php'; - require_once __DIR__ . '/../includes/functions.php'; - require_once __DIR__ . '/_guard.php'; + 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(); header('Content-Type: application/json; charset=utf-8'); @@ -75,18 +75,18 @@ if (! $sessionProbeLibraryMode) { if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); - echo json_encode(['error' => 'GET method required'], JSON_UNESCAPED_UNICODE); + echo installer_json_encode(['error' => 'GET method required'], JSON_UNESCAPED_UNICODE); exit; } $action = $_GET['action'] ?? ''; if ($action === 'set') { - echo json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE); + echo installer_json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE); } elseif ($action === 'verify') { - echo json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE); + echo installer_json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE); } else { http_response_code(400); - echo json_encode(['error' => 'Invalid action — use ?action=set or ?action=verify'], JSON_UNESCAPED_UNICODE); + echo installer_json_encode(['error' => 'Invalid action — use ?action=set or ?action=verify'], JSON_UNESCAPED_UNICODE); } } diff --git a/public/install/api/sse-probe.php b/public/install/api/sse-probe.php index 5e86fb13..2f49b841 100644 --- a/public/install/api/sse-probe.php +++ b/public/install/api/sse-probe.php @@ -25,11 +25,11 @@ * data: {"phase":2,"server_ts":1234567892.456} * * @method GET - * @package G7\Installer */ // 설치 완료 시 인스톨러 비즈니스 로직 진입 차단 -require_once __DIR__ . '/_guard.php'; +require_once __DIR__.'/../includes/utf8.php'; +require_once __DIR__.'/_guard.php'; installer_guard_or_410(); // SSE 헤더 설정 (install-worker.php 와 동일 — 호환성 검증의 정확도 보장) @@ -55,7 +55,7 @@ ignore_user_abort(false); // Phase 1 — 즉시 송신 echo "event: probe\n"; -echo 'data: ' . json_encode(['phase' => 1, 'server_ts' => microtime(true)], JSON_UNESCAPED_UNICODE) . "\n\n"; +echo 'data: '.installer_json_encode(['phase' => 1, 'server_ts' => microtime(true)], JSON_UNESCAPED_UNICODE)."\n\n"; @flush(); // 클라이언트는 두 phase 도착 시각 차이로 streaming 호환성 판정. @@ -64,7 +64,7 @@ sleep(2); // Phase 2 — 송신 후 즉시 종료 echo "event: probe\n"; -echo 'data: ' . json_encode(['phase' => 2, 'server_ts' => microtime(true)], JSON_UNESCAPED_UNICODE) . "\n\n"; +echo 'data: '.installer_json_encode(['phase' => 2, 'server_ts' => microtime(true)], JSON_UNESCAPED_UNICODE)."\n\n"; @flush(); exit; diff --git a/public/install/api/state-management.php b/public/install/api/state-management.php index 7fa15939..76131ab8 100644 --- a/public/install/api/state-management.php +++ b/public/install/api/state-management.php @@ -63,7 +63,7 @@ class StateManagementApi // GET 메서드만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'error' => 'Method Not Allowed', 'message' => lang('error_get_method_required'), ], JSON_UNESCAPED_UNICODE); @@ -139,27 +139,22 @@ class StateManagementApi // JSON 응답 반환 // - // 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가 - // 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어 + // 최종 안전망 (gnuboard/g7#62): $response 의 로그·경로에 invalid UTF-8 바이트가 + // 섞이면 표준 json_encode 는 false 를 반환하고, echo false = 빈 본문(HTTP 200) 이 되어 // 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다. - // 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이 - // 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다. - $encoded = json_encode( + // + // installer_json_encode 는 값을 정규화하고 substitute 를 적용하며, + // 실패하더라도 파싱 가능한 오류 JSON 을 돌려주므로 빈 본문이 나갈 수 없다. + // + // 폴백에도 `status`/`logs`/`log_total` 을 실어 보낸다. 프론트 PollingMonitor 는 + // `state.status` 로 진행/완료/실패를 판정하므로, 그 키가 없는 응답은 파싱은 되지만 + // 아무 전이도 일으키지 못해 화면이 조용히 멈춘 것처럼 보인다(파싱 실패 카운터도 + // 걸리지 않는다). 도달 확률과 무관하게 소비자 계약을 폴백에서도 유지한다. + echo installer_json_encode( $response, - JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE + JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT, + ['status' => $status, 'logs' => [], 'log_total' => $logTotal] ); - - if ($encoded === false) { - // substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한 - // 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다. - $encoded = json_encode([ - 'status' => $status, - 'logs' => [], - 'log_total' => $logTotal, - ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); - } - - echo $encoded; } /** @@ -175,7 +170,7 @@ class StateManagementApi // POST 메서드만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => 'Only POST method is allowed', ], JSON_UNESCAPED_UNICODE); @@ -224,7 +219,7 @@ class StateManagementApi // 성공 응답 http_response_code(200); - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'message' => lang('state_reset_completed', ['step' => $targetStep]), 'target_step' => $targetStep, @@ -243,7 +238,7 @@ class StateManagementApi // POST 메서드만 허용 if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('api_method_not_allowed'), ]); @@ -263,7 +258,7 @@ class StateManagementApi // 설치가 이미 완료된 경우 if (isset($state['installation_status']) && $state['installation_status'] === 'completed') { addLog(lang('abort_api_already_completed')); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('abort_api_already_completed'), ]); @@ -273,7 +268,7 @@ class StateManagementApi // 이미 중단된 경우 - 멱등성 보장 (재진입 시 400 에러 방지) if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') { addLog(lang('abort_api_already_aborted')); - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'message' => lang('abort_api_already_aborted'), ]); @@ -283,7 +278,7 @@ class StateManagementApi // 설치가 진행 중이 아닌 경우 if (! isset($state['installation_status']) || $state['installation_status'] !== 'running') { addLog(lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null'])); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'message' => lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']), ]); @@ -329,7 +324,7 @@ class StateManagementApi addLog(lang('abort_installation_stopped')); // 성공 응답 (리다이렉트 없음 - 현재 Step 5 유지) - echo json_encode([ + echo installer_json_encode([ 'success' => true, 'message' => lang('abort_installation_stopped'), ]); @@ -341,7 +336,7 @@ class StateManagementApi private function error400(string $message): void { http_response_code(400); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => 'Bad Request', 'message' => $message, @@ -363,7 +358,7 @@ class StateManagementApi // 에러 응답 http_response_code(500); - echo json_encode([ + echo installer_json_encode([ 'success' => false, 'error' => 'Internal Server Error', 'message' => $e->getMessage(), diff --git a/public/install/assets/js/installation-monitor.js b/public/install/assets/js/installation-monitor.js index 42c3126a..46c69a5f 100644 --- a/public/install/assets/js/installation-monitor.js +++ b/public/install/assets/js/installation-monitor.js @@ -160,6 +160,14 @@ * 1초 간격으로 state를 조회하고 diff 발생 시 콜백을 호출합니다. */ class PollingMonitor extends InstallationMonitor { + /** + * 응답 파싱 연속 실패 허용 횟수. + * + * 일시적 오류로 사용자를 놀라게 하지 않을 만큼 크고, + * 서버가 계속 빈 본문을 돌려주는 상황에서 사용자를 무한정 기다리게 하지 않을 만큼 작다. + */ + static MAX_PARSE_FAILURES = 5; + constructor(callbacks, options = {}) { super(callbacks, options); this.stateUrl = options.stateUrl; @@ -177,6 +185,10 @@ this.lastUpdatedSeen = null; this.lastUpdatedClientMs = Date.now(); this.stuckEmitted = false; + // 응답 파싱 연속 실패 횟수 — 성공 시 0 으로 리셋한다. + // 서버가 빈 본문/비 JSON 을 계속 돌려주면 콘솔 경고만 무한히 쌓이고 + // 화면에는 아무 일도 일어나지 않는다 (gnuboard/g7#62). 임계치에서 사용자에게 알린다. + this.parseFailures = 0; } start() { @@ -203,7 +215,31 @@ if (!res.ok) { return; // 일시적인 네트워크 오류는 무시 (다음 tick에서 재시도) } - const state = await res.json(); + const text = await res.text(); + + let state; + try { + state = JSON.parse(text); + } catch (parseError) { + this.parseFailures += 1; + console.warn('[PollingMonitor] poll response was not JSON:', this.parseFailures, parseError); + + if (this.parseFailures >= PollingMonitor.MAX_PARSE_FAILURES) { + this.stop(); + this._invoke('onError', { + message: null, + message_key: 'error_polling_response_invalid', + error: null, + task: null, + target: null, + manual_commands: null, + parse_failures: this.parseFailures, + }); + } + return; + } + + this.parseFailures = 0; this._diffAndEmit(state); } catch (e) { console.warn('[PollingMonitor] poll failed:', e); diff --git a/public/install/assets/js/installer.js b/public/install/assets/js/installer.js index 741b4359..22d2284b 100644 --- a/public/install/assets/js/installer.js +++ b/public/install/assets/js/installer.js @@ -136,7 +136,22 @@ throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json(); + // 빈 본문(HTTP 200 + Content-Length 0)을 response.json() 에 넘기면 + // "Unexpected end of JSON input" 이라는, 원인도 조치도 알 수 없는 문구가 화면에 뜬다. + // (gnuboard/g7#62 — 한글 계정명 환경에서 실제로 발생했다) + // 사람이 읽고 조치할 수 있는 안내로 바꾼다. + const text = await response.text(); + + if (text.trim() === '') { + throw new Error(lang('error_empty_server_response')); + } + + let data; + try { + data = JSON.parse(text); + } catch (parseError) { + throw new Error(lang('error_invalid_server_response')); + } const resultHtml = renderRequirements(data); document.getElementById('requirements-result').innerHTML = resultHtml; diff --git a/public/install/includes/config.php b/public/install/includes/config.php index 32145d75..4adf9907 100644 --- a/public/install/includes/config.php +++ b/public/install/includes/config.php @@ -4,16 +4,17 @@ * 그누보드7 웹 인스톨러 설정 파일 * * 인스톨러의 기본 상수와 설정을 정의합니다. - * - * @package G7\Installer */ // 프로젝트 루트 경로 (public/install/includes에서 3단계 상위) -if (!defined('BASE_PATH')) { +if (! defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); } -// 인스톨러 기본 URL +// UTF-8 정규화 / JSON 출력 헬퍼 (의존성 0 — 가장 먼저 로드) +require_once __DIR__.'/utf8.php'; + +// 인스톨러 기본 URL $installerPath = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'])); // install이 없으면 추가 @@ -24,22 +25,22 @@ if (substr($installerPath, -8) !== '/install') { // 다음 define 들은 가드 필수 — 테스트 환경에서 같은 프로세스가 config.php 를 직접/간접 // 재진입(예: Laravel bootstrap + 테스트 require)할 수 있어 PHP 9 에서 fatal 이 되는 // 중복 정의 warning 을 차단. -if (!defined('INSTALLER_BASE_URL')) { +if (! defined('INSTALLER_BASE_URL')) { define('INSTALLER_BASE_URL', $installerPath); } // 소프트웨어 최초 출시 연도 (저작권 표시용) -if (!defined('APP_RELEASE_YEAR')) { +if (! defined('APP_RELEASE_YEAR')) { define('APP_RELEASE_YEAR', '2026'); } // 최소 PHP 버전 -if (!defined('MIN_PHP_VERSION')) { +if (! defined('MIN_PHP_VERSION')) { define('MIN_PHP_VERSION', '8.2.0'); } // 필수 PHP 모듈 -if (!defined('REQUIRED_EXTENSIONS')) { +if (! defined('REQUIRED_EXTENSIONS')) { define('REQUIRED_EXTENSIONS', [ 'pdo', 'mbstring', @@ -59,7 +60,7 @@ if (!defined('REQUIRED_EXTENSIONS')) { } // 선택적 PHP 모듈 (설치를 권장하지만 필수는 아님) -if (!defined('OPTIONAL_EXTENSIONS')) { +if (! defined('OPTIONAL_EXTENSIONS')) { define('OPTIONAL_EXTENSIONS', [ 'zlib', // gzip 압축 지원 (응답 압축에 사용) 'gd', // 이미지 처리 @@ -70,7 +71,7 @@ if (!defined('OPTIONAL_EXTENSIONS')) { } // 최소 디스크 공간 (MB) -if (!defined('MIN_DISK_SPACE_MB')) { +if (! defined('MIN_DISK_SPACE_MB')) { define('MIN_DISK_SPACE_MB', 500); } @@ -78,24 +79,24 @@ if (!defined('MIN_DISK_SPACE_MB')) { // MySQL identifier 한도(64자) 안에서 자동 생성 인덱스명이 안전하도록 제한한다. // 가장 긴 자동 생성 인덱스명(접두사 제외)이 58자이므로 58 + 6 = 64 로 정확히 한도에 맞는다. // 7자 이상 접두사는 일부 인덱스명이 65자가 되어 마이그레이션이 실패한다. -if (!defined('MAX_DB_PREFIX_LENGTH')) { +if (! defined('MAX_DB_PREFIX_LENGTH')) { define('MAX_DB_PREFIX_LENGTH', 6); } // 디렉토리 권한 설정 (8진수) // 업계 표준 755 (WordPress/Drupal/Joomla/Laravel 공통) — 실제 통과 기준은 is_writable() && is_readable() -if (!defined('REQUIRED_DIRECTORY_PERMISSIONS')) { +if (! defined('REQUIRED_DIRECTORY_PERMISSIONS')) { define('REQUIRED_DIRECTORY_PERMISSIONS', 0755); } // 권한 표시용 문자열 (사용자에게 보여줄 형식) -if (!defined('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY')) { +if (! defined('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY')) { define('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY', '755'); } // 권한 검증이 필요한 디렉토리 목록 // 값이 true인 경우 하위 디렉토리까지 재귀적으로 체크 -if (!defined('REQUIRED_DIRECTORIES')) { +if (! defined('REQUIRED_DIRECTORIES')) { define('REQUIRED_DIRECTORIES', [ 'storage' => true, 'bootstrap/cache' => false, @@ -113,7 +114,7 @@ if (!defined('REQUIRED_DIRECTORIES')) { } // 인스톨러 기본 설정값 -if (!defined('DEFAULT_INSTALL_CONFIG')) { +if (! defined('DEFAULT_INSTALL_CONFIG')) { define('DEFAULT_INSTALL_CONFIG', [ // Write DB 설정 'db_write_host' => 'localhost', @@ -160,7 +161,7 @@ if (!defined('DEFAULT_INSTALL_CONFIG')) { // 설치 단계별 파일 매핑 // Step 5 (installation)에서 완료/실패/중단 화면까지 모두 처리 -if (!defined('STEP_FILE_MAP')) { +if (! defined('STEP_FILE_MAP')) { define('STEP_FILE_MAP', [ 0 => 'welcome', 1 => 'license', @@ -172,7 +173,7 @@ if (!defined('STEP_FILE_MAP')) { } // 인스톨러 기본 상태 정의 -if (!defined('DEFAULT_INSTALLATION_STATE')) { +if (! defined('DEFAULT_INSTALLATION_STATE')) { define('DEFAULT_INSTALLATION_STATE', [ 'current_step' => 0, 'step_status' => [ @@ -200,7 +201,7 @@ if (!defined('DEFAULT_INSTALLATION_STATE')) { } // 지원 언어 목록 (언어 추가 시 이 한 곳만 수정) -if (!defined('SUPPORTED_LANGUAGES')) { +if (! defined('SUPPORTED_LANGUAGES')) { define('SUPPORTED_LANGUAGES', [ 'ko' => '한국어 (Korean)', 'en' => 'English', @@ -208,7 +209,7 @@ if (!defined('SUPPORTED_LANGUAGES')) { } // 설치 완료 후 인스톨러 파일 삭제 여부 -if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) { +if (! defined('DELETE_INSTALLER_AFTER_COMPLETE')) { define('DELETE_INSTALLER_AFTER_COMPLETE', true); } @@ -223,9 +224,9 @@ if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) { // 클래스를 못 본 채 require 로 내려가는데, BASE_PATH 를 임시 디렉토리로 바꿔 두는 // 인스톨러 단위 테스트에서는 그 경로에 app/Support 가 없어 fatal 이 된다. // 순수 인스톨러 실행 시에는 등록된 오토로더가 없어 false 를 반환하므로 require 가 그대로 돈다. -if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts')) { - require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php'; +if (! class_exists('App\\Support\\PrivilegedDatabaseAccounts')) { + require_once BASE_PATH.'/app/Support/PrivilegedDatabaseAccounts.php'; +} +if (! class_exists('App\\Support\\OpcacheStatus')) { + require_once BASE_PATH.'/app/Support/OpcacheStatus.php'; } -if (!class_exists('App\\Support\\OpcacheStatus')) { - require_once BASE_PATH . '/app/Support/OpcacheStatus.php'; -} \ No newline at end of file diff --git a/public/install/includes/functions.php b/public/install/includes/functions.php index 9f27fbea..0a56f295 100644 --- a/public/install/includes/functions.php +++ b/public/install/includes/functions.php @@ -7,6 +7,9 @@ * 공통 유틸리티 함수를 제공합니다. */ +// UTF-8 정규화 / JSON 출력 헬퍼 (js_escape · getWebServerUser 가 사용) +require_once __DIR__.'/utf8.php'; + /** * HTML 이스케이프 함수 * @@ -31,9 +34,12 @@ if (! function_exists('e')) { */ function js_escape(mixed $value): string { - $json = json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE); + $json = installer_json_encode( + $value, + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE + ); - if ($json === false) { + if ($json === '') { return '""'; } @@ -571,6 +577,8 @@ function showStepFileNotFoundError(int $currentStep): void */ function getDatabaseFieldHash(array $config, string $prefix): string { + // json-encode:allow — 캐시 키 계산용 md5 입력, 응답으로 나가지 않는다. + // 플래그를 바꾸면 해시값이 달라져 "필드가 바뀌었다" 는 오판이 생기므로 그대로 둔다. return md5(json_encode([ $config["{$prefix}_host"] ?? '', $config["{$prefix}_port"] ?? '', @@ -716,17 +724,22 @@ function getWebServerUser(): ?string } // 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 trim($user); + return installer_normalize_process_output(trim($user), 'whoami'); } } // 3. 환경변수 $envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null; if (! empty($envUser)) { - return $envUser; + return installer_normalize_process_output($envUser, 'env:USER'); } return null; @@ -748,7 +761,7 @@ function getWebServerGroup(): ?string $envGroup = getenv('APACHE_RUN_GROUP') ?: null; if (! empty($envGroup)) { - return $envGroup; + return installer_normalize_process_output($envGroup, 'env:APACHE_RUN_GROUP'); } return null; diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php index 9ad17a37..9af413f0 100644 --- a/public/install/includes/installer-state.php +++ b/public/install/includes/installer-state.php @@ -9,6 +9,9 @@ if (! defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로 } +// UTF-8 정규화 헬퍼 — scan-extensions 경로에서는 config.php 보다 먼저 로드된다. +require_once __DIR__.'/utf8.php'; + if (! defined('STATE_PATH')) { define('STATE_PATH', BASE_PATH.'/storage/installer-state.json'); } @@ -106,8 +109,13 @@ function saveInstallationState(array $state): bool // last_updated 타임스탬프 업데이트 $state['last_updated'] = date('Y-m-d\TH:i:s\Z'); - // JSON 형식으로 저장 - $content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + // JSON 형식으로 저장 — 외부 유래 값(경로·확장 이름·로그)이 섞이므로 먼저 정규화한다. + // 이 함수는 addLog 재귀 금지 구역이라 installer_json_encode 대신 자체 가드를 쓴다. + // json-encode:allow — 정규화 + substitute 를 직접 적용한 자리 + $content = json_encode( + installer_utf8_normalize_deep($state), + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE + ); if ($content === false) { $msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg(); @@ -497,12 +505,8 @@ function addLogBatch(array $messages): bool $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; $entries = ''; foreach ($messages as $message) { - if ($isWindows) { - $encoding = mb_detect_encoding($message, ['UTF-8', 'EUC-KR', 'CP949'], true); - if ($encoding && $encoding !== 'UTF-8') { - $message = mb_convert_encoding($message, 'UTF-8', $encoding); - } - } + // 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62). + $message = installer_utf8_normalize($message); $microtime = microtime(true); $ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000)); $timestamp = date('Y-m-d H:i:s', (int) $microtime).'.'.$ms; @@ -589,24 +593,16 @@ function _addLogInternal(string $message): bool return false; } - // Windows에서 CP949 인코딩된 메시지를 UTF-8로 변환 - if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - $encoding = mb_detect_encoding($message, ['UTF-8', 'EUC-KR', 'CP949'], true); - if ($encoding && $encoding !== 'UTF-8') { - $message = mb_convert_encoding($message, 'UTF-8', $encoding); - } - } - - // OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62). - // composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나 - // 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에 - // 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면 + // 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62). + // + // composer install 등 외부 프로세스 stdout 은 시스템 코드페이지(한국어 Windows = CP949)로 + // 출력되고, 진행바(\r 갱신)는 임의 바이트 경계에서 잘린다. invalid 바이트가 로그에 남으면 // 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200) // → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다. - // mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다. - if (! mb_check_encoding($message, 'UTF-8')) { - $message = mb_scrub($message, 'UTF-8'); - } + // + // 정규화는 복원 가능한 코드페이지 출력을 먼저 되살리고(한글 보존), + // 복원 불가능한 바이트만 U+FFFD 로 치환한다. + $message = installer_utf8_normalize($message); // 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요) $microtime = microtime(true); diff --git a/public/install/includes/progress-emitter.php b/public/install/includes/progress-emitter.php index aff29289..1f8b3cbc 100644 --- a/public/install/includes/progress-emitter.php +++ b/public/install/includes/progress-emitter.php @@ -55,18 +55,14 @@ class SseEmitter implements ProgressEmitter public function emit(string $event, array $data): void { // SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62). + // // composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면 - // json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고, - // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다. - // 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 - // false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망) - $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); - if ($payload === false) { - $payload = json_encode( - ['message' => '(log line dropped: encoding error)'], - JSON_UNESCAPED_UNICODE - ); - } + // 표준 json_encode 는 false 를 반환하고, 그러면 빈 data 라인이 송출되어 + // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되고 로그 이벤트가 유실된다. + // + // installer_json_encode 는 값을 정규화하고 substitute 를 적용하며, + // 실패하더라도 파싱 가능한 오류 JSON 을 돌려주므로 빈 data 라인이 나갈 수 없다. + $payload = installer_json_encode($data); echo "event: {$event}\n"; echo 'data: '.$payload."\n\n"; diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index d1d75bae..2420dc2d 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -578,14 +578,15 @@ if (! function_exists('installComposerDependenciesSSE')) { $line = fgets($pipes[1]); if ($line !== false) { $line = trim($line); - // composer stdout 이 시스템 코드페이지(Windows CP949 등)로 출력하거나 - // 진행바(\r 갱신)가 non-blocking 읽기와 겹쳐 임의 바이트 경계에서 잘리면 - // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter 의 json_encode)과 - // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 로그 전송 전 정규화한다. + // composer stdout 은 시스템 코드페이지(Windows CP949 등)로 출력되고, + // 진행바 갱신이 non-blocking 읽기와 겹치면 임의 바이트 경계에서 잘려 + // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter)과 + // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 전송 전 정규화한다. + // + // 정규화는 복원 가능한 코드페이지 출력(한글 경로·메시지)을 먼저 되살리므로, + // 이전의 mb_scrub 단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다. // (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망) - if ($line !== '' && ! mb_check_encoding($line, 'UTF-8')) { - $line = mb_scrub($line, 'UTF-8'); - } + $line = installer_utf8_normalize($line); if (! empty($line)) { sendSSEEvent('log', ['message' => $line]); } @@ -1685,7 +1686,7 @@ if (! function_exists('createSettingsJsonSSE')) { ]; $data = array_merge($data, $settings); - $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + $json = installer_json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $filePath = $settingsDir.'/'.$category.'.json'; file_put_contents($filePath, $json, LOCK_EX); diff --git a/public/install/includes/utf8.php b/public/install/includes/utf8.php new file mode 100644 index 00000000..51db53d2 --- /dev/null +++ b/public/install/includes/utf8.php @@ -0,0 +1,182 @@ + $item) { + $normalized[is_string($key) ? installer_utf8_normalize($key) : $key] = installer_utf8_normalize_deep($item); + } + + return $normalized; + } +} + +if (! function_exists('installer_utf8_invalid_paths')) { + /** + * invalid UTF-8 이 남아 있는 키 경로 목록을 반환합니다. + * + * @param mixed $value 검사할 값 + * @return list 점 구분 키 경로 목록 + */ + function installer_utf8_invalid_paths(mixed $value): array + { + if (class_exists(ProcessOutputEncoding::class)) { + return ProcessOutputEncoding::invalidPaths($value); + } + + return []; + } +} + +if (! function_exists('installer_normalize_process_output')) { + /** + * 외부 프로세스·환경값을 정규화하고, 정규화가 실제로 필요했으면 설치 로그에 1회 남깁니다. + * + * 제보 사례(gnuboard/g7#62)의 가장 큰 어려움은 로그에 흔적이 0 이었다는 점이다. + * 운영자가 "이 서버는 계정명이 UTF-8 이 아니다" 를 로그만으로 알 수 있어야 한다. + * + * @param string $value 원본 값 + * @param string $source 값의 출처 라벨 (예: 'whoami') + * @return string 유효한 UTF-8 값 + */ + function installer_normalize_process_output(string $value, string $source): string + { + if ($value === '' || mb_check_encoding($value, 'UTF-8')) { + return $value; + } + + $normalized = installer_utf8_normalize($value); + + // 같은 출처를 요청당 한 번만 기록한다 (getWebServerUser 는 한 응답에서 여러 번 호출된다). + static $reported = []; + if (! isset($reported[$source])) { + $reported[$source] = true; + $notice = '[env] '.$source.' output was not UTF-8 — normalized (os: '.PHP_OS_FAMILY. + ', oem cp: '.(function_exists('sapi_windows_cp_get') ? (string) sapi_windows_cp_get('oem') : 'n/a').')'; + error_log($notice); + if (function_exists('addLog')) { + addLog($notice); + } + } + + return $normalized; + } +} + +if (! function_exists('installer_json_encode')) { + /** + * 인스톨러 응답 JSON 을 생성합니다. **절대 false 나 빈 문자열을 반환하지 않습니다.** + * + * 1) 값 전체를 유효 UTF-8 로 정규화하고 + * 2) `JSON_INVALID_UTF8_SUBSTITUTE` 를 덧붙여 인코딩하며 + * 3) 그래도 실패하면 실패 사유와 문제 필드 경로를 로그에 남기고 + * 파싱 가능한 오류 JSON 을 반환한다. + * + * @param mixed $data 직렬화할 값 + * @param int $flags 추가 json_encode 플래그 + * @param array $fallbackShape 3) 의 오류 JSON 에 함께 실을 키 (호출자 계약 보존용). + * 폴링 응답처럼 소비자가 특정 키를 반드시 읽어야 하는 + * 엔드포인트는 그 최소 형태를 여기로 넘긴다. + * @return string 항상 파싱 가능한 JSON 문자열 + */ + function installer_json_encode(mixed $data, int $flags = JSON_UNESCAPED_UNICODE, array $fallbackShape = []): string + { + $normalized = installer_utf8_normalize_deep($data); + + // json-encode:allow — 이 함수가 인스톨러의 유일한 json_encode 자리다. + $encoded = json_encode($normalized, $flags | JSON_INVALID_UTF8_SUBSTITUTE); + + if (is_string($encoded) && $encoded !== '') { + return $encoded; + } + + $reason = json_last_error_msg(); + $paths = installer_utf8_invalid_paths($data); + $message = '[installer-json] encode failed ('.$reason.')'. + ($paths === [] ? '' : ' at: '.implode(', ', array_slice($paths, 0, 20))); + + error_log($message); + if (function_exists('addLog')) { + addLog($message); + } + + // 호출자가 넘긴 최소 형태를 먼저 깔고 그 위에 오류 정보를 얹는다. + // (소비자가 반드시 읽는 키 — 예: 폴링의 status — 가 폴백에서도 살아 있어야 한다.) + // json-encode:allow — 폴백 자체는 정규화된 ASCII 라 실패할 수 없다. + $fallback = json_encode(array_merge( + installer_utf8_normalize_deep($fallbackShape), + [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => $reason, + 'invalid_paths' => array_slice($paths, 0, 20), + ] + ), JSON_INVALID_UTF8_SUBSTITUTE); + + return is_string($fallback) && $fallback !== '' + ? $fallback + : '{"success":false,"error":"json_encode_failed"}'; + } +} diff --git a/public/install/index.php b/public/install/index.php index 306c389b..a0171c71 100644 --- a/public/install/index.php +++ b/public/install/index.php @@ -173,11 +173,11 @@ $stepFile = __DIR__.'/views/'.$currentStep.'-'.(STEP_FILE_MAP[$currentStep] ?? ' diff --git a/public/install/lang/en.php b/public/install/lang/en.php index 1644139a..a136f6ae 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -692,6 +692,10 @@ Firewalls or proxies may be blocking long-lived HTTP connections.', // Installation stuck detection 'error_installation_stuck' => 'Installation has stopped responding. The server worker may be stuck running a command. Please refresh and retry. If the problem persists, check storage/logs/installation.log.', + // Server returned a response with no body (gnuboard/g7#62) + 'error_empty_server_response' => 'The server returned an empty response. The response may have failed to build because of the server configuration or characters used in the account name or path. Please check storage/logs/installation.log.', + 'error_invalid_server_response' => 'The server response could not be parsed. It may contain an error message or warning mixed into the output. Please check storage/logs/installation.log.', + 'error_polling_response_invalid' => 'Could not read the installation progress. The server is not returning a correctly formatted response. Please check storage/logs/installation.log and try again.', 'extension_load_failed' => 'Failed to load extension list.', 'no_admin_template_error' => 'Admin template is required but not found. Please ensure at least one admin template exists in the templates directory.', 'selection_summary' => 'Selection Summary', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index 2ba086dd..84efbfba 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -692,6 +692,10 @@ ini_set(\'zlib.output_compression\', \'off\'); // 설치 진행 중단 감지 'error_installation_stuck' => '설치가 응답하지 않습니다. 서버 워커가 명령 실행 중 멈췄을 수 있습니다. 새로고침하거나 재시도하세요. 문제가 반복되면 storage/logs/installation.log 를 확인해주세요.', + // 서버가 본문 없는 응답을 돌려준 경우 (gnuboard/g7#62) + 'error_empty_server_response' => '서버가 빈 응답을 반환했습니다. 서버 설정이나 계정 이름·경로에 사용된 문자로 인해 응답이 만들어지지 못했을 수 있습니다. storage/logs/installation.log 를 확인해주세요.', + 'error_invalid_server_response' => '서버 응답을 해석할 수 없습니다. 응답에 오류 메시지나 경고문이 섞였을 수 있습니다. storage/logs/installation.log 를 확인해주세요.', + 'error_polling_response_invalid' => '설치 진행 상황을 읽어오지 못했습니다. 서버가 올바른 형식의 응답을 반환하지 않고 있습니다. storage/logs/installation.log 를 확인한 뒤 다시 시도해주세요.', 'extension_load_failed' => '확장 기능 목록을 불러오는데 실패했습니다.', 'no_admin_template_error' => '관리자 템플릿이 필요하지만 찾을 수 없습니다. templates 디렉토리에 최소 1개 이상의 관리자 템플릿이 있는지 확인해주세요.', 'selection_summary' => '선택 요약', diff --git a/public/install/views/3-configuration.php b/public/install/views/3-configuration.php index e3c8de22..0130195e 100644 --- a/public/install/views/3-configuration.php +++ b/public/install/views/3-configuration.php @@ -539,8 +539,8 @@ $currentVendorMode = $formData['vendor_mode'] ?? 'auto'; window.DB_TEST_FLAGS = { write: , read: , - writeHash: , - readHash: + writeHash: , + readHash: }; // ======================================================================== diff --git a/public/install/views/4-extension-selection.php b/public/install/views/4-extension-selection.php index 8cd39c5f..f7b7762a 100644 --- a/public/install/views/4-extension-selection.php +++ b/public/install/views/4-extension-selection.php @@ -214,23 +214,23 @@ \ No newline at end of file diff --git a/public/install/views/5-installation.php b/public/install/views/5-installation.php index 6fce895f..1d707d09 100644 --- a/public/install/views/5-installation.php +++ b/public/install/views/5-installation.php @@ -59,79 +59,79 @@ $config = $state['config'] ?? $_SESSION['install_config'] ?? []; ' . htmlspecialchars(lang('go_to_admin_login')) . ''; + $completionButtons = ''.htmlspecialchars(lang('go_to_admin_login')).''; - $coreVersion = getCoreAppVersion(); - $completionExtra = '

' - . htmlspecialchars(lang('installed_version_label')) . ' ' - . '' . htmlspecialchars($coreVersion) . '' - . '

'; +$coreVersion = getCoreAppVersion(); +$completionExtra = '

' + .htmlspecialchars(lang('installed_version_label')).' ' + .''.htmlspecialchars($coreVersion).'' + .'

'; - // .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371) - // finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한 - // (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을 - // 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출. - // - // OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작. - // Windows 만 명령이 무의미하므로 Windows 만 비노출. - $envPath = BASE_PATH . '/.env'; - if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) { - $envPerms = fileperms($envPath) & 0777; - if ($envPerms !== 0600) { - $envOwnerUid = @fileowner($envPath); - $envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid')) - ? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid) - : 'owner'; - $webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid') - ? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data') - : 'www-data'; +// .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371) +// finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한 +// (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을 +// 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출. +// +// OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작. +// Windows 만 명령이 무의미하므로 Windows 만 비노출. +$envPath = BASE_PATH.'/.env'; +if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) { + $envPerms = fileperms($envPath) & 0777; + if ($envPerms !== 0600) { + $envOwnerUid = @fileowner($envPath); + $envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid')) + ? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid) + : 'owner'; + $webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid') + ? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data') + : 'www-data'; - $hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath)); - $hardeningCommandDifferentOwner = sprintf( - 'sudo chgrp %s %s && sudo chmod 640 %s', - htmlspecialchars($webGroupName), - htmlspecialchars($envPath), - htmlspecialchars($envPath) - ); + $hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath)); + $hardeningCommandDifferentOwner = sprintf( + 'sudo chgrp %s %s && sudo chmod 640 %s', + htmlspecialchars($webGroupName), + htmlspecialchars($envPath), + htmlspecialchars($envPath) + ); - $completionExtra .= '
' - . '

' . htmlspecialchars(lang('env_hardening_hint_title')) . '

' - . '

' - . sprintf( - htmlspecialchars(lang('env_hardening_hint_current_status')), - decoct($envPerms), - htmlspecialchars($envOwnerName) - ) - . '

' - . '

' - . htmlspecialchars(lang('env_hardening_hint_option_strict')) - . '

' - . '
' . $hardeningCommandSameOwner . '
' - . '

' - . htmlspecialchars(lang('env_hardening_hint_option_group')) - . '

' - . '
' . $hardeningCommandDifferentOwner . '
' - . '

' . htmlspecialchars(lang('env_hardening_hint_note')) . '

' - . '
'; - } + $completionExtra .= '
' + .'

'.htmlspecialchars(lang('env_hardening_hint_title')).'

' + .'

' + .sprintf( + htmlspecialchars(lang('env_hardening_hint_current_status')), + decoct($envPerms), + htmlspecialchars($envOwnerName) + ) + .'

' + .'

' + .htmlspecialchars(lang('env_hardening_hint_option_strict')) + .'

' + .'
'.$hardeningCommandSameOwner.'
' + .'

' + .htmlspecialchars(lang('env_hardening_hint_option_group')) + .'

' + .'
'.$hardeningCommandDifferentOwner.'
' + .'

'.htmlspecialchars(lang('env_hardening_hint_note')).'

' + .'
'; } +} - echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra); - ?> +echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra); +?> ' . htmlspecialchars(lang('resume_continue')) . ' - '; - echo renderInstallResultSection('aborted', 'warning', 'installation_aborted', 'installation_aborted_message', $abortedButtons); - ?> +$abortedButtons = ' + '; +echo renderInstallResultSection('aborted', 'warning', 'installation_aborted', 'installation_aborted_message', $abortedButtons); +?> ' . htmlspecialchars(lang('retry_installation')) . ' - '; - echo renderInstallResultSection('failure', 'error', 'installation_failed', '', $failureButtons); - ?> +$failureButtons = ' + '; +echo renderInstallResultSection('failure', 'error', 'installation_failed', '', $failureButtons); +?>
@@ -220,9 +220,9 @@ $languagePacks = $selectedExtensions['language_packs'] ?? [];