Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ef91bda5 | ||
|
|
8fcb79d60f | ||
|
|
79b83626eb | ||
|
|
29c25376bc | ||
|
|
512869e407 | ||
|
|
ab4f05c1c0 | ||
|
|
ce8e55b390 | ||
|
|
4c73db2e91 | ||
|
|
47a2e815aa | ||
|
|
90f154ecc0 | ||
|
|
84ca40f0d8 | ||
|
|
c4beb116ad | ||
|
|
f848086fd6 | ||
|
|
cc705c2f21 | ||
|
|
1922003917 | ||
|
|
9be8faaea2 | ||
|
|
ae447c9cc4 | ||
|
|
57ac2b0b3b | ||
|
|
cd3f6ad2b7 | ||
|
|
a690fdd2c4 | ||
|
|
8cc40e0681 | ||
|
|
d85ff9971a | ||
|
|
793afb95dc | ||
|
|
68cbee0e83 | ||
|
|
bfaf551d41 | ||
|
|
8018c525b8 | ||
|
|
25c0ab46ec | ||
|
|
13e65131f4 |
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.3
|
||||
APP_VERSION=7.0.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.3
|
||||
APP_VERSION=7.0.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -136,3 +136,10 @@ playwright-report/
|
||||
test-results/
|
||||
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
|
||||
.playwright-mcp/
|
||||
|
||||
# ===== 소스맵 =====
|
||||
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
|
||||
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
|
||||
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
|
||||
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
|
||||
*.map
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
|
||||
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
|
||||
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
|
||||
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
|
||||
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
|
||||
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
|
||||
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
|
||||
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
|
||||
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
|
||||
|
||||
## [7.0.3] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.3 g7
|
||||
# (필요 시) mv g7-7.0.4 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.3-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -94,9 +94,12 @@ class BuildCoreCommand extends Command
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
|
||||
$buildEnv = $this->buildEnv($productionMode);
|
||||
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
@@ -104,7 +107,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
@@ -114,7 +117,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
@@ -197,7 +200,13 @@ class BuildCoreCommand extends Command
|
||||
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$projectPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||
@@ -311,15 +320,30 @@ class BuildCoreCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -328,6 +352,11 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildModuleCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildModuleCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildModuleCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildPluginCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildPluginCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildPluginCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -181,8 +181,13 @@ class BuildTemplateCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -285,15 +290,30 @@ class BuildTemplateCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -302,6 +322,11 @@ class BuildTemplateCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
+11
-5
@@ -71,7 +71,7 @@ class EnvironmentDetector
|
||||
* → PATH 검색 → composer.phar
|
||||
*
|
||||
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
*/
|
||||
public function findComposerBinary(?string $hint = null): ?string
|
||||
{
|
||||
@@ -120,7 +120,7 @@ class EnvironmentDetector
|
||||
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
|
||||
*
|
||||
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
*/
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
@@ -178,7 +178,7 @@ class EnvironmentDetector
|
||||
* 지정 경로에 쓰기 가능한지 확인.
|
||||
*
|
||||
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
*/
|
||||
public function canWriteVendor(string $targetPath): bool
|
||||
{
|
||||
@@ -254,9 +254,15 @@ class EnvironmentDetector
|
||||
/**
|
||||
* composer 실행 명령 문자열 구성.
|
||||
*
|
||||
* @param array<int, string> $args
|
||||
* 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다.
|
||||
* Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat`
|
||||
* 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다.
|
||||
*
|
||||
* @param string $binary composer 바이너리 경로 (findComposerBinary 반환값)
|
||||
* @param array<int, string> $args composer 하위 명령과 옵션 (예: ['install', '--no-dev'])
|
||||
* @return string proc_open 에 넘길 실행 명령 문자열
|
||||
*/
|
||||
private function buildComposerCommand(string $binary, array $args): string
|
||||
public function buildComposerCommand(string $binary, array $args): string
|
||||
{
|
||||
$escaped = array_map('escapeshellarg', $args);
|
||||
|
||||
|
||||
+177
-57
@@ -82,7 +82,7 @@ class VendorBundler
|
||||
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
|
||||
*
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
*/
|
||||
public function buildForCore(bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -106,7 +106,7 @@ class VendorBundler
|
||||
* @param string $type 확장 타입 ('module' 또는 'plugin')
|
||||
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
*/
|
||||
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -164,14 +164,17 @@ class VendorBundler
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
|
||||
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
*/
|
||||
public function isStale(string $sourcePath, ?string $outputPath = null): bool
|
||||
{
|
||||
$outputPath ??= $sourcePath;
|
||||
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (file_exists($composerJsonPath) && ! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
// 의존성 판정도 build() 와 같은 파일을 본다 (출력 우선, 없으면 소스 폴백).
|
||||
// 소스만 보면 _bundled 에 외부 패키지를 처음 추가한 상태를 "의존성 없음" 으로
|
||||
// 오판해 재빌드가 필요 없다고 답한다.
|
||||
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($composerJsonPath !== null && ! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -187,16 +190,19 @@ class VendorBundler
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file_exists($composerJsonPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
|
||||
// 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조).
|
||||
// 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다.
|
||||
$hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($hashJsonPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashJsonPath);
|
||||
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($composerLockPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
|
||||
$hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
if ($hashLockPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashLockPath);
|
||||
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
@@ -205,6 +211,35 @@ class VendorBundler
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 해시 비교 기준이 될 파일 경로를 결정합니다.
|
||||
*
|
||||
* manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며,
|
||||
* 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리
|
||||
* (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을
|
||||
* 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의
|
||||
* 기준이 일치합니다.
|
||||
*
|
||||
* 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다.
|
||||
* 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다.
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리)
|
||||
* @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일)
|
||||
* @param string $filename 'composer.json' 또는 'composer.lock'
|
||||
* @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null
|
||||
*/
|
||||
private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string
|
||||
{
|
||||
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
|
||||
if (file_exists($outputFile)) {
|
||||
return $outputFile;
|
||||
}
|
||||
|
||||
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
|
||||
|
||||
return file_exists($sourceFile) ? $sourceFile : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 실행.
|
||||
*
|
||||
@@ -215,12 +250,20 @@ class VendorBundler
|
||||
*/
|
||||
private function build(string $sourcePath, string $outputPath, string $target, bool $force): VendorBundleResult
|
||||
{
|
||||
// 전제 조건 검증
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (! file_exists($composerJsonPath)) {
|
||||
// 빌드가 사용하는 composer.json/lock 은 **출력(_bundled) 우선, 없으면 소스 폴백** 이다
|
||||
// ({@see resolveHashTarget()}). 설치 입력·해시 기준·런타임 검증이 모두 같은 파일을
|
||||
// 보아야 한다.
|
||||
//
|
||||
// 개발자는 규정상 `_bundled` 에서만 작업하므로 새 패키지를 추가하면 `_bundled` 의
|
||||
// composer.json/lock 에만 반영되고 활성 디렉토리는 구버전으로 남는다. 설치 입력만
|
||||
// 활성에서 가져오면 구버전 lock 으로 install 한 zip 에 신버전 해시가 붙어, 무결성은
|
||||
// 통과하는데 새 패키지가 번들에서 누락된다 (런타임에 클래스 not found).
|
||||
// 코어는 source == output 이므로 항상 같은 파일이다.
|
||||
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($composerJsonPath === null) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_json_not_found',
|
||||
context: ['path' => $composerJsonPath],
|
||||
context: ['path' => $sourcePath.DIRECTORY_SEPARATOR.'composer.json'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,26 +301,33 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 파일 제거
|
||||
if (file_exists($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
if (file_exists($manifestPath)) {
|
||||
@unlink($manifestPath);
|
||||
}
|
||||
|
||||
// === 스테이징 빌드 ===
|
||||
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
|
||||
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
|
||||
//
|
||||
// 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을
|
||||
// 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이
|
||||
// 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다.
|
||||
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
|
||||
File::ensureDirectoryExists($stagingDir, 0755);
|
||||
|
||||
$tmpZipPath = $zipPath.'.tmp';
|
||||
$tmpManifestPath = $manifestPath.'.tmp';
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
|
||||
// 설치 입력 = 해시 기준과 동일한 파일 (출력 우선, 없으면 소스 폴백).
|
||||
$composerLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
|
||||
try {
|
||||
// 1. composer.json + composer.lock 을 스테이징으로 복사
|
||||
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
|
||||
$sourceLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($sourceLockPath)) {
|
||||
File::copy($sourceLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
|
||||
if ($composerLockPath !== null) {
|
||||
File::copy($composerLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
|
||||
}
|
||||
|
||||
// 2. 스테이징에서 composer install --no-dev 실행
|
||||
@@ -292,8 +342,8 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
|
||||
// 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath);
|
||||
|
||||
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
|
||||
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
@@ -301,19 +351,19 @@ class VendorBundler
|
||||
? $this->collectPackages($stagedLockPath)
|
||||
: [];
|
||||
|
||||
// manifest 의 해시는 설치 입력과 **동일한** composer.json/lock 기준 (출력 우선).
|
||||
// manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며,
|
||||
// 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}).
|
||||
$manifest = [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'generator' => 'g7 vendor-bundle:build',
|
||||
'target' => $target,
|
||||
// manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
|
||||
// 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
|
||||
// stale 여부를 감지한다.
|
||||
'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
|
||||
'composer_lock_sha256' => file_exists($sourceLockPath)
|
||||
? $this->integrityChecker->computeFileHash($sourceLockPath)
|
||||
'composer_lock_sha256' => $composerLockPath !== null
|
||||
? $this->integrityChecker->computeFileHash($composerLockPath)
|
||||
: null,
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath),
|
||||
'zip_size' => $zipSize,
|
||||
'package_count' => count($packages),
|
||||
'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
|
||||
@@ -321,7 +371,12 @@ class VendorBundler
|
||||
'packages' => $packages,
|
||||
];
|
||||
|
||||
File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다.
|
||||
// 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다.
|
||||
$this->promoteAtomic($tmpZipPath, $zipPath);
|
||||
$this->promoteAtomic($tmpManifestPath, $manifestPath);
|
||||
|
||||
Log::info('Vendor 번들 빌드 완료', [
|
||||
'target' => $target,
|
||||
@@ -340,10 +395,42 @@ class VendorBundler
|
||||
reason: 'built',
|
||||
);
|
||||
} finally {
|
||||
// 스테이징 정리 (실패/성공 무관)
|
||||
// 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관)
|
||||
if (File::isDirectory($stagingDir)) {
|
||||
File::deleteDirectory($stagingDir);
|
||||
}
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일을 최종 경로로 원자적으로 교체합니다.
|
||||
*
|
||||
* Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저
|
||||
* 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로,
|
||||
* 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다.
|
||||
*
|
||||
* @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태)
|
||||
* @param string $finalPath 최종 경로
|
||||
*
|
||||
* @throws VendorInstallException 이동 실패 시
|
||||
*/
|
||||
private function promoteAtomic(string $tmpPath, string $finalPath): void
|
||||
{
|
||||
if (file_exists($finalPath)) {
|
||||
@unlink($finalPath);
|
||||
}
|
||||
|
||||
if (! @rename($tmpPath, $finalPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_promote_failed',
|
||||
context: ['from' => $tmpPath, 'to' => $finalPath],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,30 +464,37 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', 'php');
|
||||
|
||||
if (str_contains($binary, ' ')) {
|
||||
$composerCmd = $binary;
|
||||
} elseif (str_ends_with(strtolower($binary), '.phar')) {
|
||||
$composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
|
||||
} else {
|
||||
$composerCmd = escapeshellarg($binary);
|
||||
}
|
||||
|
||||
$command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
|
||||
// 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다.
|
||||
// buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat
|
||||
// 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다.
|
||||
$command = $detector->buildComposerCommand($binary, [
|
||||
'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress',
|
||||
]);
|
||||
|
||||
Log::info('vendor-bundle 빌드: composer install 시작', [
|
||||
'target' => $target,
|
||||
'staging' => $stagingDir,
|
||||
]);
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
// stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다.
|
||||
// (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을
|
||||
// `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를
|
||||
// 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아
|
||||
// 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다.
|
||||
// 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다
|
||||
// (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성).
|
||||
$outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log';
|
||||
$errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log';
|
||||
$descriptors = self::composerOutputDescriptors($outPath, $errPath);
|
||||
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
|
||||
$process = @proc_open(
|
||||
$command,
|
||||
$descriptors,
|
||||
$pipes,
|
||||
$stagingDir,
|
||||
EnvironmentDetector::buildComposerEnv(),
|
||||
['bypass_shell' => true],
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new VendorInstallException(
|
||||
@@ -409,12 +503,17 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
// stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 —
|
||||
// proc_close 가 자식 종료까지 블록 대기한다.
|
||||
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
$stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : '';
|
||||
$stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : '';
|
||||
$output = trim($stdout.\PHP_EOL.$stderr);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
@@ -428,13 +527,34 @@ class VendorBundler
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다.
|
||||
*
|
||||
* stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 —
|
||||
* Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로
|
||||
* 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를
|
||||
* 회귀 테스트로 고정하기 위해 분리했다.
|
||||
*
|
||||
* @param string $outPath stdout 로그 파일 경로
|
||||
* @param string $errPath stderr 로그 파일 경로
|
||||
* @return array<int, array<int, string>> proc_open 2번째 인자용 descriptor 배열
|
||||
*/
|
||||
public static function composerOutputDescriptors(string $outPath, string $errPath): array
|
||||
{
|
||||
return [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $outPath, 'w'],
|
||||
2 => ['file', $errPath, 'w'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
|
||||
*
|
||||
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
|
||||
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
|
||||
*
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
*/
|
||||
private function writeZip(string $vendorPath, string $zipPath): array
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -26,11 +28,15 @@ class InstallFromUrlRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:500'],
|
||||
$rules = [
|
||||
// 원격 코드를 내려받는 지점이므로 https 고정 + 내부 주소 허용 설정과 무관하게 항상 차단
|
||||
'url' => ['required', 'url', 'max:500', new PublicOutboundUrl(schemes: ['https'], allowInternalOptIn: false)],
|
||||
'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.install_from_url_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,20 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CreateScheduleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -22,7 +29,7 @@ class CreateScheduleRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -33,7 +40,25 @@ class CreateScheduleRequest extends FormRequest
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "required|string|in:{$types}",
|
||||
'command' => 'required|string|max:2000',
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:2000',
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Url->value,
|
||||
[new PublicOutboundUrl],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Shell->value,
|
||||
[new AllowedShellCommand],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Artisan->value,
|
||||
[new AllowedArtisanCommand],
|
||||
),
|
||||
],
|
||||
'expression' => 'required|string|max:100',
|
||||
'frequency' => "required|string|in:{$frequencies}",
|
||||
'without_overlapping' => 'boolean',
|
||||
@@ -86,7 +111,9 @@ class CreateScheduleRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증된 데이터 반환
|
||||
* 검증된 데이터에 생성자(created_by)를 덧붙여 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 검증된 입력 + created_by
|
||||
*/
|
||||
public function validatedWithCreator(): array
|
||||
{
|
||||
|
||||
@@ -6,12 +6,20 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateScheduleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -21,7 +29,7 @@ class UpdateScheduleRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -32,7 +40,17 @@ class UpdateScheduleRequest extends FormRequest
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "sometimes|required|string|in:{$types}",
|
||||
'command' => 'sometimes|required|string|max:2000',
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'max:2000',
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Url), [new PublicOutboundUrl]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Shell), [new AllowedShellCommand]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Artisan), [new AllowedArtisanCommand]),
|
||||
],
|
||||
'expression' => 'sometimes|required|string|max:100',
|
||||
'frequency' => "sometimes|required|string|in:{$frequencies}",
|
||||
'without_overlapping' => 'boolean',
|
||||
@@ -47,6 +65,26 @@ class UpdateScheduleRequest extends FormRequest
|
||||
return HookManager::applyFilters('core.schedule.update_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 수정 결과 스케줄이 주어진 타입이 되는지 판정합니다.
|
||||
*
|
||||
* PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로
|
||||
* 판정한다 — 그렇지 않으면 기존 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
|
||||
*
|
||||
* @param ScheduleType $type 판정할 스케줄 타입
|
||||
* @return bool 해당 타입의 스케줄이면 true
|
||||
*/
|
||||
protected function resolvesToScheduleType(ScheduleType $type): bool
|
||||
{
|
||||
if ($this->has('type')) {
|
||||
return $this->input('type') === $type->value;
|
||||
}
|
||||
|
||||
$schedule = $this->route('schedule');
|
||||
|
||||
return $schedule instanceof Schedule && $schedule->type === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*
|
||||
|
||||
@@ -231,6 +231,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
|
||||
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
|
||||
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
|
||||
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
|
||||
|
||||
// 캐시 설정 (advanced 탭)
|
||||
'advanced.cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
|
||||
@@ -614,6 +616,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'security.auth_token_lifetime.integer' => __('validation.settings.auth_token_lifetime_integer'),
|
||||
'security.auth_token_lifetime.min' => __('validation.settings.auth_token_lifetime_min'),
|
||||
'security.auth_token_lifetime.max' => __('validation.settings.auth_token_lifetime_max'),
|
||||
'security.allow_internal_outbound_urls.boolean' => __('validation.settings.allow_internal_outbound_urls_boolean'),
|
||||
'security.max_login_attempts.integer' => __('validation.settings.max_login_attempts_integer'),
|
||||
'security.max_login_attempts.min' => __('validation.settings.max_login_attempts_min'),
|
||||
'security.max_login_attempts.max' => __('validation.settings.max_login_attempts_max'),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
|
||||
*
|
||||
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
|
||||
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
|
||||
* 노출되지 않는다.
|
||||
*/
|
||||
class AllowedArtisanCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 artisan 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class AllowedModuleFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@ class AllowedPluginFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Shell 타입 스케줄의 command 가 실행 허용된 명령인지 검증하는 Custom Rule.
|
||||
*
|
||||
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령을 서버에서 실행하는 것을 저장 시점에 차단한다.
|
||||
* 허용 여부는 `config/schedule_security.php` 의 opt-in 게이트와 실행 파일 화이트리스트가 결정하며,
|
||||
* 관리자 환경설정에 노출되지 않으므로 위임 관리자가 스스로 범위를 넓힐 수 없다.
|
||||
*/
|
||||
class AllowedShellCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 shell 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* 서버가 대신 호출하게 될 URL 이 내부망을 가리키지 않는지 검증하는 Custom Rule.
|
||||
*
|
||||
* 외부 API 엔드포인트·스케줄 URL 처럼 저장된 값이 나중에 서버의 outbound 요청 목적지가
|
||||
* 되는 입력에 적용한다. 사설/루프백/링크로컬 IP 와 내부 도메인을 입력 시점에 차단해,
|
||||
* 서버가 내부망 정찰 도구로 쓰이는 것을 막는다(SSRF).
|
||||
*
|
||||
* 사내 서버를 호출하는 것이 정당한 용도(외부 API 연동, 스케줄 URL 호출)에 한해, 관리자
|
||||
* 환경설정의 `security.allow_internal_outbound_urls` 로 내부 주소를 허용할 수 있다.
|
||||
* 원격 코드(언어팩 ZIP 등)를 내려받는 지점은 `allowInternalOptIn: false` 로 두어 이
|
||||
* 설정과 무관하게 항상 차단한다.
|
||||
*/
|
||||
class PublicOutboundUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $schemes 허용 scheme (기본 http/https — 사내 API 는 http 인 경우가 있다)
|
||||
* @param bool $allowInternalOptIn 관리자 설정으로 내부 주소를 허용할 수 있는 지점인지 (기본 true)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $schemes = ['http', 'https'],
|
||||
private readonly bool $allowInternalOptIn = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 값이 공개 인터넷 URL 인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.outbound_url.invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = ['schemes' => $this->schemes];
|
||||
|
||||
// 내부 주소 허용이 켜져 있으면 구조 검증(scheme/userinfo)만 하고 내부망 차단은 건너뛴다
|
||||
if ($this->allowInternalOptIn && (bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
||||
if (! OutboundUrlValidator::isStructurallySafeUrl($value, $options)) {
|
||||
$fail(__('validation.outbound_url.invalid'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! OutboundUrlValidator::isPublicHttpUrl($value, $options)) {
|
||||
$fail(__('validation.outbound_url.internal_not_allowed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Aws\S3\Exception\S3Exception;
|
||||
use Aws\S3\S3Client;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Predis\Client;
|
||||
|
||||
/**
|
||||
* 드라이버 연결 테스트 서비스
|
||||
@@ -89,7 +94,7 @@ class DriverConnectionTester
|
||||
}
|
||||
|
||||
// AWS SDK가 설치되어 있는지 확인
|
||||
if (! class_exists(\Aws\S3\S3Client::class)) {
|
||||
if (! class_exists(S3Client::class)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.s3_sdk_missing'),
|
||||
@@ -98,7 +103,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$client = new \Aws\S3\S3Client([
|
||||
$client = new S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $region,
|
||||
'credentials' => [
|
||||
@@ -121,7 +126,7 @@ class DriverConnectionTester
|
||||
'message' => __('settings.s3_test_success'),
|
||||
'latency' => $latency.'ms',
|
||||
];
|
||||
} catch (\Aws\S3\Exception\S3Exception $e) {
|
||||
} catch (S3Exception $e) {
|
||||
$errorMessage = match ($e->getAwsErrorCode()) {
|
||||
'NoSuchBucket' => __('settings.s3_bucket_not_found'),
|
||||
'AccessDenied' => __('settings.s3_access_denied'),
|
||||
@@ -165,7 +170,7 @@ class DriverConnectionTester
|
||||
// PHP Redis 확장 확인
|
||||
if (! extension_loaded('redis')) {
|
||||
// Predis 사용 시도
|
||||
if (! class_exists(\Predis\Client::class)) {
|
||||
if (! class_exists(Client::class)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.redis_extension_missing'),
|
||||
@@ -177,7 +182,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$redis = new \Redis();
|
||||
$redis = new \Redis;
|
||||
$connected = @$redis->connect($host, $port, 3.0);
|
||||
|
||||
if (! $connected) {
|
||||
@@ -259,7 +264,7 @@ class DriverConnectionTester
|
||||
$options['password'] = $password;
|
||||
}
|
||||
|
||||
$client = new \Predis\Client($options);
|
||||
$client = new Client($options);
|
||||
$pong = $client->ping();
|
||||
|
||||
if ($pong->getPayload() !== 'PONG') {
|
||||
@@ -307,7 +312,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$memcached = new \Memcached();
|
||||
$memcached = new \Memcached;
|
||||
$memcached->setOption(\Memcached::OPT_CONNECT_TIMEOUT, 3000);
|
||||
$memcached->addServer($host, $port);
|
||||
|
||||
@@ -376,6 +381,17 @@ class DriverConnectionTester
|
||||
// Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
|
||||
$url = sprintf('%s://%s:%d', $scheme, $host, $port);
|
||||
|
||||
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
|
||||
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
|
||||
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
|
||||
if (! OutboundUrlValidator::isStructurallySafeUrl($url, ['schemes' => ['http', 'https']])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.websocket_test_failed'),
|
||||
'error' => __('settings.websocket_invalid_host'),
|
||||
];
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// HTTP 요청으로 서버 응답 확인
|
||||
@@ -402,7 +418,7 @@ class DriverConnectionTester
|
||||
'message' => __('settings.websocket_test_failed'),
|
||||
'error' => "HTTP {$response->status()}",
|
||||
];
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
} catch (ConnectionException $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.websocket_connection_refused'),
|
||||
|
||||
@@ -166,6 +166,7 @@ class ExtensionBundleService
|
||||
public function buildCssBundle(string $type): string
|
||||
{
|
||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||
$isProduction = app()->environment('production');
|
||||
$segments = [];
|
||||
|
||||
foreach ($ordered as $identifier => $paths) {
|
||||
@@ -197,7 +198,7 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $content;
|
||||
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
||||
'type' => $type,
|
||||
@@ -417,6 +418,29 @@ class ExtensionBundleService
|
||||
}, $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 소스맵 주석을 환경에 맞게 처리합니다.
|
||||
*
|
||||
* CSS 는 JS 와 주석 문법이 달라 소스맵 참조를 블록 주석으로 표기하므로
|
||||
* processJsSourceMap() 의 `//#` 패턴으로는 검출되지 않는다.
|
||||
*
|
||||
* prod: 주석 strip(맵 참조 제거). dev: 원본 유지.
|
||||
* 병합 번들에서는 개별 맵 URL 이 어차피 어긋나므로 dev rewrite 는 하지 않는다.
|
||||
*
|
||||
* @param string $content 원본 CSS 내용
|
||||
* @param bool $isProduction 프로덕션 여부
|
||||
* @return string 처리된 내용
|
||||
*/
|
||||
private function processCssSourceMap(string $content, bool $isProduction): string
|
||||
{
|
||||
if (! $isProduction) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// 구분자로 `~` 사용 — 패턴에 `/`, `#` 가 포함됨
|
||||
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Exceptions\LanguagePackOperationException;
|
||||
use App\Exceptions\LanguagePackSlotConflictException;
|
||||
use App\Extension\Helpers\ExtensionBackupHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
@@ -16,6 +17,7 @@ use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackBaseLocales;
|
||||
use App\Services\LanguagePack\LanguagePackManifestValidator;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
@@ -25,7 +27,6 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Exceptions\LanguagePackOperationException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
@@ -340,7 +341,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $pack 가상 행
|
||||
* @param array<string, mixed> $filters 필터
|
||||
* @return bool
|
||||
*/
|
||||
private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool
|
||||
{
|
||||
@@ -600,7 +600,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
throw $e;
|
||||
}
|
||||
@@ -632,7 +632,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
@@ -653,6 +653,11 @@ class LanguagePackService
|
||||
*/
|
||||
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
|
||||
{
|
||||
// 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다.
|
||||
if (! OutboundUrlValidator::isPublicHttpUrl($url)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.download_url_not_public');
|
||||
}
|
||||
|
||||
$this->assertInstallDirectoriesWritable();
|
||||
|
||||
$tempId = (string) Str::uuid();
|
||||
@@ -679,7 +684,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
@@ -718,7 +723,7 @@ class LanguagePackService
|
||||
File::copyDirectory($bundledPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
throw $e;
|
||||
}
|
||||
@@ -901,6 +906,7 @@ class LanguagePackService
|
||||
$pack = $this->repository->findById((int) $id);
|
||||
if (! $pack) {
|
||||
$failed[] = ['id' => $id, 'reason' => 'not_found'];
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -955,7 +961,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $pack 대상 언어팩
|
||||
* @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거
|
||||
* @return void
|
||||
*/
|
||||
public function uninstall(LanguagePack $pack, bool $cascade = false): void
|
||||
{
|
||||
@@ -1000,7 +1005,6 @@ class LanguagePackService
|
||||
* 슬롯 비활성화 후 다음 후보를 active 로 승격합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 방금 비활성화된 언어팩
|
||||
* @return void
|
||||
*/
|
||||
private function promoteSlotSuccessor(LanguagePack $pack): void
|
||||
{
|
||||
@@ -1056,7 +1060,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param string $packageRoot 패키지 루트 디렉토리
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 보안 위반 발견 시
|
||||
*/
|
||||
@@ -1103,7 +1106,6 @@ class LanguagePackService
|
||||
* 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가
|
||||
* 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 디렉토리 미존재/쓰기 불가 시
|
||||
*/
|
||||
@@ -1126,7 +1128,6 @@ class LanguagePackService
|
||||
* 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인.
|
||||
*
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 의존성 미충족 시
|
||||
*/
|
||||
@@ -1146,7 +1147,6 @@ class LanguagePackService
|
||||
* 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다.
|
||||
*
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 대상 확장 미설치/비활성 시
|
||||
*/
|
||||
@@ -1256,7 +1256,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $existing 기존 언어팩
|
||||
* @param array<string, mixed> $manifest 새 manifest
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 다운그레이드 시도 시
|
||||
*/
|
||||
@@ -1301,7 +1300,6 @@ class LanguagePackService
|
||||
* _pending 임시 디렉토리를 정리합니다.
|
||||
*
|
||||
* @param string $path 정리 대상 경로
|
||||
* @return void
|
||||
*/
|
||||
private function cleanupPending(string $path): void
|
||||
{
|
||||
@@ -1309,7 +1307,7 @@ class LanguagePackService
|
||||
if (File::isDirectory($path)) {
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -1421,19 +1419,33 @@ class LanguagePackService
|
||||
$manifest = $pack->manifest;
|
||||
if (is_array($manifest)) {
|
||||
$manifestUrl = $manifest['github_url'] ?? null;
|
||||
if (is_string($manifestUrl) && Str::startsWith($manifestUrl, 'https://github.com/')) {
|
||||
if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) {
|
||||
return $manifestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$sourceUrl = (string) $pack->source_url;
|
||||
if (Str::startsWith($sourceUrl, 'https://github.com/')) {
|
||||
if (self::isGithubUrl($sourceUrl)) {
|
||||
return $sourceUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 이 실제 GitHub host 를 가리키는지 판정합니다.
|
||||
*
|
||||
* 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로
|
||||
* host 를 완전 일치로 검증합니다.
|
||||
*
|
||||
* @param string $url 판정 대상 URL
|
||||
* @return bool GitHub URL 이면 true
|
||||
*/
|
||||
private static function isGithubUrl(string $url): bool
|
||||
{
|
||||
return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
|
||||
*
|
||||
|
||||
@@ -1043,7 +1043,6 @@ class ModuleService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -708,7 +708,6 @@ class PluginService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -10,8 +10,9 @@ use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduleHistory;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
class ScheduleService
|
||||
{
|
||||
@@ -205,10 +207,13 @@ class ScheduleService
|
||||
'output' => $result['output'] ?? null,
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Success;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Success,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
// after_run 훅 실행
|
||||
HookManager::doAction('core.schedule.after_run', $schedule, $history);
|
||||
@@ -224,10 +229,13 @@ class ScheduleService
|
||||
'error_output' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Failed;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Failed,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
Log::error('Schedule execution failed', [
|
||||
'schedule_id' => $schedule->id,
|
||||
@@ -245,12 +253,18 @@ class ScheduleService
|
||||
*
|
||||
* @param Schedule $schedule 스케줄
|
||||
* @return array 실행 결과
|
||||
*
|
||||
* @throws Exception 차단목록에 걸린 명령일 때
|
||||
*/
|
||||
private function executeArtisanCommand(Schedule $schedule): array
|
||||
{
|
||||
$output = '';
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.artisan_not_allowed'));
|
||||
}
|
||||
|
||||
Artisan::call($schedule->command, [], new \Symfony\Component\Console\Output\BufferedOutput);
|
||||
Artisan::call($schedule->command, [], new BufferedOutput);
|
||||
|
||||
return [
|
||||
'output' => Artisan::output(),
|
||||
@@ -268,9 +282,19 @@ class ScheduleService
|
||||
*/
|
||||
private function executeShellCommand(Schedule $schedule): array
|
||||
{
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
|
||||
|
||||
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.shell_not_allowed'));
|
||||
}
|
||||
|
||||
$timeout = $schedule->timeout ?? 60;
|
||||
|
||||
$result = Process::timeout($timeout)->run($schedule->command);
|
||||
// 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 —
|
||||
// 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다.
|
||||
$result = Process::timeout($timeout)->run($arguments);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode());
|
||||
@@ -292,6 +316,12 @@ class ScheduleService
|
||||
*/
|
||||
private function executeUrlCall(Schedule $schedule): array
|
||||
{
|
||||
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
|
||||
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
|
||||
if (! $this->isUrlCallAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.url_not_public'));
|
||||
}
|
||||
|
||||
$timeout = $schedule->timeout ?? 30;
|
||||
|
||||
$response = Http::timeout($timeout)->get($schedule->command);
|
||||
@@ -306,6 +336,27 @@ class ScheduleService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다.
|
||||
*
|
||||
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는
|
||||
* 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다.
|
||||
* 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
|
||||
*
|
||||
* @param string $url 스케줄에 저장된 호출 URL
|
||||
* @return bool 호출을 허용하면 true
|
||||
*/
|
||||
private function isUrlCallAllowed(string $url): bool
|
||||
{
|
||||
$options = ['schemes' => ['http', 'https']];
|
||||
|
||||
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
||||
return OutboundUrlValidator::isStructurallySafeUrl($url, $options);
|
||||
}
|
||||
|
||||
return OutboundUrlValidator::isPublicHttpUrl($url, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄을 복제합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 서버가 외부로 나가는(outbound) HTTP 요청의 목적지 URL 을 검증하는 순수 유틸.
|
||||
*
|
||||
* 사용자·관리자·외부 콜백이 제어할 수 있는 값이 그대로 목적지가 되면 서버는
|
||||
* 내부망(사설 IP, 루프백, 클라우드 메타데이터 169.254.169.254)으로 요청을 대신
|
||||
* 보내는 도구가 된다(SSRF). 본 유틸은 두 계층의 판정을 제공한다:
|
||||
*
|
||||
* 1. `isHostAllowed()` — 신뢰 도메인 목록이 정해진 경우(결제/본인인증 게이트웨이 등).
|
||||
* host 를 **완전 일치**로 검증한다. 접두사 매칭(`str_starts_with`)은 금지 —
|
||||
* `https://trusted.example@127.0.0.1/`(userinfo)와
|
||||
* `https://trusted.example.attacker.com/`(접미사 확장)이 모두 통과하기 때문이다.
|
||||
*
|
||||
* 2. `isPublicHttpUrl()` — 신뢰 목록이 없고 "임의의 공개 URL 은 허용하되 내부망
|
||||
* 타격만 막는" 경우(외부 API 연동, 원격 다운로드 등).
|
||||
*
|
||||
* 두 메서드 모두 예외를 던지지 않고 bool 만 반환한다. 차단 시 어떤 예외/응답을
|
||||
* 낼지는 각 호출 지점이 자신의 반환 계약에 맞춰 결정한다.
|
||||
*
|
||||
* 방어 범위: **host 레벨**이다. DNS rebinding(검증 시점과 실제 연결 시점 사이에
|
||||
* 같은 호스트명이 내부 IP 로 재해석되는 공격)은 막지 못한다. 이를 막으려면 요청
|
||||
* 직전에 실제 해석된 IP 를 재검증해야 하며, 이는 본 유틸의 범위 밖이다.
|
||||
*/
|
||||
class OutboundUrlValidator
|
||||
{
|
||||
/** 공개 인터넷 호스트로 취급하지 않는 내부 도메인 접미사 */
|
||||
private const INTERNAL_HOST_SUFFIXES = [
|
||||
'.local',
|
||||
'.localhost',
|
||||
'.internal',
|
||||
'.intranet',
|
||||
'.lan',
|
||||
'.home.arpa',
|
||||
];
|
||||
|
||||
/** 그 자체로 내부를 가리키는 호스트명 */
|
||||
private const INTERNAL_HOST_NAMES = [
|
||||
'localhost',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
];
|
||||
|
||||
/**
|
||||
* URL 의 host 가 화이트리스트와 완전 일치하는지 검증한다.
|
||||
*
|
||||
* scheme 허용 목록·userinfo 부재·포트 표기까지 함께 강제하므로, 호출부는
|
||||
* 이 메서드 하나만 통과시키면 목적지 위조를 막을 수 있다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL (외부 입력)
|
||||
* @param array<int, string> $allowedHosts 허용 host 목록 (예: ['kssa.inicis.com'])
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
|
||||
* schemes: 허용 scheme (기본 ['https'])
|
||||
* allowPort: 명시 포트 허용 여부 (기본 false)
|
||||
* @return bool 화이트리스트 host 와 완전 일치하면 true
|
||||
*/
|
||||
public static function isHostAllowed(string $url, array $allowedHosts, array $options = []): bool
|
||||
{
|
||||
$host = self::extractSafeHost($url, $options);
|
||||
|
||||
if ($host === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($allowedHosts as $allowed) {
|
||||
if ($host === strtolower(trim($allowed))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 의 host 가 공개 인터넷 주소인지(= 내부망이 아닌지) 검증한다.
|
||||
*
|
||||
* 사설 IP(10/172.16/192.168), 루프백(127.0.0.1, ::1), 링크로컬 및 클라우드
|
||||
* 메타데이터(169.254.169.254), `localhost`·`*.local` 등 내부 도메인을 차단한다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL (외부 입력)
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
|
||||
* schemes: 허용 scheme (기본 ['https'])
|
||||
* allowPort: 명시 포트 허용 여부 (기본 true — 외부 API 는 비표준 포트를 쓸 수 있다)
|
||||
* @return bool 공개 인터넷 host 이면 true
|
||||
*/
|
||||
public static function isPublicHttpUrl(string $url, array $options = []): bool
|
||||
{
|
||||
$host = self::extractSafeHost($url, $options + ['allowPort' => true]);
|
||||
|
||||
if ($host === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::isPublicHost($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 이 구조적으로 안전한지만 판정한다 (내부망 여부는 보지 않는다).
|
||||
*
|
||||
* 내부 주소 호출을 의도적으로 허용한 환경에서도 userinfo(`@`) 위장·미허용 scheme·
|
||||
* 제어문자 주입은 여전히 막아야 하므로, 그 최소 방어선을 제공한다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
|
||||
* @return bool 구조적으로 안전하면 true
|
||||
*/
|
||||
public static function isStructurallySafeUrl(string $url, array $options = []): bool
|
||||
{
|
||||
return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다.
|
||||
*
|
||||
* @param string $host host 문자열 (예: 'example.com', '127.0.0.1', '[::1]')
|
||||
* @return bool 공개 인터넷 host 이면 true
|
||||
*/
|
||||
public static function isPublicHost(string $host): bool
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
|
||||
// parse_url 이 IPv6 를 대괄호째 반환하므로 벗겨낸다
|
||||
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
|
||||
$host = substr($host, 1, -1);
|
||||
}
|
||||
|
||||
if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IP 리터럴 — 사설/예약 대역 차단 (루프백·링크로컬·메타데이터 포함)
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return filter_var(
|
||||
$host,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
|
||||
// 10진수·8진수·16진수로 인코딩된 IP 우회(예: http://2130706433/ = 127.0.0.1) 차단.
|
||||
// 공개 도메인은 최상위 라벨이 숫자로만 이루어질 수 없다.
|
||||
$lastLabel = substr(strrchr('.'.$host, '.') ?: '', 1);
|
||||
if ($lastLabel === '' || ctype_digit($lastLabel) || str_starts_with($host, '0x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::INTERNAL_HOST_SUFFIXES as $suffix) {
|
||||
if (str_ends_with($host, $suffix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 을 파싱해 구조적으로 안전한 host 를 소문자로 반환한다.
|
||||
*
|
||||
* scheme 미허용, userinfo(`@`) 포함, 미허용 포트, host 부재이면 null 을 반환해
|
||||
* 상위 판정을 즉시 실패시킨다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
|
||||
* @return string|null 소문자 host, 구조적으로 안전하지 않으면 null
|
||||
*/
|
||||
private static function extractSafeHost(string $url, array $options): ?string
|
||||
{
|
||||
$schemes = $options['schemes'] ?? ['https'];
|
||||
$allowPort = $options['allowPort'] ?? false;
|
||||
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 개행·제어문자가 섞인 URL 은 헤더/요청 분리 시도로 간주하고 즉시 거부
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
|
||||
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// userinfo(`user:pass@host`) 는 host 위조의 핵심 벡터 — 존재만으로 거부
|
||||
if (isset($parts['user']) || isset($parts['pass'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
$normalizedSchemes = array_map(
|
||||
static fn (string $s): string => strtolower(trim($s)),
|
||||
$schemes,
|
||||
);
|
||||
|
||||
if (! in_array($scheme, $normalizedSchemes, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $allowPort && isset($parts['port'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = strtolower(trim($parts['host']));
|
||||
|
||||
return $host === '' ? null : $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
|
||||
*
|
||||
* 스케줄 command 는 저장된 문자열이 그대로 서버에서 실행되는 값이므로, 스케줄 생성 권한을
|
||||
* 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 통로가 될 수 있다(권한 상승형 RCE).
|
||||
* 본 유틸은 두 축의 판정을 제공한다:
|
||||
*
|
||||
* 1. Shell — 기본 차단. `config/schedule_security.php` 의 opt-in 게이트가 켜져 있고,
|
||||
* 셸 메타문자가 없으며, 첫 토큰의 basename 이 허용 실행 파일 목록과 완전 일치할 때만 허용.
|
||||
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
|
||||
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
|
||||
*
|
||||
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
|
||||
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
|
||||
*
|
||||
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
|
||||
*/
|
||||
class ScheduleCommandValidator
|
||||
{
|
||||
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
|
||||
private const SHELL_METACHARACTERS = [
|
||||
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
|
||||
'*', '?', '!', '\\', '"', "'", "\n", "\r", "\t",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shell 타입 command 를 실행해도 되는지 판정합니다.
|
||||
*
|
||||
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
|
||||
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isShellCommandAllowed(string $command): bool
|
||||
{
|
||||
if (! (bool) config('schedule_security.shell.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = array_values(array_filter(array_map(
|
||||
static fn ($binary): string => trim((string) $binary),
|
||||
(array) config('schedule_security.shell.allowed_binaries', []),
|
||||
)));
|
||||
|
||||
if ($allowed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = self::tokenize($command);
|
||||
|
||||
if ($tokens === null || $tokens === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(basename($tokens[0]), $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell command 를 `Process::run(array)` 에 넘길 안전한 인자 배열로 변환합니다.
|
||||
*
|
||||
* 메타문자가 섞여 있으면 셸 미경유 실행이 불가하므로 null 을 반환한다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return array<int, string>|null 인자 배열, 안전하게 토큰화할 수 없으면 null
|
||||
*/
|
||||
public static function tokenizeShellCommand(string $command): ?array
|
||||
{
|
||||
return self::tokenize($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return string|null 명령명, 빈 문자열이면 null
|
||||
*/
|
||||
public static function extractArtisanCommandName(string $command): ?string
|
||||
{
|
||||
$trimmed = trim($command);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $trimmed);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 가 차단목록에 걸리지 않는지 판정합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isArtisanCommandAllowed(string $command): bool
|
||||
{
|
||||
$name = self::extractArtisanCommandName($command);
|
||||
|
||||
if ($name === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$denylist = array_map(
|
||||
static fn ($denied): string => strtolower(trim((string) $denied)),
|
||||
(array) config('schedule_security.artisan.denylist', []),
|
||||
);
|
||||
|
||||
return ! in_array(strtolower($name), $denylist, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타문자 없는 공백 구분 토큰화. 메타문자가 있으면 null 을 반환합니다.
|
||||
*
|
||||
* 따옴표 escape 처리 버그를 원천 회피하기 위해, 따옴표를 포함한 복잡한 명령은
|
||||
* 지원하지 않고 거부하는 보수적 구현이다(복합 명령은 래퍼 스크립트로 유도).
|
||||
*
|
||||
* @param string $command command 문자열
|
||||
* @return array<int, string>|null 토큰 배열, 안전하지 않으면 null
|
||||
*/
|
||||
private static function tokenize(string $command): ?array
|
||||
{
|
||||
$command = trim($command);
|
||||
|
||||
if ($command === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 제어문자(개행 포함)가 섞인 명령은 즉시 거부
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::SHELL_METACHARACTERS as $meta) {
|
||||
if (str_contains($command, $meta)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $command);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.3'),
|
||||
'version' => env('APP_VERSION', '7.0.4'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
// 스케줄러 Shell/Artisan 실행 보안 정책.
|
||||
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
|
||||
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
|
||||
return [
|
||||
'shell' => [
|
||||
// Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단).
|
||||
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
|
||||
|
||||
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
|
||||
'allowed_binaries' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
|
||||
))),
|
||||
],
|
||||
|
||||
'artisan' => [
|
||||
// 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준).
|
||||
'denylist' => [
|
||||
'tinker',
|
||||
'db:wipe',
|
||||
'migrate:fresh',
|
||||
'migrate:reset',
|
||||
'migrate:rollback',
|
||||
'schedule:run',
|
||||
'schedule:work',
|
||||
'env:decrypt',
|
||||
'key:generate',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -24,7 +24,8 @@
|
||||
"login_lockout_time": 5,
|
||||
"two_factor_auth": false,
|
||||
"password_min_length": 8,
|
||||
"require_password_special_char": false
|
||||
"require_password_special_char": false,
|
||||
"allow_internal_outbound_urls": false
|
||||
},
|
||||
"mail": {
|
||||
"mailer": "smtp",
|
||||
|
||||
@@ -465,7 +465,7 @@ Content-Type: application/json
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| url | body | string | 예 | max 500 | URL |
|
||||
| url | body | string | 예 | max 500 | 언어팩 ZIP 다운로드 URL. `https` 만 허용하며, 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 서버가 대신 내려받는 요청이므로 내부망 접근을 막기 위함(SSRF). 원격 코드를 가져오는 경로라 `security.allow_internal_outbound_urls` 설정을 켜도 내부 주소는 계속 차단됩니다 |
|
||||
| checksum | body | string | 아니오 | — | 무결성 검증 체크섬 (SHA-256) |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ HTTP/1.1 200
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| description | body | string | 아니오 | max 1000 | 설명 |
|
||||
| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) |
|
||||
| command | body | string | 예 | max 2000 | 실행할 아티즌 커맨드 |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 |
|
||||
| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) |
|
||||
| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 |
|
||||
| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 |
|
||||
@@ -680,7 +680,7 @@ HTTP/1.1 200
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| description | body | string | 아니오 | max 1000 | 설명 |
|
||||
| type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) |
|
||||
| command | body | string | 예 | max 2000 | 실행할 아티즌 커맨드 |
|
||||
| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 |
|
||||
| expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) |
|
||||
| frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 |
|
||||
| without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 |
|
||||
|
||||
@@ -43,7 +43,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| general | object | `{"site_name":"Test Site","site_url":"https:\/\/test.examp…` | 일반 탭 설정 그룹 (사이트명·사이트 URL·설명·관리자 이메일·타임존·기본 언어·통화·점검 모드·사이트 로고 첨부). site_logo 는 SettingsService 가 별도 주입한 첨부 정보 |
|
||||
| security | object | `{"force_https":true,"login_attempt_enabled":true,"auth_to…` | 보안 탭 설정 그룹 (HTTPS 강제·로그인 시도 제한 사용·인증 토큰 유지시간(분, 0=무한)·최대 로그인 시도 횟수·잠금 시간) |
|
||||
| security | object | `{"force_https":true,"login_attempt_enabled":true,"auth_to…` | 보안 탭 설정 그룹 (HTTPS 강제·로그인 시도 제한 사용·인증 토큰 유지시간(분, 0=무한)·최대 로그인 시도 횟수·잠금 시간·내부 네트워크 주소 호출 허용). `allow_internal_outbound_urls`(boolean, 기본 false): 서버가 대신 호출하는 outbound 요청(예약 작업 URL 호출, 외부 API 연동)에서 사설 IP·`localhost` 등 내부 주소를 허용할지 여부 — 켜면 사내 서버 호출이 가능해지지만 서버가 내부망으로 요청을 보낼 수 있게 되므로 기본은 차단입니다. 언어팩 URL 설치는 원격 코드를 내려받으므로 이 설정과 무관하게 항상 내부 주소를 거부합니다 |
|
||||
| mail | object | `{"mailer":"smtp","host":"","port":587,"username":"","pass…` | 메일 탭 설정 그룹 (메일러 종류(smtp/mailgun/ses)·SMTP 호스트/포트/인증 정보·암호화 방식·발신자 주소/이름·Mailgun/SES 자격 정보) |
|
||||
| upload | object | `{"max_file_size":10,"allowed_extensions":["jpg","jpeg","p…` | 업로드 탭 설정 그룹 (최대 파일 크기(MB)·허용 확장자 목록·이미지 최대 가로/세로·이미지 품질) |
|
||||
| seo | object | `{"meta_title_suffix":"","meta_description":"","meta_keywo…` | SEO 탭 설정 그룹 (메타 타이틀 접미사·메타 설명/키워드·검색엔진 인증 코드·봇 감지·OG/Twitter 기본값·SEO 캐시·사이트맵·생성기 설정) |
|
||||
|
||||
@@ -30,6 +30,34 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용
|
||||
|
||||
이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다.
|
||||
|
||||
### 1.1 빌드가 보는 composer.json / composer.lock
|
||||
|
||||
확장의 번들 빌드는 산출물을 `_bundled` 에 쓰지만(활성 디렉토리 오염 방지), **입력으로 삼는 `composer.json` / `composer.lock` 도 `_bundled` 를 기준으로 한다**. 출력 디렉토리에 해당 파일이 없을 때만 활성 디렉토리로 폴백하며, 코어는 소스와 출력이 같은 경로(`base_path()`)이므로 언제나 동일한 파일을 본다. 이 규칙은 `VendorBundler::resolveHashTarget()` 한 곳에 있고, 빌드의 네 가지 판단이 모두 그것을 쓴다.
|
||||
|
||||
| 판단 | 사용처 |
|
||||
|------|------|
|
||||
| 외부 의존성 유무 (번들링 대상인가) | `build()`, `isStale()` |
|
||||
| `composer install` 입력 (스테이징에 복사할 파일) | `build()` |
|
||||
| manifest 해시 (`composer_{json,lock}_sha256`) | `build()` |
|
||||
| stale 판정 (재빌드 필요한가) | `isStale()` |
|
||||
|
||||
빌드는 활성 디렉토리의 `vendor/` 를 읽지 않는다. 스테이징 디렉토리에서 `composer install --no-dev` 를 새로 실행해 dev 의존성이 섞이지 않은 `vendor/` 를 만들고 그것을 압축한다.
|
||||
|
||||
네 판단이 같은 파일을 보아야 하는 이유는 두 가지다.
|
||||
|
||||
**첫째, 검증자의 대조 위치.** `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다.
|
||||
|
||||
| 경로 | verify 에 넘기는 디렉토리 |
|
||||
|------|--------------------------|
|
||||
| 신규 설치 | `_pending/{id}` (`_bundled` 복사본) |
|
||||
| 업데이트 | `_pending/{id}_updating_*` (`_bundled` 복사본) |
|
||||
| 코어 업데이트 | pending 경로 (소스 = 출력) |
|
||||
| `vendor-bundle:verify-all` | `_bundled/{id}` |
|
||||
|
||||
해시를 활성에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. `isStale()` 이 활성만 보면 `--check` 가 stale 을 `up-to-date` 로 오보하는 것도 같은 뿌리다.
|
||||
|
||||
**둘째, 개발자가 `_bundled` 에서만 작업한다는 규정.** 새 패키지를 추가하면 `_bundled` 의 `composer.json` / `composer.lock` 에만 반영되고 활성 디렉토리는 다음 `{module|plugin}:update` 전까지 구버전으로 남는다. 이때 `composer install` 의 입력만 활성에서 가져오면, **구버전 lock 으로 설치한 zip 에 신버전 해시를 붙인 manifest** 가 만들어진다. 해시는 정합하므로 무결성 검증은 통과하는데 정작 새 패키지가 번들에서 빠져, 설치 후 런타임에 클래스 not found 로 터진다. 설치 입력과 해시 기준이 같은 파일을 보면 이 어긋남 자체가 성립하지 않는다.
|
||||
|
||||
`vendor-bundle.json` 스키마 (v1.0):
|
||||
|
||||
```json
|
||||
@@ -212,11 +240,11 @@ Content-Type: application/json
|
||||
|
||||
### 7.1 SHA256 검증
|
||||
|
||||
`VendorIntegrityChecker::verify()` 가 다음을 검증합니다:
|
||||
`VendorIntegrityChecker::verify($sourceDir)` 가 다음을 검증합니다. `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리이며, 확장의 경우 `_bundled` 또는 그 복사본(`_pending`)입니다 (§1.1).
|
||||
|
||||
- vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값
|
||||
- composer.json SHA256 (소스에 존재 시)
|
||||
- composer.lock SHA256 (소스에 존재 시)
|
||||
- composer.json SHA256 (해당 디렉토리에 존재 시)
|
||||
- composer.lock SHA256 (해당 디렉토리에 존재 시)
|
||||
|
||||
검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다.
|
||||
|
||||
@@ -230,6 +258,9 @@ Content-Type: application/json
|
||||
## 8. 자동 트리거
|
||||
|
||||
`composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다.
|
||||
확장의 경우 판정 기준은 `_bundled` 디렉토리의 두 파일이다 (§1.1). 확장 버전을 올려
|
||||
`_bundled/composer.json` 의 `version` 만 바뀐 경우에도 stale 이 되므로 재빌드가 필요하다.
|
||||
|
||||
다음 명령으로 재빌드해야 합니다:
|
||||
|
||||
```bash
|
||||
@@ -269,6 +300,8 @@ CI 통합 예시:
|
||||
| `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 |
|
||||
| `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 |
|
||||
| `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 |
|
||||
| `bundle_build_promote_failed` | 빌드 산출물을 최종 경로로 원자적 교체 실패 | 디스크 공간/권한 확인 후 재빌드 (원본 번들은 promoteAtomic 으로 보존됨) |
|
||||
| 빌드가 `Generating optimized autoload files` 직후 무한 대기 (Windows) | composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유 | 코어 반영됨 — stdout/stderr 를 파이프가 아닌 파일 descriptor 로 실행 (`VendorBundler::composerOutputDescriptors`). 최신 코어에서는 재발하지 않음 |
|
||||
|
||||
## 11. 참고
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 내부 네트워크 주소 호출 차단 관련 안내 문구 일본어 번역 추가 (`validation.outbound_url.*`, `schedule.url_not_public`, `language_packs.errors.download_url_not_public`, `settings.websocket_invalid_host`) — 예약 작업·언어팩 설치·웹소켓 설정에서 내부 주소가 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 아직 공개하지 않은 예약 작업 기능의 쉘·Artisan 명령 실행 제한 안내 문구 일본어 번역 추가 (`schedule.shell_not_allowed`, `schedule.artisan_not_allowed`, `validation.schedule_command.*`) — 향후 정식 공개에 대비한 것으로, 허용되지 않은 쉘 명령이나 위험한 Artisan 명령이 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- vendor-bundle 빌드 결과 반영 실패 안내 메시지 일본어 번역 추가 (`exceptions.vendor.bundle_build_promote_failed`) — 빌드 산출물을 최종 경로로 원자적 교체하는 데 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,4 +27,9 @@ return [
|
||||
'contact_admin' => '問題が解決しない場合は、管理者にお問い合わせください。',
|
||||
],
|
||||
'back_home' => 'ホームに戻る',
|
||||
'bootstrap' => [
|
||||
'title' => '画面を読み込めませんでした',
|
||||
'message' => 'ネットワーク接続が不安定な可能性があります。更新後にもう一度お試しください。',
|
||||
'reload' => '更新',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -60,6 +60,7 @@ return [
|
||||
'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path',
|
||||
'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path',
|
||||
'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。',
|
||||
'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)',
|
||||
],
|
||||
'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
|
||||
];
|
||||
|
||||
@@ -43,6 +43,7 @@ return [
|
||||
'directory_not_writable' => '言語パックインストールディレクトリ(:path)への書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
|
||||
'identifier_not_found' => '言語パックが見つかりません (:identifier)。',
|
||||
'unsupported_source' => 'サポートされていないソース(:source) — bundled、github、urlのいずれかを使用してください。',
|
||||
'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)では言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
|
||||
],
|
||||
'validation' => [
|
||||
'file_required' => '言語パックZIPファイルをアップロードしてください。',
|
||||
|
||||
@@ -100,4 +100,7 @@ return [
|
||||
'delete' => 'スケジュール削除',
|
||||
'run' => 'スケジュール実行',
|
||||
],
|
||||
'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
|
||||
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。',
|
||||
'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。',
|
||||
];
|
||||
|
||||
@@ -153,4 +153,5 @@ return [
|
||||
'restore_success' => '設定が正常に復元されました。',
|
||||
'restore_failed' => '設定の復元に失敗しました。',
|
||||
'restore_error' => '設定の復元中にエラーが発生しました。',
|
||||
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)やhttp/https以外のプロトコルは使用できません。',
|
||||
];
|
||||
|
||||
@@ -836,6 +836,7 @@ return [
|
||||
'websocket_host_required' => 'WebSocketホストは必須です。',
|
||||
'websocket_port_required' => 'WebSocketポートは必須です。',
|
||||
'websocket_scheme_required' => 'WebSocketプロトコルを選択してください。',
|
||||
'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可は true または false 値である必要があります。',
|
||||
],
|
||||
'identity_policy' => [
|
||||
'key_required' => 'ポリシーキーを入力してください。',
|
||||
@@ -1014,4 +1015,12 @@ return [
|
||||
'exists' => '存在しない多言語キーが含まれています。',
|
||||
],
|
||||
],
|
||||
'outbound_url' => [
|
||||
'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。',
|
||||
'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
|
||||
],
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。',
|
||||
'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 core Japanese language pack (bundled)",
|
||||
"ja": "G7 コア 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 알림 설정의 "무통장 입금 안내"·"배송 완료" 알림 이름에 대한 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Removed
|
||||
|
||||
+3
-1
@@ -458,7 +458,9 @@
|
||||
"new_order_admin": "新規注文(管理者)",
|
||||
"inquiry_received": "商品問い合わせ受け取り(管理者)",
|
||||
"inquiry_replied": "商品問い合わせ回答",
|
||||
"mileage_expiring_soon": "マイレージ失効予定"
|
||||
"mileage_expiring_soon": "マイレージ失効予定",
|
||||
"order_pending_deposit": "銀行振込のご案内",
|
||||
"order_delivered": "配送完了"
|
||||
},
|
||||
"btn_search": "検索",
|
||||
"btn_reset_filter": "初期化",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 라이브 가맹점 MID 프리픽스 표기를 이니시스 정책 변경에 맞춰 SRB 로 갱신했습니다 (라이브 MID 라벨·입력 안내 문구).
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ return [
|
||||
],
|
||||
'settings' => [
|
||||
'test_mode' => 'テストモード',
|
||||
'live_mid' => 'ライブMID(SRAプレフィックス)',
|
||||
'live_mid' => 'ライブMID(SRBプレフィックス)',
|
||||
'live_api_key' => 'ライブAPIキー',
|
||||
'duplicate_field' => '同一人識別基準',
|
||||
'duplicate_block_enabled' => [
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"live_mode_warning_body": "ライブモードは実際の加盟店MIDで本人認証を実行します。ライブMIDとAPIキーを入力してから運用してください。",
|
||||
"section_live_keys": "ライブ資格情報",
|
||||
"live_mid": "ライブMID",
|
||||
"live_mid_hint": "イニシスライブ加盟店MIDはSRAで始まります (例:SRA1234567)。運用モード使用前に必須入力です。",
|
||||
"live_mid_hint": "イニシスライブ加盟店MIDはSRBで始まります (例:SRB1234567)。運用モード使用前に必須入力です。",
|
||||
"live_api_key": "ライブAPIキー",
|
||||
"section": {
|
||||
"duplicate_field": "同一人識別"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-verification_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-verification_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-verification_kginicis",
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 보안 화면의 "내부 네트워크 주소 호출 허용" 항목명·설명 일본어 번역 추가 — 새로 추가된 보안 설정이 일본어 로케일에서도 올바르게 표시됩니다.
|
||||
- 역할 관리 화면 새로고침 버튼의 일본어 번역 추가 — 일본어 로케일에서도 버튼 설명이 올바르게 표시됩니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1089,7 +1089,8 @@
|
||||
"errors": {
|
||||
"system_role_delete": "システムロールは削除できません。",
|
||||
"delete_failed": "ロール削除に失敗しました。"
|
||||
}
|
||||
},
|
||||
"refresh": "更新"
|
||||
},
|
||||
"schedules": {
|
||||
"page_title": "スケジュール管理",
|
||||
@@ -1445,7 +1446,9 @@
|
||||
"app_key_desc": "暗号化に使用されるアプリケーションキーを管理します。",
|
||||
"current_app_key": "現在のキー",
|
||||
"regenerate_key": "キー 再生成",
|
||||
"app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。"
|
||||
"app_key_warning": "キーを変更すると、既存の暗号化されたデータにアクセスできなくなる可能性があります。",
|
||||
"allow_internal_outbound_urls": "内部ネットワークアドレス呼び出し許可",
|
||||
"allow_internal_outbound_urls_desc": "外部API連携·スケジュールURL呼び出しなどで、プライベートIPやlocalhostなどの内部アドレスを使用できるようにします。社内サーバーを呼び出す必要がある場合のみ有効にしてください。有効にするとサーバーが内部ネットワークにリクエストを送信できるようになるため、デフォルトは無効です。"
|
||||
},
|
||||
"identity": {
|
||||
"sub_tabs": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-admin_basic",
|
||||
|
||||
@@ -38,4 +38,11 @@ return [
|
||||
'plugins' => 'Plugins',
|
||||
'contact_admin' => 'If the problem persists, please contact the administrator.',
|
||||
],
|
||||
|
||||
// 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 (코어 엔진 / 템플릿 컴포넌트 번들)
|
||||
'bootstrap' => [
|
||||
'title' => 'Failed to load the page',
|
||||
'message' => 'Your network connection may be unstable. Please refresh and try again.',
|
||||
'reload' => 'Refresh',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -57,6 +57,7 @@ return [
|
||||
'composer_not_available' => 'Composer cannot be executed in this environment. Use bundled mode instead.',
|
||||
'composer_not_available_for_build' => 'Building vendor-bundle requires Composer execution. Install/configure Composer in the development environment and retry.',
|
||||
'bundle_build_composer_failed' => 'composer install failed while building vendor-bundle (exit :exit): :message',
|
||||
'bundle_build_promote_failed' => 'failed to move vendor-bundle build result to its final path (:from → :to)',
|
||||
'composer_execution_failed' => 'Composer execution failed: :message',
|
||||
'bundle_zip_missing' => 'vendor-bundle.zip not found: :path',
|
||||
'bundle_manifest_missing' => 'vendor-bundle.json not found: :path',
|
||||
|
||||
@@ -34,6 +34,7 @@ return [
|
||||
'downgrade_blocked' => 'Downgrade blocked (:from → :to).',
|
||||
'protected_pack' => 'Protected language packs cannot be deactivated or removed.',
|
||||
'download_failed' => 'Failed to download from URL: :url',
|
||||
'download_url_not_public' => 'Language packs cannot be downloaded from internal network addresses (private IPs, localhost, etc.). Use a publicly reachable https address.',
|
||||
'checksum_mismatch' => 'Checksum mismatch.',
|
||||
'update_no_source' => 'No update source available (only GitHub-sourced packs can be updated).',
|
||||
'update_already_latest' => 'Already on the latest version.',
|
||||
|
||||
@@ -28,6 +28,9 @@ return [
|
||||
'copy' => 'Copy',
|
||||
'shell_command_failed' => 'Shell command execution failed.',
|
||||
'http_request_failed' => 'HTTP request failed with status: :status',
|
||||
'url_not_public' => 'Internal network addresses (private IPs, localhost, etc.) cannot be called. To call an address inside your network, enable "Allow internal network addresses" under Settings > Security.',
|
||||
'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server can be run as a schedule.',
|
||||
'artisan_not_allowed' => 'This Artisan command cannot be run as a schedule for security reasons.',
|
||||
|
||||
// Task types
|
||||
'type' => [
|
||||
|
||||
@@ -103,6 +103,7 @@ return [
|
||||
// Websocket test messages
|
||||
'websocket_test_success' => 'Successfully connected to Websocket server.',
|
||||
'websocket_test_failed' => 'Failed to connect to Websocket server.',
|
||||
'websocket_invalid_host' => 'The Websocket host setting is invalid. Enter the address only — credentials (@) and protocols other than http/https are not allowed.',
|
||||
'websocket_connection_refused' => 'Could not connect to Websocket server. Please check if the server is running.',
|
||||
|
||||
// Test mail related messages
|
||||
|
||||
@@ -504,6 +504,18 @@ return [
|
||||
'dangerous_scheme_detected' => 'Dangerous URI scheme detected: :scheme',
|
||||
],
|
||||
|
||||
// Outbound URL (external API, schedule, etc.) validation messages
|
||||
'outbound_url' => [
|
||||
'invalid' => 'This is not a valid URL. Enter an address starting with http or https.',
|
||||
'internal_not_allowed' => 'Internal network addresses (private IPs, localhost, etc.) are not allowed. Enter a publicly reachable address.',
|
||||
],
|
||||
|
||||
// Schedule command (shell/Artisan) validation messages — these run on the server
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server may be used, and special characters such as pipes (|) or semicolons (;) are not permitted.',
|
||||
'artisan_denied' => 'This Artisan command cannot be run as a schedule for security reasons.',
|
||||
],
|
||||
|
||||
// Component existence validation messages
|
||||
'component' => [
|
||||
'template_id_required' => 'template_id is required for component validation.',
|
||||
@@ -824,6 +836,7 @@ return [
|
||||
// Security settings
|
||||
'force_https_required' => 'Please select the Force HTTPS setting.',
|
||||
'force_https_boolean' => 'Force HTTPS must be true or false.',
|
||||
'allow_internal_outbound_urls_boolean' => 'Allow internal network address calls must be true or false.',
|
||||
'login_attempt_enabled_required' => 'Please select the login attempt limit setting.',
|
||||
'login_attempt_enabled_boolean' => 'Login attempt limit must be true or false.',
|
||||
'auth_token_lifetime_integer' => 'Auth token lifetime must be an integer.',
|
||||
|
||||
@@ -38,4 +38,11 @@ return [
|
||||
'plugins' => '플러그인',
|
||||
'contact_admin' => '문제가 지속되면 관리자에게 문의해 주세요.',
|
||||
],
|
||||
|
||||
// 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 (코어 엔진 / 템플릿 컴포넌트 번들)
|
||||
'bootstrap' => [
|
||||
'title' => '화면을 불러오지 못했습니다',
|
||||
'message' => '네트워크 연결이 불안정할 수 있습니다. 새로고침 후 다시 시도해 주세요.',
|
||||
'reload' => '새로고침',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -57,6 +57,7 @@ return [
|
||||
'composer_not_available' => 'Composer를 실행할 수 없는 환경입니다. 번들 모드를 사용하세요.',
|
||||
'composer_not_available_for_build' => 'vendor-bundle 빌드는 Composer 실행이 필요합니다. 개발 환경에서 Composer를 설치/설정한 후 다시 시도하세요.',
|
||||
'bundle_build_composer_failed' => 'vendor-bundle 빌드 중 composer install 실행 실패 (exit :exit): :message',
|
||||
'bundle_build_promote_failed' => 'vendor-bundle 빌드 결과를 최종 경로로 반영하지 못했습니다 (:from → :to)',
|
||||
'composer_execution_failed' => 'Composer 실행에 실패했습니다: :message',
|
||||
'bundle_zip_missing' => 'vendor-bundle.zip 파일을 찾을 수 없습니다: :path',
|
||||
'bundle_manifest_missing' => 'vendor-bundle.json 파일을 찾을 수 없습니다: :path',
|
||||
|
||||
@@ -34,6 +34,7 @@ return [
|
||||
'downgrade_blocked' => '다운그레이드가 차단되었습니다 (:from → :to).',
|
||||
'protected_pack' => '보호된 언어팩은 비활성화/제거할 수 없습니다.',
|
||||
'download_failed' => 'URL(:url) 에서 언어팩을 다운로드하지 못했습니다: :error',
|
||||
'download_url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)에서는 언어팩을 내려받을 수 없습니다. 외부에서 접속 가능한 https 주소를 사용해주세요.',
|
||||
'checksum_mismatch' => '체크섬이 일치하지 않습니다.',
|
||||
'update_no_source' => '업데이트 소스 정보가 없습니다 (GitHub 소스 언어팩만 업데이트 가능).',
|
||||
'update_already_latest' => '이미 최신 버전입니다.',
|
||||
|
||||
@@ -28,6 +28,9 @@ return [
|
||||
'copy' => '복사본',
|
||||
'shell_command_failed' => '쉘 명령 실행에 실패했습니다.',
|
||||
'http_request_failed' => 'HTTP 요청 실패 (상태: :status)',
|
||||
'url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)는 호출할 수 없습니다. 사내 주소를 호출해야 한다면 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜주세요.',
|
||||
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 스케줄로 실행할 수 있습니다.',
|
||||
'artisan_not_allowed' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
|
||||
|
||||
// 작업 유형
|
||||
'type' => [
|
||||
|
||||
@@ -103,6 +103,7 @@ return [
|
||||
// Websocket 테스트 메시지
|
||||
'websocket_test_success' => 'Websocket 서버에 성공적으로 연결되었습니다.',
|
||||
'websocket_test_failed' => 'Websocket 서버 연결에 실패했습니다.',
|
||||
'websocket_invalid_host' => 'Websocket 호스트 설정이 올바르지 않습니다. 호스트에는 주소만 입력하고 계정 정보(@)나 http/https 이외의 프로토콜은 사용할 수 없습니다.',
|
||||
'websocket_connection_refused' => 'Websocket 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
|
||||
|
||||
// 테스트 메일 관련 메시지
|
||||
|
||||
@@ -503,6 +503,18 @@ return [
|
||||
'dangerous_scheme_detected' => '위험한 URI 스킴이 감지되었습니다: :scheme',
|
||||
],
|
||||
|
||||
// 서버가 대신 호출하는 URL(외부 API·스케줄 등) 검증 메시지
|
||||
'outbound_url' => [
|
||||
'invalid' => '올바른 형식의 URL이 아닙니다. http 또는 https 로 시작하는 주소를 입력해 주세요.',
|
||||
'internal_not_allowed' => '내부 네트워크 주소(사설 IP·localhost 등)는 사용할 수 없습니다. 외부에서 접속 가능한 주소를 입력해 주세요.',
|
||||
],
|
||||
|
||||
// 서버에서 실행되는 스케줄 command(쉘·Artisan) 검증 메시지
|
||||
'schedule_command' => [
|
||||
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.',
|
||||
'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
|
||||
],
|
||||
|
||||
// 컴포넌트 존재 여부 검증 메시지
|
||||
'component' => [
|
||||
'template_id_required' => '컴포넌트 검증을 위해서는 template_id가 필요합니다.',
|
||||
@@ -897,6 +909,7 @@ return [
|
||||
// 보안 설정
|
||||
'force_https_required' => 'HTTPS 강제 적용 설정을 선택해주세요.',
|
||||
'force_https_boolean' => 'HTTPS 강제 적용은 true 또는 false 값이어야 합니다.',
|
||||
'allow_internal_outbound_urls_boolean' => '내부 네트워크 주소 호출 허용은 true 또는 false 값이어야 합니다.',
|
||||
'login_attempt_enabled_required' => '로그인 시도 제한 설정을 선택해주세요.',
|
||||
'login_attempt_enabled_boolean' => '로그인 시도 제한은 true 또는 false 값이어야 합니다.',
|
||||
'auth_token_lifetime_integer' => '인증 토큰 유지시간은 정수여야 합니다.',
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- 게시판 설정 화면의 상단 고정 영역이 좌우 여백과 어긋나 내용이 가로로 밀려 보일 수 있던 문제를 수정했습니다.
|
||||
|
||||
## [1.0.1] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/boards",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'basic_info'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file admin-board-settings-tab-scoped-datasources.test.tsx
|
||||
* @description 게시판 설정 데이터소스 탭 스코프 게이팅 회귀 가드
|
||||
*
|
||||
* 이커머스 환경설정과 동일 결함: 진입 시 전 탭의 데이터소스를 한꺼번에 fetch 했다.
|
||||
* 탭 본문은 `if` 로 게이트되어 비활성 탭은 렌더되지 않으므로 그 데이터는 아무도 읽지 않는다.
|
||||
* 엔진(DataSourceManager.filterByCondition + TemplateApp.updateQueryParams)이
|
||||
* 데이터소스 `if` 로 탭별 로딩을 지원하므로, 탭 전용 소스는 `if` 게이트를 가져야 한다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const layout = require('../../../layouts/admin/admin_board_settings.json');
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
const SHARED_SOURCE_IDS = ['settings'];
|
||||
|
||||
const TAB_SCOPED: Record<string, string[]> = {
|
||||
boards_list: ['basic_defaults'],
|
||||
roles: ['basic_defaults', 'report_policy'],
|
||||
board_types: ['basic_defaults'],
|
||||
boardNotificationDefinitions: ['notification_definitions', 'report_policy'],
|
||||
availableChannels: ['notification_definitions'],
|
||||
boardIdentityPolicies: ['identity_policies'],
|
||||
boardIdentityPurposes: ['identity_policies'],
|
||||
identityProviders: ['identity_policies'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
describe('admin_board_settings — 데이터소스 탭 스코프 게이팅', () => {
|
||||
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
|
||||
for (const id of SHARED_SOURCE_IDS) {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.if).toBeUndefined();
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s!.if).toBe('string');
|
||||
});
|
||||
|
||||
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
|
||||
const cond: string = byId(id)!.if;
|
||||
for (const tab of tabs as string[]) {
|
||||
expect(cond).toContain(`'${tab}'`);
|
||||
}
|
||||
expect(cond).toContain('query.tab');
|
||||
expect(cond).toContain('activeBoardSettingsTab');
|
||||
});
|
||||
|
||||
it('탭 전용 소스를 auto_fetch:false 로 죽여두지 않는다', () => {
|
||||
for (const id of Object.keys(TAB_SCOPED)) {
|
||||
expect(byId(id)!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('탭 전환은 navigate + replace:true 로 수행한다 (if 재평가 필요)', () => {
|
||||
const seqs: AnyJson[] = [];
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { n.forEach(walk); return; }
|
||||
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
|
||||
Object.values(n).forEach(walk);
|
||||
};
|
||||
walk(layout);
|
||||
|
||||
const tabSwitches = seqs.filter(s =>
|
||||
(s.params.actions as AnyJson[]).some(
|
||||
a => a?.handler === 'setState' && 'activeBoardSettingsTab' in (a.params ?? {})
|
||||
)
|
||||
);
|
||||
expect(tabSwitches.length).toBeGreaterThan(0);
|
||||
|
||||
for (const seq of tabSwitches) {
|
||||
const acts = seq.params.actions as AnyJson[];
|
||||
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
|
||||
const nav = acts.find(a => a.handler === 'navigate');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav!.params.replace).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -304,8 +304,11 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
|
||||
expect(innerActions[1].handler).toBe('setState');
|
||||
expect(innerActions[1].params.target).toBe('global');
|
||||
|
||||
// replaceUrl - URL 업데이트
|
||||
expect(innerActions[2].handler).toBe('replaceUrl');
|
||||
// navigate(replace:true) - URL 업데이트 + 데이터소스 `if` 재평가
|
||||
// replaceUrl 은 history 만 교체하므로 탭 전용 데이터소스가 로드되지 않는다.
|
||||
// 가드: admin-board-settings-tab-scoped-datasources.test.tsx
|
||||
expect(innerActions[2].handler).toBe('navigate');
|
||||
expect(innerActions[2].params.replace).toBe(true);
|
||||
expect(innerActions[2].params.query.tab).toBeDefined();
|
||||
|
||||
// scrollIntoView - 탭 변경 후 본문 상단으로 스크롤 (#369)
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/boards?per_page=999&fields=id,title",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -94,6 +95,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/roles/active",
|
||||
"method": "GET",
|
||||
"if": "{{['basic_defaults', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -106,6 +108,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/board-types",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -118,6 +121,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-board",
|
||||
"method": "GET",
|
||||
"if": "{{['notification_definitions', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -145,6 +149,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-channels",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'notification_definitions'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -159,6 +164,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/policies",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -182,6 +188,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/identity/purposes",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -194,6 +201,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/providers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -243,7 +251,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "sticky-tab-nav-responsive"
|
||||
"className": "sticky-section-header"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -339,11 +347,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "replaceUrl",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/settings",
|
||||
"query": {
|
||||
"tab": "{{$args[0]}}"
|
||||
}
|
||||
},
|
||||
"replace": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
- 배송정책의 배송비 계산 API 주소로 사내 서버나 클라우드 관리 주소 같은 내부 주소를 지정할 수 없도록 차단했습니다. 이 주소는 고객이 주문·장바구니에서 배송비를 확인할 때마다 쇼핑몰 서버가 대신 호출하므로, 내부 주소가 지정되면 외부에서 볼 수 없어야 할 내부 정보가 노출될 수 있었습니다. 배송정책 저장 화면과 API 테스트 호출 모두에 적용되며, 이미 저장되어 있던 주소도 실제 호출 직전에 다시 확인합니다. 사내 배송비 계산 서버를 쓰는 경우에는 코어 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜면 됩니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 쇼핑몰 환경설정 > 알림 설정에서 "무통장 입금 안내"와 "배송 완료" 두 알림의 이름이 사람이 읽을 수 없는 내부 문자열로 표시되던 문제를 수정했습니다. 이제 다른 알림과 동일하게 이름이 표시되며, 알림을 기본값으로 되돌릴 때 뜨는 확인창과 알림 편집 화면에서도 함께 바로잡혔습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ecommerce",
|
||||
"version": "1.0.0-beta.5",
|
||||
"version": "1.0.3",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "Ecommerce module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -372,7 +372,7 @@ Content-Type: application/json
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL |
|
||||
| endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL. 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 이 주소는 쇼핑몰 서버가 대신 호출하므로 내부망 접근을 막기 위함(SSRF). 사내 배송비 계산 서버를 쓰려면 코어 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요 |
|
||||
| request_fields | body | array | 아니오 | — | 요청에 실어 보낼 필드명 목록 (후보 SSoT ShippingApiRequestField 5종) |
|
||||
| config | body | array | 아니오 | — | API 호출 고급 설정 (HTTP 메서드·인증방식·필드 매핑·응답 형식/경로 등) |
|
||||
| sample | body | array | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file admin-ecommerce-settings-tab-scoped-datasources.test.tsx
|
||||
* @description 환경설정 데이터소스 탭 스코프 게이팅 회귀 가드
|
||||
*
|
||||
* 배경: 환경설정 진입 시 모든 탭의 데이터소스를 한꺼번에 fetch 했다(18건). 탭 본문은
|
||||
* `if` 로 게이트되어 비활성 탭은 렌더조차 되지 않으므로, 그 데이터는 아무도 읽지 않는
|
||||
* 순수 낭비다. 게다가 탭 전환(navigate replace:true → updateQueryParams)마다 전수 재fetch 되어
|
||||
* 요청이 2배(36건)로 불어나고 탭 클릭이 1.7초 blocking 됐다.
|
||||
*
|
||||
* 엔진의 기본 메커니즘: 데이터소스의 `if` 를 DataSourceManager.filterByCondition 이 평가해
|
||||
* fetch 대상에서 제외하고, TemplateApp.updateQueryParams 가 탭 전환 시 `if` 를 재평가해
|
||||
* 새 탭의 소스만 fetch 한다. 따라서 탭 전용 소스는 반드시 `if` 게이트를 가져야 하며,
|
||||
* 탭 전환은 navigate + replace:true 로 updateQueryParams 를 태워야 한다(replaceUrl 은
|
||||
* history 만 교체하므로 `if` 재평가가 일어나지 않음).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const layout = require('../../../layouts/admin/admin_ecommerce_settings.json');
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
/** 모든 탭이 공유하는 소스 (폼 본체) — 항상 fetch */
|
||||
const SHARED_SOURCE_IDS = ['settings'];
|
||||
|
||||
/** 탭 전용 소스 → 소유 탭 */
|
||||
const TAB_SCOPED: Record<string, string[]> = {
|
||||
seoStats: ['seo'],
|
||||
carriers: ['shipping'],
|
||||
ecommerceNotificationDefinitions: ['notification_definitions', 'mileage'],
|
||||
availableChannels: ['notification_definitions'],
|
||||
ecommerceIdentityPolicies: ['identity_policies'],
|
||||
ecommerceIdentityPurposes: ['identity_policies'],
|
||||
identityProviders: ['identity_policies'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
describe('admin_ecommerce_settings — 데이터소스 탭 스코프 게이팅', () => {
|
||||
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
|
||||
for (const id of SHARED_SOURCE_IDS) {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.if).toBeUndefined();
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
|
||||
const s = byId(id);
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s!.if).toBe('string');
|
||||
expect(s!.if.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
|
||||
const cond: string = byId(id)!.if;
|
||||
for (const tab of tabs as string[]) {
|
||||
expect(cond).toContain(`'${tab}'`);
|
||||
}
|
||||
// 활성 탭 판정에 query.tab 과 _global 을 모두 사용해야 딥링크/클릭 양쪽에서 동작
|
||||
expect(cond).toContain('query.tab');
|
||||
expect(cond).toContain('activeEcommerceSettingsTab');
|
||||
});
|
||||
|
||||
it('탭 전용 소스는 auto_fetch:false 로 죽여두지 않는다 (if 선택 시 fetch 되어야 함)', () => {
|
||||
for (const id of Object.keys(TAB_SCOPED)) {
|
||||
expect(byId(id)!.auto_fetch).not.toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 전환은 updateQueryParams 를 태워 `if` 를 재평가해야 한다.
|
||||
// replaceUrl 은 history.replaceState 만 하므로 새 탭의 소스가 영원히 로드되지 않는다.
|
||||
it('탭 전환은 navigate + replace:true 로 수행한다 (replaceUrl 금지)', () => {
|
||||
const seqs: AnyJson[] = [];
|
||||
const walk = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) { n.forEach(walk); return; }
|
||||
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
|
||||
Object.values(n).forEach(walk);
|
||||
};
|
||||
walk(layout);
|
||||
|
||||
const tabSwitches = seqs.filter(s =>
|
||||
(s.params.actions as AnyJson[]).some(
|
||||
a => a?.handler === 'setState' && 'activeEcommerceSettingsTab' in (a.params ?? {})
|
||||
)
|
||||
);
|
||||
expect(tabSwitches.length).toBeGreaterThan(0);
|
||||
|
||||
for (const seq of tabSwitches) {
|
||||
const acts = seq.params.actions as AnyJson[];
|
||||
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
|
||||
const nav = acts.find(a => a.handler === 'navigate');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav!.params.replace).toBe(true);
|
||||
expect(nav!.params.query?.tab).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
+4
@@ -387,6 +387,9 @@ describe('유효기간 / 소멸 알림 카드', () => {
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
|
||||
// URL 갱신은 navigate + replace:true 로 수행한다 — updateQueryParams 를 거쳐야
|
||||
// 데이터소스의 `if` 가 재평가되어 알림 탭 전용 소스가 로드된다.
|
||||
// 가드: admin-ecommerce-settings-tab-scoped-datasources.test.tsx
|
||||
it('알림 설정으로 이동 버튼이 환경설정 알림 탭으로 전환한다', () => {
|
||||
const btn = findById(notificationCard, 'go_to_notification_settings');
|
||||
const seq = btn.actions[0];
|
||||
@@ -394,6 +397,7 @@ describe('유효기간 / 소멸 알림 카드', () => {
|
||||
const navigate = seq.params.actions.find((a: any) => a.handler === 'navigate');
|
||||
expect(navigate.params.path).toBe('/admin/ecommerce/settings');
|
||||
expect(navigate.params.query.tab).toBe('notification_definitions');
|
||||
expect(navigate.params.replace).toBe(true);
|
||||
const setGlobal = seq.params.actions.find((a: any) => a.handler === 'setState');
|
||||
expect(setGlobal.params.activeEcommerceSettingsTab).toBe('notification_definitions');
|
||||
});
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// e2e:allow 라벨 데이터(settings.json) + Vitest 렌더 회귀 테스트만 변경 — 레이아웃 동작/구조 무변경. 알림 정의 화면의 실제 렌더 경로($t: 동적 키 해석)는 본 render 테스트가 커버하고, 화면 수준 검증은 별도 브라우저 실측(handoff §10)으로 확인.
|
||||
/**
|
||||
* @file notificationDefinitionTypeLabels.test.tsx
|
||||
* @description 이커머스 알림 정의 화면 — 타입 라벨 다국어 키 raw 노출 회귀 가드.
|
||||
*
|
||||
* 배경: `order_pending_deposit`, `order_delivered` 두 알림 타입이 module.php 에는
|
||||
* 정의됐으나 라벨을 읽는 settings.json 의 `notification_definitions.types` 에 이름이
|
||||
* 등록되지 않아, 알림 정의 화면에서
|
||||
* `sirsoft-ecommerce.admin.settings.notification_definitions.types.order_pending_deposit`
|
||||
* 같은 raw 키가 그대로 노출됐다.
|
||||
*
|
||||
* 본 테스트는 _tab_notification_definitions.json 이 사용하는 실제 바인딩
|
||||
* `$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}` 을
|
||||
* 재현하고, **실제 settings.json** 라벨을 주입해 두 타입이 raw 키가 아닌 사람이 읽는
|
||||
* 라벨로 렌더되는지 검증한다. settings.json 에서 어느 한 키가 제거되면 이 테스트가
|
||||
* 다시 red 로 떨어져 회귀를 차단한다.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
screen,
|
||||
type MockComponentRegistry,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
import settingsKo from '../../../lang/partial/ko/admin/settings.json';
|
||||
import settingsEn from '../../../lang/partial/en/admin/settings.json';
|
||||
|
||||
// 실제 화면(_tab_notification_definitions.json line ~371)의 타입 라벨 바인딩을 그대로 축약 재현.
|
||||
// 알림 목록 항목마다 H4 에 `$t:...types.{{def.type}}` 로 이름을 표시한다.
|
||||
const LABEL_BINDING =
|
||||
'$t:sirsoft-ecommerce.admin.settings.notification_definitions.types.{{def.type}}';
|
||||
|
||||
function makeLayout() {
|
||||
return {
|
||||
name: 'notification_definition_type_labels_probe',
|
||||
version: '1.0.0',
|
||||
components: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
children: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
iteration: {
|
||||
source: '{{ecommerceNotificationDefinitions?.data?.data ?? []}}',
|
||||
item_var: 'def',
|
||||
index_var: 'defIdx',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'H4',
|
||||
text: LABEL_BINDING,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 실제 서빙 데이터를 모사한 알림 정의 목록 — 문제의 두 타입 포함.
|
||||
const DEFINITIONS = [
|
||||
{ id: 1, type: 'order_pending_deposit', templates: [], variables: [] },
|
||||
{ id: 2, type: 'order_delivered', templates: [], variables: [] },
|
||||
];
|
||||
|
||||
// settings.json 은 `notification_definitions` 를 admin.settings 하위가 아닌 파일 루트에 둔다.
|
||||
// 화면 바인딩은 `sirsoft-ecommerce.admin.settings.notification_definitions...` 네임스페이스를
|
||||
// 쓰므로, 주입 시 admin.settings 아래로 매핑한다(활성 언어팩 로더의 병합 결과와 동일 구조).
|
||||
function buildTranslations(settings: Record<string, any>) {
|
||||
return {
|
||||
'sirsoft-ecommerce': {
|
||||
admin: {
|
||||
settings: settings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let registry: MockComponentRegistry;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('알림 정의 타입 라벨 — 다국어 키 raw 노출 회귀 가드', () => {
|
||||
it('ko: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'ko',
|
||||
translations: buildTranslations(settingsKo),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.getByText('무통장 입금 안내')).toBeInTheDocument();
|
||||
expect(screen.getByText('배송 완료')).toBeInTheDocument();
|
||||
|
||||
// raw 키가 화면에 남아있으면 회귀
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_delivered/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('en: order_pending_deposit / order_delivered 가 라벨로 렌더된다 (raw 키 미노출)', async () => {
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'en',
|
||||
translations: buildTranslations(settingsEn),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.getByText('Bank Transfer Payment Guide')).toBeInTheDocument();
|
||||
expect(screen.getByText('Order Delivered')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/notification_definitions\.types\.order_delivered/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('라벨 제거 시 raw 키가 노출됨 — 회귀 가드가 실제로 결함을 감지한다', async () => {
|
||||
// 이 테스트는 위 두 가드가 "우연히 통과"하는 것이 아님을 증명한다:
|
||||
// settings.json 에서 두 타입 라벨을 제거하면 화면에 raw 키가 그대로 노출된다.
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
const stripped = JSON.parse(JSON.stringify(settingsKo));
|
||||
delete stripped.notification_definitions.types.order_pending_deposit;
|
||||
delete stripped.notification_definitions.types.order_delivered;
|
||||
|
||||
const { render, cleanup } = createLayoutTest(makeLayout(), {
|
||||
componentRegistry: registry as any,
|
||||
locale: 'ko',
|
||||
translations: buildTranslations(stripped),
|
||||
initialData: {
|
||||
ecommerceNotificationDefinitions: { data: { data: DEFINITIONS } },
|
||||
},
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(screen.queryByText('무통장 입금 안내')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/notification_definitions\.types\.order_pending_deposit/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('settings.json(ko/en) 의 types 에 두 신규 타입 라벨이 존재한다', () => {
|
||||
const koTypes = (settingsKo as any).notification_definitions.types;
|
||||
const enTypes = (settingsEn as any).notification_definitions.types;
|
||||
|
||||
expect(koTypes.order_pending_deposit).toBe('무통장 입금 안내');
|
||||
expect(koTypes.order_delivered).toBe('배송 완료');
|
||||
expect(enTypes.order_pending_deposit).toBe('Bank Transfer Payment Guide');
|
||||
expect(enTypes.order_delivered).toBe('Order Delivered');
|
||||
});
|
||||
});
|
||||
@@ -447,7 +447,9 @@
|
||||
"save_error": "Failed to save notification settings.",
|
||||
"types": {
|
||||
"order_confirmed": "Order Confirmed",
|
||||
"order_pending_deposit": "Bank Transfer Payment Guide",
|
||||
"order_shipped": "Order Shipped",
|
||||
"order_delivered": "Order Delivered",
|
||||
"order_completed": "Purchase Completed",
|
||||
"order_cancelled": "Order Cancelled",
|
||||
"new_order_admin": "New Order (Admin)",
|
||||
|
||||
@@ -447,7 +447,9 @@
|
||||
"save_error": "알림 설정 저장에 실패했습니다.",
|
||||
"types": {
|
||||
"order_confirmed": "주문 확인",
|
||||
"order_pending_deposit": "무통장 입금 안내",
|
||||
"order_shipped": "배송 시작",
|
||||
"order_delivered": "배송 완료",
|
||||
"order_completed": "구매 확정",
|
||||
"order_cancelled": "주문 취소",
|
||||
"new_order_admin": "신규 주문 (관리자)",
|
||||
|
||||
+7
@@ -56,6 +56,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/admin/settings/seo-cache-info",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'seo'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true
|
||||
},
|
||||
@@ -65,6 +66,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/admin/shipping-carriers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'shipping'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"errorHandling": {
|
||||
@@ -82,6 +84,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-ecommerce",
|
||||
"method": "GET",
|
||||
"if": "{{['notification_definitions', 'mileage'].includes(query.tab || _global.activeEcommerceSettingsTab || 'basic_info')}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -117,6 +120,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/notification-channels",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'notification_definitions'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -131,6 +135,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/policies",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"params": {
|
||||
@@ -155,6 +160,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/identity/purposes",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
@@ -167,6 +173,7 @@
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/identity/providers",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\LocaleRequiredTranslatable;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
@@ -67,7 +68,7 @@ class StoreShippingPolicyRequest extends FormRequest
|
||||
'country_settings.*.ranges.tiers.*.fee' => ['required', 'numeric', 'min:0'],
|
||||
|
||||
// API 설정
|
||||
'country_settings.*.api_endpoint' => ['nullable', 'url', 'max:500'],
|
||||
'country_settings.*.api_endpoint' => ['nullable', 'url', 'max:500', new PublicOutboundUrl],
|
||||
'country_settings.*.api_request_fields' => ['nullable', 'array'],
|
||||
// 후보 5종 SSoT(ShippingApiRequestField) 외 필드명 거부 — silent drop 차단
|
||||
'country_settings.*.api_request_fields.*' => ['string', 'max:100', Rule::in(ShippingApiRequestField::values())],
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
|
||||
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
|
||||
@@ -34,7 +35,7 @@ class TestShippingApiRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'endpoint' => ['required', 'url', 'max:500'],
|
||||
'endpoint' => ['required', 'url', 'max:500', new PublicOutboundUrl],
|
||||
'request_fields' => ['nullable', 'array'],
|
||||
'request_fields.*' => ['string', Rule::in(ShippingApiRequestField::values())],
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -2149,6 +2150,17 @@ class OrderCalculationService
|
||||
*/
|
||||
protected function dispatchApiRequest(string $endpoint, array $requestData, array $config): ?Response
|
||||
{
|
||||
// 이 호출은 공개 체크아웃 트래픽으로도 유발되므로, 저장된 엔드포인트가 내부망을
|
||||
// 가리키면 요청 자체를 보내지 않는다. 저장 시점 검증(FormRequest) 도입 이전에
|
||||
// 기록된 값이나 DB 직접 수정으로 들어온 값을 여기서 막는다.
|
||||
if (! $this->isEndpointAllowed($endpoint)) {
|
||||
Log::warning('배송비 계산 API 엔드포인트가 내부 네트워크를 가리켜 호출을 차단했습니다', [
|
||||
'host' => parse_url($endpoint, PHP_URL_HOST),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$request = Http::timeout(10)->withoutRedirecting();
|
||||
|
||||
// 인증 헤더 부착
|
||||
@@ -2170,6 +2182,27 @@ class OrderCalculationService
|
||||
: $request->post($endpoint, $requestData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 배송비 계산 API 엔드포인트가 호출 가능한 주소인지 판정합니다.
|
||||
*
|
||||
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단합니다. 사내 서버를 배송비 API 로
|
||||
* 쓰는 운영 환경을 위해 관리자 환경설정의 `security.allow_internal_outbound_urls` 로
|
||||
* 허용할 수 있으며, 그 경우에도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
|
||||
*
|
||||
* @param string $endpoint API 엔드포인트 URL
|
||||
* @return bool 호출을 허용하면 true
|
||||
*/
|
||||
protected function isEndpointAllowed(string $endpoint): bool
|
||||
{
|
||||
$options = ['schemes' => ['http', 'https']];
|
||||
|
||||
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
||||
return OutboundUrlValidator::isStructurallySafeUrl($endpoint, $options);
|
||||
}
|
||||
|
||||
return OutboundUrlValidator::isPublicHttpUrl($endpoint, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 응답에서 배송비 값을 추출합니다 (응답 형식별 분기).
|
||||
*
|
||||
|
||||
+97
-1
@@ -3,9 +3,12 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingTypeSeeder;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* 배송정책 계산 API 테스트 호출 엔드포인트 테스트 (MP12)
|
||||
@@ -80,7 +83,7 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase
|
||||
*/
|
||||
public function test_connection_failure_still_returns_request_preview_and_error(): void
|
||||
{
|
||||
Http::fake(['*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Connection refused')]);
|
||||
Http::fake(['*' => fn () => throw new ConnectionException('Connection refused')]);
|
||||
|
||||
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
|
||||
'endpoint' => 'https://unreachable.example.com/calc',
|
||||
@@ -142,4 +145,97 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('endpoint');
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 네트워크 주소는 422 로 거부되고 요청이 전송되지 않는다 (SSRF 차단).
|
||||
*
|
||||
* @param string $endpoint 내부망을 가리키는 엔드포인트
|
||||
*/
|
||||
#[DataProvider('internalEndpointProvider')]
|
||||
public function test_internal_endpoint_is_rejected_and_no_request_is_sent(string $endpoint): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
|
||||
|
||||
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
|
||||
'endpoint' => $endpoint,
|
||||
'config' => ['http_method' => 'GET'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('endpoint');
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* userinfo(@) 로 목적지를 위장한 URL 도 거부한다.
|
||||
*/
|
||||
public function test_userinfo_disguised_endpoint_is_rejected(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
|
||||
|
||||
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
|
||||
'endpoint' => 'https://shipping.example.com@127.0.0.1/calc',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('endpoint');
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 주소 허용 설정을 켜면 사내 주소 호출이 가능하다 (운영 옵트인).
|
||||
*/
|
||||
public function test_internal_endpoint_is_allowed_when_setting_is_enabled(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['shipping_fee' => 2500], 200)]);
|
||||
$this->enableInternalOutboundUrls();
|
||||
|
||||
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
|
||||
'endpoint' => 'http://192.168.0.10/calc',
|
||||
'config' => ['http_method' => 'POST', 'response_path' => 'shipping_fee'],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.extracted_fee', 2500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 주소를 허용해도 userinfo 위장은 계속 거부한다.
|
||||
*/
|
||||
public function test_userinfo_disguise_is_rejected_even_when_internal_is_allowed(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['shipping_fee' => 1], 200)]);
|
||||
$this->enableInternalOutboundUrls();
|
||||
|
||||
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
|
||||
'endpoint' => 'https://shipping.example.com@127.0.0.1/calc',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 내부 네트워크 엔드포인트 목록.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function internalEndpointProvider(): array
|
||||
{
|
||||
return [
|
||||
'클라우드 메타데이터' => ['http://169.254.169.254/latest/meta-data/'],
|
||||
'루프백' => ['http://127.0.0.1:8080/calc'],
|
||||
'localhost' => ['http://localhost/calc'],
|
||||
'사설 IP' => ['http://192.168.0.10/calc'],
|
||||
'내부 도메인' => ['http://vault.internal/calc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 환경설정에서 내부 주소 호출 허용을 켠다.
|
||||
*/
|
||||
private function enableInternalOutboundUrls(): void
|
||||
{
|
||||
Config::set('g7_settings.core.security.allow_internal_outbound_urls', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_at": "2026-07-10T12:05:27+00:00",
|
||||
"generated_at": "2026-07-10T20:22:38+00:00",
|
||||
"generator": "g7 vendor-bundle:build",
|
||||
"target": "module:sirsoft-ecommerce",
|
||||
"composer_json_sha256": "aa8f74063b7f9cd969cb4effb120e3c50bb944a4d3b2e16fae7c31bd8232fce8",
|
||||
"composer_json_sha256": "5195d789bc9edd0b5140c42aeedd7c87be67c7252b477a879ff90607b4787935",
|
||||
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
|
||||
"zip_sha256": "ef77515e4d920a52a8adba1e6d7e310389c6f3f459586d3cd5c24749f24d7fb0",
|
||||
"zip_sha256": "c1767e8eca61163667e9289a3963454bab92806ce4e8a3651777f1e1aea14081",
|
||||
"zip_size": 435549,
|
||||
"package_count": 1,
|
||||
"php_requirement": "^8.2",
|
||||
|
||||
Binary file not shown.
@@ -20,7 +20,9 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
|
||||
// 소스맵 생성
|
||||
sourcemap: true,
|
||||
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
|
||||
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
|
||||
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ckeditor5",
|
||||
"version": "1.0.0-beta.4",
|
||||
"version": "1.0.0",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
|
||||
var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
|
||||
/* 다국어 탭 컨테이너 */
|
||||
.ckeditor5-locale-tabs {
|
||||
display: flex;
|
||||
@@ -369,5 +369,4 @@ var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ck
|
||||
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
|
||||
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
|
||||
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
|
||||
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G})({});
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user