41 Commits
Author SHA1 Message Date
HeuJung d5065f1bc9 Merge branch 'develop' 2026-07-16 15:33:15 +09:00
HeuJung 8828c80b56 chore(changelog): 7.0.5 사이클 코어·확장·언어팩 출시일 2026-07-16 통일
코어 7.0.5 릴리즈와 함께 버전이 오른 확장·언어팩의 CHANGELOG 출시 날짜를 오늘(2026-07-16)로 맞춤.

- 코어 7.0.5, ecommerce 1.0.4, gdpr 1.0.1, pay_kginicis 1.0.1, basic 1.0.3 출시일 통일
- core-ja 1.0.4, ecommerce-ja 1.0.4 출시일 통일
- pay_kginicis-ja: [Unreleased] 항목을 1.0.1 로 릴리즈 (manifest version 1.0.0 → 1.0.1 bump)
- pay_nhnkcp-ja / pay_nicepayments-ja 1.0.1 누락 날짜 명시
2026-07-16 15:32:29 +09:00
HeuJung a45e233b97 Merge branch 'develop' 2026-07-16 15:11:11 +09:00
HeuJung 7b7a175c07 chore(payment): 나이스페이먼츠·NHN KCP 결제 플러그인 정식 1.0.0 승격
두 결제 플러그인을 1.0.0-beta.4 에서 정식 1.0.0 으로 올린다.
공개 release latest(beta.3) 대비 프리릴리즈 → 정식 승격으로
버전 bump 규정에 정합(new-bump). manifest/package/composer/lock
4개 파일 버전 동기화 + CHANGELOG [Unreleased] 를 1.0.0 으로 확정
(nicepayments 는 중복 Fixed 챕터 병합). g7_version(>=7.0.5) 은
코드가 7.0.5 도입 표면(미들웨어 self-gate·애플페이 iOS 컨텍스트)에
의존하므로 그대로 유지.
2026-07-16 15:00:49 +09:00
정정홍andGitHub a7340f21ca Merge pull request from gnuboard:HeuJung/issue479
HeuJung/issue479
2026-07-16 14:38:10 +09:00
HeuJung f157ed1cc9 fix(core-update): sudo 재생성 시 public/storage 링크 소유자가 root 로 바뀌는 문제
sudo 로 실행된 종료 시점 복구가 symlink 로 public/storage 를 재생성하면
소유권 인자가 없어 링크가 root:root 로 남아, 원래 앱 실행 유저 소유였던
링크의 소유권이 바뀌었다. 소유권 복원(restoreOwnership) 이후 재생성되어
복원 대상에서도 빠졌다.

StorageLinkHelper::createLink 가 symlink 성공 직후 부모 public/ 의
owner/group 을 lchown/lchgrp(링크 자체 대상 — chown 은 target 을 따라감)로
상속시켜, 이 프로젝트의 "신규 항목은 부모 소유권 상속" 컨벤션과 일치시킨다.
비-POSIX/함수 부재/권한 부족 시 무해하게 skip.
2026-07-16 14:28:34 +09:00
HeuJung 0a6ff23132 fix(core-update): --prune 시 public/storage symlink 삭제로 업로드 파일 404 되는 문제
--prune 은 릴리즈 소스에 없는 public/storage 런타임 symlink 를 orphan 으로
삭제해 업로드 파일이 404 되었다. 심층 방어 2층으로 차단한다.

- 층1(예방): FilePermissionHelper 에 preserveLinkPaths 화이트리스트 추가.
 applyUpdate 가 public 타깃에 ['storage'] 전달 → 매칭 orphan 링크/junction 만
 삭제 제외 (정밀 보호)
- 층2(복구): StorageLinkHelper::ensurePublicStorageLink 신규 정적 헬퍼.
 CoreUpdateCommand 종료 시점(정상+핸드오프) 호출로 부재/손상 링크 멱등 재생성
 (Windows junction 폴백). beta.5 migration 05 는 이 헬퍼로 위임(상위호환)
