Merge pull request from gnuboard:HeuJung/issue627
fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' => '선택 요약',
|
||||||
|
|||||||
@@ -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) ?>
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user