Merge branch 'develop'

This commit is contained in:
HeuJung
2026-08-19 09:20:00 +09:00
1329 changed files with 81972 additions and 5422 deletions
+6 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -100,6 +100,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
+6 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.6
APP_VERSION=7.0.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -77,6 +77,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
+77 -1
View File
@@ -165,7 +165,7 @@
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
@@ -260,6 +260,9 @@
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) |
| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) |
| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) |
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
@@ -355,6 +358,21 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
### 일괄 처리 목록의 선택 범위
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 누르면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다. 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 실행 후에도 오류가 남지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 선택을 화면 밖 상태에 저장하는 DataGrid 에 `selectionScope` 미선언 | `"selectionScope": "page"`(일괄 처리 목록) 또는 `"free"`(선택 자체가 저장 대상인 폼)를 **명시** |
| 일괄 처리 버튼이 달린 목록에 `"free"` | `"page"` — 대상은 언제나 "화면에 보이고 체크된 행" |
| 여러 페이지에 걸쳐 고르는 폼 선택기에 `"page"` | `"free"` — 페이지를 넘기면 앞 페이지 선택이 사라져 기능이 깨진다 |
| `selectable` 이 꺼진 화면이라 안전하다고 간주 | 체크박스가 없으면 남은 선택이 **더** 안 보인다 — 범위 판정은 `selectable` 과 무관 |
| `"selectionScope": "{{조건}}"` 표현식 분기 | 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 대상 밖 행을 싣는다 |
| 화면마다 검색·필터·페이지 액션에 선택 초기화 액션을 복제 | 컴포넌트가 단일 지점에서 정리 — 액션 복제는 한 곳만 빠져도 같은 결함이 남는다 |
정적 검사가 `onSelectionChange` 가 배선된 DataGrid 를 전수 검사해 미선언을 차단한다. 상세: [component-props.md DataGrid](docs/frontend/component-props.md)
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
@@ -369,6 +387,33 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
#### 보안 게이트 대칭성 (KVE-2026-1914/1915/1919)
접근 게이트와 권한 등급 상한은 한 경로에만 있으면 다른 경로가 조용한 우회로가 된다. 게이트는 생산 지점(부모 비밀 판정 · 소유권 판정 · 등급 판정) 한 곳을 SSoT 로 두고, 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 비밀/비공개 부모(게시글)의 비밀 게이트를 하위 리소스(댓글·첨부·문의) 독립 엔드포인트에서 재적용하지 않음 | 부모 비밀 판정을 하위 전 경로(훅·서비스·첨부 서빙·댓글 목록)에 재적용 — PostResource 한 곳만으로는 부족하다 (KVE-2026-1914) |
| hash 기반 file-serving(preview/download)이 소유권·비밀·발행 상태 검사 없이 서빙 | preview 와 download 가 동일 게이트 공유 — 미발행·비소유·비밀 첨부는 404 (KVE-2026-1914 A-3/S-1/S-2) |
| User/Role 의 쓰기·상태변경·권한부여 경로가 삭제 경로보다 약한 등급 가드 | 전 경로에 동일 등급-상한(rank ceiling)을 대칭 적용 — 정적 라우트(bulk)는 스코프 미들웨어가 우회되므로 서비스 계층에서 강제한다 (KVE-2026-1919) |
| 저장측 레이아웃 표현식 검증(SafeLayoutExpressions)을 문자열 endpoint 필드에만 부착 | 표현식이 실릴 수 있는 배열 트리 전체(`content`)에 부착 — 문자열 한정 부착은 `is_array` 가드로 무력화되어 no-op 이 된다 (KVE-2026-1915) |
| 배열 트리 순회용 규칙(`NoExternalUrls`)이 문자열 필드에도 부착돼 `is_array` 로 조용히 통과 | 규칙이 문자열 스칼라도 처리하거나, 그 자리에서 떼어낸다 — 부착만 해두고 통과시키는 상태가 최악이다 |
| 같은 저장 대상의 FormRequest 마다 부착 규칙이 다름 (편집기 경로만 누락) | Store·Update·Content·ExtensionContent 4경로 동일 강도 — 편집기 저장 경로가 가장 약하면 그 경로가 우회로다 |
| same-origin 을 `//` 접두·scheme·`/` 시작 **문자열 검사**로만 판정 | 브라우저 URL 파서와 동일 정규화(tab·LF·CR 제거 → 백슬래시를 슬래시로 → 선행 슬래시 런 접기) 후 판정 — `/\/evil.com/x.js` 는 문자열상 path 지만 브라우저는 외부 origin 으로 해석한다. 런타임·저장측·정적검사 3층이 같은 정규화를 공유한다 (KVE-2026-1915 B-2) |
| same-origin 판정만 정규화하고 **신뢰 호스트 추출(`hostOf`)은 원문**으로 판정 | 두 판정이 같은 `if` 안에서 이어지므로 정규화도 공유 — 어긋나면 `https://evil.com\@cdn.신뢰.com/x.js` 가 저장측에서만 신뢰 호스트로 보여 통과한다 |
| 정적 일괄 라우트(`bulk-*`)에 등급 상한만 적용하고 **스코프 축은 비움** | 라우트 모델이 없으면 미들웨어 스코프 검사가 스킵되므로 서비스가 상세 경로와 **같은 스코프 판정**(`PermissionHelper::filterByScope`)을 재적용 — 등급 축만 막으면 스코프 축이 우회로다 (KVE-2026-1919) |
| 권한 상한(ceiling) 검사를 DB 쓰기 **뒤**에 배치 | 가드 → 쓰기 순서 — 쓰기 뒤에 검사하면 거부된 요청이 고아 행·반영된 속성 변경을 남긴다. 회귀 테스트는 403 뿐 아니라 **상태 불변**까지 단언한다 |
| 같은 리소스를 쓰는 public 서비스 메서드 중 일부만 보호 가드 보유 | 형제 public 메서드 전부 동일 가드 — 서비스는 확장에 열려 있으므로 "현재 호출부가 없다" 는 방어가 아니다 |
| 라우트 파라미터가 Model 로 resolve 되지 않는 쓰기 경로를 미들웨어 스코프 검사에 맡김 | 서비스 계층에서 재적용 — 스킵 조건은 정적 경로(`bulk-*`·`reorder`)뿐 아니라 **파라미터명 불일치**(`{id}` + `int` 타입힌트)도 있고, 후자는 상세 경로까지 무가드다 |
| 순서 변경·일괄 작업의 스코프 거부를 "대상 일부 제외" 로 처리 | 순서·트리처럼 집합 전체가 하나의 값인 작업은 **전량 거부** — 일부만 반영하면 나머지와 어긋난 상태가 저장된다 |
| 가시성 판정을 호출부가 넘기는 옵트인 플래그(`$filters['is_public'] ?? false`)에 의존 | 열람자 신원 기반 fail-closed — 옵트인은 호출부가 빠뜨리면 조용히 열린다(읽기만 하고 쓰는 곳이 없는 사문 플래그가 실재했다) |
| 부모 상태로 판정하는 게이트를 `$x->parent && …` 로 작성 | 부모를 못 읽으면 차단 — 부모가 soft-delete 되면 조건이 성립하지 않아 통과한다 |
| 리소스 `abilityMap can_*` 을 연관/타 리소스 권한으로 게이팅 | 그 엔드포인트의 라우트 권한(SSoT)과 **같은 리소스 prefix** — 상승 방지는 게이트 이중화가 아니라 rank ceiling 이 담당한다 |
이 결함군은 예외도 오류도 남기지 않는다 — 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이다. secret 게이트 재적용·hash 서빙 게이트·rank 대칭·URL 판정 3층 동형·정적 bulk 스코프 재적용·가드 선행·형제 메서드 가드 패리티·abilityMap prefix 정합은 의미 판정 영역이라 정적 검사가 일부만 덮으므로, 부모 변경·하위 서빙·등급 경로·URL 검증 지점을 건드릴 때 코드 리뷰에서 대칭성을 확인한다.
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
### 목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
@@ -500,6 +545,15 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
동결 대상은 환율만이 아니다 — **소수 자릿수·절사 규칙·환산 분모(base_unit)까지 스냅샷이 SSoT** 다. 이 값들을 현재 설정에서 조회하면, 운영자가 그 통화를 삭제하는 순간 설정에서 사라져 폴백(자릿수 2)이 적용된다. 소수 0자리 통화의 과거 주문 표기가 `¥14,835` → `¥14,835.00` 으로 바뀌고, 3자리 이상으로 설정했던 통화는 표시 금액이 절사된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따라가면 같은 화면 안에서 근거가 갈린다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 주문·환불 표시에서 `getDecimalPlaces($code)` 를 스냅샷 없이 호출 | `getDecimalPlaces($code, $currencySnapshot)` — 스냅샷이 있으면 그것이 우선 |
| 리소스가 주문 스냅샷을 자식에게 전파하지 않음 | `withOrderCurrency()` 를 전파하는 지점마다 `withCurrencySnapshot()` 도 함께 전파 |
| 상품·카탈로그 표시까지 스냅샷으로 고정 | 현재 판매가는 **현재 설정**이 정답 — 스냅샷 없이 호출한다 |
| 자릿수를 박제하지 않은 구형 스냅샷에서 예외/0 자리 강제 | 박제값이 없으면 현재 설정 폴백을 그대로 탄다 (하위호환) |
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 확장 결제수단은 자기 능력을 선언한다
@@ -515,6 +569,26 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다.
### 예외를 응답으로 바꾸는 자리
`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애·코드 결함이 "입력 오류" 로 위장되어 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다. 그리고 이미 번역된 `$e->getMessage()` 를 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문(SQL 상태코드·경로 포함 가능)이 그대로 화면에 나간다. 둘 다 예외도 로그도 남기지 않는다.
| 금지 | 올바른 사용 |
|--------|---------------|
| generic catch 가 4xx 반환 | 5xx — 의도적 4xx 는 사유와 함께 판정 테스트의 상수에 선언 |
| generic catch 에서 상태코드 인자 생략 (`moduleError($mod,'key')`) | 상태코드 명시 — 생략 시 `ResponseHelper` 기본값 **400** 이 조용히 적용된다 |
| 도메인 예외를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 유지 | 승격한 예외로 좁힌다 — 도메인 예외의 **부모**를 잡으면 남는 것은 인프라 예외뿐인데 그것까지 4xx 가 된다 |
| `error($e->getMessage(), 422)` | `error($e->getMessageKey(), 422, null, $e->getMessageParams())` |
| 도메인 예외가 번역문만 들고 다님 | 생성자에 키+치환 파라미터 보관 → `getMessageKey()` / `getMessageParams()` |
| 서비스를 typed 예외로 승격하고 컨트롤러는 generic 만 유지 | 그 서비스 메서드를 호출하는 **컨트롤러 메서드 전수**에 typed catch 추가 (없으면 도메인 사유가 500 이 된다) |
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다.
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -1097,6 +1171,8 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
```
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production` (`--full` 은 앱 번들 전용 — `public/build` 를 비워 코어 3번들을 지운다).
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
+81
View File
@@ -4,6 +4,87 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.7] - 2026-08-19
### Security
- 부관리자(위임 관리자)가 슈퍼 관리자 계정을 함부로 손대지 못하도록 막았습니다. 슈퍼 관리자 보호는 삭제·탈퇴 경로에만 있었고 비밀번호 변경·상태 변경(차단/탈퇴)·계정 잠금 해제·일괄 상태 변경 경로에는 없어, 회원 관리 권한만 위임받은 계정이 슈퍼 관리자 계정을 무력화할 수 있었습니다. 이제 이 경로 전부에 같은 기준을 적용해, 슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다. 슈퍼 관리자 본인의 작업은 종전처럼 정상 동작합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 부관리자가 역할 권한을 통해 자신보다 높은 권한을 획득하지 못하도록 막았습니다. 이전에는 역할에 권한을 부여할 때 부여하는 사람이 그 권한을 가졌는지 확인하지 않아, 권한 관리 권한만 위임받은 계정이 자신에게 없는 권한이나 더 넓은 범위의 권한을 역할에 실어 우회 상승할 수 있었습니다. 이제 자신이 보유한 권한을 자신의 범위 이내로만 부여할 수 있으며, `admin` 같은 시스템·확장 소유 역할의 권한·활성 상태 변경도 슈퍼 관리자로 제한됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 위 상한을 사용자에게 역할을 붙이는 경로에도 동일하게 적용했습니다. 이전에는 사용자 생성·수정 화면에서 역할을 배정할 때 그 역할이 담은 권한을 배정자가 모두 가졌는지 확인하지 않아, `admin` 같은 고권한 역할을 통째로 붙여 우회 상승할 수 있었습니다. 이제 배정자가 자신의 권한 범위 안에서 전부 부여할 수 있는 역할만 붙일 수 있습니다. 같은 기준이 역할을 **떼는** 방향에도 적용되어, 자신이 부여할 수 없는 상위 역할을 다른 관리자에게서 박탈하는 것도 차단됩니다. 사용자가 이미 가진 역할을 유지하는 수정은 그대로 허용되고, 슈퍼 관리자의 역할 배정은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃 편집기에 저장하는 표현식이 서버나 다른 사용자 브라우저에서 임의 코드로 실행될 수 없도록 표현식 평가 방식을 근본적으로 바꿨습니다. 이전에는 표현식을 실제 코드로 만들어 실행했기 때문에 특정한 우회 기법으로 편집 권한을 넘어선 동작이 가능했습니다. 이제 정해진 문법·함수만 해석하는 안전한 방식으로 평가하며, 위험한 표현식과 외부 주소의 스크립트 로드는 저장 단계에서도 거부합니다. 기존 레이아웃의 정상 표현식(조건·계산·목록 가공·경로 조립 등)은 그대로 동작하고, 위지윅 에디터·주소 검색처럼 정해진 외부 스크립트를 쓰는 확장은 각자 신뢰 출처를 선언해 정책 강화 이후에도 정상 동작합니다. 신뢰 출처는 모듈·플러그인·템플릿이 모두 자기 설정 파일에 선언할 수 있으며, 활성 상태인 확장의 선언만 반영됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1915)
- 레이아웃에 적는 주소가 "내 사이트 경로"인지 판정하는 방식을 브라우저의 실제 해석과 일치시켰습니다. 이전에는 주소 앞부분의 글자만 보고 판정했기 때문에, 슬래시 사이에 역슬래시나 보이지 않는 공백 문자를 끼워 넣은 주소가 내 사이트 경로처럼 통과한 뒤 브라우저에서는 외부 사이트 주소로 해석되어, 선언하지 않은 외부 스크립트가 실제로 불려 올 수 있었습니다. 이제 브라우저와 같은 기준으로 정규화한 뒤 판정하며, 저장 단계·화면 로드·정비 검사 세 곳이 같은 기준을 씁니다. 경로 중간에 역슬래시가 들어간 정상 주소는 종전처럼 그대로 동작합니다.
- 레이아웃 편집기로 저장할 때 외부 주소 차단이 적용되지 않던 문제를 수정했습니다. 레이아웃을 새로 만들거나 정보를 수정하는 경로에는 이 검사가 걸려 있었지만, 편집기가 실제로 사용하는 콘텐츠 저장 경로에는 빠져 있어 같은 내용도 어느 화면에서 저장하느냐에 따라 통과 여부가 달랐습니다. 함께, 주소 입력 칸 하나만 검사하도록 걸어 둔 설정이 실제로는 아무것도 검사하지 않고 지나가던 것도 바로잡았습니다. 이제 네 저장 경로가 모두 같은 강도로 검사합니다.
- 회원 일괄 상태 변경에서 담당 범위 제한이 적용되지 않던 문제를 수정했습니다. 회원 수정 권한은 "본인 계정만" 또는 "같은 역할 범위만" 으로 범위를 좁혀 위임할 수 있는데, 이 제한은 회원을 하나씩 여는 화면에서만 적용되고 목록에서 여러 명을 한 번에 처리하는 일괄 변경에는 적용되지 않았습니다. 그래서 범위를 좁혀 위임받은 관리자가 일괄 변경으로는 담당 밖 회원까지 차단·탈퇴 처리하고 그 회원들의 로그인 세션까지 끊을 수 있었습니다. 기본 제공 역할인 "매니저" 가 이 구성에 해당합니다. 이제 일괄 변경도 회원 상세와 같은 기준으로 대상마다 범위를 확인하며, 범위 밖 회원은 처리 대상에서 제외되고 처리 건수로 확인할 수 있습니다. 범위 제한 없이 위임받은 관리자의 일괄 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃에 적는 스크립트 주소가 확장이 선언한 신뢰 출처인지 판정할 때, 주소를 브라우저와 같은 기준으로 정규화하지 않던 문제를 수정했습니다. 신뢰 출처 이름을 주소 뒷부분에 끼워 넣고 그 앞에 역슬래시를 둔 주소가 저장 단계에서만 신뢰 출처로 보여 통과했습니다(화면 로드 단계는 차단하고 있었으므로 실제로 불려 오지는 않았습니다). 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 경우도 있었습니다. 이제 주소가 "내 사이트 경로인가" 와 "신뢰 출처인가" 를 같은 기준으로 판정합니다.
- 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다.
- 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919)
- 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915)
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
### Added
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
- S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다.
- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다.
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
### Changed
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
- 예약 작업의 실행 시각이 환경설정의 시간대를 기준으로 해석되도록 바꿨습니다. 종전에는 세계표준시 기준이어서 '새벽 4시'로 등록해도 국내 기준 낮에 실행됐습니다. 이번 변경으로 새로 추가된 자동 정리 작업은 물론, 기존 사이트맵 자동 생성·GeoIP 갱신·확장이 제공하는 예약 작업, 관리자가 직접 등록한 예약 작업까지 모두 같은 기준을 따릅니다. **시각을 지정해 둔 예약은 실제 실행 시각이 이동하므로 확인이 필요합니다.**
- 시스템 요구사항 문서를 실제 검증 기준에 맞게 정비했습니다. 지원 브라우저 항목에 검증 방식을 명시하고(자동 테스트는 Chromium 기준이며, Firefox·Safari 는 프론트엔드 의존성의 호환 범위를 기준으로 지원합니다), PHP 확장을 필수와 기능별 선택으로 구분했으며, 설치 프로그램이 자동으로 검사하는 항목과 운영자가 직접 확인해야 하는 항목을 구분하는 절을 새로 두었습니다. "요구사항" 이라고 적힌 항목이 모두 설치 단계에서 검사되는 것은 아니라는 점이 이제 문서에 드러납니다. (#95 @glitter-gim 님께서 제보해주셨습니다.)
- 문서 사이에 어긋나 있던 수치와 표기를 정정했습니다 — 디스크 용량 기준(설치 검사 기준 500MB), PHP 확장 개수, 지원 운영체제에 Windows 개발 환경 추가, 데이터베이스 표기에 MariaDB 병기, 한국어 README 의 시스템 요구사항 항목 보완, 그리고 데이터베이스 접두어 길이 제한·collation 변경 가능 여부처럼 실제로는 동작하지만 문서에 없던 조건의 추가.
- 확장 개발자용: 배포본에 함께 들어가는 브라우저 테스트 안내 문서가 실제 디렉토리 구성 및 실행 스크립트와 어긋나 있던 부분을 정정했습니다.
### Fixed
- 데이터베이스 테이블 접두어를 기본값(`g7_`)이 아닌 값으로 설치한 사이트에서, 7.0.6 업그레이드의 활동 로그 색인 교체가 옛 색인을 지우지 못하고 남겨 두던 문제를 수정했습니다. 남은 옛 색인은 조회 이득 없이 기록 쓰기 비용만 늘리므로, 이번 업그레이드에서 자동으로 정리됩니다.
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다.
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 일괄 상태 변경의 결과 안내 문구에 처리 인원수가 채워지지 않고 자리표시 원문이 그대로 표시되던 문제를 수정했습니다.
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
- 웹소켓 연결 테스트가 브라우저 접속 주소만 검사해, 테스트는 성공해도 서버 발송(알림 등)은 실패할 수 있던 문제를 수정했습니다 — 이제 서버 발송용 주소도 함께 검사하고, 실패 시 어느 쪽 주소가 문제인지 구분해 알려줍니다.
- 드라이버 설정의 S3 URL 이 너무 길 때 표시되는 영어 안내 문구가 실제 허용 길이(500자)와 다르게 255자로 안내되던 문제를 수정했습니다.
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
- 예약 작업 저장 화면에서 설치된 확장이 제공하는 Artisan 명령이 "예약 실행이 허용된 명령이 아닙니다" 로 거부되던 문제를 수정했습니다. 터미널에서의 검증은 통과하는 명령이 관리자 화면 저장에서만 거부되었습니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다.
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
## [7.0.6] - 2026-08-10
### Security
+2 -2
View File
@@ -8,7 +8,7 @@
| 항목 | 요구사항 |
|------|---------|
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
| **PHP** | 8.2 이상 (필수 확장 16개 포함 — 기능별 선택 확장은 별도) |
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
| **Composer** | 2.x |
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.6 g7
# (필요 시) mv g7-7.0.7 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+9 -5
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-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>
@@ -71,7 +71,7 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
@@ -353,10 +353,12 @@ HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
### 시스템 요구사항
- PHP 8.2+ (필수 확장 30개 포함)
- PHP 8.2+ (필수 확장 16개 포함 — `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, `zip` 등. `gd`/`imagick`, `intl`, `redis`, `bcmath` 등은 해당 기능을 쓸 때만 필요하며 전체 목록은 [docs/requirements.md](docs/requirements.md) 참조)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Node.js 20+ (빌드 시에만 필요)
- Composer 2.x
- Node.js 20+ (프론트엔드 에셋을 직접 빌드할 때만 필요)
- 웹 서버(Apache 또는 Nginx) — 문서 루트를 `public/` 으로 지정
- Redis 6.0+ (선택 — 프로덕션의 캐시·큐에 권장)
### 설치
@@ -512,15 +514,17 @@ cp .env.example .env
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
+6 -4
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.7-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>
@@ -75,7 +75,7 @@ Everything a modern web platform needs, built in.
| Layer | Technology |
|-------|------------|
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **Frontend** | React 19, Vite, Tailwind CSS 4 (dark mode supported) |
| **Authentication** | Laravel Sanctum (Bearer tokens) |
| **Testing** | PHPUnit 11.x, Vitest |
@@ -359,7 +359,7 @@ Every verification point — signup, password reset, sensitive operations, the m
### System requirements
- PHP 8.2+ with the required extensions (30 in total), including `bcmath`, `ctype`, `curl`, `dom`, `fileinfo`, `gd`, `intl`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`
- PHP 8.2+ with the required extensions (16 in total), including `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`. Additional extensions (`gd`/`imagick`, `intl`, `redis`, `bcmath`, and others) are optional and only needed for the features that use them — see [docs/requirements.md](docs/requirements.md)
- MySQL 8.0+ or MariaDB 10.3+ (utf8mb4)
- Composer 2.x
- Node.js 20+ (only needed when building frontend assets)
@@ -528,15 +528,17 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
*
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
*/
class ExpireIdentityChallengesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:expire-challenges';
/**
* The console command description.
*/
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
/**
* Execute the console command.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$expired = $service->expirePastDue();
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
return self::SUCCESS;
}
}
@@ -2,15 +2,22 @@
namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use App\Services\LayoutResolverService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearModuleCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,15 +34,19 @@ class ClearModuleCacheCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService,
private LayoutResolverService $layoutResolver
) {
parent::__construct();
}
/**
* Execute the console command.
*
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearModuleCacheCommand extends Command
$clearedCount = $this->clearModuleCache($identifier);
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
'module' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearModuleCacheCommand extends Command
// 모든 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_all'));
// 전체 모듈 캐시 키 삭제
$cacheKeys = [
'modules.all',
'modules.active',
'modules.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 모듈별 캐시 삭제
$allModules = $this->moduleManager->getAllModules();
foreach ($allModules as $moduleName => $module) {
foreach ($this->moduleManager->getAllModules() as $module) {
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
@@ -113,26 +120,23 @@ class ClearModuleCacheCommand extends Command
/**
* 특정 모듈의 캐시를 삭제합니다.
*
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
*
* @param string $identifier 모듈 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearModuleCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'module');
$this->layoutResolver->clearResolutionCacheByModule($identifier);
// 모듈별 캐시 키
$cacheKeys = [
"module.config.{$identifier}",
"module.info.{$identifier}",
"module.routes.{$identifier}",
"module.permissions.{$identifier}",
"module.menus.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
->count();
}
}
@@ -2,15 +2,21 @@
namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearPluginCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -27,8 +33,7 @@ class ClearPluginCacheCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache,
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -36,6 +41,12 @@ class ClearPluginCacheCommand extends Command
/**
* Execute the console command.
*
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
*/
public function handle(): int
{
@@ -61,6 +72,11 @@ class ClearPluginCacheCommand extends Command
$clearedCount = $this->clearPluginCache($identifier);
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
'plugin' => $identifier,
'count' => $clearedCount,
@@ -69,25 +85,16 @@ class ClearPluginCacheCommand extends Command
// 모든 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_all'));
// 전체 플러그인 캐시 키 삭제
$cacheKeys = [
'plugins.all',
'plugins.active',
'plugins.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 플러그인별 캐시 삭제
$allPlugins = $this->pluginManager->getAllPlugins();
foreach ($allPlugins as $pluginName => $plugin) {
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
@@ -113,25 +120,18 @@ class ClearPluginCacheCommand extends Command
/**
* 특정 플러그인의 캐시를 삭제합니다.
*
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
*
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearPluginCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
// 플러그인별 캐시 키 (메뉴 캐시 없음)
$cacheKeys = [
"plugin.config.{$identifier}",
"plugin.info.{$identifier}",
"plugin.routes.{$identifier}",
"plugin.permissions.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
->count();
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\ActivityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
*/
class PruneActivityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ActivityLogService $service 활동 로그 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ActivityLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
*
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
*/
class PruneIdentityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
/**
* The console command description.
*/
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$days = (int) $this->option('days');
$purged = $service->purge($days);
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\NotificationLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
*/
class PruneNotificationLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param NotificationLogService $service 알림 발송 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(NotificationLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Services\AttachmentService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 소유자 없는 고아 첨부파일 정리 커맨드
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
*
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
*
* @example php artisan attachments:prune-orphans --dry-run
* @example php artisan attachments:prune-orphans --days=7 --limit=100
*/
class PruneOrphanAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'attachments:prune-orphans
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
/**
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
protected AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
return Command::SUCCESS;
}
$days = $this->resolveRetentionDays();
if ($days < 1) {
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
return Command::SUCCESS;
}
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
return Command::SUCCESS;
}
$this->info(sprintf(
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
return Command::SUCCESS;
}
/**
* 자동 정리 토글을 false 폴백으로 조회합니다.
*
* @return bool 자동 정리 활성 여부
*/
private function isCleanupEnabled(): bool
{
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
}
/**
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
*
* @return int 보존기간(일)
*/
private function resolveRetentionDays(): int
{
$option = $this->option('days');
if ($option !== null && $option !== '') {
return (int) $option;
}
return (int) g7_core_settings('upload.orphan_retention_days', 30);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ScheduleService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
*
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneScheduleHistoryCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ScheduleService $service 스케줄 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ScheduleService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->pruneHistory($days);
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Seo\SeoCacheStatsService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
*
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneSeoCacheStatsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(SeoCacheStatsService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->cleanup($days);
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Console\Commands;
use App\Services\StorageLeftoverPruneService;
use Illuminate\Console\Command;
/**
* 스토리지 잔존물 정리 커맨드
*
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
*/
class PruneStorageLeftoversCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'storage:prune-leftovers
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
/**
* The console command description.
*/
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
/**
* 대상군 키 → 출력 라벨.
*
* @var array<string, string>
*/
private const GROUP_LABELS = [
'staging' => '업데이트 스테이징 잔존',
'temp' => '코어 임시 산출물',
'vendor_bundle_staging' => 'vendor 번들 스테이징',
'extension_backups' => '확장 백업',
'core_backups' => '코어 백업',
'legacy_browser_log' => '레거시 브라우저 로그',
];
/**
* Execute the console command.
*
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(StorageLeftoverPruneService $service): int
{
$days = max(0, (int) $this->option('days'));
$backupDays = max(0, (int) $this->option('backup-days'));
$dryRun = (bool) $this->option('dry-run');
$result = $service->prune($days, $backupDays, $dryRun);
$total = 0;
foreach (self::GROUP_LABELS as $key => $label) {
$paths = $result[$key] ?? [];
$count = count($paths);
$total += $count;
$this->line("{$label}: {$count}건");
if ($dryRun) {
foreach ($paths as $path) {
$this->line(" - {$path}");
}
}
}
$this->info($dryRun
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
return self::SUCCESS;
}
}
@@ -2,16 +2,17 @@
namespace App\Console\Commands\Template;
use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearTemplateCacheCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -28,7 +29,6 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache,
private ExtensionBundleService $bundleService
) {
parent::__construct();
@@ -67,6 +67,10 @@ class ClearTemplateCacheCommand extends Command
/**
* 특정 템플릿의 캐시 삭제
*
* 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점
* (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을
* 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119).
*/
private function clearSingleTemplateCache(string $identifier): void
{
@@ -78,34 +82,12 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
$clearedCount = 0;
// 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시
$clearedCount = $this->templateManager->clearTemplateCache($identifier);
// 1. 레이아웃 캐시 삭제
$templateRecord = Template::where('identifier', $identifier)->first();
if ($templateRecord) {
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$identifier}.{$layout->name}");
$clearedCount++;
}
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$identifier}.{$locale}");
$clearedCount++;
}
// 4. 활성 템플릿 타입 캐시 삭제
if ($templateRecord) {
$this->cache->forget("templates.active.{$templateRecord->type}");
$clearedCount++;
}
// 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump)
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
@@ -126,36 +108,17 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_all'));
$clearedCount = 0;
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
// 모든 설치된 템플릿의 캐시 삭제
$templates = Template::all();
foreach ($templates as $templateRecord) {
// 1. 레이아웃 캐시 삭제
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
$clearedCount++;
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$templateRecord->identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
$clearedCount++;
}
// 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점)
foreach (Template::all() as $templateRecord) {
$clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier);
}
// 4. 활성 템플릿 타입 캐시 삭제
$this->cache->forget('templates.active.admin');
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
// 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
// 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Traits;
use App\Console\Helpers\ConsoleConfirm;
use Symfony\Component\Console\Input\StreamableInputInterface;
/**
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
@@ -12,6 +13,7 @@ use App\Console\Helpers\ConsoleConfirm;
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
*
* - `--no-interaction` 시: $default 즉시 반환
* - 응답을 받을 수 없는 실행(콘솔 없는 STDIN) 시: $default 즉시 반환
* - empty 입력 시: $default 반환
* - yes/y → true, no/n → false (대소문자 무시)
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
@@ -26,7 +28,7 @@ trait HasUnifiedConfirm
*/
protected function unifiedConfirm(string $question, bool $default = false): bool
{
if (! $this->input->isInteractive()) {
if (! $this->input->isInteractive() || ! $this->canPromptForAnswer()) {
return $default;
}
@@ -47,4 +49,38 @@ trait HasUnifiedConfirm
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
}
}
/**
* 이 실행이 프롬프트 응답을 실제로 받을 수 있는지 판정합니다.
*
* Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로
* 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
* `--no-interaction` 없이 CI·스케줄러·에이전트처럼 콘솔이 없는 곳에서 부르면
* `isInteractive()` 가 true 로 남고 `ask()` 가 오지 않을 응답을 무한히 기다린다.
* 프롬프트 출력마저 버퍼에 갇혀 있어 겉으로는 "커맨드가 느리다" 로만 보인다.
*
* `stream_isatty()` 는 posix 확장 없이도 Windows 를 포함해 동작하므로 이를 쓴다.
* 판정 재료가 없으면 true 를 돌려 기존 동작(질문)을 유지한다 — 물어보지 못해 멈추는
* 것보다 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
*
* @return bool 응답을 받을 수 있으면 true
*/
protected function canPromptForAnswer(): bool
{
// 테스트는 QuestionHelper 를 대체하거나 입력을 주입하므로 STDIN 을 쓰지 않는다
if ($this->laravel !== null && $this->laravel->runningUnitTests()) {
return true;
}
// 입력 스트림이 주입된 실행(CommandTester::setInputs 등)도 STDIN 과 무관하다
if ($this->input instanceof StreamableInputInterface && is_resource($this->input->getStream())) {
return true;
}
if (! \defined('STDIN') || ! is_resource(STDIN) || ! \function_exists('stream_isatty')) {
return true;
}
return stream_isatty(STDIN);
}
}
@@ -25,6 +25,17 @@ interface CacheableExtensionInterface
*/
public function getStorage(): StorageInterface;
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* getStorageDiskFor($category) 가 결정한 디스크의 인스턴스를 반환하며,
* 기본 구현은 getStorage() 와 동일한 디스크를 사용합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return StorageInterface 카테고리 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
+11 -7
View File
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 확장(모듈/플러그인) 스토리지 인터페이스
*
@@ -50,12 +52,14 @@ interface StorageInterface
/**
* 파일의 공개 URL을 반환합니다.
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 반환하고, 그 외에는 null 을 반환합니다 (별도 API 엔드포인트 사용).
* 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 항상 통과하므로,
* 확장이 URL 을 공급/수정/차단할 수 있습니다 (null 반환도 훅 발화 대상).
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string;
@@ -110,9 +114,9 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null)
* @return StreamedResponse|null 파일 스트림 (파일이 없으면 null)
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
/**
* 사용할 디스크를 변경한 새 인스턴스를 반환합니다.
@@ -132,7 +136,7 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
* @return StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
}
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
* 모든 템플릿을 로드하고 초기화합니다.
* 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
*/
public function loadTemplates(): void;
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
* (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
*/
public function ensureLoaded(): void;
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
@@ -65,4 +65,12 @@ interface ActivityLogRepositoryInterface
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int;
/**
* 보존 기간이 지난 활동 로그를 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
}
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
/**
* 첨부파일 Repository 인터페이스
@@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface
*/
public function findById(int $id): ?Attachment;
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로,
* 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다.
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment;
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로,
* 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다.
*
* 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고,
* 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
* @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
}
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
public function getActiveCoreLocales(): array;
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
* 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator;
/**
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
*
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
*/
public function allForUpdateCheck(): Collection;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
*/
public function findById(int $id): ?Menu;
/**
* 여러 ID로 메뉴를 한 번에 조회합니다.
*
* 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
* 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
*
* @param array<int, int> $ids 메뉴 ID 목록
* @return Collection 메뉴 컬렉션
*/
public function findByIds(array $ids): Collection;
/**
* 슬러그로 메뉴를 찾습니다.
*
@@ -48,6 +59,14 @@ interface MenuRepositoryInterface
*/
public function findBySlug(string $slug): ?Menu;
/**
* URL 로 메뉴를 찾습니다.
*
* @param string $url 메뉴 URL
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findByUrl(string $url): ?Menu;
/**
* 새로운 메뉴를 생성합니다.
*
@@ -40,6 +40,14 @@ interface NotificationLogRepositoryInterface
*/
public function bulkDelete(array $ids): int;
/**
* 보존 기간이 지난 발송 이력을 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
/**
* 페이지네이션 목록 조회.
*
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Permission;
/**
* 여러 ID로 권한을 일괄 조회합니다.
*
* @param array<int> $ids 권한 ID 배열
* @return Collection 권한 컬렉션 (ID 기준)
*/
public function getByIds(array $ids): Collection;
/**
* 새로운 권한을 생성합니다.
*
@@ -0,0 +1,23 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 보호된 역할(코어/확장 소유) 수정 시도 시 발생하는 예외
*
* 삭제 경로에만 있던 코어/확장 소유 역할 보호를 수정·상태변경 경로까지 대칭
* 적용하기 위한 예외입니다. 비-슈퍼관리자 액터는 `admin` 등 코어/확장 소유
* 역할의 권한·활성상태를 변경할 수 없습니다(KVE-2026-1919).
*/
class CannotModifyProtectedRoleException extends Exception
{
/**
* 보호된 역할 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_protected_role'));
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 비-슈퍼관리자 액터가 슈퍼 관리자 계정/역할을 수정하려 할 때 발생하는 예외
*
* 삭제 경로에만 있던 슈퍼 관리자 보호 가드를 수정·상태변경·권한부여 경로까지
* 대칭 적용하기 위한 등급 상한(rank ceiling) 위반 예외입니다.
*/
class CannotModifySuperAdminException extends Exception
{
/**
* 슈퍼 관리자 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_super_admin'));
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 권한 상승(escalation) 시도 시 발생하는 예외
*
* 비-슈퍼관리자 액터가 역할에 자신이 보유하지 않은 권한, 또는 자신의 범위(scope)보다
* 넓은 범위의 권한을 부여하려 할 때 발생합니다(KVE-2026-1919 권한 상승 차단).
*/
class PermissionEscalationException extends Exception
{
/**
* 권한 상승 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_grant_unheld_permission'));
}
}
@@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected array $storageServices = [];
/**
* 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리).
*
* 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이
* getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가
* 서비스 코드 무수정으로 카테고리 디스크를 따릅니다.
*
* @var array<class-string, string>
*/
protected array $storageCategoryServices = [];
/**
* CacheInterface 가 필요한 서비스 클래스 목록.
*
@@ -111,13 +122,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected function registerStorageBindings(): void
{
if (empty($this->storageServices)) {
return;
if (! empty($this->storageServices)) {
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
}
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
foreach ($this->storageCategoryServices as $service => $category) {
$this->app->when($service)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorageFor($category));
}
}
/**
+94
View File
@@ -927,6 +927,31 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
*
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
*
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
*/
public function getTrustedScriptHosts(): array
{
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
if (! is_array($hosts)) {
return [];
}
return array_values(array_filter(
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
fn ($host) => $host !== ''
));
}
/**
* 레이아웃 확장 파일 경로 반환
*
@@ -1309,6 +1334,75 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return 'modules';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
private array $storageByDisk = [];
/**
* 카테고리별 스토리지 디스크 이름 반환
*
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다.
*
* 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 —
* 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
* 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return string 디스크 이름
*/
public function getStorageDiskFor(string $category): string
{
return $this->getStorageDisk();
}
/**
* 카테고리별 스토리지 드라이버 인스턴스 반환
*
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
*
* @param string $category 카테고리
* @return StorageInterface 스토리지 드라이버 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
$disk = $this->getStorageDiskFor($category);
if (! isset($this->storageByDisk[$disk])) {
$base = $this->getStorage();
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
}
return $this->storageByDisk[$disk];
}
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
$disk = ($override !== null && $override !== '')
? $override
: (string) config('core.storage.public_asset_disk', '');
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
return null;
}
return $disk;
}
/**
* 모듈 캐시 드라이버 인스턴스 반환
*
+96 -1
View File
@@ -19,7 +19,8 @@ use ReflectionClass;
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
* getName(), getVersion(), getDescription()은 plugin.json에서 자동 파싱됩니다.
*
* 참고: 플러그인은 모듈과 달리 관리자 메뉴(getAdminMenus)를 추가할 수 없습니다.
* 참고: 플러그인도 관리자 메뉴(getAdminMenus)를 선언할 수 있습니다. 설치·업데이트·활성화가
* 공통으로 지나는 선언형 산출물 동기화에서 PluginManager 가 자동으로 반영합니다.
*/
abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInterface
{
@@ -771,6 +772,31 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return [];
}
/**
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
*
* plugin.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 플러그인이 레이아웃
* `scripts[].src` 로 로드하는 외부 CDN 호스트(예: `cdn.ckeditor.com`)를 선언합니다.
* 코어는 이 목록을 집계(AbstractPlugin/AbstractModule → TrustedScriptHosts)해 런타임
* 스크립트 로더·저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된
* 호스트만** 허용하도록 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
*
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.ckeditor.com'])
*/
public function getTrustedScriptHosts(): array
{
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
if (! is_array($hosts)) {
return [];
}
return array_values(array_filter(
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
fn ($host) => $host !== ''
));
}
/**
* 레이아웃 확장 파일 경로 반환
*
@@ -1203,6 +1229,75 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return 'plugins';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
private array $storageByDisk = [];
/**
* 카테고리별 스토리지 디스크 이름 반환
*
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 플러그인이 오버라이드합니다.
*
* 주의: 오버라이드 구현은 'settings' 카테고리에서 플러그인 설정을 조회하면 안 됩니다 —
* 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
* 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
*
* @param string $category 카테고리 (settings, data, images, temp)
* @return string 디스크 이름
*/
public function getStorageDiskFor(string $category): string
{
return $this->getStorageDisk();
}
/**
* 카테고리별 스토리지 드라이버 인스턴스 반환
*
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
*
* @param string $category 카테고리
* @return StorageInterface 스토리지 드라이버 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
$disk = $this->getStorageDiskFor($category);
if (! isset($this->storageByDisk[$disk])) {
$base = $this->getStorage();
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
}
return $this->storageByDisk[$disk];
}
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
$disk = ($override !== null && $override !== '')
? $override
: (string) config('core.storage.public_asset_disk', '');
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
return null;
}
return $disk;
}
/**
* 플러그인 캐시 드라이버 인스턴스 반환
*
@@ -139,4 +139,17 @@ final class InlineModuleExtensionAdapter implements CacheableExtensionInterface
{
return $this->storage ??= new ModuleStorageDriver($this->identifier);
}
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* fallback 어댑터는 모듈 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
*
* @param string $category 카테고리
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
return $this->getStorage();
}
}
@@ -130,4 +130,17 @@ final class InlinePluginExtensionAdapter implements CacheableExtensionInterface
{
return $this->storage ??= new PluginStorageDriver($this->identifier);
}
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* fallback 어댑터는 플러그인 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다.
*
* @param string $category 카테고리
* @return StorageInterface 기본 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
return $this->getStorage();
}
}
+354 -61
View File
@@ -141,11 +141,22 @@ class ExtensionPendingHelper
// Windows에서 deleteDirectory 직후 같은 이름으로 rename이 실패하는
// 타이밍 이슈를 회피하기 위해 rename→rename→delete 패턴을 사용합니다.
// 임시 디렉토리를 _pending/ 하위에 생성하여 오토로드 오염 방지
//
// Windows 잠금 대응: 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의
// 디렉토리 핸들, IDE/Node 프로세스가 열어 둔 파일 등)이 하나라도 있으면
// 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없으므로(디렉토리 핸들은
// Restart Manager 로 감지 불가), rename 이 차단되면 파일 단위 연산으로
// 폴백한다 — 파일 생성/덮어쓰기/읽기는 디렉토리 핸들 잠금의 영향을 받지
// 않아 어떤 프로세스도 종료하지 않고 교체를 완료할 수 있다.
$basePath = dirname($targetPath);
$pendingPath = $basePath.DIRECTORY_SEPARATOR.'_pending';
File::ensureDirectoryExists($pendingPath, 0775);
$identifier = basename($targetPath);
// 이전 실패 실행이 남긴 교체용 임시 디렉토리 정리 (best-effort)
self::cleanupSwapLeftovers($pendingPath, $identifier);
$tempPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_updating_'.uniqid();
$oldPath = $pendingPath.DIRECTORY_SEPARATOR.$identifier.'_old_'.uniqid();
@@ -161,40 +172,77 @@ class ExtensionPendingHelper
// 기존 → _old 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
// 파일 잠금 감지 및 해제 시도
if (self::tryReleaseLocks($targetPath, $onProgress)) {
// 잠금 해제 후 재시도
if (! self::retryMoveDirectory($targetPath, $oldPath)) {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
}
} else {
File::deleteDirectory($tempPath);
throw new \RuntimeException(
"Failed to move existing directory: {$targetPath} → {$oldPath}"
);
// 활성 디렉토리 rename 차단 (하위 트리에 열린 핸들 존재)
// → 파일 단위 제자리 동기화로 폴백. 덮어쓰기는 잠금의 영향을 받지 않는다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 제자리 교체로 전환합니다...');
Log::info('확장 교체: 활성 디렉토리 rename 차단 — 제자리 동기화 폴백', [
'target' => $targetPath,
]);
try {
self::syncDirectoryContents($tempPath, $targetPath, $onProgress);
} finally {
self::bestEffortDeleteDirectory($tempPath);
}
self::refreshRuntimeCaches();
return;
}
// 임시 → 활성 이동 (rename, Windows NTFS 타이밍 이슈 대응 재시도)
if (! self::retryMoveDirectory($tempPath, $targetPath)) {
// 롤백: _old를 원래 위치로 복원
File::moveDirectory($oldPath, $targetPath);
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}"
);
// 방금 복사한 스테이징 트리를 파일 워처가 이미 열어 rename 이 차단된 경우
// → 파일 단위 복사로 폴백. 소스에는 읽기 접근만 필요해 잠금과 무관하게 성공한다.
$onProgress?->__invoke(null, '디렉토리 이동이 차단되어 파일 단위 복사로 전환합니다...');
Log::info('확장 교체: 스테이징 rename 차단 — 파일 단위 복사 폴백', [
'staging' => $tempPath,
'target' => $targetPath,
]);
try {
self::copyDirectoryWithProgress($tempPath, $targetPath, $tempPath, $onProgress);
} catch (\Exception $e) {
// 복사 실패: 부분 복사본 제거 후 _old 를 원래 위치로 복원
self::bestEffortDeleteDirectory($targetPath);
if (! self::retryMoveDirectory($oldPath, $targetPath)) {
try {
self::syncDirectoryContents($oldPath, $targetPath, $onProgress);
} catch (\Throwable $restoreError) {
Log::error('확장 교체 롤백 실패 — 백업 복원이 필요합니다', [
'target' => $targetPath,
'old' => $oldPath,
'error' => $restoreError->getMessage(),
]);
}
}
throw new \RuntimeException(
"Failed to move directory: {$tempPath} → {$targetPath}",
0,
$e
);
}
self::bestEffortDeleteDirectory($tempPath);
}
// 교체 완료 후 _old 삭제 (실패해도 무해)
File::deleteDirectory($oldPath);
// 교체 완료 후 _old 삭제 (실패해도 무해 — 다음 교체 시작 시 잔존물 정리가 재시도)
self::bestEffortDeleteDirectory($oldPath);
// 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
// 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
// PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
// 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
// clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
self::refreshRuntimeCaches();
}
/**
* 교체 완료 후 PHP 런타임 캐시를 갱신합니다.
*
* 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
* 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
* PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
* 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
* clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
*/
private static function refreshRuntimeCaches(): void
{
clearstatcache(true);
// opcache 가 활성화된 프로덕션에서는 활성 디렉토리 하위의 이전 컴파일 바이트코드가
@@ -237,41 +285,6 @@ class ExtensionPendingHelper
]);
}
/**
* 파일 잠금을 감지하고 해제를 시도합니다.
*
* Windows에서 다른 프로세스(IDE 등)가 파일 핸들을 보유하고 있을 때,
* 해당 프로세스를 감지하고 종료하여 디렉토리 이동이 가능하도록 합니다.
* 프로그레스바와 별개로 STDERR에 직접 출력하여 메시지가 덮어씌워지지 않습니다.
*
* @param string $directoryPath 잠금 해제할 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
* @return bool 잠금 해제 성공 여부
*/
private static function tryReleaseLocks(string $directoryPath, ?\Closure $onProgress = null): bool
{
if (! FileHandleHelper::isWindows()) {
return false;
}
// 프로그레스바에 의해 메시지가 덮어씌워지지 않도록 STDERR에 직접 출력
$stderr = fopen('php://stderr', 'w');
$outputCallback = function (string $message) use ($stderr) {
if ($stderr) {
fwrite($stderr, $message.PHP_EOL);
}
Log::info($message, ['context' => 'file_lock_release']);
};
$result = FileHandleHelper::releaseLocks($directoryPath, $outputCallback);
if ($stderr) {
fclose($stderr);
}
return $result;
}
/**
* 디렉토리 이동을 재시도합니다 (Windows NTFS 타이밍 이슈 대응).
*
@@ -333,6 +346,282 @@ class ExtensionPendingHelper
}
}
/**
* 새 버전 콘텐츠를 활성 디렉토리에 파일 단위로 제자리 동기화합니다.
*
* 디렉토리 rename 이 외부 프로세스의 핸들 잠금으로 차단될 때 사용하는 폴백입니다.
* 디렉토리 inode 를 건드리지 않고 파일 생성/덮어쓰기/삭제만 수행하므로
* 디렉토리 핸들 잠금(파일 워처 등)의 영향을 받지 않습니다.
*
* (1) 새 버전 파일 전체를 덮어쓰기 → (2) 새 버전에 없는 잔존 파일 제거.
* (1) 실패는 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하게 하고,
* (2) 실패는 로그만 남기고 진행합니다 (업데이트 전체 실패보다 잔존이 낫다).
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param \Closure|null $onProgress 진행 콜백
*
* @throws \RuntimeException 새 버전 파일을 심지 못했을 때
*/
private static function syncDirectoryContents(string $source, string $dest, ?\Closure $onProgress = null): void
{
File::ensureDirectoryExists($dest, 0775);
$failed = [];
self::overlayDirectory($source, $dest, $source, $onProgress, $failed);
if (! empty($failed)) {
throw new \RuntimeException(
'Failed to replace files locked by another process: '
.implode(', ', array_slice($failed, 0, 5))
.(count($failed) > 5 ? ' (+'.(count($failed) - 5).' more)' : '')
);
}
$staleFailures = [];
self::removeStaleEntries($source, $dest, $staleFailures);
if (! empty($staleFailures)) {
Log::warning('확장 제자리 교체: 일부 잔존 파일을 삭제하지 못했습니다 (다음 교체 시 재시도)', [
'dest' => $dest,
'failed' => $staleFailures,
]);
}
}
/**
* 소스 디렉토리의 파일을 대상 디렉토리에 재귀적으로 덮어씁니다.
*
* @param string $source 소스 디렉토리
* @param string $dest 대상 디렉토리
* @param string $basePath 상대 경로 계산 기준
* @param \Closure|null $onProgress 진행 콜백
* @param array $failed 교체 실패한 상대 경로 수집 (참조)
*/
private static function overlayDirectory(
string $source,
string $dest,
string $basePath,
?\Closure $onProgress,
array &$failed
): void {
File::ensureDirectoryExists($dest, 0775);
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
if ($item->isDir() && in_array($item->getBasename(), self::EXCLUDED_DIRECTORIES, true)) {
continue;
}
$target = $dest.DIRECTORY_SEPARATOR.$item->getBasename();
$relativePath = ltrim(str_replace($basePath, '', $item->getPathname()), '/\\');
if ($item->isDir()) {
if (is_file($target) && ! self::deleteFileWithFallback($target)) {
// 파일 → 디렉토리로 바뀐 경로인데 기존 파일을 치우지 못함
$failed[] = $relativePath;
continue;
}
self::overlayDirectory($item->getPathname(), $target, $basePath, $onProgress, $failed);
} else {
if (is_dir($target)) {
// 디렉토리 → 파일로 바뀐 경로
File::deleteDirectory($target);
}
$onProgress?->__invoke(null, $relativePath);
if (! self::replaceFile($item->getPathname(), $target)) {
$failed[] = $relativePath;
}
}
}
}
/**
* 파일 하나를 덮어씁니다 (잠금 대응 단계적 폴백).
*
* Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용하므로,
* 다른 프로세스가 열어 둔 파일이라도 내용 덮어쓰기는 대부분 성공합니다.
* 덮어쓰기가 막힌 경우에만 삭제 후 재생성 → 옆으로 치우기(rename) 순으로
* 시도합니다. FilePermissionHelper::copyFile() 은 복사 실패를 보고하지 않으므로
* (File::copy 반환값 무시) 이 경로에서는 네이티브 copy 반환값으로 판정합니다.
*
* @param string $source 소스 파일
* @param string $destination 대상 파일
* @return bool 교체 성공 여부
*/
private static function replaceFile(string $source, string $destination): bool
{
File::ensureDirectoryExists(dirname($destination));
$isExisting = is_file($destination);
$existingPerms = $isExisting ? @fileperms($destination) : null;
$existingOwner = $isExisting ? @fileowner($destination) : null;
$existingGroup = $isExisting ? @filegroup($destination) : null;
$restoreMeta = function () use ($destination, $isExisting, $existingPerms, $existingOwner, $existingGroup) {
if (! $isExisting) {
// 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (sudo 실행 시 root 소유 방지)
FilePermissionHelper::inheritOwnershipFromParent($destination);
return;
}
if ($existingPerms !== false && $existingPerms !== null) {
@chmod($destination, $existingPerms);
}
if ($existingOwner !== false && $existingOwner !== null && function_exists('chown')) {
@chown($destination, $existingOwner);
}
if ($existingGroup !== false && $existingGroup !== null && function_exists('chgrp')) {
@chgrp($destination, $existingGroup);
}
};
// 1차: 그대로 덮어쓰기
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 2차: 읽기 전용 속성 해제 후 재시도
@chmod($destination, 0666);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
// 3차: 삭제 후 재생성
if (@unlink($destination) && @copy($source, $destination)) {
$restoreMeta();
return true;
}
// 4차: 잠긴 파일을 옆으로 치우고(rename 은 덮어쓰기와 별개 권한) 새 파일 생성.
// 옆으로 치운 파일은 즉시 삭제를 시도하고, 실패해도 새 버전에 없는 파일이므로
// 다음 제자리 동기화의 잔존 파일 제거가 다시 삭제를 시도한다.
$aside = $destination.'.g7stale_'.uniqid();
if (@rename($destination, $aside)) {
@unlink($aside);
if (@copy($source, $destination)) {
$restoreMeta();
return true;
}
}
return false;
}
/**
* 파일 하나를 삭제합니다 (잠금 대응 단계적 폴백).
*
* @param string $path 삭제할 파일 경로
* @return bool 삭제(또는 옆으로 치우기) 성공 여부
*/
private static function deleteFileWithFallback(string $path): bool
{
if (@unlink($path)) {
return true;
}
@chmod($path, 0666);
if (@unlink($path)) {
return true;
}
// 삭제가 막힌 경우 옆으로 치워 원래 이름을 비운다
$aside = $path.'.g7stale_'.uniqid();
if (@rename($path, $aside)) {
@unlink($aside);
return true;
}
return false;
}
/**
* 대상 디렉토리에서 소스에 존재하지 않는 파일/디렉토리를 제거합니다.
*
* @param string $source 새 버전 콘텐츠 디렉토리
* @param string $dest 활성 디렉토리
* @param array $failures 삭제 실패 경로 수집 (참조)
*/
private static function removeStaleEntries(string $source, string $dest, array &$failures): void
{
if (! is_dir($dest)) {
return;
}
$items = new \FilesystemIterator($dest, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
$counterpart = $source.DIRECTORY_SEPARATOR.$item->getBasename();
if ($item->isDir()) {
if (is_dir($counterpart)) {
self::removeStaleEntries($counterpart, $item->getPathname(), $failures);
} else {
File::deleteDirectory($item->getPathname());
if (is_dir($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
} elseif (! is_file($counterpart)) {
if (! self::deleteFileWithFallback($item->getPathname())) {
$failures[] = $item->getPathname();
}
}
}
}
/**
* 이전 실패 실행이 남긴 교체용 임시 디렉토리를 정리합니다 (best-effort).
*
* 잠금으로 인해 삭제하지 못한 `_updating_`/`_old_` 디렉토리는 다음 교체 시작
* 시점(잠금이 풀린 뒤일 가능성이 높음)에 다시 삭제를 시도합니다.
* 호출자가 소유한 스테이징 디렉토리(`{identifier}_{Ymd_His}`)는 건드리지 않습니다.
*
* @param string $pendingPath _pending 디렉토리 경로
* @param string $identifier 확장 식별자
*/
private static function cleanupSwapLeftovers(string $pendingPath, string $identifier): void
{
foreach (['_updating_', '_old_'] as $marker) {
$pattern = $pendingPath.DIRECTORY_SEPARATOR.$identifier.$marker.'*';
foreach (glob($pattern) ?: [] as $leftover) {
if (is_dir($leftover)) {
self::bestEffortDeleteDirectory($leftover);
}
}
}
}
/**
* 디렉토리를 삭제하되, 실패해도 예외를 던지지 않습니다.
*
* 잠금으로 삭제하지 못한 디렉토리는 다음 교체 시작 시 잔존물 정리가 재시도합니다.
*
* @param string $path 삭제할 디렉토리 경로
*/
private static function bestEffortDeleteDirectory(string $path): void
{
if (! File::isDirectory($path)) {
return;
}
File::deleteDirectory($path);
if (File::isDirectory($path)) {
Log::info('디렉토리를 완전히 삭제하지 못했습니다 (다음 교체 시 잔존물 정리에서 재시도)', [
'path' => $path,
]);
}
}
/**
* 확장 디렉토리를 삭제합니다.
*
@@ -353,6 +642,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _pending 존재 여부
*/
public static function isPending(string $basePath, string $identifier): bool
{
@@ -364,6 +654,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return bool _bundled 존재 여부
*/
public static function isBundled(string $basePath, string $identifier): bool
{
@@ -375,6 +666,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _pending 절대 경로
*/
public static function getPendingPath(string $basePath, string $identifier): string
{
@@ -386,6 +678,7 @@ class ExtensionPendingHelper
*
* @param string $basePath 확장 타입의 기본 경로
* @param string $identifier 확장 식별자
* @return string _bundled 절대 경로
*/
public static function getBundledPath(string $basePath, string $identifier): string
{
+56 -6
View File
@@ -21,6 +21,7 @@ use App\Exceptions\LayoutIncludeException;
use App\Extension\Concerns\ResolvesExtensionSharedRecords;
use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
@@ -449,6 +450,9 @@ class PluginManager implements PluginManagerInterface
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
// 관리자 메뉴 자동 생성 (모듈 installModule 과 동일 순서)
$this->createPluginMenus($plugin);
// IDV 정책 자동 동기화 (identity_policies 테이블)
$this->syncPluginIdentityPolicies($plugin);
@@ -2003,10 +2007,54 @@ class PluginManager implements PluginManagerInterface
);
}
/**
* 플러그인이 선언한 관리자 메뉴를 동기화합니다.
*
* 플러그인도 자기 화면을 가질 수 있으므로 `getAdminMenus()` 를 선언할 수 있습니다.
* 이 동기화가 설치·활성화 시점에만 일어나면, 이미 활성 상태인 사이트가 플러그인을
* 업데이트해 새 화면을 받아도 메뉴가 만들어지지 않아 그 화면은 주소를 직접 입력해야만
* 닿을 수 있습니다. 그래서 선언형 산출물 동기화(설치·업데이트 공통 경로)에 포함합니다.
*
* helper 의 upsert 패턴을 그대로 쓰므로 재호출은 무해하며(멱등) 운영자 커스터마이징
* (user_overrides) 도 보존됩니다.
*
* @param PluginInterface $plugin 메뉴를 동기화할 플러그인 인스턴스
*/
protected function createPluginMenus(PluginInterface $plugin): void
{
if (! method_exists($plugin, 'getAdminMenus')) {
return;
}
$menus = $plugin->getAdminMenus();
if (empty($menus)) {
return;
}
// 활성 언어팩이 admin_menus 다국어 필드(name 등)에 추가 locale 을 주입할 수 있도록
// 모듈과 동일한 필터 훅 규약을 적용한다 (LanguagePackSeedInjector 가 결선).
$menus = HookManager::applyFilters(
"plugin.{$plugin->getIdentifier()}.admin_menus.translations",
$menus,
);
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($menus as $menuData) {
$helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$plugin->getIdentifier(),
);
}
}
/**
* 플러그인 정의 기준으로 stale 권한·역할을 정리합니다 (완전 동기화 원칙).
*
* 플러그인은 메뉴(getAdminMenus) 를 지원하지 않으므로 권한·역할만 대상.
* 메뉴 stale 정리는 비활성화·삭제 시 플러그인 자신이 `cleanupStaleMenus` 로 수행하므로
* 여기서는 권한·역할만 대상으로 한다.
* user_overrides 보존 및 `users.role_id` 참조 역할 삭제 차단은 helper 가 담당.
*/
protected function cleanupStalePluginEntries(PluginInterface $plugin): void
@@ -2065,14 +2113,15 @@ class PluginManager implements PluginManagerInterface
* 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
* (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
*
* 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재):
* 동기화 대상:
* 1. 역할 (`getRoles`)
* 2. 권한 (`getPermissions`)
* 3. 역할-권한 매핑 (`getRolePermissions`)
* 4. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 5. IDV 정책 (`getIdentityPolicies`)
* 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 7. 알림 정의/템플릿 (`getNotificationDefinitions`)
* 4. 관리자 메뉴 (`getAdminMenus`)
* 5. stale cleanup (현재 선언에 없는 기존 레코드 제거)
* 6. IDV 정책 (`getIdentityPolicies`)
* 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
* 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
*
* 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
* 무해 (멱등).
@@ -2084,6 +2133,7 @@ class PluginManager implements PluginManagerInterface
$this->createPluginRoles($plugin);
$this->createPluginPermissions($plugin);
$this->assignPermissionsToRoles($plugin);
$this->createPluginMenus($plugin);
$this->cleanupStalePluginEntries($plugin);
$this->syncPluginIdentityPolicies($plugin);
$this->syncPluginIdentityMessages($plugin);
@@ -0,0 +1,70 @@
<?php
namespace App\Extension\Storage\Concerns;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Storage;
/**
* 스토리지 드라이버 공용 공개 URL 해석 trait
*
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 생성하고, 그 외에는 null 을 생성합니다. 생성 결과는 디스크 종류와 무관하게
* `core.storage.filter_url` 필터 훅을 항상 통과하므로, 확장이 URL 을 공급/수정/차단할 수 있습니다.
*/
trait ResolvesPublicUrl
{
/**
* 파일의 공개 URL을 반환합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로 (카테고리 하위 상대 경로)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string
{
$fullPath = $this->resolvePath($category, $path);
$url = $this->diskSupportsDirectUrl()
? Storage::disk($this->disk)->url($fullPath)
: null;
$filtered = HookManager::applyFilters('core.storage.filter_url', $url, array_merge(
$this->urlHookContext(),
[
'disk' => $this->disk,
'category' => $category,
'path' => $path,
'full_path' => $fullPath,
]
));
return (is_string($filtered) && trim($filtered) !== '') ? $filtered : null;
}
/**
* 현재 디스크가 직접 URL 생성을 지원하는지 판정합니다.
*
* public 디스크는 항상 지원, 그 외 디스크는 `filesystems.disks.{disk}.url` 이
* 비어 있지 않은 문자열로 설정된 경우에만 지원합니다 (AWS_URL 미설정/빈값 방어).
*
* @return bool 직접 URL 지원 여부
*/
private function diskSupportsDirectUrl(): bool
{
if ($this->disk === 'public') {
return true;
}
$base = config("filesystems.disks.{$this->disk}.url");
return is_string($base) && trim($base) !== '';
}
/**
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope(core|module|plugin) + 확장 식별자(코어는 null)
*/
abstract protected function urlHookContext(): array;
}
+14 -11
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 코어 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class CoreStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 사용할 디스크 이름
*/
@@ -93,17 +97,16 @@ class CoreStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 식별자 (코어는 null)
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'core',
'identifier' => null,
];
}
/**
@@ -155,7 +158,7 @@ class CoreStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -180,7 +183,7 @@ class CoreStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+14 -12
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 모듈 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class ModuleStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 모듈 식별자
*/
@@ -119,18 +123,16 @@ class ModuleStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 모듈 식별자
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
// public disk인 경우에만 직접 URL 반환
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'module',
'identifier' => $this->identifier,
];
}
/**
@@ -182,7 +184,7 @@ class ModuleStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -207,7 +209,7 @@ class ModuleStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+14 -12
View File
@@ -3,7 +3,9 @@
namespace App\Extension\Storage;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\Concerns\ResolvesPublicUrl;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 플러그인 스토리지 드라이버
@@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage;
*/
class PluginStorageDriver implements StorageInterface
{
use ResolvesPublicUrl;
/**
* 플러그인 식별자
*/
@@ -119,18 +123,16 @@ class PluginStorageDriver implements StorageInterface
}
/**
* {@inheritDoc}
* `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다.
*
* @return array{scope: string, identifier: ?string} scope + 플러그인 식별자
*/
public function url(string $category, string $path): ?string
protected function urlHookContext(): array
{
// public disk인 경우에만 직접 URL 반환
if ($this->disk !== 'public') {
return null;
}
$fullPath = $this->resolvePath($category, $path);
return Storage::disk($this->disk)->url($fullPath);
return [
'scope' => 'plugin',
'identifier' => $this->identifier,
];
}
/**
@@ -182,7 +184,7 @@ class PluginStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
@@ -207,7 +209,7 @@ class PluginStorageDriver implements StorageInterface
/**
* {@inheritDoc}
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse
{
$fullPath = $this->resolvePath($category, $path);
+86 -22
View File
@@ -51,6 +51,14 @@ class TemplateManager implements TemplateManagerInterface
protected array $templates = [];
/**
* 템플릿 디렉토리 스캔이 1회 이상 수행되었는지 여부
*
* `ensureLoaded()` 의 멱등 판정에만 쓴다 — 명시적 `loadTemplates()` 는 이 값과 무관하게
* 항상 재스캔한다(설치/삭제 직후 갱신 계약).
*/
protected bool $templatesLoaded = false;
/**
* _pending 디렉토리의 템플릿 메타데이터 배열
*
@@ -98,13 +106,33 @@ class TemplateManager implements TemplateManagerInterface
return new CoreCacheDriver(config('cache.default', 'array'));
}
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* 소비자가 "템플릿 맵이 채워져 있음" 만 필요로 할 때 쓴다. `loadTemplates()` 는 맵을
* 리셋하고 디렉토리를 통째로 재스캔하므로, 그것을 무조건 호출하면 공유 인스턴스의
* 상태를 매번 갈아엎으면서 풀스캔 비용까지 반복된다.
*/
public function ensureLoaded(): void
{
if ($this->templatesLoaded) {
return;
}
$this->loadTemplates();
}
/**
* 모든 템플릿을 로드하고 초기화합니다.
*
* 항상 재스캔한다 — 설치/삭제/업데이트 직후 갱신을 보장하는 계약이다.
* 단순히 "채워져 있으면 됨" 인 호출자는 `ensureLoaded()` 를 쓴다.
*/
public function loadTemplates(): void
{
// 기존 템플릿 캐시 초기화 (테스트 환경에서 재로드 지원)
$this->templates = [];
$this->templatesLoaded = true;
if (! File::exists($this->templatesPath)) {
return;
@@ -1659,13 +1687,9 @@ class TemplateManager implements TemplateManagerInterface
}
if (! in_array($override->name, $registeredLayoutNames)) {
// 캐시 삭제 (레코드 삭제 전에 수행)
$cacheKey = "template.{$templateId}.layout.{$override->name}";
$this->cache()->forget($cacheKey);
$sourceHash = md5($override->source_type?->value.$override->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 캐시 삭제 (레코드 삭제 전에 수행) — 키 조립은 트레이트 단일 지점에 위임
// (.with_source_meta 변종 포함, 수작업 키 목록의 규약 드리프트 방지)
$this->forgetLayoutCacheKeys($override, $templateName);
$override->forceDelete();
$deletedCount++;
@@ -1712,14 +1736,10 @@ class TemplateManager implements TemplateManagerInterface
}
foreach ($overrideLayouts as $layout) {
// 기본 캐시 키 패턴으로 삭제
$cacheKey = "template.{$templateId}.layout.{$layout->name}";
$this->cache()->forget($cacheKey);
// sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴)
$sourceHash = md5($layout->source_type?->value.$layout->source_identifier);
$cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}";
$this->cache()->forget($cacheKeyWithHash);
// 키 조립은 트레이트 단일 지점에 위임 (.with_source_meta 변종 포함).
// 이 경로는 identifier 를 보유하지 않으므로 '' 전달 — 버전 포함 공개
// 서빙 키는 종전과 동일하게 버전 bump 로 무효화된다.
$this->forgetLayoutCacheKeys($layout, '');
}
Log::info(__('templates.info.override_layouts_cache_invalidated'), [
@@ -1766,41 +1786,79 @@ class TemplateManager implements TemplateManagerInterface
/**
* 템플릿 관련 모든 캐시를 삭제합니다.
*
* 템플릿 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는
* 무효화 단일 지점이다. 버전 접미사 없는 고정 키(`template.config.{identifier}`,
* `template.{id|identifier}.components_manifest`)는 캐시 버전 bump 로 무효화되지
* 않으므로 여기서 능동 forget 한다 — 누락 시 공개 config.json 이 TTL(1시간) 동안
* 이전 manifest 로 응답된다 (#588, 공개 #119).
*
* template:cache-clear 커맨드가 호출할 수 있도록 public 이다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 능동 삭제를 시도한 캐시 키 수
*/
protected function clearTemplateCache(string $templateIdentifier): void
public function clearTemplateCache(string $templateIdentifier): int
{
$clearedCount = 0;
// identifier 만으로 가능한 고정 키 forget — uninstall 등 DB 레코드가 이미
// 없는 상황에서도 반드시 수행되어야 하므로 레코드 조회보다 먼저 둔다.
$this->cache()->forget("template.config.{$templateIdentifier}");
$clearedCount++;
// components_manifest identifier 변종 (ComponentExists 는 int|string 수용)
$this->cache()->forget("template.{$templateIdentifier}.components_manifest");
$clearedCount++;
// 활성 템플릿 + 레이아웃 무변경 업데이트 경로는 캐시 버전 bump 가 없어
// 현재 버전 routes/language 키가 stale 로 남는다 — warmTemplateCache() 와
// 대칭으로 현재 버전 키를 능동 삭제한다 (이전 버전 키는 TTL 자연 만료).
$cacheVersion = self::getExtensionCacheVersion();
$this->cache()->forget("template.routes.{$templateIdentifier}.v{$cacheVersion}");
$clearedCount++;
foreach (config('app.supported_locales', ['ko', 'en']) as $locale) {
$this->cache()->forget("template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}");
$clearedCount++;
}
// DB 기반 조회 — 파일 시스템 상태와 무관하게 캐시 삭제 보장
// (파일 교체 중이거나 reloadTemplate() 전에도 캐시를 확실히 삭제)
$templateRecord = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $templateRecord) {
return;
return $clearedCount;
}
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$this->clearLayoutCaches($templateIdentifier);
// components_manifest 숫자 id 변종 (StoreLayoutRequest 가 integer 검증 — 실운영 키)
$this->cache()->forget("template.{$templateRecord->id}.components_manifest");
$clearedCount++;
// Routes/다국어 캐시는 버전 포함 키이므로 incrementExtensionCacheVersion() + TTL로 무효화됨
// 레이아웃 캐시 삭제 (버전 없는 내부 캐시)
$clearedCount += $this->clearLayoutCaches($templateIdentifier);
Log::info(__('templates.info.cache_cleared'), [
'template' => $templateIdentifier,
]);
return $clearedCount;
}
/**
* 템플릿의 모든 레이아웃 캐시를 삭제합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
protected function clearLayoutCaches(string $templateIdentifier): void
protected function clearLayoutCaches(string $templateIdentifier): int
{
// 템플릿의 모든 레이아웃 조회
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $template) {
return;
return 0;
}
$this->invalidateTemplateLayoutCache($template->id, $templateIdentifier);
return $this->layoutRepository->getByTemplateId($template->id)->count();
}
/**
@@ -3082,6 +3140,12 @@ class TemplateManager implements TemplateManagerInterface
$onProgress?->__invoke('cleanup', '정리 중...');
ExtensionBackupHelper::deleteBackup($backupPath);
// 고정 키(config/components_manifest) + 현재 버전 routes/language 키 능동 삭제.
// 비활성 업데이트·레이아웃 무변경 업데이트 경로는 버전 bump 가 없어 이 호출이
// 없으면 공개 config.json 이 TTL 동안 이전 manifest 로 응답된다 (#588, 공개 #119).
// 활성 경로의 refreshTemplateLayouts() 경유 중복 삭제는 forget 멱등이라 무해.
$this->clearTemplateCache($identifier);
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
// refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨
@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Log;
* 버전 없는 내부 캐시 키를 능동 삭제합니다:
* 1. template.{templateId}.layout.{layoutName} - LayoutService에서 사용
* 2. template.{templateId}.layout.{layoutName}.{sourceHash} - 모듈/플러그인 레이아웃
* 3. 위 두 형태의 `.with_source_meta` 접미사 변종 - 편집기용 병합 응답 캐시
*
* 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는
* incrementExtensionCacheVersion() + TTL로 무효화됩니다.
@@ -98,20 +99,26 @@ trait InvalidatesLayoutCache
$cache = $this->resolveLayoutCache();
// 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제)
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
// 편집기용 병합 응답은 `.with_source_meta` 접미사 키로 별도 캐싱되므로
// (LayoutService::getMergedLayoutCacheKey) 두 변종을 함께 지운다 —
// 접미사 키를 남기면 activate/deactivate/uninstall/refresh 후에도
// 편집기가 stale 병합 캐시를 받는다 (#588).
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, withSourceMeta: true));
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제)
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) — 접미사는 소스 해시 뒤
if ($layout->source_type && $layout->source_identifier) {
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
$sourceType = $layout->source_type->value;
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier));
$cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier, true));
}
// 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두.
// PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta`
// 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화
// (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를
// 받는다. 레이아웃
// 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다.
// 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를
// 지우므로 정합을 맞춘다.
if ($templateIdentifier) {
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
@@ -137,26 +144,32 @@ trait InvalidatesLayoutCache
/**
* 레이아웃 캐시 키를 생성합니다.
*
* `$withSourceMeta` 가 true 면 `.with_source_meta` 접미사를 붙인다 — 편집기용
* 병합 응답 키(LayoutService::getMergedLayoutCacheKey 와 동형, 접미사는 소스 해시 뒤).
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string|null $sourceType 소스 타입 (선택)
* @param string|null $sourceIdentifier 소스 식별자 (선택)
* @param bool $withSourceMeta 편집기용 병합 응답 키 여부 (선택)
* @return string 캐시 키
*/
protected function buildLayoutCacheKey(
int $templateId,
string $layoutName,
?string $sourceType = null,
?string $sourceIdentifier = null
?string $sourceIdentifier = null,
bool $withSourceMeta = false
): string {
$baseKey = "template.{$templateId}.layout.{$layoutName}";
$metaSuffix = $withSourceMeta ? '.with_source_meta' : '';
if ($sourceType && $sourceIdentifier) {
$sourceHash = md5($sourceType.$sourceIdentifier);
return "{$baseKey}.{$sourceHash}";
return "{$baseKey}.{$sourceHash}{$metaSuffix}";
}
return $baseKey;
return "{$baseKey}{$metaSuffix}";
}
}
+32
View File
@@ -204,6 +204,38 @@ class PermissionHelper
return false;
}
/**
* 스코프 접근이 허용되는 모델만 남긴 배열을 반환합니다 (정적 일괄 라우트용).
*
* `PermissionMiddleware` 의 스코프 검사는 라우트에서 모델이 resolve 될 때만
* 동작합니다 — 모델이 없으면 목록 엔드포인트로 보아 건너뜁니다. 따라서
* `{user}` 같은 파라미터가 없는 **정적 일괄 라우트**(예: `PATCH users/bulk-status`)
* 에서는 스코프 검사가 통째로 우회됩니다. 상세 경로가 403 으로 막는 대상을
* 일괄 경로로는 바꿀 수 있으면 그 경로가 우회로이므로, 서비스 계층에서
* 같은 판정(`checkScopeAccess`)을 재적용해야 합니다.
*
* 판정은 대상별로 이뤄집니다 — 액터의 유효 스코프가 self 면 자기 소유만,
* role 이면 같은 역할 범위까지, 미지정(글로벌)이면 전체가 통과합니다.
*
* @param iterable<Model> $models 검사 대상 모델 목록
* @param string $permission 권한 식별자
* @param User|null $user 사용자 (null이면 현재 인증 사용자)
* @return array<Model> 스코프 접근이 허용된 모델 목록
*/
public static function filterByScope(iterable $models, string $permission, ?User $user = null): array
{
$user = $user ?? Auth::user();
$allowed = [];
foreach ($models as $model) {
if (self::checkScopeAccess($model, $permission, $user)) {
$allowed[] = $model;
}
}
return $allowed;
}
/**
* Permission 스코프 데이터를 static 캐시와 함께 조회합니다.
*
+18 -6
View File
@@ -1,5 +1,6 @@
<?php
use App\Support\ExtensionSettingsMirror;
use Illuminate\Support\Facades\Config;
if (! function_exists('g7_settings')) {
@@ -16,15 +17,12 @@ if (! function_exists('g7_settings')) {
* @example
* // 코어 메일 설정 조회
* $mailHost = g7_settings('core.mail.host');
*
* @example
* // 모듈 설정 조회
* $shopName = g7_settings('modules.sirsoft-ecommerce.basic_info.shop_name');
*
* @example
* // 플러그인 설정 조회
* $displayMode = g7_settings('plugins.sirsoft-daum_postcode.display_mode');
*
* @example
* // 전체 설정 조회
* $allSettings = g7_settings();
@@ -50,7 +48,6 @@ if (! function_exists('g7_core_settings')) {
* @example
* // 메일 호스트 조회
* $mailHost = g7_core_settings('mail.host');
*
* @example
* // 사이트명 조회
* $siteName = g7_core_settings('general.site_name');
@@ -77,7 +74,6 @@ if (! function_exists('g7_module_settings')) {
* @example
* // 이커머스 모듈의 쇼핑몰명 조회
* $shopName = g7_module_settings('sirsoft-ecommerce', 'basic_info.shop_name');
*
* @example
* // 모듈 전체 설정 조회
* $allSettings = g7_module_settings('sirsoft-ecommerce');
@@ -92,6 +88,23 @@ if (! function_exists('g7_module_settings')) {
}
}
if (! function_exists('g7_refresh_module_settings_config')) {
/**
* 모듈 환경설정 config 미러를 다시 채웁니다.
*
* 모듈 설정 저장에는 코어 공통 지점이 없어 각 모듈의 SettingsService 가 직접 저장합니다.
* 그 서비스가 자기 캐시를 비우는 자리에서 이 헬퍼를 호출하면, 상주 프로세스에서도
* `g7_module_settings()` 가 저장 직후의 값을 읽습니다 (공개이슈 #109).
*
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
* @return void
*/
function g7_refresh_module_settings_config(string $identifier): void
{
app(ExtensionSettingsMirror::class)->refreshModule($identifier);
}
}
if (! function_exists('g7_plugin_settings')) {
/**
* 그누보드7 플러그인 환경설정 조회 헬퍼 함수
@@ -104,7 +117,6 @@ if (! function_exists('g7_plugin_settings')) {
* @example
* // 다음 우편번호 플러그인의 표시 모드 조회
* $displayMode = g7_plugin_settings('sirsoft-daum_postcode', 'display_mode');
*
* @example
* // 플러그인 전체 설정 조회
* $allSettings = g7_plugin_settings('sirsoft-daum_postcode');
@@ -11,6 +11,7 @@ use App\Http\Resources\AttachmentResource;
use App\Models\Attachment;
use App\Services\AttachmentService;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
/**
@@ -21,7 +22,7 @@ class AttachmentController extends AdminBaseController
/**
* AttachmentController 생성자
*
* @param AttachmentService $attachmentService 첨부파일 서비스
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
@@ -32,7 +33,7 @@ class AttachmentController extends AdminBaseController
/**
* 단일 파일 업로드
*
* @param UploadAttachmentRequest $request 업로드 요청
* @param UploadAttachmentRequest $request 업로드 요청
* @return JsonResponse
*/
public function upload(UploadAttachmentRequest $request): JsonResponse
@@ -64,7 +65,7 @@ class AttachmentController extends AdminBaseController
/**
* 여러 파일 일괄 업로드
*
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
* @return JsonResponse
*/
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
@@ -117,7 +118,7 @@ class AttachmentController extends AdminBaseController
/**
* 순서 변경
*
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @return JsonResponse
*/
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
@@ -126,9 +127,12 @@ class AttachmentController extends AdminBaseController
$this->attachmentService->reorder($request->input('order'));
return $this->success('attachment.reorder_success');
} catch (AuthorizationException $e) {
// 스코프 밖 첨부가 포함된 경우. 아래 제네릭 catch 보다 앞에 둬야 한다 —
// 뒤에 두면 인가 거부가 500 으로 뭉개져 상세 경로(403)와 응답이 갈린다.
return $this->error('auth.scope_denied', 403, $e->getMessage());
} catch (Exception $e) {
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
}
}
}
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 모델 직접 조회를 Service 경유로 바꾼 내부 리팩토링 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin\Identity;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -8,6 +10,7 @@ use App\Http\Requests\Admin\Identity\UpdateIdentityMessageTemplateRequest;
use App\Http\Resources\Admin\Identity\IdentityMessageTemplateResource;
use App\Models\IdentityMessageTemplate;
use App\Services\IdentityMessageTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -29,7 +32,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
*
* @param UpdateIdentityMessageTemplateRequest $request
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function update(UpdateIdentityMessageTemplateRequest $request, IdentityMessageTemplate $template)
{
@@ -54,7 +57,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 활성/비활성 토글.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function toggleActive(IdentityMessageTemplate $template)
{
@@ -79,13 +82,13 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 변수 치환 미리보기.
*
* @param PreviewIdentityMessageTemplateRequest $request
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function preview(PreviewIdentityMessageTemplateRequest $request)
{
try {
$payload = $request->validated();
$template = IdentityMessageTemplate::findOrFail($payload['template_id']);
$template = $this->templateService->findOrFailById((int) $payload['template_id']);
$rendered = $this->templateService->getPreview(
$template,
$payload['data'] ?? [],
@@ -104,7 +107,7 @@ class AdminIdentityMessageTemplateController extends AdminBaseController
* 템플릿을 시더 기본값으로 복원.
*
* @param IdentityMessageTemplate $template
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function reset(IdentityMessageTemplate $template)
{
@@ -3,15 +3,16 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\LanguagePackSlotConflictException;
use App\Extension\Helpers\ChangelogParser;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
use App\Http\Requests\LanguagePack\BulkActivateRequest;
use App\Http\Requests\LanguagePack\IndexLanguagePackRequest;
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
use App\Http\Requests\LanguagePack\InstallFromFileRequest;
use App\Http\Requests\LanguagePack\InstallFromGithubRequest;
use App\Http\Requests\LanguagePack\InstallFromUrlRequest;
use App\Http\Requests\LanguagePack\ManifestPreviewRequest;
use App\Http\Requests\LanguagePack\UninstallLanguagePackRequest;
use App\Extension\Helpers\ChangelogParser;
use App\Http\Resources\LanguagePackCollection;
use App\Http\Resources\LanguagePackResource;
use App\Services\LanguagePackService;
@@ -66,7 +67,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.fetch_success', $payload);
} catch (\Throwable $e) {
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.fetch_failed', 500, $e);
}
}
@@ -117,7 +118,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -145,7 +146,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -173,7 +174,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -202,7 +203,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -237,17 +238,17 @@ class LanguagePackController extends AdminBaseController
'target' => (new LanguagePackResource($e->target))->toArray($request),
]);
} catch (\Throwable $e) {
return $this->error('language_packs.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.activate_failed', 500, $e);
}
}
/**
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
* @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
* @param BulkActivateRequest $request 요청
* @return JsonResponse 결과 (succeeded/failed 분리)
*/
public function bulkActivate(\App\Http\Requests\LanguagePack\BulkActivateRequest $request): JsonResponse
public function bulkActivate(BulkActivateRequest $request): JsonResponse
{
$ids = $request->validated('ids');
$result = $this->service->bulkActivate($ids);
@@ -278,7 +279,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($pack))->toArray($request)
);
} catch (\Throwable $e) {
return $this->error('language_packs.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.deactivate_failed', 500, $e);
}
}
@@ -302,7 +303,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.uninstall_success');
} catch (\Throwable $e) {
return $this->error('language_packs.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.uninstall_failed', 500, $e);
}
}
@@ -318,7 +319,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.check_updates_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.check_updates_failed', 500, $e);
}
}
@@ -345,7 +346,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($updated))->toArray($request)
);
} catch (\Throwable $e) {
return $this->error('language_packs.update_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.update_failed', 500, $e);
}
}
@@ -361,7 +362,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.refresh_cache_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.refresh_cache_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
// 예외 원문을 응답에 싣지 않는다 — 디버그 노출은 ResponseHelper 가
// app.debug 에서만 Throwable 을 펼치는 기존 메커니즘에 위임한다.
return $this->error('language_packs.refresh_cache_failed', 500, $e);
}
}
@@ -378,7 +381,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.preview_success', $result);
} catch (\Throwable $e) {
return $this->error('language_packs.preview_failed', 422, $e->getMessage(), ['error' => $e->getMessage()]);
// 업로드 ZIP 검증 실패는 의도적 422 로 존치하되, 예외 원문은 싣지 않는다
// (다른 manifestPreview 컨트롤러 3종과 동형).
return $this->error('language_packs.preview_failed', 422, $e);
}
}
@@ -410,7 +415,7 @@ class LanguagePackController extends AdminBaseController
'has_changelog' => $entries !== [] || $rawContent !== '',
]);
} catch (\Throwable $e) {
return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
return $this->error('language_packs.fetch_failed', 500, $e);
}
}
}
@@ -12,6 +12,7 @@ use App\Http\Resources\MenuCollection;
use App\Http\Resources\MenuResource;
use App\Models\Menu;
use App\Services\MenuService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
@@ -222,6 +223,10 @@ class MenuController extends AdminBaseController
}
} catch (ValidationException $e) {
return $this->error('menu.order_update_failed', 422, $e->errors());
} catch (AuthorizationException $e) {
// 스코프 밖 메뉴가 포함된 경우. 제네릭 catch 보다 앞에 둬야 인가 거부가 500 으로
// 뭉개지지 않고 상세 경로(403)와 같은 응답이 된다.
return $this->error('auth.scope_denied', 403, $e->getMessage());
} catch (\Exception $e) {
return $this->error('menu.update_error', 500, $e->getMessage());
}
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -27,6 +29,9 @@ class NotificationController extends AdminBaseController
/**
* 알림 목록을 조회합니다.
*
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
* @return JsonResponse 알림 목록 응답
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
@@ -51,7 +56,11 @@ class NotificationController extends AdminBaseController
/**
* 미읽음 알림 수를 반환합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 미읽음 알림 수 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function unreadCount(Request $request): JsonResponse
{
try {
@@ -70,7 +79,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
@@ -93,6 +107,9 @@ class NotificationController extends AdminBaseController
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
* @return JsonResponse 일괄 읽음 처리 결과 응답
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
@@ -114,7 +131,11 @@ class NotificationController extends AdminBaseController
/**
* 모든 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAllAsRead(Request $request): JsonResponse
{
try {
@@ -133,7 +154,11 @@ class NotificationController extends AdminBaseController
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroyAll(Request $request): JsonResponse
{
try {
@@ -152,7 +177,12 @@ class NotificationController extends AdminBaseController
/**
* 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroy(Request $request, string $id): JsonResponse
{
try {
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Extension\PluginManager;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
use App\Services\DriverRegistryService;
use App\Services\PluginSettingsService;
use App\Support\SensitiveSettingMask;
use Illuminate\Http\JsonResponse;
@@ -49,6 +50,34 @@ class PluginSettingsController extends AdminBaseController
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
}
/**
* 설정 스키마가 public_asset_disk 키를 선언한 플러그인의 응답에
* 공개 자산 디스크 카탈로그(available_public_asset_disks)를 부착합니다.
*
* 화면이 /api/admin/settings(core.settings.read)를 따로 조회하게 두면 이
* 화면의 권한(core.plugins.read)과 표면이 갈려, 커스텀 역할에서 카탈로그만
* 조용히 비는 비대칭이 생깁니다. 설정 응답 단일 표면에서 함께 내려 권한 축을
* 일치시킵니다 (ecommerce 모듈 설정 응답의 동명 키와 동형 계약).
* 저장 시에는 스키마 기반 검증 whitelist 가 이 키를 걸러 설정에 남지 않습니다.
*
* @param string $identifier 플러그인 식별자
* @param array<string, mixed> $settings 응답에 실을 설정
* @return array<string, mixed> 카탈로그가 부착된 설정
*/
private function withPublicAssetCatalog(string $identifier, array $settings): array
{
$plugin = $this->pluginManager->getPlugin($identifier);
if ($plugin === null || ! array_key_exists('public_asset_disk', $plugin->getSettingsSchema())) {
return $settings;
}
$settings['available_public_asset_disks'] = app(DriverRegistryService::class)
->getAvailableDrivers('public_asset');
return $settings;
}
/**
* 플러그인 설정을 조회합니다.
*
@@ -63,7 +92,10 @@ class PluginSettingsController extends AdminBaseController
return $this->notFound('plugins.not_found');
}
return $this->success('common.success', $this->maskSensitive($identifier, $settings));
return $this->success(
'common.success',
$this->withPublicAssetCatalog($identifier, $this->maskSensitive($identifier, $settings))
);
}
/**
@@ -87,9 +119,14 @@ class PluginSettingsController extends AdminBaseController
return $this->error('plugins.settings.update_failed', 500);
}
// 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로
// 누락 시 저장 직후 선택지가 비어 보인다 (ecommerce 저장 응답과 동형)
return $this->success(
'plugins.settings.updated',
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
$this->withPublicAssetCatalog(
$identifier,
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
)
);
}
@@ -2,20 +2,23 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CannotModifyProtectedRoleException;
use App\Exceptions\ExtensionOwnedRoleDeleteException;
use App\Exceptions\PermissionEscalationException;
use App\Exceptions\SystemRoleDeleteException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Role\ActiveRolesRequest;
use App\Http\Requests\Role\RoleListRequest;
use App\Http\Requests\Role\StoreRoleRequest;
use App\Http\Requests\Role\UpdateRoleRequest;
use App\Http\Resources\RoleCollection;
use App\Http\Resources\RoleResource;
use App\Models\Role;
use App\Models\User;
use App\Services\RoleService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
@@ -60,13 +63,13 @@ class RoleController extends AdminBaseController
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
*
* @param Request $request HTTP 요청 객체
* @param ActiveRolesRequest $request 활성 역할 조회 요청
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
*/
public function active(Request $request): JsonResponse
public function active(ActiveRolesRequest $request): JsonResponse
{
try {
/** @var \App\Models\User $user */
/** @var User $user */
$user = $request->user();
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
@@ -78,7 +81,10 @@ class RoleController extends AdminBaseController
return $this->success('role.fetch_success', [
'data' => RoleResource::collection($roles),
'abilities' => [
'can_assign_roles' => PermissionHelper::check('core.permissions.update'),
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
// (core.permissions.update)이 아니다. 부여 가능한 개별 역할의 범위는 서버
// 상한(PermissionEscalationGuard)이 역할별로 강제한다.
'can_assign_roles' => PermissionHelper::check('core.users.update'),
],
]);
} catch (Exception $e) {
@@ -122,6 +128,8 @@ class RoleController extends AdminBaseController
new RoleResource($role),
201
);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('role.create_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -145,6 +153,10 @@ class RoleController extends AdminBaseController
'role.update_success',
new RoleResource($updatedRole)
);
} catch (CannotModifyProtectedRoleException $e) {
return $this->error('exceptions.cannot_modify_protected_role', 403);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('role.update_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -174,6 +186,8 @@ class RoleController extends AdminBaseController
} else {
return $this->error('role.update_failed');
}
} catch (CannotModifyProtectedRoleException $e) {
return $this->error('exceptions.cannot_modify_protected_role', 403);
} catch (Exception $e) {
return $this->error('role.update_failed', 500, $e->getMessage());
}
@@ -188,23 +188,21 @@ class SettingsController extends AdminBaseController
}
/**
* 데이터베이스를 백업합니다.
* 데이터베이스 백업 — 아직 제공하지 않는 기능임을 알립니다.
*
* @return JsonResponse 백업 결과 JSON 응답
* 코어에는 DB 덤프 수단이 없어 이 엔드포인트는 구현된 적이 없다.
* 종전에는 존재하지 않는 `SettingsService::backupDatabase()` 를 호출했고,
* PHP 가 던지는 `Error` 는 `catch (\Exception)` 에 걸리지 않아 그대로 500 이 되면서
* 내부 메서드 이름까지 응답에 실려 나갔다 (공개 #115 부록 B3).
*
* 기능 부재는 서버 고장이 아니므로 501(Not Implemented)로 답한다.
* 설정 파일 백업은 `POST /api/admin/settings/backup` 이 담당한다.
*
* @return JsonResponse 미제공 안내 JSON 응답 (501)
*/
public function backupDatabase(): JsonResponse
{
try {
$result = $this->settingsService->backupDatabase();
if ($result) {
return $this->success('settings.backup_success');
} else {
return $this->error('settings.backup_failed');
}
} catch (\Exception $e) {
return $this->error('settings.backup_error', 500, $e->getMessage());
}
return $this->error('settings.database_backup_unavailable', 501);
}
/**
@@ -1,8 +1,12 @@
<?php
// audit:allow api-doc-coverage reason: 응답 계약 불변 — lang 키에서 :error 플레이스홀더가 제거되어 사문화된 messageParams 인자만 정리 (키·상태코드·payload 형태 무변경)
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Exceptions\CannotModifySuperAdminException;
use App\Exceptions\PermissionEscalationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
use App\Http\Requests\User\CheckEmailRequest;
@@ -74,6 +78,8 @@ class UserController extends AdminBaseController
new UserResource($user),
201
);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('user.create_failed', 422, $e->errors());
} catch (Exception $e) {
@@ -122,10 +128,14 @@ class UserController extends AdminBaseController
'user.update_success',
new UserResource($updatedUser)
);
} catch (CannotModifySuperAdminException $e) {
return $this->error('exceptions.cannot_modify_super_admin', 403);
} catch (PermissionEscalationException $e) {
return $this->error('exceptions.cannot_grant_unheld_permission', 403);
} catch (ValidationException $e) {
return $this->error('user.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
return $this->error('user.update_failed', 500, $e);
}
}
@@ -148,8 +158,10 @@ class UserController extends AdminBaseController
'auth.account_unlocked',
new UserResource($unlocked)
);
} catch (CannotModifySuperAdminException $e) {
return $this->error('exceptions.cannot_modify_super_admin', 403);
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
return $this->error('user.update_failed', 500, $e);
}
}
@@ -317,9 +329,13 @@ class UserController extends AdminBaseController
$validated = $request->validated();
$result = $this->userService->bulkUpdateStatus($validated['ids'], $validated['status']);
// 메시지 키(user.bulk_status_updated)가 :count 플레이스홀더를 가지므로
// 치환 파라미터를 함께 전달한다 (생략 시 원문 ":count명의 …" 이 그대로 노출)
return $this->success(
'user.bulk_status_updated',
$result
$result,
200,
['count' => $result['updated_count'] ?? 0]
);
} catch (ValidationException $e) {
return $this->error('user.bulk_update_status_failed', 422, $e->errors());
@@ -1,5 +1,7 @@
<?php
// audit:allow api-doc-coverage reason: 본문 미사용 base Request 면제 주석·PHPDoc 만 추가 — 요청/응답 계약 불변
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Base\AuthBaseController;
@@ -27,6 +29,9 @@ class NotificationController extends AuthBaseController
/**
* 알림 목록을 조회합니다.
*
* @param NotificationIndexRequest $request 알림 목록 조회 요청 (page/per_page)
* @return JsonResponse 알림 목록 응답
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
@@ -51,7 +56,11 @@ class NotificationController extends AuthBaseController
/**
* 미읽음 알림 수를 반환합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 미읽음 알림 수 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function unreadCount(Request $request): JsonResponse
{
try {
@@ -70,7 +79,12 @@ class NotificationController extends AuthBaseController
/**
* 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
@@ -93,6 +107,9 @@ class NotificationController extends AuthBaseController
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*
* @param NotificationBatchReadRequest $request 일괄 읽음 처리 요청
* @return JsonResponse 일괄 읽음 처리 결과 응답
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
@@ -114,7 +131,11 @@ class NotificationController extends AuthBaseController
/**
* 모든 알림을 읽음 처리합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 읽음 처리 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function markAllAsRead(Request $request): JsonResponse
{
try {
@@ -133,7 +154,11 @@ class NotificationController extends AuthBaseController
/**
* 사용자의 모든 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @return JsonResponse 전체 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroyAll(Request $request): JsonResponse
{
try {
@@ -152,7 +177,12 @@ class NotificationController extends AuthBaseController
/**
* 알림을 삭제합니다.
*
* @param Request $request 인증 사용자 요청
* @param string $id 알림 ID
* @return JsonResponse 삭제 결과 응답
*/
// audit:allow controller-base-request-injection reason: 본문 입력을 읽지 않음 — user() 만 참조 (검증 대상 필드 없음)
public function destroy(Request $request, string $id): JsonResponse
{
try {
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Auth;
use App\Enums\AttachmentSourceType;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\VerifyPasswordRequest;
use App\Http\Requests\User\ChangePasswordRequest;
@@ -15,6 +16,7 @@ use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
@@ -302,14 +304,28 @@ class ProfileController extends AuthBaseController
return $this->unauthorized('auth.unauthenticated');
}
$this->logUserActivity('profile.withdraw');
// 사용자 탈퇴 처리 (아바타, 토큰 삭제 및 익명화)
$this->userService->withdrawUser($user);
// 활동 로그는 성공한 뒤에만 남긴다 — 실패한 탈퇴가 기록으로 남으면
// 이력만 보고는 탈퇴한 것으로 읽힌다.
$this->logUserActivity('profile.withdraw');
return $this->success('user.withdraw_success');
} catch (ValidationException $e) {
// 관리자/수퍼관리자 탈퇴 차단은 요청이 잘못된 것이지 서버 오류가 아니다.
return $this->validationError($e->errors(), 'user.withdraw_failed');
} catch (CannotDeleteSuperAdminException $e) {
// 이 예외는 ValidationException 을 상속하지 않으므로 별도 매핑이 필요하다.
return $this->validationError(
['general' => [$e->getMessage()]],
'user.withdraw_failed'
);
} catch (\Exception $e) {
return $this->error('user.withdraw_failed', 500, null, ['error' => $e->getMessage()]);
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
Log::error('User withdraw failed (profile)', ['exception' => $e]);
return $this->error('user.withdraw_failed', 500);
}
}
}
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* API 컨트롤러의 최상위 베이스 클래스
@@ -174,6 +175,40 @@ abstract class BaseApiController extends Controller
return $response;
}
/**
* 스토리지 스트림 응답에 ETag/캐싱 헤더를 부착합니다 (fileResponse 의 디스크 인지 대응).
*
* fileResponse() 는 로컬 절대 경로 전제(filemtime/filesize)라 S3 등 원격 디스크
* 행에는 쓸 수 없습니다. 원격/로컬 공통 서빙은 StorageInterface::response() 가
* 만든 스트림에 본 메서드로 동일한 캐싱 계약(ETag/304/Cache-Control/Expires)을
* 입힙니다. ETag 는 파일 stat 대신 호출자가 준 결정적 소스(행 메타)로 만듭니다.
*
* @param StreamedResponse $response 스토리지 인라인 스트림 응답
* @param string $etagSource ETag 소스 문자열 (디스크·경로·수정시각·크기 등 행 메타)
* @param int $maxAge 캐시 유지 시간 (초)
* @return StreamedResponse|Response 스트림 응답 또는 304
*/
protected function streamedFileResponse(StreamedResponse $response, string $etagSource, int $maxAge): StreamedResponse|Response
{
$etag = md5($etagSource);
// If-None-Match 헤더 확인 (ETag 비교)
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
// 환경별 캐싱 정책 (fileResponse 와 동일)
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}, immutable"
: 'no-cache';
$response->headers->set('ETag', $etag);
$response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT');
$response->headers->set('Cache-Control', $cacheControl);
return $response;
}
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
*
@@ -366,7 +366,9 @@ class IdentityVerificationController extends PublicBaseController
'scope' => $policy->scope,
'target' => $policy->target,
'purpose' => $policy->purpose,
'provider_id' => $policy->provider_id,
// 저장값을 그대로 내보내지 않는다 — 제거된 플러그인의 provider ID 가 공개 응답에
// 남지 않도록, 428 강제 경로와 같은 게터로 레지스트리 대조·폴백을 거친다 (A6a).
'provider_id' => $this->policyService->resolveProviderId($policy),
'grace_minutes' => $policy->grace_minutes,
'applies_to' => $policy->applies_to,
'fail_mode' => $policy->fail_mode,
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Attachment\DownloadAttachmentRequest;
use App\Services\AttachmentService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -22,7 +22,7 @@ class PublicAttachmentController extends PublicBaseController
/**
* PublicAttachmentController 생성자
*
* @param AttachmentService $attachmentService 첨부파일 서비스
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
@@ -36,11 +36,11 @@ class PublicAttachmentController extends PublicBaseController
* 이미지 파일은 캐싱 헤더와 함께 인라인 표시하고,
* 그 외 파일은 다운로드 방식으로 제공합니다.
*
* @param Request $request HTTP 요청
* @param string $hash 첨부파일 해시 (12자)
* @param DownloadAttachmentRequest $request 다운로드 요청
* @param string $hash 첨부파일 해시 (12자)
* @return BinaryFileResponse|StreamedResponse|Response|JsonResponse 파일 응답 또는 에러 응답
*/
public function download(Request $request, string $hash): BinaryFileResponse|StreamedResponse|Response|JsonResponse
public function download(DownloadAttachmentRequest $request, string $hash): BinaryFileResponse|StreamedResponse|Response|JsonResponse
{
$user = $request->user();
@@ -48,10 +48,10 @@ class PublicAttachmentController extends PublicBaseController
// 파일 정보 조회 (권한 체크 포함)
$fileInfo = $this->attachmentService->getFileInfo($hash, $user);
if (!$fileInfo) {
if (! $fileInfo) {
$attachment = $this->attachmentService->findByHash($hash);
if (!$attachment) {
if (! $attachment) {
return $this->notFound('attachment.not_found');
}
@@ -59,10 +59,11 @@ class PublicAttachmentController extends PublicBaseController
}
// 이미지 파일은 캐싱 헤더와 함께 응답 (환경설정 레이아웃 캐시 TTL 사용, 기본 24시간)
// 행 disk 를 따르는 스토리지 스트림 — 로컬 경로 전제 fileResponse 는 S3 행에서 성립하지 않는다 (#99)
if (str_starts_with($fileInfo['mime_type'], 'image/')) {
return $this->fileResponse(
$fileInfo['path'],
$fileInfo['mime_type'],
return $this->streamedFileResponse(
$fileInfo['response'],
$fileInfo['etag_source'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
@@ -70,7 +71,7 @@ class PublicAttachmentController extends PublicBaseController
// 이미지가 아닌 파일은 기존 다운로드 방식 유지
$response = $this->attachmentService->download($hash, $user);
if (!$response) {
if (! $response) {
return $this->forbidden('attachment.access_denied');
}
@@ -3,10 +3,12 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\LayoutService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
@@ -17,6 +19,8 @@ use Illuminate\Http\Response;
*/
class PublicLayoutController extends PublicBaseController
{
use ClearsTemplateCaches;
/**
* 레이아웃 캐시 TTL (초)
*/
@@ -69,7 +73,10 @@ class PublicLayoutController extends PublicBaseController
// 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만
// stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키
// 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다.
$cacheVersion = (int) request()->query('v', 0);
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588).
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 편집기 출처 메타 옵션
// - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환
@@ -119,7 +126,7 @@ class PublicLayoutController extends PublicBaseController
$mergedLayout,
self::CACHE_TTL
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
// 레이아웃 또는 부모 레이아웃을 찾을 수 없음 - 예외 메시지 전달
return $this->notFound($e->getMessage());
}
@@ -130,6 +130,12 @@ class PublicSearchController extends PublicBaseController
// 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다.
$response['counts_are_exact'] = $this->resolveCategoryAccuracy($results);
// 카테고리 검색 실패도 같은 이유로 코어가 일괄 조립한다 — 화면은 이 키로
// "검색 결과 없음" 과 "검색 중 오류" 를 구분해 그린다. 모듈별 복사에 맡기면
// 빠지는 카테고리가 생겨 그 카테고리의 실패만 0건으로 위장된다.
$response['categories_failed'] = $this->resolveCategoryFailures($results);
$response['search_failed'] = in_array(true, $response['categories_failed'], true);
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
$type = $context['type'] ?? 'all';
if ($type !== 'all') {
@@ -180,6 +186,27 @@ class PublicSearchController extends PublicBaseController
return $accuracy;
}
/**
* 카테고리별 검색 실패 여부를 모읍니다.
*
* 실패 카테고리 페이로드(`SearchCategoryPayload::failed()`)의 `failed` 플래그를
* 카테고리 전수에 대해 수집합니다. 키가 없는 카테고리는 실패하지 않은 것으로
* 봅니다 — 구버전 모듈이 플래그를 싣지 않아도 종전 렌더가 유지되어야 합니다.
*
* @param array $results Hook에서 반환된 검색 결과
* @return array<string, bool> 카테고리 => 실패 여부
*/
private function resolveCategoryFailures(array $results): array
{
$failures = [];
foreach ($results as $category => $categoryData) {
$failures[$category] = ($categoryData['failed'] ?? false) === true;
}
return $failures;
}
/**
* 응답 전체의 총 건수 정확도를 정합니다.
*
@@ -15,12 +15,15 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 공개 템플릿 API 컨트롤러
*/
class PublicTemplateController extends PublicBaseController
{
use ClearsTemplateCaches;
public function __construct(
private TemplateService $templateService,
private TemplateLayoutAttachmentService $layoutAttachmentService,
@@ -39,8 +42,11 @@ class PublicTemplateController extends PublicBaseController
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
// `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도
// 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588). `(int)` 캐스트로 키 위생 겸용.
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다.
// 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지
@@ -225,7 +231,7 @@ class PublicTemplateController extends PublicBaseController
// 캐시 버전을 응답에 포함하여 프론트엔드가 API 호출 시 사용하도록 함
$responseData = $configData['data'];
$responseData['cache_version'] = ClearsTemplateCaches::getExtensionCacheVersion();
$responseData['cache_version'] = self::getExtensionCacheVersion();
return $this->success(
__('templates.messages.config_retrieved'),
@@ -248,8 +254,10 @@ class PublicTemplateController extends PublicBaseController
'locale' => $locale,
]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화.
// `?v` 생략 시 현재 버전으로 폴백 (getRoutes 와 동일 — `.v0` 사각 키 방지)
$rawVersion = request()->query('v');
$cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion();
// 캐싱된 응답 반환 (1시간 유효)
$languageData = $this->cached(
@@ -340,21 +348,22 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 라우트 모델 바인딩된 첨부
* @return BinaryFileResponse|Response|JsonResponse 파일 응답 또는 404
* @return StreamedResponse|Response|JsonResponse 파일 응답 또는 404
*/
public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): BinaryFileResponse|Response|JsonResponse
public function serveFile(string $identifier, TemplateLayoutAttachment $attachment): StreamedResponse|Response|JsonResponse
{
$filePath = $this->layoutAttachmentService->getServableFilePath($identifier, $attachment);
$serveInfo = $this->layoutAttachmentService->getServableResponse($identifier, $attachment);
if ($filePath === null) {
if ($serveInfo === null) {
return $this->notFound('templates.layout_attachments.errors.not_found');
}
// 이미지/일반 파일 모두 캐싱 헤더와 함께 인라인 응답 (레이아웃 캐시 TTL, 기본 24시간).
// PublicAttachmentController 의 이미지 서빙과 동일한 fileResponse(ETag/Cache-Control) 사용.
return $this->fileResponse(
$filePath,
$attachment->mime_type,
// PublicAttachmentController 의 이미지 서빙과 동일한 streamedFileResponse(ETag/Cache-Control) 사용
// — 행 disk 를 따르는 스토리지 스트림이라 S3 등 원격 디스크 행에서도 성립한다 (#99).
return $this->streamedFileResponse(
$serveInfo['response'],
$serveInfo['etag_source'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
+11 -4
View File
@@ -2,11 +2,11 @@
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
@@ -75,9 +75,9 @@ class SetLocale
* 따라서 Bearer 토큰을 직접 파싱하여 사용자를 가져옵니다.
*
* @param Request $request HTTP 요청
* @return \App\Models\User|null 사용자 또는 null
* @return User|null 사용자 또는 null
*/
private function resolveUser(Request $request): ?\App\Models\User
private function resolveUser(Request $request): ?User
{
// 이미 인증된 경우 (세션 기반 인증)
if (Auth::check()) {
@@ -88,7 +88,14 @@ class SetLocale
$bearerToken = $request->bearerToken();
if ($bearerToken) {
$token = PersonalAccessToken::findToken($bearerToken);
if ($token && $token->tokenable instanceof \App\Models\User) {
// 만료된 토큰은 인증되지 않은 것으로 취급 (guest 로케일 폴백).
// OptionalSanctumMiddleware 와 동일한 만료 검사를 적용한다.
if ($token && $token->expires_at && $token->expires_at->isPast()) {
return null;
}
if ($token && $token->tokenable instanceof User) {
return $token->tokenable;
}
}
@@ -2,9 +2,13 @@
namespace App\Http\Requests\Admin;
// audit:allow api-doc-coverage 요청 파라미터·응답 구조 무변경 — 검증 안내의 항목 표시명 로케일 정합만 수정 (attributes 우선순위)
use App\Extension\HookManager;
use App\Extension\PluginManager;
use App\Services\DriverRegistryService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Lang;
/**
* 플러그인 설정 업데이트 요청 검증
@@ -106,6 +110,24 @@ class UpdatePluginSettingsRequest extends FormRequest
$rules[$field] = $fieldRules;
}
// 공개 자산 디스크는 스키마 타입만으로 검증할 수 없다 — 선택지가 코어 3종 +
// 플러그인이 훅으로 등록한 디스크라 런타임에만 확정되기 때문이다.
// PluginSettingsController 가 카탈로그를 부착하는 게이트(스키마에 이 키를 선언한
// 플러그인)와 동일한 조건에서 검증도 걸어, "선택지를 내려준 표면" 과 "값을 받는
// 표면" 의 강도를 맞춘다. 이렇게 두면 이 키를 선언하는 플러그인은 각자 훅을
// 구독하지 않아도 코어 환경설정과 같은 422 를 얻는다.
if (array_key_exists('public_asset_disk', $schema)) {
$rules['public_asset_disk'][] = function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === null || $value === '') {
return;
}
if (! app(DriverRegistryService::class)->isDriverAvailable('public_asset', $value)) {
$fail(__('validation.settings.public_asset_disk_invalid'));
}
};
}
// 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는
// 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자
// 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다.
@@ -143,4 +165,51 @@ class UpdatePluginSettingsRequest extends FormRequest
'min' => __('validation.min'),
];
}
/**
* 검증 오류에 쓸 필드 표시명을 반환합니다.
*
* 설정 스키마의 `label` 을 그대로 씁니다. 이 값이 없으면 Laravel 이 원시 키를 풀어쓴
* 이름("unused image retention days")을 그대로 노출하는데, 같은 화면의 입력 라벨은
* 한국어인 상태라 운영자에게는 어느 항목을 고치라는 말인지 닿지 않습니다.
*
* @return array<string, string> 필드 => 표시명
*/
public function attributes(): array
{
$plugin = app(PluginManager::class)->getPlugin($this->route('identifier'));
if (! $plugin) {
return [];
}
$locale = app()->getLocale();
$fallback = config('app.fallback_locale', 'en');
$attributes = [];
foreach ($plugin->getSettingsSchema() as $field => $config) {
// 플러그인이 요청 시점에 번역선(validation.attributes.{field})을 현재 로케일로
// 등록했다면 그것이 우선이다. 스키마 label 은 보통 ko/en 만 들고 있어, 여기서
// 폴백 라벨을 세우면 언어팩/리스너가 제공한 로케일별 라벨(ja 등)을 customAttributes
// 우선순위로 덮는다 — 검증 안내의 항목 이름만 다른 언어로 남는 회귀가 된다.
$translationKey = "validation.attributes.{$field}";
if (Lang::has($translationKey, $locale, false)) {
$attributes[$field] = Lang::get($translationKey, [], $locale);
continue;
}
$label = $config['label'] ?? null;
if (is_array($label)) {
$label = $label[$locale] ?? $label[$fallback] ?? reset($label);
}
if (is_string($label) && $label !== '') {
$attributes[$field] = $label;
}
}
return $attributes;
}
}
@@ -7,8 +7,10 @@ use App\Models\Template;
use App\Models\TemplateLayout;
use App\Rules\ComponentExists;
use App\Rules\NoExternalUrls;
use App\Rules\SafeLayoutExpressions;
use App\Rules\ValidLayoutStructure;
use App\Rules\WhitelistedEndpoint;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -24,6 +26,8 @@ class StoreLayoutRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -33,7 +37,7 @@ class StoreLayoutRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -67,6 +71,8 @@ class StoreLayoutRequest extends FormRequest
new WhitelistedEndpoint,
// 4. 외부 URL 차단
new NoExternalUrls,
// 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
new SafeLayoutExpressions,
],
];
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Layout;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Rules\NoExternalUrls;
use App\Rules\SafeLayoutExpressions;
use App\Rules\ValidDataSourceMerge;
use App\Rules\ValidLayoutStructure;
use App\Rules\ValidParentLayout;
@@ -265,6 +266,14 @@ class UpdateLayoutContentRequest extends FormRequest
'required',
'array',
new ValidLayoutStructure,
// 표현식 샌드박스 우회/원격 스크립트 저장측 차단 (KVE-2026-1915).
// content 트리 전체를 재귀 순회해야 하므로 배열 규칙에 부착한다 — 문자열
// 필드(endpoint)에 부착하면 is_array 가드로 early-return 되어 무력화된다.
new SafeLayoutExpressions,
// props·actions·init_actions 의 외부 URL 차단. 편집기 저장 경로이므로
// Store/UpdateLayoutRequest 와 동일 강도여야 한다 — 여기 누락 시 다른
// 경로에서 막히는 외부 URL 이 편집기 저장으로는 통과한다.
new NoExternalUrls,
],
// 버전 필드
@@ -426,6 +435,8 @@ class UpdateLayoutContentRequest extends FormRequest
'string',
new WhitelistedEndpoint,
new NoExternalUrls,
// SafeLayoutExpressions 는 content 배열 규칙에서 트리 전체를 순회하므로 여기(문자열
// endpoint)에는 부착하지 않는다 — 문자열에 부착 시 is_array 가드로 no-op 이 된다.
];
if (! $isExtending && ! $isBaseLayout) {
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Layout;
use App\Extension\HookManager;
use App\Rules\NoExternalUrls;
use App\Rules\SafeLayoutExpressions;
use App\Rules\ValidDataSourceMerge;
use App\Rules\ValidLayoutExtensionStructure;
use App\Rules\WhitelistedEndpoint;
@@ -62,6 +63,11 @@ class UpdateLayoutExtensionContentRequest extends FormRequest
'required',
'array',
new ValidLayoutExtensionStructure,
// 표현식 샌드박스 우회/원격 스크립트 저장측 차단 (KVE-2026-1915).
// content 트리 전체(data_sources·scripts·표현식 문자열)를 재귀 순회한다.
new SafeLayoutExpressions,
// props·actions·init_actions 의 외부 URL 차단 (Store/UpdateLayoutRequest 와 동일 강도)
new NoExternalUrls,
],
// 우선순위 (선택 — content.priority 와 별개로 직접 지정 가능)
@@ -74,6 +80,8 @@ class UpdateLayoutExtensionContentRequest extends FormRequest
'content.data_sources' => ['nullable', 'array', new ValidDataSourceMerge],
// 데이터소스 endpoint 검증
// SafeLayoutExpressions 는 content 배열 규칙이 트리 전체를 순회하며 data_sources[].endpoint
// same-origin 까지 검사하므로 여기(문자열)에는 부착하지 않는다 (문자열 부착 시 no-op).
'content.data_sources.*.endpoint' => [
'nullable',
'string',
@@ -7,8 +7,10 @@ use App\Models\Template;
use App\Models\TemplateLayout;
use App\Rules\ComponentExists;
use App\Rules\NoExternalUrls;
use App\Rules\SafeLayoutExpressions;
use App\Rules\ValidLayoutStructure;
use App\Rules\WhitelistedEndpoint;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -24,6 +26,8 @@ class UpdateLayoutRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -33,7 +37,7 @@ class UpdateLayoutRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -71,6 +75,8 @@ class UpdateLayoutRequest extends FormRequest
new WhitelistedEndpoint,
// 4. 외부 URL 차단
new NoExternalUrls,
// 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
new SafeLayoutExpressions,
],
];
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Public\Attachment;
use Illuminate\Foundation\Http\FormRequest;
class DownloadAttachmentRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 공개 다운로드 라우트 — 접근 제어는 AttachmentService 의 권한 훅
* (core.attachment.download)이 하이브리드로 수행합니다.
*
* @return bool 항상 true (권한은 서비스 훅 책임)
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* 해시는 라우트 제약(`[a-zA-Z0-9]{12}`)이 이미 형식을 강제하고,
* 그 외 입력 필드가 없는 요청입니다.
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Role;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* 활성 역할 목록(선택 옵션용) 조회 요청
*
* 별도 검증 규칙 없이 인증 컨텍스트만 전달하는 엔드포인트지만, 컨트롤러가 base
* Illuminate\Http\Request 를 직접 주입받지 않도록 전용 FormRequest 서브클래스를 둔다.
* 인증/권한은 permission 미들웨어 체인이 담당하므로 authorize() 는 true 고정.
*/
class ActiveRolesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 항상 true (권한은 미들웨어 체인이 검증)
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Services\DriverRegistryService;
use App\Support\AllowedExtensions;
use App\Support\AssetUrl;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -30,9 +31,9 @@ class SaveSettingsRequest extends FormRequest
private const SUPPORTED_STORAGE_DRIVERS = ['local', 's3'];
/**
* 지원되는 S3 리전 목록
* S3 리전 형식 (AWS 리전 코드 + S3 호환 스토리지 임의값 허용 — R2 의 `auto` 등)
*/
private const SUPPORTED_S3_REGIONS = ['ap-northeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-2', 'eu-west-1'];
private const S3_REGION_FORMAT = 'regex:/^[a-z0-9-]+$/';
/**
* 지원되는 캐시 드라이버 목록
@@ -54,11 +55,6 @@ class SaveSettingsRequest extends FormRequest
*/
private const SUPPORTED_WEBSOCKET_SCHEMES = ['http', 'https'];
/**
* 지원되는 검색엔진 드라이버 기본 목록
*/
private const DEFAULT_SEARCH_ENGINE_DRIVERS = ['mysql-fulltext'];
/**
* 지원되는 로그 드라이버 목록
*/
@@ -230,6 +226,9 @@ class SaveSettingsRequest extends FormRequest
'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)],
'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)],
'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)],
// 고아 첨부 정리 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다.
'upload.orphan_cleanup_enabled' => ['nullable', 'boolean'],
'upload.orphan_retention_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_orphan_retention_days_min', 1), 'max:'.config('core.settings_limits.upload_orphan_retention_days_max', 3650)],
// SEO 설정
'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'],
@@ -313,22 +312,24 @@ class SaveSettingsRequest extends FormRequest
'advanced.pagination_max_page' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_max_page_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_max_page_max', 100000)],
// 드라이버 설정 (drivers 탭)
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS), $this->getDriverUsabilityRule('storage')]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
'drivers.s3_region' => ['nullable', Rule::in(self::SUPPORTED_S3_REGIONS)],
'drivers.s3_region' => ['nullable', 'string', 'max:64', self::S3_REGION_FORMAT],
'drivers.s3_access_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_secret_key' => ['nullable', 'string', 'max:255'],
'drivers.s3_url' => ['nullable', 'url', 'max:500'],
'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]),
'drivers.s3_endpoint' => ['nullable', 'url', 'max:500'],
'drivers.s3_use_path_style' => ['nullable', 'boolean'],
'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS), $this->getDriverUsabilityRule('cache')]),
'drivers.redis_host' => ['nullable', 'string', 'max:255'],
'drivers.redis_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_port_min', 1), 'max:'.config('core.settings_limits.drivers_redis_port_max', 65535)],
'drivers.redis_password' => ['nullable', 'string', 'max:255'],
'drivers.redis_database' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_database_min', 0), 'max:'.config('core.settings_limits.drivers_redis_database_max', 15)],
'drivers.memcached_host' => ['nullable', 'string', 'max:255'],
'drivers.memcached_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_memcached_port_min', 1), 'max:'.config('core.settings_limits.drivers_memcached_port_max', 65535)],
'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]),
'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS), $this->getDriverUsabilityRule('session')]),
'drivers.session_lifetime' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_session_lifetime_min', 1), 'max:'.config('core.settings_limits.drivers_session_lifetime_max', 43200)], // 1분 ~ 30일
'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]),
'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS), $this->getDriverUsabilityRule('queue')]),
'drivers.websocket_enabled' => ['nullable', 'boolean'],
'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
@@ -344,6 +345,9 @@ class SaveSettingsRequest extends FormRequest
'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]),
'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]),
'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)],
// 공개 자산 디스크 — 코어 3종(none/public/s3) + 플러그인 훅 등록 디스크.
// 카탈로그가 동적(플러그인 훅)이라 정적 Rule::in 불가 → 레지스트리 조회 closure
'drivers.public_asset_disk' => $this->getPublicAssetDiskRules(),
// 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨
'identity.default_provider' => ['nullable', 'string', 'max:100'],
@@ -478,6 +482,60 @@ class SaveSettingsRequest extends FormRequest
]);
}
/**
* 드라이버 가용성 검증 rule 을 반환합니다.
*
* 선택된 드라이버가 현재 서버에서 실제로 동작 가능한지(어댑터 클래스·PHP 확장 존재)를
* 저장 시점에 검사합니다. 사용 불능 드라이버가 저장되면 사이트 전면 다운으로 이어질 수
* 있으므로(예: phpredis 확장 없는 서버의 redis) 서버 게이트로 차단합니다.
*
* @param string $category 드라이버 카테고리 (storage, cache, session, queue)
* @return \Closure 검증 클로저
*/
private function getDriverUsabilityRule(string $category): \Closure
{
return function ($attribute, $value, $fail) use ($category) {
if (empty($value)) {
return;
}
$registry = app(DriverRegistryService::class);
if (! $registry->isDriverUsable($category, $value)) {
$fail(__('validation.settings.driver_unusable', [
'driver' => $value,
'reason' => $registry->usabilityFailureReason($category, $value),
]));
}
};
}
/**
* 공개 자산 디스크에 대한 검증 규칙을 반환합니다.
*
* 선택지가 코어 3종 + 플러그인 훅 등록 디스크로 동적이라 정적 Rule::in 을 쓸 수 없고,
* DriverRegistryService 카탈로그 조회로 판정합니다.
*
* @return array 검증 규칙 배열
*/
private function getPublicAssetDiskRules(): array
{
return [
'nullable',
'string',
'max:100',
function ($attribute, $value, $fail) {
if ($value === null || $value === '') {
return;
}
if (! app(DriverRegistryService::class)->isDriverAvailable('public_asset', $value)) {
$fail(__('validation.settings.public_asset_disk_invalid'));
}
},
];
}
/**
* 추가 검증을 위해 validator after 콜백을 등록합니다.
*
@@ -623,6 +681,10 @@ class SaveSettingsRequest extends FormRequest
'upload.image_quality.integer' => __('validation.settings.image_quality_integer'),
'upload.image_quality.min' => __('validation.settings.image_quality_min'),
'upload.image_quality.max' => __('validation.settings.image_quality_max'),
'upload.orphan_cleanup_enabled.boolean' => __('validation.settings.orphan_cleanup_enabled_boolean'),
'upload.orphan_retention_days.integer' => __('validation.settings.orphan_retention_days_integer'),
'upload.orphan_retention_days.min' => __('validation.settings.orphan_retention_days_min'),
'upload.orphan_retention_days.max' => __('validation.settings.orphan_retention_days_max'),
// SEO 설정
'seo.meta_title_suffix.max' => __('validation.settings.meta_title_suffix_max'),
@@ -734,11 +796,15 @@ class SaveSettingsRequest extends FormRequest
'drivers.storage_driver.required' => __('validation.settings.storage_driver_required'),
'drivers.storage_driver.in' => __('validation.settings.storage_driver_invalid'),
'drivers.s3_bucket.max' => __('validation.settings.s3_bucket_max'),
'drivers.s3_region.in' => __('validation.settings.s3_region_invalid'),
'drivers.s3_region.regex' => __('validation.settings.s3_region_invalid'),
'drivers.s3_region.max' => __('validation.settings.s3_region_max'),
'drivers.s3_access_key.max' => __('validation.settings.s3_access_key_max'),
'drivers.s3_secret_key.max' => __('validation.settings.s3_secret_key_max'),
'drivers.s3_url.url' => __('validation.settings.s3_url_invalid'),
'drivers.s3_url.max' => __('validation.settings.s3_url_max'),
'drivers.s3_endpoint.url' => __('validation.settings.s3_endpoint_invalid'),
'drivers.s3_endpoint.max' => __('validation.settings.s3_endpoint_max'),
'drivers.s3_use_path_style.boolean' => __('validation.settings.s3_use_path_style_boolean'),
'drivers.cache_driver.required' => __('validation.settings.cache_driver_required'),
'drivers.cache_driver.in' => __('validation.settings.cache_driver_invalid'),
'drivers.redis_host.max' => __('validation.settings.redis_host_max'),
@@ -786,6 +852,8 @@ class SaveSettingsRequest extends FormRequest
'drivers.log_days.integer' => __('validation.settings.log_days_integer'),
'drivers.log_days.min' => __('validation.settings.log_days_min'),
'drivers.log_days.max' => __('validation.settings.log_days_max'),
'drivers.public_asset_disk.string' => __('validation.settings.public_asset_disk_invalid'),
'drivers.public_asset_disk.max' => __('validation.settings.public_asset_disk_invalid'),
// 본인인증(IDV) 설정
'identity.default_provider.string' => __('validation.settings.identity_default_provider_string'),
@@ -819,25 +887,25 @@ class SaveSettingsRequest extends FormRequest
'general.site_description' => __('validation.attributes.site_description'),
'general.admin_email' => __('validation.attributes.admin_email'),
'general.timezone' => __('validation.attributes.timezone'),
'general.language' => __('validation.attributes.language'),
'general.language' => __('validation.attributes.default_language'),
// 본인인증(IDV) 필드
'identity.default_provider' => __('validation.attributes.identity_default_provider'),
'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'),
'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'),
'identity.max_attempts' => __('validation.attributes.identity_max_attempts'),
// notifications
'notifications.channels' => __('validation.attributes.channels'),
'notifications.channels' => __('validation.attributes.notification_channels'),
// general
'general.currency' => __('validation.attributes.currency'),
'general.currency' => __('validation.attributes.default_currency'),
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
'general.site_logo' => __('validation.attributes.site_logo'),
// mail
'mail.mailer' => __('validation.attributes.mailer'),
'mail.host' => __('validation.attributes.host'),
'mail.port' => __('validation.attributes.port'),
'mail.username' => __('validation.attributes.username'),
'mail.password' => __('validation.attributes.password'),
'mail.host' => __('validation.attributes.smtp_host'),
'mail.port' => __('validation.attributes.smtp_port'),
'mail.username' => __('validation.attributes.smtp_username'),
'mail.password' => __('validation.attributes.smtp_password'),
'mail.encryption' => __('validation.attributes.encryption'),
'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'),
'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'),
@@ -853,6 +921,8 @@ class SaveSettingsRequest extends FormRequest
'upload.image_max_width' => __('validation.attributes.image_max_width'),
'upload.image_max_height' => __('validation.attributes.image_max_height'),
'upload.image_quality' => __('validation.attributes.image_quality'),
'upload.orphan_cleanup_enabled' => __('validation.attributes.orphan_cleanup_enabled'),
'upload.orphan_retention_days' => __('validation.attributes.orphan_retention_days'),
// seo
'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'),
'seo.meta_description' => __('validation.attributes.meta_description'),
@@ -869,7 +939,7 @@ class SaveSettingsRequest extends FormRequest
'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'),
'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'),
'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'),
'seo.cache_ttl' => __('validation.attributes.cache_ttl'),
'seo.cache_ttl' => __('validation.attributes.seo_page_cache_ttl'),
'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'),
'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'),
'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'),
@@ -916,6 +986,8 @@ class SaveSettingsRequest extends FormRequest
'drivers.s3_access_key' => __('validation.attributes.s3_access_key'),
'drivers.s3_secret_key' => __('validation.attributes.s3_secret_key'),
'drivers.s3_url' => __('validation.attributes.s3_url'),
'drivers.s3_endpoint' => __('validation.attributes.s3_endpoint'),
'drivers.s3_use_path_style' => __('validation.attributes.s3_use_path_style'),
'drivers.cache_driver' => __('validation.attributes.cache_driver'),
'drivers.redis_host' => __('validation.attributes.redis_host'),
'drivers.redis_port' => __('validation.attributes.redis_port'),
@@ -941,6 +1013,7 @@ class SaveSettingsRequest extends FormRequest
'drivers.log_driver' => __('validation.attributes.log_driver'),
'drivers.log_level' => __('validation.attributes.log_level'),
'drivers.log_days' => __('validation.attributes.log_days'),
'drivers.public_asset_disk' => __('validation.attributes.public_asset_disk'),
];
}
}
@@ -3,6 +3,8 @@
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Services\DriverRegistryService;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -20,9 +22,9 @@ class TestDriverConnectionRequest extends FormRequest
private const SUPPORTED_STORAGE_DRIVERS = ['local', 's3'];
/**
* 지원되는 S3 리전 목록
* S3 리전 형식 (AWS 리전 코드 + S3 호환 스토리지 임의값 허용 — R2 의 `auto` 등)
*/
private const SUPPORTED_S3_REGIONS = ['ap-northeast-2', 'ap-northeast-1', 'us-east-1', 'us-west-2', 'eu-west-1'];
private const S3_REGION_FORMAT = 'regex:/^[a-z0-9-]+$/';
/**
* 지원되는 캐시 드라이버 목록
@@ -57,24 +59,26 @@ class TestDriverConnectionRequest 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
{
$rules = [
// 드라이버 선택
'storage_driver' => ['nullable', Rule::in(self::SUPPORTED_STORAGE_DRIVERS)],
'cache_driver' => ['nullable', Rule::in(self::SUPPORTED_CACHE_DRIVERS)],
'session_driver' => ['nullable', Rule::in(self::SUPPORTED_SESSION_DRIVERS)],
'queue_driver' => ['nullable', Rule::in(self::SUPPORTED_QUEUE_DRIVERS)],
'storage_driver' => ['nullable', Rule::in(self::SUPPORTED_STORAGE_DRIVERS), $this->getDriverUsabilityRule('storage')],
'cache_driver' => ['nullable', Rule::in(self::SUPPORTED_CACHE_DRIVERS), $this->getDriverUsabilityRule('cache')],
'session_driver' => ['nullable', Rule::in(self::SUPPORTED_SESSION_DRIVERS), $this->getDriverUsabilityRule('session')],
'queue_driver' => ['nullable', Rule::in(self::SUPPORTED_QUEUE_DRIVERS), $this->getDriverUsabilityRule('queue')],
'websocket_enabled' => ['nullable', 'boolean'],
// S3 설정
's3_bucket' => ['nullable', 'string', 'max:255'],
's3_region' => ['nullable', Rule::in(self::SUPPORTED_S3_REGIONS)],
's3_region' => ['nullable', 'string', 'max:64', self::S3_REGION_FORMAT],
's3_access_key' => ['nullable', 'string', 'max:255'],
's3_secret_key' => ['nullable', 'string', 'max:255'],
's3_url' => ['nullable', 'url', 'max:500'],
's3_endpoint' => ['nullable', 'url', 'max:500'],
's3_use_path_style' => ['nullable', 'boolean'],
// Redis 설정
'redis_host' => ['nullable', 'string', 'max:255'],
@@ -86,11 +90,14 @@ class TestDriverConnectionRequest extends FormRequest
'memcached_host' => ['nullable', 'string', 'max:255'],
'memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
// Websocket 설정
// Websocket 설정 — client(브라우저 접속) / server(백엔드 broadcast HTTP API) endpoint 분리
'websocket_app_key' => ['nullable', 'string', 'max:255'],
'websocket_host' => ['nullable', 'string', 'max:255'],
'websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
'websocket_server_host' => ['nullable', 'string', 'max:255'],
'websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -107,11 +114,15 @@ class TestDriverConnectionRequest extends FormRequest
return [
'storage_driver.in' => __('validation.settings.storage_driver_invalid'),
's3_bucket.max' => __('validation.settings.s3_bucket_max'),
's3_region.in' => __('validation.settings.s3_region_invalid'),
's3_region.regex' => __('validation.settings.s3_region_invalid'),
's3_region.max' => __('validation.settings.s3_region_max'),
's3_access_key.max' => __('validation.settings.s3_access_key_max'),
's3_secret_key.max' => __('validation.settings.s3_secret_key_max'),
's3_url.url' => __('validation.settings.s3_url_invalid'),
's3_url.max' => __('validation.settings.s3_url_max'),
's3_endpoint.url' => __('validation.settings.s3_endpoint_invalid'),
's3_endpoint.max' => __('validation.settings.s3_endpoint_max'),
's3_use_path_style.boolean' => __('validation.settings.s3_use_path_style_boolean'),
'cache_driver.in' => __('validation.settings.cache_driver_invalid'),
'redis_host.max' => __('validation.settings.redis_host_max'),
'redis_port.integer' => __('validation.settings.redis_port_integer'),
@@ -134,6 +145,38 @@ class TestDriverConnectionRequest extends FormRequest
'websocket_port.min' => __('validation.settings.websocket_port_min'),
'websocket_port.max' => __('validation.settings.websocket_port_max'),
'websocket_scheme.in' => __('validation.settings.websocket_scheme_invalid'),
'websocket_server_host.max' => __('validation.settings.websocket_server_host_max'),
'websocket_server_port.integer' => __('validation.settings.websocket_server_port_integer'),
'websocket_server_port.min' => __('validation.settings.websocket_server_port_min'),
'websocket_server_port.max' => __('validation.settings.websocket_server_port_max'),
'websocket_server_scheme.in' => __('validation.settings.websocket_server_scheme_invalid'),
];
}
/**
* 드라이버 가용성 검증 rule 을 반환합니다.
*
* 저장 게이트(SaveSettingsRequest)와 동일 판정 — 사용 불능 드라이버는 테스트 요청
* 단계에서도 422 로 차단해 어느 경로로도 통과하지 못하게 합니다.
*
* @param string $category 드라이버 카테고리 (storage, cache, session, queue)
* @return \Closure 검증 클로저
*/
private function getDriverUsabilityRule(string $category): \Closure
{
return function ($attribute, $value, $fail) use ($category) {
if (empty($value)) {
return;
}
$registry = app(DriverRegistryService::class);
if (! $registry->isDriverUsable($category, $value)) {
$fail(__('validation.settings.driver_unusable', [
'driver' => $value,
'reason' => $registry->usabilityFailureReason($category, $value),
]));
}
};
}
}
+22 -3
View File
@@ -3,6 +3,7 @@
namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -25,7 +26,7 @@ class TestMailRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -72,8 +73,8 @@ class TestMailRequest extends FormRequest
'to_email.max' => __('settings.invalid_email'),
'from_address.required' => __('validation.required', ['attribute' => __('validation.attributes.from_address')]),
'from_name.required' => __('validation.required', ['attribute' => __('validation.attributes.from_name')]),
'host.required' => __('validation.required', ['attribute' => __('validation.attributes.host')]),
'port.required' => __('validation.required', ['attribute' => __('validation.attributes.port')]),
'host.required' => __('validation.required', ['attribute' => __('validation.attributes.smtp_host')]),
'port.required' => __('validation.required', ['attribute' => __('validation.attributes.smtp_port')]),
'mailgun_domain.required' => __('validation.required', ['attribute' => __('validation.attributes.mailgun_domain')]),
'mailgun_secret.required' => __('validation.required', ['attribute' => __('validation.attributes.mailgun_secret')]),
'ses_key.required' => __('validation.required', ['attribute' => __('validation.attributes.ses_key')]),
@@ -81,4 +82,22 @@ class TestMailRequest extends FormRequest
'ses_region.required' => __('validation.required', ['attribute' => __('validation.attributes.ses_region')]),
];
}
/**
* 검증 속성명을 반환합니다.
*
* 범용 필드명(host/port/username/password)은 전역 라벨이 범용 문구이므로,
* SMTP 전용 라벨을 이 요청에서만 명시 매핑합니다.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'host' => __('validation.attributes.smtp_host'),
'port' => __('validation.attributes.smtp_port'),
'username' => __('validation.attributes.smtp_username'),
'password' => __('validation.attributes.smtp_password'),
];
}
}
@@ -2,34 +2,198 @@
namespace App\Http\Requests\Settings;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
/**
* 단건 설정 저장 요청 검증
*
* 벌크 저장(`SaveSettingsRequest`)은 키마다 타입 규칙을 갖지만, 이 경로는 키를 URL 로 받아
* 값 하나만 싣는다. 그래서 값의 형태는 `config/settings/defaults.json` 의 기본값에서 도출한다
* — 기본값이 그 설정의 타입 선언이기 때문이다.
*/
class UpdateSettingRequest extends FormRequest
{
/**
* 문자열 값의 최대 길이
*/
private const MAX_STRING_LENGTH = 1000;
/**
* 배열 값의 JSON 직렬화 최대 길이
*/
private const MAX_ARRAY_JSON_LENGTH = 5000;
/**
* Determine if the user is authorized to make this request.
*
* @return bool 권한 체크는 permission 미들웨어 체인이 담당하므로 항상 true 반환
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 전 입력 값을 설정의 선언 타입으로 정규화합니다.
*
* 폼 전송(`application/x-www-form-urlencoded`)은 모든 값을 문자열로 실어 보낸다.
* 정규화 없이 저장하면 boolean 설정에 `"1"`, 정수 설정에 `"3600"` 같은 문자열이 남고,
* 그 값을 읽는 쪽은 타입 비교(`=== true`)에서 조용히 어긋난다.
*
* 해석할 수 없는 값(예: boolean 설정에 `"maybe"`)은 건드리지 않는다 — null 이나 false 로
* 바꿔 두면 오타 입력이 "정상 저장" 으로 통과한다. 그 판정은 규칙이 맡는다.
*/
protected function prepareForValidation(): void
{
if (! $this->has('value')) {
return;
}
$default = $this->declaredDefault();
$value = $this->input('value');
// 문자열 설정을 비우면 빈 문자열로 남긴다. `ConvertEmptyStringsToNull` 미들웨어가
// 이미 `''` 를 null 로 바꿔 두므로, 여기서 되돌리지 않으면 선언 타입이 문자열인
// 설정에 null 이 저장되어 그 값을 읽는 쪽이 기본값(`''`)과 다른 형태를 받는다.
if (is_string($default) && $value === null) {
$this->merge(['value' => '']);
return;
}
// 선언 타입이 문자열/배열이거나 기본값이 없으면 원본 유지
if (! is_bool($default) && ! is_int($default) && ! is_float($default)) {
return;
}
// 비-문자열 설정에서 빈 문자열은 "값을 지운다" 는 뜻이다
if ($value === '') {
$this->merge(['value' => null]);
return;
}
if (! is_string($value)) {
return;
}
if (is_bool($default)) {
$casted = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($casted !== null) {
$this->merge(['value' => $casted]);
}
return;
}
if (is_int($default) && preg_match('/^-?\d+$/', $value) === 1) {
$this->merge(['value' => (int) $value]);
return;
}
if (is_float($default) && is_numeric($value)) {
$this->merge(['value' => (float) $value]);
}
}
/**
* 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
{
$rules = [
'value' => 'required|string|max:1000',
// `required` 가 아니라 `present` 다 — 빈 문자열/null 은 "이 설정을 비운다" 는 정상
// 입력이고, 이를 거부하면 운영자가 한 번 넣은 값을 화면에서 지울 수 없다.
// 대신 `value` 키 자체가 빠진 요청은 그대로 거부한다.
'value' => ['present', 'nullable', $this->valueShapeRule()],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.settings.update_validation_rules', $rules, $this);
}
/**
* 값의 형태(타입·길이)를 검증하는 규칙을 반환합니다.
*
* @return \Closure 검증 클로저
*/
private function valueShapeRule(): \Closure
{
return function (string $attribute, mixed $value, callable $fail): void {
if ($value === null) {
return;
}
$default = $this->declaredDefault();
// 선언 타입과 다른 값은 거부한다 — 정규화가 해석하지 못한 입력이 여기로 온다.
if (is_bool($default) && ! is_bool($value)) {
$fail(__('validation.setting.value.boolean'));
return;
}
if (is_int($default) && ! is_int($value)) {
$fail(__('validation.setting.value.integer'));
return;
}
if (is_float($default) && ! is_int($value) && ! is_float($value)) {
$fail(__('validation.setting.value.numeric'));
return;
}
if (is_string($value)) {
if (mb_strlen($value) > self::MAX_STRING_LENGTH) {
$fail(__('validation.setting.value.max', ['max' => self::MAX_STRING_LENGTH]));
}
return;
}
if (is_array($value)) {
if (mb_strlen((string) json_encode($value)) > self::MAX_ARRAY_JSON_LENGTH) {
$fail(__('validation.setting.value.array_max', ['max' => self::MAX_ARRAY_JSON_LENGTH]));
}
return;
}
if (! is_bool($value) && ! is_int($value) && ! is_float($value)) {
$fail(__('validation.setting.value.type'));
}
};
}
/**
* 저장 대상 키의 선언 기본값을 반환합니다.
*
* 반환값의 타입이 곧 그 설정의 선언 타입입니다. 선언이 없으면 null 을 반환하며,
* 이 경우 타입 강제 없이 형태 검증만 수행합니다 (확장이 추가한 키 등).
*
* @return mixed 선언 기본값 또는 null
*/
private function declaredDefault(): mixed
{
$key = $this->route('key');
if (! is_string($key) || $key === '') {
return null;
}
return Arr::get(app(ConfigRepositoryInterface::class)->getDefaults(), $key);
}
/**
* Get custom messages for validator errors.
*
@@ -38,9 +202,7 @@ class UpdateSettingRequest extends FormRequest
public function messages(): array
{
return [
'value.required' => __('validation.setting.value.required'),
'value.string' => __('validation.setting.value.string'),
'value.max' => __('validation.setting.value.max'),
'value.present' => __('validation.setting.value.present'),
];
}
}
@@ -6,6 +6,7 @@ use App\Enums\UserStatus;
use App\Extension\HookManager;
use App\Models\User;
use App\Rules\ExcludeCurrentUser;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -13,17 +14,20 @@ class BulkUpdateUserStatusRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* 권한 검사는 라우트의 `permission:admin,core.users.update` 미들웨어가 담당합니다.
*
* @return bool 항상 true (미들웨어에서 권한 제어)
*/
public function authorize(): bool
{
// 사용자 업데이트 권한 확인
return $this->user()->can('core.users.update');
return true;
}
/**
* 검증 규칙을 정의합니다.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -45,9 +49,9 @@ class BulkUpdateUserStatusRequest extends FormRequest
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => __('validation.attributes.ids')]),
'ids.array' => __('validation.array', ['attribute' => __('validation.attributes.ids')]),
'ids.min' => __('validation.min.array', ['attribute' => __('validation.attributes.ids'), 'min' => 1]),
'ids.required' => __('validation.required', ['attribute' => __('validation.attributes.user_ids')]),
'ids.array' => __('validation.array', ['attribute' => __('validation.attributes.user_ids')]),
'ids.min' => __('validation.min.array', ['attribute' => __('validation.attributes.user_ids'), 'min' => 1]),
'ids.*.required' => __('validation.required', ['attribute' => __('validation.attributes.user_id')]),
'ids.*.uuid' => __('validation.uuid', ['attribute' => __('validation.attributes.user_id')]),
'ids.*.exists' => __('validation.exists', ['attribute' => __('validation.attributes.user_id')]),
@@ -55,4 +59,20 @@ class BulkUpdateUserStatusRequest extends FormRequest
'status.in' => __('validation.in', ['attribute' => __('validation.attributes.status')]),
];
}
/**
* 검증 속성명을 반환합니다.
*
* 범용 필드명 ids 는 전역 라벨이 범용 문구이므로,
* 사용자 대상 라벨을 이 요청에서만 명시 매핑합니다.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'ids' => __('validation.attributes.user_ids'),
'ids.*' => __('validation.attributes.user_id'),
];
}
}
+8 -4
View File
@@ -4,6 +4,7 @@ namespace App\Http\Resources;
use App\Http\Resources\Traits\HasAbilityCheck;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
class UserCollection extends BaseApiCollection
{
@@ -20,7 +21,10 @@ class UserCollection extends BaseApiCollection
'can_create' => 'core.users.create',
'can_update' => 'core.users.update',
'can_delete' => 'core.users.delete',
'can_assign_roles' => 'core.permissions.update',
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
// (core.permissions.update)이 아니다. 부여 가능한 개별 역할의 범위는 서버
// 상한(PermissionEscalationGuard)이 역할별로 강제한다.
'can_assign_roles' => 'core.users.update',
];
}
@@ -36,7 +40,7 @@ class UserCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($user) {
return (new UserResource($user))->toListArray(request());
}),
'pagination' => $this->when($this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator, [
'pagination' => $this->when($this->resource instanceof LengthAwarePaginator, [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
@@ -56,7 +60,7 @@ class UserCollection extends BaseApiCollection
*/
public function withStatistics(array $statistics = []): array
{
$isPaginator = $this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator;
$isPaginator = $this->resource instanceof LengthAwarePaginator;
return [
'data' => $this->mapWithRowNumber(function ($user) {
@@ -87,7 +91,7 @@ class UserCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($user) {
return (new UserResource($user))->withAdminInfo();
}),
'pagination' => $this->when($this->resource instanceof \Illuminate\Pagination\LengthAwarePaginator, [
'pagination' => $this->when($this->resource instanceof LengthAwarePaginator, [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
+4 -1
View File
@@ -204,7 +204,10 @@ class UserResource extends BaseApiResource
'can_create' => 'core.users.create',
'can_update' => 'core.users.update',
'can_delete' => 'core.users.delete',
'can_assign_roles' => 'core.permissions.update',
// 역할 부여는 "사용자 관리"(core.users.update)의 일부다 — "역할 정의 수정"
// (core.permissions.update: 역할에 권한을 가감)이 아니다. 부여 가능한 개별 역할의
// 범위는 서버 상한(PermissionEscalationGuard)이 역할별로 강제한다.
'can_assign_roles' => 'core.users.update',
];
}
@@ -14,6 +14,7 @@ use App\Services\ModuleSettingsService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use App\Support\TrustedScriptHosts;
use Illuminate\View\View;
class TemplateComposer
@@ -103,6 +104,10 @@ class TemplateComposer
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
// 신뢰 외부 스크립트 호스트 — 레이아웃 scripts[].src same-origin 예외 허용목록
// (KVE-2026-1915: 확장이 manifest 로 선언한 CDN 호스트만 런타임 로더가 허용)
$trustedScriptHosts = TrustedScriptHosts::hosts();
$view->with('activeAdminTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -115,5 +120,6 @@ class TemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
@@ -14,6 +14,7 @@ use App\Services\ModuleSettingsService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use App\Support\TrustedScriptHosts;
use Illuminate\View\View;
/**
@@ -109,6 +110,10 @@ class UserTemplateComposer
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
// 신뢰 외부 스크립트 호스트 — 레이아웃 scripts[].src same-origin 예외 허용목록
// (KVE-2026-1915: 확장이 manifest 로 선언한 CDN 호스트만 런타임 로더가 허용)
$trustedScriptHosts = TrustedScriptHosts::hosts();
$view->with('activeUserTemplate', $activeTemplate);
$view->with('extensionCacheVersion', $extensionCacheVersion);
$view->with('frontendSettings', $frontendSettings);
@@ -121,5 +126,6 @@ class UserTemplateComposer
$view->with('activePluginsMeta', $activePluginsMeta);
$view->with('appConfig', $appConfig);
$view->with('templateExternals', $templateExternals);
$view->with('trustedScriptHosts', $trustedScriptHosts);
}
}
@@ -4,7 +4,7 @@ namespace App\Listeners\LayoutEditor;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\LanguagePack\CustomTranslationUsageScanner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -30,6 +30,8 @@ use Throwable;
*/
class MarkOrphanedCustomTranslations implements HookListenerInterface
{
use ClearsTemplateCaches;
/**
* @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 키 리포지토리
* @param CustomTranslationUsageScanner $scanner 키 사용 스캐너 (순수)
@@ -124,12 +126,11 @@ class MarkOrphanedCustomTranslations implements HookListenerInterface
/**
* 다국어 캐시 버전을 무효화합니다.
*
* `ext.cache_version` 은 코어 소유 키이므로 컨테이너 바인딩에 의존하지 않고
* 항상 CoreCacheDriver(`g7:core:` 네임스페이스)를 직접 생성합니다
* (ClearsTemplateCaches 트레이트와 동일 SSoT).
* `ext.cache_version` 쓰기는 ClearsTemplateCaches 트레이트 단일 지점 경유 —
* 트레이트가 고정 CoreCacheDriver + 메모이즈 스토어로 write/read 일관성을 보장한다.
*/
private function invalidateLanguageCache(): void
{
(new CoreCacheDriver(config('cache.default', 'array')))->put('ext.cache_version', time());
$this->incrementExtensionCacheVersion();
}
}
+43 -3
View File
@@ -28,6 +28,14 @@ class SeoSettingsCacheListener implements HookListenerInterface
'method' => 'onSettingsSave',
'priority' => 20,
],
// 단건 저장(`PUT /api/admin/settings/{key}`)은 after_save 가 아니라 after_set 을
// 발화한다. 이 구독이 없으면 SEO 설정을 단건으로 바꿔도 캐시가 남아 있다.
// (after_save 를 추가 발화하는 대신 구독을 늘리는 이유: 활동로그 리스너가 두 훅을
// 각각 기록해 저장 1회가 로그 2건이 된다)
'core.settings.after_set' => [
'method' => 'onSettingSet',
'priority' => 20,
],
];
}
@@ -57,6 +65,40 @@ class SeoSettingsCacheListener implements HookListenerInterface
return;
}
$this->clearSeoCaches(['tab' => $tab]);
}
/**
* 코어 설정 단건 저장 시 SEO 캐시를 무효화합니다.
*
* seo 카테고리 키가 실제로 저장된 경우에만 전체 캐시를 삭제합니다.
*
* @param mixed ...$args 훅 인자 ($key, $value, $result)
*/
public function onSettingSet(...$args): void
{
$key = $args[0] ?? null;
$result = $args[2] ?? false;
if (! is_string($key) || ! str_starts_with($key, 'seo.')) {
return;
}
// 저장 실패는 상태를 바꾸지 않았으므로 캐시도 그대로 둔다
if ($result !== true) {
return;
}
$this->clearSeoCaches(['key' => $key]);
}
/**
* SEO 전체 캐시와 sitemap 캐시를 삭제합니다.
*
* @param array $logContext 로그에 남길 컨텍스트
*/
private function clearSeoCaches(array $logContext): void
{
try {
$cache = app(SeoCacheManagerInterface::class);
@@ -66,9 +108,7 @@ class SeoSettingsCacheListener implements HookListenerInterface
// Sitemap 캐시 삭제
app(CacheInterface::class)->forget('seo.sitemap');
Log::info('[SEO] Core SEO settings changed — all cache cleared', [
'tab' => $tab,
]);
Log::info('[SEO] Core SEO settings changed — all cache cleared', $logContext);
} catch (\Throwable $e) {
Log::warning('[SEO] Core SEO settings cache invalidation failed', [
'error' => $e->getMessage(),
+16 -1
View File
@@ -111,6 +111,7 @@ class Attachment extends Model
*/
public function getFullPathAttribute(): string
{
// audit:allow no-storage-disk-direct reason: 행 disk 의 파일시스템 절대경로 해석 — 코어 첨부 path 는 카테고리 없는 원 경로라 StorageInterface(카테고리/경로) 계약과 형태가 다르고, 모델 accessor 는 DI 지점이 없다 (기존 동작 유지)
return Storage::disk($this->disk)->path($this->path);
}
@@ -121,7 +122,21 @@ class Attachment extends Model
*/
public function getDownloadUrlAttribute(): string
{
return '/api/attachment/'.$this->hash;
return self::urlForHash($this->hash);
}
/**
* 해시 기반 다운로드 URL 조립 단일 지점.
*
* 조인 결과 행처럼 모델 hydration 없이 hash 만 손에 있는 호출측
* (예: 게시판 인기 게시물 목록의 아바타 URL)이 사용합니다.
*
* @param string $hash 첨부파일 해시
* @return string 다운로드 URL
*/
public static function urlForHash(string $hash): string
{
return '/api/attachment/'.$hash;
}
/**
+10 -1
View File
@@ -7,6 +7,7 @@ use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Models\Concerns\HasUserOverrides;
use Carbon\Carbon;
use Cron\CronExpression;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -153,8 +154,16 @@ class Schedule extends Model
}
try {
// cron 식은 사이트 설정 시간대로 해석한다 — 코드 예약(app.schedule_timezone)과
// 같은 기준이어야 같은 화면에서 "새벽 4시" 의 근거가 어긋나지 않는다.
// 계산 결과는 저장 타임존(app.timezone = UTC)으로 되돌려 저장한다.
$storeTimezone = (string) config('app.timezone', 'UTC');
$scheduleTimezone = (string) config('app.schedule_timezone', $storeTimezone);
$cron = new CronExpression($this->expression);
$this->next_run_at = $cron->getNextRunDate();
$nextRun = $cron->getNextRunDate('now', 0, false, $scheduleTimezone);
$this->next_run_at = Carbon::instance($nextRun)->setTimezone($storeTimezone);
} catch (\Exception $e) {
$this->next_run_at = null;
}
+53 -3
View File
@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
@@ -24,6 +25,11 @@ class User extends Authenticatable implements HasLocalePreference
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* 닉네임 컬럼 최대 길이 (마이그레이션에서 명시한 값)
*/
public const NICKNAME_MAX_LENGTH = 50;
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
@@ -602,20 +608,42 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function withdraw(): bool
{
// 멱등 가드 — 이미 탈퇴한 계정에 다시 호출해도 접미사가 겹쳐 붙지 않는다.
if ($this->isWithdrawn()) {
return true;
}
$now = now();
$dateSuffix = $now->format('Ymd'); // 예: 20260127
// name/email 은 마이그레이션에서 길이를 지정하지 않은 문자열 컬럼이므로
// 스키마 기본 길이(AppServiceProvider 의 defaultStringLength)를 그대로 따른다.
// 여기에 리터럴을 박으면 기본 길이를 바꾸는 순간 조용히 어긋난다.
$defaultLength = SchemaBuilder::$defaultStringLength ?? 255;
// 이름에 suffix 추가 (있는 경우만)
if ($this->name) {
$this->name = $this->name.'_탈퇴_'.$dateSuffix;
$this->name = $this->appendWithdrawnSuffix($this->name, '_탈퇴_'.$dateSuffix, $defaultLength);
}
// 이메일에 suffix 추가 (필수)
$this->email = $this->email.'_deleted_'.$dateSuffix;
//
// 접미사에 사용자 ID 를 포함해 구조적으로 유일하게 만든다. 날짜만 붙이면
// 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴할 때 email unique 에
// 걸려 탈퇴가 실패한다(공개이슈 #112).
$this->email = $this->appendWithdrawnSuffix(
$this->email,
'_deleted_'.$dateSuffix.'_'.$this->id,
$defaultLength,
);
// 닉네임에 suffix 추가 (있는 경우만, 날짜 없이)
if ($this->nickname) {
$this->nickname = $this->nickname.'_탈퇴';
// nickname 은 마이그레이션에서 길이를 명시(50)한 컬럼이다.
// 이 접미사는 유일성 토큰(id)이 없다 — 현재 nickname/name 에 unique 인덱스가
// 없어 무해하지만, 향후 unique 인덱스를 추가하면 email 과 동일한 충돌
// (같은 값 재가입 후 재탈퇴 실패)이 재발하므로 그때 id 부착으로 전환할 것.
$this->nickname = $this->appendWithdrawnSuffix($this->nickname, '_탈퇴', self::NICKNAME_MAX_LENGTH);
}
// 상태 변경
@@ -624,4 +652,26 @@ class User extends Authenticatable implements HasLocalePreference
return $this->save();
}
/**
* 탈퇴 접미사를 컬럼 길이 안에서 부착합니다.
*
* 원값이 길면 접미사 길이만큼 앞을 잘라 붙입니다 — 자르지 않으면 접미사가 컬럼
* 길이를 넘겨 strict mode 저장 예외가 나고, 탈퇴가 중간에서 실패합니다.
*
* @param string $value 원본 값
* @param string $suffix 부착할 접미사
* @param int $maxLength 컬럼 최대 길이
* @return string 접미사가 부착된 값
*/
protected function appendWithdrawnSuffix(string $value, string $suffix, int $maxLength): string
{
$available = $maxLength - mb_strlen($suffix);
if ($available < 0) {
$available = 0;
}
return mb_substr($value, 0, $available).$suffix;
}
}
+17 -89
View File
@@ -10,7 +10,6 @@ use App\Console\Commands\BenchCommand;
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;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
@@ -96,7 +95,9 @@ use App\Services\AttachmentService;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\TemplateService;
use App\Services\UniqueIdService;
use App\Support\ExtensionSettingsMirror;
use App\Support\PrivilegedDatabaseAccounts;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
@@ -269,14 +270,14 @@ class CoreServiceProvider extends ServiceProvider
$this->app->when(AttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
return new CoreStorageDriver(config('attachment.disk', 'local'));
return new CoreStorageDriver(config('attachment.disk', 'attachments'));
});
// TemplateLayoutAttachmentService용 CoreStorageDriver 바인딩
$this->app->when(TemplateLayoutAttachmentService::class)
->needs(StorageInterface::class)
->give(function () {
return new CoreStorageDriver(config('attachment.disk', 'local'));
return new CoreStorageDriver(config('attachment.disk', 'attachments'));
});
// 코어 서비스용 CoreCacheDriver 바인딩
@@ -347,6 +348,12 @@ class CoreServiceProvider extends ServiceProvider
$this->app->singleton(TemplateManagerInterface::class, function ($app) {
return $app->make(TemplateManager::class);
});
// 템플릿 서비스도 공유 인스턴스로 등록한다.
// 미등록 상태에서는 주입 지점마다 새로 만들어지고, 그 생성자가 매번 템플릿
// 디렉토리를 재스캔했다. 요청 단위 상태는 라우트 병합 열화 플래그 하나뿐이며
// 그 플래그는 병합 진입 시 재설정되므로 공유해도 안전하다.
$this->app->singleton(TemplateService::class);
}
/**
@@ -739,69 +746,8 @@ class CoreServiceProvider extends ServiceProvider
*/
protected function loadModuleSettingsToConfig(ModuleManager $moduleManager): void
{
$moduleSettings = [];
foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
try {
// 모듈별 환경설정 서비스 조회
$settingsService = $this->resolveModuleSettingsService($identifier);
if ($settingsService instanceof ModuleSettingsInterface) {
$settings = $settingsService->getAllSettings();
if (! empty($settings)) {
$moduleSettings[$identifier] = $settings;
}
}
} catch (\Throwable $e) {
Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
'error' => $e->getMessage(),
]);
}
}
Config::set('g7_settings.modules', $moduleSettings);
}
/**
* 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
*
* 다음 순서로 설정 서비스를 찾습니다:
* 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
* 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
*
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
* @return ModuleSettingsInterface|null 설정 서비스 인스턴스
*/
protected function resolveModuleSettingsService(string $identifier): ?ModuleSettingsInterface
{
// vendor-module 형식을 네임스페이스로 변환
$parts = explode('-', $identifier);
if (count($parts) < 2) {
return null;
}
$vendor = ucfirst($parts[0]);
$moduleName = ucfirst($parts[1]);
// 1. 인터페이스 바인딩 확인
$interfaceClass = "Modules\\{$vendor}\\{$moduleName}\\Contracts\\{$moduleName}SettingsServiceInterface";
if ($this->app->bound($interfaceClass)) {
$service = $this->app->make($interfaceClass);
if ($service instanceof ModuleSettingsInterface) {
return $service;
}
}
// 2. 구체 클래스 확인
$concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
if (class_exists($concreteClass)) {
$service = $this->app->make($concreteClass);
if ($service instanceof ModuleSettingsInterface) {
return $service;
}
}
return null;
// 미러 채움 로직은 ExtensionSettingsMirror 가 단일 소유한다 (공개이슈 #109).
app(ExtensionSettingsMirror::class)->refreshAllModules();
}
/**
@@ -814,28 +760,9 @@ class CoreServiceProvider extends ServiceProvider
*/
protected function loadPluginSettingsToConfig(PluginManager $pluginManager): void
{
$pluginSettings = [];
foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
try {
$settingsPath = storage_path("app/plugins/{$identifier}/settings/setting.json");
if (File::exists($settingsPath)) {
$content = File::get($settingsPath);
$settings = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE && ! empty($settings)) {
$pluginSettings[$identifier] = $settings;
}
}
} catch (\Throwable $e) {
Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
'error' => $e->getMessage(),
]);
}
}
Config::set('g7_settings.plugins', $pluginSettings);
// 종전에는 setting.json 을 raw 로 읽어 defaults 병합·정규화가 빠지고 암호문이 그대로
// 실렸다 — 값의 형태가 전용 게터와 달랐다. 이제 미러 소유자에 위임한다 (공개이슈 #109).
app(ExtensionSettingsMirror::class)->refreshAllPlugins();
}
/**
@@ -888,7 +815,8 @@ class CoreServiceProvider extends ServiceProvider
$configKey = $driverRegistry->getConfigKey($category);
if ($configKey && $defaultDriver) {
Config::set($configKey, $defaultDriver);
// log 카테고리의 적용 키(stack.channels)는 배열형 — 형태 변환은 레지스트리가 담당
Config::set($configKey, $driverRegistry->getConfigValueForDriver($category, $defaultDriver));
}
Log::warning("플러그인 드라이버 '{$selectedDriver}'가 '{$category}' 카테고리에서 사용 불가능합니다. 기본 드라이버 '{$defaultDriver}'로 폴백합니다.");
+103 -52
View File
@@ -4,8 +4,10 @@ namespace App\Providers;
use App\Repositories\JsonConfigRepository;
use App\Support\AllowedExtensions;
use App\Support\ExtensionSettingsMirror;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Predis\Client;
/**
* 설정 서비스 프로바이더
@@ -15,23 +17,8 @@ use Illuminate\Support\ServiceProvider;
*/
class SettingsServiceProvider extends ServiceProvider
{
/**
* 코어 설정 카테고리 목록
*/
private const CORE_CATEGORIES = [
'mail',
'general',
'security',
'debug',
'drivers',
'cache',
'upload',
'core_update',
'geoip',
'seo',
'identity',
'pagination',
];
// 코어 설정 카테고리 목록은 ExtensionSettingsMirror::CORE_CATEGORIES 가 단독 소유한다.
// 여기에 사본을 두면 미러가 읽는 목록과 갈라져도 아무도 알아채지 못한다.
/**
* 서비스를 등록합니다.
@@ -40,6 +27,10 @@ class SettingsServiceProvider extends ServiceProvider
*/
public function register(): void
{
// 미러 채움 소유자를 컨테이너 싱글톤으로 등록한다 — 부팅/저장/테스트가
// 같은 인스턴스를 해석해야 교체(스텁 주입)가 모든 경로에 통한다.
$this->app->singleton(ExtensionSettingsMirror::class);
// JsonConfigRepository를 직접 인스턴스화 (DI 컨테이너 사용 불가)
$configRepository = new JsonConfigRepository;
@@ -48,7 +39,7 @@ class SettingsServiceProvider extends ServiceProvider
$this->applyAppConfig($configRepository);
$this->applyDebugConfig($configRepository);
$this->applyDriverConfig($configRepository);
$this->applyCacheConfig($configRepository);
$this->applyPublicAssetDiskConfig($configRepository);
$this->applyUploadConfig($configRepository);
$this->applyCoreUpdateConfig($configRepository);
$this->applyGeoIpConfig($configRepository);
@@ -66,16 +57,9 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function loadCoreSettingsToConfig(JsonConfigRepository $configRepository): void
{
$coreSettings = [];
foreach (self::CORE_CATEGORIES as $category) {
$settings = $configRepository->getCategory($category);
if (! empty($settings)) {
$coreSettings[$category] = $settings;
}
}
Config::set('g7_settings.core', $coreSettings);
// 미러 채움 로직은 ExtensionSettingsMirror 가 단일 소유한다 —
// 저장 시점 재채움(공개이슈 #109)과 같은 코드를 써야 부팅과 저장의 결과가 갈리지 않는다.
$this->app->make(ExtensionSettingsMirror::class)->refreshCore($configRepository);
}
/**
@@ -187,6 +171,14 @@ class SettingsServiceProvider extends ServiceProvider
// 환경설정의 timezone은 사용자 표시용 기본 타임존
// app.timezone(서버 저장 타임존)은 항상 UTC 유지
Config::set('app.default_user_timezone', $generalSettings['timezone']);
// 예약 작업의 시각 해석 기준도 사이트 설정 시간대를 따른다.
// Laravel 의 Kernel::scheduleTimezone() 이 이 키를 읽어 Schedule 인스턴스
// 전체에 일괄 적용하므로, 코어·확장이 등록한 모든 예약이 같은 기준을 공유한다.
// (이벤트마다 ->timezone() 을 붙이는 방식은 나중에 추가되는 예약이
// 조용히 UTC 기준으로 돌아가므로 채택하지 않는다.)
// 미설정 시에는 Laravel 기본 폴백(app.timezone = UTC)이 그대로 적용된다.
Config::set('app.schedule_timezone', $generalSettings['timezone']);
}
if (! empty($generalSettings['language'])) {
@@ -342,6 +334,16 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('filesystems.default', $driverSettings['storage_driver']);
}
// 코어 첨부 업로드 디스크: ATTACHMENT_DISK env 명시가 항상 우선하고,
// 미설정 시 storage_driver=s3 를 따른다. env() 직접 호출은 config:cache 환경에서
// null 로 고정되므로 config 에 태운 attachment.disk_explicit 로 판별한다.
// 빈 문자열도 미명시로 취급한다 — `ATTACHMENT_DISK=` 가 복사된 .env 에서
// 빈 값을 명시로 읽으면 전환이 영구 미발동한다 (config 정규화의 2차 방어).
// 기존 행은 행 disk 로 서빙되므로 신구 디스크 혼재는 안전하다.
if (($driverSettings['storage_driver'] ?? null) === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
Config::set('attachment.disk', 's3');
}
// 웹소켓 설정
$this->applyWebsocketConfig($driverSettings);
@@ -354,11 +356,55 @@ class SettingsServiceProvider extends ServiceProvider
$this->applyLogConfig($driverSettings);
}
/**
* 공개 자산 디스크 설정을 적용합니다.
*
* testing 환경에서는 dev 공유 drivers.json 값이 테스트로 흘러들지 않도록
* 주입을 건너뜁니다 (테스트 격리). 실제 주입/정규화는
* injectPublicAssetDiskConfig() 가 담당합니다 — 가드와 분리해 두어야
* 정규화 규칙('none' → '')이 테스트에서 단언 가능합니다.
*/
private function applyPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
if (env('APP_ENV') === 'testing') {
return;
}
$this->injectPublicAssetDiskConfig($configRepository);
}
/**
* drivers.public_asset_disk 저장값을 core.storage.public_asset_disk 로 주입합니다.
*
* 'none'(스트리밍 유지 선택)/빈값은 미설정('')으로 정규화합니다.
* 테스트 격리 가드(applyPublicAssetDiskConfig)를 통과한 뒤에만 호출됩니다.
*
* @param JsonConfigRepository $configRepository 설정 저장소
*/
private function injectPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
$driverSettings = $configRepository->getCategory('drivers');
$disk = (string) ($driverSettings['public_asset_disk'] ?? '');
Config::set('core.storage.public_asset_disk', $disk === 'none' ? '' : $disk);
}
/**
* Redis 연결 설정을 적용합니다.
*/
private function applyRedisConfig(array $driverSettings): void
{
// phpredis 확장이 없는 서버에서 redis 드라이버 선택 시 `Class "Redis" not found` 로
// 사이트 전면 다운되는 결함 방어 — 설정된 클라이언트가 phpredis 인데 확장이 없고
// predis 가 있으면 predis 로 폴백한다. 확장이 있으면 기존 phpredis 경로 그대로다.
// env('REDIS_CLIENT') 미명시 판별은 무효였다: .env.example 이 REDIS_CLIENT=phpredis
// 를 활성 배포하므로 표준 설치에서 영구 미발동이었고, env() 직접 호출은
// config:cache 환경에서 null 로 고정된다 (A8 disk_explicit 와 동형 함정).
if ($this->shouldFallBackToPredis(extension_loaded('redis'))) {
Config::set('database.redis.client', 'predis');
}
if (! empty($driverSettings['redis_host'])) {
Config::set('database.redis.default.host', $driverSettings['redis_host']);
Config::set('database.redis.cache.host', $driverSettings['redis_host']);
@@ -380,6 +426,25 @@ class SettingsServiceProvider extends ServiceProvider
}
}
/**
* Redis 클라이언트를 predis 로 폴백해야 하는지 판정합니다.
*
* 설정된 클라이언트(config — env 시점 값이 config:cache 에도 박제됨)가 phpredis 를
* 가리키는데 확장이 로드되어 있지 않고 predis 가 존재하면 참. phpredis 명시 설정이라도
* 확장이 없으면 어차피 동작 불가이므로 predis 전환이 유일한 동작 경로다 (#99 A2).
* 확장 로드 여부는 인자로 받는다 — 확장 설치 머신에서 부재 상태를 재현할 수 없어
* 판정 자체를 단위 검증 가능하게 분리한 것 (테스트가 양 분기를 주입 검증).
*
* @param bool $phpredisLoaded phpredis 확장 로드 여부 (extension_loaded('redis'))
* @return bool predis 로 폴백해야 하면 true
*/
private function shouldFallBackToPredis(bool $phpredisLoaded): bool
{
return config('database.redis.client', 'phpredis') !== 'predis'
&& ! $phpredisLoaded
&& class_exists(Client::class);
}
/**
* Memcached 연결 설정을 적용합니다.
*/
@@ -418,6 +483,16 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($driverSettings['s3_url'])) {
Config::set('filesystems.disks.s3.url', $driverSettings['s3_url']);
}
// S3 호환 스토리지(R2/MinIO/NCP 등)의 API 요청 대상 — s3_url(공개 URL base)과 별개 축이다.
// endpoint 미주입 시 SDK 는 AWS 리전 도메인으로만 요청하므로 호환 스토리지 연결이 불가능하다.
if (! empty($driverSettings['s3_endpoint'])) {
Config::set('filesystems.disks.s3.endpoint', $driverSettings['s3_endpoint']);
}
if (! empty($driverSettings['s3_use_path_style'])) {
Config::set('filesystems.disks.s3.use_path_style_endpoint', true);
}
}
/**
@@ -619,26 +694,6 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('settings.identity', $settings);
}
/**
* 캐시 설정을 Laravel config에 적용합니다.
*/
private function applyCacheConfig(JsonConfigRepository $configRepository): void
{
$cacheSettings = $configRepository->getCategory('cache');
if (empty($cacheSettings)) {
return;
}
if (! empty($cacheSettings['driver'])) {
Config::set('cache.default', $cacheSettings['driver']);
}
if (! empty($cacheSettings['prefix'])) {
Config::set('cache.prefix', $cacheSettings['prefix']);
}
}
/**
* 업로드 설정을 Laravel config에 적용합니다.
*/
@@ -650,10 +705,6 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
if (! empty($uploadSettings['disk'])) {
Config::set('filesystems.default', $uploadSettings['disk']);
}
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
if (! empty($uploadSettings['max_file_size'])) {
@@ -7,6 +7,7 @@ use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Http\Resources\BaseApiCollection;
use App\Models\ActivityLog;
use App\Repositories\Concerns\DeletesInBatches;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Model;
*/
class ActivityLogRepository implements ActivityLogRepositoryInterface
{
use DeletesInBatches;
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -316,4 +318,20 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
{
return ActivityLog::where('user_id', $userId)->update(['user_id' => null]);
}
/**
* 보존 기간이 지난 활동 로그를 삭제합니다.
*
* 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로 호출자마다
* 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int
{
return $this->deleteInBatches(
ActivityLog::where('created_at', '<', now()->subDays(max(1, $days)))
);
}
}
+40
View File
@@ -5,6 +5,7 @@ namespace App\Repositories;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
@@ -23,6 +24,17 @@ class AttachmentRepository implements AttachmentRepositoryInterface
return Attachment::find($id);
}
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment
{
return Attachment::withTrashed()->find($id);
}
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -283,4 +295,32 @@ class AttachmentRepository implements AttachmentRepositoryInterface
}
});
}
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* @param Carbon $threshold 기준 시각
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록
* @return Collection 고아 첨부 후보
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection
{
$query = Attachment::withTrashed()
->whereNull('attachmentable_type')
->whereNull('attachmentable_id')
// 확장이 소유한 첨부는 그 확장의 라이프사이클 소관이므로 코어 GC 대상이 아니다.
->whereNull('source_identifier')
->where('created_at', '<', $threshold);
if ($protectedIds !== []) {
$query->whereNotIn('id', $protectedIds);
}
return $query
->orderBy('created_at')
->orderBy('id')
->limit($limit)
->get(['id', 'disk', 'path', 'collection', 'created_at']);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Repositories\Concerns;
use Illuminate\Database\Eloquent\Builder;
/**
* 보존 기간 초과 행을 배치로 나눠 삭제하는 트레이트
*
* 정리 예약은 도입 직후 첫 실행에서 이미 쌓여 있던 과거 데이터를 전부 지운다.
* 수년치가 한 문장으로 삭제되면 그 한 건이 테이블을 오래 잠그고, 트랜잭션 로그도
* 그만큼 부풀어 정리 배치가 사이트를 멈추게 한다 — 정리하려던 문제를 정리 작업이
* 다시 만드는 셈이다.
*
* 그래서 기본키 배치로 끊어 지운다. 각 DELETE 는 최대 batchSize 건의 기본키 IN 절이라
* 잠금 구간이 짧고, 중간에 중단돼도 이미 지운 만큼은 확정된다(정리는 멱등이라
* 다음 실행이 남은 몫을 이어서 처리한다).
*/
trait DeletesInBatches
{
/**
* 기본 배치 크기
*/
protected int $deleteBatchSize = 1000;
/**
* 조건에 맞는 행을 배치로 나눠 삭제합니다.
*
* 기본키를 먼저 모아 그 키로만 지운다 — `DELETE ... LIMIT` 은 드라이버마다 지원이
* 갈리지만 기본키 IN 절은 어디서나 같게 동작한다.
*
* @param Builder $query 삭제 대상 조건이 적용된 쿼리 (정렬·제한 미적용 상태)
* @param int|null $batchSize 배치 크기 (미지정 시 $deleteBatchSize)
* @return int 삭제된 총 건수
*/
protected function deleteInBatches(Builder $query, ?int $batchSize = null): int
{
$batchSize = max(1, $batchSize ?? $this->deleteBatchSize);
$model = $query->getModel();
$keyName = $model->getKeyName();
$total = 0;
while (true) {
// audit:allow query-repeated-execution 배치 삭제는 같은 술어를 의도적으로 반복 평가한다 — 매 회차의 모집단이 직전 삭제로 줄어든다
$keys = (clone $query)->orderBy($keyName)->limit($batchSize)->pluck($keyName);
if ($keys->isEmpty()) {
return $total;
}
$total += $model->newQuery()->whereIn($keyName, $keys)->delete();
// 마지막 배치 — 더 조회할 것이 없다
if ($keys->count() < $batchSize) {
return $total;
}
}
}
}
@@ -7,6 +7,7 @@ use App\Enums\IdentityVerificationStatus;
use App\Helpers\TimezoneHelper;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Repositories\Concerns\DeletesInBatches;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -19,6 +20,7 @@ use Illuminate\Support\Carbon;
*/
class IdentityVerificationLogRepository implements IdentityVerificationLogRepositoryInterface
{
use DeletesInBatches;
use PaginatesWithDeferredJoin;
/**
@@ -127,9 +129,10 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
*/
public function purgeOlderThan(int $days): int
{
return IdentityVerificationLog::query()
->where('created_at', '<', Carbon::now()->subDays(max(1, $days)))
->delete();
return $this->deleteInBatches(
IdentityVerificationLog::query()
->where('created_at', '<', Carbon::now()->subDays(max(1, $days)))
);
}
/**
+21 -3
View File
@@ -125,16 +125,34 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
}
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator
{
// audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다
return $this->buildFilteredQuery($filters)->paginate($perPage);
return $this->buildFilteredQuery($filters)->paginate($perPage, ['*'], 'page', $page);
}
/**
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
*
* 전량 순회 의도를 `paginate(큰 값)` 으로 흉내 내면 page 인자 암묵 해석 때문에
* HTTP `?page=2` 가 순회 범위를 비워 버린다 (공개 이슈 #102 동형). 순회는 이
* 메서드로만 한다.
*
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
*/
public function allForUpdateCheck(): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 운영자가 설치한 팩 수만큼만 존재하는
// 설정성 테이블이다 (사용량과 무관) — 대용량 목록 페이지네이션 규정의 설정성
// 테이블 예외 조항 (docs/backend/pagination.md)
return LanguagePack::query()->orderBy('identifier')->get();
}
/**
+26
View File
@@ -131,6 +131,21 @@ class MenuRepository implements MenuRepositoryInterface
return Menu::with(['creator', 'parent', 'children'])->find($id);
}
/**
* 여러 ID로 메뉴를 한 번에 조회합니다.
*
* @param array<int, int> $ids 메뉴 ID 목록
* @return Collection 메뉴 컬렉션
*/
public function findByIds(array $ids): Collection
{
if (empty($ids)) {
return new Collection;
}
return Menu::whereIn('id', $ids)->get();
}
/**
* 슬러그로 메뉴를 찾습니다.
*
@@ -142,6 +157,17 @@ class MenuRepository implements MenuRepositoryInterface
return Menu::where('slug', $slug)->first();
}
/**
* URL 로 메뉴를 찾습니다.
*
* @param string $url 메뉴 URL
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findByUrl(string $url): ?Menu
{
return Menu::where('url', $url)->first();
}
/**
* 새로운 메뉴를 생성합니다.
*
@@ -6,6 +6,7 @@ use App\Contracts\Repositories\NotificationLogRepositoryInterface;
use App\Enums\NotificationLogStatus;
use App\Models\NotificationLog;
use App\Models\User;
use App\Repositories\Concerns\DeletesInBatches;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
@@ -17,6 +18,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
class NotificationLogRepository implements NotificationLogRepositoryInterface
{
use DeletesInBatches;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -98,6 +100,22 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
return NotificationLog::whereIn('id', $ids)->delete();
}
/**
* 보존 기간이 지난 발송 이력을 삭제합니다.
*
* 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로 호출자마다
* 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int
{
return $this->deleteInBatches(
NotificationLog::where('created_at', '<', now()->subDays(max(1, $days)))
);
}
/**
* 페이지네이션 목록 조회.
*
+15
View File
@@ -30,6 +30,21 @@ class PermissionRepository implements PermissionRepositoryInterface
return Permission::find($id);
}
/**
* 여러 ID로 권한을 일괄 조회합니다.
*
* @param array<int> $ids 권한 ID 배열
* @return Collection 권한 컬렉션 (ID 기준)
*/
public function getByIds(array $ids): Collection
{
if (empty($ids)) {
return new Collection;
}
return Permission::whereIn('id', $ids)->get();
}
/**
* 식별자로 권한을 찾습니다.
*
@@ -4,6 +4,7 @@ namespace App\Repositories;
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Models\ScheduleHistory;
use App\Repositories\Concerns\DeletesInBatches;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\PaginationLimits;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection;
class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
{
use DeletesInBatches;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -169,6 +171,10 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
*/
public function deleteOlderThan(int $days): int
{
return ScheduleHistory::where('started_at', '<', now()->subDays($days))->delete();
// 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로
// 호출자마다 다시 막지 않고 실제로 지우는 자리에서 한 번 막는다.
return $this->deleteInBatches(
ScheduleHistory::where('started_at', '<', now()->subDays(max(1, $days)))
);
}
}
+4 -1
View File
@@ -4,6 +4,7 @@ namespace App\Repositories;
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
use App\Models\SeoCacheStat;
use App\Repositories\Concerns\DeletesInBatches;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Database\Eloquent\Builder;
@@ -16,6 +17,8 @@ use Illuminate\Support\Facades\DB;
*/
class SeoCacheStatRepository implements SeoCacheStatRepositoryInterface
{
use DeletesInBatches;
/**
* 그룹 집계에 허용된 컬럼 목록
*
@@ -96,7 +99,7 @@ class SeoCacheStatRepository implements SeoCacheStatRepositoryInterface
*/
public function deleteOlderThan(Carbon $cutoff): int
{
return SeoCacheStat::where('created_at', '<', $cutoff)->delete();
return $this->deleteInBatches(SeoCacheStat::where('created_at', '<', $cutoff));
}
/**
+18 -2
View File
@@ -34,8 +34,24 @@ class AllowedShellCommand implements ValidationRule
return;
}
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
$verdict = ScheduleCommandValidator::inspectShellCommand($value);
if ($verdict['allowed']) {
return;
}
// 인터프리터에 인라인 코드/명령을 넘기거나 안전하지 않은 스크립트 경로를 지정한
// 경우는 전용 안내로 구분한다 — 화이트리스트 미등재와는 조치 방법이 다르다.
$interpreterReasons = [
ScheduleCommandValidator::SHELL_REASON_INTERPRETER,
ScheduleCommandValidator::SHELL_REASON_INLINE_CODE,
ScheduleCommandValidator::SHELL_REASON_SCRIPT_PATH,
];
$key = in_array($verdict['reason'], $interpreterReasons, true)
? 'validation.schedule_command.shell_interpreter_denied'
: 'validation.schedule_command.shell_not_allowed';
$fail(__($key));
}
}
+32 -3
View File
@@ -8,8 +8,14 @@ use Illuminate\Contracts\Validation\ValidationRule;
/**
* 레이아웃 JSON에서 외부 URL을 차단하는 Custom Rule
*
* props와 actions 내의 http://, https://, data:, javascript: 등
* 위험한 URI 스킴을 감지하여 차단합니다.
* 컴포넌트 props·actions 와 최상위 init_actions 내의 http://, https://, data:,
* javascript: 등 위험한 URI 스킴을 감지하여 차단합니다.
*
* 검사 대상 구분(신뢰 경계): init_actions 는 로드 시 자동 실행되는 액션이라 외부
* navigate/apiCall URL 이 곧 자동 리다이렉트·데이터 유출 경로가 되므로 실행 지점에서
* 차단합니다. 반면 state/computed 는 데이터 값이며, 실제 위험은 그 값이 바인딩되는
* sink(컴포넌트 prop = img src 등)에서 발생하고 그 sink 는 이미 여기서 검사됩니다 —
* 예시/안내용 URL 을 담는 정당한 용례를 깨지 않기 위해 데이터 계층은 재차단하지 않습니다.
*/
class NoExternalUrls implements ValidationRule
{
@@ -31,6 +37,15 @@ class NoExternalUrls implements ValidationRule
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// 문자열 스칼라 필드에도 부착되므로(`content.endpoint` ·
// `content.data_sources.*.endpoint`) 그 값을 직접 검사한다. 배열만 처리하고
// 반환하면 그 부착이 조용한 no-op 이 된다.
if (is_string($value)) {
$this->checkForDangerousUrl($value, $attribute, $fail);
return;
}
if (! is_array($value)) {
return;
}
@@ -39,6 +54,16 @@ class NoExternalUrls implements ValidationRule
if (isset($value['components']) && is_array($value['components'])) {
$this->validateComponents($value['components'], $fail);
}
// init_actions: 로드 시 자동 실행되는 액션 — 외부 navigate/apiCall URL 은 로드 시점
// 자동 리다이렉트/데이터 유출 경로가 되므로 컴포넌트 actions 와 동일하게 검사한다.
if (isset($value['init_actions']) && is_array($value['init_actions'])) {
foreach ($value['init_actions'] as $i => $action) {
if (is_array($action)) {
$this->validateObject($action, "init_actions[$i]", $fail);
}
}
}
}
/**
@@ -112,7 +137,11 @@ class NoExternalUrls implements ValidationRule
}
// 추가 패턴 검사: //로 시작 (프로토콜 상대 URL)
if (str_starts_with($lowerValue, '//')) {
//
// 브라우저 URL 파서는 파싱 전에 ASCII tab·개행을 제거하고 백슬래시를 슬래시와
// 동등하게 처리하므로, `/\/evil.com` · `/{tab}/evil.com` 도 실제로는 외부
// origin 이 된다. 접두 검사 전에 동일하게 정규화한다(SafeLayoutExpressions 와 동형).
if (str_starts_with(SafeLayoutExpressions::normalizeForOriginCheck($lowerValue), '//')) {
$fail(__('validation.external_url.detected_in_props', ['url' => $value]));
return;
+238
View File
@@ -0,0 +1,238 @@
<?php
namespace App\Rules;
use App\Support\TrustedScriptHosts;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 레이아웃 JSON 표현식 저장측 심층 방어 규칙 (KVE-2026-1915)
*
* 클라이언트의 화이트리스트 AST 평가기(SafeExpressionEvaluator)가 표현식 실행의
* 1차 방어입니다. 이 규칙은 저장 시점에 위험 토큰을 거부하는 서버측 보조 방어로,
* 편집자가 저장한 레이아웃이 애초에 샌드박스 우회 표현식/원격 스크립트를 담지
* 못하게 합니다.
*
* 차단 대상(레이아웃 JSON 전체를 재귀 순회하며 모든 문자열 값 검사):
* - 프로토타입 체인 접근: `.constructor` / `.__proto__` / `.prototype`,
* `['constructor']` 등 문자열 리터럴 computed 접근 → Function 도달 경로
* - 함수 생성/코드 실행: `Function(`, `eval(`, 동적 `import(`
* - 원격 스크립트: `scripts[].src` 는 same-origin path-only(`/` 시작, `//`·scheme 금지),
* 단 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트는 예외로 허용
* - 외부 데이터 소스: `data_sources[].endpoint` 도 동일 (same-origin 또는 신뢰 호스트)
*
* 화살표 함수(`=>`)는 정상 표현식에서 광범위하게 사용되므로 차단하지 않습니다
* (클라이언트 평가기가 인터프리터로 안전하게 실행).
*/
class SafeLayoutExpressions implements ValidationRule
{
/**
* 위험 표현식 토큰 패턴 (문자열 값 대상)
*
* @var array<string>
*/
private const DANGEROUS_PATTERNS = [
// 프로토타입/생성자 체인 접근 (dot)
'/\.\s*(constructor|__proto__|prototype)\b/i',
// 프로토타입/생성자 체인 접근 (문자열 리터럴 computed 키) — 중첩 배열 키
// `[['constructor']]` 도 내부 `['constructor']` 가 매칭된다.
'/\[\s*[\'"](constructor|__proto__|prototype)[\'"]\s*\]/i',
// Object 리플렉션 static 호출 — 프로토타입/디스크립터를 읽어 Function 도달·프로토타입
// 오염 경로. **호출 위치(뒤에 `(`)만** 매칭해 안내 문구의 단순 단어 언급은 오탐하지 않는다.
// 리플렉션 인자 `getOwnPropertyDescriptor(x, 'constructor')` 는 이 메서드명이 반드시
// 동반되므로 여기서 잡히고, 금지 프로퍼티를 그냥 문자열로 비교하는 정상 표현식
// (`{{ mode === 'prototype' }}` — 런타임 평가기가 허용)은 차단하지 않는다.
'/\b(getPrototypeOf|setPrototypeOf|getOwnPropertyDescriptors?|defineProperty|defineProperties)\s*\(/i',
// 함수 생성자 / eval 호출
'/\bFunction\s*\(/',
'/\beval\s*\(/',
// 동적 import() — 원격 ES 모듈 로드/코드 실행 경로 (런타임 AST 평가기와 저장측 패리티)
'/\bimport\s*\(/',
// 원시 __proto__ 식별자
'/\b__proto__\b/',
// legacy 접근자 4종 — 프로퍼티를 **문자열 인자**로 지목해 프로토타입을 읽고 쓴다.
// Object 리플렉션 static 과 같은 능력을 모든 객체가 상속으로 제공하므로 같은 강도로
// 막는다. 배포 레이아웃 전수에서 사용 0건이라 정상 표현식 회귀가 없다.
'/\b__(lookup|define)(Getter|Setter)__\b/',
];
// 주의: 문자열 조립 난독화(`['const' + 'ructor']`)는 정적 토큰 매칭으로 잡을 수 없다.
// 그 형태의 최종 방어는 런타임 화이트리스트 인터프리터(SafeExpressionEvaluator)가
// 담당한다 — 키를 1회 정규화(String 강제변환)한 뒤 금지 프로퍼티를 차단하므로,
// 조립·배열·toString 강제변환 등 모든 우회 형태가 접근 시점에 거부된다. 이 저장측
// 규칙은 리터럴·리플렉션 형태를 저장 단계에서 조기 차단하는 심층 방어 계층이다.
/**
* 신뢰 외부 스크립트 호스트 캐시 (검증 1회당 집계 1회).
*
* @var array<int, string>|null
*/
private ?array $trustedHosts = null;
/**
* 검증 수행
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
$this->walk($value, $fail);
}
/**
* 레이아웃 JSON 트리를 재귀 순회하며 문자열 값을 검사합니다.
*
* @param array<mixed> $node 현재 노드
* @param Closure $fail 검증 실패 콜백
*/
private function walk(array $node, Closure $fail): void
{
foreach ($node as $key => $item) {
// 백틱 템플릿 리터럴을 포함한 위험 표현식 토큰 검사
if (is_string($item)) {
$this->assertSafeExpression($item, $fail);
continue;
}
if (! is_array($item)) {
continue;
}
// scripts[].src same-origin 검증
if ($key === 'scripts') {
$this->assertSameOriginList($item, 'src', $fail);
}
// data_sources[].endpoint same-origin 검증
if ($key === 'data_sources') {
$this->assertSameOriginList($item, 'endpoint', $fail);
}
$this->walk($item, $fail);
}
}
/**
* 문자열 표현식에 위험 토큰이 포함되어 있으면 검증을 실패시킵니다.
*
* @param string $value 검사 대상 문자열
* @param Closure $fail 검증 실패 콜백
*/
private function assertSafeExpression(string $value, Closure $fail): void
{
// 백틱 템플릿 리터럴은 차단하지 않는다 — 레이아웃이 내비게이션 경로 조립 등에
// 정상적으로 사용하며(예: `/mypage/${$args[0]}`), `${...}` 는 클라이언트
// 평가기가 인터프리터로 안전하게 해석한다. constructor/Function 등 실제 위험
// 토큰만 아래에서 거부한다.
foreach (self::DANGEROUS_PATTERNS as $pattern) {
if (preg_match($pattern, $value) === 1) {
$fail(__('validation.layout.dangerous_expression', ['snippet' => mb_substr($value, 0, 80)]));
return;
}
}
}
/**
* scripts/data_sources 배열의 지정 필드가 same-origin path-only 인지 검증합니다.
*
* @param array<mixed> $list scripts 또는 data_sources 배열
* @param string $field 검사할 필드명 (src | endpoint)
* @param Closure $fail 검증 실패 콜백
*/
private function assertSameOriginList(array $list, string $field, Closure $fail): void
{
foreach ($list as $entry) {
if (! is_array($entry) || ! isset($entry[$field]) || ! is_string($entry[$field])) {
continue;
}
$url = trim($entry[$field]);
if ($url === '') {
continue;
}
// 표현식 바인딩(`{{...}}`)은 런타임 해석 대상이라 여기서 판정하지 않는다.
if (str_starts_with($url, '{{')) {
continue;
}
// same-origin path 이거나, 확장이 선언한 신뢰 호스트면 허용. 그 외 외부 origin 차단.
if (! $this->isSameOriginPath($url)
&& ! TrustedScriptHosts::isTrustedUrl($url, $this->trustedHosts())) {
$fail(__('validation.layout.external_resource_url', ['url' => $url]));
return;
}
}
}
/**
* 신뢰 외부 스크립트 호스트 목록을 반환합니다 (검증 1회당 1회 집계 후 캐시).
*
* @return array<int, string> 신뢰 호스트명 목록
*/
private function trustedHosts(): array
{
return $this->trustedHosts ??= TrustedScriptHosts::hosts();
}
/**
* same-origin path-only URL 인지 판정합니다.
*
* 허용: `/` 로 시작하는 경로. 차단: `//`(protocol-relative), scheme 포함 절대 URL.
*
* @param string $url 검사 대상 URL
* @return bool same-origin path 이면 true
*/
private function isSameOriginPath(string $url): bool
{
$normalized = self::normalizeForOriginCheck($url);
// protocol-relative (`//evil.com/...`) 차단
if (str_starts_with($normalized, '//')) {
return false;
}
// scheme 포함 절대 URL(`https://`, `javascript:`, `data:` 등) 차단
if (preg_match('/^[a-z][a-z0-9+.\-]*:/i', $normalized) === 1) {
return false;
}
// path-only: `/` 로 시작해야 same-origin 절대 경로
return str_starts_with($normalized, '/');
}
/**
* origin 판정 전에 URL 을 브라우저 URL 파서와 동일하게 정규화합니다.
*
* 문자열 접두 검사만으로는 authority 우회를 막지 못합니다. 브라우저(WHATWG URL)는
* 파싱 전에 ASCII tab·개행을 제거하고, special scheme(http/https)에서 백슬래시를
* 슬래시와 동등하게 처리하기 때문입니다. 따라서 `/\/evil.com/x.js` ·
* `/{tab}/evil.com/x.js` 는 `//` 로 시작하지 않는데도 실제로는
* `https://evil.com/x.js` 로 해석됩니다.
*
* 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
* 않으므로 그대로 통과합니다(과차단 없음).
*
* 클라이언트(`TemplateApp.isAllowedScriptSrc`)·정적 검사
* (`layout-scripts-src-same-origin`)와 3층 동형이어야 합니다.
*
* 구현 SSoT 는 `TrustedScriptHosts::normalizeForOriginCheck` 입니다 — 같은 저장측
* 판정 안에서 same-origin 검사(이 규칙)와 신뢰 호스트 검사(`TrustedScriptHosts`)가
* 이어 붙으므로, 두 검사가 서로 다른 정규화를 쓰면 한 URL 이 "path 도 아니고
* 외부 호스트도 아닌" 상태로 빠져나간다. 위임으로 그 갈림을 구조적으로 막는다.
*
* @param string $url 원본 URL
* @return string 정규화된 URL
*/
public static function normalizeForOriginCheck(string $url): string
{
return TrustedScriptHosts::normalizeForOriginCheck($url);
}
}

Some files were not shown because too many files have changed in this diff Show More