Files
Gnuboard7/public/install/api/scan-extensions.php
T
HeuJung 01d319e74b fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면
invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다.
echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도
남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다.

2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차),
응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에
남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다.

정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub
단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.

같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가
500 이 되던 것도 함께 막았다.
2026-08-29 15:13:14 +09:00

549 lines
17 KiB
PHP

<?php
/**
* G7 인스톨러 - 확장 기능 스캔 API
*
* _bundled 디렉토리에서 템플릿, 모듈, 플러그인을 스캔하여 설치 가능한 확장 목록을 반환합니다.
* 모든 확장의 메타데이터는 JSON 매니페스트 파일(template.json, module.json, plugin.json)에서 읽습니다.
*
* @response {
* "success": true,
* "data": {
* "admin_templates": [...],
* "user_templates": [...],
* "modules": [...],
* "plugins": [...]
* }
* }
*/
// 라이브러리 모드 — 단위 테스트가 함수 정의만 로드할 때 메인 실행을 건너뛴다.
// 테스트는 BASE_PATH 를 직접 정의한 뒤 SCAN_EXTENSIONS_LIBRARY=true 로 require.
$scanExtensionsLibraryMode = defined('SCAN_EXTENSIONS_LIBRARY') && constant('SCAN_EXTENSIONS_LIBRARY');
if (! $scanExtensionsLibraryMode) {
// 기본 설정
header('Content-Type: application/json; charset=utf-8');
// 프로젝트 루트 경로
if (! defined('BASE_PATH')) {
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3));
}
// 세션 및 설정 포함 (Laravel autoload 없이 독립 실행)
// 참고: 이 API는 Laravel 부팅 없이 디렉토리 스캔만 수행
require_once dirname(__DIR__).'/includes/session.php';
require_once dirname(__DIR__).'/includes/config.php';
require_once __DIR__.'/_guard.php';
installer_guard_or_410();
}
/**
* 템플릿 디렉토리 스캔
*
* _bundled 디렉토리에서 template.json을 읽어 템플릿 정보를 수집합니다.
*
* @return array ['admin' => [...], 'user' => [...]]
*/
function scanTemplates(): array
{
$templatesDir = BASE_PATH.'/templates/_bundled';
$result = [
'admin' => [],
'user' => [],
];
if (! is_dir($templatesDir)) {
return $result;
}
$dirs = scandir($templatesDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
$templateJsonPath = $templatesDir.'/'.$dir.'/template.json';
if (! file_exists($templateJsonPath)) {
continue;
}
$content = @file_get_contents($templateJsonPath);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data)) {
continue;
}
// hidden 매니페스트는 인스톨러에서 제외 (학습용 샘플 등)
if (! empty($data['hidden'])) {
continue;
}
// 필수 필드 확인
$identifier = $data['identifier'] ?? $dir;
$name = $data['name'] ?? ['ko' => $dir, 'en' => $dir];
$version = $data['version'] ?? '1.0.0';
$description = $data['description'] ?? ['ko' => '', 'en' => ''];
$type = $data['type'] ?? 'user'; // admin 또는 user
$author = $data['author']['name'] ?? $data['vendor'] ?? null;
// dependencies 처리: 객체 형태면 모듈/플러그인 키를 배열로 변환
$dependencies = extractDependenciesFromJson($data);
$dependenciesDetailed = extractDependenciesDetailedFromJson($data);
$templateInfo = [
'identifier' => $identifier,
'name' => $name,
'version' => $version,
'description' => $description,
'type' => $type,
'dependencies' => $dependencies,
'dependencies_detailed' => $dependenciesDetailed,
'author' => $author,
'directory' => $dir,
];
if ($type === 'admin') {
$result['admin'][] = $templateInfo;
} else {
$result['user'][] = $templateInfo;
}
}
return $result;
}
/**
* 모듈 디렉토리 스캔
*
* _bundled 디렉토리에서 module.json을 읽어 모듈 정보를 수집합니다.
*
* @return array 모듈 목록
*/
function scanModules(): array
{
$modulesDir = BASE_PATH.'/modules/_bundled';
$result = [];
if (! is_dir($modulesDir)) {
return $result;
}
$dirs = scandir($modulesDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
// module.json 매니페스트에서 메타데이터 읽기
$moduleJsonPath = $modulesDir.'/'.$dir.'/module.json';
if (! file_exists($moduleJsonPath)) {
// module.json이 없으면 module.php만으로 식별 (역호환)
$modulePhpPath = $modulesDir.'/'.$dir.'/module.php';
if (! file_exists($modulePhpPath)) {
continue;
}
// JSON이 없는 경우 기본값으로 등록
$result[] = [
'identifier' => $dir,
'name' => ['ko' => $dir, 'en' => $dir],
'version' => '1.0.0',
'description' => ['ko' => '', 'en' => ''],
'dependencies' => [],
'author' => null,
'directory' => $dir,
];
continue;
}
$content = @file_get_contents($moduleJsonPath);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data)) {
continue;
}
// hidden 매니페스트는 인스톨러에서 제외 (학습용 샘플 등)
if (! empty($data['hidden'])) {
continue;
}
$dependencies = extractDependenciesFromJson($data);
$dependenciesDetailed = extractDependenciesDetailedFromJson($data);
$result[] = [
'identifier' => $data['identifier'] ?? $dir,
'name' => $data['name'] ?? ['ko' => $dir, 'en' => $dir],
'version' => $data['version'] ?? '1.0.0',
'description' => $data['description'] ?? ['ko' => '', 'en' => ''],
'dependencies' => $dependencies,
'dependencies_detailed' => $dependenciesDetailed,
'author' => $data['vendor'] ?? null,
'directory' => $dir,
];
}
return $result;
}
/**
* 플러그인 디렉토리 스캔
*
* _bundled 디렉토리에서 plugin.json을 읽어 플러그인 정보를 수집합니다.
*
* @return array 플러그인 목록
*/
function scanPlugins(): array
{
$pluginsDir = BASE_PATH.'/plugins/_bundled';
$result = [];
if (! is_dir($pluginsDir)) {
return $result;
}
$dirs = scandir($pluginsDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') {
continue;
}
// plugin.json 매니페스트에서 메타데이터 읽기
$pluginJsonPath = $pluginsDir.'/'.$dir.'/plugin.json';
if (! file_exists($pluginJsonPath)) {
// plugin.json이 없으면 plugin.php만으로 식별 (역호환)
$pluginPhpPath = $pluginsDir.'/'.$dir.'/plugin.php';
if (! file_exists($pluginPhpPath)) {
continue;
}
// JSON이 없는 경우 기본값으로 등록
$result[] = [
'identifier' => $dir,
'name' => ['ko' => $dir, 'en' => $dir],
'version' => '1.0.0',
'description' => ['ko' => '', 'en' => ''],
'dependencies' => [],
'author' => null,
'directory' => $dir,
];
continue;
}
$content = @file_get_contents($pluginJsonPath);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data)) {
continue;
}
// hidden 매니페스트는 인스톨러에서 제외 (학습용 샘플 등)
if (! empty($data['hidden'])) {
continue;
}
$dependencies = extractDependenciesFromJson($data);
$dependenciesDetailed = extractDependenciesDetailedFromJson($data);
$result[] = [
'identifier' => $data['identifier'] ?? $dir,
'name' => $data['name'] ?? ['ko' => $dir, 'en' => $dir],
'version' => $data['version'] ?? '1.0.0',
'description' => $data['description'] ?? ['ko' => '', 'en' => ''],
'dependencies' => $dependencies,
'dependencies_detailed' => $dependenciesDetailed,
'author' => $data['vendor'] ?? null,
'directory' => $dir,
];
}
return $result;
}
/**
* 번들 모듈/플러그인/템플릿 매니페스트에서 hidden=true 식별자 목록을 수집합니다.
*
* 학습용 샘플 등 hidden 처리된 호스트 확장의 식별자를 모아, 그 확장에 종속된 언어팩도
* 함께 인스톨러에서 제외할 수 있도록 합니다 (호스트 확장 hidden cascade).
*
* @return array<string> hidden 호스트 확장 식별자 목록
*/
function collectHiddenExtensionIdentifiers(): array
{
$hidden = [];
$sources = [
'modules' => 'module.json',
'plugins' => 'plugin.json',
'templates' => 'template.json',
];
foreach ($sources as $type => $manifestName) {
$bundleDir = BASE_PATH.'/'.$type.'/_bundled';
if (! is_dir($bundleDir)) {
continue;
}
$entries = scandir($bundleDir);
if ($entries === false) {
continue;
}
foreach ($entries as $sub) {
if ($sub === '.' || $sub === '..') {
continue;
}
$manifestPath = $bundleDir.'/'.$sub.'/'.$manifestName;
if (! file_exists($manifestPath)) {
continue;
}
$content = @file_get_contents($manifestPath);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) {
continue;
}
if (! empty($data['hidden'])) {
$hidden[] = $data['identifier'] ?? $sub;
}
}
}
return $hidden;
}
/**
* 번들 언어팩 디렉토리 스캔
*
* `lang-packs/_bundled/*\/language-pack.json` 매니페스트를 직접 읽어 언어팩 목록을 수집합니다.
* Laravel 부팅 없이 동작하며, 매칭/정렬은 프론트엔드에서 결정합니다.
*
* hidden 처리:
* - 매니페스트 자체에 hidden=true 가 있으면 제외 (직접 hidden)
* - 종속된 호스트 확장이 hidden 이면 함께 제외 (cascade hidden — 학습용 샘플 모듈/템플릿용 언어팩 자동 제외)
*
* 응답 항목 shape:
* identifier, directory, scope (core|module|plugin|template),
* target_identifier (확장 식별자 또는 null),
* locale, locale_name, locale_native_name, text_direction,
* name, description (다국어 객체 또는 string),
* version, author
*
* @return array 번들 언어팩 목록
*/
function scanLanguagePacks(): array
{
$dir = BASE_PATH.'/lang-packs/_bundled';
$result = [];
if (! is_dir($dir)) {
return $result;
}
$entries = scandir($dir);
if ($entries === false) {
return $result;
}
$hiddenHostIdentifiers = collectHiddenExtensionIdentifiers();
foreach ($entries as $sub) {
if ($sub === '.' || $sub === '..') {
continue;
}
$manifestPath = $dir.'/'.$sub.'/language-pack.json';
if (! file_exists($manifestPath)) {
continue;
}
$content = @file_get_contents($manifestPath);
if ($content === false) {
continue;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data)) {
continue;
}
// 직접 hidden 처리된 매니페스트 제외
if (! empty($data['hidden'])) {
continue;
}
// 종속 호스트 확장이 hidden 이면 cascade 제외 (학습용 샘플 모듈/템플릿용 언어팩 자동 제외)
$targetIdentifier = $data['target_identifier'] ?? null;
if (is_string($targetIdentifier) && $targetIdentifier !== '' && in_array($targetIdentifier, $hiddenHostIdentifiers, true)) {
continue;
}
$result[] = [
'identifier' => $data['identifier'] ?? $sub,
'directory' => $sub,
'scope' => $data['scope'] ?? 'core',
'target_identifier' => $data['target_identifier'] ?? null,
'locale' => $data['locale'] ?? '',
'locale_name' => $data['locale_name'] ?? '',
'locale_native_name' => $data['locale_native_name'] ?? ($data['locale_name'] ?? ($data['locale'] ?? '')),
'text_direction' => $data['text_direction'] ?? 'ltr',
'name' => $data['name'] ?? $sub,
'description' => $data['description'] ?? ['ko' => '', 'en' => ''],
'version' => $data['version'] ?? '1.0.0',
];
}
return $result;
}
/**
* JSON 매니페스트에서 의존성 identifier 배열 추출 (하위 호환 유지).
*
* dependencies 구조:
* - 객체 형태: { "modules": {"sirsoft-ecommerce": ">=1.0.0"}, "plugins": {...} }
* - 배열 형태: ["module1", "module2"]
*
* @param array $data JSON 매니페스트 데이터
* @return array 의존성 identifier 목록
*/
function extractDependenciesFromJson(array $data): array
{
$rawDeps = $data['dependencies'] ?? [];
$dependencies = [];
if (is_array($rawDeps)) {
if (isset($rawDeps['modules']) || isset($rawDeps['plugins'])) {
foreach (['modules', 'plugins'] as $depType) {
if (! empty($rawDeps[$depType]) && is_array($rawDeps[$depType])) {
$dependencies = array_merge($dependencies, array_keys($rawDeps[$depType]));
}
}
} else {
$dependencies = array_values($rawDeps);
}
}
return $dependencies;
}
/**
* JSON 매니페스트에서 타입+버전이 보존된 의존성 상세 배열 추출.
*
* 반환: [['type' => 'modules'|'plugins', 'identifier' => '...', 'version' => '>=0.1.1'], ...]
* 프론트엔드 validateExtensionSelection()에서 의존성 그래프 검증에 사용합니다.
*
* @param array $data JSON 매니페스트 데이터
* @return array 의존성 상세 배열
*/
function extractDependenciesDetailedFromJson(array $data): array
{
$rawDeps = $data['dependencies'] ?? [];
$detailed = [];
if (! is_array($rawDeps)) {
return $detailed;
}
if (isset($rawDeps['modules']) || isset($rawDeps['plugins'])) {
foreach (['modules', 'plugins'] as $depType) {
if (! empty($rawDeps[$depType]) && is_array($rawDeps[$depType])) {
foreach ($rawDeps[$depType] as $identifier => $version) {
if (is_int($identifier)) {
// 배열 원소: 값이 identifier
$detailed[] = [
'type' => $depType,
'identifier' => (string) $version,
'version' => '*',
];
} else {
$detailed[] = [
'type' => $depType,
'identifier' => (string) $identifier,
'version' => is_string($version) ? $version : '*',
];
}
}
}
}
} else {
// 레거시 배열 형태 — 타입을 알 수 없으므로 'unknown'으로 표기
foreach ($rawDeps as $identifier) {
$detailed[] = [
'type' => 'unknown',
'identifier' => (string) $identifier,
'version' => '*',
];
}
}
return $detailed;
}
// ===== 메인 실행 =====
// 라이브러리 모드(테스트) 에서는 메인 실행을 건너뛴다.
if ($scanExtensionsLibraryMode) {
return;
}
try {
// 템플릿 스캔
$templates = scanTemplates();
// 모듈 스캔
$modules = scanModules();
// 플러그인 스캔
$plugins = scanPlugins();
// 번들 언어팩 스캔
$languagePacks = scanLanguagePacks();
// Admin 템플릿 필수 검증
if (empty($templates['admin'])) {
http_response_code(400);
echo installer_json_encode([
'success' => false,
'error' => 'no_admin_template',
'error_message' => 'Admin template is required but not found. Please ensure at least one admin template exists in the templates/_bundled directory.',
], JSON_UNESCAPED_UNICODE);
exit;
}
// 결과 반환
echo installer_json_encode([
'success' => true,
'data' => [
'admin_templates' => $templates['admin'],
'user_templates' => $templates['user'],
'modules' => $modules,
'plugins' => $plugins,
'language_packs' => $languagePacks,
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo installer_json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE);
}