Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5065f1bc9 | ||
|
|
8828c80b56 | ||
|
|
a45e233b97 | ||
|
|
7b7a175c07 | ||
|
|
a7340f21ca | ||
|
|
f157ed1cc9 | ||
|
|
0a6ff23132 | ||
|
|
9251af5c6a | ||
|
|
7598b76498 | ||
|
|
c0e0d82459 | ||
|
|
8b9bfd0c91 | ||
|
|
e896a47d4f | ||
|
|
10fe28a16d | ||
|
|
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.5
|
||||
|
||||
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.5
|
||||
|
||||
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
|
||||
|
||||
@@ -750,6 +750,7 @@ BaseApiController (최상위)
|
||||
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
|
||||
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
|
||||
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
|
||||
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
|
||||
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
```
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.5] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다.
|
||||
- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.)
|
||||
- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. 관리자 권한으로 업데이트를 실행할 때 이 연결을 다시 이어 붙이면서 소유자가 관리자 계정으로 바뀌던 문제도 함께 바로잡아, 원래 계정 소유가 그대로 유지됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
|
||||
|
||||
## [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.5 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.5-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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Helpers\StorageLinkHelper;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
@@ -517,6 +518,12 @@ class CoreUpdateCommand extends Command
|
||||
$this->surfacePermissionWarnings($service, $log);
|
||||
$log('업데이트 경로 소유권 복원 완료');
|
||||
|
||||
// 소유권 복원 완료 후 · 유지보수 해제 전에 `public/storage` symlink 를 방어 복구한다.
|
||||
// `--prune` 이 릴리즈 소스에 없는 symlink 를 orphan 으로 삭제했거나(#43) 손상된
|
||||
// 경우에도, 첫 요청부터 `/storage/...` 가 200 을 반환하도록 링크를 재생성한다.
|
||||
StorageLinkHelper::ensurePublicStorageLink();
|
||||
$log('public/storage symlink 확인/복구 완료');
|
||||
|
||||
$service->cleanupPending($pendingPath);
|
||||
|
||||
if ($backupPath) {
|
||||
@@ -623,6 +630,11 @@ class CoreUpdateCommand extends Command
|
||||
$this->surfacePermissionWarnings($service, $log);
|
||||
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
|
||||
|
||||
// 핸드오프 시에도 파일은 이미 toVersion 으로 반영돼 `public/storage` symlink 가
|
||||
// 삭제/손상됐을 수 있으므로 유지보수 해제 전에 동일하게 방어 복구한다(#43).
|
||||
StorageLinkHelper::ensurePublicStorageLink();
|
||||
$log('public/storage symlink 확인/복구 완료 (핸드오프)');
|
||||
|
||||
if (! empty($pendingPath)) {
|
||||
$service->cleanupPending($pendingPath);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Extension;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 라우트 매칭 인덱스 계약.
|
||||
*
|
||||
* 활성 확장(모듈/플러그인)이 `getMiddleware()` 로 선언한 미들웨어를 수집해,
|
||||
* 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` 술어와 대조하여 매칭되는
|
||||
* 미들웨어 FQCN 목록을 반환합니다. 코어 self-gate 게이트(`ExtensionMiddlewareGate`)가
|
||||
* 이 계약을 사용해 그룹/timing 별로 매칭 미들웨어를 동적 실행합니다.
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피합니다.
|
||||
* IDV `IdentityPolicyRepository::getRouteScopeIndex()` 미러.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
interface ExtensionMiddlewareRegistryInterface
|
||||
{
|
||||
/**
|
||||
* 현재 요청에 매칭되는 확장 미들웨어 FQCN 목록을 반환합니다.
|
||||
*
|
||||
* 게이트 래퍼가 자기 그룹·timing 을 전달하면, 그 축의 엔트리 중 라우트명 또는
|
||||
* URI 술어가 매칭되는 것을 확장 로드 순서대로 반환합니다.
|
||||
*
|
||||
* @param string $routeName 현재 요청 라우트 이름 (무명 라우트면 빈 문자열)
|
||||
* @param string $path 현재 요청 URI path (선행 슬래시 없음, 무명 라우트 타게팅용)
|
||||
* @param string $group 요청이 속한 그룹 ('web'|'api')
|
||||
* @param string $timing 실행 시점 ('before_core'|'after_core')
|
||||
* @return array<int, class-string> 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서)
|
||||
*/
|
||||
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array;
|
||||
}
|
||||
@@ -551,6 +551,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증)
|
||||
* 'groups' => ['api'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api']
|
||||
* 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저)
|
||||
* 'targets' => ['self'], // 라우트명/URI 패턴 배열. 'self' = 자기 확장 prefix 자동 치환
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) |
|
||||
* 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' |
|
||||
* 원시 라우트명 glob·brace(`api.modules.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용).
|
||||
* targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언".
|
||||
*/
|
||||
public function getMiddleware(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종.
|
||||
*
|
||||
@@ -1022,11 +1053,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
*
|
||||
* @param string $pageType 레이아웃 meta.seo.page_type
|
||||
* @return array<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 키별 데이터 경로 메타 — 예:
|
||||
* [
|
||||
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
|
||||
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
||||
* ]
|
||||
* 키별 데이터 경로 메타 — 예:
|
||||
* [
|
||||
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
|
||||
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
||||
* ]
|
||||
*/
|
||||
public function seoOgDefaultMeta(string $pageType): array
|
||||
{
|
||||
@@ -1038,7 +1069,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
*
|
||||
* @param string $pageType 페이지 타입
|
||||
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
|
||||
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
|
||||
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
|
||||
*/
|
||||
public function seoTwitterDefaultMeta(string $pageType): array
|
||||
{
|
||||
@@ -1054,11 +1085,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
|
||||
*
|
||||
* @param string $pageType 페이지 타입
|
||||
* @return array<string, array{expr: string, label: string|array<string, string>}>
|
||||
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
|
||||
* [
|
||||
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
||||
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
|
||||
* ]
|
||||
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
|
||||
* [
|
||||
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
||||
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
|
||||
* ]
|
||||
*/
|
||||
public function seoStructuredDataMeta(string $pageType): array
|
||||
{
|
||||
|
||||
@@ -508,6 +508,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
||||
*
|
||||
* 플러그인이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
||||
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
||||
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
||||
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
||||
* 됩니다 (순수하게 유지).
|
||||
*
|
||||
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 플러그인만 오버라이드합니다.
|
||||
*
|
||||
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
||||
* [
|
||||
* [
|
||||
* 'class' => VbankNotifyIpWhitelist::class, // 미들웨어 FQCN (class_exists 검증)
|
||||
* 'groups' => ['web'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api']
|
||||
* 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저)
|
||||
* 'targets' => ['web.plugins.vendor-plugin.payment.vbank-notify'], // 라우트명/URI 패턴 배열
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) |
|
||||
* 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' |
|
||||
* 원시 라우트명 glob·brace(`web.plugins.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용).
|
||||
* targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언".
|
||||
*/
|
||||
public function getMiddleware(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 활성 확장의 미들웨어 선언을 수집해 라우트 매칭 인덱스를 캐시로 제공합니다.
|
||||
*
|
||||
* IDV `getRouteScopeIndex` 와 달리, targets 가 정확한 이름이 아니라 glob/brace/URI
|
||||
* 패턴이므로 [정확한이름 => 미들웨어] 해시맵이 아니라 [group.timing => list<{predicates, class}>]
|
||||
* 구조로 두고 요청 라우트명·URI 를 술어로 순회 매칭합니다. 확장 미들웨어 총량이 소수(수십
|
||||
* 이하)라 O(n) 순회 비용은 무시 가능. 결과는 (routeName|path)+group+timing 단위로 2차 캐시.
|
||||
*
|
||||
* 술어는 캐시를 위해 클로저 대신 `{op, arg}` 태그 배열로 보관하고 조회 시 평가합니다
|
||||
* (클로저는 직렬화 불가). op ∈ {name, name_ext, name_core, uri, always}.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
class ExtensionMiddlewareRegistry implements ExtensionMiddlewareRegistryInterface
|
||||
{
|
||||
/**
|
||||
* 미들웨어 인덱스 캐시 키 (IDV `identity_policies.route_scope_index` 네이밍 컨벤션 미러).
|
||||
* 물리 키는 CoreCacheDriver 접두사로 `g7:core:extension.middleware_index`.
|
||||
*/
|
||||
public const CACHE_KEY = 'extension.middleware_index';
|
||||
|
||||
/**
|
||||
* 캐시 무효화 태그 (단수 slug). Manager 상태 변경 지점에서 flush().
|
||||
*/
|
||||
public const CACHE_TAG = 'extension_middleware';
|
||||
|
||||
/**
|
||||
* 허용 그룹.
|
||||
*/
|
||||
private const ALLOWED_GROUPS = ['web', 'api'];
|
||||
|
||||
/**
|
||||
* 확장 라우트 판별 glob (양 그룹). 라우트명이 이 중 하나에 매칭되면 확장 라우트.
|
||||
*/
|
||||
private const EXT_GLOB = ['*.modules.*', '*.plugins.*'];
|
||||
|
||||
/**
|
||||
* 요청 단위 2차 캐시 (동일 요청 내 게이트가 4회 조회해도 인덱스 1회 빌드).
|
||||
*
|
||||
* @var array<string, list<class-string>>
|
||||
*/
|
||||
protected array $resolveCache = [];
|
||||
|
||||
/**
|
||||
* @param CacheInterface $cache 코어 캐시 드라이버 (CoreCacheDriver 자동 바인딩)
|
||||
*/
|
||||
public function __construct(
|
||||
protected CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array
|
||||
{
|
||||
$memoKey = $routeName."\0".$path."\0".$group."\0".$timing;
|
||||
if (isset($this->resolveCache[$memoKey])) {
|
||||
return $this->resolveCache[$memoKey];
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
foreach ($this->buildIndex() as $entry) {
|
||||
if ($entry['group'] !== $group || $entry['timing'] !== $timing) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entry['predicates'] as $predicate) {
|
||||
if ($this->predicateMatches($predicate, $routeName, $path)) {
|
||||
$matched[] = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($matched, static fn (array $a, array $b): int => $a['order'] <=> $b['order']);
|
||||
|
||||
$classes = array_values(array_map(static fn (array $e): string => $e['class'], $matched));
|
||||
|
||||
return $this->resolveCache[$memoKey] = $classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 확장의 미들웨어 선언을 그룹별 엔트리로 펼쳐 캐시된 인덱스를 반환합니다.
|
||||
*
|
||||
* @return list<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, class: class-string, order: int}>
|
||||
*/
|
||||
protected function buildIndex(): array
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.extension_middleware_ttl', 3600);
|
||||
|
||||
return $this->cache->remember(
|
||||
self::CACHE_KEY,
|
||||
fn (): array => $this->collectEntries(),
|
||||
$ttl,
|
||||
[self::CACHE_TAG],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈·플러그인의 getMiddleware() 를 검증·정규화해 엔트리 배열로 수집합니다.
|
||||
*
|
||||
* @return list<array{timing: string, group: string, predicates: list<array{op: string, arg?: string}>, class: class-string, order: int}>
|
||||
*/
|
||||
protected function collectEntries(): array
|
||||
{
|
||||
$entries = [];
|
||||
$order = 0;
|
||||
|
||||
foreach ($this->activeExtensions() as [$extension, $identifier, $kind]) {
|
||||
if (! method_exists($extension, 'getMiddleware')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$declarations = $extension->getMiddleware();
|
||||
if (! is_array($declarations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($declarations as $declaration) {
|
||||
$normalized = $this->normalizeDeclaration($declaration, $identifier, $kind);
|
||||
if ($normalized === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$class, $groups, $timing, $targets] = $normalized;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$predicates = [];
|
||||
foreach ($targets as $target) {
|
||||
foreach ($this->normalizeTarget($target, $identifier, $kind, $group) as $predicate) {
|
||||
$predicates[] = $predicate;
|
||||
}
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'timing' => $timing,
|
||||
'group' => $group,
|
||||
'predicates' => $predicates,
|
||||
'class' => $class,
|
||||
'order' => $order,
|
||||
];
|
||||
}
|
||||
|
||||
$order++;
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 미들웨어 선언을 검증·정규화합니다. 무효 시 warning 후 null.
|
||||
*
|
||||
* @param mixed $declaration getMiddleware() 항목
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $kind 'module' | 'plugin'
|
||||
* @return array{0: class-string, 1: list<string>, 2: string, 3: list<string>}|null
|
||||
*/
|
||||
protected function normalizeDeclaration(mixed $declaration, string $identifier, string $kind): ?array
|
||||
{
|
||||
if (! is_array($declaration)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = $declaration['class'] ?? null;
|
||||
if (! is_string($class) || $class === '' || ! class_exists($class)) {
|
||||
Log::warning('확장 미들웨어 선언의 class 가 유효하지 않아 등록을 건너뜁니다.', [
|
||||
'extension' => $identifier,
|
||||
'kind' => $kind,
|
||||
'class' => $class,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$groups = $declaration['groups'] ?? [];
|
||||
$groups = is_array($groups)
|
||||
? array_values(array_filter($groups, static fn ($g): bool => in_array($g, self::ALLOWED_GROUPS, true)))
|
||||
: [];
|
||||
if ($groups === []) {
|
||||
Log::warning('확장 미들웨어 선언의 groups 가 비어있거나 허용되지 않은 값이라 등록을 건너뜁니다.', [
|
||||
'extension' => $identifier,
|
||||
'kind' => $kind,
|
||||
'class' => $class,
|
||||
'groups' => $declaration['groups'] ?? null,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$targets = $declaration['targets'] ?? [];
|
||||
$targets = is_array($targets)
|
||||
? array_values(array_filter($targets, static fn ($t): bool => is_string($t) && $t !== ''))
|
||||
: [];
|
||||
if ($targets === []) {
|
||||
Log::warning('확장 미들웨어 선언의 targets 가 비어있어 등록을 건너뜁니다 (대상 명시 필수).', [
|
||||
'extension' => $identifier,
|
||||
'kind' => $kind,
|
||||
'class' => $class,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$timing = $declaration['timing'] ?? 'after_core';
|
||||
if (! in_array($timing, ['before_core', 'after_core'], true)) {
|
||||
$timing = 'after_core';
|
||||
}
|
||||
|
||||
return [$class, $groups, $timing, $targets];
|
||||
}
|
||||
|
||||
/**
|
||||
* target 값을 술어(들)로 정규화합니다 (§1 카탈로그 → 매칭식).
|
||||
*
|
||||
* brace 는 여러 name 술어로 전개되므로 list 반환.
|
||||
*
|
||||
* @param string $target targets 항목
|
||||
* @param string $identifier 확장 식별자 ('self' 치환용)
|
||||
* @param string $kind 'module' | 'plugin'
|
||||
* @param string $group 이 엔트리가 펼쳐진 단일 그룹 ('self' 치환용)
|
||||
* @return list<array{op: string, arg?: string}>
|
||||
*/
|
||||
protected function normalizeTarget(string $target, string $identifier, string $kind, string $group): array
|
||||
{
|
||||
// URI 패턴 (무명 라우트 대응) — '/' 로 시작. arg 는 선행 슬래시를 포함한 원형을 유지하고
|
||||
// (매칭 시 path 도 선행 슬래시로 정규화), 이렇게 해야 루트('/')·글롭('/admin/*') 모두
|
||||
// $request->is() 와 동일하게 동작한다 ($request->path() 는 루트에서 '/' 를 반환).
|
||||
if (str_starts_with($target, '/')) {
|
||||
return [['op' => 'uri', 'arg' => $target]];
|
||||
}
|
||||
|
||||
return match (strtolower($target)) {
|
||||
'self' => [['op' => 'name', 'arg' => "{$group}.{$kind}s.{$identifier}.*"]],
|
||||
'all_extensions' => [['op' => 'name_ext']],
|
||||
'core' => [['op' => 'name_core']],
|
||||
'everything', '*' => [['op' => 'always']],
|
||||
default => $this->normalizeSpecificTarget($target),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* module:{id} / plugin:{id} / brace / 원시 glob 을 name 술어로 변환합니다.
|
||||
*
|
||||
* @param string $target targets 항목
|
||||
* @return list<array{op: string, arg: string}>
|
||||
*/
|
||||
protected function normalizeSpecificTarget(string $target): array
|
||||
{
|
||||
if (str_starts_with($target, 'module:')) {
|
||||
$id = substr($target, strlen('module:'));
|
||||
|
||||
return [['op' => 'name', 'arg' => "*.modules.{$id}.*"]];
|
||||
}
|
||||
|
||||
if (str_starts_with($target, 'plugin:')) {
|
||||
$id = substr($target, strlen('plugin:'));
|
||||
|
||||
return [['op' => 'name', 'arg' => "*.plugins.{$id}.*"]];
|
||||
}
|
||||
|
||||
// Str::is 는 brace 미지원 → 미리 전개해 개별 name 술어로 저장.
|
||||
return array_map(
|
||||
static fn (string $glob): array => ['op' => 'name', 'arg' => $glob],
|
||||
$this->expandTargetBraces($target),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 술어가 현재 요청 라우트명·path 에 매칭되는지 평가합니다.
|
||||
*
|
||||
* op=uri → path 매칭; op=always → 무조건 true (무명 라우트 포함);
|
||||
* name 계열 → 무명 라우트(routeName='')는 항상 miss.
|
||||
*
|
||||
* @param array{op: string, arg?: string} $predicate 술어
|
||||
* @param string $routeName 요청 라우트명 (무명이면 빈 문자열)
|
||||
* @param string $path 요청 URI path (선행 슬래시 없음)
|
||||
* @return bool 매칭 여부
|
||||
*/
|
||||
protected function predicateMatches(array $predicate, string $routeName, string $path): bool
|
||||
{
|
||||
$op = $predicate['op'];
|
||||
|
||||
if ($op === 'always') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($op === 'uri') {
|
||||
// path 를 선행 슬래시로 정규화해 arg('/...') 와 정렬 ($request->is() 정합).
|
||||
return Str::is($predicate['arg'] ?? '', '/'.ltrim($path, '/'));
|
||||
}
|
||||
|
||||
// name 계열: 무명 라우트는 URI/always 로만 잡히므로 여기서 miss.
|
||||
if ($routeName === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($op) {
|
||||
'name' => Str::is($predicate['arg'] ?? '', $routeName),
|
||||
'name_ext' => $this->isExtensionRoute($routeName),
|
||||
'name_core' => ! $this->isExtensionRoute($routeName),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트명이 확장 라우트(*.modules.* / *.plugins.*)인지 판별합니다.
|
||||
*
|
||||
* @param string $routeName 요청 라우트명 (비어있지 않음)
|
||||
* @return bool 확장 라우트 여부
|
||||
*/
|
||||
protected function isExtensionRoute(string $routeName): bool
|
||||
{
|
||||
return Str::is(self::EXT_GLOB, $routeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* brace expansion — 'a.{b,c}.d' → ['a.b.d', 'a.c.d']. 단일 그룹만 지원.
|
||||
* IDV `IdentityPolicyRepository::expandTargetBraces()` 재사용.
|
||||
*
|
||||
* @param string $target targets 항목
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function expandTargetBraces(string $target): array
|
||||
{
|
||||
if (! preg_match('/\{([^{}]+)\}/', $target, $matches)) {
|
||||
return [$target];
|
||||
}
|
||||
$options = array_map('trim', explode(',', $matches[1]));
|
||||
|
||||
return array_map(
|
||||
static fn (string $opt): string => preg_replace('/\{[^{}]+\}/', $opt, $target, 1),
|
||||
$options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 모듈·플러그인 인스턴스를 [instance, identifier, kind] 로 순회합니다.
|
||||
*
|
||||
* @return iterable<array{0: object, 1: string, 2: string}>
|
||||
*/
|
||||
protected function activeExtensions(): iterable
|
||||
{
|
||||
foreach (app(ModuleManager::class)->getActiveModules() as $module) {
|
||||
yield [$module, $module->getIdentifier(), 'module'];
|
||||
}
|
||||
|
||||
foreach (app(PluginManager::class)->getActivePlugins() as $plugin) {
|
||||
yield [$plugin, $plugin->getIdentifier(), 'plugin'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 캐시를 무효화합니다 (확장 활성/비활성/설치/제거/리로드 시).
|
||||
*
|
||||
* IDV `IdentityPolicy::flushRouteScopeCache()` 미러 정적 메서드.
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
$cache = app(CacheInterface::class);
|
||||
if ($cache->supportsTags()) {
|
||||
$cache->flushTags([self::CACHE_TAG]);
|
||||
|
||||
return;
|
||||
}
|
||||
$cache->forget(self::CACHE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,17 @@ class FilePermissionHelper
|
||||
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
|
||||
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
|
||||
* @param array<string, bool>|null $applyList 적용 대상 파일 상대경로 화이트리스트
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* @param array<int, string> $preserveLinkPaths orphan 정리 시 삭제하지 않고 보존할
|
||||
* symlink/junction 의 상대경로 화이트리스트(이 디렉토리 루트 기준, 예 `['storage']`).
|
||||
* `removeOrphans=true` 라도 이 목록에 매칭되는 orphan 링크는 삭제하지 않는다 — 코어
|
||||
* 업데이트 prune 모드에서 `public/storage` symlink 가 orphan 으로 판정되어 삭제되던
|
||||
* 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다.
|
||||
*/
|
||||
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void
|
||||
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null, array $preserveLinkPaths = []): void
|
||||
{
|
||||
if (! File::isDirectory($destination)) {
|
||||
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
|
||||
@@ -81,7 +86,10 @@ class FilePermissionHelper
|
||||
|
||||
if ($item->isDir()) {
|
||||
// preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달.
|
||||
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList);
|
||||
// preserveLinkPaths 는 이 디렉토리 루트 기준 상대경로(예 `public/storage` → `storage`)라
|
||||
// 재귀에 그대로 하향 전달 — removeOrphanItems 가 각 레벨에서 itemRelativePath 전체로
|
||||
// 매칭하므로 중첩 보호 경로도 정확히 판정된다.
|
||||
static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList, preserveLinkPaths: $preserveLinkPaths);
|
||||
} else {
|
||||
// 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 —
|
||||
// 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존.
|
||||
@@ -94,7 +102,7 @@ class FilePermissionHelper
|
||||
|
||||
// 소스에 없는 대상 파일/디렉토리 삭제
|
||||
if ($removeOrphans && File::isDirectory($destination)) {
|
||||
static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans);
|
||||
static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans, $preserveLinkPaths);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +210,11 @@ class FilePermissionHelper
|
||||
* @param array $excludes 제외할 이름 또는 경로 목록
|
||||
* @param string $relativePath 현재 상대 경로
|
||||
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
|
||||
* @param array<int, string> $preserveLinkPaths 삭제하지 않고 보존할 symlink/junction
|
||||
* 상대경로 화이트리스트(루트 기준). orphan 이 링크이면서 이 목록에 매칭되면 삭제하지
|
||||
* 않고 보존한다 — `public/storage` symlink 보호(#43). 화이트리스트 밖 orphan 링크는 삭제.
|
||||
*/
|
||||
protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false): void
|
||||
protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false, array $preserveLinkPaths = []): void
|
||||
{
|
||||
// 최상위 한 레벨에서 소스에 없는 항목(사용자 추가) 보존 — `_bundled/my-project` 등.
|
||||
// 호출자(copyDirectory)는 최상위 진입 시에만 relativePath='' + 플래그 true 로 들어오며,
|
||||
@@ -227,6 +238,21 @@ class FilePermissionHelper
|
||||
|
||||
// 소스에 존재하지 않는 항목만 삭제
|
||||
if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) {
|
||||
// 보호 화이트리스트 매칭 링크/junction 은 orphan 이어도 보존 — 코어 업데이트
|
||||
// prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로 삭제되어
|
||||
// 업로드 파일이 404 되던 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 아래에서 삭제.
|
||||
if (
|
||||
in_array($itemRelativePath, $preserveLinkPaths, true)
|
||||
&& (is_link($destItem->getPathname()) || static::isReparsePoint($destItem->getPathname()))
|
||||
) {
|
||||
Log::info('removeOrphanItems: 보호 대상 orphan 링크 보존', [
|
||||
'path' => $destItem->getPathname(),
|
||||
'relative' => $itemRelativePath,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir()
|
||||
// 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는
|
||||
// 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* `public/storage` symlink 멱등 복구 헬퍼.
|
||||
*
|
||||
* 코어 업데이트(특히 `--prune`) 이후 `public/storage` symlink 가 부재/손상 상태로 남는
|
||||
* 결함(#43)을 종료 시점에 방어 복구한다. `is_link` 이면 no-op, 부재/일반 디렉토리이면
|
||||
* `storage/app/public` 을 가리키는 symlink 를 (필요 시 백업 후) 재생성한다.
|
||||
*
|
||||
* V-1 안전: 이 헬퍼는 beta.5 DataMigration `RecoverPublicStorageSymlink` 가 in-process
|
||||
* fallback 으로 호출할 수 있으므로, `Illuminate\Support\Facades\*` 파사드 + 네이티브
|
||||
* 함수 + 로컬 로직만 사용한다 (신규 Service/Manager/Repository 메서드 의존 금지).
|
||||
*
|
||||
* 로거 주입: 두 호출자의 로깅 채널이 다르다 — migration 은 `$context->logger`(채널
|
||||
* `upgrade`), 코어 업데이트 커맨드는 `Log::channel('upgrade')`. 파사드를 직접 쓰면
|
||||
* migration 의 채널 격리가 깨지므로 로거를 주입받고, `null` 이면 `upgrade` 채널로 폴백한다.
|
||||
*/
|
||||
class StorageLinkHelper
|
||||
{
|
||||
/**
|
||||
* `public/storage` 가 정상 symlink 인지 확인하고, 아니면 멱등 재생성합니다.
|
||||
*
|
||||
* 멱등 판정 순서:
|
||||
* 1. `is_link(public/storage)` → 이미 정상 symlink, no-op.
|
||||
* 2. `storage/app/public` 부재 → Laravel storage:link 컨벤션 미사용 환경, skip
|
||||
* (false positive 차단 — 운영자 의도적 구성 존중).
|
||||
* 3. `public/storage` 부재 → `symlink()` 로 신규 생성 (prune 삭제 갭 복구 핵심).
|
||||
* 4. `public/storage` 가 일반 디렉토리 → `.broken.{YmdHis}` rename 백업 후 재생성
|
||||
* (dereferenced 콘텐츠 데이터 손실 없이 보존).
|
||||
*
|
||||
* Windows `SeCreateSymbolicLink` 권한 부족으로 `symlink()` 가 실패하면 target 이
|
||||
* 디렉토리일 때 `mklink /J`(junction) 로 폴백한다. 그래도 실패하면 rename 백업을
|
||||
* 원위치로 복원하고 warning 로그만 남긴다 (graceful degrade — 데이터 손실 없음).
|
||||
*
|
||||
* @param LoggerInterface|null $logger 로그 채널 격리용 주입 로거.
|
||||
* `null` 이면 `Log::channel('upgrade')` 로 폴백.
|
||||
*/
|
||||
public static function ensurePublicStorageLink(?LoggerInterface $logger = null): void
|
||||
{
|
||||
$logger ??= Log::channel('upgrade');
|
||||
|
||||
$publicStorage = public_path('storage');
|
||||
$storageSource = storage_path('app/public');
|
||||
|
||||
// 1) 이미 정상 symlink → no-op (멱등)
|
||||
if (is_link($publicStorage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Laravel storage:link source 부재 → 표준 미사용 환경, skip (false positive 차단)
|
||||
if (! is_dir($storageSource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) public/storage 부재 → 신규 생성 (prune 이 삭제한 케이스 — 기존 스텝 미커버)
|
||||
if (! file_exists($publicStorage)) {
|
||||
if (static::createLink($storageSource, $publicStorage)) {
|
||||
$logger->info('[storage-link] public/storage symlink 신규 생성', [
|
||||
'target' => $storageSource,
|
||||
]);
|
||||
} else {
|
||||
$logger->warning('[storage-link] public/storage symlink 생성 실패 — 수동 `php artisan storage:link` 필요', [
|
||||
'target' => $storageSource,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows junction(`mklink /J`)은 `is_link()` 가 false 지만 이미 정상 링크다
|
||||
// (reparse point). rename 백업 대상에서 제외 — no-op 로 존중한다.
|
||||
if (static::isReparsePoint($publicStorage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) public/storage 가 일반 디렉토리(손상) → rename 백업 후 재생성
|
||||
$backup = $publicStorage.'.broken.'.date('YmdHis');
|
||||
if (! @rename($publicStorage, $backup)) {
|
||||
$logger->warning('[storage-link] public/storage rename 실패 — 복구 skip', [
|
||||
'path' => $publicStorage,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (static::createLink($storageSource, $publicStorage)) {
|
||||
$logger->info('[storage-link] public/storage symlink 재생성 완료 — 백업 디렉토리 검증 후 수동 삭제 권장', [
|
||||
'backup' => $backup,
|
||||
'target' => $storageSource,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 재생성 실패 → rename 원복 (데이터 손실 없음)
|
||||
@rename($backup, $publicStorage);
|
||||
$logger->warning('[storage-link] public/storage symlink 재생성 실패 — rename 원복, 수동 `php artisan storage:link` 필요', [
|
||||
'target' => $storageSource,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `symlink()` 로 링크를 생성하고, 실패 시 Windows junction 폴백을 시도합니다.
|
||||
*
|
||||
* Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction
|
||||
* (`mklink /J`)은 권한 없이 생성 가능하므로, target 이 디렉토리이면 junction 으로 폴백한다.
|
||||
*
|
||||
* @param string $target 링크가 가리킬 대상 (`storage/app/public`)
|
||||
* @param string $link 생성할 링크 경로 (`public/storage`)
|
||||
* @return bool 링크 생성 성공 여부
|
||||
*/
|
||||
protected static function createLink(string $target, string $link): bool
|
||||
{
|
||||
if (@symlink($target, $link)) {
|
||||
static::inheritParentOwnership($link);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// symlink 실패 + target 이 디렉토리 → Windows junction 폴백.
|
||||
if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) {
|
||||
exec(
|
||||
'cmd /c mklink /J '.escapeshellarg($link).' '.escapeshellarg($target).' 2>&1',
|
||||
$out,
|
||||
$code
|
||||
);
|
||||
if ($code === 0 && is_dir($link)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 갓 생성한 링크의 소유자/그룹을 부모 디렉토리로부터 상속시킵니다.
|
||||
*
|
||||
* `symlink()` 는 소유권 지정 인자가 없어 실행 프로세스(sudo → root)의 소유로 링크를
|
||||
* 만든다. 이 때문에 sudo 로 실행된 코어 업데이트가 종료 시점에 `public/storage` 를
|
||||
* 재생성하면 링크가 root:root 로 남아, 원래 앱 실행 유저 소유였던 링크의 소유권이
|
||||
* 바뀌던 문제(#43 후속)를 차단한다. 부모 `public/` 의 owner/group 을 기준으로 삼아
|
||||
* 이 프로젝트의 "신규 항목은 부모 소유권 상속" 컨벤션(`FilePermissionHelper`)과 일치시킨다.
|
||||
*
|
||||
* `chown`/`chgrp` 은 심볼릭 링크의 target 을 따라가므로, 링크 *자체* 를 대상으로 하는
|
||||
* `lchown`/`lchgrp` 을 사용한다. 비-POSIX(Windows) 또는 함수 부재/권한 부족 시 무해하게 skip.
|
||||
*
|
||||
* @param string $link 방금 생성한 링크 경로
|
||||
*/
|
||||
protected static function inheritParentOwnership(string $link): void
|
||||
{
|
||||
// lchown/lchgrp 은 POSIX 전용 — Windows 및 함수 비활성 환경에서는 no-op.
|
||||
if (! function_exists('lchown') || ! function_exists('lchgrp')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = dirname($link);
|
||||
$owner = @fileowner($parent);
|
||||
$group = @filegroup($parent);
|
||||
|
||||
if ($owner !== false) {
|
||||
@lchown($link, $owner);
|
||||
}
|
||||
if ($group !== false) {
|
||||
@lchgrp($link, $group);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로가 Windows JUNCTION 인지 판정합니다 (`is_link()` 미인식 reparse point).
|
||||
*
|
||||
* Windows 에서 junction 과 일반 디렉토리는 둘 다 `is_dir()` 이 true 라 구분되지 않고,
|
||||
* `readlink()` 는 일반 디렉토리에는 **자기 경로**를, junction 에는 **target 경로**를
|
||||
* 반환한다. 따라서 "readlink 가 자기 경로와 다른 target 을 반환" 하는 경우만 junction 으로
|
||||
* 판정한다. 일반 symlink 는 호출부(case 1)가 먼저 처리하므로 여기서 false. 비-Windows 는
|
||||
* 항상 false. 이 판정으로 정상 junction 은 case 4(rename 재생성) 대상에서 제외된다.
|
||||
*
|
||||
* @param string $path 검사할 경로
|
||||
* @return bool junction 여부
|
||||
*/
|
||||
protected static function isReparsePoint(string $path): bool
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 일반 symlink / 파일 은 junction 아님.
|
||||
if (is_link($path) || is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$target = @readlink($path);
|
||||
if ($target === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// readlink 가 자기 경로와 다른 target 을 반환하면 junction. (일반 디렉토리는 자기 경로
|
||||
// 반환 → 같으므로 false.) 경로 구분자/대소문자 차이를 흡수하기 위해 정규화 후 비교.
|
||||
$normalize = static fn (string $p): string => strtolower(str_replace('/', '\\', rtrim($p, '/\\')));
|
||||
|
||||
return $normalize($target) !== $normalize($path);
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,9 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 모듈 설치 완료
|
||||
HookManager::doAction('core.modules.installed', $moduleName);
|
||||
|
||||
@@ -648,6 +651,10 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 모듈이 선언한 정책이
|
||||
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 모듈이 선언한 미들웨어가
|
||||
// 게이트 매칭 후보에 다시 포함되도록 한다.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
|
||||
// 훅 발행: 모듈 활성화 완료
|
||||
@@ -791,6 +798,9 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 비활성 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
|
||||
}
|
||||
@@ -963,6 +973,9 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// 모듈 상태 캐시 무효화
|
||||
self::invalidateModuleStatusCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 제거된 모듈의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 활성 모듈 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
|
||||
$onProgress?->__invoke('files', '파일 삭제 중...');
|
||||
ExtensionPendingHelper::deleteExtensionDirectory($this->modulesPath, $module->getIdentifier());
|
||||
@@ -3763,6 +3776,9 @@ class ModuleManager implements ModuleManagerInterface
|
||||
// pending/bundled 목록에서 제거
|
||||
unset($this->pendingModules[$moduleName]);
|
||||
unset($this->bundledModules[$moduleName]);
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 재로드된 모듈의 갱신된 미들웨어 선언 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -494,6 +494,9 @@ class PluginManager implements PluginManagerInterface
|
||||
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 훅 발행: 플러그인 설치 완료
|
||||
HookManager::doAction('core.plugins.installed', $pluginName);
|
||||
|
||||
@@ -629,6 +632,10 @@ class PluginManager implements PluginManagerInterface
|
||||
// 본인인증 route scope 캐시 무효화 — 재활성화 시 이 플러그인이 선언한 정책이
|
||||
// 다시 enforce 대상에 포함되도록 한다 (applyActiveExtensionScope 재평가).
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 재활성화 시 이 플러그인이 선언한 미들웨어가
|
||||
// 게이트 매칭 후보에 다시 포함되도록 한다.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
|
||||
// 훅 발행: 플러그인 활성화 완료
|
||||
@@ -775,6 +782,9 @@ class PluginManager implements PluginManagerInterface
|
||||
// IdentityPolicy 모델 이벤트가 발화하지 않아, 라이프사이클에서 명시적으로 호출한다.
|
||||
IdentityPolicy::flushRouteScopeCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 비활성 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 요구사항 #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
|
||||
HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
|
||||
}
|
||||
@@ -991,6 +1001,9 @@ class PluginManager implements PluginManagerInterface
|
||||
// 플러그인 상태 캐시 무효화
|
||||
self::invalidatePluginStatusCache();
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 제거된 플러그인의 미들웨어가 게이트 매칭에서 즉시 제외.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
|
||||
// 활성 플러그인 디렉토리 전체 삭제 (_pending/_bundled에 원본 보존되므로 재설치 가능)
|
||||
$onProgress?->__invoke('files', '파일 삭제 중...');
|
||||
ExtensionPendingHelper::deleteExtensionDirectory($this->pluginsPath, $plugin->getIdentifier());
|
||||
@@ -3920,6 +3933,9 @@ class PluginManager implements PluginManagerInterface
|
||||
// pending/bundled 목록에서 제거
|
||||
unset($this->pendingPlugins[$pluginName]);
|
||||
unset($this->bundledPlugins[$pluginName]);
|
||||
|
||||
// 확장 미들웨어 인덱스 무효화 — 재로드된 플러그인의 갱신된 미들웨어 선언 반영.
|
||||
ExtensionMiddlewareRegistry::flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pipeline\Pipeline;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 확장 선언 미들웨어의 self-gate 실행기.
|
||||
*
|
||||
* 확장이 getMiddleware() 로 선언한 미들웨어를 web/api 그룹에 직접 넣는 대신,
|
||||
* 이 래퍼가 그룹에 등록되어 요청 시점에 현재 라우트 이름·URI 가 각 확장 미들웨어의
|
||||
* targets 패턴에 매칭될 때만 해당 미들웨어를 동적으로 실행한다. 매칭 없으면
|
||||
* 즉시 통과 (코어 EnforceIdentityPolicy 의 라우트명 인덱스 조회 모델과 동일).
|
||||
*
|
||||
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
|
||||
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피한다.
|
||||
*
|
||||
* bootstrap/app.php 에서 web/api × before_core/after_core 로 총 4회 등록된다.
|
||||
* Kernel 상 prepend/append 위치는 등록 시점 매핑이고, 게이트 실행 로직은 timing 을 안다.
|
||||
*
|
||||
* @since 7.0.5
|
||||
*/
|
||||
class ExtensionMiddlewareGate
|
||||
{
|
||||
/**
|
||||
* @param Application $app Pipeline 실행용 컨테이너
|
||||
* @param ExtensionMiddlewareRegistryInterface $registry 확장 미들웨어 매칭 인덱스
|
||||
*/
|
||||
public function __construct(
|
||||
protected Application $app,
|
||||
protected ExtensionMiddlewareRegistryInterface $registry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 미들웨어 진입점.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Closure $next 다음 파이프라인
|
||||
* @param string $group 이 래퍼가 등록된 그룹 ('web'|'api')
|
||||
* @param string $timing 이 래퍼 인스턴스가 담당하는 실행 시점 ('before_core'|'after_core')
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $group, string $timing): Response
|
||||
{
|
||||
// 라우트명(무명이면 빈 문자열)과 URI path(항상 존재, 선행 슬래시 없음) 둘 다 전달 —
|
||||
// 무명 SSR 셸 catch-all 은 URI 패턴 target 으로만 매칭되므로 routeName 빈 값에 조기 통과하지 않는다.
|
||||
$routeName = $request->route()?->getName() ?? '';
|
||||
$path = $request->path();
|
||||
$matched = $this->registry->resolveForRoute($routeName, $path, $group, $timing);
|
||||
|
||||
if ($matched === []) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 매칭된 확장 미들웨어들을 Laravel Pipeline 으로 순차 실행 후 $next.
|
||||
return (new Pipeline($this->app))
|
||||
->send($request)
|
||||
->through($matched)
|
||||
->then($next);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
@@ -42,6 +43,7 @@ use App\Enums\DeactivationReason;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\ExtensionManager;
|
||||
use App\Extension\ExtensionMiddlewareRegistry;
|
||||
use App\Extension\HookCacheManager;
|
||||
use App\Extension\HookListenerRegistrar;
|
||||
use App\Extension\HookManager;
|
||||
@@ -250,6 +252,9 @@ class CoreServiceProvider extends ServiceProvider
|
||||
$this->app->bind(CacheInterface::class, function () {
|
||||
return new CoreCacheDriver(config('cache.default'));
|
||||
});
|
||||
|
||||
// 확장 선언 미들웨어 self-gate 인덱스 (요청 내 인덱스 1회 빌드 → singleton)
|
||||
$this->app->singleton(ExtensionMiddlewareRegistryInterface::class, ExtensionMiddlewareRegistry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,6 @@ class SeoRenderer implements SeoRendererInterface
|
||||
'googleAnalyticsId' => $meta['googleAnalyticsId'],
|
||||
'googleVerification' => $meta['googleVerification'],
|
||||
'naverVerification' => $meta['naverVerification'],
|
||||
'cssPath' => $this->getCssPath(),
|
||||
'stylesheets' => $allStylesheets,
|
||||
'extraHeadTags' => '',
|
||||
'extraBodyEnd' => '',
|
||||
@@ -732,28 +731,6 @@ class SeoRenderer implements SeoRendererInterface
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite 빌드 CSS 경로를 반환합니다.
|
||||
*
|
||||
* @return string CSS 경로
|
||||
*/
|
||||
private function getCssPath(): string
|
||||
{
|
||||
$manifestPath = public_path('build/manifest.json');
|
||||
if (file_exists($manifestPath)) {
|
||||
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||
foreach ($manifest as $entry) {
|
||||
if (isset($entry['css'])) {
|
||||
foreach ($entry['css'] as $css) {
|
||||
return '/build/'.$css;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '/build/assets/app.css';
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 렌더링용 _global 컨텍스트를 구성합니다.
|
||||
*
|
||||
|
||||
@@ -904,7 +904,7 @@ class CoreUpdateService
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용
|
||||
* @param array<int, string>|null $applyList 적용 대상 상대경로 목록(target 접두사 포함).
|
||||
* `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기.
|
||||
* `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기.
|
||||
*/
|
||||
public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void
|
||||
{
|
||||
@@ -953,6 +953,12 @@ class CoreUpdateService
|
||||
// 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단.
|
||||
// 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다.
|
||||
$preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled');
|
||||
|
||||
// `public` 타깃 prune 시 `public/storage` symlink 를 orphan 삭제에서 보호한다 —
|
||||
// 릴리즈 소스에는 런타임 symlink 가 없으므로 orphan 으로 판정되어 삭제되면
|
||||
// 업로드 파일이 404 되는 결함(#43). 다른 타깃은 빈 배열이라 영향 없음.
|
||||
$preserveLinkPaths = $normalizedTarget === 'public' ? ['storage'] : [];
|
||||
|
||||
FilePermissionHelper::copyDirectory(
|
||||
$src,
|
||||
$dest,
|
||||
@@ -961,6 +967,7 @@ class CoreUpdateService
|
||||
removeOrphans: $prune,
|
||||
preserveTopLevelOrphans: $preserveTopLevelOrphans,
|
||||
applyList: $targetApplyList,
|
||||
preserveLinkPaths: $preserveLinkPaths,
|
||||
);
|
||||
} else {
|
||||
// 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄을 복제합니다.
|
||||
*
|
||||
|
||||
@@ -198,7 +198,10 @@ class SettingsService
|
||||
$result[$frontendKey] = $this->castValue($value, $fieldSchema);
|
||||
}
|
||||
|
||||
return $result;
|
||||
// 확장이 요청 컨텍스트 기반 값(예: 기기 유형)을 appConfig 에 주입할 수 있도록 필터 훅 제공.
|
||||
// config/frontend.php 는 정적 config 값만 담으므로, 요청별로 달라지는 값은 이 훅으로 확장이 채운다.
|
||||
// 프론트는 window.G7Config.appConfig → _global.appConfig 로 그대로 받는다.
|
||||
return HookManager::applyFilters('core.frontend.filter_app_config', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Http\Middleware\AdminMiddleware;
|
||||
use App\Http\Middleware\CheckTemplateDependencies;
|
||||
use App\Http\Middleware\CheckUserStatus;
|
||||
use App\Http\Middleware\EnforceIdentityPolicy;
|
||||
use App\Http\Middleware\ExtensionMiddlewareGate;
|
||||
use App\Http\Middleware\GzipEncodeResponse;
|
||||
use App\Http\Middleware\MaintenanceModePage;
|
||||
use App\Http\Middleware\OptionalSanctumMiddleware;
|
||||
@@ -106,6 +107,17 @@ $app = Application::configure(basePath: dirname(__DIR__))
|
||||
EnforceIdentityPolicy::class,
|
||||
]);
|
||||
|
||||
// 확장 선언 미들웨어 self-gate 래퍼 — 확장이 getMiddleware() 로 선언한 미들웨어를
|
||||
// 요청 시점에 라우트명·URI 매칭으로 실행. web/api × before_core/after_core 로 4회 등록.
|
||||
// - before_core → 그룹 선두(prepend): 코어 전처리(인증·SetLocale·IDV) 전체보다 먼저 실행
|
||||
// - after_core → 그룹 후미(append): 코어 그룹 미들웨어 뒤에서 실행
|
||||
// Kernel 의 prepend/append 는 코어 내부 구현이고, 확장 개발자·게이트 파라미터는
|
||||
// before_core/after_core 의도 명칭만 본다. 매칭 확장 미들웨어가 없으면 no-op.
|
||||
foreach (['web', 'api'] as $extGroup) {
|
||||
$middleware->prependToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},before_core");
|
||||
$middleware->appendToGroup($extGroup, ExtensionMiddlewareGate::class.":{$extGroup},after_core");
|
||||
}
|
||||
|
||||
// 비인증 게스트 redirect 경로 가드 (공개#39).
|
||||
// Laravel 12 기본값은 `redirectGuestsTo(fn () => route('login'))` 인데,
|
||||
// 이 프로젝트엔 'login' 이름 라우트가 없어 Accept 헤더 없는 /api/* 비인증 요청이
|
||||
@@ -134,6 +146,7 @@ $app = Application::configure(basePath: dirname(__DIR__))
|
||||
'start.api.session' => StartApiSession::class,
|
||||
'seo' => SeoMiddleware::class,
|
||||
'identity.policy' => EnforceIdentityPolicy::class,
|
||||
'extension.middleware' => ExtensionMiddlewareGate::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.3'),
|
||||
'version' => env('APP_VERSION', '7.0.5'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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 캐시·사이트맵·생성기 설정) |
|
||||
|
||||
@@ -210,6 +210,8 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
--prune 모드 (또는 백업 부재 fallback):
|
||||
- targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작)
|
||||
- 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력
|
||||
- `public/storage` symlink 는 orphan 삭제에서 보호됨 — `public` 타깃 정리 시
|
||||
`preserveLinkPaths: ['storage']` 화이트리스트로 링크/junction 을 보존 (#43, 아래 §참조)
|
||||
|
||||
공통:
|
||||
- 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용
|
||||
@@ -222,6 +224,12 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
|
||||
> **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)".
|
||||
|
||||
> **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다.
|
||||
> - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님).
|
||||
> - **층 2 (복구)**: 업데이트 종료 시점(정상 Step 11 + 핸드오프 catch)에 `StorageLinkHelper::ensurePublicStorageLink()` 를 호출해 `public/storage` 가 정상 링크인지 확인하고, 부재/손상이면 `storage/app/public` 을 가리키는 링크를 (필요 시 `.broken.{YmdHis}` rename 백업 후) 재생성한다. 버전 무관 매 업데이트 실행. Windows `SeCreateSymbolicLink` 권한 부족 시 junction(`mklink /J`) 폴백까지 시도하고, 그래도 실패하면 rename 원복 + 수동 `storage:link` 안내(데이터 손실 없음). 롤백 catch 경로는 백업 복원이 링크를 원상 회복하므로 대상 아님.
|
||||
> - **소유권 상속**: `symlink()` 은 소유권 인자가 없어 sudo 실행 시 링크가 root:root 로 생성된다. 재생성 직후 부모 `public/` 의 owner/group 을 기준으로 `lchown`/`lchgrp`(링크 자체 대상 — `chown` 은 target 을 따라감) 보정해 원래 앱 실행 유저 소유를 유지한다. 이 프로젝트의 "신규 항목은 부모 소유권 상속"(`FilePermissionHelper`) 컨벤션과 일치. 비-POSIX/함수 부재/권한 부족 시 무해하게 skip.
|
||||
> - 이 복구 로직은 beta.5 DataMigration `RecoverPublicStorageSymlink` 와 `StorageLinkHelper` 로 일원화되어 있다(부재→재생성 케이스까지 상위호환).
|
||||
|
||||
> **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다.
|
||||
|
||||
### Step 9: 마이그레이션 + 동기화
|
||||
@@ -671,7 +679,8 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 |
|
||||
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제. `public` 타깃은 `copyDirectory(..., preserveLinkPaths: ['storage'])` 로 `public/storage` symlink/junction 을 orphan 삭제에서 보호 (#43) |
|
||||
| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). 재생성 링크는 부모 `public/` 의 소유자/그룹을 `lchown`/`lchgrp` 상속(sudo 후 root:root 잔존 차단). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) |
|
||||
| `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) |
|
||||
| `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 |
|
||||
| `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install |
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
3. 실행 순서: 전역 → 그룹(web/api) → 라우트 → 컨트롤러
|
||||
4. permission 미들웨어: scope_type 기반 접근 제어 (except/only/menu 옵션 폐기)
|
||||
5. scope 체크: Permission.resource_route_key + owner_key + role_permissions.scope_type
|
||||
6. 확장 미들웨어 → getMiddleware() 선언(self-gate). SP Kernel 직접 조작·라우트 파일 자기 FQCN 금지
|
||||
```
|
||||
|
||||
---
|
||||
@@ -21,6 +22,7 @@
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [미들웨어 실행 순서](#미들웨어-실행-순서)
|
||||
- [미들웨어 등록 방식](#미들웨어-등록-방식)
|
||||
- [확장 미들웨어 선언 (self-gate)](#확장-미들웨어-선언-self-gate)
|
||||
- [올바른 등록 예시](#올바른-등록-예시)
|
||||
- [문제 상황과 해결책](#문제-상황과-해결책)
|
||||
- [디버깅 방법](#디버깅-방법)
|
||||
@@ -81,6 +83,7 @@
|
||||
| `appendToGroup('api', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
|
||||
| `appendToGroup('web', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
|
||||
| `alias()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 |
|
||||
| `getMiddleware()` (확장 self-gate) | 요청 시점 대상 매칭 | timing 에 따름 | 확장(모듈/플러그인)의 미들웨어 — [아래 절](#확장-미들웨어-선언-self-gate) |
|
||||
|
||||
### append vs appendToGroup 차이
|
||||
|
||||
@@ -93,6 +96,73 @@
|
||||
|
||||
---
|
||||
|
||||
## 확장 미들웨어 선언 (self-gate)
|
||||
|
||||
확장(모듈/플러그인)은 자기 미들웨어를 web/api 그룹에 직접 넣지 않는다. 각 확장이 `getMiddleware()` 로 미들웨어와 그 부착 대상을 선언하고, 코어 게이트 래퍼(`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 대상 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행한다. 코어 IDV 정책의 라우트명 인덱스 조회 모델과 동일하다.
|
||||
|
||||
배경: 확장 라우트는 부팅 이후 지연 등록되고 확장 간 부팅 순서 보장이 없어, 부팅 시점에 다른 확장(또는 코어 특정 라우트)의 라우트를 순회해 미들웨어를 부착하는 방식은 성립하지 않는다. 요청 시점 매칭은 모든 라우트가 등록 완료된 뒤라 이 제약을 원천 회피한다.
|
||||
|
||||
### getMiddleware() 스키마
|
||||
|
||||
```php
|
||||
public function getMiddleware(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증)
|
||||
'groups' => ['api'], // ['web'] | ['api'] | ['web','api']
|
||||
'timing' => 'after_core', // 'after_core'(기본) | 'before_core'
|
||||
'targets' => ['self'], // 라우트명/URI 패턴 배열 (필수, 아래 카탈로그)
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 필수 | 의미 |
|
||||
|------|------|------|
|
||||
| `class` | O | 미들웨어 FQCN. `class_exists` 실패 시 등록 거부 |
|
||||
| `groups` | O | `['web']` \| `['api']` \| `['web','api']`. 빈 배열·미허용 그룹은 등록 거부 |
|
||||
| `timing` | X | `after_core`(기본, 코어 그룹 미들웨어 뒤) \| `before_core`(코어 전처리 전체보다 먼저). `before_core` 는 인증 결과·로케일에 의존하는 미들웨어에 쓰지 않는다 (응답 사전차단/전처리 전용) |
|
||||
| `targets` | O | 부착 대상 패턴 배열. 누락·빈 배열 시 등록 거부 |
|
||||
|
||||
### targets 카탈로그
|
||||
|
||||
| 값 | 의미 | 매칭 방식 |
|
||||
|----|------|----------|
|
||||
| `self` | 자기 확장 라우트 전체 | 항목 groups 의 각 그룹마다 `{group}.modules.{id}.*` / `{group}.plugins.{id}.*` prefix 로 치환 |
|
||||
| `all_extensions` | 모든 확장 라우트 (코어 제외) | 라우트명이 `*.modules.*` 또는 `*.plugins.*` |
|
||||
| `core` | 코어 라우트 전체 (확장 제외) | 라우트명이 확장 prefix 가 **아닌** 것 (negative 판별). 무명 라우트는 core 로 간주하지 않음 |
|
||||
| `everything` (별칭 `*`) | 코어 + 모든 확장 (전부) | 무조건 매칭 (무명 라우트 포함) |
|
||||
| `module:{id}` | 특정 모듈 라우트 전체 | `*.modules.{id}.*` |
|
||||
| `plugin:{id}` | 특정 플러그인 라우트 전체 | `*.plugins.{id}.*` |
|
||||
| 원시 glob 문자열 | 임의 라우트 이름 패턴 (`api.modules.foo.cart.*`) | `Str::is()` 라우트명 매칭 |
|
||||
| brace 문자열 | `{a,b}` 단일 그룹 전개 | 전개 후 각 glob 매칭 |
|
||||
| `/` 로 시작하는 URI 패턴 | 임의 요청 URI 패턴 (`/`, `/admin/*`) — 무명 라우트 타게팅용 | `$request->path()` 매칭 |
|
||||
|
||||
`everything`·`*`, `all_extensions`, `core` 는 코어·타 확장에 개입하는 **광역 타게팅** 이다. 선언에 대상이 명문화되므로 감사 가능하며, 리뷰 시 주의 대상으로 표기한다. 자기 라우트 중 일부만 노려야 하면 `self` 대신 원시 glob 으로 정밀화한다.
|
||||
|
||||
### 이중 매칭 (라우트명 + URI) — 무명 catch-all 대응
|
||||
|
||||
target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아니면 **라우트명 패턴**(`$request->route()?->getName()` 매칭)이다. 라우트명은 dot-notation 이라 `/` 로 시작하지 않으므로 구분이 명확하다. 코어 SSR 셸 catch-all 라우트는 `->name()` 이 없어(무명) 라우트명으로 타게팅할 수 없다 → 이런 무명 라우트를 대상으로 하려면 URI 패턴(`/`)이나 `everything` 을 쓴다. 라우트명 계열 target 은 무명 라우트에서 항상 miss 한다.
|
||||
|
||||
### 게이트 동작과 무효화
|
||||
|
||||
- 게이트 래퍼는 코어가 bootstrap 에서 web/api × before_core/after_core 로 4회 등록한다. 매칭 확장 미들웨어가 없으면 no-op 이라 확장 0개 환경에서도 무해하다.
|
||||
- registry 인덱스는 **활성 확장만** 포함한다. 비활성 확장의 미들웨어는 게이트가 실행하지 않는다.
|
||||
- 인덱스는 확장 활성/비활성/설치/제거/리로드 시 자동 무효화된다.
|
||||
|
||||
### 금지 패턴
|
||||
|
||||
| 금지 | 올바른 방식 |
|
||||
|------|------------|
|
||||
| 확장 SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` 직접 호출 | `getMiddleware()` 선언 |
|
||||
| 확장 SP 에서 `aliasMiddleware()` 직접 호출 | `getMiddleware()` 선언 |
|
||||
| 확장 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착 | `getMiddleware()` 선언 |
|
||||
|
||||
자동 차단: audit 룰 `extension-middleware-declarative-registration`, `extension-route-middleware-alias-reference`, `extension-middleware-gate-cache-coverage` (모두 error). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다.
|
||||
|
||||
---
|
||||
|
||||
## 올바른 등록 예시
|
||||
|
||||
### bootstrap/app.php
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 |
|
||||
| `getDependencies()` | `[]` | 의존성 |
|
||||
| `getMetadata()` | `[]` | 메타데이터 |
|
||||
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 모듈은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `ModuleManager::runUpgradeSteps` 가 `RuntimeException` throw |
|
||||
|
||||
#### 동적 권한/역할/메뉴 보존 규칙
|
||||
|
||||
@@ -74,6 +74,8 @@ vendor-my-module → Modules\Vendor\My\Module
|
||||
| URL prefix | `modules/{module-name}` |
|
||||
| Name prefix | `web.modules.{module-name}.` |
|
||||
|
||||
> 라우트 이름(`{web\|api}.modules.{module-name}.{name}`)은 확장 미들웨어 타게팅의 1급 키다. `Module::getMiddleware()` 의 `targets` 는 이 라우트명 패턴으로 미들웨어 부착 대상을 지정한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate).
|
||||
|
||||
---
|
||||
|
||||
## 4. 라우트 파일 작성
|
||||
|
||||
@@ -240,6 +240,7 @@ class Plugin implements PluginInterface
|
||||
| `getDynamicRoleIdentifiers()` | `[]` | 런타임 생성 역할 식별자 — stale cleanup 보존 대상 |
|
||||
| `getDependencies()` | `[]` | 의존하는 모듈/플러그인 목록 |
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 클래스 목록 |
|
||||
| `getMiddleware()` | `[]` | 확장 미들웨어 선언 (self-gate targets) — `{class, groups, timing?, targets}` ([middleware.md](../backend/middleware.md)) |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견). **`g7_version >= 7.0.0-beta.5` 인 플러그인은 신규 step 이 `AbstractUpgradeStep` 상속 의무** ([upgrade-step-guide §13](upgrade-step-guide.md)) — 미상속 시 `PluginManager::runUpgradeSteps` 가 `RuntimeException` throw |
|
||||
|
||||
> **동적 식별자 보존 규칙**: `Permission::updateOrCreate()` / `Role::firstOrCreate()` 등으로 런타임에 생성한 엔티티는 업데이트 시 `cleanupStalePluginEntries` 에 의해 "정적 정의에 없는 고아 레코드" 로 판정되어 삭제될 위험이 있습니다. 이를 방지하려면 동적 식별자 목록을 위 3개 훅에서 반환하세요 — 정적 정의 + 동적 식별자가 병합된 expected 목록을 기준으로 판정되어 보존됩니다. 상세는 [extension-update-system.md](extension-update-system.md) 참조.
|
||||
@@ -394,6 +395,7 @@ class FooPluginServiceProvider extends BasePluginServiceProvider
|
||||
- **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다.
|
||||
- **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다.
|
||||
- **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다.
|
||||
- **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. audit 룰 `extension-middleware-declarative-registration` / `extension-route-middleware-alias-reference` 가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate).
|
||||
|
||||
상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md)
|
||||
|
||||
|
||||
@@ -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. 참고
|
||||
|
||||
|
||||
@@ -74,14 +74,27 @@ test('@smoke 홈페이지 마운트', async ({ page }) => {
|
||||
|
||||
기존 PHPUnit testsuite (`Unit`/`Feature`/`Module`/`Plugin`) 및 Vitest 의 코어/확장 분리 원칙과 동일.
|
||||
|
||||
확장이 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**:
|
||||
확장은 `tests/Playwright/playwright.config.ts` 에 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**한다.
|
||||
config 가 확장 루트가 아니라 `tests/Playwright/` 아래에 있으므로 `npx playwright test` 를 인자 없이 부르면
|
||||
config 를 찾지 못한다 — `npm run test:e2e` (config 경로가 이미 묶여 있음) 를 쓴다.
|
||||
|
||||
```powershell
|
||||
cd modules/_bundled/sirsoft-ecommerce
|
||||
$env:PLAYWRIGHT_BASE_URL='https://g7.dev'
|
||||
npx playwright test
|
||||
npm run test:e2e # 확장 전체
|
||||
npm run test:e2e -- specs/admin/some.spec.ts # 단일 spec
|
||||
npm run test:e2e:ui # UI 디버깅 모드
|
||||
```
|
||||
|
||||
base URL 은 코어 `.env` 의 `APP_URL` 에서 자동 해석된다 (`PLAYWRIGHT_BASE_URL` 로 덮어쓸 수 있다).
|
||||
|
||||
### 실행 시 유의 (경험칙)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| --- | --- |
|
||||
| 산출물 위치 | 리포트·trace 는 **코어 루트**의 `test-results/{type}/{id}/`, `playwright-report/{type}/{id}/` 에 쌓인다. 확장 디렉토리 안에 쌓으면 Windows 에서 `{module\|template}:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다 |
|
||||
| 레이아웃 수정 후 | 레이아웃 JSON 을 고쳤으면 `{type}:update {id} --force` **+ `template:cache-clear {템플릿}`** 까지 해야 브라우저에 반영된다. 레이아웃은 템플릿 캐시(`/api/layouts/{template}/...`)로 서빙되므로 `cache:clear` 만으로는 갱신되지 않는다 |
|
||||
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 설정 저장을 수반하는 spec 은 `--workers=1` 로 확인한다 |
|
||||
|
||||
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
|
||||
|
||||
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|
||||
|
||||
@@ -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-16
|
||||
|
||||
### 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,18 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.4] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- 주문설정 결제수단 목록의 "PG 고정" 표시에 대한 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.3] - 2026-07-14
|
||||
|
||||
### Added
|
||||
|
||||
- 알림 설정의 "무통장 입금 안내"·"배송 완료" 알림 이름에 대한 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.2] - 2026-07-10
|
||||
|
||||
### Removed
|
||||
|
||||
+5
-2
@@ -151,7 +151,8 @@
|
||||
"pg_not_installed": "PG未インストール",
|
||||
"pg_select_first": "PG社をまず選択してください",
|
||||
"mileage_deduction_timing": "マイレージ差し引き時点",
|
||||
"mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。"
|
||||
"mileage_deduction_timing_disabled_hint": "マイレージ使用がオフになっているため、差し引き時点が適用されません。",
|
||||
"pg_fixed": "PG 固定"
|
||||
},
|
||||
"bank_accounts": {
|
||||
"title": "無通帳口座番号設定",
|
||||
@@ -458,7 +459,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.4",
|
||||
"license": "MIT",
|
||||
"scope": "module",
|
||||
"target_identifier": "sirsoft-ecommerce",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_kginicis",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- 환불 진행 중 안내(`refund.in_progress`)와 KCP CLI 바이너리 누락·실행 권한 부족 안내(`errors.cli_binary_missing`, `errors.cli_binary_not_executable`) 일본어 번역 추가 — 환불 중복 요청·CLI 실행 환경 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문 설정 화면의 NHN KCP 테스트모드 경고(`nhnkcp_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NHN KCP決済キャンセルが確認されました(注文 :order_number、返金金額 :refund_amount円)。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'refund' => [
|
||||
'missing_tid' => '取引番号(tno)がないため返金を進めることができません。',
|
||||
'default_reason' => '購入者返金リクエスト',
|
||||
'in_progress' => 'NHN KCP 返金はすでに処理中です。',
|
||||
],
|
||||
'errors' => [
|
||||
'wsdl_missing' => 'KCP WSDL ファイルがありません: :file',
|
||||
'approval_key_error' => 'KCP 承認キーエラー [:code]: :message',
|
||||
'soap_error' => 'KCP SOAP 連携エラー: :message',
|
||||
'cli_binary_missing' => 'KCP CLIバイナリ不足: :path',
|
||||
'cli_binary_not_executable' => 'KCP CLIバイナリ実行権限不足 — 管理者の対応が必要です (sudo chmod 755 :path)',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'payco' => [
|
||||
'name' => 'PAYCO',
|
||||
'description' => 'PAYCOで決済(NHN KCP)',
|
||||
],
|
||||
'naverpay' => [
|
||||
'name' => 'ネイバーペイ(カード)',
|
||||
'description' => 'ネイバーペイクレジットカードで決済(NHN KCP)',
|
||||
],
|
||||
'naverpay_point' => [
|
||||
'name' => 'ネイバーペイ(ポイント)',
|
||||
'description' => 'ネイバーペイマネー/ポイントで決済(NHN KCP)',
|
||||
],
|
||||
'kakaopay' => [
|
||||
'name' => 'カカオペイ',
|
||||
'description' => 'カカオペイで決済(NHN KCP)',
|
||||
],
|
||||
'applepay' => [
|
||||
'name' => 'アップルペイ',
|
||||
'description' => 'アップルペイで決済(NHN KCP)·アップルペイはiOSデバイスのモバイル決済のみ利用可能です。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'NHN KCP',
|
||||
'description' => 'NHN KCP決済ゲートウェイ(Standard Pay連動)— カード·口座振替·仮想口座·携帯電話決済対応',
|
||||
];
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"health": "連携状態",
|
||||
"kcp_escrow_delivery": "エスクロー配送",
|
||||
"kcp_status": "決済状態",
|
||||
"nhnkcp_easy_pay_config": "簡便決済設定",
|
||||
"nhnkcp_test_mode_status": "テストモード状態",
|
||||
"nhnkcp_test_map": "テスト決済マップ",
|
||||
"nicepay_test_map": "テスト決済マップ",
|
||||
"settings": "決済設定",
|
||||
"vbank_info": "仮想口座情報"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"refund": {
|
||||
"missing_tid": "決済取引番号(tno)がありません。",
|
||||
"default_reason": "管理者返金"
|
||||
},
|
||||
"payment": {
|
||||
"auth_failed": "KCP決済認証に失敗しました。",
|
||||
"order_not_found": "注文が見つかりません。",
|
||||
"amount_mismatch": "決済金額が一致しません。",
|
||||
"confirm_failed": "KCP取引照会に失敗しました。"
|
||||
}
|
||||
},
|
||||
"payment_error": {
|
||||
"title": "決済エラー",
|
||||
"message": "決済処理中にエラーが発生しました。"
|
||||
},
|
||||
"user": {
|
||||
"test_mode_badge": "テスト決済",
|
||||
"payment_type_label": "決済方法",
|
||||
"receipt_label": "領収書",
|
||||
"receipt_view": "領収書照会",
|
||||
"cash_receipt_view": "現金領収書照会",
|
||||
"vbank_title": "仮想口座入金案内",
|
||||
"vbank_bank_name": "銀行",
|
||||
"vbank_account": "口座番号",
|
||||
"vbank_holder": "預金者名",
|
||||
"vbank_due": "入金期限",
|
||||
"vbank_notice": "入金期限内に入金いただかない場合、注文は自動キャンセルされます。",
|
||||
"tax_breakdown_title": "税金詳細",
|
||||
"supply_amount": "供給価額",
|
||||
"vat_amount": "付加価値税",
|
||||
"tax_free_amount": "非課税金額",
|
||||
"escrow_badge": "エスクロー"
|
||||
},
|
||||
"admin": {
|
||||
"test_mode_badge": "テスト決済",
|
||||
"test_mode_order_notice": "この注文はテスト決済として処理されました。",
|
||||
"test_mode_settings_warning_plugin": "NHN KCP",
|
||||
"test_mode_settings_warning_title": "(注意)NHN KCP決済設定が現在テスト決済になっています。",
|
||||
"test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。",
|
||||
"test_mode_settings_warning_action": "実決済に設定",
|
||||
"order_info_title": "NHN KCP決済情報",
|
||||
"order_info_description": "DBに保存されたKCP決済情報を表示します。",
|
||||
"pay_status_approved": "承認",
|
||||
"pay_status_cancelled": "キャンセル",
|
||||
"pay_status_unknown": "未確認",
|
||||
"result_tno": "取引番号 (TNO)",
|
||||
"result_app_no": "承認番号",
|
||||
"result_app_time": "承認日時",
|
||||
"result_pay_method": "決済方法",
|
||||
"result_res_cd": "応答コード",
|
||||
"escrow_badge": "エスクロー",
|
||||
"escrow_delivery_title": "KCPエスクロー配送登録",
|
||||
"escrow_delivery_description": "商品発送後、KCPに送り状番号を登録してください。",
|
||||
"escrow_delivery_invoice_label": "送り状番号",
|
||||
"escrow_delivery_invoice_placeholder": "送り状番号入力",
|
||||
"escrow_delivery_courier_label": "配送業者",
|
||||
"escrow_delivery_courier_placeholder": "配送業者選択",
|
||||
"escrow_delivery_already_registered": "配送登録完了",
|
||||
"escrow_delivery_registered_at": "登録日時",
|
||||
"escrow_delivery_invoice_info": "送り状番号",
|
||||
"escrow_delivery_courier_info": "配送業者",
|
||||
"escrow_delivery_register_button": "配送登録",
|
||||
"escrow_delivery_change_button": "配送情報編集",
|
||||
"escrow_delivery_registering": "登録中...",
|
||||
"escrow_delivery_success": "配送情報がKCPに登録されました。",
|
||||
"escrow_delivery_error": "配送登録に失敗しました。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "NHN KCP設定",
|
||||
"description": "NHN KCP決済プラグイン設定を管理します。",
|
||||
"test_mode": "テストモード",
|
||||
"test_mode_hint": "テストモードでは実際の決済は発生しません。",
|
||||
"live_mode_warning_title": "ライブモード有効化",
|
||||
"live_mode_warning_body": "実際の決済が発生します。ライブサイトコードおよびキーを正確に設定したことを確認してください。",
|
||||
"section_test_keys": "テスト認証情報",
|
||||
"test_site_cd": "テストサイトコード (site_cd)",
|
||||
"test_site_cd_hint": "KCPから発行されたテストサイトコード (デフォルト値: T0000)",
|
||||
"test_site_key": "テストサイトキー (site_key)",
|
||||
"test_site_key_hint": "KCPから発行されたテストサイトキー",
|
||||
"section_live_keys": "ライブ認証情報",
|
||||
"live_site_cd": "ライブサイトコード (site_cd)",
|
||||
"live_site_cd_hint": "KCPから発行されたライブサイトコード (SRプレフィックスを除外して入力)",
|
||||
"live_site_cd_example": "例: xxxxxx → 実際に使用するsite_cdはSRxxxxxx",
|
||||
"live_site_key": "ライブサイトキー (site_key)",
|
||||
"live_site_key_hint": "外部に暴露されないよう注意してください。",
|
||||
"section_easy_pay": "簡便決済設定",
|
||||
"section_easy_pay_hint": "使用する簡便決済サービスを選択してください。KCP加盟店契約が完了したサービスのみ有効化してください。",
|
||||
"easy_pay_allow_with_other_pg": "他のPGで使用可能です",
|
||||
"easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG事業者が基本決済方法として設定されている場合でも、チェックアウトページにNHN KCP簡便決済ボタンが表示されます。",
|
||||
"easy_pay_payco": "PAYCO (ペイコ)",
|
||||
"easy_pay_naverpay": "ネイバーペイ",
|
||||
"easy_pay_naverpay_hint": "テストモードでは決済不可",
|
||||
"easy_pay_naverpay_point": "ネイバーペイポイント決済",
|
||||
"easy_pay_naverpay_point_hint": "KCP加盟店ではネイバーペイポイント決済の契約が必要です。[DR02]エラーが発生した場合は無効化してKCPにお問い合わせください。",
|
||||
"easy_pay_kakaopay": "カカオペイ",
|
||||
"easy_pay_kakaopay_hint": "テストモードでは決済不可",
|
||||
"easy_pay_applepay": "アップルペイ",
|
||||
"easy_pay_applepay_hint": "iOS Safariでのみ使用可能です",
|
||||
"section_redirect": "リダイレクトURL",
|
||||
"redirect_success_url": "決済成功リダイレクトURL",
|
||||
"redirect_success_url_hint": "相対パス(/shop/...)または完全URLの両方が可能です。{orderId}は注文番号に自動置換されます。",
|
||||
"redirect_fail_url": "決済失敗リダイレクトURL",
|
||||
"redirect_fail_url_hint": "相対パスまたは完全URLの両方が可能です。エラー情報はクエリパラメータとして自動追加されます。",
|
||||
"section_vbank": "仮想口座設定",
|
||||
"section_vbank_hint": "仮想口座決済に関する設定です。",
|
||||
"vbank_expire_days": "入金有効期限",
|
||||
"vbank_expire_days_hint": "仮想口座発行後、入金可能な期限(1~7日)。",
|
||||
"vbank_expire_days_unit": "日",
|
||||
"section_vbank_notify": "仮想口座入金通知URL",
|
||||
"vbank_notify_hint": "NHN KCP加盟店管理画面でこのURLを入金通知URLとして登録する必要があり、仮想口座決済が自動的に処理されます。",
|
||||
"vbank_notify_copied": "URLがクリップボードにコピーされました。",
|
||||
"copy": "コピー",
|
||||
"section_escrow": "エスクロー設定",
|
||||
"section_escrow_hint": "エスクロー決済を使用すると、購入者が購入確認後、販売者に決済金額が支払われます。",
|
||||
"use_escrow": "エスクロー決済を使用",
|
||||
"use_escrow_hint": "有効化するとすべての決済にエスクローが適用されます。",
|
||||
"escrow_test_site_cd": "エスクロー テストサイトコード",
|
||||
"escrow_test_site_cd_hint": "KCPエスクロー テスト用site_cd(デフォルト:T0007)",
|
||||
"section_escrow_notify": "エスクロー共通通知URL",
|
||||
"escrow_common_notify_hint": "NHN KCP加盟店管理画面でこのURLを共通通知URLとして登録する必要があり、購入確認·キャンセルが自動的に処理されます。",
|
||||
"escrow_common_notify_copied": "URLがクリップボードにコピーされました。"
|
||||
},
|
||||
"easy_pay": {
|
||||
"section_title": "簡便決済"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nhnkcp",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "NHN KCP",
|
||||
"description": "NHN KCP 決済ゲートウェイ (Standard Pay 連携)"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- 가상계좌 환불 검증 안내(`errors.vbank_refund_*` — 환불 대상 주문 없음·환불 불가 결제·환불 금액 불일치·환불 처리 진행 중) 일본어 번역 추가 — 관리자 가상계좌 환불 요청 검증 실패 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
- 주문 설정 화면의 나이스페이먼츠 테스트모드 경고(`nicepay_test_mode_status`, `test_mode_settings_warning_*`) 일본어 번역 추가 — 테스트결제 상태 안내와 실결제 설정 이동 버튼이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'action' => [
|
||||
'cancel' => 'PG決済キャンセル',
|
||||
],
|
||||
'description' => [
|
||||
'payment_cancel' => 'NICEペイメンツ決済キャンセルが確認されました (注文 :order_number、返金金額 :refund_amount円)。',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'errors' => [
|
||||
'tid_required' => 'TIDを入力してください。',
|
||||
'order_not_found' => '注文が見つかりません。',
|
||||
'invalid_request' => '無効なリクエストです。',
|
||||
'invalid_amount' => 'リクエスト金額が有効ではありません。',
|
||||
'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。',
|
||||
'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。',
|
||||
'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount円)',
|
||||
'vbank_refund_order_not_found' => '返金する注文が見つかりません。',
|
||||
'vbank_refund_invalid_payment' => '返金可能なNICEペイメンツ仮想口座入金完了決済ではありません。',
|
||||
'vbank_refund_amount_mismatch' => '要求された返金金額がDBのキャンセル可能金額と一致していません。',
|
||||
'vbank_refund_already_processing' => '既に仮想口座返金処理が進行中です。',
|
||||
],
|
||||
'refund' => [
|
||||
'missing_tid' => '取引ID(TID)がないため返金を進めることができません。',
|
||||
'default_reason' => '購入者返金リクエスト',
|
||||
],
|
||||
'defaults' => [
|
||||
'vbank_refund_msg' => '仮想口座返金',
|
||||
],
|
||||
];
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'naverpay' => [
|
||||
'name' => 'Naver Pay (ナイスペイメンツ)',
|
||||
'description' => 'Naver Payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'kakaopay' => [
|
||||
'name' => 'KakaoPay (ナイスペイメンツ)',
|
||||
'description' => 'KakaoPayで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'samsungpay' => [
|
||||
'name' => 'Samsung Pay (ナイスペイメンツ)',
|
||||
'description' => 'Samsung Payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'applepay' => [
|
||||
'name' => 'Apple Pay (ナイスペイメンツ)',
|
||||
'description' => 'Apple Payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'payco' => [
|
||||
'name' => 'PAYCO (ナイスペイメンツ)',
|
||||
'description' => 'PAYCOで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'skpay' => [
|
||||
'name' => '11pay (ナイスペイメンツ)',
|
||||
'description' => '11payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'ssgpay' => [
|
||||
'name' => 'SSG Pay (ナイスペイメンツ)',
|
||||
'description' => 'SSG Payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
'lpay' => [
|
||||
'name' => 'L.pay (ナイスペイメンツ)',
|
||||
'description' => 'L.payで決済 (ナイスペイメンツ)',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'ナイスペイメンツ',
|
||||
'description' => '国内PG企業ナイスペイメンツ決済連携(カード/口座振替/仮想口座/携帯電話 + 7種簡便決済)',
|
||||
];
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"nicepay_easy_pay_config": "簡易決済設定",
|
||||
"nicepay_escrow": "エスクロー",
|
||||
"nicepay_status": "決済ステータス",
|
||||
"nicepay_test_mode_status": "テストモード状態",
|
||||
"nicepay_test_map": "テスト決済マップ",
|
||||
"settings": "決済設定",
|
||||
"vbank_info": "仮想口座情報"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "NicePayments設定",
|
||||
"description": "NicePayments PG連動設定",
|
||||
"test_mode": "テストモード",
|
||||
"test_mode_hint": "テストモードでも実際のカード承認・出金通知が発生する場合があり、テストアカウント決済は当日23時30分頃一括自動キャンセルされます。",
|
||||
"section_test_keys": "テストキー設定",
|
||||
"test_mid": "テスト加盟店ID(MID)",
|
||||
"test_mid_hint": "NicePayments公用テストMIDです。テスト専用であり、変更する必要はありません。",
|
||||
"test_merchant_key": "テスト加盟店キー",
|
||||
"test_merchant_key_hint": "NicePayments公用テストキーです。テスト専用であり、変更する必要はありません。",
|
||||
"section_live_keys": "ライブキー設定",
|
||||
"live_mid": "ライブ加盟店ID(MID)",
|
||||
"live_mid_hint": "NicePayments店舗管理者 > 加盟店情報 > KEY管理から確認。「SR」を除いた残りだけを入力してください。",
|
||||
"live_mid_example": "例)店舗IDがSRpaytestmの場合、paytestmのみを入力 → 完成:SRpaytestm",
|
||||
"live_merchant_key": "ライブ加盟店キー",
|
||||
"live_merchant_key_hint": "外部に公開されないよう注意してください。",
|
||||
"live_mode_warning_title": "ライブモード有効化",
|
||||
"live_mode_warning_body": "現在、実際の決済が処理されています。ライブMIDと加盟店キーが正しく設定されていることを確認してください。",
|
||||
"section_vbank_notify": "仮想口座入金通知URL",
|
||||
"vbank_notify_hint": "仮想口座使用時は、以下のURLをNicePayments加盟店管理者 → 加盟店情報設定に登録してください。入金が完了するとこのアドレスに自動通知されます。",
|
||||
"vbank_notify_copied": "URLがクリップボードにコピーされました。",
|
||||
"copy": "コピー",
|
||||
"section_redirect": "リダイレクトURL",
|
||||
"redirect_success_url": "決済成功リダイレクトURL",
|
||||
"redirect_success_url_hint": "相対パス(/shop/...)または完全URL(https://...)の両方が可能です。{orderId}は注文番号に自動置換されます。",
|
||||
"redirect_fail_url": "決済失敗リダイレクトURL",
|
||||
"redirect_fail_url_hint": "相対パスまたは完全URL両方が可能です。エラー情報はクエリパラメーターとして自動追加されます。",
|
||||
"use_escrow": "エスクロー決済使用",
|
||||
"use_escrow_hint": "実物商品販売時に購入者保護のためのエスクロー決済を有効化します。エスクロー決済後の配送登録が必要です。",
|
||||
"section_easy_pay": "簡易決済設定",
|
||||
"section_easy_pay_hint": "使用する簡易決済サービスを選択してください。NicePayments加盟店契約が完了したサービスのみ有効化してください。",
|
||||
"easy_pay_naverpay": "NaverPay",
|
||||
"easy_pay_naverpay_hint": "テストモードでも使用可能",
|
||||
"easy_pay_kakaopay": "KakaoPay",
|
||||
"easy_pay_samsungpay": "Samsung Pay",
|
||||
"easy_pay_applepay": "Apple Pay",
|
||||
"easy_pay_applepay_hint": "SafariおよびAppleデバイスでのみ使用可能",
|
||||
"easy_pay_payco": "PAYCO",
|
||||
"easy_pay_skpay": "11pay(SKペイ)",
|
||||
"easy_pay_ssgpay": "SSGペイ",
|
||||
"easy_pay_lpay": "L.pay",
|
||||
"easy_pay_allow_with_other_pg": "他のPGと使用可能",
|
||||
"easy_pay_allow_with_other_pg_hint": "有効化すると、他のPG企業が基本決済手段として設定されている場合でも、チェックアウトページにNicePay簡易決済ボタンが表示されます。"
|
||||
},
|
||||
"admin": {
|
||||
"transaction_query_title": "TID取引照会",
|
||||
"transaction_query_description": "NicePayments取引ID(TID)で決済ステータスを直接照会します。",
|
||||
"transaction_tid_label": "取引ID(TID)",
|
||||
"transaction_query_button": "照会",
|
||||
"transaction_query_error": "照会失敗",
|
||||
"transaction_result_title": "照会結果",
|
||||
"result_tid": "取引ID(TID)",
|
||||
"result_order_id": "注文番号(Moid)",
|
||||
"result_amount": "決済金額",
|
||||
"result_status": "決済ステータス",
|
||||
"result_method": "決済手段",
|
||||
"result_auth_code": "承認番号",
|
||||
"result_auth_date": "承認日時",
|
||||
"result_result_code": "結果コード",
|
||||
"order_verify_title": "NicePayments決済検証",
|
||||
"order_verify_description": "NicePaymentsサーバーからリアルタイムで決済ステータスを照会します。",
|
||||
"order_verify_button": "決済照会",
|
||||
"order_verify_error": "照会失敗",
|
||||
"pay_status_approved": "承認",
|
||||
"pay_status_cancelled": "キャンセル",
|
||||
"pay_status_unknown": "未確認",
|
||||
"vbank_noti_title": "仮想口座入金通知履歴",
|
||||
"vbank_noti_description": "NicePay入金通知受信記録(自動蓄積)",
|
||||
"vbank_noti_count": "通知回数",
|
||||
"vbank_noti_first_at": "初回通知受信",
|
||||
"vbank_noti_last_at": "最終通知受信",
|
||||
"vbank_noti_deposited_at": "入金完了通知時刻",
|
||||
"vbank_noti_cancelled_at": "入金キャンセル通知時刻",
|
||||
"vbank_noti_depositor": "入金者名(最終)",
|
||||
"vbank_noti_history": "通知履歴(時間順)",
|
||||
"vbank_noti_type_deposited": "入金完了",
|
||||
"vbank_noti_type_cancelled": "入金キャンセル",
|
||||
"vbank_noti_type_other": "その他の通知",
|
||||
"vbank_noti_query_button": "通知履歴照会",
|
||||
"vbank_noti_loading": "閲覧中...",
|
||||
"vbank_noti_not_loaded": "閲覧ボタンをクリックすると、NicePay入金通知履歴を確認できます。",
|
||||
"vbank_noti_empty": "受信された入金通知がありません。",
|
||||
"vbank_noti_load_error": "入金通知履歴閲覧中にエラーが発生しました。",
|
||||
"escrow_badge": "エスクロー",
|
||||
"result_escrow": "エスクロー有無",
|
||||
"escrow_yes": "エスクロー決済",
|
||||
"escrow_no": "通常決済",
|
||||
"escrow_payment_notice": "エスクロー決済です。配送登録後、購入者の購入確定が必要です。",
|
||||
"escrow_delivery_title": "エスクロー配送登録",
|
||||
"escrow_delivery_company": "配送業者名",
|
||||
"escrow_delivery_company_placeholder": "例:CJ大韓通運",
|
||||
"escrow_tracking_number": "送り状番号",
|
||||
"escrow_tracking_number_placeholder": "送り状番号を入力してください",
|
||||
"escrow_buyer_address": "配送先住所",
|
||||
"escrow_register_name": "登録者名",
|
||||
"escrow_register_button": "配送登録",
|
||||
"escrow_register_success": "エスクロー配送が登録されました。",
|
||||
"escrow_register_error": "エスクロー配送登録に失敗しました。",
|
||||
"test_mode_badge": "テスト決済",
|
||||
"test_mode_order_notice": "テスト決済として処理された注文です。実際のカード承認・出金通知が発生する可能性がありますが、NicePaymentsテストアカウント決済は当日23時30分頃一括自動キャンセルされます。配送前に運営決済の有無をご確認ください。",
|
||||
"test_mode_settings_warning_plugin": "NicePayments",
|
||||
"test_mode_settings_warning_title": "(注意)NicePayments決済設定が現在テスト決済になっています。",
|
||||
"test_mode_settings_warning_body": "テスト決済では実際の決済は発生しません。ショップ運営中の場合は必ず実決済に設定して運営してください。",
|
||||
"test_mode_settings_warning_action": "実決済に設定",
|
||||
"test_mode_orders_in_list": "テスト決済注文を含む"
|
||||
},
|
||||
"easy_pay": {
|
||||
"section_title": "簡便決済"
|
||||
},
|
||||
"user": {
|
||||
"receipt_label": "領収書",
|
||||
"view_receipt": "領収書を見る",
|
||||
"view_cash_receipt": "現金領収書照会",
|
||||
"easy_pay_label": "簡便決済サービス",
|
||||
"test_mode_badge": "テスト決済",
|
||||
"tax_breakdown_title": "税金詳細",
|
||||
"supply_amount": "供給価額",
|
||||
"vat_amount": "付加価値税",
|
||||
"tax_free_amount": "非課税金額",
|
||||
"vbank_deposit_info": "仮想口座入金ご案内",
|
||||
"vbank_bank": "銀行",
|
||||
"vbank_account": "口座番号",
|
||||
"vbank_holder": "口座名義人",
|
||||
"vbank_amount": "入金金額",
|
||||
"vbank_due": "入金期限",
|
||||
"vbank_notice": "入金期限内に入金が完了しない場合、注文は自動キャンセルされます。"
|
||||
},
|
||||
"messages": {
|
||||
"refund": {
|
||||
"missing_tid": "決済取引番号(TID)がありません。",
|
||||
"default_reason": "管理者返金"
|
||||
},
|
||||
"payment": {
|
||||
"auth_failed": "決済認証に失敗しました。",
|
||||
"mid_mismatch": "加盟店IDが一致しません。",
|
||||
"signature_mismatch": "決済署名検証に失敗しました。",
|
||||
"order_not_found": "注文が見つかりません。",
|
||||
"amount_mismatch": "決済金額が一致しません。",
|
||||
"authorize_failed": "決済承認に失敗しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-pay_nicepayments-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-pay_nicepayments",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "NICE Payments",
|
||||
"description": "NICE Payments 決済ゲートウェイ (統合決済ウィンドウ連携)"
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user