2026-07-16 12:42:54 +09:00
정정홍andGitHub 9251af5c6a Merge pull request from gnuboard:HeuJung/issue477
fix(seo): 봇 SEO HTML의 코어 app.css 404 참조 제거 (, )
2026-07-16 11:24:19 +09:00
HeuJung 7598b76498 fix(seo): 봇 SEO HTML의 코어 app.css 404 참조 제거 (, )
SeoRenderer.getCssPath가 구 Vite manifest 구조만 파싱해 존재하지 않는 고정 경로 /build/assets/app.css 를 반환, 봇 렌더링 HTML(seo.blade)에서 CSS 404가 발생했다. 해당 파일은 실제 사용자 화면에서 미참조하는 Laravel 기본 빌드 산출물이므로, 담당자 답변 방향대로 참조 자체와 getCssPath 메서드를 제거했다. 봇 스타일은 템플릿 CSS($stylesheets)가 그대로 담당한다.
2026-07-16 11:16:55 +09:00
HeuJung c0e0d82459 refactor(extension): 확장 미들웨어 선언적 self-gate 정규화
확장이 SP Kernel 미들웨어 그룹을 직접 조작하거나 라우트 파일에 미들웨어
FQCN 을 직접 부착하던 임시 방식을, 확장이 부착 대상(targets)을 명시 선언하고
코어가 요청 시점에 라우트명·URI 로 매칭해 실행하는 self-gate 로 정규화.
번들 7건(ecommerce/gdpr/pay 3종) 이전, audit 룰 3건·문서·버전 동기화 동반.

부수적으로 generate-skills.cjs 가 재생성 시 신의성실(부수의무) 섹션을 소실
시키던 회귀를 수정 — 스킬별 불릿을 스크립트 SSoT 로 편입해 재생성에도 보존.
2026-07-16 10:22:51 +09:00
HeuJung 8b9bfd0c91 refactor(ecommerce,pay): 간편결제 브랜드 마크 카탈로그 편입 + 애플페이 iOS 게이팅 이관
3사 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 브랜드 마크를 주입하고
injector끼리 같은 버튼을 두고 경합하던 구조를 제거한다. 시각정보(색 배지/
SVG 로고)를 결제수단 카탈로그(_cached_brand_mark)로 편입해 코어 체크아웃
레이아웃의 신규 BrandMark 컴포넌트가 데이터 기반으로 직접 렌더한다.
reconcileKginicisPatchedDuplicates(중복 방어)도 함께 제거했다.

애플페이 iOS 게이팅도 injector 밖으로 이관: 이커머스 모듈 DetectDevice
미들웨어가 UA로 기기를 판정해 코어 core.frontend.filter_app_config 훅으로
appConfig.isIos를 주입하고(1차), 템플릿 부트스트랩이 iPadOS 데스크탑 UA를
클라 신호로 보정한다(2차). 코어는 훅만 제공하고 감지 로직은 모듈이 소유한다.

미들웨어는 Router::pushMiddlewareToGroup 이 아닌 HTTP Kernel::appendMiddlewareToGroup
으로 web 그룹에 등록한다(전자는 Router 레지스트리만 갱신해 SSR 셸 요청 파이프라인에
미반영 → isIos 항상 false). sirsoft-gdpr 선례와 동일.

코어 최소 요구 버전 7.0.5 상향(appConfig 필터 훅 도입).
2026-07-15 17:50:39 +09:00
HeuJung e896a47d4f feat(ecommerce,pay): 확장 결제수단(간편결제) 1급화
PG 플러그인이 등록하는 간편결제(네이버페이 등)를 코어가 정식 결제수단으로
인식하도록 결제수단 카탈로그를 SSoT 로 전환했다. 확장 결제수단이
pg_provider=null 로 등록돼 서버가 "PG 결제가 아닌 주문"으로 오인, 결제 실패
주문에 관리자 신규주문 알림이 발송되고 TempOrder 가 즉시 삭제돼 재결제가
막히던 두 결함을 하나의 원인(능력 해석을 enum 이 답하지 못함)으로 근본 해결.

