점 구분 키 경로 목록
+ */
+ 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: = $dbWriteTested ? 'true' : 'false' ?>,
read: = $dbReadTested ? 'true' : 'false' ?>,
- writeHash: = json_encode($dbWriteHash) ?>,
- readHash: = json_encode($dbReadHash) ?>
+ writeHash: = installer_json_encode($dbWriteHash) ?>,
+ readHash: = installer_json_encode($dbReadHash) ?>
};
// ========================================================================
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'] ?? [];