Merge pull request from gnuboard:HeuJung/issue627

fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
This commit is contained in:
정정홍
2026-08-29 15:15:08 +09:00
committed by GitHub
37 changed files with 1662 additions and 277 deletions
+4
View File
@@ -46,6 +46,10 @@
- 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 배포 도중 확장 파일이 잠시 비면 내용이 빠진 빈 묶음이 정상 응답으로 전달되어, 한참 뒤 기능이 동작하지 않는 형태로만 드러나던 문제를 수정했습니다. 이제 그 상태를 오류로 알립니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 일부 확장자(`.mjs`, `.webp`, `.otf`) 의 없는 파일을 요청하면 파일 대신 페이지 내용이 전달되어 화면이 깨지던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 초기 화면 파일을 새로 만들 때 파일 시스템이 일시적으로 이동을 거부하면 그 한 번으로 생성이 실패하던 문제를 수정했습니다. 이제 잠시 후 다시 시도하며, 사이트 동작에는 영향이 없지만 불필요한 실패 알림이 뜨던 상황이 사라집니다. (#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 ## [7.0.9] - 2026-08-24
+22
View File
@@ -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` 형태로 기록됩니다.
---
## 설치 후 확인 ## 설치 후 확인
설치가 완료되면 아래 페이지에 접근할 수 있습니다. 설치가 완료되면 아래 페이지에 접근할 수 있습니다.
+1 -1
View File
@@ -525,8 +525,8 @@ cp .env.example .env
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a> <a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a> <a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a> <a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a> <a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a> <a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a> <a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a> <a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
+1 -1
View File
@@ -539,8 +539,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a> <a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a> <a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a> <a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a> <a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a> <a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a> <a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a> <a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
+6 -2
View File
@@ -11,6 +11,7 @@ use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Support\ConfigCacheHelper; use App\Support\ConfigCacheHelper;
use App\Support\ExtensionSettingsMirror; use App\Support\ExtensionSettingsMirror;
use App\Support\OpcacheStatus; use App\Support\OpcacheStatus;
use App\Support\ProcessOutputEncoding;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -1496,7 +1497,10 @@ class SettingsService
if (PHP_OS_FAMILY === 'Windows') { if (PHP_OS_FAMILY === 'Windows') {
$output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1'); $output = @shell_exec('powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name" 2>&1');
if ($output) { 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')) { if ($name !== '' && ! str_contains(strtolower($name), 'error')) {
return $name; return $name;
} }
@@ -1504,7 +1508,7 @@ class SettingsService
$output = @shell_exec('wmic cpu get name 2>&1'); $output = @shell_exec('wmic cpu get name 2>&1');
if ($output) { if ($output) {
$lines = explode("\n", trim($output)); $lines = explode("\n", trim(ProcessOutputEncoding::normalize($output)));
if (isset($lines[1]) && trim($lines[1]) !== '') { if (isset($lines[1]) && trim($lines[1]) !== '') {
return trim($lines[1]); return trim($lines[1]);
} }
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Support;
/**
* 외부 프로세스 출력·환경값의 문자 인코딩 정규화 (인스톨러·코어 공용 SSoT).
*
* 인스톨러(`public/install/`)는 Laravel 오토로드 없이 동작하는 순수 PHP 이므로
* 이 클래스는 파사드·헬퍼 등 프레임워크 의존성을 일절 참조하지 않는다.
* 인스톨러는 `require_once` 로 이 파일을 직접 로드해 사용한다.
*
* 배경:
* `exec('whoami')` 같은 외부 명령은 UTF-8 이 아니라 **그 명령을 실행한 콘솔의
* 코드페이지**로 출력한다. 한국어 Windows 는 OEM 949(CP949) 이므로 계정명에
* 한글이 있으면 invalid UTF-8 바이트가 PHP 로 들어온다. 그 값이 응답 배열에 실리면
* `json_encode()` 가 `false` 를 반환하고 `echo false` 는 빈 문자열이라
* HTTP 200 + 빈 본문이 나간다 — 예외도 로그도 남지 않는다.
*
* 변환 순서는 손실이 적은 쪽부터다. 마지막 단계는 의미를 잃더라도
* 응답 자체는 반드시 살린다.
*/
final class ProcessOutputEncoding
{
/**
* 1순위로 시도하는 확정 감지 대상 인코딩.
*
* G7 1차 대상 환경(한국어 Windows)을 OS 와 무관하게 같은 답으로 처리하기 위해
* 플랫폼 분기보다 앞에 둔다. `mb_detect_encoding(strict)` 는 순수 KS X 1001 을
* `EUC-KR` 로, 확장 한글을 `CP949` 로 판정하므로 두 이름을 모두 나열한다.
*
* @var list<string>
*/
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<string> 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;
}
}
+15 -14
View File
@@ -15,6 +15,7 @@ use App\Support\PrivilegedDatabaseAccounts;
// 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다. // 실행 바이너리 경로 허용 형태 정책 — 인스톨러 API 와 설치 워커가 같은 규칙을 공유한다.
// 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다. // 한쪽만 고치면 다른 쪽이 우회로가 되므로 의존성 없는 공용 파일로 두고 양쪽에서 로드한다.
require_once __DIR__.'/../includes/utf8.php';
require_once __DIR__.'/../includes/binary-path-policy.php'; require_once __DIR__.'/../includes/binary-path-policy.php';
/** /**
@@ -87,7 +88,7 @@ class ValidationApi
$requirements['is_windows'] = isWindows(); $requirements['is_windows'] = isWindows();
// JSON 응답 반환 // 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 메서드만 허용 // POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('api_method_not_allowed'), 'message' => lang('api_method_not_allowed'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -204,7 +205,7 @@ class ValidationApi
} }
// 성공 응답 // 성공 응답
echo json_encode([ echo installer_json_encode([
'success' => true, 'success' => true,
'message' => $message, 'message' => $message,
'type' => $type, 'type' => $type,
@@ -229,7 +230,7 @@ class ValidationApi
logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e); logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e);
// 에러 응답 (200 OK + success: false) // 에러 응답 (200 OK + success: false)
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_db_connection_failed_detail', ['type' => ($isReadDb ? 'Read' : 'Write'), 'error' => $e->getMessage()]), 'message' => lang('error_db_connection_failed_detail', ['type' => ($isReadDb ? 'Read' : 'Write'), 'error' => $e->getMessage()]),
'type' => $type ?? 'write', 'type' => $type ?? 'write',
@@ -250,7 +251,7 @@ class ValidationApi
logInstallationError(lang('error_db_test_failed'), $e); logInstallationError(lang('error_db_test_failed'), $e);
// 에러 응답 (200 OK + success: false) // 에러 응답 (200 OK + success: false)
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'type' => $type ?? 'write', 'type' => $type ?? 'write',
@@ -774,7 +775,7 @@ class ValidationApi
$phpForComposer = ! empty($found) ? $found[0]['path'] : 'php'; $phpForComposer = ! empty($found) ? $found[0]['path'] : 'php';
$composerResult = $this->detectComposerBinary($phpForComposer); $composerResult = $this->detectComposerBinary($phpForComposer);
echo json_encode([ echo installer_json_encode([
'success' => ! empty($found), 'success' => ! empty($found),
'binaries' => $found, 'binaries' => $found,
'default_php_available' => $defaultPhpAvailable, 'default_php_available' => $defaultPhpAvailable,
@@ -833,7 +834,7 @@ class ValidationApi
$result = $this->validatePhpPath($path); $result = $this->validatePhpPath($path);
echo json_encode([ echo installer_json_encode([
'success' => $result['valid'], 'success' => $result['valid'],
'version' => $result['version'], 'version' => $result['version'],
'message' => $result['message'], 'message' => $result['message'],
@@ -856,7 +857,7 @@ class ValidationApi
$result = $this->validateComposerPath($composerPath, $phpPath); $result = $this->validateComposerPath($composerPath, $phpPath);
echo json_encode([ echo installer_json_encode([
'success' => $result['valid'], 'success' => $result['valid'],
'version' => $result['version'], 'version' => $result['version'],
'message' => $result['message'], 'message' => $result['message'],
@@ -871,7 +872,7 @@ class ValidationApi
{ {
$path = $_GET['path'] ?? ''; $path = $_GET['path'] ?? '';
if (empty($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; return;
} }
@@ -881,7 +882,7 @@ class ValidationApi
// 상대경로 부모 추적이 필요한 시나리오가 없다. // 상대경로 부모 추적이 필요한 시나리오가 없다.
$hasParentSegment = preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $path) === 1; $hasParentSegment = preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $path) === 1;
if ($hasParentSegment || str_contains($path, "\0")) { if ($hasParentSegment || str_contains($path, "\0")) {
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_core_pending_path_invalid'), 'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -898,7 +899,7 @@ class ValidationApi
if ($resolved === false || ! is_dir($resolved)) { if ($resolved === false || ! is_dir($resolved)) {
// 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여 // 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여
// 임의 경로 enumeration 신호 차단. // 임의 경로 enumeration 신호 차단.
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_core_pending_path_invalid'), 'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -907,7 +908,7 @@ class ValidationApi
} }
$writable = is_writable($resolved); $writable = is_writable($resolved);
echo json_encode([ echo installer_json_encode([
'success' => $writable, 'success' => $writable,
'message' => $writable 'message' => $writable
? lang('success_core_pending_path') ? lang('success_core_pending_path')
@@ -1147,7 +1148,7 @@ class ValidationApi
private function error400(string $message): void private function error400(string $message): void
{ {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => 'Bad Request', 'error' => 'Bad Request',
'message' => $message, 'message' => $message,
@@ -1167,7 +1168,7 @@ class ValidationApi
// 에러 응답 // 에러 응답
http_response_code(500); http_response_code(500);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => 'Internal Server Error', 'error' => 'Internal Server Error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
+6 -8
View File
@@ -5,14 +5,12 @@
* *
* Step 5 진입 시 JavaScript에서 호출하여 .env 파일의 * Step 5 진입 시 JavaScript에서 호출하여 .env 파일의
* 존재 여부 및 쓰기 가능 여부를 확인합니다. * 존재 여부 및 쓰기 가능 여부를 확인합니다.
*
* @package G7\Installer
*/ */
// 필수 파일 include // 필수 파일 include
require_once __DIR__ . '/../includes/config.php'; require_once __DIR__.'/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__.'/../includes/functions.php';
require_once __DIR__ . '/_guard.php'; require_once __DIR__.'/_guard.php';
installer_guard_or_410(); installer_guard_or_410();
// JSON 응답 헤더 // JSON 응답 헤더
@@ -21,14 +19,14 @@ header('Content-Type: application/json; charset=utf-8');
// GET 요청만 허용 // GET 요청만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => 'Only GET requests are allowed.', 'message' => 'Only GET requests are allowed.',
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
exit; exit;
} }
$envPath = BASE_PATH . '/.env'; $envPath = BASE_PATH.'/.env';
// 소유자 === 웹 서버 사용자 여부 판별 (chgrp/sudo 생략 기준) // 소유자 === 웹 서버 사용자 여부 판별 (chgrp/sudo 생략 기준)
$webUser = getWebServerUser(); $webUser = getWebServerUser();
@@ -37,7 +35,7 @@ $baseOwner = function_exists('posix_getpwuid') && function_exists('posix_getuid'
: null; : null;
$ownerIsWebUser = $webUser && $baseOwner && $webUser === $baseOwner; $ownerIsWebUser = $webUser && $baseOwner && $webUser === $baseOwner;
echo json_encode([ echo installer_json_encode([
'env_exists' => file_exists($envPath), 'env_exists' => file_exists($envPath),
'env_writable' => file_exists($envPath) && is_writable($envPath), 'env_writable' => file_exists($envPath) && is_writable($envPath),
'path' => BASE_PATH, 'path' => BASE_PATH,
+1 -1
View File
@@ -37,7 +37,7 @@ if (session_status() === PHP_SESSION_ACTIVE) {
ignore_user_abort(true); 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-Type: application/json; charset=utf-8');
header('Content-Length: '.strlen($accepted)); header('Content-Length: '.strlen($accepted));
+6 -6
View File
@@ -36,7 +36,7 @@ header('Content-Type: application/json; charset=UTF-8');
*/ */
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_method_not_allowed'), 'message' => lang('error_method_not_allowed'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -58,7 +58,7 @@ try {
// state.json에도 config가 없으면 에러 // state.json에도 config가 없으면 에러
if (empty($config)) { if (empty($config)) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_config_not_in_session'), 'message' => lang('error_config_not_in_session'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -108,7 +108,7 @@ try {
if (! empty($missingFields)) { if (! empty($missingFields)) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_required_fields_missing', ['fields' => implode(', ', $missingFields)]), 'message' => lang('error_required_fields_missing', ['fields' => implode(', ', $missingFields)]),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -125,7 +125,7 @@ try {
if (! empty($missingRequiredFiles)) { if (! empty($missingRequiredFiles)) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_env_not_found'), 'message' => lang('error_env_not_found'),
'env_required' => true, 'env_required' => true,
@@ -250,7 +250,7 @@ try {
/** /**
* 응답 JSON 준비 (echo 이전에 미리 문자열화하여 Content-Length 계산용) * 응답 JSON 준비 (echo 이전에 미리 문자열화하여 Content-Length 계산용)
*/ */
$responseJson = json_encode([ $responseJson = installer_json_encode([
'success' => true, 'success' => true,
'status' => 'started', 'status' => 'started',
'message' => lang('success_installation_started'), 'message' => lang('success_installation_started'),
@@ -374,7 +374,7 @@ try {
// 에러 응답 // 에러 응답
http_response_code(500); http_response_code(500);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('error_installation_start_exception', ['error' => $e->getMessage()]), 'message' => lang('error_installation_start_exception', ['error' => $e->getMessage()]),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
+10 -12
View File
@@ -31,16 +31,14 @@
* 적용하지 않음 — SSE 호환성 사전 체크(sse-probe.php) 가 buffered 환경을 감지하면 * 적용하지 않음 — SSE 호환성 사전 체크(sse-probe.php) 가 buffered 환경을 감지하면
* 클라이언트가 폴링 모드로 fallback. * 클라이언트가 폴링 모드로 fallback.
* ============================================================================ * ============================================================================
*
* @package G7\Installer
*/ */
require_once __DIR__ . '/../includes/config.php'; require_once __DIR__.'/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__.'/../includes/functions.php';
require_once __DIR__ . '/../includes/installer-state.php'; require_once __DIR__.'/../includes/installer-state.php';
require_once __DIR__ . '/../includes/progress-emitter.php'; require_once __DIR__.'/../includes/progress-emitter.php';
require_once __DIR__ . '/../includes/task-runner.php'; require_once __DIR__.'/../includes/task-runner.php';
require_once __DIR__ . '/_guard.php'; require_once __DIR__.'/_guard.php';
installer_guard_or_410(); installer_guard_or_410();
// SSE는 세션을 사용하지 않음 (세션 잠금 방지) // SSE는 세션을 사용하지 않음 (세션 잠금 방지)
@@ -52,7 +50,7 @@ $translations = loadTranslations($lang);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); http_response_code(405);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('sse_method_not_allowed'), 'message' => lang('sse_method_not_allowed'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -94,14 +92,14 @@ error_reporting(E_ALL);
// Worker 시작 로그 (디버깅용) // Worker 시작 로그 (디버깅용)
addLog('=== Install Worker SSE Started ==='); addLog('=== Install Worker SSE Started ===');
addLog('Client IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); addLog('Client IP: '.($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
// 워커 lock 획득 — 다른 워커가 활동 중이면 진입 거부 (race 차단). // 워커 lock 획득 — 다른 워커가 활동 중이면 진입 거부 (race 차단).
// SSE 응답으로 거부 사유 전달 후 종료. // SSE 응답으로 거부 사유 전달 후 종료.
$lockResult = acquireWorkerLock(15); $lockResult = acquireWorkerLock(15);
if (! $lockResult['acquired']) { if (! $lockResult['acquired']) {
addLog('=== SSE Worker rejected — another worker is active ==='); addLog('=== SSE Worker rejected — another worker is active ===');
setProgressEmitter(new SseEmitter()); setProgressEmitter(new SseEmitter);
sendSSEEvent('aborted', [ sendSSEEvent('aborted', [
'message' => lang('error_worker_busy'), 'message' => lang('error_worker_busy'),
'reason' => 'busy', 'reason' => 'busy',
@@ -110,7 +108,7 @@ if (! $lockResult['acquired']) {
} }
$workerId = $lockResult['worker_id']; $workerId = $lockResult['worker_id'];
addLog('Worker lock acquired: ' . $workerId . ' (reason: ' . $lockResult['reason'] . ')'); addLog('Worker lock acquired: '.$workerId.' (reason: '.$lockResult['reason'].')');
// 워커 종료 시 lock 자동 해제 // 워커 종료 시 lock 자동 해제
register_shutdown_function('releaseWorkerLock', $workerId); register_shutdown_function('releaseWorkerLock', $workerId);
+14 -13
View File
@@ -6,6 +6,7 @@
* 사용자가 선택한 확장 기능 목록을 state.json에 저장합니다. * 사용자가 선택한 확장 기능 목록을 state.json에 저장합니다.
* *
* @method POST * @method POST
*
* @body { * @body {
* "admin_templates": ["sirsoft-admin_basic"], * "admin_templates": ["sirsoft-admin_basic"],
* "user_templates": ["sirsoft-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)); define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3));
// 세션 및 설정 포함 // 세션 및 설정 포함
require_once dirname(__DIR__) . '/includes/session.php'; require_once dirname(__DIR__).'/includes/session.php';
require_once dirname(__DIR__) . '/includes/config.php'; require_once dirname(__DIR__).'/includes/config.php';
require_once dirname(__DIR__) . '/includes/installer-state.php'; require_once dirname(__DIR__).'/includes/installer-state.php';
require_once dirname(__DIR__) . '/includes/functions.php'; require_once dirname(__DIR__).'/includes/functions.php';
require_once __DIR__ . '/_guard.php'; require_once __DIR__.'/_guard.php';
installer_guard_or_410(); installer_guard_or_410();
// 다국어 로드 // 다국어 로드
@@ -40,7 +41,7 @@ $translations = loadTranslations($currentLang);
// POST 요청만 허용 // POST 요청만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => lang('api_method_not_allowed'), 'error' => lang('api_method_not_allowed'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -52,7 +53,7 @@ $input = json_decode(file_get_contents('php://input'), true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => lang('api_invalid_request'), 'error' => lang('api_invalid_request'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -88,7 +89,7 @@ $extensionNames = isset($input['extension_names']) && is_array($input['extension
// 관리자 템플릿은 최소 1개 필수 // 관리자 템플릿은 최소 1개 필수
if (empty($adminTemplates)) { if (empty($adminTemplates)) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => lang('error_admin_template_required'), 'error' => lang('error_admin_template_required'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -114,9 +115,9 @@ $identifierGroups = [
]; ];
foreach ($identifierGroups as $groupKey => $ids) { foreach ($identifierGroups as $groupKey => $ids) {
foreach ($ids as $id) { foreach ($ids as $id) {
if (!preg_match($identifierPattern, $id)) { if (! preg_match($identifierPattern, $id)) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => lang('error_invalid_extension_identifier', ['identifier' => $id, 'group' => $groupKey]), 'error' => lang('error_invalid_extension_identifier', ['identifier' => $id, 'group' => $groupKey]),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -153,7 +154,7 @@ try {
// 상태 저장 // 상태 저장
$saved = saveInstallationState($state); $saved = saveInstallationState($state);
if (!$saved) { if (! $saved) {
throw new Exception(lang('state_save_failed')); throw new Exception(lang('state_save_failed'));
} }
@@ -167,7 +168,7 @@ try {
])); ]));
// 성공 응답 // 성공 응답
echo json_encode([ echo installer_json_encode([
'success' => true, 'success' => true,
'message' => lang('log_extensions_saved'), 'message' => lang('log_extensions_saved'),
'data' => [ 'data' => [
@@ -178,7 +179,7 @@ try {
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
+4 -4
View File
@@ -458,7 +458,7 @@ function extractDependenciesDetailedFromJson(array $data): array
$rawDeps = $data['dependencies'] ?? []; $rawDeps = $data['dependencies'] ?? [];
$detailed = []; $detailed = [];
if (!is_array($rawDeps)) { if (! is_array($rawDeps)) {
return $detailed; return $detailed;
} }
@@ -519,7 +519,7 @@ try {
// Admin 템플릿 필수 검증 // Admin 템플릿 필수 검증
if (empty($templates['admin'])) { if (empty($templates['admin'])) {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => 'no_admin_template', '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.', '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, 'success' => true,
'data' => [ 'data' => [
'admin_templates' => $templates['admin'], 'admin_templates' => $templates['admin'],
@@ -541,7 +541,7 @@ try {
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
+8 -8
View File
@@ -64,10 +64,10 @@ $sessionProbeLibraryMode = defined('SESSION_PROBE_LIBRARY') && constant('SESSION
if (! $sessionProbeLibraryMode) { if (! $sessionProbeLibraryMode) {
// 정식 진입점 — config / session / functions / guard 로드 // 정식 진입점 — config / session / functions / guard 로드
require_once __DIR__ . '/../includes/config.php'; require_once __DIR__.'/../includes/config.php';
require_once __DIR__ . '/../includes/session.php'; require_once __DIR__.'/../includes/session.php';
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__.'/../includes/functions.php';
require_once __DIR__ . '/_guard.php'; require_once __DIR__.'/_guard.php';
installer_guard_or_410(); installer_guard_or_410();
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -75,18 +75,18 @@ if (! $sessionProbeLibraryMode) {
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); 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; exit;
} }
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
if ($action === 'set') { if ($action === 'set') {
echo json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE); echo installer_json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE);
} elseif ($action === 'verify') { } elseif ($action === 'verify') {
echo json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE); echo installer_json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE);
} else { } else {
http_response_code(400); 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);
} }
} }
+4 -4
View File
@@ -25,11 +25,11 @@
* data: {"phase":2,"server_ts":1234567892.456} * data: {"phase":2,"server_ts":1234567892.456}
* *
* @method GET * @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(); installer_guard_or_410();
// SSE 헤더 설정 (install-worker.php 와 동일 — 호환성 검증의 정확도 보장) // SSE 헤더 설정 (install-worker.php 와 동일 — 호환성 검증의 정확도 보장)
@@ -55,7 +55,7 @@ ignore_user_abort(false);
// Phase 1 — 즉시 송신 // Phase 1 — 즉시 송신
echo "event: probe\n"; 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(); @flush();
// 클라이언트는 두 phase 도착 시각 차이로 streaming 호환성 판정. // 클라이언트는 두 phase 도착 시각 차이로 streaming 호환성 판정.
@@ -64,7 +64,7 @@ sleep(2);
// Phase 2 — 송신 후 즉시 종료 // Phase 2 — 송신 후 즉시 종료
echo "event: probe\n"; 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(); @flush();
exit; exit;
+23 -28
View File
@@ -63,7 +63,7 @@ class StateManagementApi
// GET 메서드만 허용 // GET 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'error' => 'Method Not Allowed', 'error' => 'Method Not Allowed',
'message' => lang('error_get_method_required'), 'message' => lang('error_get_method_required'),
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -139,27 +139,22 @@ class StateManagementApi
// JSON 응답 반환 // JSON 응답 반환
// //
// 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가 // 최종 안전망 (gnuboard/g7#62): $response 의 로그·경로에 invalid UTF-8 바이트가
// 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어 // 섞이면 표준 json_encode 는 false 를 반환하고, echo false = 빈 본문(HTTP 200) 이 되어
// 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다. // 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다.
// 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이 //
// 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다. // installer_json_encode 는 값을 정규화하고 substitute 를 적용하며,
$encoded = json_encode( // 실패하더라도 파싱 가능한 오류 JSON 을 돌려주므로 빈 본문이 나갈 수 없다.
//
// 폴백에도 `status`/`logs`/`log_total` 을 실어 보낸다. 프론트 PollingMonitor 는
// `state.status` 로 진행/완료/실패를 판정하므로, 그 키가 없는 응답은 파싱은 되지만
// 아무 전이도 일으키지 못해 화면이 조용히 멈춘 것처럼 보인다(파싱 실패 카운터도
// 걸리지 않는다). 도달 확률과 무관하게 소비자 계약을 폴백에서도 유지한다.
echo installer_json_encode(
$response, $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 메서드만 허용 // POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => 'Only POST method is allowed', 'message' => 'Only POST method is allowed',
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
@@ -224,7 +219,7 @@ class StateManagementApi
// 성공 응답 // 성공 응답
http_response_code(200); http_response_code(200);
echo json_encode([ echo installer_json_encode([
'success' => true, 'success' => true,
'message' => lang('state_reset_completed', ['step' => $targetStep]), 'message' => lang('state_reset_completed', ['step' => $targetStep]),
'target_step' => $targetStep, 'target_step' => $targetStep,
@@ -243,7 +238,7 @@ class StateManagementApi
// POST 메서드만 허용 // POST 메서드만 허용
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); http_response_code(405);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('api_method_not_allowed'), 'message' => lang('api_method_not_allowed'),
]); ]);
@@ -263,7 +258,7 @@ class StateManagementApi
// 설치가 이미 완료된 경우 // 설치가 이미 완료된 경우
if (isset($state['installation_status']) && $state['installation_status'] === 'completed') { if (isset($state['installation_status']) && $state['installation_status'] === 'completed') {
addLog(lang('abort_api_already_completed')); addLog(lang('abort_api_already_completed'));
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('abort_api_already_completed'), 'message' => lang('abort_api_already_completed'),
]); ]);
@@ -273,7 +268,7 @@ class StateManagementApi
// 이미 중단된 경우 - 멱등성 보장 (재진입 시 400 에러 방지) // 이미 중단된 경우 - 멱등성 보장 (재진입 시 400 에러 방지)
if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') { if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') {
addLog(lang('abort_api_already_aborted')); addLog(lang('abort_api_already_aborted'));
echo json_encode([ echo installer_json_encode([
'success' => true, 'success' => true,
'message' => lang('abort_api_already_aborted'), 'message' => lang('abort_api_already_aborted'),
]); ]);
@@ -283,7 +278,7 @@ class StateManagementApi
// 설치가 진행 중이 아닌 경우 // 설치가 진행 중이 아닌 경우
if (! isset($state['installation_status']) || $state['installation_status'] !== 'running') { if (! isset($state['installation_status']) || $state['installation_status'] !== 'running') {
addLog(lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null'])); addLog(lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']));
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'message' => lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']), 'message' => lang('abort_api_not_running', ['status' => $state['installation_status'] ?? 'null']),
]); ]);
@@ -329,7 +324,7 @@ class StateManagementApi
addLog(lang('abort_installation_stopped')); addLog(lang('abort_installation_stopped'));
// 성공 응답 (리다이렉트 없음 - 현재 Step 5 유지) // 성공 응답 (리다이렉트 없음 - 현재 Step 5 유지)
echo json_encode([ echo installer_json_encode([
'success' => true, 'success' => true,
'message' => lang('abort_installation_stopped'), 'message' => lang('abort_installation_stopped'),
]); ]);
@@ -341,7 +336,7 @@ class StateManagementApi
private function error400(string $message): void private function error400(string $message): void
{ {
http_response_code(400); http_response_code(400);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => 'Bad Request', 'error' => 'Bad Request',
'message' => $message, 'message' => $message,
@@ -363,7 +358,7 @@ class StateManagementApi
// 에러 응답 // 에러 응답
http_response_code(500); http_response_code(500);
echo json_encode([ echo installer_json_encode([
'success' => false, 'success' => false,
'error' => 'Internal Server Error', 'error' => 'Internal Server Error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
@@ -160,6 +160,14 @@
* 1초 간격으로 state를 조회하고 diff 발생 시 콜백을 호출합니다. * 1초 간격으로 state를 조회하고 diff 발생 시 콜백을 호출합니다.
*/ */
class PollingMonitor extends InstallationMonitor { class PollingMonitor extends InstallationMonitor {
/**
* 응답 파싱 연속 실패 허용 횟수.
*
* 일시적 오류로 사용자를 놀라게 하지 않을 만큼 크고,
* 서버가 계속 빈 본문을 돌려주는 상황에서 사용자를 무한정 기다리게 하지 않을 만큼 작다.
*/
static MAX_PARSE_FAILURES = 5;
constructor(callbacks, options = {}) { constructor(callbacks, options = {}) {
super(callbacks, options); super(callbacks, options);
this.stateUrl = options.stateUrl; this.stateUrl = options.stateUrl;
@@ -177,6 +185,10 @@
this.lastUpdatedSeen = null; this.lastUpdatedSeen = null;
this.lastUpdatedClientMs = Date.now(); this.lastUpdatedClientMs = Date.now();
this.stuckEmitted = false; this.stuckEmitted = false;
// 응답 파싱 연속 실패 횟수 — 성공 시 0 으로 리셋한다.
// 서버가 빈 본문/비 JSON 을 계속 돌려주면 콘솔 경고만 무한히 쌓이고
// 화면에는 아무 일도 일어나지 않는다 (gnuboard/g7#62). 임계치에서 사용자에게 알린다.
this.parseFailures = 0;
} }
start() { start() {
@@ -203,7 +215,31 @@
if (!res.ok) { if (!res.ok) {
return; // 일시적인 네트워크 오류는 무시 (다음 tick에서 재시도) 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); this._diffAndEmit(state);
} catch (e) { } catch (e) {
console.warn('[PollingMonitor] poll failed:', e); console.warn('[PollingMonitor] poll failed:', e);
+16 -1
View File
@@ -136,7 +136,22 @@
throw new Error(`HTTP ${response.status}: ${response.statusText}`); 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); const resultHtml = renderRequirements(data);
document.getElementById('requirements-result').innerHTML = resultHtml; document.getElementById('requirements-result').innerHTML = resultHtml;
+25 -24
View File
@@ -4,16 +4,17 @@
* 그누보드7 웹 인스톨러 설정 파일 * 그누보드7 웹 인스톨러 설정 파일
* *
* 인스톨러의 기본 상수와 설정을 정의합니다. * 인스톨러의 기본 상수와 설정을 정의합니다.
*
* @package G7\Installer
*/ */
// 프로젝트 루트 경로 (public/install/includes에서 3단계 상위) // 프로젝트 루트 경로 (public/install/includes에서 3단계 상위)
if (!defined('BASE_PATH')) { if (! defined('BASE_PATH')) {
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); 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'])); $installerPath = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME']));
// install이 없으면 추가 // install이 없으면 추가
@@ -24,22 +25,22 @@ if (substr($installerPath, -8) !== '/install') {
// 다음 define 들은 가드 필수 — 테스트 환경에서 같은 프로세스가 config.php 를 직접/간접 // 다음 define 들은 가드 필수 — 테스트 환경에서 같은 프로세스가 config.php 를 직접/간접
// 재진입(예: Laravel bootstrap + 테스트 require)할 수 있어 PHP 9 에서 fatal 이 되는 // 재진입(예: Laravel bootstrap + 테스트 require)할 수 있어 PHP 9 에서 fatal 이 되는
// 중복 정의 warning 을 차단. // 중복 정의 warning 을 차단.
if (!defined('INSTALLER_BASE_URL')) { if (! defined('INSTALLER_BASE_URL')) {
define('INSTALLER_BASE_URL', $installerPath); define('INSTALLER_BASE_URL', $installerPath);
} }
// 소프트웨어 최초 출시 연도 (저작권 표시용) // 소프트웨어 최초 출시 연도 (저작권 표시용)
if (!defined('APP_RELEASE_YEAR')) { if (! defined('APP_RELEASE_YEAR')) {
define('APP_RELEASE_YEAR', '2026'); define('APP_RELEASE_YEAR', '2026');
} }
// 최소 PHP 버전 // 최소 PHP 버전
if (!defined('MIN_PHP_VERSION')) { if (! defined('MIN_PHP_VERSION')) {
define('MIN_PHP_VERSION', '8.2.0'); define('MIN_PHP_VERSION', '8.2.0');
} }
// 필수 PHP 모듈 // 필수 PHP 모듈
if (!defined('REQUIRED_EXTENSIONS')) { if (! defined('REQUIRED_EXTENSIONS')) {
define('REQUIRED_EXTENSIONS', [ define('REQUIRED_EXTENSIONS', [
'pdo', 'pdo',
'mbstring', 'mbstring',
@@ -59,7 +60,7 @@ if (!defined('REQUIRED_EXTENSIONS')) {
} }
// 선택적 PHP 모듈 (설치를 권장하지만 필수는 아님) // 선택적 PHP 모듈 (설치를 권장하지만 필수는 아님)
if (!defined('OPTIONAL_EXTENSIONS')) { if (! defined('OPTIONAL_EXTENSIONS')) {
define('OPTIONAL_EXTENSIONS', [ define('OPTIONAL_EXTENSIONS', [
'zlib', // gzip 압축 지원 (응답 압축에 사용) 'zlib', // gzip 압축 지원 (응답 압축에 사용)
'gd', // 이미지 처리 'gd', // 이미지 처리
@@ -70,7 +71,7 @@ if (!defined('OPTIONAL_EXTENSIONS')) {
} }
// 최소 디스크 공간 (MB) // 최소 디스크 공간 (MB)
if (!defined('MIN_DISK_SPACE_MB')) { if (! defined('MIN_DISK_SPACE_MB')) {
define('MIN_DISK_SPACE_MB', 500); define('MIN_DISK_SPACE_MB', 500);
} }
@@ -78,24 +79,24 @@ if (!defined('MIN_DISK_SPACE_MB')) {
// MySQL identifier 한도(64자) 안에서 자동 생성 인덱스명이 안전하도록 제한한다. // MySQL identifier 한도(64자) 안에서 자동 생성 인덱스명이 안전하도록 제한한다.
// 가장 긴 자동 생성 인덱스명(접두사 제외)이 58자이므로 58 + 6 = 64 로 정확히 한도에 맞는다. // 가장 긴 자동 생성 인덱스명(접두사 제외)이 58자이므로 58 + 6 = 64 로 정확히 한도에 맞는다.
// 7자 이상 접두사는 일부 인덱스명이 65자가 되어 마이그레이션이 실패한다. // 7자 이상 접두사는 일부 인덱스명이 65자가 되어 마이그레이션이 실패한다.
if (!defined('MAX_DB_PREFIX_LENGTH')) { if (! defined('MAX_DB_PREFIX_LENGTH')) {
define('MAX_DB_PREFIX_LENGTH', 6); define('MAX_DB_PREFIX_LENGTH', 6);
} }
// 디렉토리 권한 설정 (8진수) // 디렉토리 권한 설정 (8진수)
// 업계 표준 755 (WordPress/Drupal/Joomla/Laravel 공통) — 실제 통과 기준은 is_writable() && is_readable() // 업계 표준 755 (WordPress/Drupal/Joomla/Laravel 공통) — 실제 통과 기준은 is_writable() && is_readable()
if (!defined('REQUIRED_DIRECTORY_PERMISSIONS')) { if (! defined('REQUIRED_DIRECTORY_PERMISSIONS')) {
define('REQUIRED_DIRECTORY_PERMISSIONS', 0755); define('REQUIRED_DIRECTORY_PERMISSIONS', 0755);
} }
// 권한 표시용 문자열 (사용자에게 보여줄 형식) // 권한 표시용 문자열 (사용자에게 보여줄 형식)
if (!defined('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY')) { if (! defined('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY')) {
define('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY', '755'); define('REQUIRED_DIRECTORY_PERMISSIONS_DISPLAY', '755');
} }
// 권한 검증이 필요한 디렉토리 목록 // 권한 검증이 필요한 디렉토리 목록
// 값이 true인 경우 하위 디렉토리까지 재귀적으로 체크 // 값이 true인 경우 하위 디렉토리까지 재귀적으로 체크
if (!defined('REQUIRED_DIRECTORIES')) { if (! defined('REQUIRED_DIRECTORIES')) {
define('REQUIRED_DIRECTORIES', [ define('REQUIRED_DIRECTORIES', [
'storage' => true, 'storage' => true,
'bootstrap/cache' => false, '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', [ define('DEFAULT_INSTALL_CONFIG', [
// Write DB 설정 // Write DB 설정
'db_write_host' => 'localhost', 'db_write_host' => 'localhost',
@@ -160,7 +161,7 @@ if (!defined('DEFAULT_INSTALL_CONFIG')) {
// 설치 단계별 파일 매핑 // 설치 단계별 파일 매핑
// Step 5 (installation)에서 완료/실패/중단 화면까지 모두 처리 // Step 5 (installation)에서 완료/실패/중단 화면까지 모두 처리
if (!defined('STEP_FILE_MAP')) { if (! defined('STEP_FILE_MAP')) {
define('STEP_FILE_MAP', [ define('STEP_FILE_MAP', [
0 => 'welcome', 0 => 'welcome',
1 => 'license', 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', [ define('DEFAULT_INSTALLATION_STATE', [
'current_step' => 0, 'current_step' => 0,
'step_status' => [ 'step_status' => [
@@ -200,7 +201,7 @@ if (!defined('DEFAULT_INSTALLATION_STATE')) {
} }
// 지원 언어 목록 (언어 추가 시 이 한 곳만 수정) // 지원 언어 목록 (언어 추가 시 이 한 곳만 수정)
if (!defined('SUPPORTED_LANGUAGES')) { if (! defined('SUPPORTED_LANGUAGES')) {
define('SUPPORTED_LANGUAGES', [ define('SUPPORTED_LANGUAGES', [
'ko' => '한국어 (Korean)', 'ko' => '한국어 (Korean)',
'en' => 'English', '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); define('DELETE_INSTALLER_AFTER_COMPLETE', true);
} }
@@ -223,9 +224,9 @@ if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) {
// 클래스를 못 본 채 require 로 내려가는데, BASE_PATH 를 임시 디렉토리로 바꿔 두는 // 클래스를 못 본 채 require 로 내려가는데, BASE_PATH 를 임시 디렉토리로 바꿔 두는
// 인스톨러 단위 테스트에서는 그 경로에 app/Support 가 없어 fatal 이 된다. // 인스톨러 단위 테스트에서는 그 경로에 app/Support 가 없어 fatal 이 된다.
// 순수 인스톨러 실행 시에는 등록된 오토로더가 없어 false 를 반환하므로 require 가 그대로 돈다. // 순수 인스톨러 실행 시에는 등록된 오토로더가 없어 false 를 반환하므로 require 가 그대로 돈다.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts')) { if (! class_exists('App\\Support\\PrivilegedDatabaseAccounts')) {
require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php'; 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';
}
+18 -5
View File
@@ -7,6 +7,9 @@
* 공통 유틸리티 함수를 제공합니다. * 공통 유틸리티 함수를 제공합니다.
*/ */
// UTF-8 정규화 / JSON 출력 헬퍼 (js_escape · getWebServerUser 가 사용)
require_once __DIR__.'/utf8.php';
/** /**
* HTML 이스케이프 함수 * HTML 이스케이프 함수
* *
@@ -31,9 +34,12 @@ if (! function_exists('e')) {
*/ */
function js_escape(mixed $value): string 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 '""'; return '""';
} }
@@ -571,6 +577,8 @@ function showStepFileNotFoundError(int $currentStep): void
*/ */
function getDatabaseFieldHash(array $config, string $prefix): string function getDatabaseFieldHash(array $config, string $prefix): string
{ {
// json-encode:allow — 캐시 키 계산용 md5 입력, 응답으로 나가지 않는다.
// 플래그를 바꾸면 해시값이 달라져 "필드가 바뀌었다" 는 오판이 생기므로 그대로 둔다.
return md5(json_encode([ return md5(json_encode([
$config["{$prefix}_host"] ?? '', $config["{$prefix}_host"] ?? '',
$config["{$prefix}_port"] ?? '', $config["{$prefix}_port"] ?? '',
@@ -716,17 +724,22 @@ function getWebServerUser(): ?string
} }
// 2. exec('whoami') // 2. exec('whoami')
//
// whoami 는 UTF-8 이 아니라 그 명령을 실행한 콘솔의 코드페이지로 출력한다.
// 한국어 Windows(OEM 949)에서 계정명에 한글이 있으면 invalid UTF-8 바이트가 들어오고,
// 그 값이 응답에 실리면 json_encode 가 false 를 반환해 빈 본문(HTTP 200)이 나간다
// (gnuboard/g7#62). 반환 전에 반드시 정규화한다.
if (function_exists('exec')) { if (function_exists('exec')) {
$user = @exec('whoami'); $user = @exec('whoami');
if (! empty($user)) { if (! empty($user)) {
return trim($user); return installer_normalize_process_output(trim($user), 'whoami');
} }
} }
// 3. 환경변수 // 3. 환경변수
$envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null; $envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null;
if (! empty($envUser)) { if (! empty($envUser)) {
return $envUser; return installer_normalize_process_output($envUser, 'env:USER');
} }
return null; return null;
@@ -748,7 +761,7 @@ function getWebServerGroup(): ?string
$envGroup = getenv('APACHE_RUN_GROUP') ?: null; $envGroup = getenv('APACHE_RUN_GROUP') ?: null;
if (! empty($envGroup)) { if (! empty($envGroup)) {
return $envGroup; return installer_normalize_process_output($envGroup, 'env:APACHE_RUN_GROUP');
} }
return null; return null;
+20 -24
View File
@@ -9,6 +9,9 @@ if (! defined('BASE_PATH')) {
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로 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')) { if (! defined('STATE_PATH')) {
define('STATE_PATH', BASE_PATH.'/storage/installer-state.json'); define('STATE_PATH', BASE_PATH.'/storage/installer-state.json');
} }
@@ -106,8 +109,13 @@ function saveInstallationState(array $state): bool
// last_updated 타임스탬프 업데이트 // last_updated 타임스탬프 업데이트
$state['last_updated'] = date('Y-m-d\TH:i:s\Z'); $state['last_updated'] = date('Y-m-d\TH:i:s\Z');
// JSON 형식으로 저장 // JSON 형식으로 저장 — 외부 유래 값(경로·확장 이름·로그)이 섞이므로 먼저 정규화한다.
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // 이 함수는 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) { if ($content === false) {
$msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg(); $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'; $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
$entries = ''; $entries = '';
foreach ($messages as $message) { foreach ($messages as $message) {
if ($isWindows) { // 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
$encoding = mb_detect_encoding($message, ['UTF-8', 'EUC-KR', 'CP949'], true); $message = installer_utf8_normalize($message);
if ($encoding && $encoding !== 'UTF-8') {
$message = mb_convert_encoding($message, 'UTF-8', $encoding);
}
}
$microtime = microtime(true); $microtime = microtime(true);
$ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000)); $ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000));
$timestamp = date('Y-m-d H:i:s', (int) $microtime).'.'.$ms; $timestamp = date('Y-m-d H:i:s', (int) $microtime).'.'.$ms;
@@ -589,24 +593,16 @@ function _addLogInternal(string $message): bool
return false; return false;
} }
// Windows에서 CP949 인코딩된 메시지를 UTF-8로 변환 // 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { //
$encoding = mb_detect_encoding($message, ['UTF-8', 'EUC-KR', 'CP949'], true); // composer install 등 외부 프로세스 stdout 은 시스템 코드페이지(한국어 Windows = CP949)로
if ($encoding && $encoding !== 'UTF-8') { // 출력되고, 진행바(\r 갱신)는 임의 바이트 경계에서 잘린다. invalid 바이트가 로그에 남으면
$message = mb_convert_encoding($message, 'UTF-8', $encoding);
}
}
// OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
// composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나
// 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에
// 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면
// 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200) // 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200)
// → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다. // → 프론트 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 필요) // 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요)
$microtime = microtime(true); $microtime = microtime(true);
+7 -11
View File
@@ -55,18 +55,14 @@ class SseEmitter implements ProgressEmitter
public function emit(string $event, array $data): void public function emit(string $event, array $data): void
{ {
// SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62). // SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62).
//
// composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면 // composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면
// json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고, // 표준 json_encode 는 false 를 반환하고, 그러면 빈 data 라인이 송출되어
// 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다. // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되고 로그 이벤트가 유실된다.
// 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 //
// false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망) // installer_json_encode 는 값을 정규화하고 substitute 를 적용하며,
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); // 실패하더라도 파싱 가능한 오류 JSON 을 돌려주므로 빈 data 라인이 나갈 수 없다.
if ($payload === false) { $payload = installer_json_encode($data);
$payload = json_encode(
['message' => '(log line dropped: encoding error)'],
JSON_UNESCAPED_UNICODE
);
}
echo "event: {$event}\n"; echo "event: {$event}\n";
echo 'data: '.$payload."\n\n"; echo 'data: '.$payload."\n\n";
+9 -8
View File
@@ -578,14 +578,15 @@ if (! function_exists('installComposerDependenciesSSE')) {
$line = fgets($pipes[1]); $line = fgets($pipes[1]);
if ($line !== false) { if ($line !== false) {
$line = trim($line); $line = trim($line);
// composer stdout 이 시스템 코드페이지(Windows CP949 등)로 출력하거나 // composer stdout 은 시스템 코드페이지(Windows CP949 등)로 출력되고,
// 진행바(\r 갱신)가 non-blocking 읽기와 겹쳐 임의 바이트 경계에서 잘리면 // 진행바 갱신이 non-blocking 읽기와 겹치면 임의 바이트 경계에서 잘려
// invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter 의 json_encode)과 // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter)과
// 폴링 응답(state-management) 양쪽이 무력화되지 않도록 로그 전송 전 정규화한다. // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 전송 전 정규화한다.
//
// 정규화는 복원 가능한 코드페이지 출력(한글 경로·메시지)을 먼저 되살리므로,
// 이전의 mb_scrub 단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.
// (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망) // (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망)
if ($line !== '' && ! mb_check_encoding($line, 'UTF-8')) { $line = installer_utf8_normalize($line);
$line = mb_scrub($line, 'UTF-8');
}
if (! empty($line)) { if (! empty($line)) {
sendSSEEvent('log', ['message' => $line]); sendSSEEvent('log', ['message' => $line]);
} }
@@ -1685,7 +1686,7 @@ if (! function_exists('createSettingsJsonSSE')) {
]; ];
$data = array_merge($data, $settings); $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'; $filePath = $settingsDir.'/'.$category.'.json';
file_put_contents($filePath, $json, LOCK_EX); file_put_contents($filePath, $json, LOCK_EX);
+182
View File
@@ -0,0 +1,182 @@
<?php
use App\Support\ProcessOutputEncoding;
/**
* 인스톨러 UTF-8 정규화 / JSON 출력 헬퍼
*
* 외부 명령 출력(`whoami` 등)과 예외 메시지는 UTF-8 이라는 보장이 없다.
* 한국어 Windows 의 `whoami` 는 OEM 949(CP949) 로 출력하므로 계정명에 한글이 있으면
* invalid UTF-8 바이트가 응답 배열에 실리고 `json_encode()` 가 `false` 를 반환한다.
* `echo false` 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가고, 프론트는
* "Unexpected end of JSON input" 으로 죽는데 서버에는 예외도 로그도 남지 않는다.
*
* 인스톨러의 모든 JSON 응답은 이 파일의 `installer_json_encode()` 를 거친다.
*
* 의존성 0 — 어느 시점에 로드되어도 안전해야 하므로 다른 인스톨러 파일을 include 하지 않는다.
*/
// 코어와 공유하는 정규화 SSoT. BASE_PATH 가 테스트에서 temp 로 바뀌어도
// 실제 파일 경로(__DIR__ 기준)로 찾는다.
if (! class_exists(ProcessOutputEncoding::class, false)) {
$processOutputEncodingPath = dirname(__DIR__, 3).'/app/Support/ProcessOutputEncoding.php';
if (is_file($processOutputEncodingPath)) {
require_once $processOutputEncodingPath;
}
unset($processOutputEncodingPath);
}
if (! function_exists('installer_utf8_normalize')) {
/**
* 문자열 하나를 항상 유효한 UTF-8 로 만듭니다.
*
* @param string $value 정규화할 원본 문자열
* @return string 유효한 UTF-8 문자열
*/
function installer_utf8_normalize(string $value): string
{
if (class_exists(ProcessOutputEncoding::class)) {
return ProcessOutputEncoding::normalize($value);
}
// 코어 파일이 없는 비정상 배치에서도 응답은 살린다.
return mb_check_encoding($value, 'UTF-8') ? $value : mb_scrub($value, 'UTF-8');
}
}
if (! function_exists('installer_utf8_normalize_deep')) {
/**
* 배열 트리 전체(키 포함)를 유효한 UTF-8 로 만듭니다.
*
* @param mixed $value 정규화할 값
* @return mixed 정규화된 값
*/
function installer_utf8_normalize_deep(mixed $value): mixed
{
if (class_exists(ProcessOutputEncoding::class)) {
return ProcessOutputEncoding::normalizeDeep($value);
}
if (is_string($value)) {
return installer_utf8_normalize($value);
}
if (! is_array($value)) {
return $value;
}
$normalized = [];
foreach ($value as $key => $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<string> 점 구분 키 경로 목록
*/
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"}';
}
}
+3 -3
View File
@@ -173,11 +173,11 @@ $stepFile = __DIR__.'/views/'.$currentStep.'-'.(STEP_FILE_MAP[$currentStep] ?? '
<script> <script>
window.INSTALLER_BASE_URL = '<?= INSTALLER_BASE_URL ?>'; window.INSTALLER_BASE_URL = '<?= INSTALLER_BASE_URL ?>';
window.CURRENT_STEP = <?= $currentStep ?>; window.CURRENT_STEP = <?= $currentStep ?>;
window.INSTALLER_LANG = <?= json_encode($translations ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; window.INSTALLER_LANG = <?= installer_json_encode($translations ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
window.INSTALLER_STATE_LOCALE = <?= json_encode($state['g7_locale'] ?? null) ?>; window.INSTALLER_STATE_LOCALE = <?= installer_json_encode($state['g7_locale'] ?? null) ?>;
// DB 최고권한 계정 목록 — 서버 상수를 그대로 내려보내 JS 에 목록을 중복 정의하지 않는다. // DB 최고권한 계정 목록 — 서버 상수를 그대로 내려보내 JS 에 목록을 중복 정의하지 않는다.
// 클라이언트 검증은 즉시 피드백용이며, 실제 차단은 서버 3개 경로가 담당한다. // 클라이언트 검증은 즉시 피드백용이며, 실제 차단은 서버 3개 경로가 담당한다.
window.INSTALLER_BLOCKED_DB_ACCOUNTS = <?= json_encode(PrivilegedDatabaseAccounts::BLOCKED) ?>; window.INSTALLER_BLOCKED_DB_ACCOUNTS = <?= installer_json_encode(PrivilegedDatabaseAccounts::BLOCKED) ?>;
</script> </script>
<script src="<?= INSTALLER_BASE_URL ?>/assets/js/installation-monitor.js?v=<?= time() ?>"></script> <script src="<?= INSTALLER_BASE_URL ?>/assets/js/installation-monitor.js?v=<?= time() ?>"></script>
<script src="<?= INSTALLER_BASE_URL ?>/assets/js/installer.js?v=<?= time() ?>"></script> <script src="<?= INSTALLER_BASE_URL ?>/assets/js/installer.js?v=<?= time() ?>"></script>
+4
View File
@@ -692,6 +692,10 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
// Installation stuck detection // 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.', '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.', '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.', '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', 'selection_summary' => 'Selection Summary',
+4
View File
@@ -692,6 +692,10 @@ ini_set(\'zlib.output_compression\', \'off\');
// 설치 진행 중단 감지 // 설치 진행 중단 감지
'error_installation_stuck' => '설치가 응답하지 않습니다. 서버 워커가 명령 실행 중 멈췄을 수 있습니다. 새로고침하거나 재시도하세요. 문제가 반복되면 storage/logs/installation.log 를 확인해주세요.', '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' => '확장 기능 목록을 불러오는데 실패했습니다.', 'extension_load_failed' => '확장 기능 목록을 불러오는데 실패했습니다.',
'no_admin_template_error' => '관리자 템플릿이 필요하지만 찾을 수 없습니다. templates 디렉토리에 최소 1개 이상의 관리자 템플릿이 있는지 확인해주세요.', 'no_admin_template_error' => '관리자 템플릿이 필요하지만 찾을 수 없습니다. templates 디렉토리에 최소 1개 이상의 관리자 템플릿이 있는지 확인해주세요.',
'selection_summary' => '선택 요약', 'selection_summary' => '선택 요약',
+2 -2
View File
@@ -539,8 +539,8 @@ $currentVendorMode = $formData['vendor_mode'] ?? 'auto';
window.DB_TEST_FLAGS = { window.DB_TEST_FLAGS = {
write: <?= $dbWriteTested ? 'true' : 'false' ?>, write: <?= $dbWriteTested ? 'true' : 'false' ?>,
read: <?= $dbReadTested ? 'true' : 'false' ?>, read: <?= $dbReadTested ? 'true' : 'false' ?>,
writeHash: <?= json_encode($dbWriteHash) ?>, writeHash: <?= installer_json_encode($dbWriteHash) ?>,
readHash: <?= json_encode($dbReadHash) ?> readHash: <?= installer_json_encode($dbReadHash) ?>
}; };
// ======================================================================== // ========================================================================
+18 -18
View File
@@ -214,23 +214,23 @@
<script> <script>
// Step 4 번역 키 전달 // Step 4 번역 키 전달
window.EXTENSION_LABELS = { window.EXTENSION_LABELS = {
version: <?= json_encode(lang('version')) ?>, version: <?= installer_json_encode(lang('version')) ?>,
select: <?= json_encode(lang('select')) ?>, select: <?= installer_json_encode(lang('select')) ?>,
selected: <?= json_encode(lang('selected')) ?>, selected: <?= installer_json_encode(lang('selected')) ?>,
dependencies: <?= json_encode(lang('dependencies')) ?>, dependencies: <?= installer_json_encode(lang('dependencies')) ?>,
admin_template_required: <?= json_encode(lang('admin_template_required')) ?>, admin_template_required: <?= installer_json_encode(lang('admin_template_required')) ?>,
saving: <?= json_encode(lang('saving')) ?>, saving: <?= installer_json_encode(lang('saving')) ?>,
save_failed: <?= json_encode(lang('save_failed')) ?>, save_failed: <?= installer_json_encode(lang('save_failed')) ?>,
next: <?= json_encode(lang('next')) ?>, next: <?= installer_json_encode(lang('next')) ?>,
extension_load_failed: <?= json_encode(lang('extension_load_failed')) ?>, extension_load_failed: <?= installer_json_encode(lang('extension_load_failed')) ?>,
dep_auto_badge_label: <?= json_encode(lang('dep_auto_badge_label')) ?>, dep_auto_badge_label: <?= installer_json_encode(lang('dep_auto_badge_label')) ?>,
dep_lock_message: <?= json_encode(lang('dep_lock_message')) ?>, dep_lock_message: <?= installer_json_encode(lang('dep_lock_message')) ?>,
dep_version_required: <?= json_encode(lang('dep_version_required')) ?>, dep_version_required: <?= installer_json_encode(lang('dep_version_required')) ?>,
dep_version_available: <?= json_encode(lang('dep_version_available')) ?>, dep_version_available: <?= installer_json_encode(lang('dep_version_available')) ?>,
language_pack_disabled_by_extension: <?= json_encode(lang('language_pack_disabled_by_extension')) ?>, language_pack_disabled_by_extension: <?= installer_json_encode(lang('language_pack_disabled_by_extension')) ?>,
language_pack_scope_core: <?= json_encode(lang('language_pack_scope_core')) ?>, language_pack_scope_core: <?= installer_json_encode(lang('language_pack_scope_core')) ?>,
language_pack_scope_module: <?= json_encode(lang('language_pack_scope_module')) ?>, language_pack_scope_module: <?= installer_json_encode(lang('language_pack_scope_module')) ?>,
language_pack_scope_plugin: <?= json_encode(lang('language_pack_scope_plugin')) ?>, language_pack_scope_plugin: <?= installer_json_encode(lang('language_pack_scope_plugin')) ?>,
language_pack_scope_template: <?= json_encode(lang('language_pack_scope_template')) ?> language_pack_scope_template: <?= installer_json_encode(lang('language_pack_scope_template')) ?>
}; };
</script> </script>
+73 -73
View File
@@ -59,79 +59,79 @@ $config = $state['config'] ?? $_SESSION['install_config'] ?? [];
<!-- 완료 섹션 --> <!-- 완료 섹션 -->
<?php <?php
$completionButtons = '<a href="../admin/login" class="btn btn-success">' . htmlspecialchars(lang('go_to_admin_login')) . '</a>'; $completionButtons = '<a href="../admin/login" class="btn btn-success">'.htmlspecialchars(lang('go_to_admin_login')).'</a>';
$coreVersion = getCoreAppVersion(); $coreVersion = getCoreAppVersion();
$completionExtra = '<p class="result-version">' $completionExtra = '<p class="result-version">'
. htmlspecialchars(lang('installed_version_label')) . ' ' .htmlspecialchars(lang('installed_version_label')).' '
. '<strong>' . htmlspecialchars($coreVersion) . '</strong>' .'<strong>'.htmlspecialchars($coreVersion).'</strong>'
. '</p>'; .'</p>';
// .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371) // .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371)
// finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한 // finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한
// (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을 // (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을
// 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출. // 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출.
// //
// OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작. // OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작.
// Windows 만 명령이 무의미하므로 Windows 만 비노출. // Windows 만 명령이 무의미하므로 Windows 만 비노출.
$envPath = BASE_PATH . '/.env'; $envPath = BASE_PATH.'/.env';
if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) { if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) {
$envPerms = fileperms($envPath) & 0777; $envPerms = fileperms($envPath) & 0777;
if ($envPerms !== 0600) { if ($envPerms !== 0600) {
$envOwnerUid = @fileowner($envPath); $envOwnerUid = @fileowner($envPath);
$envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid')) $envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid'))
? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid) ? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid)
: 'owner'; : 'owner';
$webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid') $webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid')
? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data') ? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data')
: 'www-data'; : 'www-data';
$hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath)); $hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath));
$hardeningCommandDifferentOwner = sprintf( $hardeningCommandDifferentOwner = sprintf(
'sudo chgrp %s %s && sudo chmod 640 %s', 'sudo chgrp %s %s && sudo chmod 640 %s',
htmlspecialchars($webGroupName), htmlspecialchars($webGroupName),
htmlspecialchars($envPath), htmlspecialchars($envPath),
htmlspecialchars($envPath) htmlspecialchars($envPath)
); );
$completionExtra .= '<div class="env-hardening-hint">' $completionExtra .= '<div class="env-hardening-hint">'
. '<p class="env-hardening-hint-title">' . htmlspecialchars(lang('env_hardening_hint_title')) . '</p>' .'<p class="env-hardening-hint-title">'.htmlspecialchars(lang('env_hardening_hint_title')).'</p>'
. '<p class="env-hardening-hint-status">' .'<p class="env-hardening-hint-status">'
. sprintf( .sprintf(
htmlspecialchars(lang('env_hardening_hint_current_status')), htmlspecialchars(lang('env_hardening_hint_current_status')),
decoct($envPerms), decoct($envPerms),
htmlspecialchars($envOwnerName) htmlspecialchars($envOwnerName)
) )
. '</p>' .'</p>'
. '<p class="env-hardening-hint-option">' .'<p class="env-hardening-hint-option">'
. htmlspecialchars(lang('env_hardening_hint_option_strict')) .htmlspecialchars(lang('env_hardening_hint_option_strict'))
. '</p>' .'</p>'
. '<div class="code-box"><pre>' . $hardeningCommandSameOwner . '</pre></div>' .'<div class="code-box"><pre>'.$hardeningCommandSameOwner.'</pre></div>'
. '<p class="env-hardening-hint-option">' .'<p class="env-hardening-hint-option">'
. htmlspecialchars(lang('env_hardening_hint_option_group')) .htmlspecialchars(lang('env_hardening_hint_option_group'))
. '</p>' .'</p>'
. '<div class="code-box"><pre>' . $hardeningCommandDifferentOwner . '</pre></div>' .'<div class="code-box"><pre>'.$hardeningCommandDifferentOwner.'</pre></div>'
. '<p class="env-hardening-hint-note">' . htmlspecialchars(lang('env_hardening_hint_note')) . '</p>' .'<p class="env-hardening-hint-note">'.htmlspecialchars(lang('env_hardening_hint_note')).'</p>'
. '</div>'; .'</div>';
}
} }
}
echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra); echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra);
?> ?>
<!-- 중단 섹션 --> <!-- 중단 섹션 -->
<?php <?php
$abortedButtons = '<button onclick="resumeInstallationFromAborted()" class="btn btn-primary">' . htmlspecialchars(lang('resume_continue')) . '</button> $abortedButtons = '<button onclick="resumeInstallationFromAborted()" class="btn btn-primary">'.htmlspecialchars(lang('resume_continue')).'</button>
<button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">' . htmlspecialchars(lang('back_to_settings')) . '</button>'; <button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">'.htmlspecialchars(lang('back_to_settings')).'</button>';
echo renderInstallResultSection('aborted', 'warning', 'installation_aborted', 'installation_aborted_message', $abortedButtons); echo renderInstallResultSection('aborted', 'warning', 'installation_aborted', 'installation_aborted_message', $abortedButtons);
?> ?>
<!-- 실패 섹션 --> <!-- 실패 섹션 -->
<?php <?php
$failureButtons = '<button onclick="retryInstallation()" class="btn btn-primary">' . htmlspecialchars(lang('retry_installation')) . '</button> $failureButtons = '<button onclick="retryInstallation()" class="btn btn-primary">'.htmlspecialchars(lang('retry_installation')).'</button>
<button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">' . htmlspecialchars(lang('back_to_settings')) . '</button>'; <button onclick="goToSettingsWithConfirm()" class="btn btn-secondary">'.htmlspecialchars(lang('back_to_settings')).'</button>';
echo renderInstallResultSection('failure', 'error', 'installation_failed', '', $failureButtons); echo renderInstallResultSection('failure', 'error', 'installation_failed', '', $failureButtons);
?> ?>
<!-- 설치 진행 방식 선택 (SSE / 폴링) --> <!-- 설치 진행 방식 선택 (SSE / 폴링) -->
<div class="requirement-card installation-mode-card" id="installation-mode-card"> <div class="requirement-card installation-mode-card" id="installation-mode-card">
@@ -220,9 +220,9 @@ $languagePacks = $selectedExtensions['language_packs'] ?? [];
<script> <script>
// Step 5 전용 데이터 전달 (INSTALLER_BASE_URL과 INSTALLER_LANG는 footer.php에서 처리) // Step 5 전용 데이터 전달 (INSTALLER_BASE_URL과 INSTALLER_LANG는 footer.php에서 처리)
window.INSTALLER_CONFIG = <?= json_encode($config) ?>; window.INSTALLER_CONFIG = <?= installer_json_encode($config) ?>;
window.INSTALLER_SELECTED_EXTENSIONS = <?= json_encode($selectedExtensions) ?>; window.INSTALLER_SELECTED_EXTENSIONS = <?= installer_json_encode($selectedExtensions) ?>;
window.INSTALLER_EXTENSION_NAMES = <?= json_encode($state['extension_names'] ?? []) ?>; window.INSTALLER_EXTENSION_NAMES = <?= installer_json_encode($state['extension_names'] ?? []) ?>;
// 작업 그룹 정의 // 작업 그룹 정의
window.INSTALLER_TASK_GROUPS = [ window.INSTALLER_TASK_GROUPS = [
@@ -247,47 +247,47 @@ window.INSTALLER_TASK_GROUPS = [
{ {
id: 'admin_templates', id: 'admin_templates',
labelKey: 'task_group_admin_templates', labelKey: 'task_group_admin_templates',
tasks: <?= json_encode(array_map(function($tpl) { tasks: <?= installer_json_encode(array_map(function ($tpl) {
return [ return [
['id' => 'template_install', 'target' => $tpl], ['id' => 'template_install', 'target' => $tpl],
['id' => 'template_activate', 'target' => $tpl] ['id' => 'template_activate', 'target' => $tpl],
]; ];
}, $adminTemplates)) ?> }, $adminTemplates)) ?>
}, },
{ {
id: 'modules', id: 'modules',
labelKey: 'task_group_modules', labelKey: 'task_group_modules',
tasks: <?= json_encode(array_map(function($mod) { tasks: <?= installer_json_encode(array_map(function ($mod) {
return [ return [
['id' => 'module_install', 'target' => $mod], ['id' => 'module_install', 'target' => $mod],
['id' => 'module_activate', 'target' => $mod] ['id' => 'module_activate', 'target' => $mod],
]; ];
}, $modules)) ?> }, $modules)) ?>
}, },
{ {
id: 'plugins', id: 'plugins',
labelKey: 'task_group_plugins', labelKey: 'task_group_plugins',
tasks: <?= json_encode(array_map(function($plg) { tasks: <?= installer_json_encode(array_map(function ($plg) {
return [ return [
['id' => 'plugin_install', 'target' => $plg], ['id' => 'plugin_install', 'target' => $plg],
['id' => 'plugin_activate', 'target' => $plg] ['id' => 'plugin_activate', 'target' => $plg],
]; ];
}, $plugins)) ?> }, $plugins)) ?>
}, },
{ {
id: 'user_templates', id: 'user_templates',
labelKey: 'task_group_user_templates', labelKey: 'task_group_user_templates',
tasks: <?= json_encode(array_map(function($tpl) { tasks: <?= installer_json_encode(array_map(function ($tpl) {
return [ return [
['id' => 'user_template_install', 'target' => $tpl], ['id' => 'user_template_install', 'target' => $tpl],
['id' => 'user_template_activate', 'target' => $tpl] ['id' => 'user_template_activate', 'target' => $tpl],
]; ];
}, $userTemplates)) ?> }, $userTemplates)) ?>
}, },
{ {
id: 'language_packs', id: 'language_packs',
labelKey: 'task_group_language_packs', labelKey: 'task_group_language_packs',
tasks: <?= json_encode(array_map(function($pack) { tasks: <?= installer_json_encode(array_map(function ($pack) {
return ['id' => 'language_pack_install', 'target' => $pack]; return ['id' => 'language_pack_install', 'target' => $pack];
}, $languagePacks)) ?> }, $languagePacks)) ?>
}, },
@@ -0,0 +1,80 @@
<?php
namespace Tests\Feature\Services;
use App\Services\SettingsService;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 시스템 정보 응답의 직렬화 계약 테스트.
*
* 배경 (gnuboard/g7#62 와 동형):
* `getSystemInfo()` 는 외부 프로세스 출력(powershell/wmic CPU 조회)과 환경값을
* 한 배열에 모아 관리자 환경설정 화면으로 내보낸다. 그 안에 invalid UTF-8 이
* 섞이면 Laravel JsonResponse 직렬화가 Malformed UTF-8 로 실패해 화면이 500 이 된다.
* `safeSystemProbe` 는 probe 예외만 잡고 직렬화 예외는 잡지 못한다.
*
* 단위 테스트(SettingsServiceCpuInfoTest)는 CPU 필드 하나의 정규화 계약만 본다.
* 이 테스트는 **페이로드 전체**가 직렬화 가능한지를 잠가, 나중에 다른 probe 가
* 외부 출력을 그대로 싣더라도 같은 결함이 재발하지 않게 한다.
*
* DB 스키마를 건드리지 않으므로 RefreshDatabase 를 쓰지 않는다.
*/
class SettingsServiceSystemInfoTest extends TestCase
{
#[Test]
public function system_info_payload_is_always_json_serializable(): void
{
$info = app(SettingsService::class)->getSystemInfo();
$this->assertNotSame([], $info, '시스템 정보 페이로드가 비어 있으면 안 된다.');
$encoded = json_encode($info, JSON_UNESCAPED_UNICODE);
$this->assertNotFalse(
$encoded,
'getSystemInfo() 페이로드 직렬화 실패: '.json_last_error_msg()
.' — invalid UTF-8 필드: '.implode(', ', $this->invalidUtf8Paths($info))
);
}
#[Test]
public function every_string_in_system_info_is_valid_utf8(): void
{
$info = app(SettingsService::class)->getSystemInfo();
$this->assertSame(
[],
$this->invalidUtf8Paths($info),
'시스템 정보에 유효하지 않은 UTF-8 문자열이 있으면 관리자 화면이 500 이 된다.'
);
}
/**
* invalid UTF-8 문자열이 있는 키 경로를 점 표기로 수집합니다.
*
* @param mixed $value 검사할 값
* @param string $prefix 상위 경로
* @return array<int, string> 점 구분 경로 목록
*/
private function invalidUtf8Paths(mixed $value, string $prefix = ''): array
{
if (is_string($value)) {
return mb_check_encoding($value, 'UTF-8') ? [] : [$prefix === '' ? '(root)' : $prefix];
}
if (! is_array($value)) {
return [];
}
$paths = [];
foreach ($value as $key => $item) {
$label = $prefix === '' ? (string) $key : $prefix.'.'.$key;
$paths = array_merge($paths, $this->invalidUtf8Paths($item, $label));
}
return $paths;
}
}
+43 -5
View File
@@ -173,6 +173,33 @@ class AddLogUtf8ScrubTest extends TestCase
$this->assertNotSame('', $encoded, '인코딩 결과 본문이 비어 있으면 안 된다.'); $this->assertNotSame('', $encoded, '인코딩 결과 본문이 비어 있으면 안 된다.');
} }
/**
* 한국어 Windows 의 CP949 출력은 U+FFFD 로 훼손되지 않고 복원되어야 한다.
*
* 이전 구현은 mb_scrub 단독이라 한글이 물음표/대체문자로 뭉개졌다.
* 로그는 운영자가 원인을 찾는 유일한 단서이므로 읽을 수 있어야 한다 (gnuboard/g7#62).
*/
#[Test]
public function addlog_restores_cp949_korean_output(): void
{
addLog('warmup');
$expected = 'it-manager\\티아모라';
$raw = (string) mb_convert_encoding($expected, 'CP949', 'UTF-8');
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
addLog($raw);
$logs = getInstallationLogs();
$message = $logs[count($logs) - 1]['message'] ?? '';
$this->assertSame(
$expected,
$message,
'CP949 한글 출력은 U+FFFD 훼손 없이 원문으로 복원되어야 한다.'
);
}
#[Test] #[Test]
public function addlog_preserves_valid_utf8_korean_unchanged(): void public function addlog_preserves_valid_utf8_korean_unchanged(): void
{ {
@@ -225,11 +252,15 @@ class AddLogUtf8ScrubTest extends TestCase
} }
/** /**
* progress-emitter.php 의 SseEmitter::emit 이 JSON_INVALID_UTF8_SUBSTITUTE 를 적용하는지 * progress-emitter.php 의 SseEmitter::emit 이 빈 data 라인을 송출할 수 없는지
* 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식). * 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식).
*
* 가드는 인라인 플래그에서 공용 헬퍼 installer_json_encode 로 옮겨졌다.
* 헬퍼는 값을 정규화하고 substitute 를 적용하며, 실패해도 파싱 가능한 JSON 을 돌려주므로
* 빈 data 라인이 나갈 수 있는 경로가 없다. 계약은 그대로이고 수단만 단일화됐다.
*/ */
#[Test] #[Test]
public function sse_emitter_source_uses_invalid_utf8_substitute_flag(): void public function sse_emitter_source_routes_through_guarded_encoder(): void
{ {
$source = (string) file_get_contents( $source = (string) file_get_contents(
dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php' dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php'
@@ -242,10 +273,17 @@ class AddLogUtf8ScrubTest extends TestCase
$sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null); $sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null);
$this->assertStringContainsString( $this->assertStringContainsString(
'JSON_INVALID_UTF8_SUBSTITUTE', 'installer_json_encode(',
$sseBody, $sseBody,
'SseEmitter::emit 의 json_encode 는 JSON_INVALID_UTF8_SUBSTITUTE 를 적용해 '. 'SseEmitter::emit 은 가드된 공용 인코더를 경유해야 한다 '.
'invalid UTF-8 로그로 인한 빈 SSE data 라인 송출을 차단해야 한다.' '(invalid UTF-8 로그로 인한 빈 SSE data 라인 송출 차단).'
);
// raw json_encode 로 되돌아가면 빈 data 라인 경로가 되살아난다.
$this->assertDoesNotMatchRegularExpression(
'/(?<![A-Za-z0-9_])json_encode\\s*\\(/',
$sseBody,
'SseEmitter::emit 에 가드 없는 json_encode 가 남아 있으면 안 된다.'
); );
} }
} }
@@ -0,0 +1,311 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 JSON 응답 경계 테스트.
*
* 배경 (gnuboard/g7#62, sir.kr 566085):
* `echo json_encode($data)` 는 $data 에 invalid UTF-8 이 섞이면 false 를 반환하고,
* `echo false` 는 빈 문자열이라 HTTP 200 + 빈 본문이 나간다. 프론트는
* "Unexpected end of JSON input" 으로 죽는데 서버에는 예외도 로그도 없다.
*
* 2026-07 수정(#445)은 로그 축 4곳만 막았다. 응답 경계 전체를 단일 헬퍼로 닫지 않으면
* 다음 사람이 `echo json_encode` 를 다시 써서 같은 결함이 재발한다.
*
* BASE_PATH 격리 패턴은 AddLogUtf8ScrubTest 와 동일하다.
*/
#[RunClassInSeparateProcess]
#[PreserveGlobalState(false)]
class InstallerJsonOutputTest extends TestCase
{
private static string $sharedBase = '';
private static string $skipReason = '';
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
if (defined('BASE_PATH')) {
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
if (strpos($resolved, $tempPrefix) !== 0) {
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
'격리 실행 필요: php vendor/bin/phpunit --filter=InstallerJsonOutputTest';
return;
}
self::$sharedBase = (string) BASE_PATH;
} else {
self::$sharedBase = sys_get_temp_dir().'/g7-installer-json-test-'.bin2hex(random_bytes(4));
define('BASE_PATH', self::$sharedBase);
}
$logDir = self::$sharedBase.'/storage/logs';
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
if (! isset($_SERVER['SCRIPT_NAME'])) {
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
}
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
require_once dirname(__DIR__, 3).'/public/install/includes/installer-state.php';
}
public static function tearDownAfterClass(): void
{
if (self::$skipReason === '' && self::$sharedBase !== '') {
$logFile = self::$sharedBase.'/storage/logs/installation.log';
if (file_exists($logFile)) {
@unlink($logFile);
}
}
parent::tearDownAfterClass();
}
protected function setUp(): void
{
parent::setUp();
if (self::$skipReason !== '') {
$this->markTestSkipped(self::$skipReason);
}
$logFile = self::$sharedBase.'/storage/logs/installation.log';
if (file_exists($logFile)) {
@unlink($logFile);
}
}
private static function cp949(string $utf8): string
{
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
}
#[Test]
public function encode_restores_cp949_korean_account_name(): void
{
$raw = self::cp949('it-manager\\티아모라');
// 사전 조건: 표본이 실제로 결함을 재현해야 한다.
$this->assertFalse(json_encode(['u' => $raw]), '표본은 표준 json_encode 를 false 로 만들어야 한다.');
$json = installer_json_encode(['u' => $raw]);
$this->assertIsString($json);
$this->assertNotSame('', $json, '응답 본문은 절대 비어 있으면 안 된다.');
$decoded = json_decode($json, true);
$this->assertIsArray($decoded, '반환값은 항상 파싱 가능한 JSON 이어야 한다.');
$this->assertSame('it-manager\\티아모라', $decoded['u'], '한글 계정명이 원문 그대로 복원되어야 한다.');
}
#[Test]
public function encode_never_returns_empty_for_damaged_bytes(): void
{
foreach (["a\xFFb", "emoji \xF0\x9F head", "tail\x80end"] as $raw) {
$json = installer_json_encode(['v' => $raw]);
$this->assertIsString($json);
$this->assertNotSame('', $json, '손상 바이트에서도 본문이 비면 안 된다.');
$this->assertIsArray(json_decode($json, true), '반환값은 파싱 가능해야 한다.');
}
}
#[Test]
public function encode_failure_falls_back_to_json_and_logs_key_path(): void
{
// INF 는 UTF-8 과 무관하게 json_encode 를 실패시킨다 (폴백 경로 강제 진입).
$json = installer_json_encode(['bad' => INF]);
$this->assertIsString($json);
$this->assertNotSame('', $json, '인코딩이 실패해도 빈 본문을 내보내면 안 된다.');
$decoded = json_decode($json, true);
$this->assertIsArray($decoded, '폴백 응답 자체는 반드시 파싱 가능해야 한다.');
$this->assertFalse($decoded['success'], '폴백 응답은 실패를 명시해야 한다.');
$this->assertSame('json_encode_failed', $decoded['error']);
$this->assertArrayHasKey('invalid_paths', $decoded);
// 관측성: 제보 사례에서 로그에 흔적이 0 이던 것이 이 결함의 핵심 어려움이었다.
$logFile = self::$sharedBase.'/storage/logs/installation.log';
$this->assertFileExists($logFile, '인코딩 실패는 반드시 설치 로그에 남아야 한다.');
$this->assertStringContainsString(
'[installer-json] encode failed',
(string) file_get_contents($logFile),
'로그에 실패 위치를 특정할 수 있는 행이 있어야 한다.'
);
}
#[Test]
public function encode_failure_preserves_caller_supplied_fallback_shape(): void
{
// 폴링 응답(state-management::getState)은 프론트 PollingMonitor 가 `state.status`
// 로 진행/완료/실패를 판정한다. 그 키가 빠진 폴백은 파싱은 되지만 아무 전이도
// 일으키지 못해 화면이 조용히 멈춘 것처럼 보이고, JSON 이 유효하므로 파싱 실패
// 카운터(5회)에도 걸리지 않는다. 폴백에서도 소비자 계약이 유지되어야 한다.
$json = installer_json_encode(
['logs' => ['line'], 'broken' => INF],
JSON_UNESCAPED_UNICODE,
['status' => 'running', 'logs' => [], 'log_total' => 7]
);
$decoded = json_decode($json, true);
$this->assertIsArray($decoded, '폴백 응답은 파싱 가능해야 한다.');
$this->assertSame('running', $decoded['status'], '호출자가 넘긴 status 가 폴백에 살아 있어야 한다.');
$this->assertSame([], $decoded['logs']);
$this->assertSame(7, $decoded['log_total']);
// 오류 정보는 호출자 형태 위에 얹혀 함께 나간다.
$this->assertFalse($decoded['success']);
$this->assertSame('json_encode_failed', $decoded['error']);
}
#[Test]
public function encode_keeps_unescaped_unicode_by_default(): void
{
$json = installer_json_encode(['msg' => '설치']);
$this->assertStringContainsString('설치', $json, '기존 JSON_UNESCAPED_UNICODE 계약을 유지해야 한다.');
}
#[Test]
public function js_escape_still_produces_valid_javascript_literal(): void
{
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
// JSON_HEX_QUOT 로 인용부호를 이스케이프하는 기존 계약(수정 전과 동일)
$this->assertSame('"a\\u0022b"', js_escape('a"b'), 'js_escape 의 기존 이스케이프 계약이 유지되어야 한다.');
// 한글 계정명이 섞여도 '""' 폴백으로 떨어지지 않아야 한다.
$escaped = js_escape(self::cp949('티아모라'));
$this->assertNotSame('""', $escaped, 'CP949 입력에서 빈 문자열 폴백으로 떨어지면 안 된다.');
$this->assertSame('티아모라', json_decode($escaped, true));
}
#[Test]
public function get_web_server_user_always_returns_valid_utf8(): void
{
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
$user = getWebServerUser();
$group = getWebServerGroup();
// 머신 무관 계약: 출처가 무엇이든 응답에 실릴 값은 항상 유효 UTF-8 이어야 한다.
$this->assertTrue(
$user === null || mb_check_encoding($user, 'UTF-8'),
'getWebServerUser() 반환값은 항상 유효 UTF-8 이어야 한다.'
);
$this->assertTrue(
$group === null || mb_check_encoding($group, 'UTF-8'),
'getWebServerGroup() 반환값은 항상 유효 UTF-8 이어야 한다.'
);
}
/**
* 정적 계약: 인스톨러의 출력 경로에 raw `json_encode(` 가 남아 있으면 안 된다.
*
* 이 테스트가 없으면 다음 사람이 `echo json_encode` 를 다시 쓰고 같은 결함이 재발한다.
*/
#[Test]
public function no_raw_json_encode_remains_in_installer_output_paths(): void
{
$root = dirname(__DIR__, 3).'/public/install';
/**
* 면제 대상 (각각 사유 있음).
*
* - utf8.php : installer_json_encode 구현 본체 (여기가 유일한 json_encode 자리)
* - functions.php:574 : md5 캐시 키 생성 — 출력이 아니다
* - _guard.php : 다른 파일을 include 하지 않는 독립 진입 가드, ASCII 리터럴만 출력
* - session.php : config.php 보다 먼저 로드되는 경로가 있어 헬퍼를 못 쓴다, ko/en 리터럴만
* - installer-state.php: addLog 재귀 금지 — 상태 저장은 자체 가드(§saveInstallationState)
*/
$allowlist = [
'includes/utf8.php',
'api/_guard.php',
'includes/session.php',
'includes/installer-state.php',
];
$violations = [];
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
if (in_array($rel, $allowlist, true)) {
continue;
}
$source = (string) file_get_contents($file->getPathname());
$lines = explode("\n", $source);
foreach ($lines as $i => $line) {
// installer_json_encode 는 허용, 그 외 json_encode( 호출은 위반
if (! preg_match('/(?<![A-Za-z0-9_])json_encode\s*\(/', $line)) {
continue;
}
if (str_contains($line, 'installer_json_encode')) {
continue;
}
// 출력이 아닌 용도(md5 캐시 키 등)는 같은 줄 또는 바로 위 주석의
// 'json-encode:allow' 표식으로 면제한다 (사유를 코드에 남기게 하기 위함).
$context = implode("\n", array_slice($lines, max(0, $i - 3), 4));
if (str_contains($context, 'json-encode:allow')) {
continue;
}
$violations[] = $rel.':'.($i + 1).' — '.trim($line);
}
}
$this->assertSame(
[],
$violations,
"인스톨러 출력 경로에 raw json_encode 가 남아 있다. installer_json_encode 를 사용할 것:\n".
implode("\n", $violations)
);
}
/**
* 프론트에 노출되는 신규 안내 문구가 ko/en 양쪽에 정의되어 있어야 한다.
*
* 누락 시 화면에 `error_empty_server_response` 같은 키가 그대로 노출된다.
*/
#[Test]
public function new_error_message_keys_exist_in_both_locales(): void
{
$keys = [
'error_empty_server_response',
'error_invalid_server_response',
'error_polling_response_invalid',
];
foreach (['ko', 'en'] as $locale) {
$path = dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
$this->assertFileExists($path);
/** @var array<string, string> $messages */
$messages = require $path;
foreach ($keys as $key) {
$this->assertArrayHasKey($key, $messages, "{$locale}.php 에 '{$key}' 가 정의되어야 한다.");
$this->assertNotSame('', trim((string) $messages[$key]), "{$locale}.{$key} 는 비어 있으면 안 된다.");
}
}
}
}
@@ -0,0 +1,157 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 설치 2단계(설치 환경 확인) 응답 인코딩 회귀 테스트.
*
* 배경 (sir.kr 566085 / gnuboard/g7#62 reopen):
* `whoami` = `it-manager\티아모라` (한글 계정명) 인 Windows 에서
* `check-configuration.php?action=requirements` 가 HTTP 200 + 빈 본문을 반환하고
* 화면에 "요구사항 검증 실패: ... Unexpected end of JSON input" 만 표시된다.
*
* 원인: whoami 의 CP949 출력이 `directories.web_server_user` /
* `required_files.*.owner` 에 정규화 없이 실려 json_encode() 가 false 를 반환한다.
*
* getWebServerUser() 는 전역 함수라 mock 할 수 없으므로,
* checkRequirements() 가 만드는 것과 같은 배열 형태에 CP949 값을 주입하고
* **같은 직렬화 경로**(installer_json_encode)로 인코딩해 계약을 검사한다.
*/
#[RunClassInSeparateProcess]
#[PreserveGlobalState(false)]
class RequirementsResponseUtf8Test extends TestCase
{
private static string $sharedBase = '';
private static string $skipReason = '';
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
if (defined('BASE_PATH')) {
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
if (strpos($resolved, $tempPrefix) !== 0) {
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
'격리 실행 필요: php vendor/bin/phpunit --filter=RequirementsResponseUtf8Test';
return;
}
self::$sharedBase = (string) BASE_PATH;
} else {
self::$sharedBase = sys_get_temp_dir().'/g7-installer-req-utf8-'.bin2hex(random_bytes(4));
define('BASE_PATH', self::$sharedBase);
}
foreach (['/storage/logs', '/storage/app', '/bootstrap/cache'] as $dir) {
if (! is_dir(self::$sharedBase.$dir)) {
mkdir(self::$sharedBase.$dir, 0755, true);
}
}
if (! isset($_SERVER['SCRIPT_NAME'])) {
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
}
// 라이브러리 모드 — 파일 로드만으로 요청을 처리하지 않도록 한다.
if (! defined('CHECK_CONFIGURATION_LIBRARY')) {
define('CHECK_CONFIGURATION_LIBRARY', true);
}
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
require_once dirname(__DIR__, 3).'/public/install/api/check-configuration.php';
}
protected function setUp(): void
{
parent::setUp();
if (self::$skipReason !== '') {
$this->markTestSkipped(self::$skipReason);
}
}
private static function cp949(string $utf8): string
{
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
}
/**
* 2단계 응답이 한글 계정명 환경에서도 본문을 실어 보내고 계정명을 복원해야 한다.
*/
#[Test]
public function requirements_response_survives_korean_account_name(): void
{
$account = 'it-manager\\티아모라';
$raw = self::cp949($account);
// checkDirectoryPermissions() / checkRequiredFiles() 가 만드는 형태를 그대로 모사
$requirements = [
'directories' => [
'required' => true,
'web_server_group' => $raw,
'web_server_user' => $raw,
'all_passed' => false,
'message' => '디렉토리 권한을 확인하세요.',
],
'required_files' => [
'required' => true,
'files' => [
'.env' => ['exists' => true, 'writable' => false, 'owner' => $raw],
],
'all_passed' => false,
],
'all_required_passed' => false,
'is_windows' => true,
];
// 사전 조건: 이 배열이 실제로 결함을 재현해야 한다.
$this->assertFalse(
json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
'표본 배열은 기존 직렬화 방식에서 false 가 되어야 한다 (결함 재현).'
);
// checkRequirements() 가 쓰는 것과 같은 경로
$json = installer_json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
$this->assertIsString($json);
$this->assertNotSame('', $json, 'HTTP 200 + 빈 본문이 다시 나가면 안 된다.');
$decoded = json_decode($json, true);
$this->assertIsArray($decoded, '프론트의 response.json() 이 파싱할 수 있어야 한다.');
$this->assertSame($account, $decoded['directories']['web_server_user']);
$this->assertSame($account, $decoded['directories']['web_server_group']);
$this->assertSame($account, $decoded['required_files']['files']['.env']['owner']);
// 나머지 필드가 정규화 과정에서 사라지거나 변형되지 않아야 한다.
$this->assertFalse($decoded['all_required_passed']);
$this->assertTrue($decoded['is_windows']);
$this->assertSame('디렉토리 권한을 확인하세요.', $decoded['directories']['message']);
}
/**
* 회귀 가드 (머신 무관): 출처가 posix / whoami / 환경변수 어느 쪽이든
* 응답에 실릴 값은 항상 유효 UTF-8 이어야 한다.
*/
#[Test]
public function web_server_identity_is_always_encodable(): void
{
$payload = [
'web_server_user' => getWebServerUser(),
'web_server_group' => getWebServerGroup(),
];
$this->assertNotFalse(
json_encode($payload),
'현재 머신의 웹서버 사용자/그룹 값이 json_encode 를 무력화하면 안 된다.'
);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Tests\Unit\Services;
use App\Support\ProcessOutputEncoding;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 시스템 정보(CPU) 수집의 인코딩 계약 테스트.
*
* 배경 (gnuboard/g7#62 와 동형):
* `getCpuInfo()` 는 `powershell … 2>&1` / `wmic … 2>&1` 의 출력을 문자열로 반환한다.
* `2>&1` 로 합쳐진 오류 문장은 시스템 코드페이지(한국어 Windows = CP949)로 나오므로
* 정규화 없이 반환하면 그 값이 시스템 정보 API 응답에 실려 JsonResponse 직렬화가
* Malformed UTF-8 로 실패한다 — 관리자 환경설정 화면이 500 이 된다.
*
* `safeSystemProbe` 는 probe 자체의 예외만 잡고 직렬화 예외는 잡지 못하므로
* 출처에서 정규화하는 것이 유일한 차단 지점이다.
*
* `getCpuInfo()` 는 protected 이고 `shell_exec` 을 주입할 수 없으므로,
* 이 테스트는 그 메서드가 의존하는 정규화 계약을 검사한다.
*/
class SettingsServiceCpuInfoTest extends TestCase
{
/**
* 한국어 Windows 의 powershell 오류 문장을 재현한 CP949 표본.
*/
private const ERROR_SENTENCE = "'powershell'은(는) 내부 또는 외부 명령으로 인식되지 않습니다.";
#[Test]
public function cp949_shell_error_sentence_becomes_serializable(): void
{
$raw = (string) mb_convert_encoding(self::ERROR_SENTENCE, 'CP949', 'UTF-8');
// 사전 조건: 이 바이트가 실제로 직렬화를 무력화해야 의미가 있다.
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'));
$this->assertFalse(json_encode(['cpu' => trim($raw)]));
$normalized = ProcessOutputEncoding::normalize($raw);
$this->assertSame(self::ERROR_SENTENCE, $normalized, '한글 오류 문장이 원문대로 복원되어야 한다.');
$this->assertNotFalse(
json_encode(['cpu' => trim($normalized)]),
'정규화 후에는 시스템 정보 응답이 직렬화되어야 한다 (500 차단).'
);
}
/**
* 정규화가 기존 판정 로직을 바꾸지 않아야 한다.
*
* 오류 문장이 CPU 이름으로 보이는 표시 문제는 이번 범위 밖이며,
* 영문 'error' 판정은 그대로 유지된다 (기능 축소 없음).
*/
#[Test]
public function normalization_preserves_existing_error_detection(): void
{
$englishError = "wmic : The term 'wmic' is not recognized. ".
'FullyQualifiedErrorId : CommandNotFoundException';
$normalized = ProcessOutputEncoding::normalize($englishError);
$this->assertSame($englishError, $normalized, '유효 UTF-8 출력은 변형되지 않아야 한다.');
$this->assertStringContainsString(
'error',
strtolower($normalized),
'기존 error 판정이 정규화 후에도 동일하게 동작해야 한다.'
);
}
/**
* 정상적인 CPU 이름은 한 글자도 변하지 않아야 한다.
*/
#[Test]
public function normal_cpu_name_is_unchanged(): void
{
$name = 'AMD Ryzen 9 7950X 16-Core Processor';
$this->assertSame($name, ProcessOutputEncoding::normalize($name));
}
/**
* getCpuInfo() 가 정규화를 실제로 경유하는지 소스 레벨 회귀 검증.
*
* 정규화를 걷어내면 이 클래스의 나머지 계약이 전부 통과해도 500 이 되살아난다.
*/
#[Test]
public function get_cpu_info_source_routes_shell_output_through_normalizer(): void
{
$source = (string) file_get_contents(dirname(__DIR__, 3).'/app/Services/SettingsService.php');
$start = strpos($source, 'protected function getCpuInfo()');
$this->assertNotFalse($start, 'getCpuInfo() 를 찾을 수 없습니다.');
$body = substr($source, $start, 2000);
// shell_exec 출력을 소비하는 두 분기(powershell / wmic) 모두 정규화를 거쳐야 한다.
$this->assertSame(
2,
substr_count($body, 'ProcessOutputEncoding::normalize('),
'powershell·wmic 두 분기 모두 외부 출력을 정규화해야 한다 — '.
'한쪽만 막으면 폴백 경로에서 같은 500 이 재발한다.'
);
}
}
@@ -0,0 +1,204 @@
<?php
namespace Tests\Unit\Support;
use App\Support\ProcessOutputEncoding;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 외부 프로세스 출력 인코딩 정규화 테스트.
*
* 배경 (gnuboard/g7#62, sir.kr 566085):
* 한국어 Windows 에서 `whoami` 는 OEM 코드페이지(CP949) 로 출력한다.
* 계정명에 한글이 포함되면 그 바이트가 invalid UTF-8 이라
* json_encode() 가 false 를 반환하고 `echo false` = 빈 본문(HTTP 200)이 나간다.
* 예외도 로그도 남지 않아 운영자가 원인을 특정할 수 없다.
*
* 이 클래스는 그 출력을 "항상 유효한 UTF-8" 로 만드는 단일 출처다.
* 인스톨러(프레임워크 없음)와 코어가 함께 쓴다.
*/
class ProcessOutputEncodingTest extends TestCase
{
/**
* 한국어 Windows 의 whoami 출력을 재현한 CP949 바이트.
*
* hex 를 손으로 적지 않는다 — 오타로 다른 글자가 되는 사고가 실제로 있었다.
*/
private static function cp949(string $utf8): string
{
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
}
/**
* 유효 UTF-8 입력은 한 바이트도 변형되지 않아야 한다.
*
* @return array<string, array{string}>
*/
public static function validUtf8Provider(): array
{
return [
'empty' => [''],
'ascii' => ['www-data'],
'korean' => ['it-manager\\티아모라'],
'emoji' => ['user 🎉 name'],
'mixed' => ['서버-01 / nginx / 그룹'],
];
}
#[Test]
#[DataProvider('validUtf8Provider')]
public function valid_utf8_passes_through_unchanged(string $value): void
{
$this->assertSame(
$value,
ProcessOutputEncoding::normalize($value),
'유효 UTF-8 입력은 정규화 대상이 아니므로 변형되면 안 된다.'
);
}
#[Test]
public function cp949_korean_account_name_is_restored_exactly(): void
{
$expected = 'it-manager\\티아모라';
$raw = self::cp949($expected);
// 사전 조건: 표본이 실제로 결함을 재현하는 바이트여야 의미가 있다.
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
$this->assertFalse(json_encode(['u' => $raw]), '표본은 json_encode 를 false 로 만들어야 한다.');
$this->assertSame(
$expected,
ProcessOutputEncoding::normalize($raw),
'CP949 한글 계정명은 원문 그대로 복원되어야 한다 (U+FFFD 훼손 금지).'
);
}
#[Test]
public function cp949_extended_hangul_is_restored(): void
{
// KS X 1001 밖의 확장 한글 — mb_detect_encoding 이 EUC-KR 이 아닌 CP949 로 답하는 영역
$expected = '똠방각하';
$raw = self::cp949($expected);
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
$this->assertSame($expected, ProcessOutputEncoding::normalize($raw));
}
/**
* 복원 불가능한 손상 바이트 표본.
*
* AddLogUtf8ScrubTest::invalidUtf8Provider 와 같은 표본 — composer 진행바(\r 갱신)가
* 임의 바이트 경계에서 잘릴 때 생기는 형태다. 여기서는 "정확한 복원" 이 아니라
* "응답이 살아남는다" 만 요구한다.
*
* @return array<string, array{string}>
*/
public static function unrecoverableProvider(): array
{
return [
'truncated hangul lead bytes' => ["Downloading \xEC\x99 package"],
'lone 0xFF byte' => ["progress: abc\xFFdef 100%"],
'truncated 4-byte sequence' => ["emoji \xF0\x9F head"],
'lone continuation byte' => ["tail\x80end"],
];
}
#[Test]
#[DataProvider('unrecoverableProvider')]
public function damaged_bytes_still_yield_encodable_utf8(string $raw): void
{
$normalized = ProcessOutputEncoding::normalize($raw);
$this->assertTrue(
mb_check_encoding($normalized, 'UTF-8'),
'복원 불가능한 바이트라도 결과는 반드시 유효 UTF-8 이어야 한다.'
);
$this->assertNotFalse(
json_encode(['v' => $normalized]),
'정규화 결과는 json_encode 를 false 로 만들면 안 된다 (빈 본문 방지).'
);
}
#[Test]
public function normalize_deep_covers_nested_values_and_keys(): void
{
$cp949 = self::cp949('티아모라');
$input = [
'directories' => [
'web_server_user' => $cp949,
'nested' => [['owner' => $cp949], 42, true, null, 1.5],
],
$cp949 => 'key-is-invalid-too',
];
$out = ProcessOutputEncoding::normalizeDeep($input);
$this->assertNotFalse(
json_encode($out),
'배열 트리 전체가 정규화되어야 json_encode 가 살아남는다.'
);
$this->assertSame('티아모라', $out['directories']['web_server_user']);
$this->assertSame('티아모라', $out['directories']['nested'][0]['owner']);
$this->assertArrayHasKey('티아모라', $out, '배열 키도 정규화 대상이다.');
// 문자열 외 스칼라는 타입·값이 보존되어야 한다.
$this->assertSame(42, $out['directories']['nested'][1]);
$this->assertTrue($out['directories']['nested'][2]);
$this->assertNull($out['directories']['nested'][3]);
$this->assertSame(1.5, $out['directories']['nested'][4]);
}
#[Test]
public function invalid_paths_reports_dotted_key_path(): void
{
$cp949 = self::cp949('티아모라');
$this->assertSame(
['directories.web_server_user'],
ProcessOutputEncoding::invalidPaths(['directories' => ['web_server_user' => $cp949]]),
'encode 실패 시 운영자가 어느 필드가 원인인지 알 수 있어야 한다.'
);
$this->assertSame(
[],
ProcessOutputEncoding::invalidPaths(['directories' => ['web_server_user' => 'www-data']]),
'정상 입력에서는 보고할 경로가 없어야 한다.'
);
}
#[Test]
public function invalid_paths_reports_scalar_root_and_invalid_keys(): void
{
$cp949 = self::cp949('티아모라');
$this->assertSame([''], ProcessOutputEncoding::invalidPaths($cp949), '스칼라 루트도 보고 대상이다.');
$paths = ProcessOutputEncoding::invalidPaths([$cp949 => 'ok']);
$this->assertNotEmpty($paths, 'invalid 한 배열 키도 encode 를 실패시키므로 보고되어야 한다.');
}
/**
* Windows OEM 코드페이지 폴백(2단계) 경로.
*
* 결과 문자열은 머신 로케일에 따라 달라지므로 값은 단언하지 않는다 —
* "항상 유효 UTF-8 을 돌려준다" 는 계약만 검사한다.
*/
#[Test]
public function windows_oem_fallback_still_returns_valid_utf8(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->markTestSkipped('Windows OEM 폴백 경로는 Windows 에서만 실행된다.');
}
foreach (["\x82\x60\x82\x61", "\x81\x40", "\xA1\xA1"] as $raw) {
$normalized = ProcessOutputEncoding::normalize($raw);
$this->assertTrue(
mb_check_encoding($normalized, 'UTF-8'),
'OEM 폴백을 타더라도 결과는 유효 UTF-8 이어야 한다.'
);
}
}
}
@@ -0,0 +1,79 @@
# audit:allow test-scenario-coverage reason: 인스톨러(설치 전 표면)는 설치 완료 사이트에 붙는 Playwright 인프라와 양립하지 않는다 (_guard 가 410). 브라우저 계층은 Chrome MCP 정밀 점검 매트릭스로 대체하며, 그 결과는 작업 이력 문서에 기재한다.
feature: 인스톨러 프로세스 출력 인코딩 정규화와 JSON 응답 경계
description: |
외부 프로세스 출력(`whoami`, composer stdout, powershell/wmic)은 UTF-8 이 아니라
그 명령을 실행한 콘솔의 코드페이지로 나온다. 한국어 Windows(OEM 949)에서 계정명에
한글이 있으면 invalid UTF-8 바이트가 응답 배열에 실리고, `json_encode()` 가 false 를
반환해 `echo false` = HTTP 200 + 빈 본문이 나간다. 예외도 로그도 남지 않는다.
핵심 동작:
- ProcessOutputEncoding: 한국어 코드페이지 확정 감지 → Windows OEM/ANSI 폴백 → mb_scrub
(복원 가능한 출력은 한글 그대로 살리고, 불가능한 바이트만 대체)
- installer_json_encode: 정규화 + substitute + 실패 시 파싱 가능한 오류 JSON
(false·빈 문자열을 절대 반환하지 않는다)
- 출처 정규화: getWebServerUser/Group, composer 스트림, addLog, 상태 파일 저장
- 관측성: 정규화가 실제로 필요했으면 설치 로그에 1회 기록, encode 실패는 키 경로까지 기록
- 프론트: 빈 본문/비 JSON 응답을 읽을 수 있는 안내 문구로, 폴링은 5회 연속 실패 시 중단
axes:
output_encoding: [utf8, cp949_korean, cp949_extended_hangul, truncated_utf8, single_high_byte, empty]
sink:
- requirements_response
- polling_state_response
- sse_event
- state_json_file
- installation_log
- js_embedded_view
- core_system_info
value_position: [scalar, nested_array_value, array_key]
locale: [ko, en]
exclusions:
- { output_encoding: empty, value_position: array_key, reason: "빈 문자열 키는 인스톨러 응답 형태에 존재하지 않는다" }
- { sink: core_system_info, value_position: array_key, reason: "CPU 정보는 스칼라 값 한 개" }
- { sink: core_system_info, locale: en, reason: "정규화는 로케일에 의존하지 않는다 — ko 표본으로 충분" }
- { sink: installation_log, value_position: array_key, reason: "로그 메시지는 스칼라 문자열" }
- { sink: installation_log, value_position: nested_array_value, reason: "동일" }
effects:
# 응답이 절대 비지 않는다 (결함 본체)
- response_body_never_empty
- requirements_response_parses_after_cp949_account_name
- polling_response_parses_after_invalid_utf8_log
- sse_data_line_never_empty
# 복원 품질 (mb_scrub 단독 대비 향상)
- korean_account_name_restored_exactly
- extended_hangul_restored
- valid_utf8_passes_through_unchanged
- non_string_scalars_preserve_type_and_value
- unrecoverable_bytes_still_yield_encodable_utf8
# 출처 차단
- web_server_user_always_valid_utf8
- composer_stream_line_normalized_before_emit
- state_json_file_written_with_substitute_flag
- core_cpu_info_normalized_before_serialization
# 관측성 (제보 사례에 로그 흔적이 0 이었다)
- encode_failure_logged_with_key_path
- non_utf8_process_output_logged_once_per_source
# 프론트 안내
- frontend_shows_readable_message_on_empty_body
- frontend_shows_readable_message_on_unparsable_body
- polling_stops_after_5_consecutive_parse_failures
- polling_parse_failure_counter_resets_on_success
- new_error_message_keys_defined_in_ko_and_en
# 재발 차단
- no_raw_json_encode_remains_in_installer_output_paths
- sse_emitter_routes_through_guarded_encoder
- get_cpu_info_routes_both_branches_through_normalizer
test_files:
- tests/Unit/Support/ProcessOutputEncodingTest.php
- tests/Unit/Installer/InstallerJsonOutputTest.php
- tests/Unit/Installer/RequirementsResponseUtf8Test.php
- tests/Unit/Installer/AddLogUtf8ScrubTest.php
- tests/Unit/Services/SettingsServiceCpuInfoTest.php
validation:
browser_matrix: .claude/docs/chrome-mcp-inspection-matrix.md