- PaymentMethodResolver 신규: 능력(PG 필요/라벨/환불수단/PG 고정) 해석을
 카탈로그 SSoT 로 통일, enum case 없는 확장 ID 도 동일 판정
- 프론트 결제수단 위장(card) 제거, provider-agnostic 결제 진입(pg_payment_handler)
- 관리자 주문설정 결제수단 목록 PG 고정 3분기 UI (pg_locked/needs_pg)
- 저장된 pg_provider=null 자가치유(read-time merge) + 백필 업그레이드 스텝
- 사용자 주문완료 화면 즉시 결제완료 헤더를 입금대기 여집합으로 판정(basic 1.0.3)
- 전 계층 회귀 테스트: 훅 발화 스파이(관리자 알림 미발화/PG 환불 훅 진입),
 주문 응답 계약(requires_pg_payment/pg_payment_handler)+TempOrder 보존,
 관리자 3분기 렌더, 확장 E2E 인프라
2026-07-15 11:23:51 +09:00
HeuJung 10fe28a16d Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit f2ecaefa6b5315904d18fe94a062e04af3a023e8.
2026-07-14 17:46:37 +09:00
HeuJung 00ef91bda5 Merge branch 'develop' 2026-07-14 15:56:07 +09:00
HeuJung 8fcb79d60f docs(changelog): 공개 대상 아닌 나이스페이먼츠 플러그인 수정 항목 제거
7.0.4 에서 나이스페이먼츠 결제 플러그인을 번들에서 제외하므로,
해당 플러그인의 결함 수정 항목은 코어 릴리즈 노트에서 제거한다.
2026-07-14 15:54:46 +09:00
HeuJung 79b83626eb Merge branch 'develop' 2026-07-14 15:16:41 +09:00
HeuJung 29c25376bc 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거 2026-07-14 15:09:24 +09:00
HeuJung 512869e407 docs(pay_nhnkcp): 공개 배포 대상 주석에서 내부 역할 호칭 제거
RestrictKcpIp 의 테스트 모드 설명 주석에 내부 역할 호칭이 남아 있어
공개 release 누출 검사에서 경고로 검출되었다. 의미가 동일한 일반 표현으로
정정해 공개 저장소 발행 시 내부 단서가 실리지 않도록 한다.
2026-07-14 15:05:04 +09:00
정정홍andGitHub ab4f05c1c0 Merge pull request from gnuboard:HeuJung/issue473
fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
2026-07-14 14:55:57 +09:00
HeuJung ce8e55b390 fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan
명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567).
Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행,
Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증.

아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를
2026-07-14 로 통일.
2026-07-14 14:48:33 +09:00
정정홍andGitHub 4c73db2e91 Merge pull request from gnuboard:HeuJung/issue471
HeuJung/issue471
2026-07-14 13:50:08 +09:00
HeuJung 47a2e815aa test(security): 활성 확장 디렉토리 소스맵 잔존 회귀 가드 추가
브라우저 실측(제기자 관점 재검증) 중 발견: 기존 가드 3종이 모두 git 추적
파일만 검사해, 실제 서빙 대상인 활성 확장 디렉토리(gitignore 대상)를 통째로
놓치고 있었다. _bundled 를 --production 으로 재빌드해도 {type}:update 를
돌리지 않은 확장은 활성 디렉토리에 예전 .map 과 sourceMappingURL 을 그대로
안고 있었고(6건), 그 상태로 테스트는 전부 green 이었다.

- 활성 디렉토리(templates|modules|plugins)/{id}/dist/js/*.map 를 glob 로 전수 검사
- 배포본 포함 여부는 _bundled 원본 존재로 판정. DB 레코드로 판정하면 테스트 DB 에
 확장 행이 0건이라 전부 skip 되어 영구 green 이 된다(실제로 이 함정에 빠져
 .map 을 심어도 통과했다)
- 가드 실효성은 설치된 확장에 .map 을 심어 red 확인 후 제거해 green 복귀로 실증

서빙 계층이 .map 을 거부하므로 원본 유출로는 이어지지 않았으나(브라우저 실측:
모든 .map 요청이 non-JSON, 원본 TS 4종 전부 404), 배포본 위생상 잔존해서는 안 된다.
2026-07-14 13:13:42 +09:00
HeuJung 90f154ecc0 fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정
## 소스맵 노출 차단 (공개 이슈 )

배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.

- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
 G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
 개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
 $productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
 산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
 (--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
 허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
 CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).

실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).

## 관리자 환경설정 탭 지연 로딩

코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.

## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)

- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
 로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
 형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
 존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
 먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
 삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
 (리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
 logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
 통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
 보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
 (pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
 컨벤션대로 skip 가드.

## 테스트

- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
 ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트

버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
2026-07-14 12:50:23 +09:00
정정홍andGitHub 84ca40f0d8 Merge pull request from gnuboard:HeuJung/issue466
HeuJung/issue466
2026-07-14 09:20:09 +09:00
HeuJung c4beb116ad fix(security): outbound URL 내부주소·host 검증 전수 강화
서버가 외부 입력으로 목적지가 제어되는 outbound HTTP 요청을 보내는 지점을
전수 조사해, 내부망(사설 IP·루프백·링크로컬·메타데이터)과 host 위조를 차단한다.
KVE-2026-1546(이니시스 인증 URL 접두사 매칭 우회)과 동일 클래스의
지점(언어팩 URL 설치, 스케줄 URL 호출, 배송비 계산 API, GitHub URL 판정)을
코어 공용 유틸 App\Support\OutboundUrlValidator 로 수렴시켰다.

- 접두사 매칭을 host 완전일치로 교체 — userinfo(@) 위장과 접미사 확장 도메인
 우회 벡터 차단.
- 사내 서버 호출이 정당한 관리자 지점(스케줄·외부 API)은 신규 설정
 security.allow_internal_outbound_urls(기본 off)로 옵트인.
- 언어팩 URL 설치는 원격 코드 다운로드라 옵트인과 무관하게 내부 주소 항상 차단.
- 웹소켓 연결 테스트는 localhost·사설 IP 가 정상 구성이므로 구조적 위조만 차단.

각 지점에 red 재현 → 수정 → green 회귀 테스트 + 실브라우저 E2E 로 검증.
수기 API 문서가 자동 재생성으로 소실되는 사고를 막는 PreToolUse 훅을 함께 추가한다.
2026-07-14 09:17:00 +09:00
정정홍andGitHub f848086fd6 Merge pull request from gnuboard:HeuJung/issue465
HeuJung/issue465
2026-07-13 17:05:21 +09:00
HeuJung cc705c2f21 test(installer): 엔드포인트 조립 계층 회귀 가드 추가
escapeEnvValue 재선언 fatal 이 통과한 사각지대를 구조적으로 봉인한다.

인스톨러는 Laravel 오토로드 밖의 플레인 PHP require 체인이라, 엔드포인트가
"어떤 파일을 어떤 순서로 조립하는가" 가 곧 함수 선언 순서다. 기존 단위 테스트는
함수를 직접 호출하므로 이 조립 계층을 전혀 검증하지 못했고, 그래서 재선언
fatal(빈 응답 → 프론트 JSON 파싱 에러)이 그대로 통과했다.

추가: 엔드포인트 5종(install-process / state-management / finalize-env /
install-worker / index)의 실제 require 체인을 격리 프로세스로 재현하고,
그 엔드포인트가 호출하는 함수가 모두 정의되는지 + fatal 이 없는지 검증.

이로써 "Call to undefined function"(정의 파일 미포함) 과 "Cannot redeclare"
(가드 없는 중복 선언) 두 fatal 유형이 모두 차단된다 — 증상은 빈 응답으로
동일하지만 원인이 달라 각각 잡아야 한다.

가드 제거 시 9건 중 3건이 red 로 전환됨을 확인 (거짓 통과 아님).
2026-07-13 17:00:59 +09:00
HeuJung 1922003917 fix(installer): 설치 시작 즉시 빈 응답 — escapeEnvValue 재선언 fatal
직전 커밋이 install-process.php 에 installer-runtime.php require 를
functions.php 앞에 추가하면서 발생한 회귀.

installer-runtime.php 는 escapeEnvValue 를 function_exists 가드와 함께
polyfill 로 선언하는데, functions.php 는 같은 함수를 가드 없이 선언하고 있었다.
로드 순서가 뒤바뀌자 "Cannot redeclare escapeEnvValue" fatal 이 발생하고,
JSON 이 출력되기 전에 스크립트가 죽어 응답 본문이 비었다. 브라우저의
res.json 이 이를 "Unexpected end of JSON input" 으로 보고해 설치 시작
즉시 실패했다 (SSE / 폴링 양 모드 — 둘 다 install-process.php 를 경유).

수정:
- functions.php 의 escapeEnvValue 에 function_exists 가드 추가 (근본 방어 —
 require 순서만 되돌리면 다음 사람이 순서를 바꿀 때 재발한다)
- install-process.php 의 require 순서를 다른 엔드포인트와 동일하게 정렬

인스톨러 전역 전수 스캔 결과 중복 선언 함수는 escapeEnvValue 하나뿐이며
이제 양쪽 모두 가드를 갖는다.

회귀 가드: InstallerIncludeOrderTest — 격리 프로세스로 로드 순서를 바꿔가며
fatal 부재 검증 + 중복 선언 함수의 가드 보유를 정적 검사 (수정 전 3/4 fail 확인).
2026-07-13 17:00:59 +09:00
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00
eunhyeandGitHub ae447c9cc4 Merge pull request from gnuboard/chym1217/issue458
feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
2026-07-13 16:32:22 +09:00
chym1217 57ac2b0b3b feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
- 라이브 자격증명 입력칸 브라우저 자동완성 자동 채움 차단
 (live_mid: one-time-code / live_api_key: new-password —
 크롬이 autoComplete=off 를 무시함을 실측 후 강화)
- 라이브 가맹점 MID 프리픽스를 이니시스 정책 변경에 맞춰 SRA→SRB 전환.
 런타임 프리픽스를 InicisIdentityProvider::LIVE_MID_PREFIX 상수로 추출,
 표시 문자열(라벨·Span·힌트 ko/en/ja)은 값 교체.
- 설정 저장 테스트가 실제 스토리지 설정 파일을 오염시키던 결함 격리
 (PluginTestCase 에서 plugins 디스크를 테스트 전용 경로로 격리)
- 설정 화면 배너 아이콘 크기 시맨틱 정정 (w-N h-N → text-*, 정렬 회귀 해결)
- 프리픽스 상수 ↔ 표시 문자열 정합 audit 룰 추가 (이중관리 불일치 차단)
2026-07-13 15:18:10 +09:00
HeuJung cd3f6ad2b7 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit bac963b011f7b180c0d64e4f050131b5f05b85c1.
2026-07-13 11:10:50 +09:00
HeuJung a690fdd2c4 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 일본어 언어팩 번들 제거"
This reverts commit 2aead0879df6ab08ec58b9b3ab08c7cdaa50a935.
2026-07-13 11:10:50 +09:00
정정홍andGitHub 8cc40e0681 Merge pull request from gnuboard:HeuJung/issue463
fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
2026-07-12 17:22:56 +09:00
HeuJung d85ff9971a test(engine): 감사 지적 3건 반영 — 컴포넌트 번들 E2E 커버 + 약한 단언 실질화
D-4 (실질 커버리지 공백):
- 코어 번들은 "브라우저가 abort 된 script 를 스스로 재요청한다" 는 실측 근거로
 상시-부재 폴백 테스트를 대체본으로 뒀으면서, 형제 경로인 템플릿 컴포넌트
 번들에는 그 대체본조차 없었다. 6경로 중 이 1경로만 E2E 무커버였다.
 상시-부재 → 폴백 안내 E2E 추가 (수정 전 blade 에서 red 확인).

B-9 (제목↔단언 불일치):
- 제목은 "렌더는 진행된다" 인데 단언은 executeInitActions 가 reject 하지
 않는다는 것뿐이라, 그 뒤 init 체인이 죽어도 통과했다. renderTemplate 직접
 단언은 라우터/레이아웃 로드를 거치는 별개 경로라 단위에서는 모킹을 시험하는
 꼴이 되므로, 실제 본체인 "초기화가 에러 화면 없이 완주한다" 로 단언을 바꾸고
 제목도 그에 맞췄다.

B-1 (spy 미사용):
- setRoutes spy 를 선언만 하고 쓰지 않아 "에러 화면이 안 떴다" 만 단언했다.
 라우트가 비어도 에러 화면은 안 뜬다. 재시도로 받아온 routes 가 실제로
 라우터에 반영됐음을 단언한다 (재시도 제거 시 red 확인).
2026-07-12 17:09:03 +09:00
HeuJung 793afb95dc fix(engine): 감사 지적 반영 — 언어팩 버전 bump + 회귀 가드 실질화
전수조사에서 지적된 5건을 코드로 재확인하고 조치했다.

차단성:
- ja 언어팩 1.0.3 → 1.0.4. 1.0.3 은 이미 배포된 버전이라, 같은 버전에 키를
 추가하면 기설치 사이트가 업데이트를 받지 못해 번역이 영영 도달하지 않는다.

회귀 가드 실질화 (green ≠ 회귀를 잡는다):
- 미등록 핸들러 억제 테스트가 동어반복이었다. 값을 손으로 대입하고 그 값을
 읽어, 억제를 삭제해도 통과했다. 실제 dispatch 후 표시 계층이 호출되지
 않음을 감시하도록 교체 (억제 제거 시 red 확인).
- script 번들의 "1건 취소 → 복구" E2E 는 수정 전에도 통과한다. 브라우저가
 abort 된 script 를 스스로 재요청하기 때문 (수정 전 blade 로 실측: 재시도
 훅 0개인데 hits=2, 렌더 정상). 거짓 안심이므로 제거하고, blade 가 고유하게
 보장하는 것 — 재시도/폴백 훅 설치 + 정상 경로 오발동 없음 — 을 단언하도록
 교체 (수정 전 red 확인).
- 재시도 횟수 단언을 하한(>=2)에서 정확값(==2)으로. 과잉 재시도 회귀를
 통과시키던 구멍을 막는다.

정확도:
- waitForHandlers 가 전역 실패 플래그로 판정해, 무관한 확장의 실패가 정상
 로드된 확장의 핸들러 대기까지 포기시켰다. 실패 키 단위로 정밀화한다.
2026-07-12 16:30:38 +09:00
HeuJung 68cbee0e83 fix(engine): 좁은 창에서 레이아웃 편집기가 찌그러지는 문제
레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 +
라벨 있는 툴바 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 그런데
셸에 너비 하한이 없어 창을 좁히면 이들이 창 너비에 맞춰 압착됐고, 툴바가
flex row 라 각 항목 폭이 깎이면서 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가").

반응형으로 재배치하는 대신 셸이 최소 너비(EDITOR_MIN_WIDTH=1280)를 유지하고
모자란 폭은 브라우저 가로 스크롤이 흡수하도록 했다. 편집기 UI 는 축약 대상이
아니며, 압착을 허용하면 캔버스 실측 폭이 뷰포트에 종속돼 디바이스 미리보기의
의미 자체가 사라진다.

툴바 압착 차단은 직접 자식 전체를 덮는 CSS 규칙으로 뒀다. 버튼마다 인라인
style 로 붙이면 자체 style 을 가진 하위 컴포넌트(템플릿 전환기·언어 전환기)와
이후 추가될 항목이 빠져 같은 결함이 재발한다.

jsdom 은 레이아웃 엔진이 없어 단위 테스트로는 "style 선언 존재" 까지만 잠긴다.
실제로 압착이 멈췄는지는 렌더 박스를 재는 브라우저에서만 증명되므로 Playwright
spec 을 함께 추가했다(수정 되돌림 시 red 확인). 아울러 이 파일의 e2e:allow 면제
주석은 사유("브라우저 가시 UI 무변경")가 더 이상 참이 아니라 제거했다.
2026-07-12 16:13:51 +09:00
HeuJung bfaf551d41 fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
모바일에서 새로고침을 연타하면 진행 중이던 요청이 취소되는데, 화면 구성에
필요한 요청 하나만 그렇게 끊겨도 전면 "초기화 실패" 로 끝났다. 재시도가 전혀
없었고, 로컬은 왕복이 수 ms 라 재현되지 않아 오래 남아 있었다.

fetch 는 4xx/5xx 로 reject 하지 않고 Response.ok=false 로 resolve 한다.
따라서 TypeError 는 "응답 자체가 없었다"(취소·커넥션 유실)는 뜻이며, 그 경우만
재시도해야 한다. HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기와 이중으로
겹치지 않게 했다.

- routes.json / components.json / 레이아웃 JSON: 네트워크 실패에만 지수 백오프 재시도
- 실패한 promise 가 레이아웃 캐시에 남아 영구 실패가 되던 문제 수정
- 문서 이탈(pagehide) 중에는 에러 화면을 그리지 않는다 (버려질 문서)
- 확장 에셋 로더의 실패 계약을 resolve → reject 로 (실패를 발생 지점에서 표면화)
- 번들 로드가 실패로 확정되면 오지 않을 핸들러를 5초간 기다리지 않는다
- 미등록 핸들러의 raw 내부 식별자가 토스트로 노출되던 것을 차단
- 번들이 끝내 부재해도 백지 대신 새로고침 버튼이 있는 안내 화면

정상 경로 성능은 종전과 같다. script 태그는 정적으로 유지해 프리로드 스캐너
이점을 보존하고, 실패했을 때만 onerror 에서 동적 재시도를 건다.
2026-07-12 15:39:07 +09:00
정정홍andGitHub 8018c525b8 Merge pull request from gnuboard:HeuJung/issue461
HeuJung/issue461
2026-07-11 22:00:42 +09:00
HeuJung 25c0ab46ec fix(vendor-bundle): 확장에 새 패키지 추가 시 번들 누락 수정
빌드가 composer install 의 입력(스테이징에 복사할 composer.json/lock)만 활성
디렉토리에서 가져오고 manifest 해시는 _bundled 기준으로 기록해, 두 판단이 서로
다른 파일을 보고 있었다.

개발자는 규정상 _bundled 에서만 작업하므로 새 패키지를 추가하면 _bundled 에만
반영되고 활성은 다음 update 전까지 구버전으로 남는다. 그 결과 구버전 lock 으로
설치한 zip 에 신버전 해시가 붙어, 무결성 검증은 통과하는데 새 패키지가 번들에서
누락되고 설치 후 런타임에 클래스 not found 로 터졌다.

설치 입력·해시 기준·의존성 판정·stale 판정 네 가지를 모두 resolveHashTarget
(출력 우선, 없으면 소스 폴백) 으로 통일. 코어는 source == output 이라 무영향.

실증: 이커머스에 psr/log 추가 → 빌드 2 packages(수정 전 1) → zip 에 vendor/psr/log
포함 → module:update → 활성 vendor 배포 + Psr\Log\LoggerInterface 런타임 로드 확인.
2026-07-11 19:50:06 +09:00
HeuJung 13e65131f4 fix(vendor-bundle): 코어 빌드 무한 행 해결 + 이커머스 알림 라벨 raw 노출 수정
이커머스 알림 정의 화면에서 order_pending_deposit·order_delivered 두 알림이
라벨 대신 raw 다국어 키로 표시되던 문제를 수정. settings.json 3로케일에 라벨
추가 + 렌더 회귀 테스트 + 브라우저 실측.

vendor-bundle 시스템 결함 5종 해결:
- (A/B) manifest 해시 기준을 활성이 아닌 출력(_bundled) 디렉토리로 통일해
 확장 업데이트가 composer_json_sha_mismatch 순환에 빠지던 문제 해소
- (C) 코어 빌드 시 composer autoload dump 의 async 손자 프로세스가 부모
 파이프 핸들을 상속·점유해 무한 대기하던 교착을, stdout/stderr 를 파이프
 아닌 파일 descriptor 로 받아 원천 차단 (파이프=행/파일=완주 A/B 실측으로 확정)
- (D) 빌드 실패 시 원본 번들 유실 방지 — 임시파일 성공 후 원자적 교체
- (E) Windows .bat 실행 시 bypass_shell 통일

코어 7.0.4 / 이커머스·언어팩 1.0.3 bump.
2026-07-11 07:00:34 +09:00
640 changed files with 70108 additions and 2342 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -136,3 +136,10 @@ playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
# ===== 소스맵 =====
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
+1
View File
@@ -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 커맨드로 수행
```
+32
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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>
+34 -5
View File
@@ -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;
}
+42 -11
View File
@@ -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
{
+31
View File
@@ -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);
}
}
+34 -8
View File
@@ -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() 보다 먼저 평가되어야 한다.
+207
View File
@@ -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);
}
}
+16
View File
@@ -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();
}
}
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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'),
+5
View File
@@ -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);
}
/**
+41
View File
@@ -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'));
}
}
}
-3
View File
@@ -20,9 +20,6 @@ class AllowedModuleFileType implements ValidationRule
// Data
'json',
// Source Maps
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
-3
View File
@@ -20,9 +20,6 @@ class AllowedPluginFileType implements ValidationRule
// Data
'json',
// Source Maps
'map',
// Images
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
+41
View File
@@ -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'));
}
}
}
+66
View File
@@ -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'));
}
}
}
-23
View File
@@ -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 컨텍스트를 구성합니다.
*
+8 -1
View File
@@ -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 — 증분 모드에서는 적용 목록에 있을 때만 복사.
+24 -8
View File
@@ -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'),
+25 -1
View File
@@ -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() 참조가 있는지 확인합니다.
*
+29 -17
View File
@@ -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 폴백 경로).
*
-1
View File
@@ -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',
-1
View File
@@ -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',
+59 -8
View File
@@ -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);
}
/**
* 스케줄을 복제합니다.
*
+4 -1
View File
@@ -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);
}
/**
+213
View File
@@ -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;
}
}
+149
View File
@@ -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;
}
}
+13
View File
@@ -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
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.3'),
'version' => env('APP_VERSION', '7.0.5'),
/*
|--------------------------------------------------------------------------
+32
View File
@@ -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',
],
],
];
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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 | 아니오 | — | 설치 후 자동 활성화 여부 |
+2 -2
View File
@@ -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 | 아니오 | — | 중복 실행 방지 여부 |
+1 -1
View File
@@ -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 캐시·사이트맵·생성기 설정) |
+10 -1
View File
@@ -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 |
+70
View File
@@ -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
+1
View File
@@ -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 |
#### 동적 권한/역할/메뉴 보존 규칙
+2
View File
@@ -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. 라우트 파일 작성
+2
View File
@@ -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)
+36 -3
View File
@@ -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. 참고
+16 -3
View File
@@ -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
@@ -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' => '仮想口座返金',
],
];
@@ -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
@@ -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",
+7
View File
@@ -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',
],
];
+1
View File
@@ -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',
+1
View File
@@ -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.',
+3
View File
@@ -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' => [
+1
View File
@@ -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
+13
View File
@@ -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