diff --git a/.env.example b/.env.example
index 46373dbb..373dd7dd 100644
--- a/.env.example
+++ b/.env.example
@@ -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}"
diff --git a/.env.testing.example b/.env.testing.example
index ecfb2ee2..6aaace74 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -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}"
diff --git a/AGENTS.md b/AGENTS.md
index bd3ef5a0..8dd1a4df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
@@ -355,6 +358,21 @@ Icon 은 `` 글리프라 박스 크기가 곧 `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 은 `` 글리프라 박스 크기가 곧 `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` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a82be45..45dccead 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/INSTALL.md b/INSTALL.md
index 0a5d6d57..802b5d98 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -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
diff --git a/README.ko.md b/README.ko.md
index 4286e9a8..06a43295 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,7 @@
-
+
@@ -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
+
+
+
-
diff --git a/README.md b/README.md
index c6059483..3410c40b 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -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 —
+
+
+
-
diff --git a/app/Console/Commands/ExpireIdentityChallengesCommand.php b/app/Console/Commands/ExpireIdentityChallengesCommand.php
new file mode 100644
index 00000000..422ffedd
--- /dev/null
+++ b/app/Console/Commands/ExpireIdentityChallengesCommand.php
@@ -0,0 +1,39 @@
+expirePastDue();
+
+ $this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/Module/ClearModuleCacheCommand.php b/app/Console/Commands/Module/ClearModuleCacheCommand.php
index 6c2d5362..5acd7b49 100644
--- a/app/Console/Commands/Module/ClearModuleCacheCommand.php
+++ b/app/Console/Commands/Module/ClearModuleCacheCommand.php
@@ -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();
}
}
diff --git a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
index 0dd0aa42..8e3bcb22 100644
--- a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
+++ b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php
@@ -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();
}
}
diff --git a/app/Console/Commands/PruneActivityLogsCommand.php b/app/Console/Commands/PruneActivityLogsCommand.php
new file mode 100644
index 00000000..f0c35bbf
--- /dev/null
+++ b/app/Console/Commands/PruneActivityLogsCommand.php
@@ -0,0 +1,41 @@
+option('days');
+ $deleted = $service->prune($days);
+
+ $this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PruneIdentityLogsCommand.php b/app/Console/Commands/PruneIdentityLogsCommand.php
new file mode 100644
index 00000000..13e8f655
--- /dev/null
+++ b/app/Console/Commands/PruneIdentityLogsCommand.php
@@ -0,0 +1,43 @@
+option('days');
+ $purged = $service->purge($days);
+
+ $this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PruneNotificationLogsCommand.php b/app/Console/Commands/PruneNotificationLogsCommand.php
new file mode 100644
index 00000000..f75220c0
--- /dev/null
+++ b/app/Console/Commands/PruneNotificationLogsCommand.php
@@ -0,0 +1,41 @@
+option('days');
+ $deleted = $service->prune($days);
+
+ $this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PruneOrphanAttachmentsCommand.php b/app/Console/Commands/PruneOrphanAttachmentsCommand.php
new file mode 100644
index 00000000..a4ef255e
--- /dev/null
+++ b/app/Console/Commands/PruneOrphanAttachmentsCommand.php
@@ -0,0 +1,123 @@
+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);
+ }
+}
diff --git a/app/Console/Commands/PruneScheduleHistoryCommand.php b/app/Console/Commands/PruneScheduleHistoryCommand.php
new file mode 100644
index 00000000..f7e88717
--- /dev/null
+++ b/app/Console/Commands/PruneScheduleHistoryCommand.php
@@ -0,0 +1,43 @@
+option('days');
+ $deleted = $service->pruneHistory($days);
+
+ $this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PruneSeoCacheStatsCommand.php b/app/Console/Commands/PruneSeoCacheStatsCommand.php
new file mode 100644
index 00000000..5902b71b
--- /dev/null
+++ b/app/Console/Commands/PruneSeoCacheStatsCommand.php
@@ -0,0 +1,43 @@
+option('days');
+ $deleted = $service->cleanup($days);
+
+ $this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PruneStorageLeftoversCommand.php b/app/Console/Commands/PruneStorageLeftoversCommand.php
new file mode 100644
index 00000000..2f46d720
--- /dev/null
+++ b/app/Console/Commands/PruneStorageLeftoversCommand.php
@@ -0,0 +1,81 @@
+
+ */
+ 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;
+ }
+}
diff --git a/app/Console/Commands/Template/ClearTemplateCacheCommand.php b/app/Console/Commands/Template/ClearTemplateCacheCommand.php
index ca13ec43..cc52d257 100644
--- a/app/Console/Commands/Template/ClearTemplateCacheCommand.php
+++ b/app/Console/Commands/Template/ClearTemplateCacheCommand.php
@@ -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', [
diff --git a/app/Console/Commands/Traits/HasUnifiedConfirm.php b/app/Console/Commands/Traits/HasUnifiedConfirm.php
index bac68bf4..f549b057 100644
--- a/app/Console/Commands/Traits/HasUnifiedConfirm.php
+++ b/app/Console/Commands/Traits/HasUnifiedConfirm.php
@@ -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);
+ }
}
diff --git a/app/Contracts/Extension/CacheableExtensionInterface.php b/app/Contracts/Extension/CacheableExtensionInterface.php
index 82bae277..29f70d15 100644
--- a/app/Contracts/Extension/CacheableExtensionInterface.php
+++ b/app/Contracts/Extension/CacheableExtensionInterface.php
@@ -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;
+
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
diff --git a/app/Contracts/Extension/StorageInterface.php b/app/Contracts/Extension/StorageInterface.php
index efc2a8d9..f1b30113 100644
--- a/app/Contracts/Extension/StorageInterface.php
+++ b/app/Contracts/Extension/StorageInterface.php
@@ -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;
}
diff --git a/app/Contracts/Extension/TemplateManagerInterface.php b/app/Contracts/Extension/TemplateManagerInterface.php
index 44faaacc..f0e4d785 100644
--- a/app/Contracts/Extension/TemplateManagerInterface.php
+++ b/app/Contracts/Extension/TemplateManagerInterface.php
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
- * 모든 템플릿을 로드하고 초기화합니다.
+ * 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
*/
public function loadTemplates(): void;
+ /**
+ * 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
+ *
+ * "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
+ * (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
+ */
+ public function ensureLoaded(): void;
+
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
diff --git a/app/Contracts/Repositories/ActivityLogRepositoryInterface.php b/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
index 871d08fa..3bb3bb64 100644
--- a/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
+++ b/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
@@ -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;
}
diff --git a/app/Contracts/Repositories/AttachmentRepositoryInterface.php b/app/Contracts/Repositories/AttachmentRepositoryInterface.php
index f62684cb..471c653e 100644
--- a/app/Contracts/Repositories/AttachmentRepositoryInterface.php
+++ b/app/Contracts/Repositories/AttachmentRepositoryInterface.php
@@ -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 $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
+ * @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
+ */
+ public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
}
diff --git a/app/Contracts/Repositories/LanguagePackRepositoryInterface.php b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
index accb74c2..76ed8893 100644
--- a/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
+++ b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
public function getActiveCoreLocales(): array;
/**
- * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
+ *
+ * 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
+ * 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
*
* @param array $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 설치된 전체 언어팩
+ */
+ public function allForUpdateCheck(): Collection;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
diff --git a/app/Contracts/Repositories/MenuRepositoryInterface.php b/app/Contracts/Repositories/MenuRepositoryInterface.php
index 14b26a0d..0a2ae822 100644
--- a/app/Contracts/Repositories/MenuRepositoryInterface.php
+++ b/app/Contracts/Repositories/MenuRepositoryInterface.php
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
*/
public function findById(int $id): ?Menu;
+ /**
+ * 여러 ID로 메뉴를 한 번에 조회합니다.
+ *
+ * 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
+ * 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
+ *
+ * @param array $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;
+
/**
* 새로운 메뉴를 생성합니다.
*
diff --git a/app/Contracts/Repositories/NotificationLogRepositoryInterface.php b/app/Contracts/Repositories/NotificationLogRepositoryInterface.php
index 0673c73d..86190df2 100644
--- a/app/Contracts/Repositories/NotificationLogRepositoryInterface.php
+++ b/app/Contracts/Repositories/NotificationLogRepositoryInterface.php
@@ -40,6 +40,14 @@ interface NotificationLogRepositoryInterface
*/
public function bulkDelete(array $ids): int;
+ /**
+ * 보존 기간이 지난 발송 이력을 삭제합니다.
+ *
+ * @param int $days 보존 기간 (일)
+ * @return int 삭제된 건수
+ */
+ public function deleteOlderThan(int $days): int;
+
/**
* 페이지네이션 목록 조회.
*
diff --git a/app/Contracts/Repositories/PermissionRepositoryInterface.php b/app/Contracts/Repositories/PermissionRepositoryInterface.php
index f4ea99e0..77bbacbd 100644
--- a/app/Contracts/Repositories/PermissionRepositoryInterface.php
+++ b/app/Contracts/Repositories/PermissionRepositoryInterface.php
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Permission;
+ /**
+ * 여러 ID로 권한을 일괄 조회합니다.
+ *
+ * @param array $ids 권한 ID 배열
+ * @return Collection 권한 컬렉션 (ID 기준)
+ */
+ public function getByIds(array $ids): Collection;
+
/**
* 새로운 권한을 생성합니다.
*
diff --git a/app/Exceptions/CannotModifyProtectedRoleException.php b/app/Exceptions/CannotModifyProtectedRoleException.php
new file mode 100644
index 00000000..6580858a
--- /dev/null
+++ b/app/Exceptions/CannotModifyProtectedRoleException.php
@@ -0,0 +1,23 @@
+
+ */
+ 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));
+ }
}
/**
diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php
index 66fea241..5bdfde59 100644
--- a/app/Extension/AbstractModule.php
+++ b/app/Extension/AbstractModule.php
@@ -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 신뢰 호스트명 목록 (예: ['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
+ */
+ 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;
+ }
+
/**
* 모듈 캐시 드라이버 인스턴스 반환
*
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index 1d41aa54..380ef150 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -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 신뢰 호스트명 목록 (예: ['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
+ */
+ 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;
+ }
+
/**
* 플러그인 캐시 드라이버 인스턴스 반환
*
diff --git a/app/Extension/BaseModuleServiceProvider.php b/app/Extension/BaseModuleServiceProvider.php
index ef70ba57..ef7b9325 100644
--- a/app/Extension/BaseModuleServiceProvider.php
+++ b/app/Extension/BaseModuleServiceProvider.php
@@ -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();
+ }
}
diff --git a/app/Extension/BasePluginServiceProvider.php b/app/Extension/BasePluginServiceProvider.php
index f9efaae4..4c7bca0f 100644
--- a/app/Extension/BasePluginServiceProvider.php
+++ b/app/Extension/BasePluginServiceProvider.php
@@ -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();
+ }
}
diff --git a/app/Extension/Helpers/ExtensionPendingHelper.php b/app/Extension/Helpers/ExtensionPendingHelper.php
index fc02ec2f..a3e45d8b 100644
--- a/app/Extension/Helpers/ExtensionPendingHelper.php
+++ b/app/Extension/Helpers/ExtensionPendingHelper.php
@@ -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
{
diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php
index 192185ff..3c53cf25 100644
--- a/app/Extension/PluginManager.php
+++ b/app/Extension/PluginManager.php
@@ -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);
diff --git a/app/Extension/Storage/Concerns/ResolvesPublicUrl.php b/app/Extension/Storage/Concerns/ResolvesPublicUrl.php
new file mode 100644
index 00000000..1b125e2d
--- /dev/null
+++ b/app/Extension/Storage/Concerns/ResolvesPublicUrl.php
@@ -0,0 +1,70 @@
+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;
+}
diff --git a/app/Extension/Storage/CoreStorageDriver.php b/app/Extension/Storage/CoreStorageDriver.php
index 9e8a7fbd..402de7a7 100644
--- a/app/Extension/Storage/CoreStorageDriver.php
+++ b/app/Extension/Storage/CoreStorageDriver.php
@@ -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);
diff --git a/app/Extension/Storage/ModuleStorageDriver.php b/app/Extension/Storage/ModuleStorageDriver.php
index 17c80b82..e48f7805 100644
--- a/app/Extension/Storage/ModuleStorageDriver.php
+++ b/app/Extension/Storage/ModuleStorageDriver.php
@@ -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);
diff --git a/app/Extension/Storage/PluginStorageDriver.php b/app/Extension/Storage/PluginStorageDriver.php
index 4a892b48..7d836a91 100644
--- a/app/Extension/Storage/PluginStorageDriver.php
+++ b/app/Extension/Storage/PluginStorageDriver.php
@@ -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);
diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php
index 15b2a6b2..6f13c8c1 100644
--- a/app/Extension/TemplateManager.php
+++ b/app/Extension/TemplateManager.php
@@ -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() 호출됨
diff --git a/app/Extension/Traits/InvalidatesLayoutCache.php b/app/Extension/Traits/InvalidatesLayoutCache.php
index 57e5e704..ba4ba526 100644
--- a/app/Extension/Traits/InvalidatesLayoutCache.php
+++ b/app/Extension/Traits/InvalidatesLayoutCache.php
@@ -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}";
}
}
diff --git a/app/Helpers/PermissionHelper.php b/app/Helpers/PermissionHelper.php
index de3656c4..642e0ccc 100644
--- a/app/Helpers/PermissionHelper.php
+++ b/app/Helpers/PermissionHelper.php
@@ -204,6 +204,38 @@ class PermissionHelper
return false;
}
+ /**
+ * 스코프 접근이 허용되는 모델만 남긴 배열을 반환합니다 (정적 일괄 라우트용).
+ *
+ * `PermissionMiddleware` 의 스코프 검사는 라우트에서 모델이 resolve 될 때만
+ * 동작합니다 — 모델이 없으면 목록 엔드포인트로 보아 건너뜁니다. 따라서
+ * `{user}` 같은 파라미터가 없는 **정적 일괄 라우트**(예: `PATCH users/bulk-status`)
+ * 에서는 스코프 검사가 통째로 우회됩니다. 상세 경로가 403 으로 막는 대상을
+ * 일괄 경로로는 바꿀 수 있으면 그 경로가 우회로이므로, 서비스 계층에서
+ * 같은 판정(`checkScopeAccess`)을 재적용해야 합니다.
+ *
+ * 판정은 대상별로 이뤄집니다 — 액터의 유효 스코프가 self 면 자기 소유만,
+ * role 이면 같은 역할 범위까지, 미지정(글로벌)이면 전체가 통과합니다.
+ *
+ * @param iterable $models 검사 대상 모델 목록
+ * @param string $permission 권한 식별자
+ * @param User|null $user 사용자 (null이면 현재 인증 사용자)
+ * @return array 스코프 접근이 허용된 모델 목록
+ */
+ 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 캐시와 함께 조회합니다.
*
diff --git a/app/Helpers/settings_helpers.php b/app/Helpers/settings_helpers.php
index b6b00000..767c1160 100644
--- a/app/Helpers/settings_helpers.php
+++ b/app/Helpers/settings_helpers.php
@@ -1,5 +1,6 @@
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');
diff --git a/app/Http/Controllers/Api/Admin/AttachmentController.php b/app/Http/Controllers/Api/Admin/AttachmentController.php
index 76a2c737..4f78c239 100644
--- a/app/Http/Controllers/Api/Admin/AttachmentController.php
+++ b/app/Http/Controllers/Api/Admin/AttachmentController.php
@@ -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());
}
}
-
}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
index f4071a48..3f37d610 100644
--- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
@@ -1,5 +1,7 @@
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)
{
diff --git a/app/Http/Controllers/Api/Admin/LanguagePackController.php b/app/Http/Controllers/Api/Admin/LanguagePackController.php
index ec29467d..4dbcc9d9 100644
--- a/app/Http/Controllers/Api/Admin/LanguagePackController.php
+++ b/app/Http/Controllers/Api/Admin/LanguagePackController.php
@@ -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);
}
}
}
diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php
index 1672b8b0..e3453f53 100644
--- a/app/Http/Controllers/Api/Admin/MenuController.php
+++ b/app/Http/Controllers/Api/Admin/MenuController.php
@@ -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());
}
diff --git a/app/Http/Controllers/Api/Admin/NotificationController.php b/app/Http/Controllers/Api/Admin/NotificationController.php
index 5ee26214..a6251373 100644
--- a/app/Http/Controllers/Api/Admin/NotificationController.php
+++ b/app/Http/Controllers/Api/Admin/NotificationController.php
@@ -1,5 +1,7 @@
getSettingsSchema());
}
+ /**
+ * 설정 스키마가 public_asset_disk 키를 선언한 플러그인의 응답에
+ * 공개 자산 디스크 카탈로그(available_public_asset_disks)를 부착합니다.
+ *
+ * 화면이 /api/admin/settings(core.settings.read)를 따로 조회하게 두면 이
+ * 화면의 권한(core.plugins.read)과 표면이 갈려, 커스텀 역할에서 카탈로그만
+ * 조용히 비는 비대칭이 생깁니다. 설정 응답 단일 표면에서 함께 내려 권한 축을
+ * 일치시킵니다 (ecommerce 모듈 설정 응답의 동명 키와 동형 계약).
+ * 저장 시에는 스키마 기반 검증 whitelist 가 이 키를 걸러 설정에 남지 않습니다.
+ *
+ * @param string $identifier 플러그인 식별자
+ * @param array $settings 응답에 실을 설정
+ * @return array 카탈로그가 부착된 설정
+ */
+ 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) ?? [])
+ )
);
}
diff --git a/app/Http/Controllers/Api/Admin/RoleController.php b/app/Http/Controllers/Api/Admin/RoleController.php
index 7fce1960..b12bff66 100644
--- a/app/Http/Controllers/Api/Admin/RoleController.php
+++ b/app/Http/Controllers/Api/Admin/RoleController.php
@@ -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());
}
diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php
index c3e6d14f..3dc58098 100644
--- a/app/Http/Controllers/Api/Admin/SettingsController.php
+++ b/app/Http/Controllers/Api/Admin/SettingsController.php
@@ -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);
}
/**
diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php
index 65baa1b1..50ea649a 100644
--- a/app/Http/Controllers/Api/Admin/UserController.php
+++ b/app/Http/Controllers/Api/Admin/UserController.php
@@ -1,8 +1,12 @@
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());
diff --git a/app/Http/Controllers/Api/Auth/NotificationController.php b/app/Http/Controllers/Api/Auth/NotificationController.php
index 4ebe5c3f..5f5d0b3f 100644
--- a/app/Http/Controllers/Api/Auth/NotificationController.php
+++ b/app/Http/Controllers/Api/Auth/NotificationController.php
@@ -1,5 +1,7 @@
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);
}
}
}
diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php
index c8f4e2f5..3b489b6f 100644
--- a/app/Http/Controllers/Api/Base/BaseApiController.php
+++ b/app/Http/Controllers/Api/Base/BaseApiController.php
@@ -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 응답을 반환합니다 (캐싱 헤더 포함).
*
diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
index 2d898b0c..89fc494e 100644
--- a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
+++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
@@ -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,
diff --git a/app/Http/Controllers/Api/Public/PublicAttachmentController.php b/app/Http/Controllers/Api/Public/PublicAttachmentController.php
index b90dbd0e..186201be 100644
--- a/app/Http/Controllers/Api/Public/PublicAttachmentController.php
+++ b/app/Http/Controllers/Api/Public/PublicAttachmentController.php
@@ -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');
}
diff --git a/app/Http/Controllers/Api/Public/PublicLayoutController.php b/app/Http/Controllers/Api/Public/PublicLayoutController.php
index 77cf1e68..cecd2d5e 100644
--- a/app/Http/Controllers/Api/Public/PublicLayoutController.php
+++ b/app/Http/Controllers/Api/Public/PublicLayoutController.php
@@ -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());
}
diff --git a/app/Http/Controllers/Api/Public/PublicSearchController.php b/app/Http/Controllers/Api/Public/PublicSearchController.php
index 5bcf8cce..ab9d6835 100644
--- a/app/Http/Controllers/Api/Public/PublicSearchController.php
+++ b/app/Http/Controllers/Api/Public/PublicSearchController.php
@@ -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 카테고리 => 실패 여부
+ */
+ private function resolveCategoryFailures(array $results): array
+ {
+ $failures = [];
+
+ foreach ($results as $category => $categoryData) {
+ $failures[$category] = ($categoryData['failed'] ?? false) === true;
+ }
+
+ return $failures;
+ }
+
/**
* 응답 전체의 총 건수 정확도를 정합니다.
*
diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php
index 13d39d9e..b51cbd3b 100644
--- a/app/Http/Controllers/Api/Public/PublicTemplateController.php
+++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php
@@ -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)
);
}
diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php
index c1d80aa2..fd60ee35 100644
--- a/app/Http/Middleware/SetLocale.php
+++ b/app/Http/Middleware/SetLocale.php
@@ -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;
}
}
diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php
index 2b25a1e6..cb91f0d7 100644
--- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php
+++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php
@@ -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 필드 => 표시명
+ */
+ 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;
+ }
}
diff --git a/app/Http/Requests/Layout/StoreLayoutRequest.php b/app/Http/Requests/Layout/StoreLayoutRequest.php
index 2213d13a..e11f1627 100644
--- a/app/Http/Requests/Layout/StoreLayoutRequest.php
+++ b/app/Http/Requests/Layout/StoreLayoutRequest.php
@@ -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>
+ * @return array|string>
*/
public function rules(): array
{
@@ -67,6 +71,8 @@ class StoreLayoutRequest extends FormRequest
new WhitelistedEndpoint,
// 4. 외부 URL 차단
new NoExternalUrls,
+ // 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
+ new SafeLayoutExpressions,
],
];
diff --git a/app/Http/Requests/Layout/UpdateLayoutContentRequest.php b/app/Http/Requests/Layout/UpdateLayoutContentRequest.php
index 3d53ac34..48cfd551 100644
--- a/app/Http/Requests/Layout/UpdateLayoutContentRequest.php
+++ b/app/Http/Requests/Layout/UpdateLayoutContentRequest.php
@@ -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) {
diff --git a/app/Http/Requests/Layout/UpdateLayoutExtensionContentRequest.php b/app/Http/Requests/Layout/UpdateLayoutExtensionContentRequest.php
index f2d7e745..9a003783 100644
--- a/app/Http/Requests/Layout/UpdateLayoutExtensionContentRequest.php
+++ b/app/Http/Requests/Layout/UpdateLayoutExtensionContentRequest.php
@@ -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',
diff --git a/app/Http/Requests/Layout/UpdateLayoutRequest.php b/app/Http/Requests/Layout/UpdateLayoutRequest.php
index 5278f30a..0d187a1b 100644
--- a/app/Http/Requests/Layout/UpdateLayoutRequest.php
+++ b/app/Http/Requests/Layout/UpdateLayoutRequest.php
@@ -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>
+ * @return array|string>
*/
public function rules(): array
{
@@ -71,6 +75,8 @@ class UpdateLayoutRequest extends FormRequest
new WhitelistedEndpoint,
// 4. 외부 URL 차단
new NoExternalUrls,
+ // 5. 표현식 샌드박스 우회/원격 스크립트 저장측 차단
+ new SafeLayoutExpressions,
],
];
diff --git a/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php b/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php
new file mode 100644
index 00000000..581f2a5e
--- /dev/null
+++ b/app/Http/Requests/Public/Attachment/DownloadAttachmentRequest.php
@@ -0,0 +1,34 @@
+ 검증 규칙 배열
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Role/ActiveRolesRequest.php b/app/Http/Requests/Role/ActiveRolesRequest.php
new file mode 100644
index 00000000..f44b88cf
--- /dev/null
+++ b/app/Http/Requests/Role/ActiveRolesRequest.php
@@ -0,0 +1,36 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index ddcd5384..a7da06c5 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -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'),
];
}
}
diff --git a/app/Http/Requests/Settings/TestDriverConnectionRequest.php b/app/Http/Requests/Settings/TestDriverConnectionRequest.php
index 45968f99..4d57cbb3 100644
--- a/app/Http/Requests/Settings/TestDriverConnectionRequest.php
+++ b/app/Http/Requests/Settings/TestDriverConnectionRequest.php
@@ -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>
+ * @return array|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),
+ ]));
+ }
+ };
+ }
}
diff --git a/app/Http/Requests/Settings/TestMailRequest.php b/app/Http/Requests/Settings/TestMailRequest.php
index 4dc54069..4eb43e25 100644
--- a/app/Http/Requests/Settings/TestMailRequest.php
+++ b/app/Http/Requests/Settings/TestMailRequest.php
@@ -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>
+ * @return array|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
+ */
+ 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'),
+ ];
+ }
}
diff --git a/app/Http/Requests/Settings/UpdateSettingRequest.php b/app/Http/Requests/Settings/UpdateSettingRequest.php
index 22b3cacb..039c6d4c 100644
--- a/app/Http/Requests/Settings/UpdateSettingRequest.php
+++ b/app/Http/Requests/Settings/UpdateSettingRequest.php
@@ -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>
+ * @return array|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'),
];
}
}
diff --git a/app/Http/Requests/User/BulkUpdateUserStatusRequest.php b/app/Http/Requests/User/BulkUpdateUserStatusRequest.php
index cd5d4ab7..3657586a 100644
--- a/app/Http/Requests/User/BulkUpdateUserStatusRequest.php
+++ b/app/Http/Requests/User/BulkUpdateUserStatusRequest.php
@@ -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>
+ * @return array|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
+ */
+ public function attributes(): array
+ {
+ return [
+ 'ids' => __('validation.attributes.user_ids'),
+ 'ids.*' => __('validation.attributes.user_id'),
+ ];
+ }
}
diff --git a/app/Http/Resources/UserCollection.php b/app/Http/Resources/UserCollection.php
index 3cb1f996..d032b83e 100644
--- a/app/Http/Resources/UserCollection.php
+++ b/app/Http/Resources/UserCollection.php
@@ -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(),
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
index 3c397b3b..969d7ed6 100644
--- a/app/Http/Resources/UserResource.php
+++ b/app/Http/Resources/UserResource.php
@@ -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',
];
}
diff --git a/app/Http/View/Composers/TemplateComposer.php b/app/Http/View/Composers/TemplateComposer.php
index 716dfbd1..01cef569 100644
--- a/app/Http/View/Composers/TemplateComposer.php
+++ b/app/Http/View/Composers/TemplateComposer.php
@@ -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);
}
}
diff --git a/app/Http/View/Composers/UserTemplateComposer.php b/app/Http/View/Composers/UserTemplateComposer.php
index 5188e100..4205a4c3 100644
--- a/app/Http/View/Composers/UserTemplateComposer.php
+++ b/app/Http/View/Composers/UserTemplateComposer.php
@@ -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);
}
}
diff --git a/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php b/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php
index 814dbd24..bdb67fe4 100644
--- a/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php
+++ b/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php
@@ -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();
}
}
diff --git a/app/Listeners/SeoSettingsCacheListener.php b/app/Listeners/SeoSettingsCacheListener.php
index cc5aa557..0669ad68 100644
--- a/app/Listeners/SeoSettingsCacheListener.php
+++ b/app/Listeners/SeoSettingsCacheListener.php
@@ -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(),
diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php
index 2c67c9ad..ac3a4378 100644
--- a/app/Models/Attachment.php
+++ b/app/Models/Attachment.php
@@ -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;
}
/**
diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php
index 3ba4d595..971381d0 100644
--- a/app/Models/Schedule.php
+++ b/app/Models/Schedule.php
@@ -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;
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 6e365a0e..379ef4b8 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -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 */
use HasApiTokens, HasFactory, Notifiable;
+ /**
+ * 닉네임 컬럼 최대 길이 (마이그레이션에서 명시한 값)
+ */
+ public const NICKNAME_MAX_LENGTH = 50;
+
/** @var 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;
+ }
}
diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php
index a7064352..5254d16a 100644
--- a/app/Providers/CoreServiceProvider.php
+++ b/app/Providers/CoreServiceProvider.php
@@ -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}'로 폴백합니다.");
diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php
index 678f4846..05e37e67 100644
--- a/app/Providers/SettingsServiceProvider.php
+++ b/app/Providers/SettingsServiceProvider.php
@@ -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'])) {
diff --git a/app/Repositories/ActivityLogRepository.php b/app/Repositories/ActivityLogRepository.php
index 15418f77..5381cf63 100644
--- a/app/Repositories/ActivityLogRepository.php
+++ b/app/Repositories/ActivityLogRepository.php
@@ -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)))
+ );
+ }
}
diff --git a/app/Repositories/AttachmentRepository.php b/app/Repositories/AttachmentRepository.php
index bc9b2669..c53ed6b6 100644
--- a/app/Repositories/AttachmentRepository.php
+++ b/app/Repositories/AttachmentRepository.php
@@ -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 $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']);
+ }
}
diff --git a/app/Repositories/Concerns/DeletesInBatches.php b/app/Repositories/Concerns/DeletesInBatches.php
new file mode 100644
index 00000000..843ef83d
--- /dev/null
+++ b/app/Repositories/Concerns/DeletesInBatches.php
@@ -0,0 +1,59 @@
+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;
+ }
+ }
+ }
+}
diff --git a/app/Repositories/IdentityVerificationLogRepository.php b/app/Repositories/IdentityVerificationLogRepository.php
index d62ec2f3..1dd21814 100644
--- a/app/Repositories/IdentityVerificationLogRepository.php
+++ b/app/Repositories/IdentityVerificationLogRepository.php
@@ -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)))
+ );
}
/**
diff --git a/app/Repositories/LanguagePackRepository.php b/app/Repositories/LanguagePackRepository.php
index 426408b1..183f47f8 100644
--- a/app/Repositories/LanguagePackRepository.php
+++ b/app/Repositories/LanguagePackRepository.php
@@ -125,16 +125,34 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
}
/**
- * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* @param array $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 설치된 전체 언어팩
+ */
+ public function allForUpdateCheck(): Collection
+ {
+ // audit:allow query-unbounded-get reason: 언어팩은 운영자가 설치한 팩 수만큼만 존재하는
+ // 설정성 테이블이다 (사용량과 무관) — 대용량 목록 페이지네이션 규정의 설정성
+ // 테이블 예외 조항 (docs/backend/pagination.md)
+ return LanguagePack::query()->orderBy('identifier')->get();
}
/**
diff --git a/app/Repositories/MenuRepository.php b/app/Repositories/MenuRepository.php
index b9d5b17b..35b4cda6 100644
--- a/app/Repositories/MenuRepository.php
+++ b/app/Repositories/MenuRepository.php
@@ -131,6 +131,21 @@ class MenuRepository implements MenuRepositoryInterface
return Menu::with(['creator', 'parent', 'children'])->find($id);
}
+ /**
+ * 여러 ID로 메뉴를 한 번에 조회합니다.
+ *
+ * @param array $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();
+ }
+
/**
* 새로운 메뉴를 생성합니다.
*
diff --git a/app/Repositories/NotificationLogRepository.php b/app/Repositories/NotificationLogRepository.php
index 52415dc6..dfa247ee 100644
--- a/app/Repositories/NotificationLogRepository.php
+++ b/app/Repositories/NotificationLogRepository.php
@@ -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)))
+ );
+ }
+
/**
* 페이지네이션 목록 조회.
*
diff --git a/app/Repositories/PermissionRepository.php b/app/Repositories/PermissionRepository.php
index 84810e1c..38f11496 100644
--- a/app/Repositories/PermissionRepository.php
+++ b/app/Repositories/PermissionRepository.php
@@ -30,6 +30,21 @@ class PermissionRepository implements PermissionRepositoryInterface
return Permission::find($id);
}
+ /**
+ * 여러 ID로 권한을 일괄 조회합니다.
+ *
+ * @param array $ids 권한 ID 배열
+ * @return Collection 권한 컬렉션 (ID 기준)
+ */
+ public function getByIds(array $ids): Collection
+ {
+ if (empty($ids)) {
+ return new Collection;
+ }
+
+ return Permission::whereIn('id', $ids)->get();
+ }
+
/**
* 식별자로 권한을 찾습니다.
*
diff --git a/app/Repositories/ScheduleHistoryRepository.php b/app/Repositories/ScheduleHistoryRepository.php
index 10cc4909..df7042f8 100644
--- a/app/Repositories/ScheduleHistoryRepository.php
+++ b/app/Repositories/ScheduleHistoryRepository.php
@@ -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)))
+ );
}
}
diff --git a/app/Repositories/SeoCacheStatRepository.php b/app/Repositories/SeoCacheStatRepository.php
index a6bb968e..d35f9e7d 100644
--- a/app/Repositories/SeoCacheStatRepository.php
+++ b/app/Repositories/SeoCacheStatRepository.php
@@ -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));
}
/**
diff --git a/app/Rules/AllowedShellCommand.php b/app/Rules/AllowedShellCommand.php
index 2a7723dd..f5cb09ae 100644
--- a/app/Rules/AllowedShellCommand.php
+++ b/app/Rules/AllowedShellCommand.php
@@ -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));
}
}
diff --git a/app/Rules/NoExternalUrls.php b/app/Rules/NoExternalUrls.php
index 9ec5bee6..9f3ea534 100644
--- a/app/Rules/NoExternalUrls.php
+++ b/app/Rules/NoExternalUrls.php
@@ -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;
diff --git a/app/Rules/SafeLayoutExpressions.php b/app/Rules/SafeLayoutExpressions.php
new file mode 100644
index 00000000..794045f4
--- /dev/null
+++ b/app/Rules/SafeLayoutExpressions.php
@@ -0,0 +1,238 @@
+`)는 정상 표현식에서 광범위하게 사용되므로 차단하지 않습니다
+ * (클라이언트 평가기가 인터프리터로 안전하게 실행).
+ */
+class SafeLayoutExpressions implements ValidationRule
+{
+ /**
+ * 위험 표현식 토큰 패턴 (문자열 값 대상)
+ *
+ * @var array
+ */
+ 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|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 $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 $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 신뢰 호스트명 목록
+ */
+ 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);
+ }
+}
diff --git a/app/Search/Engines/DatabaseFulltextEngine.php b/app/Search/Engines/DatabaseFulltextEngine.php
index 77f7da54..de02d2a5 100644
--- a/app/Search/Engines/DatabaseFulltextEngine.php
+++ b/app/Search/Engines/DatabaseFulltextEngine.php
@@ -6,15 +6,18 @@ use App\Enums\TotalRelation;
use App\Search\Contracts\FulltextSearchable;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
+use App\Search\FulltextIndexInspector;
use App\Search\KeywordSearch;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\LazyCollection;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
+use Throwable;
/**
* MySQL FULLTEXT + ngram 커스텀 Scout 엔진
@@ -31,6 +34,27 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
*/
private static ?bool $isMariaDbCache = null;
+ /**
+ * FULLTEXT 인덱스 카탈로그 캐시 (프로세스 수명 동안 유지, lazy 적재)
+ *
+ * 프리픽스 제거·소문자 테이블명 => 인덱스별 컬럼 집합(소문자·정렬) 목록.
+ * null 은 미적재. 적재 실패(Throwable)는 캐시하지 않는다 — 일시 장애가
+ * 워커 수명 내내 LIKE 강등으로 굳는 것을 막기 위함이다.
+ *
+ * FPM 워커의 static 캐시이므로 배포로 인덱스가 추가돼도 반영은 워커 재시작
+ * (또는 `addFulltextIndex()` 경유 생성) 시점이다.
+ *
+ * @var array>>|null
+ */
+ private static ?array $fulltextIndexCatalog = null;
+
+ /**
+ * 인덱스 부재 폴백 경고를 조합당 한 번만 남기기 위한 기록
+ *
+ * @var array
+ */
+ private static array $warnedMissingIndexes = [];
+
/**
* 모델을 검색 인덱스에 업데이트합니다.
*
@@ -293,7 +317,7 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
$version = DB::selectOne('SELECT VERSION() as version');
return static::$isMariaDbCache = $version && str_contains(strtolower($version->version), 'mariadb');
- } catch (\Throwable) {
+ } catch (Throwable) {
return static::$isMariaDbCache = false;
}
}
@@ -301,6 +325,184 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
return static::$isMariaDbCache = false;
}
+ /**
+ * 대상 테이블·컬럼 조합을 커버하는 FULLTEXT 인덱스가 있는지 판정합니다.
+ *
+ * MySQL 의 `MATCH(a, b)` 는 어떤 FULLTEXT 인덱스의 컬럼 **집합과 정확히 일치**
+ * (순서 무관)해야 실행됩니다. 복합 (title,content) 인덱스는 `MATCH(title)` 단독을
+ * 커버하지 못합니다. 판정은 한정자 제거·소문자·정렬 정규화 후 집합 동등 비교입니다.
+ *
+ * @param string $table 테이블명 (프리픽스 포함/미포함 모두 허용)
+ * @param array $columns MATCH 대상 컬럼명
+ * @return bool 커버하는 인덱스가 있으면 true
+ */
+ public static function fulltextIndexCoversColumns(string $table, array $columns): bool
+ {
+ if (! static::supportsFulltext()) {
+ // sqlite/pgsql 등에서는 INFORMATION_SCHEMA 접근 자체를 하지 않는다.
+ return false;
+ }
+
+ $catalog = static::fulltextIndexCatalog();
+
+ if ($catalog === null) {
+ return false;
+ }
+
+ $wanted = self::normalizeColumnSet($columns);
+
+ if ($wanted === []) {
+ return false;
+ }
+
+ foreach (self::tableLookupKeys($table) as $key) {
+ foreach ($catalog[$key] ?? [] as $indexed) {
+ if ($indexed === $wanted) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 카탈로그를 lazy 적재해 반환합니다.
+ *
+ * @return array>>|null 적재 실패 시 null (미캐시)
+ */
+ private static function fulltextIndexCatalog(): ?array
+ {
+ if (static::$fulltextIndexCatalog !== null) {
+ return static::$fulltextIndexCatalog;
+ }
+
+ try {
+ $sets = (new FulltextIndexInspector)->indexedColumnSets();
+ } catch (Throwable $e) {
+ // 실패는 캐시하지 않는다 — 다음 요청에서 다시 시도한다.
+ if (! isset(static::$warnedMissingIndexes['__catalog_load__'])) {
+ static::$warnedMissingIndexes['__catalog_load__'] = true;
+ Log::warning('FULLTEXT 인덱스 카탈로그 적재에 실패해 이번 요청은 부분일치(LIKE)로 검색합니다.', [
+ 'exception' => $e,
+ ]);
+ }
+
+ return null;
+ }
+
+ $normalized = [];
+ foreach ($sets as $table => $columnSets) {
+ $normalized[strtolower((string) $table)] = array_map(
+ static fn (array $set): array => self::normalizeColumnSet($set),
+ $columnSets
+ );
+ }
+
+ return static::$fulltextIndexCatalog = $normalized;
+ }
+
+ /**
+ * 컬럼 집합을 비교 가능한 형태로 정규화합니다 (한정자 제거·소문자·정렬·중복 제거).
+ *
+ * @param array $columns 컬럼명 목록 (`posts.title` 형태 허용)
+ * @return array 정규화된 컬럼 집합
+ */
+ private static function normalizeColumnSet(array $columns): array
+ {
+ $normalized = array_values(array_unique(array_map(
+ static function (string $column): string {
+ $pos = strrpos($column, '.');
+
+ return strtolower($pos === false ? $column : substr($column, $pos + 1));
+ },
+ $columns
+ )));
+
+ sort($normalized);
+
+ return $normalized;
+ }
+
+ /**
+ * 테이블명 조회 키 후보를 만듭니다 (프리픽스 유/무 양쪽).
+ *
+ * 카탈로그 키는 프리픽스 제거 형태다. 호출자가 프리픽스 포함 이름을 넘겨도 판정이
+ * 되도록 제거 후보를 함께 만든다. 프리픽스가 빈 문자열인 설치에서도 동작한다.
+ *
+ * @param string $table 테이블명
+ * @return array 소문자 조회 키 후보
+ */
+ private static function tableLookupKeys(string $table): array
+ {
+ $keys = [strtolower($table)];
+
+ $prefix = DB::getTablePrefix();
+ if ($prefix !== '' && str_starts_with($table, $prefix)) {
+ $keys[] = strtolower(substr($table, strlen($prefix)));
+ }
+
+ return array_values(array_unique($keys));
+ }
+
+ /**
+ * 카탈로그를 테스트용으로 시드합니다.
+ *
+ * @param array>> $catalog 테이블명 => 컬럼 집합 목록
+ */
+ public static function primeFulltextIndexCatalog(array $catalog): void
+ {
+ $normalized = [];
+ foreach ($catalog as $table => $columnSets) {
+ $normalized[strtolower((string) $table)] = array_map(
+ static fn (array $set): array => self::normalizeColumnSet($set),
+ $columnSets
+ );
+ }
+
+ static::$fulltextIndexCatalog = $normalized;
+ }
+
+ /**
+ * 카탈로그 캐시와 경고 기록을 함께 초기화합니다 (인덱스 생성 직후·테스트).
+ */
+ public static function forgetFulltextIndexCatalog(): void
+ {
+ static::$fulltextIndexCatalog = null;
+ static::$warnedMissingIndexes = [];
+ }
+
+ /**
+ * 인덱스 부재 폴백 사실을 테이블+컬럼 조합당 한 번 기록합니다.
+ *
+ * 드라이버 미지원 폴백(정상 경로)과 달리, 드라이버는 지원하는데 인덱스가 없어
+ * 내려가는 폴백은 설치 결함의 신호이므로 기록을 남긴다. 기록하지 않으면
+ * "검색이 느리고 관련도가 없다" 는 증상만 남고 원인을 찾을 단서가 없다.
+ *
+ * @param string $table 테이블명
+ * @param array $columns MATCH 대상 컬럼명
+ */
+ protected static function warnMissingFulltextIndexOnce(string $table, array $columns): void
+ {
+ $normalized = self::normalizeColumnSet($columns);
+ $key = strtolower($table).':'.implode(',', $normalized);
+
+ if (isset(static::$warnedMissingIndexes[$key])) {
+ return;
+ }
+
+ static::$warnedMissingIndexes[$key] = true;
+
+ Log::warning('대상 컬럼 조합의 FULLTEXT 인덱스가 없어 부분일치(LIKE)로 검색합니다. 관련도 정렬이 적용되지 않고 전체 스캔이 발생합니다.', [
+ 'table' => $table,
+ 'columns' => $normalized,
+ // search:index 는 **이미 존재하는** 인덱스만 열거해 상태를 보므로, 인덱스가
+ // 아예 없는 이 상황은 그 목록에 나타나지도 재생성 대상이 되지도 않는다.
+ // 안내가 그 커맨드를 가리키면 운영자는 "이상 없음" 을 보고 추적이 끊긴다.
+ 'hint' => '해당 테이블의 FULLTEXT 인덱스를 만드는 마이그레이션이 적용되지 않았습니다. 그 확장의 마이그레이션을 다시 실행해 인덱스를 생성하세요.',
+ ]);
+ }
+
/**
* FULLTEXT BOOLEAN MODE 검색어에서 연산자 문자를 제거하고 안전한 구문으로 변환합니다.
*
@@ -414,6 +616,19 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
return;
}
+ $table = $query->getModel()->getTable();
+
+ if (! static::fulltextIndexCoversColumns($table, $columns)) {
+ // 드라이버는 지원하는데 이 컬럼 조합을 커버하는 인덱스가 없다 — MATCH 를
+ // 조립하면 실행 시 1191(Can't find FULLTEXT index) 이 되고, 그 오류는
+ // 소비처의 catch 에 삼켜져 "검색 결과 0건" 으로 위장된다. LIKE 로 내려가고
+ // 설치 결함의 신호로 조합당 1회 기록한다.
+ static::warnMissingFulltextIndexOnce($table, $columns);
+ KeywordSearch::applyLikeMatch($query, $columns, $keyword, $boolean);
+
+ return;
+ }
+
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
@@ -478,6 +693,9 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
"ALTER TABLE `{$prefix}{$table}` "
."ADD FULLTEXT INDEX `{$indexName}` (`{$columnList}`){$parserClause}"
);
+
+ // 방금 만든 인덱스가 이 프로세스의 커버 판정에 바로 반영되도록 카탈로그를 비운다.
+ static::forgetFulltextIndexCatalog();
}
/**
@@ -523,6 +741,21 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
$useFulltext = static::supportsFulltext();
+ if ($useFulltext) {
+ // 이 경로는 컬럼마다 단일 MATCH(col) 를 조립하므로, 각 컬럼이 **개별**
+ // FULLTEXT 인덱스로 커버될 때만 MATCH 를 쓴다. 복합 인덱스만 있는 테이블은
+ // 실행 시 1191 이 되므로 LIKE 분기로 내려간다.
+ $uncovered = array_values(array_filter(
+ $columns,
+ static fn (string $column): bool => ! static::fulltextIndexCoversColumns($model->getTable(), [$column])
+ ));
+
+ if ($uncovered !== []) {
+ static::warnMissingFulltextIndexOnce($model->getTable(), $uncovered);
+ $useFulltext = false;
+ }
+ }
+
// prefix 포함 테이블명 (selectRaw에서 사용)
$prefix = DB::getTablePrefix();
$qualifiedTable = $prefix.$model->getTable();
@@ -555,12 +788,11 @@ class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
$scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score';
$this->applySelect($query, $model, $qualifiedTable, $scoreRaw, $scoreBindings, $keysOnly);
} else {
- // LIKE fallback (PostgreSQL, SQLite 등)
- $query->where(function ($q) use ($columns, $keyword) {
- foreach ($columns as $column) {
- $q->orWhere($column, 'LIKE', "%{$keyword}%");
- }
- });
+ // LIKE fallback (FULLTEXT 미지원 드라이버 + 개별 인덱스 미커버)
+ // 폴백 술어 조립은 KeywordSearch 단일 지점에 맡긴다 — 와일드카드(% _ \)
+ // escape 와 드라이버별 대소문자 무시 연산자가 그쪽에만 있어, 여기서 손으로
+ // 조립하면 같은 "부분일치" 인데 검색어에 % 가 섞였을 때 결과가 갈린다.
+ KeywordSearch::applyLikeMatch($query, $columns, $keyword);
// 스코어 고정 0 (관련성 순위 불가)
$this->applySelect($query, $model, $qualifiedTable, '0 as _ft_score', [], $keysOnly);
diff --git a/app/Search/FulltextIndexInspector.php b/app/Search/FulltextIndexInspector.php
index b62a48ad..c1ba571d 100644
--- a/app/Search/FulltextIndexInspector.php
+++ b/app/Search/FulltextIndexInspector.php
@@ -93,6 +93,48 @@ class FulltextIndexInspector
return $definitions;
}
+ /**
+ * 현재 스키마의 FULLTEXT 인덱스 컬럼 집합을 테이블별로 수집합니다.
+ *
+ * `discover()` 와 같은 INFORMATION_SCHEMA 조회를 쓰되, 인덱스마다 `SHOW CREATE TABLE`
+ * 을 도는 파서 해석은 하지 않는 경량판입니다 — 커버 판정에는 컬럼 집합만 필요합니다.
+ *
+ * 반환 키는 프리픽스를 제거한 테이블명입니다. `Model::getTable()` 이 프리픽스 미포함
+ * 이름을 돌려주므로, 판정하는 쪽이 그 이름으로 바로 조회할 수 있게 맞춥니다.
+ *
+ * @return array>> 테이블명 => 인덱스별 컬럼 집합 목록
+ */
+ public function indexedColumnSets(): array
+ {
+ if (! DatabaseFulltextEngine::supportsFulltext()) {
+ return [];
+ }
+
+ $rows = DB::select(
+ 'SELECT TABLE_NAME AS table_name,
+ GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND INDEX_TYPE = ?
+ GROUP BY TABLE_NAME, INDEX_NAME',
+ ['FULLTEXT']
+ );
+
+ $prefix = DB::getTablePrefix();
+ $sets = [];
+
+ foreach ($rows as $row) {
+ $table = (string) $row->table_name;
+
+ if ($prefix !== '' && str_starts_with($table, $prefix)) {
+ $table = substr($table, strlen($prefix));
+ }
+
+ $sets[strtolower($table)][] = explode(',', (string) $row->columns);
+ }
+
+ return $sets;
+ }
+
/**
* 인덱스 하나의 건강도를 판정합니다.
*
diff --git a/app/Search/SearchCategoryPayload.php b/app/Search/SearchCategoryPayload.php
index 2a72fc16..4a877025 100644
--- a/app/Search/SearchCategoryPayload.php
+++ b/app/Search/SearchCategoryPayload.php
@@ -80,6 +80,33 @@ final class SearchCategoryPayload
], $extra);
}
+ /**
+ * 카테고리 검색이 예외로 실패했을 때의 페이로드를 만듭니다.
+ *
+ * 실패한 0건을 "정확한 0건" 으로 말하지 않는다 — `total_is_exact=false` 로 내보내
+ * 배지가 정확한 값처럼 그려지는 것을 막고, `failed` 플래그로 화면이 "결과 없음" 과
+ * 구분되는 오류 안내를 그릴 수 있게 한다. 키 집합은 다른 팩토리와 동일하게 유지해
+ * 화면이 분기 없이 읽게 한다.
+ *
+ * @param array $extra 도메인 고유 필드 (available_boards 등)
+ * @return array 카테고리 페이로드
+ */
+ public static function failed(array $extra = []): array
+ {
+ return array_merge([
+ 'failed' => true,
+ 'total' => 0,
+ 'total_relation' => TotalRelation::AtLeast->value,
+ 'total_is_exact' => false,
+ 'result_cap' => null,
+ 'last_page' => null,
+ 'has_more_pages' => false,
+ 'next_cursor' => null,
+ 'prev_cursor' => null,
+ 'items' => [],
+ ], $extra);
+ }
+
/**
* 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다.
*
diff --git a/app/Search/SearchHighlighter.php b/app/Search/SearchHighlighter.php
new file mode 100644
index 00000000..3a90b46b
--- /dev/null
+++ b/app/Search/SearchHighlighter.php
@@ -0,0 +1,62 @@
+ 로 감싼다. 이스케이프를 건너뛰면 제목·본문에
+ * 삽입된 태그가 그대로 실행 계약으로 나간다(게시판/상품/페이지 검색 공통).
+ */
+class SearchHighlighter
+{
+ /**
+ * 원문을 HTML 이스케이프한 뒤 검색어만 로 감쌉니다.
+ *
+ * @param string|null $text 원문 텍스트(평문)
+ * @param string $keyword 검색어
+ * @return string 이스케이프 완료된 안전한 HTML
+ */
+ public static function highlight(?string $text, string $keyword): string
+ {
+ if ($text === null || $text === '') {
+ return '';
+ }
+
+ $safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
+
+ if ($keyword === '') {
+ return $safe;
+ }
+
+ // 키워드도 동일하게 이스케이프해, 이스케이프된 원문과 일관되게 매칭한다.
+ $safeKeyword = htmlspecialchars($keyword, ENT_QUOTES, 'UTF-8');
+ $escapedKeyword = preg_quote($safeKeyword, '/');
+
+ $result = preg_replace('/('.$escapedKeyword.')/iu', '$1', $safe);
+
+ // 유효하지 않은 UTF-8 등으로 preg_replace 가 null 을 반환하면 이스케이프본을 유지한다.
+ return $result ?? $safe;
+ }
+
+ /**
+ * HTML 본문을 태그 없는 평문으로 변환합니다.
+ *
+ * 엔티티를 먼저 디코드한 뒤 태그를 제거해, 엔티티로 인코딩된 태그가
+ * 평문화 단계에서 실제 태그로 부활하지 못하게 합니다.
+ *
+ * @param string|null $html 본문(HTML 또는 평문)
+ * @return string 태그가 제거된 평문
+ */
+ public static function toPlainText(?string $html): string
+ {
+ if ($html === null || $html === '') {
+ return '';
+ }
+
+ $decoded = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
+
+ return trim((string) preg_replace('/\s+/', ' ', strip_tags($decoded)));
+ }
+}
diff --git a/app/Seo/SeoCacheStatsService.php b/app/Seo/SeoCacheStatsService.php
index 027332d7..d2df1c19 100644
--- a/app/Seo/SeoCacheStatsService.php
+++ b/app/Seo/SeoCacheStatsService.php
@@ -127,6 +127,10 @@ class SeoCacheStatsService
*/
public function cleanup(int $daysToKeep = 30): int
{
+ // 보존 기간 하한(1일)은 이 계층이 소유한다 — 파기는 되돌릴 수 없으므로
+ // 호출자마다 다시 막지 않고 실제로 지우는 경로에서 한 번 막는다.
+ $daysToKeep = max(1, $daysToKeep);
+
$deleted = $this->statRepository->deleteOlderThan(Carbon::now()->subDays($daysToKeep));
Log::info('[SEO] 캐시 통계 정리 완료', [
diff --git a/app/Seo/SeoMetaResolver.php b/app/Seo/SeoMetaResolver.php
index 8cb12d66..678ccbc3 100644
--- a/app/Seo/SeoMetaResolver.php
+++ b/app/Seo/SeoMetaResolver.php
@@ -253,6 +253,7 @@ class SeoMetaResolver
return $this->resolveLocalizedValue($value);
}
}
+
return '';
}
@@ -428,6 +429,37 @@ class SeoMetaResolver
return [$contextIdentifier, $rest];
}
+ /**
+ * `` 조립용 접미사를 정규화합니다.
+ *
+ * 관리자 저장 경로의 TrimStrings 미들웨어가 접미사의 선행 공백을 제거하므로
+ * ("` | 그누보드7`" 을 입력해도 "`| 그누보드7`" 로 저장된다), blade 의
+ * `{{ $title }}{{ $titleSuffix }}` 단순 연결에서 제목과 구분자가 붙어 버린다.
+ * 이 메서드가 조립 규칙의 SSoT 다:
+ *
+ * - 제목이 있고 접미사가 공백으로 시작하지 않으면 공백 하나를 복원해 잇는다.
+ * - 제목이 비어 있으면 매달린 선행 구분자(`|`,`-`,`·`,`:`,`/`,`–`,`—`)를 떼어
+ * 사이트명 부분만 남긴다 (홈처럼 페이지 제목이 없는 화면의 "`| 사이트명`" 방지).
+ *
+ * @param string $title 최종 페이지 제목 (filter 훅 적용 후)
+ * @param string $suffix 코어 설정의 타이틀 접미사
+ * @return string 조립에 사용할 접미사 (제목이 없으면 접미사가 곧 전체 제목)
+ */
+ public static function composeTitleSuffix(string $title, string $suffix): string
+ {
+ if (trim($suffix) === '') {
+ return '';
+ }
+
+ if ($title === '') {
+ $stripped = (string) preg_replace('/^[\s|·:\/\x{2013}\x{2014}-]+/u', '', $suffix);
+
+ return trim($stripped);
+ }
+
+ return preg_match('/^\s/u', $suffix) === 1 ? $suffix : ' '.$suffix;
+ }
+
/**
* 레이아웃 meta의 title을 해석합니다 (fallback용).
*
@@ -691,7 +723,15 @@ class SeoMetaResolver
$resolved = array_merge(['@context' => 'https://schema.org'], $structuredData);
- return json_encode($resolved, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+ // JSON-LD 는 `` 가 스크립트 컨텍스트를 조기 종료하면 그
+ // 뒤의 페이로드가 실행 가능한 script 요소로 재생성된다(반사형 XSS — 봇 렌더는
+ // _escaped_fragment_ 로 일반 UA 도 강제됨). JSON_HEX_TAG 로 `<`·`>` 를 <·>
+ // 로 이스케이프해 브레이크아웃을 차단한다. JSON_HEX_AMP 는 방어 심화.
+ return json_encode(
+ $resolved,
+ JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP,
+ );
}
/**
@@ -790,9 +830,9 @@ class SeoMetaResolver
* 구조화 데이터 하위 객체가 실질적으로 비어있는지 확인합니다.
*
* @type 키가 있는 객체에서, @type 외 필드 중 하나라도 빈 문자열이면
- * 해당 객체를 JSON-LD에서 제거합니다.
- * 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
- * Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
+ * 해당 객체를 JSON-LD에서 제거합니다.
+ * 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
+ * Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
*
* @param array $resolved 평가된 구조화 데이터 객체
* @return bool 비어있으면 true
diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php
index cf6581b9..22c17fd3 100644
--- a/app/Seo/SeoRenderer.php
+++ b/app/Seo/SeoRenderer.php
@@ -491,7 +491,9 @@ class SeoRenderer implements SeoRendererInterface
$viewData = [
'locale' => $locale,
'title' => $meta['title'],
- 'titleSuffix' => $meta['titleSuffix'],
+ // filter 훅이 title 을 바꿨을 수 있으므로 최종 title 기준으로 접미사를 정규화한다
+ // (TrimStrings 로 선행 공백이 제거된 접미사 복원 + 빈 제목의 매달린 구분자 제거)
+ 'titleSuffix' => SeoMetaResolver::composeTitleSuffix((string) ($meta['title'] ?? ''), (string) ($meta['titleSuffix'] ?? '')),
'description' => $meta['description'],
'keywords' => $meta['keywords'],
'canonicalUrl' => $canonicalUrl,
diff --git a/app/Services/ActivityLogService.php b/app/Services/ActivityLogService.php
index 9516aeed..b03b3c8e 100644
--- a/app/Services/ActivityLogService.php
+++ b/app/Services/ActivityLogService.php
@@ -83,4 +83,26 @@ class ActivityLogService
return $count;
}
+
+ /**
+ * 보존 기간이 지난 활동 로그를 정리합니다 (자동 파기).
+ *
+ * 운영자가 고른 ID 를 지우는 deleteMany 와는 별개의 훅을 발행한다. 자동 파기는
+ * 대상을 ID 로 지목하지 않고(기간으로 정한다), 사람이 없는 예약 실행이라
+ * 본인인증 같은 대화형 가드를 태울 수 없기 때문이다. 확장은 이 훅으로
+ * 외부 보관 등 자기 처리를 붙인다.
+ *
+ * @param int $days 보존 기간 (일)
+ * @return int 삭제된 건수
+ */
+ public function prune(int $days): int
+ {
+ HookManager::doAction('core.activity_log.before_prune', $days);
+
+ $count = $this->repository->deleteOlderThan($days);
+
+ HookManager::doAction('core.activity_log.after_prune', $days, $count);
+
+ return $count;
+ }
}
diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php
index 540a4c46..b0f48620 100644
--- a/app/Services/AttachmentService.php
+++ b/app/Services/AttachmentService.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Extension\HookManager;
+use App\Helpers\PermissionHelper;
use App\Models\Attachment;
use App\Models\User;
use App\Support\ImageResizer;
@@ -13,6 +14,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -163,14 +165,93 @@ class AttachmentService
}
/**
- * 첨부파일 삭제
+ * 소유자 없이 방치된 고아 첨부를 정리합니다.
+ *
+ * 폼 저장 전에 즉시 업로드되는 첨부(FileUploader 등)는 소유자 없이 먼저 만들어지므로,
+ * 폼을 저장하지 않고 이탈하면 파일과 기록이 그대로 남습니다.
+ *
+ * 오탐을 막기 위해 아래 조건을 모두 만족하는 행만 대상으로 삼습니다.
+ *
+ * 1. 소유자(attachmentable_type/id)가 없다
+ * 2. 업로드 후 보존기간이 지났다 (폼 작성 중 유예)
+ * 3. 확장 소유가 아니다 (source_identifier 없음 — 확장 라이프사이클 소관)
+ * 4. 현역으로 쓰이는 첨부가 아니다 (현재 사이트 로고 등)
+ *
+ * 4번 보호 조건은 이 메서드가 직접 해석합니다. 호출자가 넘기는 값으로 두면 그 인자를
+ * 빠뜨린 호출 하나가 운영 중인 로고를 파기하는데, 나머지 세 조건은 조회의 불변식이라
+ * 보호 조건만 규약으로 남는 비대칭이 생깁니다.
+ *
+ * 삭제는 단건 삭제 경로(delete)를 그대로 재사용하므로 파일 삭제·훅·로그가 동일합니다.
+ *
+ * @param int $days 보존기간(일)
+ * @param int $limit 한 회차에 처리할 최대 건수
+ * @param bool $dryRun true 면 대상만 세고 삭제하지 않음
+ * @return array{scanned: int, deleted: int, failed: int} 처리 결과
+ */
+ public function pruneOrphans(int $days, int $limit, bool $dryRun = false): array
+ {
+ $threshold = now()->subDays($days);
+ $candidates = $this->repository->findOrphanCandidates(
+ $threshold,
+ $limit,
+ $this->protectedAttachmentIds(),
+ );
+
+ $result = ['scanned' => $candidates->count(), 'deleted' => 0, 'failed' => 0];
+
+ if ($dryRun) {
+ return $result;
+ }
+
+ foreach ($candidates as $candidate) {
+ $this->delete($candidate->id) ? $result['deleted']++ : $result['failed']++;
+ }
+
+ return $result;
+ }
+
+ /**
+ * 고아 정리에서 보호할 첨부 ID 목록을 반환합니다.
+ *
+ * 사이트 로고는 소유자(attachmentable) 없이 설정값이 직접 참조하는 첨부라 판정식만으로는
+ * 고아와 구분되지 않습니다. 현역으로 설정에 실린 ID 를 명시적으로 제외합니다.
+ *
+ * @return array 보호할 첨부 ID 목록
+ */
+ private function protectedAttachmentIds(): array
+ {
+ $siteLogo = g7_core_settings('general.site_logo', []);
+
+ if (! is_array($siteLogo)) {
+ return [];
+ }
+
+ $ids = [];
+
+ foreach ($siteLogo as $item) {
+ $id = is_array($item) ? ($item['id'] ?? null) : $item;
+
+ if (is_numeric($id)) {
+ $ids[] = (int) $id;
+ }
+ }
+
+ return array_values(array_unique($ids));
+ }
+
+ /**
+ * 첨부파일 삭제 (파일 + 기록 영구 삭제)
+ *
+ * 대상 조회는 소프트삭제된 행까지 포함한다. 이 메서드가 수행하는 것은 forceDelete 이므로
+ * 이미 소프트삭제된 행도 정당한 대상이고, 기본 조회로 찾으면 고아 정리가 그 행을 영원히
+ * 회수하지 못한 채 실패만 반복한다.
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
- $attachment = $this->repository->findById($id);
+ $attachment = $this->repository->findByIdWithTrashed($id);
if (! $attachment) {
return false;
@@ -181,23 +262,40 @@ class AttachmentService
$attachmentableId = $attachment->attachmentable_id;
$collection = $attachment->collection;
- // Before 훅
- HookManager::doAction('core.attachment.before_delete', $attachment);
+ // DB 를 먼저 지우고 파일은 커밋 후에 지운다.
+ // 순서를 반대로 두면 DB 삭제가 실패했을 때 파일 없는 행이 남아 다운로드가 404 가 된다.
+ // 반대로 이 순서에서는 최악이어도 참조되지 않는 고아 파일만 남고 데이터는 온전하다.
+ $result = DB::transaction(function () use ($id, $attachment, $attachmentableType, $attachmentableId, $collection) {
+ // Before 훅
+ HookManager::doAction('core.attachment.before_delete', $attachment);
- // 스토리지에서 파일 삭제
- $this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
+ // DB에서 영구 삭제
+ $deleted = $this->repository->forceDelete($id);
- // DB에서 영구 삭제
- $result = $this->repository->forceDelete($id);
+ // 삭제 후 남은 파일들의 순서 재정렬
+ if ($deleted && $attachmentableType && $attachmentableId) {
+ $this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
+ }
- Log::info('첨부파일 삭제 완료', [
- 'attachment_id' => $id,
- 'hash' => $attachment->hash,
- ]);
+ return $deleted;
+ });
- // 삭제 후 남은 파일들의 순서 재정렬
- if ($result && $attachmentableType && $attachmentableId) {
- $this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
+ if ($result) {
+ // 커밋 후 스토리지 파일 삭제 (실패해도 DB 는 되돌리지 않는다)
+ try {
+ $this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
+ } catch (\Throwable $e) {
+ Log::warning('첨부파일 스토리지 삭제 실패 (고아 파일 잔존)', [
+ 'attachment_id' => $id,
+ 'path' => $attachment->path,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ Log::info('첨부파일 삭제 완료', [
+ 'attachment_id' => $id,
+ 'hash' => $attachment->hash,
+ ]);
}
// After 훅
@@ -213,6 +311,8 @@ class AttachmentService
*/
public function reorder(array $orderData): void
{
+ $this->assertReorderWithinScope($orderData);
+
// Before 훅
HookManager::doAction('core.attachment.before_reorder', $orderData);
@@ -222,6 +322,44 @@ class AttachmentService
HookManager::doAction('core.attachment.after_reorder', $orderData);
}
+ /**
+ * 순서 변경 대상이 액터의 스코프 안에 있는지 검사합니다.
+ *
+ * `PATCH admin/attachments/reorder` 는 라우트 모델이 없는 정적 경로다. PermissionMiddleware
+ * 는 `$request->route('attachment')` 가 Model 일 때만 스코프를 검사하고 없으면 목록
+ * 엔드포인트로 보아 건너뛰므로(`PermissionMiddleware`), 상세 경로(`DELETE {attachment}`)가
+ * 미들웨어로 강제하는 스코프 축이 이 경로에서만 비어 있었다. 배포 기본 역할 `manager` 가
+ * `core.attachments.update` 를 `self` 스코프로 보유하므로 이론 구성이 아니라 기본값에서
+ * 성립한다 — 타인 소유 첨부의 순서를 바꿀 수 있었다.
+ *
+ * 대상 일부만 걸러내지 않고 **전체를 거부**한다. 순서는 집합 전체에 대한 하나의 배열이라
+ * 일부만 반영하면 나머지와 어긋난 순서가 저장되기 때문이다(사용자 일괄 상태변경이
+ * "제외" 를 택한 것과 의미론이 다르다).
+ *
+ * @param array $orderData 순서 데이터
+ *
+ * @throws AuthorizationException 스코프 밖 첨부가 하나라도 포함된 경우
+ */
+ private function assertReorderWithinScope(array $orderData): void
+ {
+ $ids = array_values(array_unique(array_filter(
+ array_map(static fn ($item): int => (int) ($item['id'] ?? 0), $orderData)
+ )));
+
+ if (empty($ids)) {
+ return;
+ }
+
+ $attachments = $this->repository->findByIds($ids);
+
+ // 판정은 상세 경로와 같은 SSoT 에 위임한다 — 여기서 재구현하면 두 경로의 강도가 갈린다.
+ $permitted = PermissionHelper::filterByScope($attachments, 'core.attachments.update');
+
+ if (count($permitted) !== $attachments->count()) {
+ throw new AuthorizationException(__('auth.scope_denied'));
+ }
+ }
+
/**
* 다운로드 응답 생성
*
@@ -270,12 +408,15 @@ class AttachmentService
/**
* 이미지 파일 정보 조회 (캐싱 응답용)
*
- * 권한 체크 후 파일 경로와 메타 정보를 반환합니다.
- * 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다.
+ * 권한 체크 후 인라인 스트림 응답과 캐싱용 메타를 반환합니다.
+ * 로컬 절대 경로 조립(getBasePath) 방식은 S3 등 원격 디스크 행에서 성립하지
+ * 않으므로(#99 — filemtime stat 실패 500), 행 disk 를 그대로 따르는
+ * StorageInterface::response() 스트림으로 서빙합니다. 컨트롤러는
+ * streamedFileResponse() 로 캐싱 헤더(ETag/304)를 입혀 응답합니다.
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
- * @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
+ * @return array{response: StreamedResponse, etag_source: string, mime_type: string, filename: string}|null 서빙 정보 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
@@ -293,9 +434,19 @@ class AttachmentService
// 필터 훅 - 다운로드 전 처리
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
- // 파일 존재 확인
- $diskStorage = $this->storage->withDisk($attachment->disk);
- if (! $diskStorage->exists('', $attachment->path)) {
+ // 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행)
+ // Content-Type/Length 를 행 메타로 선지정해 원격 디스크의 추가 메타 조회를 생략한다.
+ $response = $this->storage->withDisk($attachment->disk)->response(
+ '',
+ $attachment->path,
+ $attachment->original_filename,
+ [
+ 'Content-Type' => $attachment->mime_type,
+ 'Content-Length' => (string) $attachment->size,
+ ]
+ );
+
+ if (! $response) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
@@ -305,7 +456,14 @@ class AttachmentService
}
return [
- 'path' => $diskStorage->getBasePath('').DIRECTORY_SEPARATOR.$attachment->path,
+ 'response' => $response,
+ // 파일 stat 없이 결정적인 ETag 소스 (업로드 파일은 경로당 불변)
+ 'etag_source' => implode('|', [
+ $attachment->disk,
+ $attachment->path,
+ (string) $attachment->updated_at?->getTimestamp(),
+ (string) $attachment->size,
+ ]),
'mime_type' => $attachment->mime_type,
'filename' => $attachment->original_filename,
];
@@ -358,18 +516,29 @@ class AttachmentService
// Before 훅
HookManager::doAction('core.attachment.before_bulk_delete', $identifier);
- // 해당 첨부파일들의 스토리지 파일 삭제
+ // 삭제 대상 스냅샷 + 파일 경로를 먼저 수집한다 (DB 삭제 후에는 조회할 수 없다).
$attachments = $this->repository->getBySourceIdentifier($identifier);
$attachmentIds = $attachments->pluck('id')->toArray();
$snapshots = $attachments->keyBy('id')->map(fn ($a) => $a->toArray())->toArray();
+ $files = $attachments->map(fn ($a) => ['disk' => $a->disk, 'path' => $a->path])->all();
- foreach ($attachments as $attachment) {
- $this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
+ // DB 일괄 삭제를 먼저 커밋한다 — 루프 중간에 실패하면 파일만 일부 사라지고
+ // DB 행은 전부 남아 다운로드가 404 가 되기 때문이다.
+ $count = DB::transaction(fn () => $this->repository->deleteBySourceIdentifier($identifier));
+
+ // 커밋 후 파일 정리 (실패해도 DB 는 되돌리지 않는다 — 고아 파일은 비파괴)
+ foreach ($files as $file) {
+ try {
+ $this->storage->withDisk($file['disk'])->delete('', $file['path']);
+ } catch (\Throwable $e) {
+ Log::warning('첨부파일 스토리지 일괄 삭제 실패 (고아 파일 잔존)', [
+ 'source_identifier' => $identifier,
+ 'path' => $file['path'],
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
- // DB에서 삭제
- $count = $this->repository->deleteBySourceIdentifier($identifier);
-
Log::info('소스 식별자 기준 첨부파일 일괄 삭제', [
'source_identifier' => $identifier,
'deleted_count' => $count,
diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php
index 1987d3a9..7dd137dc 100644
--- a/app/Services/AuthService.php
+++ b/app/Services/AuthService.php
@@ -15,6 +15,7 @@ use App\Extension\HookManager;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -347,19 +348,27 @@ class AuthService
'status' => $status,
];
- $user = $this->userRepository->create($userData);
+ // 계정 생성 이후 단계에서 실패하면 이메일만 점유한 유령 계정이 남아
+ // 같은 이메일로 재가입조차 불가능해진다. 생성~토큰 발급을 하나로 묶는다.
+ [$user, $token] = DB::transaction(function () use ($userData, $data, $now) {
+ $user = $this->userRepository->create($userData);
- // 약관 동의 이력 기록
- $this->recordConsents($user, $data, $now);
+ // 약관 동의 이력 기록
+ $this->recordConsents($user, $data, $now);
- // 'user' 역할 자동 할당 (UserService 패턴과 동일)
- $userRole = $this->roleRepository->findByIdentifier('user');
- if ($userRole) {
- $user->roles()->sync([$userRole->id]);
- $user->flushPermissionCaches();
- }
+ // 'user' 역할 자동 할당 (UserService 패턴과 동일)
+ $userRole = $this->roleRepository->findByIdentifier('user');
+ if ($userRole) {
+ $user->roles()->sync([$userRole->id]);
+ }
- $token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
+ $token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
+
+ return [$user, $token];
+ });
+
+ // 커밋 후 부수효과 (캐시는 DB 트랜잭션의 롤백 대상이 아니다)
+ $user->flushPermissionCaches();
// Hook 발생 (회원가입 완료) — 알림 발송은 NotificationHookListener,
// signup_after_create 정책이 enabled 면 InitiateIdentityChallengeAfterRegister 가 challenge 발행.
@@ -612,16 +621,20 @@ class AuthService
]);
}
- // Hook 발생 (비밀번호 재설정 시작)
- HookManager::doAction('core.auth.before_reset_password', $user);
+ // 비밀번호를 바꾼 뒤 토큰 삭제가 실패하면 그 재설정 토큰이 계속 유효한 채로
+ // 남아 재사용된다(보안). 두 단계를 하나로 묶는다.
+ DB::transaction(function () use ($user, $password, $record) {
+ // Hook 발생 (비밀번호 재설정 시작)
+ HookManager::doAction('core.auth.before_reset_password', $user);
- // 비밀번호 업데이트
- $this->userRepository->update($user, [
- 'password' => Hash::make($password),
- ]);
+ // 비밀번호 업데이트
+ $this->userRepository->update($user, [
+ 'password' => Hash::make($password),
+ ]);
- // 사용된 토큰 삭제
- $record->delete();
+ // 사용된 토큰 삭제
+ $record->delete();
+ });
// Hook 발생 (비밀번호 변경 완료) — 알림 발송은 NotificationHookListener가 처리
HookManager::doAction('core.auth.after_password_changed', $user);
diff --git a/app/Services/DriverConnectionTester.php b/app/Services/DriverConnectionTester.php
index 121ab437..f69fc939 100644
--- a/app/Services/DriverConnectionTester.php
+++ b/app/Services/DriverConnectionTester.php
@@ -8,6 +8,7 @@ use Aws\S3\S3Client;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
+use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Predis\Client;
/**
@@ -101,9 +102,19 @@ class DriverConnectionTester
];
}
+ // Flysystem S3 어댑터 존재 선검사 — 테스트는 SDK 직접 경로지만 실제 파일 I/O 는
+ // Storage::disk('s3') → 어댑터를 경유하므로, 어댑터가 없으면 테스트가 성공해도
+ // 실경로가 즉사한다 (#99 의 결함 은폐 구조). sdk_missing 검사와 대칭.
+ if (! class_exists(AwsS3V3Adapter::class)) {
+ return [
+ 'success' => false,
+ 'message' => __('settings.s3_adapter_missing'),
+ ];
+ }
+
$startTime = microtime(true);
- $client = new S3Client([
+ $clientOptions = [
'version' => 'latest',
'region' => $region,
'credentials' => [
@@ -114,7 +125,18 @@ class DriverConnectionTester
'timeout' => 5,
'connect_timeout' => 3,
],
- ]);
+ ];
+
+ // S3 호환 스토리지(R2/MinIO 등) — 실경로(applyS3Config 주입)와 동일하게
+ // endpoint/path-style 을 반영해야 테스트가 실제 연결 대상과 일치한다.
+ if (! empty($config['s3_endpoint'])) {
+ $clientOptions['endpoint'] = $config['s3_endpoint'];
+ }
+ if (! empty($config['s3_use_path_style'])) {
+ $clientOptions['use_path_style_endpoint'] = true;
+ }
+
+ $client = new S3Client($clientOptions);
// 버킷 존재 확인
$client->headBucket(['Bucket' => $bucket]);
@@ -372,15 +394,53 @@ class DriverConnectionTester
* @return array 테스트 결과
*/
public function testWebsocket(array $config): array
+ {
+ $clientHost = $config['websocket_host'] ?? 'localhost';
+ $clientPort = (int) ($config['websocket_port'] ?? 8080);
+ $clientScheme = $config['websocket_scheme'] ?? 'https';
+
+ // 서버(백엔드 broadcast HTTP API) endpoint — 빈 값은 클라이언트 값으로 폴백한다
+ // (SettingsServiceProvider::applyWebsocketConfig 와 동일 규칙). 백엔드 송신은
+ // server endpoint 를 쓰므로 client 만 검사하면 "테스트 성공 + 실제 발송 실패" 가 된다.
+ $serverHost = ($config['websocket_server_host'] ?? '') !== '' ? $config['websocket_server_host'] : $clientHost;
+ $serverPort = (int) ($config['websocket_server_port'] ?? 0) > 0 ? (int) $config['websocket_server_port'] : $clientPort;
+ $serverScheme = ($config['websocket_server_scheme'] ?? '') !== '' ? $config['websocket_server_scheme'] : $clientScheme;
+
+ $clientUrl = sprintf('%s://%s:%d', $clientScheme, $clientHost, $clientPort);
+ $serverUrl = sprintf('%s://%s:%d', $serverScheme, $serverHost, $serverPort);
+
+ // 클라이언트 endpoint 검사
+ $clientResult = $this->probeWebsocketEndpoint($clientUrl);
+
+ if (! $clientResult['success']) {
+ return $clientResult;
+ }
+
+ // 서버 endpoint 검사 (클라이언트와 동일 주소면 중복 요청 생략)
+ if ($serverUrl !== $clientUrl) {
+ $serverResult = $this->probeWebsocketEndpoint($serverUrl);
+
+ if (! $serverResult['success']) {
+ return [
+ 'success' => false,
+ 'message' => __('settings.websocket_server_test_failed'),
+ 'error' => $serverResult['error'] ?? ($serverResult['message'] ?? ''),
+ ];
+ }
+ }
+
+ return $clientResult;
+ }
+
+ /**
+ * Websocket(Reverb) HTTP endpoint 하나를 probe 합니다.
+ *
+ * @param string $url 검사할 endpoint URL
+ * @return array 테스트 결과
+ */
+ private function probeWebsocketEndpoint(string $url): array
{
try {
- $host = $config['websocket_host'] ?? 'localhost';
- $port = (int) ($config['websocket_port'] ?? 8080);
- $scheme = $config['websocket_scheme'] ?? 'https';
-
- // Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
- $url = sprintf('%s://%s:%d', $scheme, $host, $port);
-
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
@@ -426,6 +486,7 @@ class DriverConnectionTester
];
} catch (\Exception $e) {
Log::warning('Websocket connection test failed', [
+ 'url' => $url,
'error' => $e->getMessage(),
]);
diff --git a/app/Services/DriverRegistryService.php b/app/Services/DriverRegistryService.php
index 36cc9a11..eae52f40 100644
--- a/app/Services/DriverRegistryService.php
+++ b/app/Services/DriverRegistryService.php
@@ -5,7 +5,8 @@ namespace App\Services;
use App\Extension\HookManager;
use App\Repositories\JsonConfigRepository;
use Illuminate\Support\Facades\Lang;
-use Illuminate\Support\Facades\Log;
+use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
+use Predis\Client;
/**
* 코어 드라이버 레지스트리 서비스
@@ -26,12 +27,16 @@ class DriverRegistryService
*/
private const CORE_DRIVER_IDS = [
'storage' => ['local', 's3'],
+ 'public_asset' => ['none', 'public', 's3'],
'cache' => ['file', 'redis'],
'session' => ['file', 'database', 'redis'],
'queue' => ['sync', 'database', 'redis'],
'log' => ['single', 'daily'],
'websocket' => ['reverb'],
'mail' => ['smtp', 'mailgun', 'ses'],
+ // 검색엔진도 저장 가능한 드라이버 선택이다. 레지스트리에 등재해야 폴백 가드
+ // (플러그인 제거 후 죽은 값 → 기본 드라이버)가 이 카테고리에도 적용된다.
+ 'search' => ['mysql-fulltext'],
];
/**
@@ -41,12 +46,14 @@ class DriverRegistryService
*/
private const DEFAULT_DRIVERS = [
'storage' => 'local',
+ 'public_asset' => 'none',
'cache' => 'file',
'session' => 'database',
'queue' => 'database',
'log' => 'daily',
'websocket' => '',
'mail' => 'smtp',
+ 'search' => 'mysql-fulltext',
];
/**
@@ -56,12 +63,18 @@ class DriverRegistryService
*/
public const CONFIG_KEYS = [
'storage' => 'filesystems.default',
+ 'public_asset' => 'core.storage.public_asset_disk',
'cache' => 'cache.default',
'session' => 'session.driver',
'queue' => 'queue.default',
- 'log' => 'logging.default',
+ // 실제 적용 키 — SettingsServiceProvider::applyLogConfig() 가 기록하는 키와 동일해야
+ // 플러그인 로그 드라이버 폴백이 실효한다 (logging.default 는 어디에서도 읽지 않는 죽은 키였다).
+ // 이 키의 값은 채널 "배열" 이다 — getConfigValueForDriver() 로 형태를 맞춘다.
+ 'log' => 'logging.channels.stack.channels',
'websocket' => 'broadcasting.default',
'mail' => 'mail.default',
+ // SettingsServiceProvider::applyDriverConfig() 가 기록하는 키와 동일해야 폴백이 실효한다
+ 'search' => 'scout.driver',
];
/**
@@ -71,12 +84,16 @@ class DriverRegistryService
*/
private const SETTINGS_KEYS = [
'storage' => ['category' => 'drivers', 'key' => 'storage_driver'],
+ 'public_asset' => ['category' => 'drivers', 'key' => 'public_asset_disk'],
'cache' => ['category' => 'drivers', 'key' => 'cache_driver'],
'session' => ['category' => 'drivers', 'key' => 'session_driver'],
'queue' => ['category' => 'drivers', 'key' => 'queue_driver'],
'log' => ['category' => 'drivers', 'key' => 'log_driver'],
- 'websocket' => ['category' => 'drivers', 'key' => 'websocket_driver'],
+ // websocket 은 드라이버 ID 선택 설정이 없다 (불리언 websocket_enabled 뿐) —
+ // 종전의 'websocket_driver' 는 어떤 저장 경로에도 없는 유령 키라 항상 skip 이었다.
+ // 카테고리 제외로 같은 동작을 명시화한다 (getSettingsKey('websocket') === null).
'mail' => ['category' => 'mail', 'key' => 'mailer'],
+ 'search' => ['category' => 'drivers', 'key' => 'search_engine_driver'],
];
/**
@@ -103,11 +120,53 @@ class DriverRegistryService
{
$coreDrivers = $this->buildCoreDrivers($category);
+ // 검색엔진은 Scout 엔진 등록 훅이 SSoT 다 — 플러그인에 제2의 등록 훅을 요구하지 않고
+ // 그 훅을 함께 읽는다. 일반 드라이버 훅도 그대로 유지해 양쪽 등록 방식을 모두 인식한다.
+ if ($category === 'search') {
+ $coreDrivers = $this->mergeSearchEngineDrivers($coreDrivers);
+ }
+
$hookName = self::HOOK_PREFIX.$category.self::HOOK_SUFFIX;
return HookManager::applyFilters($hookName, $coreDrivers);
}
+ /**
+ * Scout 엔진 등록 훅의 드라이버를 카탈로그 형태로 병합합니다.
+ *
+ * `core.search.engine_drivers` 는 `[id => EngineClass]` 맵이므로 키만 취해
+ * `{id, label}` 형태로 변환한다. 이미 코어 목록에 있는 ID 는 중복 추가하지 않는다.
+ *
+ * @param array}> $coreDrivers 코어 드라이버 목록
+ * @return array}> 병합된 목록
+ */
+ private function mergeSearchEngineDrivers(array $coreDrivers): array
+ {
+ $engines = HookManager::applyFilters('core.search.engine_drivers', []);
+
+ if (! is_array($engines)) {
+ return $coreDrivers;
+ }
+
+ $existing = array_map(fn ($driver) => $driver['id'] ?? null, $coreDrivers);
+ $locales = config('app.translatable_locales', ['ko', 'en']);
+
+ foreach (array_keys($engines) as $id) {
+ if (! is_string($id) || $id === '' || in_array($id, $existing, true)) {
+ continue;
+ }
+
+ $label = [];
+ foreach ($locales as $locale) {
+ $label[$locale] = Lang::get("settings.drivers.search.{$id}", [], $locale) ?: $id;
+ }
+
+ $coreDrivers[] = ['id' => $id, 'label' => $label];
+ }
+
+ return $coreDrivers;
+ }
+
/**
* 모든 카테고리의 사용 가능한 드라이버 목록을 반환합니다.
*
@@ -237,6 +296,70 @@ class DriverRegistryService
return $inUse;
}
+ /**
+ * 주어진 드라이버가 현재 서버에서 실제로 동작 가능한지 판정합니다.
+ *
+ * isDriverAvailable() 이 "등록 여부"(카탈로그 소속)를 보는 것과 달리, 이 메서드는
+ * 어댑터 클래스·PHP 확장 등 런타임 능력의 존재를 검사합니다. 사용 불능 드라이버가
+ * 저장되면 다음 부팅에서 사이트 전면 다운으로 이어질 수 있으므로 저장/테스트
+ * FormRequest 의 서버 게이트가 이 판정을 사용합니다.
+ *
+ * @param string $category 드라이버 카테고리 (storage, cache, session, queue 등)
+ * @param string $driverId 드라이버 ID
+ * @return bool 동작 가능하면 true
+ */
+ public function isDriverUsable(string $category, string $driverId): bool
+ {
+ return $this->usabilityFailureReason($category, $driverId) === null;
+ }
+
+ /**
+ * 드라이버 사용 불능 사유를 반환합니다.
+ *
+ * 판정은 드라이버 ID 가 요구하는 런타임 능력 기준입니다. 코어가 능력을 모르는
+ * 드라이버(플러그인 등록 드라이버 포함)는 null(사용 가능) — 플러그인 드라이버의
+ * 가용성은 그 플러그인의 ServiceProvider 가 보증합니다.
+ *
+ * @param string $category 드라이버 카테고리
+ * @param string $driverId 드라이버 ID
+ * @return string|null 사용 불능 사유 (다국어), 사용 가능하면 null
+ */
+ public function usabilityFailureReason(string $category, string $driverId): ?string
+ {
+ return match ($driverId) {
+ 's3' => class_exists(AwsS3V3Adapter::class)
+ ? null
+ : __('settings.driver_unusable_s3_adapter'),
+ // redis 는 설정된 클라이언트 기준으로 판정한다 — predis 명시 설정이면 predis
+ // 존재만 본다. phpredis 설정은 확장 부재여도 부트 폴백(SettingsServiceProvider::
+ // shouldFallBackToPredis)이 predis 로 전환하므로 predis 존재까지 사용 가능이다.
+ 'redis' => (config('database.redis.client', 'phpredis') === 'predis'
+ ? class_exists(Client::class)
+ : (extension_loaded('redis') || class_exists(Client::class)))
+ ? null
+ : __('settings.driver_unusable_redis_client'),
+ 'memcached' => extension_loaded('memcached')
+ ? null
+ : __('settings.driver_unusable_memcached_extension'),
+ default => null,
+ };
+ }
+
+ /**
+ * 카테고리의 Config 키에 기록할 값 형태로 드라이버 ID 를 변환합니다.
+ *
+ * log 카테고리의 적용 키(logging.channels.stack.channels)는 채널 배열이므로
+ * 배열로 감싸고, 그 외 카테고리는 드라이버 ID 문자열 그대로입니다.
+ *
+ * @param string $category 드라이버 카테고리
+ * @param string $driverId 드라이버 ID
+ * @return string|array Config 기록 값
+ */
+ public function getConfigValueForDriver(string $category, string $driverId): string|array
+ {
+ return $category === 'log' ? [$driverId] : $driverId;
+ }
+
/**
* 지원되는 카테고리 목록을 반환합니다.
*
diff --git a/app/Services/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php
index 78670dbe..ed882824 100644
--- a/app/Services/IdentityMessageTemplateService.php
+++ b/app/Services/IdentityMessageTemplateService.php
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
/**
@@ -67,6 +68,29 @@ class IdentityMessageTemplateService
);
}
+ /**
+ * ID 로 템플릿을 조회하며, 없으면 예외를 발생시킵니다.
+ *
+ * 컨트롤러가 모델을 직접 조회하지 않도록 하는 Service 경유 접근자입니다
+ * (Controller → Service → Repository 계층 규정).
+ *
+ * @param int $id 템플릿 ID
+ * @return IdentityMessageTemplate 템플릿 모델
+ *
+ * @throws ModelNotFoundException 템플릿이 없는 경우
+ */
+ public function findOrFailById(int $id): IdentityMessageTemplate
+ {
+ $template = $this->repository->findById($id);
+
+ if (! $template) {
+ throw (new ModelNotFoundException)
+ ->setModel(IdentityMessageTemplate::class, [$id]);
+ }
+
+ return $template;
+ }
+
/**
* 템플릿 수정.
*
diff --git a/app/Services/IdentityPolicyService.php b/app/Services/IdentityPolicyService.php
index 7c747b34..4fa7e398 100644
--- a/app/Services/IdentityPolicyService.php
+++ b/app/Services/IdentityPolicyService.php
@@ -8,11 +8,15 @@ use App\Enums\IdentityOriginType;
use App\Enums\IdentityPolicyAppliesTo;
use App\Enums\IdentityPolicyFailMode;
use App\Enums\IdentityPolicySourceType;
+use App\Enums\IdentityVerificationStatus;
use App\Exceptions\IdentityVerificationRequiredException;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
+use App\Extension\ModuleManager;
+use App\Extension\PluginManager;
use App\Models\IdentityPolicy;
use App\Models\User;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* 본인인증 정책 해석/강제 Service.
@@ -159,10 +163,13 @@ class IdentityPolicyService
* `resolveRenderHint` 와 동일한 우선순위 체인을 적용하여, 정책에 provider 를 지정하지 않아도
* 환경설정의 기본값이 launcher payload 의 provider_id 로 전달되도록 한다.
*
+ * 428 강제 경로와 프론트 프리페치 엔드포인트가 이 게터 하나로 수렴한다 — 같은 데이터에
+ * 두 개의 해석이 존재하면 한쪽만 조용히 raw 값을 내보낸다(A6a).
+ *
* @param IdentityPolicy $policy 대상 정책
* @return string|null 해석된 provider id (해석 실패 시 정책의 원본 값)
*/
- protected function resolveProviderId(IdentityPolicy $policy): ?string
+ public function resolveProviderId(IdentityPolicy $policy): ?string
{
try {
$providerId = $policy->provider_id;
@@ -193,7 +200,7 @@ class IdentityPolicyService
*
* @param array $filters 필터 조건
* @param int $perPage 페이지 크기
- * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ * @return LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20)
{
@@ -345,7 +352,7 @@ class IdentityPolicyService
if ($policy->source_type === IdentityPolicySourceType::Module) {
try {
- $manager = app(\App\Extension\ModuleManager::class);
+ $manager = app(ModuleManager::class);
$module = $manager->getModuleByIdentifier($policy->source_identifier)
?? $manager->getModule($policy->source_identifier);
if ($module && method_exists($module, 'getIdentityPolicies')) {
@@ -364,7 +371,7 @@ class IdentityPolicyService
if ($policy->source_type === IdentityPolicySourceType::Plugin) {
try {
- $manager = app(\App\Extension\PluginManager::class);
+ $manager = app(PluginManager::class);
$plugin = $manager->getPlugin($policy->source_identifier);
if ($plugin && method_exists($plugin, 'getIdentityPolicies')) {
foreach ($plugin->getIdentityPolicies() as $data) {
@@ -542,7 +549,7 @@ class IdentityPolicyService
'channel' => 'policy',
'user_id' => $user?->id,
'target_hash' => $this->resolveTargetHash($user, $context) ?? str_repeat('0', 64),
- 'status' => \App\Enums\IdentityVerificationStatus::PolicyViolationLogged->value,
+ 'status' => IdentityVerificationStatus::PolicyViolationLogged->value,
'origin_type' => $context['origin_type'] ?? IdentityOriginType::Policy->value,
'origin_identifier' => $context['origin_identifier'] ?? null,
'origin_policy_key' => $policy->key,
diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php
index 6a267f58..75e453ba 100644
--- a/app/Services/LanguagePackService.php
+++ b/app/Services/LanguagePackService.php
@@ -724,8 +724,11 @@ class LanguagePackService
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
- File::deleteDirectory($tempPath);
throw $e;
+ } finally {
+ // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
+ // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
+ File::deleteDirectory($tempPath);
}
}
@@ -776,8 +779,11 @@ class LanguagePackService
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
} catch (Throwable $e) {
$this->cleanupPending($extractPath);
- File::deleteDirectory($tempPath);
throw $e;
+ } finally {
+ // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
+ // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
+ File::deleteDirectory($tempPath);
}
}
@@ -1498,9 +1504,11 @@ class LanguagePackService
public function checkUpdates(?string $identifier = null): array
{
// 요구사항 #4: 모듈 패턴 — 모든 source_type 점검 (GitHub 1순위, 실패 시 bundled 폴백)
+ // 전량 순회는 allForUpdateCheck() — paginate 는 HTTP page 파라미터를 암묵 해석해
+ // `?page=2` 요청에서 순회가 0건이 된다 (공개 이슈 #102 동형)
$packs = $identifier
? collect([$this->repository->findByIdentifier($identifier)])->filter()
- : $this->repository->paginate([], 1000)->getCollection();
+ : $this->repository->allForUpdateCheck();
$checked = 0;
$updates = 0;
@@ -1675,8 +1683,9 @@ class LanguagePackService
return $updates;
}
- // DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨)
- $packs = $this->repository->paginate([], 1000)->getCollection();
+ // DB 에 설치된 모든 언어팩 (보호 가상 행 제외 — protected 로 필터됨).
+ // 전량 순회는 allForUpdateCheck() — paginate 의 암묵 page 해석 회피 (#102 동형)
+ $packs = $this->repository->allForUpdateCheck();
foreach ($packs as $pack) {
if (! $this->hasBundledManifest($pack->identifier)) {
diff --git a/app/Services/LayoutExtensionService.php b/app/Services/LayoutExtensionService.php
index b143da21..3efc2c53 100644
--- a/app/Services/LayoutExtensionService.php
+++ b/app/Services/LayoutExtensionService.php
@@ -2,7 +2,6 @@
namespace App\Services;
-use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
@@ -15,6 +14,7 @@ use App\Extension\HookManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
+use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Models\LayoutExtension;
use App\Models\TemplateLayoutExtensionVersion;
@@ -32,6 +32,7 @@ use Illuminate\Support\Facades\Log;
*/
class LayoutExtensionService
{
+ use ClearsTemplateCaches;
use ComputesLayoutContentHash;
/**
@@ -123,7 +124,6 @@ class LayoutExtensionService
private LayoutExtensionRepositoryInterface $repository,
private LayoutExtensionVersionRepositoryInterface $versionRepository,
private LayoutRepositoryInterface $layoutRepository,
- private CacheInterface $cache,
private PluginRepositoryInterface $pluginRepository,
private ModuleRepositoryInterface $moduleRepository,
) {}
@@ -1830,8 +1830,10 @@ class LayoutExtensionService
}
// extension_point 는 여러 레이아웃에서 사용될 수 있으므로 전역 캐시 버전 갱신
- // overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신
- $this->cache->put('ext.cache_version', time());
+ // overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신.
+ // 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 —
+ // 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다.
+ $this->incrementExtensionCacheVersion();
}
/**
diff --git a/app/Services/LayoutService.php b/app/Services/LayoutService.php
index e0f03b6e..4f764d2f 100644
--- a/app/Services/LayoutService.php
+++ b/app/Services/LayoutService.php
@@ -12,6 +12,7 @@ use App\Exceptions\CircularReferenceException;
use App\Exceptions\ConcurrentModificationException;
use App\Exceptions\LayoutIncludeException;
use App\Extension\HookManager;
+use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\PermissionHelper;
use App\Models\TemplateLayout;
use App\Models\User;
@@ -21,6 +22,8 @@ use Illuminate\Support\Facades\Log;
class LayoutService
{
+ use ClearsTemplateCaches;
+
/**
* LayoutRepository, LayoutVersionRepository, TemplateRepository, LayoutResolverService 및 LayoutExtensionService 주입
*/
@@ -1470,7 +1473,9 @@ class LayoutService
// 프론트엔드 브라우저 캐시 무효화를 위해 ext.cache_version 증가
// PublicLayoutController가 ?v={version} 기반 HTTP 캐시를 사용하므로
// 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
- $this->cache->put('ext.cache_version', time());
+ // 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 —
+ // 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다.
+ $this->incrementExtensionCacheVersion();
// After 훅 - 레이아웃 업데이트 후
HookManager::doAction('core.layout.after_update', $layout, $templateId, $name, $data);
@@ -1579,8 +1584,8 @@ class LayoutService
// 캐시 무효화
$this->clearDependentLayoutsCache($templateId, $name);
- // 프론트엔드 브라우저 캐시 무효화
- $this->cache->put('ext.cache_version', time());
+ // 프론트엔드 브라우저 캐시 무효화 (트레이트 단일 지점 경유)
+ $this->incrementExtensionCacheVersion();
// After 훅 - 버전 복원 후
HookManager::doAction('core.layout.after_version_restore', $newVersion, $templateId, $name, $versionId);
diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php
index 53a2ce12..c1c457c1 100644
--- a/app/Services/MenuService.php
+++ b/app/Services/MenuService.php
@@ -7,10 +7,13 @@ use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\MenuPermissionType;
use App\Extension\HookManager;
+use App\Helpers\PermissionHelper;
use App\Models\Menu;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class MenuService
@@ -182,13 +185,16 @@ class MenuService
]);
}
- // Hook: 메뉴 삭제 전
- HookManager::doAction('core.menu.before_delete', $menu);
+ // 역할 연결만 끊기고 메뉴는 남는 상태를 막기 위해 두 단계를 하나로 묶는다.
+ $result = DB::transaction(function () use ($menu) {
+ // Hook: 메뉴 삭제 전
+ HookManager::doAction('core.menu.before_delete', $menu);
- // 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
- $menu->roles()->detach();
+ // 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
+ $menu->roles()->detach();
- $result = $this->menuRepository->delete($menu);
+ return $this->menuRepository->delete($menu);
+ });
// Hook: 메뉴 삭제 후
HookManager::doAction('core.menu.after_delete', $menu->id);
@@ -199,11 +205,19 @@ class MenuService
/**
* 메뉴 순서를 업데이트합니다 (드래그 앤 드롭).
*
+ * 형제 `updateMenuOrderWithHierarchy` 와 같은 리소스를 같은 방식으로 쓰므로 스코프
+ * 가드도 대칭이어야 한다. 코어 내 호출부가 없다는 사실은 방어가 아니다 — 이 서비스는
+ * 확장이 주입받을 수 있고, 가드가 형제 경로에만 있으면 여기가 우회로가 된다.
+ *
* @param array $menuOrders 메뉴 ID와 순서 매핑 배열
* @return bool 업데이트 성공 여부
+ *
+ * @throws AuthorizationException 스코프 밖 메뉴가 하나라도 포함된 경우
*/
public function updateMenuOrder(array $menuOrders): bool
{
+ $this->assertMenusWithinScope(array_map('intval', array_values($menuOrders)));
+
// 훅: 메뉴 순서 변경 전 (IDV 정책 가드 지점)
HookManager::doAction('core.menu.before_update_order', $menuOrders);
@@ -223,6 +237,8 @@ class MenuService
*/
public function updateMenuOrderWithHierarchy(array $orderData): bool
{
+ $this->assertOrderTargetsWithinScope($orderData);
+
$result = $this->menuRepository->updateOrderWithHierarchy($orderData);
// 훅: 메뉴 계층 순서 변경 후
@@ -231,6 +247,74 @@ class MenuService
return $result;
}
+ /**
+ * 순서 변경 대상 메뉴가 액터의 스코프 안에 있는지 검사합니다.
+ *
+ * `PUT admin/menus/order` 는 라우트 모델이 없는 정적 경로라 PermissionMiddleware 의
+ * 스코프 검사가 스킵된다(`{menu}` 파라미터 부재 → "목록 엔드포인트" 로 간주). 상세
+ * 경로(`PUT menus/{menu}`)가 미들웨어로 강제하는 축이 이 경로에서만 비어 있었다.
+ * 배포 기본 역할은 `core.menus.update` 를 글로벌로 주므로 기본값 노출은 아니지만,
+ * 운영자가 역할 화면에서 스코프를 self/role 로 좁히는 순간 우회로가 된다.
+ *
+ * 첨부 순서 변경과 같은 이유로 **전량 거부**다 — 순서는 트리 전체에 대한 하나의
+ * 배열이라 일부만 반영하면 나머지와 어긋난 계층이 저장된다.
+ *
+ * @param array $orderData 순서 데이터 (parent_menus / child_menus / moved_items)
+ *
+ * @throws AuthorizationException 스코프 밖 메뉴가 하나라도 포함된 경우
+ */
+ private function assertOrderTargetsWithinScope(array $orderData): void
+ {
+ $ids = [];
+
+ foreach ($orderData['parent_menus'] ?? [] as $item) {
+ $ids[] = (int) ($item['id'] ?? 0);
+ }
+
+ foreach ($orderData['child_menus'] ?? [] as $children) {
+ foreach ($children as $item) {
+ $ids[] = (int) ($item['id'] ?? 0);
+ }
+ }
+
+ // 이동 항목은 **옮기는 메뉴 자신**만 확인한다. 새 부모까지 검사하면, 공용 상위 메뉴
+ // 아래에 자기 메뉴를 다는 정상 사용이 막힌다 — 결함은 "남의 메뉴 순서를 바꾼다" 였지
+ // "남의 메뉴 아래에 못 붙인다" 가 아니다.
+ foreach ($orderData['moved_items'] ?? [] as $item) {
+ $ids[] = (int) ($item['id'] ?? 0);
+ }
+
+ $this->assertMenusWithinScope($ids);
+ }
+
+ /**
+ * 주어진 메뉴 ID 집합이 전부 액터의 스코프 안에 있는지 검사합니다.
+ *
+ * 순서 변경 두 경로(`updateMenuOrder` · `updateMenuOrderWithHierarchy`)가 공유하는
+ * 판정부다. 한쪽만 검사하면 형제 경로가 우회로가 되므로 판정을 한 곳에 둔다.
+ *
+ * @param array $ids 검사 대상 메뉴 ID 목록
+ *
+ * @throws AuthorizationException 스코프 밖 메뉴가 하나라도 포함된 경우
+ */
+ private function assertMenusWithinScope(array $ids): void
+ {
+ $ids = array_values(array_unique(array_filter($ids)));
+
+ if (empty($ids)) {
+ return;
+ }
+
+ $menus = $this->menuRepository->findByIds($ids);
+
+ // 판정은 상세 경로와 같은 SSoT 에 위임한다.
+ $permitted = PermissionHelper::filterByScope($menus, 'core.menus.update');
+
+ if (count($permitted) !== $menus->count()) {
+ throw new AuthorizationException(__('auth.scope_denied'));
+ }
+ }
+
/**
* 메뉴의 활성화 상태를 토글합니다.
*
diff --git a/app/Services/NotificationLogService.php b/app/Services/NotificationLogService.php
index cc8a6c58..9ac8c6e4 100644
--- a/app/Services/NotificationLogService.php
+++ b/app/Services/NotificationLogService.php
@@ -93,6 +93,27 @@ class NotificationLogService
return $result;
}
+ /**
+ * 보존 기간이 지난 발송 이력을 정리합니다 (자동 파기).
+ *
+ * 운영자가 고른 ID 를 지우는 bulkDelete 와는 별개의 훅을 발행한다 — 자동 파기는
+ * 대상을 기간으로 정하고 사람 없이 예약 실행되므로, 대화형 가드를 전제한 훅에
+ * 얹으면 예약이 그 가드에 걸린다. 확장은 이 훅으로 자기 처리를 붙인다.
+ *
+ * @param int $days 보존 기간 (일)
+ * @return int 삭제된 건수
+ */
+ public function prune(int $days): int
+ {
+ HookManager::doAction('core.notification_log.before_prune', $days);
+
+ $count = $this->repository->deleteOlderThan($days);
+
+ HookManager::doAction('core.notification_log.after_prune', $days, $count);
+
+ return $count;
+ }
+
/**
* 다건 삭제합니다.
*
diff --git a/app/Services/PluginSettingsService.php b/app/Services/PluginSettingsService.php
index b8f6e2a5..3043333c 100644
--- a/app/Services/PluginSettingsService.php
+++ b/app/Services/PluginSettingsService.php
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Extension\HookManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
+use App\Support\ExtensionSettingsMirror;
use App\Support\SensitiveSettingMask;
use App\Traits\FiltersFrontendSchema;
use App\Traits\NormalizesSettingsData;
@@ -189,6 +190,10 @@ class PluginSettingsService
// 캐시 초기화
if ($result) {
unset($this->settingsCache[$identifier]);
+
+ // 같은 프로세스의 config 미러도 즉시 다시 채운다 (공개이슈 #109) —
+ // 이 호출이 없으면 상주 프로세스가 저장 후에도 옛 값을 계속 읽는다.
+ app(ExtensionSettingsMirror::class)->refreshPlugin($identifier);
}
// After 훅
@@ -247,6 +252,10 @@ class PluginSettingsService
// 캐시 초기화
unset($this->settingsCache[$identifier]);
+ // 초기화도 값을 바꾸는 쓰기다 — 미러를 두면 같은 프로세스가 초기화 전 값을
+ // 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
+ app(ExtensionSettingsMirror::class)->refreshPlugin($identifier);
+
// After 훅
HookManager::doAction('core.plugin_settings.after_reset', $identifier);
diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php
index cf033049..fc3eb827 100644
--- a/app/Services/RoleService.php
+++ b/app/Services/RoleService.php
@@ -3,20 +3,24 @@
namespace App\Services;
use App\Contracts\Repositories\RoleRepositoryInterface;
+use App\Exceptions\CannotModifyProtectedRoleException;
use App\Exceptions\ExtensionOwnedRoleDeleteException;
use App\Exceptions\SystemRoleDeleteException;
use App\Extension\HookManager;
use App\Models\Role;
use App\Models\User;
+use App\Support\PermissionEscalationGuard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RoleService
{
public function __construct(
- private RoleRepositoryInterface $roleRepository
+ private RoleRepositoryInterface $roleRepository,
+ private PermissionEscalationGuard $escalationGuard
) {}
/**
@@ -104,6 +108,12 @@ class RoleService
$permissions = $data['permissions'] ?? [];
unset($data['permissions']);
+ // 권한 상승 상한을 **역할 생성 전에** 검사한다. 생성 뒤에 검사하면 거부된 요청이
+ // 권한 0개짜리 고아 역할 행을 남긴다(사용자 경로와 동일한 "가드 → 쓰기" 순서).
+ if (! empty($permissions)) {
+ $this->escalationGuard->assertGrantWithinActorCeiling($permissions);
+ }
+
// 훅: 생성 전
HookManager::doAction('core.role.before_create', $data);
@@ -133,10 +143,20 @@ class RoleService
*/
public function updateRole(Role $role, array $data): Role
{
+ // 보호된 역할(코어/확장 소유) 수정 상한: 삭제 경로와 대칭.
+ // 비-슈퍼관리자 액터는 admin 등 코어/확장 소유 역할을 변경할 수 없다.
+ $this->assertActorMayModifyRole($role);
+
// 권한 목록 분리
$permissions = $data['permissions'] ?? null;
unset($data['permissions']);
+ // 권한 상승 상한을 **속성 업데이트 전에** 검사한다. update 뒤에 검사하면
+ // 403 을 받은 요청이 name/is_active 변경만 반영된 상태를 남긴다.
+ if ($permissions !== null) {
+ $this->escalationGuard->assertGrantWithinActorCeiling($permissions);
+ }
+
// 훅: 업데이트 전 (원본 data 전달)
HookManager::doAction('core.role.before_update', $role, $data);
@@ -183,16 +203,20 @@ class RoleService
throw new ExtensionOwnedRoleDeleteException;
}
- // 훅: 삭제 전
- HookManager::doAction('core.role.before_delete', $role);
+ // 관계 해제 후 삭제가 실패하면 그 역할을 가진 사용자 전원이 권한만 잃고
+ // 역할은 그대로 남는다. 전 단계를 하나로 묶는다.
+ $result = DB::transaction(function () use ($role) {
+ // 훅: 삭제 전
+ HookManager::doAction('core.role.before_delete', $role);
- // 관계 해제 (명시적 삭제 - CASCADE 의존 금지)
- $this->roleRepository->detachAllPermissions($role);
- $role->menus()->detach();
- $role->users()->detach();
+ // 관계 해제 (명시적 삭제 - CASCADE 의존 금지)
+ $this->roleRepository->detachAllPermissions($role);
+ $role->menus()->detach();
+ $role->users()->detach();
- // 역할 삭제
- $result = $this->roleRepository->delete($role);
+ // 역할 삭제
+ return $this->roleRepository->delete($role);
+ });
// 훅: 삭제 후
HookManager::doAction('core.role.after_delete', $role->id);
@@ -208,6 +232,15 @@ class RoleService
*/
public function syncPermissions(Role $role, array $permissions): void
{
+ // 보호된 역할(코어/확장 소유) 수정 상한: updateRole·toggleRoleStatus 와 대칭.
+ // 이 메서드는 public 이므로 서비스를 주입한 확장이 직접 호출할 수 있다 —
+ // 형제 경로에만 가드를 두면 여기가 코어 역할 보호의 우회로가 된다.
+ $this->assertActorMayModifyRole($role);
+
+ // 권한 상승 상한(ceiling): 비-슈퍼관리자 액터는 자신이 보유하지 않았거나
+ // 자신의 범위(scope)보다 넓은 범위의 권한을 부여할 수 없다(SSoT 가드에 위임).
+ $this->escalationGuard->assertGrantWithinActorCeiling($permissions);
+
// 동기화 전 현재 권한 식별자 캡처 (Listener diff 계산용)
$previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
@@ -239,6 +272,9 @@ class RoleService
*/
public function toggleRoleStatus(Role $role): bool
{
+ // 보호된 역할(코어/확장 소유) 상태변경 상한: 삭제 경로와 대칭.
+ $this->assertActorMayModifyRole($role);
+
$newStatus = ! $role->is_active;
// 훅: 상태 변경 전
@@ -254,6 +290,37 @@ class RoleService
return $result;
}
+ /**
+ * 액터가 보호된 역할(코어/확장 소유)을 수정할 수 있는지 확인합니다.
+ *
+ * 인증 액터가 없으면(Artisan/내부 시더) 신뢰 경로로 간주해 통과시킵니다.
+ * 슈퍼 관리자는 모든 역할을 수정할 수 있고, 그 외 액터는 삭제 경로와 동일하게
+ * 코어/확장 소유 역할을 수정할 수 없습니다.
+ *
+ * @param Role $role 대상 역할
+ *
+ * @throws CannotModifyProtectedRoleException 상한 위반 시
+ */
+ private function assertActorMayModifyRole(Role $role): void
+ {
+ $actor = Auth::user();
+
+ // 인증 액터 부재 = 내부/Artisan 신뢰 경로 → 가드 미적용
+ if (! $actor instanceof User) {
+ return;
+ }
+
+ // 슈퍼 관리자는 모든 역할 수정 가능
+ if ($actor->isSuperAdmin()) {
+ return;
+ }
+
+ // 비-슈퍼관리자는 코어/확장 소유 역할 수정 불가
+ if ($role->isCore() || $role->isExtensionOwned()) {
+ throw new CannotModifyProtectedRoleException;
+ }
+ }
+
/**
* name에서 identifier를 자동 생성합니다.
*
diff --git a/app/Services/ScheduleService.php b/app/Services/ScheduleService.php
index 0a2978f1..f68899f5 100644
--- a/app/Services/ScheduleService.php
+++ b/app/Services/ScheduleService.php
@@ -122,13 +122,16 @@ class ScheduleService
public function delete(Schedule $schedule): bool
{
try {
- // before_delete 훅 실행
- HookManager::doAction('core.schedule.before_delete', $schedule);
+ // 이력만 지워지고 스케줄은 남는 상태를 막기 위해 두 단계를 하나로 묶는다.
+ $result = DB::transaction(function () use ($schedule) {
+ // before_delete 훅 실행
+ HookManager::doAction('core.schedule.before_delete', $schedule);
- // 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
- $schedule->histories()->delete();
+ // 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
+ $schedule->histories()->delete();
- $result = $this->scheduleRepository->delete($schedule);
+ return $this->scheduleRepository->delete($schedule);
+ });
// after_delete 훅 실행
HookManager::doAction('core.schedule.after_delete', $schedule->id);
@@ -488,6 +491,26 @@ class ScheduleService
];
}
+ /**
+ * 보존 기간이 지난 실행 이력을 정리합니다 (자동 파기).
+ *
+ * 운영자가 고른 ID 를 지우는 bulkDeleteHistory 와 달리 대상을 기간으로 정하고
+ * 사람 없이 예약 실행되므로 별개의 훅을 발행합니다.
+ *
+ * @param int $days 보존 기간 (일)
+ * @return int 삭제된 건수
+ */
+ public function pruneHistory(int $days): int
+ {
+ HookManager::doAction('core.schedule.before_prune_history', $days);
+
+ $deletedCount = $this->historyRepository->deleteOlderThan($days);
+
+ HookManager::doAction('core.schedule.after_prune_history', $days, $deletedCount);
+
+ return $deletedCount;
+ }
+
/**
* 활성화된 스케줄들을 조회합니다.
*
diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php
index a191a5bc..80072b9d 100644
--- a/app/Services/SettingsService.php
+++ b/app/Services/SettingsService.php
@@ -9,6 +9,7 @@ use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Support\ConfigCacheHelper;
+use App\Support\ExtensionSettingsMirror;
use App\Support\OpcacheStatus;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -28,7 +29,8 @@ class SettingsService
public function __construct(
private ConfigRepositoryInterface $configRepository,
private AttachmentRepositoryInterface $attachmentRepository,
- private CacheInterface $cache
+ private CacheInterface $cache,
+ private AttachmentService $attachmentService
) {}
/**
@@ -43,6 +45,31 @@ class SettingsService
{
$this->cache->forget('settings.system');
ConfigCacheHelper::rebuild();
+
+ // 같은 프로세스의 in-memory 미러도 즉시 다시 채운다 (공개이슈 #109).
+ // 이 호출이 없으면 상주 프로세스(큐 워커·schedule:work·Reverb)는 저장 후에도
+ // 부팅 시점의 옛 값을 영원히 읽는다 — FPM 에서만 드러나지 않는 결함이다.
+ app(ExtensionSettingsMirror::class)->refreshCore();
+ }
+
+ /**
+ * 큐 워커에 정상 종료 후 재시작 신호를 보냅니다.
+ *
+ * drivers 카테고리(queue/broadcasting/cache 등)는 long-running worker 에 영향을 준다.
+ * SettingsServiceProvider 는 worker boot 시점에 한 번만 config 를 적용하므로, 재시작
+ * 신호가 없으면 워커가 부팅 시점의 옛 드라이버로 계속 동작한다.
+ *
+ * 신호 전송 실패가 설정 저장을 되돌리지는 않는다 (경고 로깅 후 계속).
+ */
+ private function restartQueueWorkers(): void
+ {
+ try {
+ Artisan::call('queue:restart');
+ } catch (\Throwable $e) {
+ Log::warning('queue:restart 실행 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
/**
@@ -489,9 +516,18 @@ class SettingsService
return $result;
}
- // general 탭인 경우 site_logo 첨부파일 연결
- if ($tab === 'general') {
- $tabSettings['site_logo'] = $this->collectSiteLogoIds();
+ // general 탭인 경우 site_logo 첨부파일 연결.
+ // site_logo 를 제출하지 않은 저장(다른 필드만 변경)은 기존 저장값을 그대로 둔다 —
+ // 이때 컬렉션을 다시 훑으면 미참조 첨부가 설정으로 딸려 들어온다.
+ $removedSiteLogoIds = [];
+
+ if ($tab === 'general' && is_array($tabSettings['site_logo'] ?? null)) {
+ // 파기 대상 판정은 저장 **전에** 한다 — 저장 후에는 직전 저장값을 알 수 없다.
+ // 실제 파기는 저장이 성공한 뒤에 수행한다: 저장이 실패했는데 파일만 사라지면
+ // 설정에는 이미 없는 첨부 id 가 남아 로고가 깨진다.
+ $removedSiteLogoIds = $this->resolveRemovedSiteLogoIds($tabSettings['site_logo']);
+
+ $tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
}
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
@@ -510,6 +546,9 @@ class SettingsService
if ($result) {
$this->invalidateSettingsCache();
+ // 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
+ $this->purgeSiteLogoAttachments($removedSiteLogoIds);
+
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
// 않아 브라우저 자가 복구가 닿지 않는다 → 캐시를 비워 재생성시킨다.
@@ -522,13 +561,7 @@ class SettingsService
// SettingsServiceProvider는 worker boot 시점에 한 번만 config 적용하므로
// 워커가 정상 종료 후 재시작되도록 신호 전송 (cache 기반, 즉시 종료 X)
if ($tab === 'drivers') {
- try {
- Artisan::call('queue:restart');
- } catch (\Throwable $e) {
- Log::warning('queue:restart 실행 실패', [
- 'error' => $e->getMessage(),
- ]);
- }
+ $this->restartQueueWorkers();
}
}
@@ -664,15 +697,120 @@ class SettingsService
}
/**
- * site_logo 컬렉션의 첨부파일 ID 목록을 수집합니다.
+ * 저장할 사이트 로고 첨부 ID 목록을 결정합니다.
*
- * @return array 첨부파일 ID 배열
+ * 기준은 **이번 저장 요청이 제출한 목록**입니다. 컬렉션 전체를 다시 훑으면, 저장에 실패했거나
+ * 작성 중 이탈해 남은 미참조 첨부까지 설정에 다시 편입되어(운영자가 올린 적 없는 로고가
+ * 되살아나는) 누적이 발생합니다.
+ *
+ * 제출값에 있더라도 실제로 존재하지 않는 첨부(다른 경로로 이미 삭제된 id)는 걸러냅니다.
+ *
+ * @param array $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열)
+ * @return array 저장할 첨부파일 ID 배열
*/
- private function collectSiteLogoIds(): array
+ private function resolveSiteLogoIds(array $submitted): array
{
- $attachments = $this->attachmentRepository->getByCollection('site_logo');
+ $submittedIds = $this->extractAttachmentIds($submitted);
- return $attachments->pluck('id')->toArray();
+ if ($submittedIds === []) {
+ return [];
+ }
+
+ $existingIds = $this->attachmentRepository->getByCollection('site_logo')
+ ->pluck('id')
+ ->all();
+
+ return array_values(array_intersect($submittedIds, $existingIds));
+ }
+
+ /**
+ * 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
+ *
+ * 판정 기준은 **직전 저장값**입니다. 직전에 저장돼 있었는데 이번 제출에서 빠진 id 만
+ * 운영자가 명시적으로 뺀 것이고, 직전 저장값에도 없던 id 는 이번에 새로 올라온 첨부입니다.
+ * 그래서 이 판정은 저장으로 값이 덮이기 **전에** 수행해야 합니다.
+ *
+ * 저장할 목록 자체는 제출값이 정합니다(resolveSiteLogoIds) — 컬렉션 전체를 훑으면 저장에
+ * 실패했거나 이탈로 남은 미참조 첨부가 설정에 되살아납니다.
+ *
+ * @param mixed $submitted 제출된 site_logo 값 (첨부 객체 배열 또는 ID 배열, 미제출이면 null)
+ * @return array 파기 대상 첨부 ID 목록
+ */
+ private function resolveRemovedSiteLogoIds(mixed $submitted): array
+ {
+ // site_logo 를 아예 제출하지 않은 저장(다른 필드만 변경)은 판정 대상이 아니다.
+ if (! is_array($submitted)) {
+ return [];
+ }
+
+ $previousIds = $this->extractAttachmentIds(
+ $this->configRepository->getCategory('general')['site_logo'] ?? []
+ );
+
+ if ($previousIds === []) {
+ return [];
+ }
+
+ $keptIds = $this->extractAttachmentIds($submitted);
+
+ return array_values(array_diff($previousIds, $keptIds));
+ }
+
+ /**
+ * 제거가 확정된 사이트 로고 첨부를 파일까지 파기합니다.
+ *
+ * 설정 저장이 성공한 뒤에만 호출합니다 — 저장이 실패했는데 파일이 먼저 사라지면 설정에는
+ * 이미 없는 첨부 id 가 남아 로고가 깨집니다.
+ *
+ * @param array $removedIds 파기 대상 첨부 ID 목록
+ */
+ private function purgeSiteLogoAttachments(array $removedIds): void
+ {
+ if ($removedIds === []) {
+ return;
+ }
+
+ foreach ($removedIds as $removedId) {
+ // 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
+ // 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
+ try {
+ $this->attachmentService->delete($removedId);
+ } catch (\Exception $e) {
+ Log::warning('사이트 로고 첨부 파기 실패 — 저장은 확정됨', [
+ 'attachment_id' => $removedId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ Log::info('사이트 로고 첨부 제거', ['attachment_ids' => $removedIds]);
+ }
+
+ /**
+ * 첨부 목록 값에서 첨부 ID 만 추출합니다.
+ *
+ * 저장값은 ID 배열이지만 화면 제출값은 첨부 객체 배열이라 두 형태를 모두 받습니다.
+ *
+ * @param mixed $value 첨부 목록 값
+ * @return array 첨부 ID 목록
+ */
+ private function extractAttachmentIds(mixed $value): array
+ {
+ if (! is_array($value)) {
+ return [];
+ }
+
+ $ids = [];
+
+ foreach ($value as $item) {
+ $id = is_array($item) ? ($item['id'] ?? null) : $item;
+
+ if (is_numeric($id)) {
+ $ids[] = (int) $id;
+ }
+ }
+
+ return array_values(array_unique($ids));
}
/**
@@ -690,6 +828,18 @@ class SettingsService
/**
* 단일 설정 값을 저장합니다.
*
+ * 벌크 저장(saveSettings)이 수행하는 부수효과 중 저장 키에 해당하는 것을 함께 수행한다
+ * (공개 #114 동종). 예전에는 값만 쓰고 SEO 프리렌더 캐시 삭제·큐 워커 재시작 신호를
+ * 건너뛰어, 같은 값을 어느 경로로 바꾸느냐에 따라 시스템 상태가 달라졌다.
+ *
+ * 키는 **원본 저장소 키**로 받는다 — 벌크 저장의 `reverseFrontendKeys()`(화면 키 → 저장소
+ * 키 역변환)를 적용하지 않는다. 이 경로의 프로그램 호출자(본인인증 플러그인 설치/삭제의
+ * `identity.purpose_providers.*`)가 저장소 키를 직접 넘기고 있어, 역변환을 끼우면 그
+ * 호출들이 엉뚱한 키에 저장된다.
+ *
+ * 벌크 위임도 하지 않는다 — 벌크의 shallow `array_merge` 로는 깊은 키를 저장할 때
+ * 형제 매핑이 통째로 소실된다.
+ *
* @param string $key 설정 키 (예: 'general.site_name')
* @param mixed $value 저장할 값
* @return bool 저장 성공 여부
@@ -700,10 +850,25 @@ class SettingsService
HookManager::doAction('core.settings.before_set', $key, $value);
try {
+ // 자산 URL 방식이 바뀌는지 저장 **전에** 판정한다 (이슈 #486 동형).
+ // 저장 후에는 이전 값을 알 수 없어 변경 여부를 판별할 수 없다.
+ $assetUrlModeChanged = $key === 'general.asset_url_mode'
+ && $this->configRepository->get($key) !== $value;
+
$result = $this->configRepository->set($key, $value);
if ($result) {
- $this->cache->forget('settings.system');
+ // 인라인 무효화 대신 공통 경로를 탄다 — saveSettings/saveAdvancedSettings 와
+ // 같은 처리를 받아야 미러 재채움·디스크 config 캐시 재생성이 빠지지 않는다.
+ $this->invalidateSettingsCache();
+
+ if ($assetUrlModeChanged) {
+ $this->clearSeoCacheForAssetUrlMode();
+ }
+
+ if (str_starts_with($key, 'drivers.') || $key === 'drivers') {
+ $this->restartQueueWorkers();
+ }
}
// After 훅
@@ -738,7 +903,9 @@ class SettingsService
$result = $this->configRepository->restore($backupPath);
if ($result) {
- $this->cache->forget('settings.system');
+ // 복원도 설정 전체를 갈아엎는 쓰기다 — 캐시만 비우고 미러를 두면
+ // 같은 프로세스가 복원 전 값을 계속 읽는다 (저장 경로와 동일 결함, 공개이슈 #109).
+ $this->invalidateSettingsCache();
}
return $result;
diff --git a/app/Services/StorageLeftoverPruneService.php b/app/Services/StorageLeftoverPruneService.php
new file mode 100644
index 00000000..63fb277a
--- /dev/null
+++ b/app/Services/StorageLeftoverPruneService.php
@@ -0,0 +1,347 @@
+> 대상군별 삭제(예정) 경로 목록
+ */
+ public function prune(int $days = 3, int $backupDays = 30, bool $dryRun = false): array
+ {
+ $now = time();
+ $cutoff = $now - $days * 86400;
+ $backupCutoff = $now - $backupDays * 86400;
+
+ return [
+ 'staging' => $this->pruneUpdateStaging($cutoff, $dryRun),
+ 'temp' => $this->pruneCoreTemp($cutoff, $dryRun),
+ 'vendor_bundle_staging' => $this->pruneVendorBundleStaging($cutoff, $dryRun),
+ 'extension_backups' => $this->pruneExtensionBackups($backupCutoff, $dryRun),
+ 'core_backups' => $this->pruneCoreBackups($backupCutoff, $dryRun),
+ 'legacy_browser_log' => $this->pruneLegacyBrowserLog($dryRun),
+ ];
+ }
+
+ /**
+ * `{modules,plugins,templates}/_pending` 의 스테이징/교체 잔존물을 정리합니다.
+ *
+ * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneUpdateStaging(int $cutoff, bool $dryRun): array
+ {
+ $pruned = [];
+
+ foreach (['modules', 'plugins', 'templates'] as $type) {
+ $pendingRoot = $this->basePath($type.DIRECTORY_SEPARATOR.'_pending');
+
+ if (! File::isDirectory($pendingRoot)) {
+ continue;
+ }
+
+ foreach (File::directories($pendingRoot) as $dir) {
+ $name = basename($dir);
+
+ // 타임스탬프/교체 마커가 없는 디렉토리는 다운로드 대기 정상 확장 — 불가침.
+ if (! preg_match(self::STAGING_TIMESTAMP_PATTERN, $name)
+ && ! preg_match(self::SWAP_LEFTOVER_PATTERN, $name)) {
+ continue;
+ }
+
+ if ($this->isFresh($dir, $cutoff)) {
+ continue;
+ }
+
+ $pruned[] = $dir;
+
+ if (! $dryRun) {
+ File::deleteDirectory($dir);
+ }
+ }
+ }
+
+ return $pruned;
+ }
+
+ /**
+ * `storage/app/temp` 직하위 전부(디렉토리·파일)를 보존일 기준으로 정리합니다.
+ *
+ * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneCoreTemp(int $cutoff, bool $dryRun): array
+ {
+ return $this->pruneEntriesByAge(
+ $this->storagePath('app'.DIRECTORY_SEPARATOR.'temp'.DIRECTORY_SEPARATOR.'*'),
+ $cutoff,
+ $dryRun
+ );
+ }
+
+ /**
+ * vendor 번들 스테이징(`build-*`)을 보존일 기준으로 정리합니다.
+ *
+ * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneVendorBundleStaging(int $cutoff, bool $dryRun): array
+ {
+ return $this->pruneEntriesByAge(
+ $this->storagePath('app'.DIRECTORY_SEPARATOR.'vendor-bundle-staging'.DIRECTORY_SEPARATOR.'build-*'),
+ $cutoff,
+ $dryRun
+ );
+ }
+
+ /**
+ * 확장 백업(`extension_backups/{type}/{identifier}_{Ymd_His}`)을 정리합니다.
+ *
+ * identifier 별 최신 1개는 나이와 무관하게 보존하고, 나머지 중 보존일 경과분만
+ * 삭제합니다. type 디렉토리는 열거로 얻으므로 새 백업 유형(lang-packs 등)도
+ * 하드코딩 없이 같은 규칙을 받습니다.
+ *
+ * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneExtensionBackups(int $backupCutoff, bool $dryRun): array
+ {
+ $pruned = [];
+ $backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'extension_backups');
+
+ if (! File::isDirectory($backupRoot)) {
+ return $pruned;
+ }
+
+ foreach (File::directories($backupRoot) as $typeDir) {
+ /** @var array> $byIdentifier */
+ $byIdentifier = [];
+
+ foreach (File::directories($typeDir) as $dir) {
+ if (! preg_match(self::BACKUP_NAME_PATTERN, basename($dir), $matches)) {
+ continue;
+ }
+
+ $byIdentifier[$matches[1]][] = ['path' => $dir, 'stamp' => $matches[2]];
+ }
+
+ foreach ($byIdentifier as $entries) {
+ array_push($pruned, ...$this->pruneBackupSet($entries, $backupCutoff, $dryRun));
+ }
+ }
+
+ return $pruned;
+ }
+
+ /**
+ * 코어 백업(`core_backups/core_{Ymd_His}`)을 정리합니다 — 최신 1개 상시 보존.
+ *
+ * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneCoreBackups(int $backupCutoff, bool $dryRun): array
+ {
+ $backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'core_backups');
+
+ if (! File::isDirectory($backupRoot)) {
+ return [];
+ }
+
+ $entries = [];
+
+ foreach (File::directories($backupRoot) as $dir) {
+ if (! preg_match(self::CORE_BACKUP_NAME_PATTERN, basename($dir), $matches)) {
+ continue;
+ }
+
+ $entries[] = ['path' => $dir, 'stamp' => $matches[1]];
+ }
+
+ return $this->pruneBackupSet($entries, $backupCutoff, $dryRun);
+ }
+
+ /**
+ * 레거시 단일 `storage/logs/browser.log` 를 정리합니다.
+ *
+ * 브라우저 로그가 daily 드라이버로 전환되어 이 파일에 쓰는 코드는 더 이상 없다 —
+ * 존재 자체가 잔존물이므로 나이와 무관하게 삭제합니다. 날짜별 `browser-*.log` 는
+ * daily 드라이버가 보존일로 자체 정리하므로 대상이 아닙니다.
+ *
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneLegacyBrowserLog(bool $dryRun): array
+ {
+ $path = $this->storagePath('logs'.DIRECTORY_SEPARATOR.'browser.log');
+
+ if (! File::exists($path)) {
+ return [];
+ }
+
+ if (! $dryRun) {
+ File::delete($path);
+ }
+
+ return [$path];
+ }
+
+ /**
+ * 같은 백업 집합에서 최신 1개를 제외하고 보존일 경과분을 삭제합니다.
+ *
+ * 최신 판정은 디렉토리명 타임스탬프(`Ymd_His` — 사전순 = 시간순) 기준입니다.
+ *
+ * @param list $entries 같은 identifier 의 백업 목록
+ * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneBackupSet(array $entries, int $backupCutoff, bool $dryRun): array
+ {
+ if ($entries === []) {
+ return [];
+ }
+
+ usort($entries, static fn (array $a, array $b): int => strcmp($b['stamp'], $a['stamp']));
+
+ // 최신 1개는 롤백 자산 — 나이와 무관하게 보존.
+ array_shift($entries);
+
+ $pruned = [];
+
+ foreach ($entries as $entry) {
+ if ($this->isFresh($entry['path'], $backupCutoff)) {
+ continue;
+ }
+
+ $pruned[] = $entry['path'];
+
+ if (! $dryRun) {
+ File::deleteDirectory($entry['path']);
+ }
+ }
+
+ return $pruned;
+ }
+
+ /**
+ * glob 패턴에 걸리는 항목(디렉토리·파일)을 보존일 기준으로 정리합니다.
+ *
+ * @param string $pattern glob 패턴
+ * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
+ * @param bool $dryRun 삭제 없이 목록만
+ * @return list 삭제(예정) 경로 목록
+ */
+ private function pruneEntriesByAge(string $pattern, int $cutoff, bool $dryRun): array
+ {
+ $pruned = [];
+
+ foreach (File::glob($pattern) ?: [] as $entry) {
+ if ($this->isFresh($entry, $cutoff)) {
+ continue;
+ }
+
+ $pruned[] = $entry;
+
+ if (! $dryRun) {
+ is_dir($entry) ? File::deleteDirectory($entry) : File::delete($entry);
+ }
+ }
+
+ return $pruned;
+ }
+
+ /**
+ * 항목의 mtime 이 보존일 이내인지 판정합니다 (mtime 조회 실패 시 보존 — fail-safe).
+ *
+ * @param string $path 대상 경로
+ * @param int $cutoff 기준 시각 (Unix time)
+ * @return bool 보존일 이내(삭제 금지)면 true
+ */
+ private function isFresh(string $path, int $cutoff): bool
+ {
+ $mtime = @filemtime($path);
+
+ return $mtime === false || $mtime > $cutoff;
+ }
+
+ /**
+ * 프로젝트 루트 기준 경로를 만듭니다.
+ *
+ * @param string $path 루트 기준 상대 경로
+ * @return string 절대 경로
+ */
+ private function basePath(string $path): string
+ {
+ return ($this->baseRoot ?? base_path()).DIRECTORY_SEPARATOR.$path;
+ }
+
+ /**
+ * 스토리지 루트 기준 경로를 만듭니다.
+ *
+ * @param string $path 스토리지 루트 기준 상대 경로
+ * @return string 절대 경로
+ */
+ private function storagePath(string $path): string
+ {
+ return ($this->storageRoot ?? storage_path()).DIRECTORY_SEPARATOR.$path;
+ }
+}
diff --git a/app/Services/TemplateCustomTranslationService.php b/app/Services/TemplateCustomTranslationService.php
index 66fed7ef..952c5c84 100644
--- a/app/Services/TemplateCustomTranslationService.php
+++ b/app/Services/TemplateCustomTranslationService.php
@@ -2,9 +2,9 @@
namespace App\Services;
-use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface;
use App\Exceptions\ConcurrentModificationException;
+use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\TemplateCustomTranslation;
use App\Services\LanguagePack\CustomTranslationUsageScanner;
use Illuminate\Database\Eloquent\Collection;
@@ -22,10 +22,12 @@ use Illuminate\Database\QueryException;
* - seq 충돌 시 재시도 (unique 제약 위반 → seq 증가)
* - 신규 키 생성 시 모든 활성 로케일에 폴백 시드
* - 낙관적 잠금 (`lock_version` — update 경로)
- * - 커스텀 키 CRUD 시 다국어 캐시 무효화
+ * - 커스텀 키 CRUD 시 다국어 캐시 무효화
*/
class TemplateCustomTranslationService
{
+ use ClearsTemplateCaches;
+
/**
* seq 충돌 재시도 최대 횟수.
*/
@@ -33,11 +35,9 @@ class TemplateCustomTranslationService
/**
* @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 다국어 키 리포지토리
- * @param CacheInterface $cache 캐시 드라이버
*/
public function __construct(
private readonly TemplateCustomTranslationRepositoryInterface $repository,
- private readonly CacheInterface $cache,
) {}
/**
@@ -287,11 +287,12 @@ class TemplateCustomTranslationService
*
* `serveLanguage` 캐시 키가 `template.language.{id}.{locale}.v{cacheVersion}`
* 형태로 `ext.cache_version` 을 포함하므로, 이 값을 갱신하면 다음
- * `loadTranslations` 가 재fetch 합니다.
+ * `loadTranslations` 가 재fetch 합니다. 쓰기는 트레이트 단일 지점
+ * (고정 CoreCacheDriver + 메모이즈 스토어) 경유.
*/
private function invalidateLanguageCache(): void
{
- $this->cache->put('ext.cache_version', time());
+ $this->incrementExtensionCacheVersion();
}
/**
diff --git a/app/Services/TemplateLayoutAttachmentService.php b/app/Services/TemplateLayoutAttachmentService.php
index 3506d6d0..5630f0bd 100644
--- a/app/Services/TemplateLayoutAttachmentService.php
+++ b/app/Services/TemplateLayoutAttachmentService.php
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Extension\HookManager;
use App\Models\TemplateLayoutAttachment;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
@@ -12,6 +13,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 템플릿 레이아웃 첨부 파일 서비스
@@ -52,6 +54,12 @@ class TemplateLayoutAttachmentService
return ['success' => false, 'attachment' => null, 'url' => null, 'error' => 'template_not_found'];
}
+ // 훅: 업로드 전 (확장 지점)
+ HookManager::doAction('core.template_layout_attachment.before_upload', $file, $templateIdentifier, $layoutName);
+
+ // 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
+ $file = HookManager::applyFilters('core.template_layout_attachment.filter_upload_file', $file);
+
// 저장 경로 — 템플릿 식별자/날짜별 디렉토리 + UUID 파일명 (충돌 회피)
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
$relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}";
@@ -87,6 +95,8 @@ class TemplateLayoutAttachmentService
'path' => $relativePath,
]);
+ HookManager::doAction('core.template_layout_attachment.after_upload', $attachment);
+
return [
'success' => true,
'attachment' => $attachment,
@@ -160,17 +170,17 @@ class TemplateLayoutAttachmentService
}
/**
- * 서빙 가능한 첨부 파일의 절대 경로를 돌려줍니다.
+ * 서빙 가능한 첨부의 인라인 스트림 응답과 캐싱 메타를 돌려줍니다.
*
- * 첨부가 주어진 템플릿 소속인지 검증하고, 스토리지에 파일이 실제로 존재하면
- * 컨트롤러가 스트림할 수 있는 절대 파일시스템 경로를 반환한다. 소속 불일치 /
- * 파일 부재 시 null (컨트롤러가 404).
+ * 첨부가 주어진 템플릿 소속인지 검증하고, 행 disk 를 따르는 스토리지 스트림
+ * 응답을 반환한다 (S3 등 원격 디스크 행 포함 — 로컬 절대 경로 방식은 #99 에서
+ * filemtime 500). 소속 불일치 / 파일 부재 시 null (컨트롤러가 404).
*
* @param string $templateIdentifier 요청 경로의 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 서빙 대상 첨부
- * @return string|null 절대 파일 경로 또는 null
+ * @return array{response: StreamedResponse, etag_source: string}|null 서빙 정보 또는 null
*/
- public function getServableFilePath(string $templateIdentifier, TemplateLayoutAttachment $attachment): ?string
+ public function getServableResponse(string $templateIdentifier, TemplateLayoutAttachment $attachment): ?array
{
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
// 경로 식별자와 첨부의 소속 템플릿이 일치해야 한다 (교차 템플릿 접근 차단).
@@ -178,12 +188,31 @@ class TemplateLayoutAttachmentService
return null;
}
- $driver = $this->storage->withDisk($attachment->disk);
- if (! $driver->exists(self::STORAGE_CATEGORY, $attachment->path)) {
+ // 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행).
+ // 로컬 절대 경로 조립(getBasePath)은 S3 등 원격 디스크 행에서 성립하지 않는다 (#99).
+ $response = $this->storage->withDisk($attachment->disk)->response(
+ self::STORAGE_CATEGORY,
+ $attachment->path,
+ $attachment->original_name ?? basename($attachment->path),
+ [
+ 'Content-Type' => $attachment->mime_type,
+ 'Content-Length' => (string) $attachment->size,
+ ]
+ );
+
+ if (! $response) {
return null;
}
- // 절대 경로 — {category base}/{relative path}. fileResponse 가 절대 경로를 요구.
- return rtrim($driver->getBasePath(self::STORAGE_CATEGORY), '/\\').'/'.$attachment->path;
+ return [
+ 'response' => $response,
+ // 파일 stat 없이 결정적인 ETag 소스 (업로드 파일은 경로당 불변)
+ 'etag_source' => implode('|', [
+ $attachment->disk,
+ $attachment->path,
+ (string) $attachment->updated_at?->getTimestamp(),
+ (string) $attachment->size,
+ ]),
+ ];
}
}
diff --git a/app/Services/TemplateService.php b/app/Services/TemplateService.php
index 0e73bd19..658c913d 100644
--- a/app/Services/TemplateService.php
+++ b/app/Services/TemplateService.php
@@ -36,8 +36,12 @@ class TemplateService
private PluginManagerInterface $pluginManager,
private LayoutVersionRepositoryInterface $layoutVersionRepository
) {
- // TemplateManager 초기화 (템플릿 스캔)
- $this->templateManager->loadTemplates();
+ // TemplateManager 초기화 — 아직 로드되지 않았을 때만 스캔한다.
+ // 무조건 loadTemplates() 를 부르면 공유 싱글톤의 템플릿 맵을 리셋한 뒤 디렉토리를
+ // 통째로 재스캔하므로, 이 서비스가 주입될 때마다 풀스캔과 상태 변형이 반복된다.
+ // (웹/serve/test 는 CoreServiceProvider::boot 가 로드를 보장하지만, 그 외 콘솔 경로는
+ // 로딩을 건너뛰므로 이 초기화 자체를 없앨 수는 없다)
+ $this->templateManager->ensureLoaded();
}
/**
@@ -1213,6 +1217,11 @@ class TemplateService
*/
public function getEditorRoutesDataWithModules(string $identifier): array
{
+ // 열화 판정은 이 호출의 병합 결과만 가리켜야 한다. 이 서비스는 공유 인스턴스라
+ // 리셋하지 않으면 직전 호출(업데이트 스왑 창)의 판정이 인스턴스에 눌어붙어,
+ // 모듈 디렉토리가 복구된 뒤의 병합까지 열화로 보고된다.
+ $this->routeMergeDegraded = false;
+
// 1. routes.json 경로 — 활성 디렉토리 우선, _bundled 폴백 (활성/비활성 무관).
$candidates = [
base_path("templates/{$identifier}/routes.json"),
diff --git a/app/Services/UserService.php b/app/Services/UserService.php
index a4444465..4a398929 100644
--- a/app/Services/UserService.php
+++ b/app/Services/UserService.php
@@ -6,17 +6,22 @@ use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Enums\UserStatus;
use App\Exceptions\CannotDeleteSuperAdminException;
+use App\Exceptions\PermissionEscalationException;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Models\ActivityLog;
+use App\Models\Attachment;
use App\Models\User;
+use App\Support\PermissionEscalationGuard;
+use App\Support\UserGradeGuard;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class UserService
@@ -24,7 +29,8 @@ class UserService
public function __construct(
private UserRepositoryInterface $userRepository,
private RoleRepositoryInterface $roleRepository,
- private AttachmentService $attachmentService
+ private AttachmentService $attachmentService,
+ private PermissionEscalationGuard $escalationGuard
) {}
/**
@@ -77,10 +83,19 @@ class UserService
}
unset($data['roles']);
- // 역할 할당 권한 체크: core.permissions.update 권한 없으면 기본 역할 자동 할당
- if (! PermissionHelper::check('core.permissions.update')) {
+ // 역할 할당: 요청 역할이 없으면 기본 역할('user')을 자동 배정하고, 요청 역할이
+ // 있으면 액터 상한(ceiling) 검사만 받는다.
+ //
+ // 역할 부여는 "사용자 관리"(core.users.create — 이 경로는 라우트에서 이미 강제됨)의
+ // 일부이지, "역할 정의 수정"(core.permissions.update — 역할에 권한을 가감하는 권한)을
+ // 요구하지 않는다. 권한 상승 방지는 오직 상한(ceiling)이 담당한다: 액터가 보유하지
+ // 않았거나 자신의 범위보다 넓은 권한을 담은 역할은 부여할 수 없다(KVE-2026-1919).
+ // 신규 사용자는 기존 역할이 없으므로 요청 역할 전부가 새로 부여되는 역할이다.
+ if ($roleIds === null || count($roleIds) === 0) {
$defaultRoleId = $this->roleRepository->findByIdentifier('user')?->id;
$roleIds = $defaultRoleId ? [$defaultRoleId] : null;
+ } else {
+ $this->escalationGuard->assertRoleAssignmentWithinActorCeiling($roleIds);
}
$user = $this->userRepository->create($data);
@@ -100,8 +115,16 @@ class UserService
throw $e;
}
+ // 권한 상승 상한 위반은 그대로 전파해 컨트롤러가 403 으로 매핑하도록 한다.
+ if ($e instanceof PermissionEscalationException) {
+ throw $e;
+ }
+
+ // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
+ Log::error('User create failed', ['exception' => $e]);
+
throw ValidationException::withMessages([
- 'general' => [__('user.create_failed', ['error' => $e->getMessage()])],
+ 'general' => [__('user.create_failed')],
]);
}
}
@@ -117,6 +140,10 @@ class UserService
*/
public function updateUser(User $user, array $data): User
{
+ // 등급 상한 가드: 비-슈퍼관리자 액터는 슈퍼 관리자 계정을 수정할 수 없다.
+ // (비밀번호·status(withdrawn/blocked)·email 등 모든 수정 경로를 한 지점에서 차단)
+ UserGradeGuard::assertActorMayModify($user);
+
try {
// 원본 데이터 보관 (after_update 훅에서 사용)
$originalData = $data;
@@ -146,17 +173,35 @@ class UserService
}
unset($data['roles']);
- // 역할 할당 권한 체크
+ // 역할 할당
if ($roleIds !== null) {
$authUser = Auth::user();
- // core.permissions.update 권한 없으면 역할 변경 불가
- if (! PermissionHelper::check('core.permissions.update', $authUser)) {
- $roleIds = null;
+ // 역할 조작 상한(ceiling): 이번 변경으로 붙거나 떨어지는 역할이 액터가 전부
+ // 부여할 수 있는 권한만 담고 있는지 확인한다(KVE-2026-1919). 역할 부여는
+ // "사용자 관리"(core.users.update — 이 경로는 라우트에서 이미 강제됨)의 일부이며,
+ // "역할 정의 수정"(core.permissions.update — 역할에 권한을 가감하는 권한)을
+ // 요구하지 않는다. 권한 상승 방지는 오직 상한이 담당한다: 액터가 보유하지
+ // 않았거나 자신의 범위보다 넓은 권한을 담은 역할은 부여할 수 없고, 위반 시
+ // PermissionEscalationException 이 전파되어 403 으로 명시 거부된다(과거처럼 조용히
+ // 무시하지 않는다).
+ //
+ // 검사 대상은 **추가·제거 양방향의 변경분**이다. 추가는 그 역할의 권한을
+ // 부여하는 것이고, 제거는 상위 역할의 권한 구성을 박탈하는 하향 조작이므로 —
+ // 액터가 스스로 부여할 수 없는(상한 밖) 역할은 붙이지도 떼지도 못한다. 추가만
+ // 검사하면 core.users.update 만 가진 하위 관리자가 다른 관리자의 상위 역할을
+ // 박탈하는 경로가 상한 없이 뚫린다. 기존 유지 역할은 변경이 아니므로 제외한다.
+ $currentRoleIds = $user->roles->pluck('id')->all();
+ $changedRoleIds = array_values(array_unique(array_merge(
+ array_diff($roleIds, $currentRoleIds), // 추가되는 역할
+ array_diff($currentRoleIds, $roleIds), // 제거되는 역할
+ )));
+ if (! empty($changedRoleIds)) {
+ $this->escalationGuard->assertRoleAssignmentWithinActorCeiling($changedRoleIds);
}
// 자기잠금 방지: 마지막 admin 역할 사용자가 자기 admin 역할을 제거하려는 경우 차단
- if ($roleIds !== null && $authUser && $authUser->id === $user->id) {
+ if ($authUser && $authUser->id === $user->id) {
$adminRole = $this->roleRepository->findByIdentifier('admin');
if ($adminRole && $user->roles->contains('id', $adminRole->id) && ! in_array($adminRole->id, $roleIds)) {
// admin 역할을 가진 다른 사용자가 있는지 확인
@@ -170,6 +215,23 @@ class UserService
}
}
+ // 관리자가 상태만 '탈퇴'로 바꾸는 경로도 정식 탈퇴 로직으로 통일한다.
+ //
+ // 여기서 status/withdrawn_at 을 먼저 저장하면 withdraw() 의 멱등 가드가
+ // 이미 탈퇴한 계정으로 판정해 익명화와 훅이 조용히 생략된다.
+ // 따라서 상태 필드는 제거하고, 다른 필드 갱신을 마친 뒤 withdrawUser() 에 위임한다.
+ $withdrawViaStatus = isset($data['status'])
+ && $data['status'] === UserStatus::Withdrawn->value
+ && ! $user->isWithdrawn();
+
+ if ($withdrawViaStatus) {
+ unset($data['status']);
+
+ // 차단 대상(관리자/수퍼관리자)이면 다른 필드도 저장되지 않아야 하므로
+ // 갱신 전에 가드를 평가한다.
+ $this->assertWithdrawable($user);
+ }
+
// 상태 변경 감지 및 타임스탬프 자동 설정
$oldStatus = $user->status;
$newStatus = $data['status'] ?? null;
@@ -178,7 +240,8 @@ class UserService
$newStatusEnum = UserStatus::from($newStatus);
$data = match ($newStatusEnum) {
UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
- UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
+ // 탈퇴는 위 위임 경로(withdrawUser)가 전담하므로 여기로는 도달하지 않는다.
+ UserStatus::Withdrawn => $data,
UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
UserStatus::Inactive => $data,
};
@@ -187,22 +250,45 @@ class UserService
// 스냅샷 캡처 (ChangeDetector용)
$snapshot = $user->toArray();
- $this->userRepository->update($user, $data);
+ // 탈퇴 위임 시 아바타는 커밋 후에 지운다 (트랜잭션 안에서 지우면 롤백돼도 파일은 안 돌아온다).
+ $avatarAttachment = $withdrawViaStatus ? $user->avatarAttachment : null;
- // 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
- if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
- $user->tokens()->delete();
- }
+ // 프로필 갱신·토큰 정리·역할 동기화가 서로 어긋나지 않도록 하나로 묶는다.
+ //
+ // 상태 변경 경유 탈퇴는 **탈퇴까지 같은 트랜잭션**에 넣는다 — 프로필 갱신을
+ // 먼저 커밋하고 탈퇴를 따로 커밋하면, 탈퇴가 실패했을 때 이름·이메일 변경만
+ // 남아 "전부 성공하거나 전부 취소" 가 그 경로에서만 깨진다.
+ DB::transaction(function () use ($user, $data, $newStatus, $oldStatus, $roleIds, $withdrawViaStatus) {
+ $this->userRepository->update($user, $data);
- // 역할 동기화
+ // 상태가 Active 외로 변경되었으면 토큰 삭제 (즉시 로그아웃)
+ if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
+ $user->tokens()->delete();
+ }
+
+ // 역할 동기화
+ if ($roleIds !== null) {
+ $user->roles()->sync($roleIds);
+ }
+
+ // 익명화 + before_withdraw 훅 + 연계 데이터 정리 (본인 탈퇴와 동일 코드)
+ if ($withdrawViaStatus) {
+ $this->performWithdrawWrites($user);
+ }
+ });
+
+ // 커밋 후 부수효과 (캐시는 롤백 대상이 아니다)
if ($roleIds !== null) {
- $user->roles()->sync($roleIds);
$user->flushPermissionCaches();
}
// After 훅: 사용자 객체와 원본 데이터, 스냅샷 전달
HookManager::doAction('core.user.after_update', $user, $originalData, $snapshot);
+ if ($withdrawViaStatus) {
+ $this->runWithdrawAfterEffects($user, $avatarAttachment);
+ }
+
// 비밀번호 변경 시 알림 훅 발화 — 발송은 NotificationHookListener가 처리
if ($passwordChanged) {
HookManager::doAction('core.auth.after_password_changed', $user);
@@ -214,12 +300,102 @@ class UserService
throw $e;
}
+ if ($e instanceof CannotDeleteSuperAdminException) {
+ throw $e;
+ }
+
+ // 권한 상승 상한 위반은 그대로 전파해 컨트롤러가 403 으로 매핑하도록 한다
+ // (일반 실패로 감싸면 422 로 잘못 내려간다).
+ if ($e instanceof PermissionEscalationException) {
+ throw $e;
+ }
+
+ // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
+ Log::error('User update failed', ['user_id' => $user->id, 'exception' => $e]);
+
throw ValidationException::withMessages([
- 'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
+ 'general' => [__('user.update_failed')],
]);
}
}
+ /**
+ * 탈퇴 가능한 계정인지 검사합니다.
+ *
+ * 탈퇴 경로(본인 탈퇴 / 관리자 상태 변경 / 일괄 상태 변경)가 모두 같은 기준을
+ * 쓰도록 한 곳에 둡니다 — 한쪽만 느슨하면 그 경로가 우회로가 됩니다.
+ *
+ * @param User $user 검사 대상 사용자
+ *
+ * @throws CannotDeleteSuperAdminException 슈퍼 관리자인 경우
+ * @throws ValidationException 관리자 역할을 가진 경우
+ */
+ private function assertWithdrawable(User $user): void
+ {
+ // 슈퍼 관리자는 탈퇴 불가
+ if ($user->isSuperAdmin()) {
+ throw new CannotDeleteSuperAdminException;
+ }
+
+ // 관리자 역할을 가진 계정은 탈퇴 방지
+ if ($user->isAdmin()) {
+ throw ValidationException::withMessages([
+ 'general' => [__('user.withdraw_admin_forbidden')],
+ ]);
+ }
+ }
+
+ /**
+ * 탈퇴의 DB 변경만 수행합니다 (호출자가 연 트랜잭션 안에서 실행).
+ *
+ * 관리자가 상태 변경으로 탈퇴시키는 경로는 프로필 갱신과 탈퇴를 **한 트랜잭션**으로
+ * 묶어야 한다 — 두 번에 나눠 커밋하면 탈퇴가 실패했을 때 프로필 변경만 남는다.
+ * 그래서 DB 변경분을 이 메서드로 떼어 두 경로가 같은 코드를 공유한다.
+ *
+ * @param User $user 탈퇴 처리할 사용자 모델
+ * @return bool 탈퇴 처리 성공 여부
+ */
+ private function performWithdrawWrites(User $user): bool
+ {
+ // 훅 실행 (탈퇴 전)
+ HookManager::doAction('core.user.before_withdraw', $user);
+
+ // 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
+ $user->consents()->delete();
+
+ // 토큰 삭제 (로그아웃 처리)
+ $user->tokens()->delete();
+
+ // 탈퇴 처리 (suffix 추가 및 상태 변경)
+ return $user->withdraw();
+ }
+
+ /**
+ * 탈퇴 커밋 후 부수효과를 수행합니다.
+ *
+ * @param User $user 탈퇴 처리된 사용자 모델
+ * @param Attachment|null $avatarAttachment 탈퇴 전 캡처한 아바타 첨부
+ */
+ private function runWithdrawAfterEffects(User $user, $avatarAttachment): void
+ {
+ // 아바타 파일 삭제는 실패해도 탈퇴를 되돌리지 않는다
+ // (고아 파일은 비파괴이고, 탈퇴를 되살리는 쪽이 훨씬 해롭다).
+ if ($avatarAttachment) {
+ try {
+ $this->attachmentService->delete($avatarAttachment->id);
+ } catch (Exception $e) {
+ Log::warning('탈퇴 아바타 삭제 실패', [
+ 'user_id' => $user->id,
+ 'attachment_id' => $avatarAttachment->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // 훅 실행 (탈퇴 후)
+ HookManager::doAction('core.user.after_withdraw', $user);
+ }
+
/**
* 사용자를 탈퇴 처리합니다.
*
@@ -235,39 +411,19 @@ class UserService
public function withdrawUser(User $user): bool
{
try {
- // 슈퍼 관리자는 탈퇴 불가
- if ($user->isSuperAdmin()) {
- throw new CannotDeleteSuperAdminException;
- }
+ // 슈퍼 관리자 / 관리자 탈퇴 차단 (전 탈퇴 경로 공통 기준)
+ $this->assertWithdrawable($user);
- // 관리자 역할을 가진 계정은 탈퇴 방지
- if ($user->isAdmin()) {
- throw ValidationException::withMessages([
- 'general' => [__('user.withdraw_admin_forbidden')],
- ]);
- }
-
- // 훅 실행 (탈퇴 전)
- HookManager::doAction('core.user.before_withdraw', $user);
-
- // 약관 동의 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
- $user->consents()->delete();
-
- // 아바타 Attachment 삭제 (다형성 관계)
+ // 아바타는 파일 삭제를 동반하므로 트랜잭션 밖(커밋 후)에서 처리한다 —
+ // 트랜잭션 안에서 지우면 롤백되어도 파일은 돌아오지 않는다.
$avatarAttachment = $user->avatarAttachment;
- if ($avatarAttachment) {
- $this->attachmentService->delete($avatarAttachment->id);
- }
- // 토큰 삭제 (로그아웃 처리)
- $user->tokens()->delete();
+ // DB 변경은 전부 성공하거나 전부 취소된다 (공개이슈 #112).
+ // 마지막 단계인 withdraw() 가 실패하면 동의 이력·토큰 삭제도 함께 되돌아간다.
+ $result = DB::transaction(fn () => $this->performWithdrawWrites($user));
- // 탈퇴 처리 (suffix 추가 및 상태 변경)
- $result = $user->withdraw();
-
- // 훅 실행 (탈퇴 후)
if ($result) {
- HookManager::doAction('core.user.after_withdraw', $user);
+ $this->runWithdrawAfterEffects($user, $avatarAttachment);
}
return $result;
@@ -280,8 +436,11 @@ class UserService
throw $e;
}
+ // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문(SQL 상태코드·경로 등)을 싣지 않는다.
+ Log::error('User withdraw failed', ['user_id' => $user->id, 'exception' => $e]);
+
throw ValidationException::withMessages([
- 'general' => [__('user.withdraw_failed', ['error' => $e->getMessage()])],
+ 'general' => [__('user.withdraw_failed')],
]);
}
}
@@ -310,27 +469,42 @@ class UserService
// 삭제 전 사용자 데이터 보관 (after_delete 훅에서 사용)
$userData = $user->only(['id', 'uuid', 'name', 'email']);
- // Before 훅
- HookManager::doAction('core.user.before_delete', $user);
+ // 아바타는 파일 삭제를 동반하므로 커밋 후에 처리한다 (롤백되어도 파일은 안 돌아온다).
+ $avatarAttachment = $user->avatarAttachment;
- // 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
- $user->roles()->detach();
+ // 마지막 delete 가 FK 등으로 실패하면 역할·동의·토큰만 사라지고 계정은 남는
+ // 껍데기 활성 계정이 된다 (실회귀 이력 존재). 전 단계를 하나로 묶는다.
+ $result = DB::transaction(function () use ($user) {
+ // Before 훅
+ HookManager::doAction('core.user.before_delete', $user);
+
+ // 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
+ $user->roles()->detach();
+
+ // 약관 동의 이력 삭제
+ $user->consents()->delete();
+
+ // API 토큰 삭제
+ $user->tokens()->delete();
+
+ return $this->userRepository->delete($user);
+ });
+
+ // 커밋 성공 후 부수효과
$user->flushPermissionCaches();
- // 약관 동의 이력 삭제
- $user->consents()->delete();
-
- // API 토큰 삭제
- $user->tokens()->delete();
-
- // 아바타 Attachment 삭제 (다형성 관계)
- $avatarAttachment = $user->avatarAttachment;
if ($avatarAttachment) {
- $this->attachmentService->delete($avatarAttachment->id);
+ try {
+ $this->attachmentService->delete($avatarAttachment->id);
+ } catch (Exception $e) {
+ Log::warning('사용자 삭제 아바타 정리 실패', [
+ 'user_id' => $userData['id'] ?? null,
+ 'attachment_id' => $avatarAttachment->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
- $result = $this->userRepository->delete($user);
-
// After 훅: 삭제된 사용자 데이터 전달
HookManager::doAction('core.user.after_delete', $userData);
@@ -344,8 +518,11 @@ class UserService
throw $e;
}
+ // 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
+ Log::error('User delete failed', ['user_id' => $user->id, 'exception' => $e]);
+
throw ValidationException::withMessages([
- 'general' => [__('user.delete_failed', ['error' => $e->getMessage()])],
+ 'general' => [__('user.delete_failed')],
]);
}
}
@@ -470,22 +647,6 @@ class UserService
return $excludeUserUuid && $user->uuid === $excludeUserUuid;
}
- /**
- * 사용자 활성화 상태를 업데이트합니다.
- * 현재는 구현되지 않음 - 필요시 확장 가능
- *
- * @param User $user 대상 사용자 모델
- * @param bool $isActive 활성화 상태
- * @return User 사용자 모델
- */
- public function updateUserStatus(User $user, bool $isActive): User
- {
- // 현재 User 모델에 is_active 필드가 없으므로 필요시 추가
- // $this->userRepository->update($user, ['is_active' => $isActive]);
-
- return $user;
- }
-
/**
* 사용자의 활동 로그를 조회합니다.
*
@@ -521,6 +682,9 @@ class UserService
*/
public function unlockAccount(User $user): User
{
+ // 등급 상한 가드: 비-슈퍼관리자 액터는 슈퍼 관리자 계정을 조작할 수 없다(정합성).
+ UserGradeGuard::assertActorMayModify($user);
+
HookManager::doAction('core.user.before_unlock', $user);
$this->userRepository->resetLoginAttempts($user);
@@ -534,19 +698,6 @@ class UserService
return $user;
}
- /**
- * 사용자의 마지막 로그인 시간을 현재 시간으로 업데이트합니다.
- *
- * @param User $user 대상 사용자 모델
- * @return User 업데이트된 사용자 모델
- */
- public function updateLastLogin(User $user): User
- {
- $this->userRepository->update($user, ['last_login_at' => now()]);
-
- return $user->fresh();
- }
-
/**
* 사용자의 언어 설정을 업데이트합니다.
*
@@ -579,8 +730,42 @@ class UserService
$statusEnum = UserStatus::from($status);
- // UUID → 정수 ID 변환 (내부 쿼리용)
- $userIds = $this->userRepository->getIdsByUuids($uuids);
+ // 정적 라우트(`PATCH users/bulk-status`)는 라우트 모델이 없어 PermissionMiddleware 의
+ // 스코프 검사가 통째로 건너뛰어진다. 따라서 상세 경로(`PUT users/{user}`)가 미들웨어로
+ // 강제하는 두 축을 서비스 계층에서 재적용한다 — 어느 한 축만 막으면 나머지가 우회로다.
+ // 탈퇴 분기보다 먼저 적용해야 한다 — 뒤에 두면 탈퇴 경로가 스코프 검사를 건너뛰는
+ // 우회로가 된다(KVE-1919).
+ $targets = $this->userRepository->findManyByUuids($uuids);
+
+ // ① 등급 축: 비-슈퍼관리자 액터가 포함시킨 슈퍼 관리자 대상은 제외한다
+ // (슈퍼 관리자 무력화 차단 — 슈퍼 세션 유지).
+ $modifiable = UserGradeGuard::filterModifiable($targets);
+
+ // ② 스코프 축: 액터의 유효 스코프(self/role/글로벌) 밖 대상은 제외한다.
+ // 판정은 상세 경로와 동일한 SSoT(PermissionHelper::checkScopeAccess)에 위임한다 —
+ // 여기서 재구현하면 role 분기만 빠지는 식으로 두 경로의 강도가 갈린다.
+ $modifiable = PermissionHelper::filterByScope($modifiable, 'core.users.update');
+
+ // 일괄 '탈퇴'는 건별 정식 탈퇴로 전환한다 — 상태 컬럼만 바꾸면 익명화와
+ // before/after_withdraw 훅이 통째로 생략되어, 본인 탈퇴와 결과가 달라진다.
+ // 위에서 등급·스코프로 걸러낸 대상에 한해서만 수행한다.
+ if ($statusEnum === UserStatus::Withdrawn) {
+ $modifiableUuids = array_map(static fn (User $u): string => $u->uuid, $modifiable);
+
+ return $this->bulkWithdraw($modifiableUuids, $status);
+ }
+
+ $userIds = array_map(static fn (User $u): int => $u->id, $modifiable);
+
+ if (empty($userIds)) {
+ HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, 0);
+
+ return [
+ 'updated_count' => 0,
+ 'failed_count' => 0,
+ 'failed_reasons' => [],
+ ];
+ }
// DB 트랜잭션으로 일괄 업데이트
$updatedCount = DB::transaction(function () use ($userIds, $statusEnum) {
@@ -588,7 +773,8 @@ class UserService
$updateData = ['status' => $statusEnum->value];
$updateData = match ($statusEnum) {
UserStatus::Blocked => array_merge($updateData, ['blocked_at' => now()]),
- UserStatus::Withdrawn => array_merge($updateData, ['withdrawn_at' => now()]),
+ // 탈퇴는 위에서 건별 withdrawUser() 로 분기하므로 여기로는 도달하지 않는다.
+ UserStatus::Withdrawn => $updateData,
UserStatus::Active => array_merge($updateData, ['blocked_at' => null, 'withdrawn_at' => null]),
UserStatus::Inactive => $updateData,
};
@@ -608,6 +794,83 @@ class UserService
return [
'updated_count' => $updatedCount,
+ 'failed_count' => 0,
+ 'failed_reasons' => [],
+ ];
+ }
+
+ /**
+ * 탈퇴 실패 예외를 운영자에게 보여줄 한 줄 사유로 바꿉니다.
+ *
+ * @param Exception $e 탈퇴 시도에서 발생한 예외
+ * @return string 운영자에게 보여줄 실패 사유
+ */
+ private function describeWithdrawFailure(Exception $e): string
+ {
+ if ($e instanceof ValidationException) {
+ $first = collect($e->errors())->flatten()->first();
+
+ if (is_string($first) && $first !== '') {
+ return $first;
+ }
+ }
+
+ if ($e instanceof CannotDeleteSuperAdminException) {
+ return __('exceptions.cannot_delete_super_admin');
+ }
+
+ // 알 수 없는 예외의 원문 메시지는 사유로 쓰지 않는다 — 내부 사정(SQL·경로·클래스명)이
+ // 운영자 화면으로 새고, 화면 문구 조립에서 파라미터 구분자로 쓰이는 문자(`|` `&` `=`)가
+ // 섞이면 안내가 잘린다. 사유는 언어 파일이 소유하는 문구로만 구성한다.
+ return __('user.withdraw_failed_unknown');
+ }
+
+ /**
+ * 일괄 탈퇴를 건별 정식 탈퇴로 수행합니다.
+ *
+ * @param array $uuids 대상 사용자 UUID 배열
+ * @param string $status 요청 상태값 (훅 전달용)
+ * @return array{updated_count: int, failed_count: int, failed_reasons: array} 처리 결과
+ */
+ private function bulkWithdraw(array $uuids, string $status): array
+ {
+ $updatedCount = 0;
+ $failedCount = 0;
+ $failedReasons = [];
+
+ foreach ($uuids as $uuid) {
+ $user = $this->userRepository->findByUuid($uuid);
+
+ if (! $user) {
+ $failedCount++;
+ $failedReasons[] = __('user.not_found');
+
+ continue;
+ }
+
+ try {
+ $this->withdrawUser($user);
+ $updatedCount++;
+ } catch (Exception $e) {
+ $failedCount++;
+ $failedReasons[] = $this->describeWithdrawFailure($e);
+
+ Log::warning('일괄 탈퇴 건 실패', [
+ 'uuid' => $uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // after_bulk_update 훅 실행
+ HookManager::doAction('sirsoft-core.user.after_bulk_update', $uuids, $status, $updatedCount);
+
+ // 사유를 함께 돌려주지 않으면 화면은 "몇 건 실패" 까지만 말할 수 있다 —
+ // 운영자는 왜 안 됐는지 알 방법이 없어 같은 조작을 반복하게 된다.
+ return [
+ 'updated_count' => $updatedCount,
+ 'failed_count' => $failedCount,
+ 'failed_reasons' => array_values(array_unique($failedReasons)),
];
}
diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php
index e17ecc4d..724f97e7 100644
--- a/app/Support/ApiDoc/ApiDocScaffolder.php
+++ b/app/Support/ApiDoc/ApiDocScaffolder.php
@@ -1138,7 +1138,8 @@ class ApiDocScaffolder
// 종료 마커를 표의 끝 경계로 삼는데, 전자는 그 마커를 잘라내고 돌려준다.
$merged .= $this->applyPreservedErrorTable(
$withErrors,
- $this->exactGeneratedBlock($existing, $key)
+ $this->exactGeneratedBlock($existing, $key),
+ $section
)."\n";
}
@@ -1252,7 +1253,44 @@ class ApiDocScaffolder
* @param string|null $previous 기존 문서의 같은 엔드포인트 생성 블록
* @return string 에러 표가 보존된 섹션
*/
- private function applyPreservedErrorTable(string $section, ?string $previous): string
+ /**
+ * 403 행의 요구 권한 식별자를 이번 재생성 산출값으로 갱신합니다.
+ *
+ * 에러 표 병합은 같은 상태코드에서 기존(사람) 행을 이기게 두는데, 403 의 권한 식별자는
+ * 라우트 정의에서 파생된 사실이라 사람 서술과 같은 취급을 하면 안 된다. 두 행이 모두
+ * 백틱 식별자를 가질 때만 그 부분을 치환하고, 나머지 문구(사람이 덧붙인 도메인 조건)는
+ * 건드리지 않는다. 식별자가 없는 형태(관리자 게이트만 걸린 라우트)는 갱신 대상이 아니다.
+ *
+ * @param string $preserved 병합 결과로 살아남은 기존 403 행
+ * @param string $generated 이번 재생성이 만든 403 행
+ * @return string 식별자만 갱신된 403 행
+ */
+ private function refreshPermissionIdentifier(string $preserved, string $generated): string
+ {
+ if (! preg_match('/`([^`]+)`/', $generated, $new)) {
+ return $preserved;
+ }
+
+ if (! preg_match('/`([^`]+)`/', $preserved, $old)) {
+ return $preserved;
+ }
+
+ if ($old[1] === $new[1]) {
+ return $preserved;
+ }
+
+ $needle = '`'.$old[1].'`';
+ $pos = strpos($preserved, $needle);
+
+ if ($pos === false) {
+ return $preserved;
+ }
+
+ // preg_replace 는 대체 문자열의 `$`·`\` 를 역참조로 해석하므로 쓰지 않는다.
+ return substr_replace($preserved, '`'.$new[1].'`', $pos, strlen($needle));
+ }
+
+ private function applyPreservedErrorTable(string $section, ?string $previous, ?string $generated = null): string
{
if ($previous === null) {
return $section;
@@ -1285,12 +1323,28 @@ class ApiDocScaffolder
// 상태코드 키로 병합. `+` 는 왼쪽 우선이므로 기존 행을 먼저 둬서, 같은 상태코드면
// 사람이 쓴 구체적 조건이 자동 문구를 이긴다. 자동 추론에만 있는 상태코드는 새로 편입된다.
- $merged = $previousRows + $this->errorTableRows($section);
+ $currentRows = $this->errorTableRows($section);
+ $merged = $previousRows + $currentRows;
if ($merged === []) {
return $section;
}
+ // 403 행의 권한 식별자만은 예외다 — 그것은 사람 서술이 아니라 라우트에서 파생된
+ // 사실이라, 위 병합 규칙을 그대로 두면 라우트의 요구 권한을 바꿔도 옛 식별자가
+ // 영구히 남아 문서가 조용히 틀린 권한을 안내한다. 사람이 보강한 조건 문구는 그대로
+ // 두고 백틱 식별자만 갱신한다.
+ //
+ // 대조 원본은 반드시 **이번 회차가 생성한 원본 섹션**이어야 한다. 여기 들어오는
+ // $section 은 restoreTableDescriptions 를 이미 거쳐 403 행의 설명 셀이 기존 문서
+ // 값으로 되돌아가 있으므로(에러 표도 상태코드를 행 키로 갖는 표라 그 복원 대상에
+ // 걸린다), 그것을 기준으로 삼으면 갱신이 언제나 no-op 이 된다.
+ $generatedRows = $generated === null ? $currentRows : $this->errorTableRows($generated);
+
+ if (isset($merged['403'], $generatedRows['403'])) {
+ $merged['403'] = $this->refreshPermissionIdentifier($merged['403'], $generatedRows['403']);
+ }
+
ksort($merged, SORT_NUMERIC);
$table = "| 상태코드 | 의미 | 발생 조건 |\n| --- | --- | --- |\n".implode("\n", $merged);
diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php
index 42837901..8ce26e7e 100644
--- a/app/Support/ApiDoc/ParameterDescriber.php
+++ b/app/Support/ApiDoc/ParameterDescriber.php
@@ -168,6 +168,7 @@ class ParameterDescriber
'port' => '포트 번호',
'encryption' => '전송 암호화 방식 (tls/ssl)',
'storage_driver' => '스토리지 드라이버 (local/s3)',
+ 'public_asset_disk' => '공개 자산 직접 URL 서빙 디스크 (none/public/s3 + 플러그인 등록 디스크)',
'cache_driver' => '캐시 드라이버 (file/redis/memcached)',
'session_driver' => '세션 드라이버 (file/database/redis)',
'queue_driver' => '큐 드라이버 (sync/database/redis)',
@@ -178,10 +179,12 @@ class ParameterDescriber
'memcached_host' => 'Memcached 호스트 주소',
'memcached_port' => 'Memcached 포트 번호',
's3_bucket' => 'S3 버킷명',
- 's3_region' => 'S3 리전',
+ 's3_region' => 'S3 리전 (소문자 영숫자·하이픈 — AWS 리전 코드 또는 S3 호환 스토리지 값, R2 는 auto)',
's3_access_key' => 'S3 액세스 키',
's3_secret_key' => 'S3 시크릿 키',
- 's3_url' => 'S3 엔드포인트 URL',
+ 's3_url' => 'S3 공개 URL(CDN) base — 파일 URL 생성용 (API 요청 주소 아님)',
+ 's3_endpoint' => 'S3 API 엔드포인트 — S3 호환 스토리지(R2/MinIO 등)용, AWS S3 는 미입력',
+ 's3_use_path_style' => 'S3 path-style 주소 사용 여부 (MinIO 등)',
'ses_key' => 'SES 액세스 키',
'ses_secret' => 'SES 시크릿 키',
'ses_region' => 'SES 리전',
diff --git a/app/Support/ConfigCacheHelper.php b/app/Support/ConfigCacheHelper.php
index 4a4af013..ff5d34bd 100644
--- a/app/Support/ConfigCacheHelper.php
+++ b/app/Support/ConfigCacheHelper.php
@@ -16,9 +16,13 @@ use Illuminate\Support\Facades\Log;
* 한 번 비워진 뒤 재생성되지 않아 성능 이점이 영구히 사라진다 — 이를 단일 SSoT 로 막는다.
*
* 정책: 환경 무관 항상 재생성. config:cache 는 그 자체로 부팅 비용을
- * 절감하고, G7 설정은 config 캐시에 박제되지 않고 매 요청 SettingsServiceProvider /
- * CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로(설정 stale 없음),
- * 항상 켜두는 것이 이득이다. local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
+ * 절감하고, G7 설정은 config 캐시에 박제되지 않고 부팅 때 SettingsServiceProvider /
+ * CoreServiceProvider 의 런타임 Config::set() 으로 재주입되므로 항상 켜두는 것이 이득이다.
+ *
+ * 주의: "매 요청 재주입되므로 stale 없음" 은 FPM 전제였다. 큐 워커·schedule:work·Reverb
+ * 처럼 프로세스가 상주하는 환경에서는 부팅이 한 번뿐이라 저장 후에도 옛 값이 남는다.
+ * 그래서 저장 경로가 `ExtensionSettingsMirror` 로 in-memory 미러를 직접 다시 채운다
+ * (docs/backend/admin-settings-access.md "config 미러 갱신 시점" 참조). local 개발 시 config/*.php 수정이 즉시 반영되지 않는 점은
* 개발자가 `php artisan config:clear` 로 대응하는 개발자 책임 영역이다.
*/
class ConfigCacheHelper
diff --git a/app/Support/DevTools/BrowserLogWriter.php b/app/Support/DevTools/BrowserLogWriter.php
index ea9a7b6b..2737ce04 100644
--- a/app/Support/DevTools/BrowserLogWriter.php
+++ b/app/Support/DevTools/BrowserLogWriter.php
@@ -137,9 +137,12 @@ class BrowserLogWriter
return Log::channel('browser');
}
+ // daily + 유한 보존일 — single 드라이버는 로테이션 없이 무한 append 되어
+ // browser.log 가 저장소 단일 최대 파일로 자란다 (실측 989MB).
return Log::build([
- 'driver' => 'single',
+ 'driver' => 'daily',
'path' => storage_path('logs/browser.log'),
+ 'days' => 7,
'level' => config('logging.level', 'debug'),
]);
}
diff --git a/app/Support/ExtensionSettingsMirror.php b/app/Support/ExtensionSettingsMirror.php
new file mode 100644
index 00000000..c149f067
--- /dev/null
+++ b/app/Support/ExtensionSettingsMirror.php
@@ -0,0 +1,310 @@
+
+ */
+ public const CORE_CATEGORIES = [
+ 'mail',
+ 'general',
+ 'security',
+ 'debug',
+ 'drivers',
+ 'cache',
+ 'upload',
+ 'core_update',
+ 'geoip',
+ 'seo',
+ 'identity',
+ 'pagination',
+ ];
+
+ /**
+ * 코어 설정 미러(`g7_settings.core`)를 다시 채웁니다.
+ *
+ * @param JsonConfigRepository|null $configRepository 설정 저장소 (미지정 시 새로 생성)
+ */
+ public function refreshCore(?JsonConfigRepository $configRepository = null): void
+ {
+ // 이 메서드는 SettingsServiceProvider::register() 에서도 호출된다 —
+ // 그 시점은 DI 컨테이너 사용 전이므로 직접 인스턴스화가 유일한 선택지다.
+ $configRepository ??= new JsonConfigRepository;
+
+ $coreSettings = [];
+
+ foreach (self::CORE_CATEGORIES as $category) {
+ $settings = $configRepository->getCategory($category);
+
+ if (! empty($settings)) {
+ $coreSettings[$category] = $settings;
+ }
+ }
+
+ Config::set('g7_settings.core', $coreSettings);
+ }
+
+ /**
+ * 활성 모듈 전체의 설정 미러를 다시 채웁니다.
+ */
+ public function refreshAllModules(): void
+ {
+ $moduleManager = app(ModuleManager::class);
+ $moduleSettings = [];
+
+ foreach (array_keys($moduleManager->getActiveModules()) as $identifier) {
+ $settings = $this->resolveModuleSettings($identifier);
+
+ if (! empty($settings)) {
+ $moduleSettings[$identifier] = $settings;
+ }
+ }
+
+ Config::set('g7_settings.modules', $moduleSettings);
+ }
+
+ /**
+ * 모듈 하나의 설정 미러를 다시 채웁니다.
+ *
+ * @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
+ */
+ public function refreshModule(string $identifier): void
+ {
+ $settings = $this->resolveModuleSettings($identifier);
+
+ if (empty($settings)) {
+ Config::set('g7_settings.modules.'.$identifier, []);
+
+ return;
+ }
+
+ Config::set('g7_settings.modules.'.$identifier, $settings);
+ }
+
+ /**
+ * 활성 플러그인 전체의 설정 미러를 다시 채웁니다.
+ */
+ public function refreshAllPlugins(): void
+ {
+ $pluginManager = app(PluginManager::class);
+ $pluginSettings = [];
+
+ foreach (array_keys($pluginManager->getActivePlugins()) as $identifier) {
+ $settings = $this->resolvePluginSettings($identifier);
+
+ if (! empty($settings)) {
+ $pluginSettings[$identifier] = $settings;
+ }
+ }
+
+ Config::set('g7_settings.plugins', $pluginSettings);
+ }
+
+ /**
+ * 플러그인 하나의 설정 미러를 다시 채웁니다.
+ *
+ * @param string $identifier 플러그인 식별자 (예: sirsoft-gdpr)
+ */
+ public function refreshPlugin(string $identifier): void
+ {
+ Config::set('g7_settings.plugins.'.$identifier, $this->resolvePluginSettings($identifier));
+ }
+
+ /**
+ * 모듈 설정 서비스에서 미러에 담을 값을 만듭니다.
+ *
+ * 확장 하나가 실패해도 부팅/저장을 중단시키지 않습니다.
+ *
+ * @param string $identifier 모듈 식별자
+ * @return array 미러 값 (민감 항목 제외)
+ */
+ private function resolveModuleSettings(string $identifier): array
+ {
+ try {
+ $settingsService = $this->resolveModuleSettingsService($identifier);
+
+ if (! $settingsService instanceof ModuleSettingsInterface) {
+ return [];
+ }
+
+ return $this->stripSensitive(
+ $settingsService->getAllSettings(),
+ $this->resolveModuleSchema($identifier),
+ );
+ } catch (\Throwable $e) {
+ Log::warning("모듈 환경설정 로딩 실패: {$identifier}", [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+
+ /**
+ * 플러그인 설정에서 미러에 담을 값을 만듭니다.
+ *
+ * 종전에는 setting.json 을 raw 로 읽어 defaults 병합도 정규화도 없었고,
+ * 암호문이 그대로 실렸습니다. 이제 PluginSettingsService 를 경유해 값의 형태를
+ * 전용 게터와 맞추되, 민감 항목은 **키 자체를 담지 않습니다**.
+ *
+ * @param string $identifier 플러그인 식별자
+ * @return array 미러 값 (민감 항목 제외)
+ */
+ private function resolvePluginSettings(string $identifier): array
+ {
+ try {
+ $pluginManager = app(PluginManager::class);
+ $plugin = $pluginManager->getPlugin($identifier);
+
+ if (! $plugin) {
+ return [];
+ }
+
+ $settings = app(PluginSettingsService::class)->get($identifier);
+
+ if (! is_array($settings)) {
+ return [];
+ }
+
+ return $this->stripSensitive($settings, $plugin->getSettingsSchema());
+ } catch (\Throwable $e) {
+ Log::warning("플러그인 환경설정 로딩 실패: {$identifier}", [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ }
+ }
+
+ /**
+ * 스키마에서 `sensitive: true` 로 선언된 필드를 제거합니다.
+ *
+ * 미러는 봇 전용 화면 생성기가 키 제한 없이 조회하는 경로이므로, 민감값이 실리면
+ * 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나갑니다. 브라우저 경로는
+ * 애초에 제외 중이라 화면만 봐서는 드러나지 않는 비대칭입니다.
+ *
+ * @param array $settings 설정 배열
+ * @param array $schema 설정 스키마
+ * @return array 민감 항목이 제거된 설정
+ */
+ private function stripSensitive(array $settings, array $schema): array
+ {
+ foreach ($schema as $field => $config) {
+ if (! is_array($config)) {
+ continue;
+ }
+
+ if ($config['sensitive'] ?? false) {
+ unset($settings[$field]);
+
+ continue;
+ }
+
+ // 카테고리형 스키마 (category.field) 지원
+ if (! isset($settings[$field]) || ! is_array($settings[$field])) {
+ continue;
+ }
+
+ $nested = array_filter($config, 'is_array');
+
+ if ($nested !== []) {
+ $settings[$field] = $this->stripSensitive($settings[$field], $nested);
+ }
+ }
+
+ return $settings;
+ }
+
+ /**
+ * 모듈의 설정 스키마를 조회합니다.
+ *
+ * @param string $identifier 모듈 식별자
+ * @return array 설정 스키마 (없으면 빈 배열)
+ */
+ private function resolveModuleSchema(string $identifier): array
+ {
+ $module = app(ModuleManager::class)->getModule($identifier);
+
+ if (! $module || ! method_exists($module, 'getSettingsSchema')) {
+ return [];
+ }
+
+ $schema = $module->getSettingsSchema();
+
+ return is_array($schema) ? $schema : [];
+ }
+
+ /**
+ * 모듈의 환경설정 서비스를 찾아 인스턴스화합니다.
+ *
+ * 다음 순서로 설정 서비스를 찾습니다:
+ * 1. 인터페이스 바인딩: Modules\Vendor\Module\Contracts\ModuleSettingsServiceInterface
+ * 2. 구체 클래스: Modules\Vendor\Module\Services\ModuleSettingsService
+ *
+ * @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
+ * @return ModuleSettingsInterface|null 설정 서비스 인스턴스
+ */
+ private 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 (app()->bound($interfaceClass)) {
+ $service = app($interfaceClass);
+
+ if ($service instanceof ModuleSettingsInterface) {
+ return $service;
+ }
+ }
+
+ // 2. 구체 클래스 확인
+ $concreteClass = "Modules\\{$vendor}\\{$moduleName}\\Services\\{$moduleName}SettingsService";
+
+ if (class_exists($concreteClass)) {
+ $service = app($concreteClass);
+
+ if ($service instanceof ModuleSettingsInterface) {
+ return $service;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/Support/PermissionEscalationGuard.php b/app/Support/PermissionEscalationGuard.php
new file mode 100644
index 00000000..b8fe9047
--- /dev/null
+++ b/app/Support/PermissionEscalationGuard.php
@@ -0,0 +1,177 @@
+ $permissions 부여 권한 배열
+ *
+ * @throws PermissionEscalationException 상한 위반 시
+ */
+ public function assertGrantWithinActorCeiling(array $permissions): void
+ {
+ $actor = Auth::user();
+
+ // 인증 액터 부재 = 내부/Artisan 신뢰 경로 → 상한 미적용
+ if (! $actor instanceof User) {
+ return;
+ }
+
+ // 슈퍼 관리자는 상한 없음
+ if ($actor->isSuperAdmin()) {
+ return;
+ }
+
+ if (empty($permissions)) {
+ return;
+ }
+
+ // 부여 대상 권한 ID → 식별자 매핑
+ $ids = array_values(array_filter(array_map(
+ static fn ($p) => $p['id'] ?? null,
+ $permissions
+ )));
+ $identifierById = $this->permissionRepository->getByIds($ids)
+ ->keyBy('id')
+ ->map(static fn ($permission) => $permission->identifier);
+
+ foreach ($permissions as $permission) {
+ $id = $permission['id'] ?? null;
+ $identifier = $id !== null ? ($identifierById[$id] ?? null) : null;
+
+ // 식별자를 해석할 수 없는 권한은 안전하게 거부
+ if ($identifier === null) {
+ throw new PermissionEscalationException;
+ }
+
+ // 액터가 보유하지 않은 권한은 부여 불가
+ if (! $actor->hasPermission($identifier)) {
+ throw new PermissionEscalationException;
+ }
+
+ // 부여 범위가 액터의 effective scope 보다 넓으면 불가
+ $requestedScope = $permission['scope_type'] ?? null;
+ $actorScope = $actor->getEffectiveScopeForPermission($identifier);
+
+ if ($this->scopeRank($requestedScope) > $this->scopeRank($actorScope)) {
+ throw new PermissionEscalationException;
+ }
+ }
+ }
+
+ /**
+ * 사용자에게 부여하려는 역할들이 액터의 상한을 넘지 않는지 확인합니다.
+ *
+ * 역할을 붙이는 것은 그 역할이 담은 권한 전부를 부여하는 것과 같으므로, 각 역할의
+ * 권한을 그 pivot scope 와 함께 펼쳐 권한 부여 상한(assertGrantWithinActorCeiling)을
+ * 그대로 적용합니다. 호출측은 **이번 조작으로 변경되는(추가·제거) 역할만** 전달해야 합니다 —
+ * 기존 유지 역할은 변경이 아니므로 제외합니다. 제거 방향도 같은 상한을 받는 이유는, 상위
+ * 역할을 박탈하는 하향 조작 역시 액터가 권한을 갖지 못한 역할 구성에 대한 조작이기 때문입니다.
+ *
+ * @param array $roleIds 이번 조작으로 변경되는(추가·제거) 역할 ID 목록
+ *
+ * @throws PermissionEscalationException 상한 위반 시
+ */
+ public function assertRoleAssignmentWithinActorCeiling(array $roleIds): void
+ {
+ $actor = Auth::user();
+
+ if (! $actor instanceof User) {
+ return;
+ }
+
+ if ($actor->isSuperAdmin()) {
+ return;
+ }
+
+ $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id !== null));
+
+ if (empty($roleIds)) {
+ return;
+ }
+
+ // 시스템 baseline 역할('user')은 상한에서 면제한다.
+ //
+ // 'user' 는 모든 회원이 갖는 기본 역할로, core.permissions.update 가 없는 액터의
+ // 생성 경로가 상한 검사 없이 자동 배정하는 바로 그 역할이다(UserService::createUser).
+ // 그 역할의 권한은 알림 self-service·본인인증 등 회원 baseline 뿐이라 액터가
+ // 그것을 "부여" 해도 권한 상승 벡터가 되지 않는다. baseline 을 검사에 넣으면
+ // core.permissions.update 를 가진(=더 권한 있는) 액터가 baseline 을 명시 지정했을 때만
+ // 403 이 되어, 권한 낮은 액터의 auto-assign 경로와 비대칭으로 정상 회원 생성/수정이
+ // 깨진다. 비-baseline 역할은 여전히 전량 상한 검사를 받는다.
+ $baselineRoleId = $this->roleRepository->findByIdentifier('user')?->id;
+
+ // 부여 역할들의 권한을 [{id, scope_type}] 로 펼친다
+ $permissions = [];
+ foreach ($roleIds as $roleId) {
+ if ($baselineRoleId !== null && (int) $roleId === (int) $baselineRoleId) {
+ continue;
+ }
+
+ $role = $this->roleRepository->findById((int) $roleId);
+
+ // 존재하지 않는 역할은 안전하게 거부
+ if ($role === null) {
+ throw new PermissionEscalationException;
+ }
+
+ foreach ($role->permissions as $permission) {
+ $permissions[] = [
+ 'id' => $permission->id,
+ 'scope_type' => $permission->pivot->scope_type ?? null,
+ ];
+ }
+ }
+
+ $this->assertGrantWithinActorCeiling($permissions);
+ }
+
+ /**
+ * scope_type 의 넓이 순위를 반환합니다 (클수록 넓음).
+ *
+ * null(전체) > 'role'(소유역할) > 'self'(본인) 순으로, User::getEffectiveScopeForPermission
+ * 의 union 우선순위와 동일한 서열을 사용합니다.
+ *
+ * @param string|null $scope 범위 문자열
+ * @return int 넓이 순위 (2=전체, 1=role, 0=self)
+ */
+ private function scopeRank(?string $scope): int
+ {
+ return match ($scope) {
+ null => 2,
+ 'role' => 1,
+ default => 0,
+ };
+ }
+}
diff --git a/app/Support/Query/BoundedPaginator.php b/app/Support/Query/BoundedPaginator.php
index 229966ea..e310e247 100644
--- a/app/Support/Query/BoundedPaginator.php
+++ b/app/Support/Query/BoundedPaginator.php
@@ -156,8 +156,10 @@ class BoundedPaginator
// newQuery() 는 같은 커넥션의 빈 빌더를 만든다. fromSub 가 바인딩까지 옮겨 주므로
// 서브쿼리 SQL 을 문자열로 조립하거나 mergeBindings 를 부를 필요가 없다.
+ // 별칭은 빌더가 wrapTable 로 접두사를 붙이므로 `g7_` 을 직접 쓰면 `g7_g7_...` 이
+ // 된다 (참조 0곳이라 실동작 무해였으나 명명 혼선 제거).
$counted = (int) $bounded->newQuery()
- ->fromSub($bounded, 'g7_bounded_total')
+ ->fromSub($bounded, 'bounded_total')
->count();
return $counted > $resultCap
diff --git a/app/Support/ScheduleCommandValidator.php b/app/Support/ScheduleCommandValidator.php
index 874c60de..e61e73e4 100644
--- a/app/Support/ScheduleCommandValidator.php
+++ b/app/Support/ScheduleCommandValidator.php
@@ -47,6 +47,24 @@ class ScheduleCommandValidator
/** 허용 개수를 넘는 위치 인자가 쓰임 */
public const ARTISAN_REASON_ARGUMENT = 'argument_denied';
+ /** Shell — 게이트 off 또는 화이트리스트 빔 */
+ public const SHELL_REASON_DISABLED = 'shell_disabled';
+
+ /** Shell — 셸 메타문자/제어문자로 토큰화 불가 */
+ public const SHELL_REASON_METACHARACTER = 'shell_metacharacter';
+
+ /** Shell — 첫 토큰 basename 이 화이트리스트에 없음 */
+ public const SHELL_REASON_NOT_ALLOWED = 'shell_not_allowlisted';
+
+ /** Shell — 완전 거부형 인터프리터 / 인자 없는 인터프리터 / 스크립트 자리 artisan */
+ public const SHELL_REASON_INTERPRETER = 'shell_interpreter';
+
+ /** Shell — 인터프리터 뒤 스크립트 자리가 하이픈(인라인 코드 플래그) */
+ public const SHELL_REASON_INLINE_CODE = 'shell_inline_code';
+
+ /** Shell — 스크립트 경로 형태 위반(상대경로 / `..` / `#`) */
+ public const SHELL_REASON_SCRIPT_PATH = 'shell_script_path';
+
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
private const SHELL_METACHARACTERS = [
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
@@ -56,16 +74,35 @@ class ScheduleCommandValidator
/**
* Shell 타입 command 를 실행해도 되는지 판정합니다.
*
- * 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
- * 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
+ * 판정은 전적으로 `inspectShellCommand()` 에 위임한다 — 사유가 필요 없는 호출부
+ * (실행부의 마지막 방어선 등)를 위한 bool 형태이며, 두 경로의 결과가 갈릴 여지가 없다.
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return bool 실행을 허용하면 true
*/
public static function isShellCommandAllowed(string $command): bool
+ {
+ return self::inspectShellCommand($command)['allowed'];
+ }
+
+ /**
+ * Shell command 를 실행 가능한지 판정하고 거부 사유를 함께 돌려줍니다.
+ *
+ * 판정 순서:
+ * 1. 게이트 off 또는 화이트리스트 빔 → SHELL_REASON_DISABLED
+ * 2. 메타문자/제어문자로 토큰화 불가 → SHELL_REASON_METACHARACTER
+ * 3. 첫 토큰 basename 이 화이트리스트에 없음 → SHELL_REASON_NOT_ALLOWED
+ * 4. 첫 토큰이 완전 거부형(reject_binaries) → SHELL_REASON_INTERPRETER (화이트리스트보다 우선)
+ * 5. 첫 토큰이 스크립트 실행형 인터프리터 → 스크립트 자리 형태 규칙 적용
+ * 6. 그 외(일반 스크립트, 예 backup.sh) → 통과 (기존 계약 보존)
+ *
+ * @param string $command 스케줄에 저장된 shell command 문자열
+ * @return array{allowed: bool, reason: string|null}
+ */
+ public static function inspectShellCommand(string $command): array
{
if (! (bool) config('schedule_security.shell.enabled', false)) {
- return false;
+ return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$allowed = array_values(array_filter(array_map(
@@ -74,16 +111,160 @@ class ScheduleCommandValidator
)));
if ($allowed === []) {
- return false;
+ return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$tokens = self::tokenize($command);
if ($tokens === null || $tokens === []) {
+ return self::shellVerdict(false, self::SHELL_REASON_METACHARACTER);
+ }
+
+ $binary = basename($tokens[0]);
+
+ if (! in_array($binary, $allowed, true)) {
+ return self::shellVerdict(false, self::SHELL_REASON_NOT_ALLOWED);
+ }
+
+ $normalized = self::normalizeShellBinary($binary);
+
+ // 완전 거부형 — 화이트리스트에 등재돼 있어도 실행하지 않는다.
+ if (in_array($normalized, self::shellList('reject_binaries'), true)) {
+ return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
+ }
+
+ // 스크립트 실행형 인터프리터 — 첫 인자(스크립트 자리)에 형태 규칙을 적용한다.
+ if (in_array($normalized, self::shellList('script_interpreters'), true)) {
+ return self::inspectInterpreterScript($tokens);
+ }
+
+ // 일반 스크립트(인터프리터 아님) — 기존 계약 보존.
+ return self::shellVerdict(true, null);
+ }
+
+ /**
+ * 인터프리터 뒤 스크립트 자리(첫 인자)가 허용 형태인지 판정합니다.
+ *
+ * 인스톨러 선례 `public/install/includes/binary-path-policy.php` 의
+ * `installer_binary_path_shape_ok`("하이픈 선두 토큰 거부 + `..`/`#` 차단 + 절대경로 요구")
+ * 를 스케줄 Shell 스크립트 자리에 이식한 것이다.
+ *
+ * @param array $tokens 토큰 배열 (첫 원소는 인터프리터)
+ * @return array{allowed: bool, reason: string|null}
+ */
+ private static function inspectInterpreterScript(array $tokens): array
+ {
+ // 인자 0개 — 실행할 스크립트가 없다(인터프리터 REPL/셸 기동).
+ if (! isset($tokens[1]) || $tokens[1] === '') {
+ return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
+ }
+
+ $script = $tokens[1];
+
+ // 하이픈 선두 = 인라인 코드/옵션 플래그(`-c`/`-e`/`-r`/`-m`/`-d`/`-x` 등) — 일괄 차단.
+ if ($script[0] === '-') {
+ return self::shellVerdict(false, self::SHELL_REASON_INLINE_CODE);
+ }
+
+ // 스크립트 자리 basename 이 artisan 이면 거부 — 코어 Artisan 축은 Artisan 타입으로.
+ $deniedNames = array_map(
+ static fn ($name): string => strtolower(trim((string) $name)),
+ self::shellList('denied_script_names'),
+ );
+
+ if (in_array(strtolower(basename(str_replace('\\', '/', $script))), $deniedNames, true)) {
+ return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
+ }
+
+ // `#` 이후를 주석으로 흘려 확장자를 위장하는 트릭 차단.
+ if (str_contains($script, '#')) {
+ return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
+ }
+
+ // `..` 세그먼트 차단 (CWD 상대 이동/트래버설).
+ if (preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $script) === 1) {
+ return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
+ }
+
+ // 스크립트는 절대경로여야 한다 (CWD 에 의존한 실행 차단).
+ if (! self::isAbsolutePath($script)) {
+ return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
+ }
+
+ // 스크립트 뒤 인자는 스크립트 소유이므로 검사하지 않는다.
+ return self::shellVerdict(true, null);
+ }
+
+ /**
+ * 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
+ *
+ * @param string $token 경로 토큰
+ * @return bool 절대경로면 true
+ */
+ private static function isAbsolutePath(string $token): bool
+ {
+ if ($token === '') {
return false;
}
- return in_array(basename($tokens[0]), $allowed, true);
+ if ($token[0] === '/') {
+ return true;
+ }
+
+ if (preg_match('#^[A-Za-z]:[\\\\/]#', $token) === 1) {
+ return true;
+ }
+
+ return str_starts_with($token, '\\\\');
+ }
+
+ /**
+ * shell 정책 배열 설정을 소문자 문자열 목록으로 정규화합니다.
+ *
+ * @param string $key `schedule_security.shell` 하위 키
+ * @return array
+ */
+ /**
+ * 인터프리터/거부형 분류용으로 바이너리 이름을 정규화합니다.
+ *
+ * 분류 목록(`script_interpreters`/`reject_binaries`)은 기본 이름만 담는다.
+ * 운영자가 Windows 관례(`python.exe`)나 버전 접미사(`python3.12`, `php8.2`)로
+ * 화이트리스트에 등재하면 정확 일치 분류가 비켜가 인터프리터가 "일반 스크립트 —
+ * 통과" 분기를 타고, `-c` 인라인 코드 차단(KVE-2026-1653)이 무력화된다.
+ * 화이트리스트 대조 자체는 운영자가 적은 원문 그대로 유지한다 — 여기서 접는
+ * 것은 위험 분류뿐이다.
+ *
+ * @param string $binary 첫 토큰의 basename
+ * @return string 소문자 + 실행 확장자(.exe/.bat/.cmd/.com) 제거 + 후행 버전 숫자 제거
+ */
+ private static function normalizeShellBinary(string $binary): string
+ {
+ $name = strtolower($binary);
+ $name = (string) preg_replace('/\.(exe|bat|cmd|com)$/', '', $name);
+
+ // 후행 버전 접미사(python3.12 → python, php8.2 → php)를 접는다.
+ // python3 이 python 으로 접혀도 두 이름 모두 분류 목록에 있어 판정은 동일하다.
+ return (string) preg_replace('/[0-9][0-9.]*$/', '', $name);
+ }
+
+ private static function shellList(string $key): array
+ {
+ return array_values(array_filter(array_map(
+ static fn ($value): string => strtolower(trim((string) $value)),
+ (array) config('schedule_security.shell.'.$key, []),
+ )));
+ }
+
+ /**
+ * Shell 판정 결과 배열을 만듭니다.
+ *
+ * @param bool $allowed 허용 여부
+ * @param string|null $reason 거부 사유 코드
+ * @return array{allowed: bool, reason: string|null}
+ */
+ private static function shellVerdict(bool $allowed, ?string $reason = null): array
+ {
+ return ['allowed' => $allowed, 'reason' => $reason];
}
/**
@@ -410,15 +591,88 @@ class ScheduleCommandValidator
$command = self::findRegisteredCommand($name);
- if ($command === null) {
+ if ($command !== null) {
+ $class = get_class($command);
+
+ foreach ($namespaces as $namespace) {
+ if (str_starts_with($class, $namespace)) {
+ return $command->getDefinition();
+ }
+ }
+
return null;
}
- $class = get_class($command);
+ // 레지스트리에 없으면 프로바이더 선언 폴백 — HTTP 요청에서는 확장 프로바이더가
+ // `runningInConsole()` 게이트로 커맨드 등록을 건너뛰어 `Artisan::all()` 에
+ // 확장 커맨드가 존재하지 않는다. 관리자 화면의 스케줄 저장 검증이 바로 이
+ // 문맥이므로, 등록 인스턴스가 없을 때는 활성 프로바이더의 `$commands` 선언을
+ // 같은 기준(클래스 네임스페이스 + 실제 명령명 대조)으로 해석한다.
+ return self::resolveProviderDeclaredCommandDefinition($name, $namespaces);
+ }
- foreach ($namespaces as $namespace) {
- if (str_starts_with($class, $namespace)) {
- return $command->getDefinition();
+ /**
+ * 활성 서비스 프로바이더가 `$commands` 프로퍼티로 선언한 확장 커맨드에서
+ * 명령명을 해석해 정의를 돌려줍니다.
+ *
+ * 판정 기준은 레지스트리 경로와 동일하다 — 커맨드 **클래스**의 네임스페이스가
+ * 확장 네임스페이스여야 하고, 요청된 이름은 그 클래스를 실제 인스턴스화해 얻은
+ * 선언 명령명과 일치해야 한다(이름만으로 위조할 수 없다). 프로바이더가 커맨드
+ * 목록을 생성자에서 동적으로 만들면 기본값이 비므로 이 폴백에는 걸리지 않는다
+ * (fail-closed 유지).
+ *
+ * @param string $name 명령명
+ * @param array $namespaces 허용 확장 네임스페이스 접두사
+ * @return InputDefinition|null 해석 불가하면 null
+ */
+ private static function resolveProviderDeclaredCommandDefinition(string $name, array $namespaces): ?InputDefinition
+ {
+ try {
+ $providers = app()->getLoadedProviders();
+ } catch (Throwable) {
+ return null;
+ }
+
+ foreach (array_keys($providers) as $providerClass) {
+ if (! class_exists($providerClass)) {
+ continue;
+ }
+
+ try {
+ $defaults = (new \ReflectionClass($providerClass))->getDefaultProperties();
+ } catch (Throwable) {
+ continue;
+ }
+
+ foreach ((array) ($defaults['commands'] ?? []) as $commandClass) {
+ if (! is_string($commandClass) || ! class_exists($commandClass)) {
+ continue;
+ }
+
+ $owned = false;
+ foreach ($namespaces as $namespace) {
+ if (str_starts_with($commandClass, $namespace)) {
+ $owned = true;
+ break;
+ }
+ }
+
+ if (! $owned) {
+ continue;
+ }
+
+ try {
+ /** @var SymfonyCommand $instance */
+ $instance = app()->make($commandClass);
+ } catch (Throwable) {
+ continue;
+ }
+
+ if (! $instance instanceof SymfonyCommand || $instance->getName() !== $name) {
+ continue;
+ }
+
+ return $instance->getDefinition();
}
}
diff --git a/app/Support/TrustedScriptHosts.php b/app/Support/TrustedScriptHosts.php
new file mode 100644
index 00000000..7ba0ce4f
--- /dev/null
+++ b/app/Support/TrustedScriptHosts.php
@@ -0,0 +1,180 @@
+ 중복 제거된 호스트명 목록 (소문자)
+ */
+ public static function hosts(): array
+ {
+ $hosts = [];
+
+ try {
+ foreach (app(ModuleManager::class)->getActiveModules() as $module) {
+ if (method_exists($module, 'getTrustedScriptHosts')) {
+ $hosts = array_merge($hosts, $module->getTrustedScriptHosts());
+ }
+ }
+ } catch (\Throwable $e) {
+ Log::warning('TrustedScriptHosts: 모듈 집계 실패 - '.$e->getMessage());
+ }
+
+ try {
+ foreach (app(PluginManager::class)->getActivePlugins() as $plugin) {
+ if (method_exists($plugin, 'getTrustedScriptHosts')) {
+ $hosts = array_merge($hosts, $plugin->getTrustedScriptHosts());
+ }
+ }
+ } catch (\Throwable $e) {
+ Log::warning('TrustedScriptHosts: 플러그인 집계 실패 - '.$e->getMessage());
+ }
+
+ // 템플릿은 모듈/플러그인과 달리 PHP 확장 클래스가 없고 manifest 배열로 다뤄지므로,
+ // 활성 템플릿의 template.json 에서 직접 읽는다. 타입별 활성 템플릿은 각각 하나뿐이다.
+ try {
+ $templateManager = app(TemplateManager::class);
+
+ foreach (['admin', 'user'] as $type) {
+ $template = $templateManager->getActiveTemplate($type);
+
+ if (! is_array($template) || ! isset($template['trusted_script_hosts'])) {
+ continue;
+ }
+
+ $declared = $template['trusted_script_hosts'];
+
+ if (! is_array($declared)) {
+ continue;
+ }
+
+ $hosts = array_merge($hosts, $declared);
+ }
+ } catch (\Throwable $e) {
+ Log::warning('TrustedScriptHosts: 템플릿 집계 실패 - '.$e->getMessage());
+ }
+
+ // 확장이 훅으로 동적 추가할 수 있는 경로 (manifest 외 경로)
+ $hosts = HookManager::applyFilters(self::FILTER_HOOK, $hosts);
+
+ if (! is_array($hosts)) {
+ $hosts = [];
+ }
+
+ // 정규화: 문자열·비어있지 않음·소문자·중복 제거
+ $normalized = [];
+ foreach ($hosts as $host) {
+ if (! is_string($host)) {
+ continue;
+ }
+ $host = strtolower(trim($host));
+ if ($host !== '') {
+ $normalized[$host] = true;
+ }
+ }
+
+ return array_keys($normalized);
+ }
+
+ /**
+ * URL 에서 호스트명을 추출합니다.
+ *
+ * `//host/path`(protocol-relative)·`https://host/path`(scheme 포함) 모두 처리합니다.
+ * same-origin 경로(`/path`)는 호스트가 없으므로 null 을 반환합니다.
+ *
+ * @param string $url 검사 대상 URL
+ * @return string|null 소문자 호스트명 (없으면 null)
+ */
+ public static function hostOf(string $url): ?string
+ {
+ $host = parse_url(self::normalizeForOriginCheck(trim($url)), PHP_URL_HOST);
+
+ return is_string($host) && $host !== '' ? strtolower($host) : null;
+ }
+
+ /**
+ * origin 판정 전에 URL 을 브라우저 URL 파서와 동일하게 정규화합니다.
+ *
+ * 브라우저(WHATWG URL)는 파싱 전에 ASCII tab·LF·CR 을 제거하고, special scheme
+ * (http/https)에서 백슬래시를 슬래시와 동등하게 처리합니다. 이 정규화 없이 판정하면
+ * 같은 문자열을 계층마다 다른 출처로 읽습니다:
+ *
+ * - `https://evil.com\@cdn.ckeditor.com/x.js` — 정규화 없이는 호스트가
+ * `cdn.ckeditor.com`(userinfo 해석)이지만 브라우저는 `evil.com` 에서 로드합니다.
+ * - `/\/cdn.ckeditor.com/x.js` — 문자열상 path 지만 브라우저는 authority 로 읽습니다.
+ *
+ * 정규화 후 판정하면 경로 중간의 백슬래시·탭(`/js/a\b.js`)은 authority 를 만들지
+ * 않으므로 그대로 통과합니다(과차단 없음).
+ *
+ * 이 메서드가 origin 판정 정규화의 SSoT 입니다 — 저장측 규칙
+ * (`App\Rules\SafeLayoutExpressions`)이 위임하고, 클라이언트
+ * (`TemplateApp.normalizeScriptSrcForOriginCheck`)·정적 검사
+ * (`layout-scripts-src-same-origin`)가 동형 구현을 갖습니다. 한 계층만 바꾸면
+ * 그 계층만 다른 출처를 보게 되며, 예외도 경고도 없이 판정만 갈립니다.
+ *
+ * @param string $url 원본 URL
+ * @return string 정규화된 URL
+ */
+ public static function normalizeForOriginCheck(string $url): string
+ {
+ // ASCII tab / LF / CR 제거 (브라우저 파서가 파싱 전에 제거하는 문자)
+ $stripped = str_replace(["\t", "\n", "\r"], '', $url);
+
+ // 백슬래시를 슬래시로 (special scheme 에서 등가)
+ $slashed = str_replace('\\', '/', $stripped);
+
+ // 선행 슬래시가 3개 이상이어도 브라우저는 authority 시작으로 접는다
+ // (`///host/x` ≡ `//host/x`, `https:///host/x` ≡ `https://host/x`).
+ // 접지 않으면 `/\/host/x` 가 정규화 후 `///host/x` 가 되어 parse_url 은
+ // 호스트를 못 찾는데 브라우저는 host 에서 로드하는 갈림이 생긴다.
+ // 경로 중간의 연속 슬래시(`/js//a.js`)는 브라우저도 경로로 두므로 건드리지 않는다.
+ return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
+ }
+
+ /**
+ * URL 의 호스트가 신뢰 목록에 있는지 판정합니다.
+ *
+ * @param string $url 검사 대상 URL
+ * @param array|null $hosts 신뢰 호스트 목록 (미지정 시 self::hosts())
+ * @return bool 신뢰 호스트면 true (호스트 없는 same-origin 경로는 false)
+ */
+ public static function isTrustedUrl(string $url, ?array $hosts = null): bool
+ {
+ $host = self::hostOf($url);
+
+ if ($host === null) {
+ return false;
+ }
+
+ $hosts ??= self::hosts();
+
+ return in_array($host, $hosts, true);
+ }
+}
diff --git a/app/Support/UserGradeGuard.php b/app/Support/UserGradeGuard.php
new file mode 100644
index 00000000..b882a30d
--- /dev/null
+++ b/app/Support/UserGradeGuard.php
@@ -0,0 +1,83 @@
+isSuperAdmin()) {
+ return true;
+ }
+
+ // 슈퍼 관리자 대상은 슈퍼 관리자 액터만 수정 가능
+ return $actor->isSuperAdmin();
+ }
+
+ /**
+ * 액터가 대상을 수정할 수 없으면 예외를 던집니다.
+ *
+ * @param User $target 수정 대상 사용자
+ * @param User|null $actor 행위자(미지정 시 현재 인증 사용자)
+ *
+ * @throws CannotModifySuperAdminException 상한 위반 시
+ */
+ public static function assertActorMayModify(User $target, ?User $actor = null): void
+ {
+ if (! self::mayModify($target, $actor)) {
+ throw new CannotModifySuperAdminException;
+ }
+ }
+
+ /**
+ * 액터가 수정 가능한 대상만 남긴 배열을 반환합니다(일괄 작업용).
+ *
+ * @param iterable $targets 대상 사용자 목록
+ * @param User|null $actor 행위자(미지정 시 현재 인증 사용자)
+ * @return array 수정 가능한 대상 목록
+ */
+ public static function filterModifiable(iterable $targets, ?User $actor = null): array
+ {
+ $actor ??= Auth::user();
+
+ $allowed = [];
+ foreach ($targets as $target) {
+ if (self::mayModify($target, $actor)) {
+ $allowed[] = $target;
+ }
+ }
+
+ return $allowed;
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index d43438ac..eaec8778 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -1,7 +1,10 @@
render(function (PermissionEscalationException|CannotModifySuperAdminException|CannotModifyProtectedRoleException $e, Request $request) {
+ if ($request->expectsJson() || $request->is('api/*')) {
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ], 403);
+ }
+ });
+
// 확장 코어 버전 호환성 검사 실패 → HTTP 422 + error_code: 'core_version_mismatch'
// (extension update/activate/recovery 등 사전 검증 진입 지점에서 throw)
$exceptions->render(function (CoreVersionMismatchException $e, Request $request) {
diff --git a/composer.json b/composer.json
index 2c69d7e3..04624205 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,8 @@
"laravel/sanctum": "^4.2",
"laravel/scout": "^11.1",
"laravel/tinker": "^2.10.1",
+ "league/flysystem-aws-s3-v3": "^3.25.1",
+ "predis/predis": "^2.0",
"symfony/http-client": "^7.4",
"symfony/mailgun-mailer": "^7.4"
},
diff --git a/composer.lock b/composer.lock
index 97c428c9..9228fc16 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "675518b422a5d9dc29590b1f22a49c25",
+ "content-hash": "f34469561e87f11296bf77ecc5db9fbf",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2547,6 +2547,61 @@
},
"time": "2026-06-25T06:52:23+00:00"
},
+ {
+ "name": "league/flysystem-aws-s3-v3",
+ "version": "3.35.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
+ "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
+ "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
+ "shasum": ""
+ },
+ "require": {
+ "aws/aws-sdk-php": "^3.371.5",
+ "league/flysystem": "^3.10.0",
+ "league/mime-type-detection": "^1.0.0",
+ "php": "^8.0.2"
+ },
+ "conflict": {
+ "guzzlehttp/guzzle": "<7.0",
+ "guzzlehttp/ringphp": "<1.1.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "League\\Flysystem\\AwsS3V3\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Frank de Jonge",
+ "email": "info@frankdejonge.nl"
+ }
+ ],
+ "description": "AWS S3 filesystem adapter for Flysystem.",
+ "keywords": [
+ "Flysystem",
+ "aws",
+ "file",
+ "files",
+ "filesystem",
+ "s3",
+ "storage"
+ ],
+ "support": {
+ "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
+ },
+ "time": "2026-07-01T23:25:49+00:00"
+ },
{
"name": "league/flysystem-local",
"version": "3.31.0",
@@ -3601,6 +3656,68 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
+ {
+ "name": "predis/predis",
+ "version": "v2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/predis/predis.git",
+ "reference": "07105e050622ed80bd60808367ced9e379f31530"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/predis/predis/zipball/07105e050622ed80bd60808367ced9e379f31530",
+ "reference": "07105e050622ed80bd60808367ced9e379f31530",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.3",
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpcov": "^6.0 || ^8.0",
+ "phpunit/phpunit": "^8.0 || ^9.4"
+ },
+ "suggest": {
+ "ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Predis\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Till Krüss",
+ "homepage": "https://till.im",
+ "role": "Maintainer"
+ }
+ ],
+ "description": "A flexible and feature-complete Redis/Valkey client for PHP.",
+ "homepage": "http://github.com/predis/predis",
+ "keywords": [
+ "nosql",
+ "predis",
+ "redis"
+ ],
+ "support": {
+ "issues": "https://github.com/predis/predis/issues",
+ "source": "https://github.com/predis/predis/tree/v2.4.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/tillkruss",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-12T18:00:11+00:00"
+ },
{
"name": "psr/clock",
"version": "1.0.0",
@@ -10573,5 +10690,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/config/app.php b/config/app.php
index b18d9058..ca5ebd49 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.6'),
+ 'version' => env('APP_VERSION', '7.0.7'),
/*
|--------------------------------------------------------------------------
diff --git a/config/attachment.php b/config/attachment.php
index 75e64a1a..43c2f5f9 100644
--- a/config/attachment.php
+++ b/config/attachment.php
@@ -9,8 +9,26 @@ return [
| 첨부파일 저장에 사용할 디스크를 지정합니다.
| config/filesystems.php에 정의된 디스크 이름을 사용합니다.
|
+ | 빈 값(`ATTACHMENT_DISK=`)은 미설정으로 간주한다 — env() 는 키가 존재하면
+ | 빈 문자열을 그대로 돌려주므로(기본값 미적용), `cp .env.example .env` 설치
+ | 절차에서 빈 값이 그대로 오면 Storage::disk('') 로 업로드가 전면 실패한다.
+ |
*/
- 'disk' => env('ATTACHMENT_DISK', 'attachments'),
+ 'disk' => env('ATTACHMENT_DISK') ?: 'attachments',
+
+ /*
+ |--------------------------------------------------------------------------
+ | 디스크 명시 설정 여부 (내부 판별용)
+ |--------------------------------------------------------------------------
+ |
+ | ATTACHMENT_DISK env 가 명시되었는지 판별하기 위한 키. env() 직접 호출은
+ | config:cache 환경에서 항상 null 이므로, config 캐시에 태워 두고 이 값으로
+ | 판별한다. null 이면 미명시 — 관리자 환경설정의 storage_driver(s3) 가
+ | 업로드 디스크를 전환할 수 있다 (SettingsServiceProvider::applyDriverConfig).
+ | 빈 값(`ATTACHMENT_DISK=`)도 미명시로 정규화한다 (위 disk 의 사유와 동일).
+ |
+ */
+ 'disk_explicit' => env('ATTACHMENT_DISK') ?: null,
/*
|--------------------------------------------------------------------------
diff --git a/config/core.php b/config/core.php
index e132f002..3609bbef 100644
--- a/config/core.php
+++ b/config/core.php
@@ -25,6 +25,8 @@ return [
'upload_image_max_height_max' => 10000,
'upload_image_quality_min' => 1,
'upload_image_quality_max' => 100,
+ 'upload_orphan_retention_days_min' => 1,
+ 'upload_orphan_retention_days_max' => 3650,
// SEO
'seo_og_image_default_width_min' => 0,
@@ -125,6 +127,19 @@ return [
'like_operator_default' => 'like',
],
+ /*
+ |--------------------------------------------------------------------------
+ | 스토리지 — 공개 자산 직접 URL 서빙 디스크
+ |--------------------------------------------------------------------------
+ | 완전 공개 자산(상품/카테고리/리뷰/에디터 이미지)의 직접 URL(CDN) 서빙에 쓸
+ | 디스크입니다. 빈 문자열이면 미설정(기존 PHP 스트리밍 유지)이며, 값은 관리자
+ | 환경설정(drivers.public_asset_disk)에서 SettingsServiceProvider 가 주입합니다.
+ | 확장은 개별 설정으로 이 전역값을 오버라이드할 수 있습니다.
+ */
+ 'storage' => [
+ 'public_asset_disk' => '',
+ ],
+
/*
|--------------------------------------------------------------------------
| 코어 권한 정의
diff --git a/config/filesystems.php b/config/filesystems.php
index 56165197..a2f3655b 100644
--- a/config/filesystems.php
+++ b/config/filesystems.php
@@ -99,8 +99,11 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
- 'url' => env('AWS_URL'),
- 'endpoint' => env('AWS_ENDPOINT'),
+ // 빈 값(`AWS_URL=`/`AWS_ENDPOINT=`)은 미설정으로 정규화 — env() 는 키가
+ // 존재하면 빈 문자열을 돌려주므로, '' 가 endpoint 로 주입되면 S3 클라이언트
+ // 구성이 깨진다 (cp .env.example 설치 절차 방어).
+ 'url' => env('AWS_URL') ?: null,
+ 'endpoint' => env('AWS_ENDPOINT') ?: null,
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
'report' => false,
diff --git a/config/schedule_security.php b/config/schedule_security.php
index 996463bb..62b63e88 100644
--- a/config/schedule_security.php
+++ b/config/schedule_security.php
@@ -9,10 +9,38 @@ return [
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
+ // Shell 타입은 범용 크론탭 대체이므로 운영자가 python/node/php/bash 등
+ // 인터프리터를 등록해 기존 스케줄러 스크립트를 주기 실행할 수 있다.
'allowed_binaries' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
))),
+
+ // 아래 세 목록은 코드 소유 리터럴이다(env 아님) — 스케줄 권한만 위임받은 관리자가
+ // 정책을 넓혀 인라인 코드 실행을 복구하지 못하게 한다.
+
+ // 스크립트 실행형 인터프리터 — 첫 인자를 "스크립트 파일 경로" 로 강제한다.
+ // 위험한 것은 인터프리터 자체가 아니라 인터프리터에게 인라인 코드/명령을 넘기는 것이므로
+ // (하이픈 선두=인라인 코드 플래그 거부, 절대경로 요구, `..`/`#` 금지, basename≠artisan)
+ // 를 적용해 스크립트 파일 실행만 허용한다.
+ 'script_interpreters' => [
+ 'bash', 'sh', 'dash', 'zsh', 'ksh', 'csh', 'tcsh', 'ash', 'fish',
+ 'php', 'php-cgi', 'python', 'python2', 'python3',
+ 'perl', 'ruby', 'node', 'nodejs', 'lua', 'luajit', 'tclsh',
+ 'rscript', 'pwsh', 'powershell',
+ ],
+
+ // 완전 거부형 — 첫 인자가 코드/애플릿/감싼 명령이라 "스크립트 파일" 모델이 성립하지 않는다.
+ // allowed_binaries 에 등재돼 있어도 실행하지 않는다.
+ 'reject_binaries' => [
+ 'env', 'xargs', 'sudo', 'doas', 'make', 'awk', 'gawk', 'mawk', 'nawk', 'sed', 'find',
+ 'busybox', 'nohup', 'timeout', 'watch', 'nice', 'ionice', 'setsid', 'stdbuf',
+ 'script', 'expect', 'socat', 'nc', 'ncat', 'tee', 'flock', 'chroot', 'unshare',
+ ],
+
+ // 인터프리터 뒤 스크립트 자리에 올 수 없는 basename — 코어 Artisan 축 우회 방지.
+ // `php artisan ...` 는 Artisan 타입으로 유도한다.
+ 'denied_script_names' => ['artisan'],
],
'artisan' => [
@@ -54,6 +82,16 @@ return [
'notification:cleanup' => ['options' => []],
'layout-previews:cleanup' => ['options' => []],
'ext-bundles:cleanup' => ['options' => []],
+ 'seo:prune-stats' => ['options' => ['days']],
+ 'schedules:prune-history' => ['options' => ['days']],
+ 'identity:expire-challenges' => ['options' => []],
+ 'identity:prune-logs' => ['options' => ['days']],
+ 'activity-log:prune' => ['options' => ['days']],
+ 'notification-log:prune' => ['options' => ['days']],
+ // 실삭제가 기본이나 사용자 파일 파기는 커맨드 자체가 설정 토글·보존기간으로 이중 확인한다.
+ 'attachments:prune-orphans' => ['options' => ['dry-run', 'limit', 'days', 'scheduled']],
+ // 잔존물 정리 — 최신 백업 1개 상시 보존 가드는 커맨드 내부 소유(옵션으로 무력화 불가).
+ 'storage:prune-leftovers' => ['options' => ['days', 'backup-days', 'dry-run']],
// SEO
'seo:warmup' => ['options' => ['layout']],
diff --git a/config/settings/defaults.json b/config/settings/defaults.json
index 2d57483e..30a388b7 100644
--- a/config/settings/defaults.json
+++ b/config/settings/defaults.json
@@ -49,7 +49,9 @@
"allowed_extensions": ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip"],
"image_max_width": 2000,
"image_max_height": 2000,
- "image_quality": 85
+ "image_quality": 85,
+ "orphan_cleanup_enabled": false,
+ "orphan_retention_days": 30
},
"seo": {
"meta_title_suffix": "",
@@ -118,11 +120,14 @@
},
"drivers": {
"storage_driver": "local",
+ "public_asset_disk": "none",
"s3_bucket": "",
"s3_region": "ap-northeast-2",
"s3_access_key": "",
"s3_secret_key": "",
"s3_url": "",
+ "s3_endpoint": "",
+ "s3_use_path_style": false,
"cache_driver": "file",
"redis_host": "127.0.0.1",
"redis_port": 6379,
@@ -199,7 +204,9 @@
"allowed_extensions_formatted": { "type": "string", "sensitive": false, "computed": true, "source": "allowed_extensions", "transform": "format_extensions" },
"image_max_width": { "type": "integer", "sensitive": false },
"image_max_height": { "type": "integer", "sensitive": false },
- "image_quality": { "type": "integer", "sensitive": false, "expose": false }
+ "image_quality": { "type": "integer", "sensitive": false, "expose": false },
+ "orphan_cleanup_enabled": { "type": "boolean", "sensitive": false, "expose": false },
+ "orphan_retention_days": { "type": "integer", "sensitive": false, "expose": false }
}
},
"seo": {
@@ -300,11 +307,14 @@
"_comment": "드라이버 설정 — 프론트엔드 미노출 (admin_settings에서 API 원본값 기반 비교로 변경)",
"fields": {
"storage_driver": { "type": "string", "sensitive": false },
+ "public_asset_disk": { "type": "string", "sensitive": false },
"s3_bucket": { "type": "string", "sensitive": false },
"s3_region": { "type": "string", "sensitive": false },
"s3_access_key": { "type": "string", "sensitive": true },
"s3_secret_key": { "type": "string", "sensitive": true },
"s3_url": { "type": "string", "sensitive": false },
+ "s3_endpoint": { "type": "string", "sensitive": false },
+ "s3_use_path_style": { "type": "boolean", "sensitive": false },
"cache_driver": { "type": "string", "sensitive": false },
"redis_host": { "type": "string", "sensitive": false },
"redis_port": { "type": "integer", "sensitive": false },
@@ -328,7 +338,8 @@
"websocket_verify_ssl": { "type": "boolean", "sensitive": false },
"websocket_server_host": { "type": "string", "sensitive": false },
"websocket_server_port": { "type": "integer", "sensitive": false },
- "websocket_server_scheme": { "type": "string", "sensitive": false }
+ "websocket_server_scheme": { "type": "string", "sensitive": false },
+ "search_engine_driver": { "type": "string", "sensitive": false }
}
},
"notifications": {
@@ -341,7 +352,7 @@
"expose": true,
"_comment": "본인인증 인프라 provider 기술 파라미터 — signup/enabled 등 정책 분기 키는 IdentityPolicy 로 흡수됨",
"fields": {
- "default_provider": { "type": "string", "sensitive": false },
+ "default_provider": { "type": "string", "sensitive": false, "expose": false, "_comment": "저장값을 그대로 SSR 로 내보내면 제거된 플러그인의 provider ID 가 전 공개 페이지에 남는다. 소비처 0 이므로 미노출 — 프론트가 provider 를 알아야 하는 지점(428 응답 / 정책 프리페치)은 레지스트리 대조를 거친 값을 따로 받는다" },
"purpose_providers": { "type": "array", "sensitive": false, "expose": false },
"challenge_ttl_minutes": { "type": "integer", "sensitive": false, "expose": false },
"max_attempts": { "type": "integer", "sensitive": false, "expose": false }
diff --git a/docs/backend/admin-settings-access.md b/docs/backend/admin-settings-access.md
index 3d0138d1..a96fa95c 100644
--- a/docs/backend/admin-settings-access.md
+++ b/docs/backend/admin-settings-access.md
@@ -38,10 +38,12 @@
| `drivers.session_lifetime` | `session.lifetime` (testing 차단) |
| `drivers.queue_driver` | `queue.default` (testing 차단) |
| `drivers.storage_driver` | `filesystems.default` |
+| `drivers.public_asset_disk` | `core.storage.public_asset_disk` (`'none'`/빈값 → `''`, testing 차단) |
| `drivers.search_engine_driver` | `scout.driver` |
| `drivers.redis_*` | `database.redis.*` |
| `drivers.memcached_*` | `cache.stores.memcached.*` |
-| `drivers.s3_*` | `filesystems.disks.s3.*` |
+| `drivers.s3_*` | `filesystems.disks.s3.*` — `s3_url` → `url`(공개 URL base), `s3_endpoint` → `endpoint`(API 요청 대상), `s3_use_path_style` → `use_path_style_endpoint` |
+| `drivers.storage_driver` = `s3` | `attachment.disk` = `s3` (단, `ATTACHMENT_DISK` env 명시 시 env 우선 — `attachment.disk_explicit` 로 판별) |
| `geoip.feature_enabled`, `geoip.license_key`, `geoip.auto_update_enabled` | `geoip.*` |
위 매핑은 [`app/Providers/SettingsServiceProvider.php`](../../app/Providers/SettingsServiceProvider.php) 가 단일 SSoT.
@@ -92,6 +94,40 @@ g7_core_settings('drivers.queue_driver'); // dev 공유 drivers.json 의 datab
---
+## config 미러 갱신 시점 (부팅 + 저장)
+
+`g7_settings.core` / `g7_settings.modules.{id}` / `g7_settings.plugins.{id}` 는 in-memory config 미러다. `g7_core_settings()` / `g7_module_settings()` / `g7_plugin_settings()` 가 이 미러를 읽는다.
+
+미러는 **부팅 때와 저장 때** 모두 채워진다. 채움 로직은 `App\Support\ExtensionSettingsMirror` 가 단일 소유하며, Provider 와 저장 경로가 같은 코드를 호출한다.
+
+| 축 | 채우는 지점 |
+|----|-----------|
+| 코어 | 부팅 `SettingsServiceProvider::register()` / 저장 `SettingsService::invalidateSettingsCache()` |
+| 모듈 | 부팅 `CoreServiceProvider` / 저장 각 모듈 SettingsService 의 캐시 초기화 지점에서 `g7_refresh_module_settings_config($id)` |
+| 플러그인 | 부팅 `CoreServiceProvider` / 저장 `PluginSettingsService::save()` |
+
+부팅만으로 채우면 FPM 에서는 요청마다 재부팅되어 드러나지 않지만, 큐 워커·`schedule:work`·Reverb 처럼 프로세스가 상주하는 환경에서는 저장 후에도 그 프로세스가 옛 값을 영원히 읽는다. 새 저장 경로를 만들면 그 자리에서 미러 재채움을 함께 호출한다.
+
+> 다중 상주 워커 간 브로드캐스트 동기화는 범위 밖이다 — 저장을 수행한 프로세스와 이후 새로 부팅되는 프로세스가 정합하면 충분하고, 워커 재기동은 드라이버 탭의 큐 재시작이 담당한다.
+
+### 미러는 민감 항목을 담지 않는다 — 민감값은 전용 게터로
+
+스키마에 `sensitive: true` 로 선언된 필드는 미러에서 **키 자체가 제거**된다 (암호문도 싣지 않는다).
+
+미러는 봇 전용 화면 생성기가 레이아웃 표현식에서 참조한 키를 **제한 없이** 조회하는 경로다. 민감값이 실려 있으면 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나간다. 브라우저용 설정 전달 경로는 애초에 민감 항목을 제외하므로 화면만 봐서는 드러나지 않는 비대칭이다.
+
+민감값이 필요한 서버 코드는 전용 게터를 쓴다.
+
+```php
+// 표시·분기용 (민감 항목 제외)
+g7_plugin_settings('sirsoft-gdpr', 'duplicate_block_enabled');
+
+// 민감값 (복호화 포함)
+plugin_setting('sirsoft-pay_kginicis', 'api_key');
+```
+
+---
+
## 새 admin 환경설정 키 추가 시 점검
새 카테고리/키를 `storage/app/settings/*.json` 에 추가할 때:
diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md
index da192033..75eaf33f 100644
--- a/docs/backend/api/README.md
+++ b/docs/backend/api/README.md
@@ -178,6 +178,21 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
성공 판정은 상태코드가 아니라 **본문의 매직 토큰과 Content-Type** 으로 합니다. 상태코드만 보면
"404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서 영원히 오판합니다.
+### 보안 게이트 (KVE-2026 대응)
+
+일부 관리 엔드포인트에는 표준 응답 외에 다음 보안 게이트가 적용됩니다(각 엔드포인트 표에는 별도 표기가 없어도 공통 적용).
+
+- **등급 상한 (KVE-2026-1919)** — 사용자·역할 쓰기 경로:
+ - `PUT /api/admin/users/{user}`, `POST /api/admin/users/{user}/unlock`: 비-슈퍼관리자 액터가 슈퍼 관리자 계정을 수정·잠금해제하려 하면 `403` (`exceptions.cannot_modify_super_admin`).
+ - `PATCH /api/admin/users/bulk-status`: 비-슈퍼관리자 액터가 포함시킨 슈퍼 관리자 대상은 일괄 처리에서 제외(요청은 `200`, 슈퍼 관리자 상태 불변).
+ - `POST /api/admin/roles`, `PUT /api/admin/roles/{role}`: 비-슈퍼관리자 액터가 자신이 보유하지 않았거나 자신의 범위(scope)보다 넓은 권한을 부여하려 하면 `403` (`exceptions.cannot_grant_unheld_permission`).
+ - `PUT /api/admin/roles/{role}`, `PATCH /api/admin/roles/{role}/toggle-status`: 비-슈퍼관리자 액터가 코어/확장 소유 역할(예: `admin`)을 수정·토글하려 하면 `403` (`exceptions.cannot_modify_protected_role`).
+ - 슈퍼 관리자 액터의 동일 작업은 정상 수행됩니다.
+- **레이아웃 저장 표현식/URL 검증 (KVE-2026-1915)** — 레이아웃 생성·수정(`POST/PUT /api/admin/layouts*`)의 `content` 검증:
+ - `{{...}}`·`computed`·`init_actions`/`actions` 문자열 값에 위험 토큰이 있으면 `422` (`validation.layout.dangerous_expression`). 차단 토큰은 프로토타입 체인 접근(`.constructor`/`.__proto__`/`.prototype`, `['constructor']`, 원시 `__proto__`)·`Function(`·`eval(`·`import(` 입니다.
+ - `scripts[].src`·`data_sources[].endpoint` 가 same-origin path-only(`/` 시작)가 아니면 `422` (`validation.layout.external_resource_url`). 단, 활성 확장(모듈·플러그인·템플릿)이 자기 manifest 의 `trusted_script_hosts` 로 선언한 호스트는 예외로 허용됩니다 — 이 목록은 확장 배포물이 정하며 요청으로 바꿀 수 없습니다.
+ - 정상 표현식(조건·계산·목록 가공·화살표 함수·템플릿 리터럴·경로 조립)은 통과합니다.
+
## 코어 API 레퍼런스
diff --git a/docs/backend/api/activity-logs.md b/docs/backend/api/activity-logs.md
index 0e3d36ef..af4b50ae 100644
--- a/docs/backend/api/activity-logs.md
+++ b/docs/backend/api/activity-logs.md
@@ -261,7 +261,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -313,7 +313,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md
index e8a2b82c..69fca6b7 100644
--- a/docs/backend/api/auth.md
+++ b/docs/backend/api/auth.md
@@ -535,17 +535,58 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`AuthService::completeTwoFactor()` 가 로그인 세션을 발급해 반환한 배열 — `user` 만 `UserResource` 로 감싼다). 성공 시 페이로드는 일반 로그인(`POST /api/auth/login`)과 동일하다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| user | object | `{"uuid":"a234c2b1-…","name":"홍길동","is_admin":false, …}` | 로그인한 사용자 정보 (`UserResource` — 필드 전수는 `GET /api/auth/user` 응답 필드 표의 기본(코어) 필드와 동일) |
+| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 Sanctum 접근 토큰 평문 (이후 `Authorization: Bearer` 헤더로 사용, 발급 시 1회만 노출) |
+| token_type | string | `Bearer` | 토큰 타입 (항상 `Bearer`) |
+
+> 위 문서의 실측이 `422` 로 관측된 것은 유효한 challenge 없이 프로브가 호출됐기 때문이다. 정상 흐름(비밀번호 단계가 돌려준 `challenge_id` + 올바른 코드)에서는 `200` 과 위 페이로드가 반환된다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "로그인이 성공했습니다.",
+ "data": {
+ "user": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "email": "apidoc-sample-user@example.com",
+ "language": "ko",
+ "status": "active",
+ "is_admin": false,
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true,
+ "can_assign_roles": true
+ }
+ },
+ "token": "{MASKED}",
+ "token_type": "Bearer"
+ }
+}
+```
+
+> `user` 객체는 지면 절약을 위해 축약했습니다. 실제로는 `UserResource` 필드 전수가 내려옵니다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
+| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우. **세 사유를 같은 응답으로 뭉뚱그린다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
+| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) |
diff --git a/docs/backend/api/core-update.md b/docs/backend/api/core-update.md
index a2b60ab0..625f23bf 100644
--- a/docs/backend/api/core-update.md
+++ b/docs/backend/api/core-update.md
@@ -126,7 +126,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/dashboard.md b/docs/backend/api/dashboard.md
index cc9ed775..faaf8672 100644
--- a/docs/backend/api/dashboard.md
+++ b/docs/backend/api/dashboard.md
@@ -214,7 +214,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
@@ -323,7 +323,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
@@ -395,7 +395,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
@@ -481,7 +481,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
diff --git a/docs/backend/api/identity.md b/docs/backend/api/identity.md
index 9a842161..bace9eeb 100644
--- a/docs/backend/api/identity.md
+++ b/docs/backend/api/identity.md
@@ -234,7 +234,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.purge`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -352,7 +352,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -513,7 +513,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 정의 생성 중 예외 발생 (`IDV 메시지 정의 생성에 실패했습니다.`) |
@@ -713,7 +713,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1018,7 +1018,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1145,7 +1145,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1417,7 +1417,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1509,7 +1509,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.messages.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1652,7 +1652,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.policies.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -2215,7 +2215,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.admin.identity.logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.admin.identity.providers.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -2634,7 +2634,7 @@ _단건 응답: `data` 객체의 필드 (매칭·활성 정책이 없으면 `dat
| scope | string | `hook` | 정책 적용 범위 (route / hook / custom) |
| target | string | `sirsoft-board.report.before_create` | 매칭 대상 |
| purpose | string | `sensitive_action` | 이 정책이 요구하는 인증 목적 |
-| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null) |
+| provider_id | null | `null` | 강제할 프로바이더 ID (미지정 시 null). 정책의 저장값을 그대로 내보내지 않고 **현재 등록된 프로바이더로 해석한 값**이다 — 저장값이 등록 목록에 있으면 그대로, 없거나 비어 있으면 인증 목적 기준 폴백 체인(환경설정 기본 프로바이더 → 목적별 지정 → 등록된 첫 프로바이더)의 결과가 실린다. 428 강제 응답의 `verification.provider_id` 와 같은 해석을 공유하므로, 삭제된 플러그인의 식별자가 이 응답에 남지 않는다 |
| grace_minutes | integer | `30` | 재인증 유예 시간(분) — 0=매번 요구 |
| applies_to | string | `self` | 적용 대상 사용자 (self / admin / both) |
| fail_mode | string | `block` | 실패 시 동작 (block: HTTP 428 차단 / log_only: 감사 로그만) |
diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md
index a8cbb02f..abc9a9f3 100644
--- a/docs/backend/api/language-packs.md
+++ b/docs/backend/api/language-packs.md
@@ -1005,6 +1005,8 @@ HTTP/1.1 200
**설명** 업로드된 ZIP을 실제로 설치하지 않고 manifest 와 검증 결과만 미리 조회합니다. `core.language_packs.install` 권한이 필요합니다. 부수 효과 없이 읽기만 수행하며 검증 실패 시 422로 응답합니다. 설치 확인 모달에서 대상 언어팩의 메타데이터와 유효성을 미리 보여줄 때 사용합니다.
+검증 실패 응답에는 예외 원문이 실리지 않습니다. 상세 진단 정보는 `app.debug` 가 켜진 환경에서만 `debug` 필드로 노출됩니다 — 이 규칙은 이 컨트롤러의 모든 실패 응답(설치·활성화·비활성화·삭제·업데이트 확인·업데이트 수행·캐시 갱신·목록 조회)에 동일하게 적용됩니다.
+
### POST /api/admin/language-packs/refresh-cache
@@ -1147,11 +1149,55 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`LanguagePackResource::toDetailArray`). 목록 응답(`GET /api/admin/language-packs`)의 항목 필드에 아래 상세 전용 필드가 더해집니다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| manifest | object\|null | `{"identifier":"g7-core-ja","locale":"ja","version":"1.0.6"}` | 언어팩 `language-pack.json` 원문 |
+| validation_summary | object | — | 설치 정합성 요약 |
+| validation_summary.target_version_mismatch | boolean | `false` | 대상(코어/확장) 버전과 언어팩이 맞지 않는지 여부 |
+| validation_summary.depends_on_core_locale | string\|null | `ja` | manifest `requires.depends_on_core_locale` — 이 팩이 전제하는 코어 로케일 |
+| source_meta | object | — | 설치 출처 메타 |
+| source_meta.type | string | `zip` | 설치 방식 (`zip`, `github`, `url`, `bundled`) |
+| source_meta.url | string\|null | `null` | 원격 설치 시 원본 주소 |
+| source_meta.installed_by | integer\|null | `1` | 설치를 수행한 사용자 ID |
+| source_meta.latest_version | string\|null | `1.0.6` | 업데이트 확인으로 파악된 최신 버전 |
+| source_meta.directory_path | string\|null | `lang-packs/g7-core-ja` | 프로젝트 루트 기준 설치 디렉토리 경로 (해석 실패 시 `null`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "언어팩 목록을 조회했습니다.",
+ "data": {
+ "identifier": "g7-core-ja",
+ "locale": "ja",
+ "version": "1.0.6",
+ "status": "active",
+ "manifest": {
+ "identifier": "g7-core-ja",
+ "locale": "ja",
+ "version": "1.0.6"
+ },
+ "validation_summary": {
+ "target_version_mismatch": false,
+ "depends_on_core_locale": null
+ },
+ "source_meta": {
+ "type": "bundled",
+ "url": null,
+ "installed_by": 1,
+ "latest_version": "1.0.6",
+ "directory_path": "lang-packs/g7-core-ja"
+ }
+ }
+}
+```
**에러 응답**
@@ -1330,11 +1376,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드. `{id}` 가 정수면 설치된 언어팩을, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` 를 대상으로 합니다. `CHANGELOG.md` 가 없으면 예외가 아니라 빈 결과로 응답합니다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `g7-core-ja` | 대상 언어팩 식별자 |
+| entries | array | `[{"version":"1.0.6","date":"2026-08-12","changes":{"Added":["..."]}}]` | `CHANGELOG.md` 를 파싱한 버전별 항목 목록 (Keep a Changelog 기준). 파일이 없으면 빈 배열 |
+| changelog | string | `"# Changelog\n\n## [1.0.6] - 2026-08-12\n..."` | `CHANGELOG.md` 원문. 파일이 없으면 빈 문자열 |
+| has_changelog | boolean | `true` | 표시할 변경 이력이 있는지 여부 (`entries` 가 비어 있지 않거나 원문이 빈 문자열이 아니면 `true`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "언어팩 목록을 조회했습니다.",
+ "data": {
+ "identifier": "g7-core-ja",
+ "entries": [
+ {
+ "version": "1.0.6",
+ "date": "2026-08-12",
+ "changes": {
+ "Added": ["배송지 변경 오류 안내 문구의 일본어 번역을 추가했습니다."]
+ }
+ }
+ ],
+ "changelog": "# Changelog\n\n## [1.0.6] - 2026-08-12\n\n### Added\n\n- ...\n",
+ "has_changelog": true
+ }
+}
+```
**에러 응답**
diff --git a/docs/backend/api/layouts.md b/docs/backend/api/layouts.md
index 17c494c5..6bc7e2bc 100644
--- a/docs/backend/api/layouts.md
+++ b/docs/backend/api/layouts.md
@@ -40,21 +40,86 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
**응답 필드** (`data` 내부)
-
+이 엔드포인트는 `ResponseHelper` 봉투(`success`/`message`/`data`)를 쓰지 않는다. **레이아웃 JSON 자체**를 최상위로 그대로 반환하므로 `data` 래퍼가 없다 — 템플릿 엔진이 실제 레이아웃 응답과 동일하게 소비할 수 있어야 하기 때문이다.
+
+최상위 키는 레이아웃 JSON 스키마를 따르며, 상속(`extends`) 병합과 모듈/플러그인 layout extension 적용이 **끝난 결과물**이다.
+
+| 필드 | 타입 | 설명 |
+| --- | --- | --- |
+| version | string | 레이아웃 스키마 버전 |
+| layout_name | string | 레이아웃 식별명 |
+| meta | object | 제목·설명·`auth_required`·`is_base`·SEO 설정 등 페이지 메타 |
+| components | array | 렌더링 컴포넌트 트리 (상속 병합 + extension 주입 완료 상태) |
+| slots | object | 슬롯 정의 (베이스 레이아웃일 때) |
+| data_sources | array | API 데이터 소스 정의 |
+| computed | object | 계산된 값 정의 |
+| state / init_state / initLocal / initGlobal / initIsolated | object | 초기 상태 정의 |
+| init_actions | array | 최초 렌더 시 실행할 액션 |
+| actions / named_actions | object | 재사용 액션 정의 |
+| modals | object | 모달 정의 |
+| errorHandling | object | 에러 핸들링 정의 |
+| globalHeaders | array | 공통 요청 헤더 (`pattern` + `headers` 쌍) |
+| permissions | object | 레이아웃 권한 선언 |
+
+> `extends` 키는 병합 후 결과에는 남지 않는다 (병합 입력으로만 쓰인다). 실제 포함되는 키 집합은 저장된 레이아웃 내용에 따라 달라진다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: application/json
+```
+
+```json
+{
+ "version": "1.0",
+ "layout_name": "product_list",
+ "meta": {
+ "title": "상품 목록",
+ "auth_required": false
+ },
+ "data_sources": [
+ {
+ "id": "products",
+ "endpoint": "/api/modules/sirsoft-ecommerce/products",
+ "method": "GET"
+ }
+ ],
+ "components": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "container mx-auto" },
+ "children": []
+ }
+ ]
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 404 | Not Found | 토큰에 해당하는 미리보기가 없거나 **만료된 경우** (`templates.layout_not_found`). 조회 시 `notExpired()` 스코프가 적용되므로 만료 토큰은 미존재와 동일하게 404 다 |
-**설명**
+**설명**
+
+레이아웃 편집기에서 **저장하지 않은 편집 중 내용**을 실제 화면으로 확인하기 위한 미리보기 서빙 엔드포인트다. 인증이 아니라 **토큰 자체가 보안 메커니즘**이며(`optional.sanctum` — 비회원도 접근 가능), 토큰 유효기간은 발급 시점부터 **30분**이다.
+
+미리보기 종류는 두 가지다.
+
+| `preview_type` | 동작 |
+| --- | --- |
+| `layout` (기본) | 편집 중인 레이아웃 content 를 기준으로 `extends` 상속을 병합한 뒤 모듈/플러그인 extension 을 적용한다 |
+| `extension` | 대표 레이아웃을 먼저 병합하고, 편집 중인 **확장 content** 를 그 확장 자리에 임시 치환한 상태로 extension 을 적용한다 |
+
+주의사항:
+
+- 토큰은 만료되면 되살릴 수 없다. 편집기에서 미리보기를 다시 열면 새 토큰이 발급된다.
+- 확장 미리보기의 임시 치환은 요청 처리 중에만 유효하며, `finally` 로 항상 해제되므로 다른 요청에 새지 않는다.
+- 응답이 봉투 없는 원본 JSON 이므로, 이 URL 을 `data_sources` 로 소비할 때 `{{x?.data?.…}}` 가 아니라 최상위 키를 직접 참조한다.
### GET /api/layouts/preview/{token}.json
@@ -180,21 +245,54 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
**응답 필드** (`data` 내부)
-
+확장자 없는 형태(`GET /api/layouts/preview/{token}`)와 **같은 컨트롤러 메서드**이므로 응답이 동일하다. `ResponseHelper` 봉투 없이 병합·확장 적용이 끝난 **레이아웃 JSON 자체**를 최상위로 반환한다. 필드 표는 위 항목을 참조한다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: application/json
+```
+
+```json
+{
+ "version": "1.0",
+ "layout_name": "product_list",
+ "meta": {
+ "title": "상품 목록",
+ "auth_required": false
+ },
+ "data_sources": [
+ {
+ "id": "products",
+ "endpoint": "/api/modules/sirsoft-ecommerce/products",
+ "method": "GET"
+ }
+ ],
+ "components": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "container mx-auto" },
+ "children": []
+ }
+ ]
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 404 | Not Found | 토큰에 해당하는 미리보기가 없거나 **만료된 경우** (`templates.layout_not_found`). 서버의 정적 최적화 블록이 `.json` 확장자를 가로채도 404 가 되므로, 이 경우 확장자 없는 형태로 재요청한다 |
-**설명**
+**설명**
+
+확장자 붙은 형태의 미리보기 서빙이다. 동작·토큰 수명(30분)·미리보기 종류는 확장자 없는 `GET /api/layouts/preview/{token}` 항목과 동일하다.
+
+두 형태가 함께 등록되는 이유는 서버 설정 차이 때문이다. 정규식 location(`location ~* \.(js|css|json)$`)이 프리픽스 location 보다 먼저 매칭되는 nginx 구성에서는 `.json` 으로 끝나는 동적 응답이 `try_files ... /index.php` 폴백 없이 404 가 된다. 그래서 라우트는 `Route::dualSuffix()` 로 두 형태를 동시에 등록하고, 클라이언트는 `/api/system/asset-probe` 프로브 결과에 따라 어느 쪽을 쓸지 결정한다. URL 조립은 서버측 `App\Support\AssetUrl`, 프론트측 `resources/js/core/support/assetUrl.ts` 가 담당하며 직접 문자열로 조립하지 않는다.
### GET /api/layouts/{templateIdentifier}/{layoutName}.json
diff --git a/docs/backend/api/me.md b/docs/backend/api/me.md
index 0c4f08bf..6f1edfc7 100644
--- a/docs/backend/api/me.md
+++ b/docs/backend/api/me.md
@@ -62,6 +62,32 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
현재 로그인한 사용자가 자신의 계정을 탈퇴한다. 프론트의 회원 탈퇴 모달(`_modal_withdraw.json`)이 호출한다. 별도 요청 파라미터 없이 인증 토큰의 사용자를 대상으로 하며, `UserService::withdrawUser()` 가 아바타·토큰 삭제와 개인정보 익명화를 수행한다. 되돌릴 수 없는 작업이므로 호출 전 사용자 확인 절차를 두는 것을 권장한다.
+탈퇴는 원자적으로 처리된다 — 약관 동의 이력 삭제·토큰 삭제·익명화가 하나의 트랜잭션이며, 어느 단계에서 실패해도 전부 취소된다. 아바타 파일 삭제는 커밋이 확정된 뒤에 수행되고, 파일 삭제가 실패해도 탈퇴는 되돌리지 않는다(참조되지 않는 파일만 남는다).
+
+익명화된 이메일에는 사용자 ID 가 포함되어 구조적으로 유일하다 — 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 충돌하지 않는다.
+
+탈퇴 처리 중 예기치 못한 오류(예: 데이터베이스 예외)가 발생하면 500 과 함께 고정된 안내 메시지(`user.withdraw_failed`)만 반환한다 — 예외 원문(SQL 상태코드·쿼리 등)은 응답에 싣지 않고 서버 로그에만 기록한다(회원에게 노출되는 경로이므로 내부 정보 유출을 차단한다).
+
+**추가 오류 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Validation Error | 관리자 역할 보유 계정 또는 수퍼관리자 계정의 탈퇴 시도 (`errors.general` 에 사유) |
+
+```json
+{
+ "success": false,
+ "message": "회원 탈퇴에 실패했습니다.",
+ "errors": {
+ "general": [
+ "관리자 계정은 탈퇴할 수 없습니다."
+ ]
+ }
+}
+```
+
+> 차단 응답은 종전에 500 이었으나, 잘못된 요청이지 서버 오류가 아니므로 422 로 바뀌었다. 활동 로그도 탈퇴가 성공한 경우에만 기록된다.
+
### GET /api/me
@@ -345,7 +371,7 @@ _단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — GE
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 (사용자 시간대 기준 문자열) |
| updated_at | string | `2026-07-08 11:02:10` | 수정 일시 (사용자 시간대 기준 문자열) |
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
-| abilities | object | `{"can_read":false,"can_create":false,"can_update":false,"can_delete":false,"can_assign_roles":false}` | 현재 사용자의 이 리소스에 대한 권한 맵 (core.users.read/create/update/delete, core.permissions.update 기준. 슈퍼관리자 계정은 `can_delete` 가 항상 false) |
+| abilities | object | `{"can_read":false,"can_create":false,"can_update":false,"can_delete":false,"can_assign_roles":false}` | 현재 사용자의 이 리소스에 대한 권한 맵 (core.users.read/create/update/delete 기준. `can_assign_roles` 는 `core.users.update` — 역할 부여는 사용자 관리의 일부. 슈퍼관리자 계정은 `can_delete` 가 항상 false) |
관계형 필드(`modules`, `plugins`, `menus`, `roles`, `permissions`, `consents`, `terms_consent`, `privacy_consent`)와 카운트 필드(`modules_count`, `plugins_count`, `menus_count`)는 해당 관계가 로드된 경우에만 응답에 포함된다 (프로필 수정 응답에서는 로드하지 않으므로 나타나지 않는다).
diff --git a/docs/backend/api/menus.md b/docs/backend/api/menus.md
index 822e0e83..495bc945 100644
--- a/docs/backend/api/menus.md
+++ b/docs/backend/api/menus.md
@@ -324,7 +324,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.menus.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -742,7 +742,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `moved_items[].new_parent_id` 가 자기 자신·자손을 가리키는 순환 참조인 경우 포함 |
@@ -798,7 +798,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.menus.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1102,7 +1102,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1198,7 +1198,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.menus.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/modules.md b/docs/backend/api/modules.md
index 1a60240d..1bee33e7 100644
--- a/docs/backend/api/modules.md
+++ b/docs/backend/api/modules.md
@@ -176,7 +176,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read\|core.menus.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -266,7 +266,7 @@ _단건 응답: `data` 객체의 필드 (`data.module` 은 목록과 동일한 `
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 |
| 409 | Conflict | `force` 없이 호출했고 필요한 의존 확장이 미충족인 경우 (`error` 에 `warning`, `missing_modules`, `missing_plugins` 포함) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 활성화 처리 중 예외 발생 (`module.activate_failed`) |
@@ -333,7 +333,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 업데이트 확인 처리가 실패한 경우 (`modules.check_updates_failed`) |
| 500 | Internal Server Error | 업데이트 확인 중 예외 발생 |
@@ -439,7 +439,7 @@ _단건 응답: `data` 는 비활성화된 모듈의 `ModuleResource` 객체 (
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 |
| 409 | Conflict | `force` 없이 호출했고 이 모듈에 의존하는 활성 확장이 있는 경우 (`error` 에 `warning`, `dependent_templates`, `dependent_modules`, `dependent_plugins` 포함) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 비활성화 처리 중 예외 발생 (`module.deactivate_failed`) |
@@ -560,7 +560,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 설치 파이프라인이 던진 검증 오류 (의존 확장 cascade 설치 실패·이미 설치됨 등 — `error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`modules.installation_failed`) |
@@ -671,7 +671,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 ZIP 처리 오류 (module.json 미존재/형식 오류·식별자 누락·이미 설치됨) |
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
@@ -779,7 +779,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 422 | Unprocessable Entity | URL 형식 검증 실패, 또는 GitHub 처리 오류 (저장소 미존재·다운로드 실패·module.json 형식 오류·이미 설치됨) |
| 500 | Internal Server Error | 설치 처리 중 예외 발생 (`module.install_failed`) |
@@ -1001,7 +1001,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 파일 검증 실패(ZIP 아님·50MB 초과), 또는 미리보기 처리 실패 (`module.preview_failed` — `error.error` 에 사유) |
@@ -1104,7 +1104,7 @@ _단건 응답: `data` 는 갱신된 모듈의 `ModuleResource` 객체 (목록
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.activate`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 레이아웃 갱신 실패 (모듈 미존재·비활성 상태 — `modules.refresh_layouts_failed`) |
| 500 | Internal Server Error | 갱신 처리 중 예외 발생 (`module.refresh_layouts_failed`) |
@@ -1155,7 +1155,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 제거 처리 중 예외 발생 (`module.uninstall_failed`) |
@@ -1242,7 +1242,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1300,7 +1300,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1374,7 +1374,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1433,7 +1433,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1550,7 +1550,7 @@ _단건 응답: `data` 객체의 필드 (`ModuleResource::toDetailArray()` + 주
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
| 500 | Internal Server Error | 조회 중 예외 발생 (`module.fetch_failed`) |
@@ -1620,7 +1620,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.read`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
| 422 | Unprocessable Entity | 수정 레이아웃 확인 실패 (`modules.check_modified_layouts_failed` — `error.errors.module_name`) |
| 500 | Internal Server Error | 확인 처리 중 예외 발생 |
@@ -1728,7 +1728,7 @@ _단건 응답: `data` 객체의 필드 (`target` + `dependencies[]` + `language
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈이 활성/_pending/_bundled 어디에도 없는 경우 (`module.not_found`) |
| 500 | Internal Server Error | 대상 확장을 찾을 수 없거나 프리뷰 빌드 중 예외 발생 (`module.fetch_failed`) |
@@ -1821,7 +1821,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.uninstall`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
| 500 | Internal Server Error | 삭제 정보 조회 중 예외 발생 (`module.uninstall_info_failed`) |
@@ -1932,7 +1932,7 @@ _단건 응답: `data` 는 업데이트된 모듈의 `ModuleResource` 객체 (
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.modules.read \| core.menus.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.modules.install`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 모듈을 찾을 수 없는 경우 (`module.not_found`) |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 업데이트 실패 (업데이트 소스 없음·다운그레이드 차단·코어 버전 비호환 — `error.errors.module_name` 에 사유) |
| 500 | Internal Server Error | 업데이트 처리 중 예외 발생 (`modules.errors.update_failed`) |
@@ -1965,11 +1965,26 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 표준 JSON 봉투가 아니라 **모듈 에셋 파일 본문** 을 그대로 반환한다 — `data` 구조가 없다._
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `파일 확장자에 따른 MIME (예: text/javascript, image/png)` | 서빙 대상의 MIME 타입 |
+| Cache-Control | `public, max-age=31536000, immutable` (프로덕션) / `no-cache` (그 외) | 환경에 따라 갈린다 |
+| ETag | `{md5(mtime+size)}` | `If-None-Match` 가 일치하면 본문 없이 `304` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/javascript
+Cache-Control: public, max-age=31536000, immutable
+ETag: "9f2c…"
+
+(function(){ /* 모듈 에셋 본문 */ })();
+```
+
+> 같은 ETag 로 재요청하면 본문 없이 `304 Not Modified` 가 반환된다.
**에러 응답**
@@ -2137,15 +2152,35 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 표준 JSON 봉투가 아니라 **활성 모듈 CSS 를 병합한 번들 본문** 을 그대로 반환한다 — `data` 구조가 없다._
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `text/css` | 서빙 대상의 MIME 타입 |
+| Cache-Control | `public, max-age=31536000, immutable` (프로덕션) / `no-cache` (그 외) | 환경에 따라 갈린다 |
+| ETag | `{md5(mtime+size)}` | `If-None-Match` 가 일치하면 본문 없이 `304` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/css
+Cache-Control: public, max-age=31536000, immutable
+ETag: "9f2c…"
+
+/* module-a */ .a{}
+/* module-b */ .b{}
+```
+
+> 같은 ETag 로 재요청하면 본문 없이 `304 Not Modified` 가 반환된다.
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 200 | OK (빈 본문) | 활성 확장이 없거나 병합할 에셋이 없는 경우 — 오류가 아니라 빈 번들이다 |
+
+> 개별 확장의 병합이 실패하면 그 확장만 건너뛰고 나머지는 그대로 병합된다(실패 격리). 건너뛴 사실은 서버 로그(warning)에 남는다.
@@ -2172,15 +2207,36 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 표준 JSON 봉투가 아니라 **활성 모듈 JS(IIFE)를 병합한 번들 본문** 을 그대로 반환한다 — `data` 구조가 없다._
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `text/javascript` | 서빙 대상의 MIME 타입 |
+| Cache-Control | `public, max-age=31536000, immutable` (프로덕션) / `no-cache` (그 외) | 환경에 따라 갈린다 |
+| ETag | `{md5(mtime+size)}` | `If-None-Match` 가 일치하면 본문 없이 `304` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/javascript
+Cache-Control: public, max-age=31536000, immutable
+ETag: "9f2c…"
+
+(function(){/* module-a */})()
+;
+(function(){/* module-b */})()
+```
+
+> 같은 ETag 로 재요청하면 본문 없이 `304 Not Modified` 가 반환된다.
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 200 | OK (빈 본문) | 활성 확장이 없거나 병합할 에셋이 없는 경우 — 오류가 아니라 빈 번들이다 |
+
+> 개별 확장의 병합이 실패하면 그 확장만 건너뛰고 나머지는 그대로 병합된다(실패 격리). 건너뛴 사실은 서버 로그(warning)에 남는다.
@@ -2211,7 +2267,13 @@ Accept: application/json
-
+_`data` 는 모듈 의 `components.json` 내용을 그대로 담은 **컴포넌트 맵**이다 (고정 필드 집합이 아니라 컴포넌트명 → 정의 매핑)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| (컴포넌트명) | object | `{"type":"composite","props":{…}}` | 컴포넌트 정의. 키는 레이아웃 JSON 의 `name` 과 일치한다 |
+
+> 파일이 없거나 비어 있으면 `data` 는 빈 객체(`{}`)다 — 오류가 아니다.
**응답 예시**
@@ -2281,7 +2343,24 @@ _이 엔드포인트는 표준 `success/message/data` 봉투를 사용하지 않
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "설정을 조회했습니다.",
+ "data": {
+ "ProductCard": {
+ "type": "composite",
+ "props": {
+ "product": "object"
+ }
+ }
+ }
+}
+```
**에러 응답**
diff --git a/docs/backend/api/notification-definitions.md b/docs/backend/api/notification-definitions.md
index 9e8d89af..60bc034f 100644
--- a/docs/backend/api/notification-definitions.md
+++ b/docs/backend/api/notification-definitions.md
@@ -464,7 +464,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -663,7 +663,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -797,7 +797,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/notification-logs.md b/docs/backend/api/notification-logs.md
index 6075d782..2caf906e 100644
--- a/docs/backend/api/notification-logs.md
+++ b/docs/backend/api/notification-logs.md
@@ -235,7 +235,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -287,7 +287,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/notification-templates.md b/docs/backend/api/notification-templates.md
index 2a15be54..3b788ff9 100644
--- a/docs/backend/api/notification-templates.md
+++ b/docs/backend/api/notification-templates.md
@@ -200,7 +200,7 @@ _단건 응답: `data` 객체의 필드 (`NotificationTemplateResource`)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -302,7 +302,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -404,7 +404,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/notifications.md b/docs/backend/api/notifications.md
index 6326e24a..68252e76 100644
--- a/docs/backend/api/notifications.md
+++ b/docs/backend/api/notifications.md
@@ -202,7 +202,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -260,7 +260,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -329,7 +329,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -441,7 +441,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -526,7 +526,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -664,7 +664,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -722,7 +722,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -780,7 +780,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -849,7 +849,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -907,7 +907,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -961,7 +961,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1046,7 +1046,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md
index 6cc66aad..a84769ba 100644
--- a/docs/backend/api/plugins.md
+++ b/docs/backend/api/plugins.md
@@ -1581,6 +1581,8 @@ HTTP/1.1 200
**설명** 플러그인의 현재 설정 값을 조회합니다. `core.plugins.read` 권한이 필요합니다. 저장된 설정이 없거나 플러그인을 찾을 수 없으면 404 를 반환합니다. 설정 페이지 진입 시 폼의 현재 값을 채우는 용도이며, 폼 스키마/UI 구성은 별도의 `settings/layout` 엔드포인트에서 조회합니다.
+설정 스키마(getSettingsSchema)에 `public_asset_disk` 키를 선언한 플러그인(예: `sirsoft-ckeditor5`)은 응답 `data` 에 공개 자산 디스크 카탈로그 `available_public_asset_disks` 가 함께 부착됩니다 — 각 항목은 `{id, label(로케일별 맵), provider?}` 구조입니다. 설정 화면이 코어 환경설정 API(`core.settings.read`)를 따로 조회하지 않고 이 응답 하나로 선택지를 구성하게 하기 위한 것으로, 이 키는 저장 대상이 아니며 PUT 시 검증 whitelist 에서 걸러집니다.
+
### PUT /api/admin/plugins/{identifier}/settings
@@ -1641,7 +1643,7 @@ _단건 응답: `data` 는 저장 후 다시 조회한 설정 값 맵입니다.
-**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다.
+**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다. 설정 스키마에 `public_asset_disk` 키를 선언한 플러그인은 저장 응답에도 `available_public_asset_disks` 카탈로그가 재부착됩니다 (GET 응답과 동형 — 저장 직후 화면 폼 상태 갱신용).
### GET /api/admin/plugins/{identifier}/settings/layout
@@ -2226,11 +2228,30 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — `?file=` 쿼리로 받은 경로의 에셋
+파일 원본 바이트를 그대로 서빙합니다 (성공 시 200 또는 304). 파일 경로 미전달
+(`file` 쿼리 부재)은 422 로 응답합니다._
+
+| 응답 헤더 | 예시값 | 용도/설명 |
+| --- | --- | --- |
+| Content-Type | `text/javascript` | 파일 확장자에서 감지한 MIME 타입 |
+| ETag | `"a1b2c3d4…"` | 파일 내용 기반 검증자 (재요청 시 304 판정) |
+| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 no-cache) |
**응답 예시**
-
+```http
+GET /api/plugins/assets/sirsoft-daum_postcode?file=dist%2Fjs%2Fplugin.iife.js HTTP/1.1
+```
+
+```http
+HTTP/1.1 200
+Content-Type: text/javascript
+Cache-Control: public, max-age=31536000
+ETag: "a1b2c3d4e5f6"
+
+(function(){/* 플러그인 IIFE 번들 원본 바이트 */})();
+```
**에러 응답**
@@ -2241,7 +2262,7 @@ Accept: application/json
-**설명**
+**설명** 플러그인 에셋 서빙(`GET /api/plugins/assets/{identifier}/{path}`)의 확장자 없는 이중 모드 변형입니다. 파일 경로를 경로 세그먼트 대신 `?file=` 쿼리로 받으며, 검증·에러·캐시 동작은 경로 세그먼트 형태와 동일합니다. `.js`·`.css` 주소를 가로채는 정적 파일 최적화 서버 설정에서 확장자 붙은 주소가 404 가 될 때 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/assets/{identifier}/{path}
@@ -2425,19 +2446,36 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 CSS 를 병합한
+텍스트를 그대로 서빙합니다 (성공 시 200 또는 304). `GET /api/plugins/bundle.css`
+와 동일 응답입니다._
+
+| 응답 헤더 | 예시값 | 용도/설명 |
+| --- | --- | --- |
+| Content-Type | `text/css` | 항상 CSS |
+| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) |
+| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) |
+
+활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/css`) 입니다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/css
+Cache-Control: public, max-age=31536000
+
+/* sirsoft-gdpr */
+.g7-gdpr-banner{position:fixed;bottom:0}
+```
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
-**설명**
+**설명** 플러그인 CSS 번들(`GET /api/plugins/bundle.css`)의 확장자 없는 이중 모드 변형입니다. 동작·응답이 확장자 형태와 동일하며, `.css` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/bundle/js
@@ -2460,19 +2498,37 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 IIFE JS 를 병합한
+텍스트를 그대로 서빙합니다 (성공 시 200 또는 304). `GET /api/plugins/bundle.js`
+와 동일 응답입니다._
+
+| 응답 헤더 | 예시값 | 용도/설명 |
+| --- | --- | --- |
+| Content-Type | `text/javascript` | 항상 JavaScript |
+| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) |
+| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) |
+
+활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/javascript`) 입니다. 병합 시 각 IIFE 사이는 `\n;\n` 구분자로 연결됩니다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/javascript
+Cache-Control: public, max-age=31536000
+
+(function(){/* sirsoft-gdpr plugin.iife.js */})();
+;
+(function(){/* sirsoft-ckeditor5 plugin.iife.js */})();
+```
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
-**설명**
+**설명** 플러그인 JS 번들(`GET /api/plugins/bundle.js`)의 확장자 없는 이중 모드 변형입니다. 동작·응답이 확장자 형태와 동일하며, `.js` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/{identifier}/components
@@ -2497,9 +2553,19 @@ Accept: application/json
**응답 필드** (`data` 내부)
+_이 엔드포인트는 `success/message/data` 봉투를 사용하지 않습니다 — 플러그인의
+`components.json` 원문을 그대로 JSON body 로 반환합니다 (`Cache-Control: public,
+max-age=3600`). 파일이 없는 구버전 플러그인은 빈 객체(`{}`) 로 폴백합니다._
-
-
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| $schema | string | `https://json-schema.org/draft/2020-12/schema` | 매니페스트 JSON 스키마 참조 |
+| identifier | string | `sirsoft-daum_postcode` | 이 매니페스트를 소유한 플러그인 식별자 |
+| version | string | `1.0.0` | 매니페스트(플러그인) 버전 |
+| components | object | `{"basic":[],"composite":[],"layout":[]}` | 타입별 컴포넌트 정의 맵 |
+| components.basic | array | `[]` | Basic 컴포넌트 정의 목록 (HTML 래핑 계층) |
+| components.composite | array | `[]` | Composite 컴포넌트 정의 목록 (집합 컴포넌트) |
+| components.layout | array | `[]` | Layout 컴포넌트 정의 목록 |
**응답 예시**
@@ -2528,7 +2594,7 @@ HTTP/1.1 200
-**설명**
+**설명** 플러그인 컴포넌트 매니페스트(`GET /api/plugins/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/{identifier}/components.json
diff --git a/docs/backend/api/profile.md b/docs/backend/api/profile.md
index c66ccf19..7c77b125 100644
--- a/docs/backend/api/profile.md
+++ b/docs/backend/api/profile.md
@@ -279,7 +279,7 @@ _단건 응답: `data` 객체의 필드 (`UserResource::toArray()` 산물 — `s
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
-| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.profile.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/roles.md b/docs/backend/api/roles.md
index bf1eda1e..8c2bf050 100644
--- a/docs/backend/api/roles.md
+++ b/docs/backend/api/roles.md
@@ -273,7 +273,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.permissions.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -414,7 +414,7 @@ HTTP/1.1 200
**설명**
-셀렉트 UI(사용자 폼·메뉴 편집의 역할 선택 등)에 채울 활성 역할 목록을 제공한다. 별도 권한 미들웨어가 없어 인증만 되면 호출 가능하지만, 내부에서 권한에 따라 범위가 갈린다. `core.permissions.read` 권한 보유자는 전체 활성 역할을 받고(사용자에게 역할을 부여하는 관리 용도), 미보유자는 자신에게 부여된 활성 역할만 받는다(자기 정보 폼 표시 용도). 응답의 `abilities.can_assign_roles` 는 `core.permissions.update` 권한 보유 여부를 나타낸다.
+셀렉트 UI(사용자 폼·메뉴 편집의 역할 선택 등)에 채울 활성 역할 목록을 제공한다. 별도 권한 미들웨어가 없어 인증만 되면 호출 가능하지만, 내부에서 권한에 따라 범위가 갈린다. `core.permissions.read` 권한 보유자는 전체 활성 역할을 받고(사용자에게 역할을 부여하는 관리 용도), 미보유자는 자신에게 부여된 활성 역할만 받는다(자기 정보 폼 표시 용도). 응답의 `abilities.can_assign_roles` 는 `core.users.update`(사용자 관리) 권한 보유 여부를 나타낸다 — 역할 부여는 사용자 관리의 일부이지 역할 정의 수정(`core.permissions.update`)이 아니다. 부여 가능한 개별 역할의 범위는 서버 상한(권한 상승 가드)이 역할별로 강제한다.
### DELETE /api/admin/roles/{role}
@@ -444,14 +444,24 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "역할이 성공적으로 삭제되었습니다.",
+ "data": null
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.permissions.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -652,7 +662,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -754,7 +764,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.permissions.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.permissions.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/schedules.md b/docs/backend/api/schedules.md
index b7c5a56f..0f866a15 100644
--- a/docs/backend/api/schedules.md
+++ b/docs/backend/api/schedules.md
@@ -286,7 +286,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -346,7 +346,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -415,7 +415,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -467,7 +467,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
| 404 | Not Found | 지정한 `historyId` 의 실행 이력이 존재하지 않는 경우 (`schedule.history_not_found`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -588,7 +588,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -842,7 +842,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -946,7 +946,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.create`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1147,7 +1147,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.schedules.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.schedules.run`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
diff --git a/docs/backend/api/search.md b/docs/backend/api/search.md
index c3666f8a..4270031f 100644
--- a/docs/backend/api/search.md
+++ b/docs/backend/api/search.md
@@ -21,7 +21,7 @@
- **라우트명**: `api.search`
- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicSearchController@search`
-- **인증/권한**: 공개 (인증 불필요)
+- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)
**요청 파라미터**
@@ -62,10 +62,16 @@ _단건 응답: `data` 객체의 필드._
| next_cursor | string\|null | `null` | 다음 페이지 커서. 커서 방식으로 응답했을 때만 채워지며, `page` 방식 응답에서는 `null` 입니다 |
| prev_cursor | string\|null | `null` | 이전 페이지 커서. 위와 같습니다 |
| counts_are_exact | object | `{}` | 카테고리별 총 건수 정확도 (`{"posts": true, "products": false}`). 탭 배지가 카테고리마다 그려지므로 정확도도 카테고리마다 제공됩니다 — 정확하지 않은 배지는 화면에서 "이상" 으로 표기됩니다 |
+| categories_failed | object | `{}` | 카테고리별 검색 실패 여부 (`{"posts": true, "products": false}`). 카테고리 검색이 서버 예외로 실패하면 그 카테고리만 `true` 가 되며, 화면은 이 값으로 "검색 결과 없음" 과 구분되는 오류 안내를 그립니다. 실패해도 HTTP 는 200 입니다 (다른 카테고리 결과는 정상 전달) |
+| search_failed | boolean | `false` | 하나 이상의 카테고리가 실패했는지 여부 (`categories_failed` 의 논리합) |
카테고리(탭) 중 하나라도 상한에 걸리면 합계도 정확하지 않습니다 — 정확한 카테고리 몇 개를
더해 봐야 전체가 정확해지지 않기 때문입니다. 그 경우 `total_is_exact` 는 `false` 가 됩니다.
+실패한 카테고리의 페이로드는 `failed: true` 와 함께 `total: 0`, `total_is_exact: false`
+(`total_relation: at_least`) 로 내려갑니다 — 실패한 0건을 "정확한 0건" 으로 말하지 않기
+위함입니다. 배지·건수 표기는 이 정확도를 그대로 따릅니다.
+
> 상한·페이지 이동 규약 상세: [pagination.md](../pagination.md)
**응답 예시**
@@ -98,6 +104,6 @@ HTTP/1.1 200
**설명**
-프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증이 필요 없으며 게스트도 사용할 수 있습니다. 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요.
+프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증 없이(게스트) 사용할 수 있고, Bearer 토큰을 보내면 회원으로 해석되어 게시판별 열람 권한이 검색 결과와 `available_boards` 필터 목록에 반영됩니다(`optional.sanctum` — 위조 토큰은 401, 만료 토큰은 게스트로 처리). 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요.
diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md
index badfd233..29075368 100644
--- a/docs/backend/api/seo.md
+++ b/docs/backend/api/seo.md
@@ -133,7 +133,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 캐시 무효화(`invalidateByLayout` / `clearAll`) 중 예외가 발생한 경우 (`messages.error_occurred`) |
@@ -206,7 +206,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
@@ -411,7 +411,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 500 | Internal Server Error | 워밍업 처리 중 예외가 발생한 경우 (`messages.error_occurred`) |
diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md
index f8c1695e..88cec79c 100644
--- a/docs/backend/api/settings.md
+++ b/docs/backend/api/settings.md
@@ -71,12 +71,14 @@ _단건 응답: `data` 객체의 필드._
| geoip | object | `{"geoip_enabled":false,"geoip_license_key":null,"geoip_au…` | GeoIP 원본 카테고리 (GeoIP 사용 여부·MaxMind 라이선스 키·DB 자동 갱신 사용). advanced 탭에 병합된 파생 뷰 |
| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 |
| identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) |
-| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공 |
-| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | |
+| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/public_asset/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공. `public_asset` 은 공개 자산 직접 URL 서빙 디스크 선택지 (코어 none/public/s3 + 플러그인 훅 등록분) |
+| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 설정값이 아니라 화면이 쓰는 메타. `limits` 는 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -120,19 +122,19 @@ HTTP/1.1 200
"webp",
"... (총 11건 중 5건 표시)"
],
- "image_max_width": "800",
- "image_max_height": "600",
+ "image_max_width": 2000,
+ "image_max_height": 2000,
"image_quality": 85
},
"seo": {
- "meta_title_suffix": null,
- "meta_description": null,
- "meta_keywords": null,
- "google_analytics_id": null,
- "google_site_verification": null,
- "...": "(24개 키 생략, 총 29개)"
+ "meta_title_suffix": "",
+ "meta_description": "",
+ "meta_keywords": "",
+ "google_analytics_id": "",
+ "google_site_verification": "",
+ "...": "(23개 키 생략, 총 28개)"
},
- "...": "(11개 키 생략, 총 16개)"
+ "...": "(12개 키 생략, 총 17개)"
}
}
```
@@ -200,6 +202,8 @@ HTTP/1.1 200
| upload.image_max_width | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 너비 (px) |
| upload.image_max_height | body | integer | 아니오 | min 100, max 10000 | 이미지 리사이즈 최대 높이 (px) |
| upload.image_quality | body | integer | 아니오 | min 1, max 100 | 이미지 리사이즈 시 압축 품질 (1~100) |
+| upload.orphan_cleanup_enabled | body | boolean | 아니오 | — | 고아 첨부(소유자 없이 남은 첨부) 자동 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작하며, 예약 작업(`attachments:prune-orphans --scheduled`)이 이 값을 false 폴백으로 재확인한다 |
+| upload.orphan_retention_days | body | integer | 아니오 | min 1, max 3650 | 고아 첨부 보존기간(일, 기본 30). 폼 작성 중 이탈을 감안한 유예 기간이다. 하한·상한은 `config('core.settings_limits.upload_orphan_retention_days_*')` 가 정하며 화면 입력도 같은 값을 읽는다 |
| seo.meta_title_suffix | body | string | 아니오 | max 100 | 모든 페이지 SEO 제목 뒤에 붙는 접미 문구 |
| seo.meta_description | body | string | 아니오 | max 160 | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약) |
| seo.meta_keywords | body | string | 아니오 | max 255 | SEO 메타 키워드 (검색엔진 노출 키워드, 쉼표 구분) |
@@ -242,11 +246,14 @@ HTTP/1.1 200
| advanced.geoip_license_key | body | string | 아니오 | max 200 | MaxMind GeoLite2 라이선스 키 |
| advanced.geoip_auto_update_enabled | body | boolean | 아니오 | — | GeoIP DB 자동 업데이트 사용 여부 (주 1회 자동 재다운로드) |
| drivers.storage_driver | body | string | 아니오 | — | 스토리지 드라이버 (local/s3) |
+| drivers.public_asset_disk | body | string | 아니오 | max 100 | 공개 자산 직접 URL 서빙 디스크 (none/public/s3 + 플러그인 훅 등록 디스크). 카탈로그(`available_drivers.public_asset`)에 없는 값은 422 |
| drivers.s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 |
-| drivers.s3_region | body | string | 아니오 | — | S3 리전 |
+| drivers.s3_region | body | string | 아니오 | max 64, 소문자 영숫자·하이픈 (`^[a-z0-9-]+$`) | S3 리전 — AWS 리전 코드 또는 S3 호환 스토리지 값 (Cloudflare R2 는 `auto`, MinIO 관례는 `us-east-1`) |
| drivers.s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 |
| drivers.s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 |
-| drivers.s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL |
+| drivers.s3_url | body | string | 아니오 | url, max 500 | S3 공개 URL(CDN) base — 파일 URL 생성에만 사용 (API 요청 주소 아님) |
+| drivers.s3_endpoint | body | string | 아니오 | url, max 500 | S3 API 엔드포인트 — S3 호환 스토리지(R2/MinIO/NCP 등)용. AWS S3 는 미입력 (예: `https://.r2.cloudflarestorage.com`) |
+| drivers.s3_use_path_style | body | boolean | 아니오 | — | S3 path-style 주소 사용 여부 — MinIO 등 path-style 전용 스토리지에서 true |
| drivers.cache_driver | body | string | 아니오 | — | 캐시 드라이버 (file/redis/memcached) |
| drivers.redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 |
| drivers.redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 |
@@ -328,6 +335,7 @@ Content-Type: application/json
"general.language": "예시값",
"general.currency": "예시값",
"general.maintenance_mode": true,
+ "general.asset_url_mode": "https://example.com",
"general.site_logo": [
"예시값"
],
@@ -370,6 +378,11 @@ Content-Type: application/json
"seo.cache_ttl": 1,
"seo.sitemap_enabled": true,
"seo.sitemap_cache_ttl": 1,
+ "seo.sitemap_urls_per_file": 1,
+ "seo.sitemap_gzip": true,
+ "seo.sitemap_serve_stale_on_miss": true,
+ "seo.sitemap_max_urls_per_contributor": 1,
+ "seo.sitemap_hreflang_enabled": true,
"seo.sitemap_schedule": "예시값",
"seo.sitemap_schedule_time": "예시값",
"seo.generator_enabled": true,
@@ -379,6 +392,10 @@ Content-Type: application/json
"security.auth_token_lifetime": 1,
"security.max_login_attempts": 1,
"security.login_lockout_time": 1,
+ "security.password_min_length": 1,
+ "security.require_password_special_char": true,
+ "security.two_factor_auth": true,
+ "security.allow_internal_outbound_urls": true,
"advanced.cache_enabled": true,
"advanced.layout_cache_enabled": true,
"advanced.layout_cache_ttl": 1,
@@ -386,6 +403,7 @@ Content-Type: application/json
"advanced.stats_cache_ttl": 1,
"advanced.seo_cache_enabled": true,
"advanced.seo_cache_ttl": 1,
+ "advanced.seo_sitemap_cache_ttl": 1,
"advanced.debug_mode": true,
"advanced.sql_query_log": true,
"advanced.core_update_github_url": "https://example.com",
@@ -393,12 +411,16 @@ Content-Type: application/json
"advanced.geoip_enabled": true,
"advanced.geoip_license_key": "예시값",
"advanced.geoip_auto_update_enabled": true,
+ "advanced.pagination_result_cap": 1,
+ "advanced.pagination_max_page": 1,
"drivers.storage_driver": "예시값",
"drivers.s3_bucket": "예시값",
"drivers.s3_region": "예시값",
"drivers.s3_access_key": "예시값",
"drivers.s3_secret_key": "예시값",
"drivers.s3_url": "https://example.com",
+ "drivers.s3_endpoint": "예시값",
+ "drivers.s3_use_path_style": true,
"drivers.cache_driver": "예시값",
"drivers.redis_host": "예시값",
"drivers.redis_port": 1,
@@ -490,7 +512,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -529,6 +551,8 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -601,7 +625,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 500 | Internal Server Error | 백업 파일 생성에 실패한 경우 (`settings.backup_failed`) |
@@ -632,14 +656,14 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('settings.backup_success')` 를 데이터 없이 호출하므로 `data` 는 `null` 입니다._
+_이 엔드포인트는 성공 응답을 반환하지 않습니다. 데이터베이스 백업 기능이 아직 제공되지 않아 항상 501 로 응답합니다._
**응답 예시**
```json
{
- "success": true,
- "message": "데이터베이스 백업이 성공적으로 시작되었습니다.",
+ "success": false,
+ "message": "데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.",
"data": null
}
```
@@ -648,16 +672,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
-| 400 | Bad Request | 백업이 수행되지 않은 경우 (`settings.backup_failed`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
-| 500 | Internal Server Error | 백업 처리 중 예외가 발생한 경우 (`settings.backup_error`) |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
+| 501 | Not Implemented | 항상 (데이터베이스 백업 미제공, `settings.database_backup_unavailable`) |
**설명**
-데이터베이스를 백업합니다. SettingsService 에 위임하며, 성공/실패를 메시지로 반환합니다. 설정 백업(`POST /backup`)이 설정 파일만 다루는 것과 달리, 이 엔드포인트는 DB 데이터를 백업 대상으로 합니다.
+**데이터베이스 백업은 아직 제공하지 않습니다.** 이 엔드포인트는 라우트만 존재하며 호출하면 항상 501(Not Implemented)로 응답합니다. 코어에는 데이터베이스 덤프 수단이 없어 구현된 적이 없으며, 관리자 화면에도 이 기능을 호출하는 지점이 없습니다.
+
+설정 파일 백업이 필요하면 `POST /api/admin/settings/backup` 을 사용하십시오 — 그쪽은 정상 동작하며 생성된 백업 경로를 `data.backup_path` 로 돌려줍니다.
### POST /api/admin/settings/clear-cache
@@ -699,7 +724,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| --- | --- | --- |
| 400 | Bad Request | 캐시 정리가 수행되지 않은 경우 (`settings.cache_clear_failed`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 500 | Internal Server Error | 캐시 정리 중 예외가 발생한 경우 (`settings.cache_clear_error`) |
@@ -760,7 +785,7 @@ _단건 응답: `data` 객체의 필드 (GeoIpDatabaseService::updateDatabase()
| --- | --- | --- |
| 400 | Bad Request | MaxMind 라이선스 키가 설정되지 않은 경우 (`missing_license_key`) |
| 401 | Unauthorized | MaxMind 라이선스 키가 유효하지 않은 경우 (`unauthorized`) |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 500 | Internal Server Error | MaxMind 연결 실패(`connection_failed`) 또는 다운로드·압축 해제 실패 |
@@ -795,6 +820,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -812,7 +839,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
@@ -874,7 +901,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthorized | 본문 `password` 가 요청자 본인의 비밀번호와 일치하지 않는 경우 (`settings.invalid_password`) |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없거나, FormRequest 가 `super_admin` 역할이 아닌 사용자를 거부한 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없거나, FormRequest 가 `super_admin` 역할이 아닌 사용자를 거부한 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | `.env` 기록/config 캐시 재생성 실패 (`settings.app_key_regenerate_failed`) |
@@ -933,7 +960,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| --- | --- | --- |
| 400 | Bad Request | 복원이 수행되지 않은 경우 (`settings.restore_failed`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 백업 파일을 읽을 수 없는 등 복원 중 예외 발생 (`settings.restore_error`) |
@@ -983,7 +1010,7 @@ _단건 응답: `data` 객체의 필드._
| php_memory_limit | string | `512M` | PHP `memory_limit` ini 값 |
| max_execution_time | string | `36000초` | PHP `max_execution_time` ini 값 (초 단위 접미사 부착) |
| upload_max_filesize | string | `2G` | PHP `upload_max_filesize` ini 값 |
-| opcache | object | `{"loaded":true,"enabled":true}` | |
+| opcache | object | `{"loaded":true,"enabled":true}` | OPcache 상태 (`OpcacheStatus::probe()`). `loaded` 는 확장 적재 여부, `enabled` 는 `opcache.enable` 지시자 값이며 `ini_get` 이 차단·미정의인 환경에서는 **확인 불가를 뜻하는 `null`** 이 된다 (false 와 구분된다) |
| install_path | string | `C:\Users\HeuJung\htdocs\g7_2` | 애플리케이션 설치 루트 경로 (`base_path()`) |
| config_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 설정 파일 저장 경로 (`storage/app/settings`) |
| log_path | string | `C:\Users\HeuJung\htdocs\g7_2\storage\…` | 로그 파일 저장 경로 (`storage/logs`) |
@@ -995,6 +1022,8 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
+
+
```http
HTTP/1.1 200
```
@@ -1008,22 +1037,22 @@ HTTP/1.1 200
"web_server": "nginx/1.27.3",
"php_version": "8.3.26",
"mysql_version": "Mysql 8.4.3",
- "g7_version": "7.0.6",
+ "g7_version": "7.0.7",
"g7_release_year": "2026",
"laravel_version": "12.62.0",
- "environment": "local",
+ "environment": "production",
"cpu_info": "Intel(R) Core(TM) Ultra 5 225H",
"memory_usage": {
"total": "31.49 GB",
- "used": "24.31 GB",
- "free": "7.18 GB",
- "percentage": 77.2
+ "used": "28.24 GB",
+ "free": "3.25 GB",
+ "percentage": 89.69
},
"disk_usage": {
"total": "474.72 GB",
- "used": "408.15 GB",
- "free": "66.57 GB",
- "percentage": 85.98
+ "used": "375.43 GB",
+ "free": "99.28 GB",
+ "percentage": 79.09
},
"php_memory_limit": "512M",
"max_execution_time": "36000초",
@@ -1032,10 +1061,10 @@ HTTP/1.1 200
"loaded": true,
"enabled": true
},
- "install_path": "C:\\Users\\HeuJung\\htdocs\\g7_2",
- "config_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/settings",
- "log_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\logs",
- "upload_path": "C:\\Users\\HeuJung\\htdocs\\g7_2\\storage\\app/public",
+ "install_path": "C:\\Users\\HeuJung\\htdocs\\g7",
+ "config_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\app/settings",
+ "log_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\logs",
+ "upload_path": "C:\\Users\\HeuJung\\htdocs\\g7\\storage\\app/public",
"php_extensions": {
"required": {
"openssl": true,
@@ -1066,13 +1095,13 @@ HTTP/1.1 200
"write": {
"host": "localhost",
"port": 3306,
- "database": "g7_2",
- "username": "g7_2"
+ "database": "g7",
+ "username": "g7"
},
"read": []
},
"timezone": "UTC",
- "server_time": "2026-08-04 12:53:55"
+ "server_time": "2026-08-13 05:00:24"
}
}
```
@@ -1107,10 +1136,12 @@ HTTP/1.1 200
| queue_driver | body | string | 아니오 | — | 큐 드라이버 (sync/database/redis) |
| websocket_enabled | body | boolean | 아니오 | — | WebSocket 사용 여부 |
| s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 |
-| s3_region | body | string | 아니오 | — | S3 리전 |
+| s3_region | body | string | 아니오 | max 64, 소문자 영숫자·하이픈 (`^[a-z0-9-]+$`) | S3 리전 — AWS 리전 코드 또는 S3 호환 스토리지 값 (Cloudflare R2 는 `auto`) |
| s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 |
| s3_secret_key | body | string | 아니오 | max 255 | S3 시크릿 키 |
-| s3_url | body | string | 아니오 | max 500 | S3 엔드포인트 URL |
+| s3_url | body | string | 아니오 | url, max 500 | S3 공개 URL(CDN) base — 연결 테스트에는 사용되지 않음 |
+| s3_endpoint | body | string | 아니오 | url, max 500 | S3 API 엔드포인트 — 테스트 시 실제 아웃바운드 대상에 반영 (S3 호환 스토리지용) |
+| s3_use_path_style | body | boolean | 아니오 | — | S3 path-style 주소 사용 여부 (MinIO 등) |
| redis_host | body | string | 아니오 | max 255 | Redis 호스트 주소 |
| redis_port | body | integer | 아니오 | min 1, max 65535 | Redis 포트 번호 |
| redis_password | body | string | 아니오 | max 255 | Redis 비밀번호 |
@@ -1118,9 +1149,18 @@ HTTP/1.1 200
| memcached_host | body | string | 아니오 | max 255 | Memcached 호스트 주소 |
| memcached_port | body | integer | 아니오 | min 1, max 65535 | Memcached 포트 번호 |
| websocket_app_key | body | string | 아니오 | max 255 | WebSocket 앱 키 |
-| websocket_host | body | string | 아니오 | max 255 | WebSocket 호스트 주소 |
-| websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 포트 번호 |
-| websocket_scheme | body | string | 아니오 | — | WebSocket 스킴 (http/https) |
+| websocket_host | body | string | 아니오 | max 255 | WebSocket 클라이언트(브라우저 접속) 호스트 주소 |
+| websocket_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 클라이언트 포트 번호 |
+| websocket_scheme | body | string | 아니오 | — | WebSocket 클라이언트 스킴 (http/https) |
+| websocket_server_host | body | string | 아니오 | max 255 | WebSocket 서버(백엔드 발송용) 호스트 주소 — 미입력 시 클라이언트 값으로 폴백 |
+| websocket_server_port | body | integer | 아니오 | min 1, max 65535 | WebSocket 서버 포트 번호 — 미입력 시 클라이언트 값으로 폴백 |
+| websocket_server_scheme | body | string | 아니오 | — | WebSocket 서버 스킴 (http/https) — 미입력 시 클라이언트 값으로 폴백 |
+
+> WebSocket 테스트는 클라이언트/서버 양측 endpoint 를 모두 probe 합니다. 백엔드 broadcast 는 서버 endpoint 를 사용하므로, 서버 endpoint 실패 시 별도 메시지(`settings.websocket_server_test_failed`)로 구분 보고됩니다.
+
+> S3 테스트는 Flysystem 어댑터(`league/flysystem-aws-s3-v3`) 존재를 선검사하며, `s3_endpoint`/`s3_use_path_style` 이 실제 아웃바운드 대상에 반영됩니다.
+
+> 사용 불능 드라이버(어댑터 클래스·PHP 확장 부재)는 저장/테스트 모두 422 (`validation.settings.driver_unusable`) 로 거부됩니다.
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.settings.test_driver_connection_validation_rules`).
@@ -1144,6 +1184,8 @@ Content-Type: application/json
"s3_access_key": "예시값",
"s3_secret_key": "예시값",
"s3_url": "https://example.com",
+ "s3_endpoint": "예시값",
+ "s3_use_path_style": true,
"redis_host": "예시값",
"redis_port": 1,
"redis_password": "Password123!",
@@ -1153,7 +1195,10 @@ Content-Type: application/json
"websocket_app_key": "예시값",
"websocket_host": "예시값",
"websocket_port": 1,
- "websocket_scheme": "예시값"
+ "websocket_scheme": "예시값",
+ "websocket_server_host": "예시값",
+ "websocket_server_port": 1,
+ "websocket_server_scheme": "예시값"
}
```
@@ -1194,7 +1239,7 @@ _단건 응답: `data` 객체의 필드 (DriverConnectionTester::testAll() 산
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 테스트 실행 중 예외가 발생한 경우 (`settings.driver_test_error`) |
@@ -1288,7 +1333,7 @@ _단건 응답: `data` 객체의 필드 (발송 성공 시에만 반환)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 발송 실패 시 — `message` 는 `테스트 메일 발송에 실패했습니다.`, `error` 에 원본 예외 메시지(SMTP 인증 실패·연결 거부 등)가 담김 |
@@ -1407,7 +1452,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| --- | --- | --- |
| 400 | Bad Request | 저장이 수행되지 않은 경우 (`settings.update_failed`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 저장 중 예외가 발생한 경우 (`settings.update_error`) |
diff --git a/docs/backend/api/system.md b/docs/backend/api/system.md
index a139aaf0..0f1742bb 100644
--- a/docs/backend/api/system.md
+++ b/docs/backend/api/system.md
@@ -37,19 +37,50 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+이 엔드포인트는 JSON 봉투(`success`/`message`/`data`)를 쓰지 않는다. 정적 자산으로 오인될 응답을 그대로 흉내내는 것이 목적이라, 본문은 자바스크립트이고 `data` 구조가 존재하지 않는다.
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `application/javascript; charset=utf-8` | 정적 `.js` 응답과 동일한 형태 |
+| Cache-Control | `no-store, no-cache, must-revalidate, max-age=0` | 프로브는 매 요청 실측이어야 하므로 캐시 금지 |
+| Pragma | `no-cache` | 구형 프록시 대응 |
+| X-Content-Type-Options | `nosniff` | MIME 스니핑 차단 |
+| 본문 매직 토큰 | `G7_ASSET_PROBE_OK` | 성공 판정용. 상태코드가 아니라 **이 토큰의 존재**로 판정한다 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: application/javascript; charset=utf-8
+Cache-Control: no-store, no-cache, must-revalidate, max-age=0
+```
+
+```javascript
+/* G7 asset URL mode probe */
+window.__g7AssetProbe = 'G7_ASSET_PROBE_OK';
+```
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+_에러 응답이 정의되어 있지 않다. 이 라우트는 DB 에 접근하지 않으므로 설치 전에도 응답하며, 도달하기만 하면 항상 `200` 이다. 도달하지 못해 `404`/`5xx` 가 관측되면 그것은 에러가 아니라 **판정 입력**이다 (아래 설명 참조)._
-**설명**
+**설명**
+
+서버(nginx/Apache)의 정적 최적화 블록이 확장자 붙은 동적 응답을 가로채는지 판정하기 위한 **대조군** 엔드포인트다. 확장자가 없으므로 정적 블록의 표적이 되지 않는다. 클라이언트는 이 URL 과 `/api/system/asset-probe.js` 를 쌍으로 요청해 다음과 같이 판정한다.
+
+| `asset-probe.js` | `asset-probe` (본 엔드포인트) | 판정 |
+| --- | --- | --- |
+| 성공 | 성공 | `extension` — 확장자 붙은 URL 을 그대로 써도 된다 |
+| 실패 | 성공 | `extensionless` — 정적 블록 가로채기 확정. 확장자 없는 형태로 전환한다 |
+| 실패 | 실패 | 자산 URL 모드 문제가 아니다 (PHP/라우팅 장애) — 별도 안내 |
+
+주의사항:
+
+- **판정은 상태코드가 아니라 본문으로 한다.** `res.ok && body.includes('G7_ASSET_PROBE_OK')` 로 확인해야 한다. 상태코드만 보면 "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서 영원히 `extension` 으로 오판하고, 재감지를 몇 번 눌러도 같은 오답이 나온다.
+- **감지는 반드시 브라우저에서 수행한다.** 서버측에서 자기 `APP_URL` 로 curl 하면 loopback 이 nginx vhost·SSL·프록시 체인을 우회하거나 다른 vhost 를 타서 오판한다.
+- **`public/` 하위에 실물 `asset-probe.js` 를 두지 않는다.** 실제 파일이 있으면 nginx 가 그것을 성공적으로 서빙해 거짓 양성이 된다.
### GET /api/system/asset-probe.js
@@ -72,17 +103,49 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+확장자 없는 대조군(`GET /api/system/asset-probe`)과 **같은 컨트롤러 메서드**이므로 응답 형태가 동일하다. JSON 봉투를 쓰지 않으며 `data` 구조가 없다.
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `application/javascript; charset=utf-8` | 정적 `.js` 응답과 동일한 형태 |
+| Cache-Control | `no-store, no-cache, must-revalidate, max-age=0` | 프로브는 매 요청 실측이어야 하므로 캐시 금지 |
+| Pragma | `no-cache` | 구형 프록시 대응 |
+| X-Content-Type-Options | `nosniff` | MIME 스니핑 차단 |
+| 본문 매직 토큰 | `G7_ASSET_PROBE_OK` | 성공 판정용. 상태코드가 아니라 **이 토큰의 존재**로 판정한다 |
**응답 예시**
-
+애플리케이션까지 도달했을 때 (`extension` 모드 가능):
+
+```http
+HTTP/1.1 200
+Content-Type: application/javascript; charset=utf-8
+Cache-Control: no-store, no-cache, must-revalidate, max-age=0
+```
+
+```javascript
+/* G7 asset URL mode probe */
+window.__g7AssetProbe = 'G7_ASSET_PROBE_OK';
+```
+
+서버의 정적 최적화 블록이 가로챘을 때 (`extensionless` 모드 확정) — 응답은 서버 설정에 좌우되며 매직 토큰이 없다:
+
+```http
+HTTP/1.1 404
+Content-Type: text/html
+```
+
+> 위 문서의 실측이 `404` 로 관측된 것이 이 경우다. 정규식 location(`location ~* \.(js|css|json)$`)이 프리픽스 location 보다 먼저 매칭되어, 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다.
**에러 응답**
-_대표 에러 없음 (공개 조회). _
+_에러 응답이 정의되어 있지 않다. 이 라우트는 DB 에 접근하지 않으므로 애플리케이션에 도달하면 항상 `200` 이다. `404`/`5xx` 는 에러가 아니라 **판정 입력**이며, 대조군이 성공했다면 `extensionless` 모드로 확정한다._
-**설명**
+**설명**
+
+자산 URL 모드 감지의 **표적** 엔드포인트다. 확장자(`.js`)로 끝나므로 서버의 정적 최적화 블록이 가로채는지 여부가 그대로 드러난다. 판정표와 주의사항은 대조군 `GET /api/system/asset-probe` 항목을 참조한다.
+
+이 프로브가 실패하고 대조군이 성공하면, 코드는 확장자 없는 URL 형태를 써야 한다. 서버측 URL 조립은 `App\Support\AssetUrl`, 프론트측은 `resources/js/core/support/assetUrl.ts` 가 같은 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 라우트 등록은 단일 `Route::get()` 이 아니라 `Route::dualSuffix()` / `dualSuffixSegment()` / `dualAsset()` 로 확장자 형태와 확장자 없는 형태를 동시에 등록한다.
diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md
index a2fec5f8..2f541657 100644
--- a/docs/backend/api/templates.md
+++ b/docs/backend/api/templates.md
@@ -293,7 +293,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
| 409 | Conflict | 필요한 의존 모듈/플러그인이 충족되지 않은 경우 (`errors` 에 `warning`, `missing_modules`, `missing_plugins`, `message`) — `force=true` 로 우회 가능 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 활성화 처리 실패 (이미 활성 상태·미설치·코어 버전 비호환 등) |
@@ -360,7 +360,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 업데이트 확인 처리 실패 (GitHub API 호출 실패 등) |
@@ -470,7 +470,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 비활성화 처리 실패 (템플릿 미존재 등) |
@@ -590,7 +590,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource + cascade 결과)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 설치 실패 (이미 설치됨·manifest 오류·cascade 의존 확장 설치 실패 등 — `errors` 에 번역된 사유) |
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
@@ -703,7 +703,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 처리 실패 (template.json 누락/무효, 이미 설치된 식별자, 잘못된 디렉토리명 등) |
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
@@ -813,7 +813,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 목록 응답 항
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 422 | Unprocessable Entity | URL 검증 위반 또는 설치 실패 (유효하지 않은 GitHub URL, 저장소 없음, 다운로드 실패, 이미 설치된 식별자 등) |
| 500 | Server Error | 설치 처리 중 예기치 못한 오류 |
@@ -862,7 +862,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 첨부 파일 삭제 실패 (스토리지/DB 삭제 실패 — `첨부 파일 삭제에 실패했습니다.`) |
@@ -944,7 +944,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 422 | Unprocessable Entity | 파일 검증 위반 또는 ZIP 열기 실패 (`manifest 미리보기에 실패했습니다.` + `errors.error`) |
@@ -1053,7 +1053,7 @@ _단건 응답: `data` 객체의 필드 (TemplateResource — 갱신 후 템플
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.activate`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 레이아웃 갱신 실패 (레이아웃 JSON 무효, layout_name 누락 등) |
| 500 | Server Error | 레이아웃 갱신 처리 중 예기치 못한 오류 |
@@ -1105,7 +1105,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 제거 실패 (활성 상태·파일 삭제 실패 등 — `errors.identifier` 에 번역된 사유) |
| 500 | Server Error | 제거 처리 중 예기치 못한 오류 |
@@ -1319,7 +1319,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1426,18 +1426,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 객체의 필드 (`BroadcastCatalogService::collect` + 요청 식별자)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
+| channels | array | `[{"name":"core.notifications","source":{"kind":"core"}}]` | 구독 가능한 브로드캐스트 채널 목록. `source.kind` 는 `core`/`module`/`plugin` 이며 확장 채널에는 `source.identifier` 가 붙는다 (활성 확장만 수집) |
+| events | array | `[]` | 정적 이벤트 카탈로그. 이벤트는 동적 발행이라 **항상 빈 배열**이며, 편집기는 자유 텍스트 입력으로 폴백한다 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "요청이 성공했습니다.",
+ "data": {
+ "identifier": "sirsoft-admin_basic",
+ "channels": [
+ { "name": "core.notifications", "source": { "kind": "core" } },
+ { "name": "module.sirsoft-board.posts", "source": { "kind": "module", "identifier": "sirsoft-board" } }
+ ],
+ "events": []
+ }
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1517,18 +1540,31 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 표준 JSON 봉투가 아니라 **편집기 미리보기용 컴포넌트 CSS 본문** 을 그대로 반환한다 — `data` 구조가 없다._
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `text/css; charset=UTF-8` | 서빙 대상의 MIME 타입 |
+| Cache-Control | `public, max-age=31536000, immutable` (프로덕션) / `no-cache` (그 외) | 환경에 따라 갈린다 |
+| ETag | `{md5(mtime+size)}` | `If-None-Match` 가 일치하면 본문 없이 `304` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/css; charset=UTF-8
+
+.g7-card{border-radius:.5rem}
+```
+
+> CSS 가 없는 템플릿도 **빈 본문 200** 으로 응답한다 — 편집기 부팅이 실패하지 않게 하기 위한 폴백이며 404 가 아니다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1626,18 +1662,36 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 는 템플릿의 `components.json` 내용을 그대로 담은 **컴포넌트 맵**이다 (고정 필드 집합이 아니라 컴포넌트명 → 정의 매핑)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| (컴포넌트명) | object | `{"type":"basic","tag":"div"}` | 컴포넌트 정의. 키는 레이아웃 JSON 의 `name` 과 일치한다 |
+
+> 활성/`_bundled` 어디에도 `components.json` 이 없으면 `404`(`templates.layout_not_found`)다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "설정을 조회했습니다.",
+ "data": {
+ "Card": { "type": "composite", "props": { "title": "string" } }
+ }
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1736,11 +1790,32 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 객체의 필드 (`EditorSpecAssembler::assemble`)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
+| spec | object \| null | `{"palette":[…],"styleControls":{…}}` | 합본된 편집기 스펙. 분할 매니페스트(`editor-spec/` + `$include`)는 **활성 디렉토리 기준**으로 합쳐지며(`_bundled` 폴백 없음), 미분할 원본 파일은 그대로 반환된다. 스펙이 없으면 `null` (404 아님) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "편집기 스펙을 조회했습니다.",
+ "data": {
+ "identifier": "sirsoft-admin_basic",
+ "spec": {
+ "palette": [{ "name": "Card", "label": "카드" }],
+ "styleControls": {}
+ }
+ }
+}
+```
**에러 응답**
@@ -93553,7 +93628,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -93612,7 +93687,7 @@ _단건 응답: `data` 는 템플릿 `lang/{locale}.json` 의 내용을 그대
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -93726,18 +93801,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 객체의 필드._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
+| permissions | array | `[{"identifier":"core.users.read","name":"사용자 조회"}]` | 레이아웃 조건식에 쓸 수 있는 권한 후보 목록 (현재 로케일로 해석된 표시명 포함) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "설정을 조회했습니다.",
+ "data": {
+ "identifier": "sirsoft-admin_basic",
+ "permissions": [
+ { "identifier": "core.users.read", "name": "사용자 조회" }
+ ]
+ }
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -93929,18 +94024,45 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 는 템플릿 `routes.json` 에 모듈·플러그인 라우트를 병합하고 각 라우트에 출처를 태깅한 결과다._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| version | string | `1.0.0` | routes.json 스키마 버전 |
+| routes | array | `[{"path":"/","layout":"home","source":{"kind":"template","identifier":"sirsoft-basic"}}]` | 라우트 목록. **`source` 태깅이 필수**다 — 편집기 라우트 트리가 `source.kind` 로 그룹핑하므로 태깅이 없으면 클라이언트가 라우트 트리 렌더에서 실패한다 |
+
+> 공개 라우트 엔드포인트와 달리 **비활성 템플릿도 조회 가능**하고 `_bundled` 폴백이 적용된다 (편집 대상이 활성일 필요가 없다).
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "라우트를 조회했습니다.",
+ "data": {
+ "version": "1.0.0",
+ "routes": [
+ {
+ "path": "/",
+ "layout": "home",
+ "auth_required": false,
+ "source": { "kind": "template", "identifier": "sirsoft-basic" }
+ }
+ ]
+ }
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -94025,7 +94147,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -94045,8 +94167,8 @@ _단건 응답: `data` 객체의 필드._
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
-| extensions | query | string | 아니오 | — | |
-| page_type | query | string | 아니오 | — | |
+| extensions | query | string | 아니오 | JSON 배열 문자열 | 후보를 수집할 확장 선언. `[{"type":"module","id":"sirsoft-board"}]` 형태의 JSON 문자열이며, `type`·`id` 가 모두 문자열인 항목만 채택된다(그 외는 조용히 무시). 편집 중인 레이아웃이 아직 활성화하지 않은 확장의 후보까지 보려 할 때 쓴다 |
+| page_type | query | string | 아니오 | — | 치환 변수(`vars`) 후보를 좁힐 페이지 유형. 빈 문자열은 미지정과 같게 처리된다 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.seo_candidate.index_validation_rules`).
@@ -94307,18 +94429,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_`data` 객체의 필드 (`SeoCandidateService::collect` + 요청 식별자)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `sirsoft-admin_basic` | 요청한 템플릿 식별자 (요청값 반향) |
+| page_types | array | `[{"id":"board_list","label":"게시판 목록"}]` | 선택 가능한 페이지 유형 후보 (활성 확장 + 요청이 선언한 확장 기준) |
+| toggle_settings | array | `[{"key":"use_og","label":"오픈그래프 사용"}]` | SEO 토글 설정 후보 (현재 로케일로 해석) |
+| vars | array | `[{"name":"post.title","label":"게시글 제목"}]` | 메타 템플릿에 넣을 수 있는 치환 변수 후보 (`page_type` 을 주면 그 유형으로 좁혀진다) |
+| extensions | array | `[{"type":"module","id":"sirsoft-board","name":"게시판"}]` | 후보를 제공한 활성 확장 목록 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "요청이 성공했습니다.",
+ "data": {
+ "identifier": "sirsoft-admin_basic",
+ "page_types": [{ "id": "board_list", "label": "게시판 목록" }],
+ "toggle_settings": [{ "key": "use_og", "label": "오픈그래프 사용" }],
+ "vars": [{ "name": "post.title", "label": "게시글 제목" }],
+ "extensions": [{ "type": "module", "id": "sirsoft-board", "name": "게시판" }]
+ }
+}
+```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -94461,7 +94607,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -94531,7 +94677,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -94612,7 +94758,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 (`템플릿을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 파일 검증 위반 (이미지 아님, 지원하지 않는 형식 jpg/jpeg/png/gif/webp/svg 외, 크기 초과 등) |
| 500 | Server Error | 스토리지 저장 실패 (`첨부 파일 업로드에 실패했습니다.`) |
@@ -94924,7 +95070,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락 또는 빈 배열 등) |
| 500 | Server Error | 삭제 트랜잭션 실패 |
@@ -95005,7 +95151,7 @@ _목록 응답: `data` 는 커스텀 다국어 키 배열입니다 (페이지네
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -95090,7 +95236,7 @@ _단건 응답: `data` 객체의 필드 (생성된 커스텀 다국어 키, HTTP
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 키 생성 트랜잭션 실패 |
@@ -95141,7 +95287,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (`data`: `null`,
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 (교차 템플릿 삭제 차단) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 삭제 트랜잭션 실패 |
@@ -95229,7 +95375,7 @@ _단건 응답: `data` 객체의 필드 (수정된 커스텀 다국어 키)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 없거나, 해당 키가 없거나, 그 키가 경로의 템플릿 소속이 아닌 경우 |
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -95336,7 +95482,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 대상 템플릿을 찾을 수 없거나 프리뷰 구성 실패 |
@@ -95667,7 +95813,7 @@ _단건 응답: `data` 객체의 필드 (수정된 LayoutExtensionResource)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 템플릿/확장이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -95899,7 +96045,7 @@ _단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 템플릿/확장/버전이 없거나, 확장이 경로의 템플릿 소속이 아닌 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 복원 트랜잭션 실패 |
@@ -96441,7 +96587,7 @@ _단건 응답: `data` 객체의 필드 (수정된 LayoutResource)._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿이 존재하지 않는 경우 |
| 409 | Conflict | 낙관적 잠금 충돌 — 다른 사용자가 먼저 수정 (`errors`: `error=concurrent_modification`, `current_version`, `your_version`, `resource`) |
| 422 | Unprocessable Entity | content 구조 검증 위반 (레이아웃 구조/슬롯/데이터소스 병합/엔드포인트 화이트리스트/외부 URL 차단/권한 구조 규칙) |
@@ -96678,7 +96824,7 @@ _단건 응답: `data` 객체의 필드 (복원 결과로 새로 기록된 버
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.layouts.edit`)이 없는 경우 |
| 404 | Not Found | 대상 템플릿·레이아웃·버전이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 복원 트랜잭션 실패 |
@@ -96828,7 +96974,7 @@ _단건 응답: `data` 객체의 필드._
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.uninstall`)이 없는 경우 |
| 404 | Not Found | 해당 식별자의 템플릿이 없는 경우 (`템플릿을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Server Error | 삭제 정보 조회 실패 |
@@ -96944,7 +97090,7 @@ _업데이트할 내용이 없거나 템플릿 정보를 다시 읽지 못한
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.templates.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.templates.install`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 또는 업데이트 실패 (미설치, 다운그레이드 차단, 다운로드 실패, 코어 버전 비호환 등 — `errors.template_name` 에 번역된 사유) |
| 500 | Server Error | 업데이트 처리 중 예기치 못한 오류 |
@@ -96977,11 +97123,24 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_이 엔드포인트는 표준 JSON 봉투가 아니라 **템플릿 에셋 파일 본문** 을 그대로 반환한다 — `data` 구조가 없다._
+
+| 항목 | 값 | 설명 |
+| --- | --- | --- |
+| Content-Type | `파일 확장자에 따른 MIME (예: text/javascript, text/css, image/png)` | 서빙 대상의 MIME 타입 |
+| Cache-Control | `public, max-age=31536000, immutable` (프로덕션) / `no-cache` (그 외) | 환경에 따라 갈린다 |
+| ETag | `{md5(mtime+size)}` | `If-None-Match` 가 일치하면 본문 없이 `304` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+Content-Type: text/javascript
+
+(function(){ /* 템플릿 에셋 본문 */ })();
+```
+
+> 같은 ETag 로 재요청하면 본문 없이 `304 Not Modified` 가 반환된다.
**에러 응답**
@@ -97069,7 +97228,13 @@ Accept: application/json
-
+_`data` 는 템플릿의 `components.json` 내용을 그대로 담은 **컴포넌트 맵**이다 (고정 필드 집합이 아니라 컴포넌트명 → 정의 매핑)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| (컴포넌트명) | object | `{"type":"basic","tag":"div"}` | 컴포넌트 정의. 키는 레이아웃 JSON 의 `name` 과 일치한다 |
+
+> 활성/`_bundled` 어디에도 `components.json` 이 없으면 `404`(`templates.layout_not_found`)다.
**응답 예시**
@@ -97137,7 +97302,19 @@ _이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "설정을 조회했습니다.",
+ "data": {
+ "Card": { "type": "composite", "props": { "title": "string" } }
+ }
+}
+```
**에러 응답**
@@ -97396,11 +97573,35 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_`data` 는 템플릿의 `template.json` 매니페스트 내용이다 (고정 필드 집합이 아니라 매니페스트 그대로)._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `sirsoft-basic` | 템플릿 식별자 |
+| version | string | `1.1.1` | 템플릿 버전 |
+| type | string | `user` | 템플릿 유형 (`admin`/`user`) |
+| (그 외 매니페스트 키) | mixed | — | `template.json` 이 선언한 나머지 키가 그대로 실린다 |
+
+> **활성 템플릿만** 조회된다 — 비활성이거나 매니페스트가 없으면 `404`. 응답은 1시간 캐시된다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "설정을 조회했습니다.",
+ "data": {
+ "identifier": "sirsoft-basic",
+ "name": "Sirsoft Basic",
+ "version": "1.1.1",
+ "type": "user"
+ }
+}
+```
**에러 응답**
@@ -97413,7 +97614,7 @@ Accept: application/json
-**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 인증이 필요 없습니다.
+**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 서버 캐시는 템플릿 라이프사이클(`template:update` / `template:cache-clear` / 비활성화 / 삭제) 시점에 즉시 무효화되어 최신 매니페스트가 반영됩니다. 인증이 필요 없습니다.
### GET /api/templates/{identifier}/editor-spec
@@ -189529,11 +189730,37 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_`data` 는 템플릿 `routes.json` 에 활성 모듈·플러그인 라우트를 병합한 결과다._
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| version | string | `1.0.0` | routes.json 스키마 버전 |
+| routes | array | `[{"path":"/","layout":"home","source":{"kind":"template","identifier":"sirsoft-basic"}}]` | 라우트 목록 (각 항목에 출처 `source` 태깅) |
+
+> 응답은 `?v=` 쿼리를 포함한 키로 캐시된다. 다만 확장 업데이트 중 활성 디렉토리가 비어 라우트가 빠진 **열화 스냅샷은 캐시에 남기지 않는다** — 남기면 업데이트가 끝난 뒤에도 그 확장의 화면이 캐시 만료까지 404 로 남는다.
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "라우트를 조회했습니다.",
+ "data": {
+ "version": "1.0.0",
+ "routes": [
+ {
+ "path": "/",
+ "layout": "home",
+ "source": { "kind": "template", "identifier": "sirsoft-basic" }
+ }
+ ]
+ }
+}
+```
**에러 응답**
diff --git a/docs/backend/api/users.md b/docs/backend/api/users.md
index eaa18e7e..72e27012 100644
--- a/docs/backend/api/users.md
+++ b/docs/backend/api/users.md
@@ -378,7 +378,7 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.users.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 500 | Internal Server Error | 사용자 생성 중 예외 발생 (`user.create_failed`, `errors.error` 에 예외 메시지) |
@@ -428,6 +428,8 @@ _단건 응답: `data` 객체의 필드 (UserService::bulkUpdateStatus() 반환
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| updated_count | integer | `3` | 실제로 상태가 변경된 사용자 수 (요청자 본인 제외 규칙 적용 후 갱신된 행 수) |
+| failed_count | integer | `1` | 처리하지 못한 사용자 수 (탈퇴 전환에서 관리자·수퍼관리자 등 차단 대상이 섞인 경우). 탈퇴 외 상태 전환은 항상 `0` |
+| failed_reasons | array\ | `["관리자 계정은 탈퇴할 수 없습니다."]` | 처리하지 못한 사유 (중복 제거). 탈퇴 외 상태 전환은 항상 `[]` |
**응답 예시**
@@ -440,7 +442,9 @@ HTTP/1.1 200
"success": true,
"message": ":count명의 사용자 상태가 변경되었습니다.",
"data": {
- "updated_count": 3
+ "updated_count": 3,
+ "failed_count": 0,
+ "failed_reasons": []
}
}
```
@@ -452,7 +456,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 예: 요청자 본인 UUID 포함 시 `ExcludeCurrentUser` 위반) |
| 500 | Internal Server Error | 일괄 변경 중 예외 발생 (`user.bulk_update_status_failed`) |
@@ -462,6 +466,10 @@ HTTP/1.1 200
여러 사용자의 계정 상태를 한 번에 변경합니다. `ids` 는 대상 사용자 UUID 배열이며, `status` 는 `active`/`inactive`/`blocked`/`withdrawn`/`pending_verification`(UserStatus Enum 값) 중 하나이다. `ExcludeCurrentUser` 규칙으로 요청자 본인은 대상에서 제외된다. 목록 화면의 다중 선택 후 일괄 상태 변경에 사용한다.
+`status=withdrawn` 요청은 상태 컬럼만 바꾸지 않고 **건별로 정식 탈퇴 처리**를 수행한다 (익명화 + 연계 데이터 정리 + 탈퇴 훅). 회원이 직접 탈퇴한 경우와 결과가 같다. 관리자 역할을 가진 대상은 건너뛰고 `failed_count` 로 집계되며, 나머지 대상의 처리는 계속된다.
+
+전건이 차단되어도 응답은 200 이다. 소비자는 `updated_count` 와 `failed_count` 를 함께 읽어 결과를 판정해야 한다 — 200 만 보고 성공으로 안내하면 아무 일도 일어나지 않은 요청이 처리된 것으로 표시된다. 사유는 `failed_reasons` 로 전달되므로 그대로 노출하면 된다.
+
### POST /api/admin/users/check-email
@@ -1003,14 +1011,26 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "사용자가 성공적으로 삭제되었습니다.",
+ "data": null
+}
+```
+
+> 슈퍼 관리자 계정을 대상으로 하면 삭제되지 않고 `422`(`exceptions.cannot_delete_super_admin`)로 거부된다. 삭제는 CASCADE 에 의존하지 않고 연관 데이터를 명시적으로 정리한 뒤 수행되며, 정리 단계에서 실패하면 `422` 와 함께 `error.errors.general[0]` 에 상세 사유가 담긴다.
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.users.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
@@ -1020,6 +1040,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다.
+삭제는 원자적으로 처리된다 — 역할 연결 해제·약관 동의 이력 삭제·토큰 삭제·계정 삭제가 하나의 트랜잭션이며, 마지막 삭제가 외래키 제약 등으로 실패하면 앞 단계도 전부 취소된다(연결만 끊긴 활성 계정이 남지 않는다). 아바타 파일 삭제는 커밋 후에 수행된다.
+
### GET /api/admin/users/{user}
@@ -1091,7 +1113,7 @@ _단건 응답: `data` 객체의 필드._
| withdrawn_at | null | `null` | withdrawn 일시 |
| blocked_at | null | `null` | blocked 일시 |
| failed_login_attempts | integer | `0` | 연속 로그인 실패 횟수 |
-| locked_permanently | boolean | `false` | |
+| locked_permanently | boolean | `false` | 영구 잠금 여부. true 면 `locked_until` 과 무관하게 잠금이 유지되며, 해제는 성공 로그인 또는 관리자의 잠금 해제로만 이뤄진다 (잠금 시간 설정이 `0`= 무기한일 때 세워진다) |
| locked_until | null | `null` | 계정 잠금 해제 시각 (NULL = 잠금 없음) |
| is_locked | boolean | `false` | locked 여부 |
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) |
@@ -1426,7 +1448,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
-| 403 | Forbidden | 요구 권한(`core.users.read`)이 없는 경우 |
+| 403 | Forbidden | 요구 권한(`core.users.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — 마지막 관리자 본인의 admin 역할 제거 시도 시 `user.last_admin_role_cannot_remove` 포함) |
| 500 | Internal Server Error | 수정 중 예외 발생 (`user.update_failed`, `errors.error` 에 예외 메시지) |
diff --git a/docs/backend/console-confirm.md b/docs/backend/console-confirm.md
index c9525c47..a76f67be 100644
--- a/docs/backend/console-confirm.md
+++ b/docs/backend/console-confirm.md
@@ -10,7 +10,7 @@
1. 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel $this->confirm() 직접 호출 금지
2. trait: App\Console\Commands\Traits\HasUnifiedConfirm
3. 입력 규칙: empty=default, yes/y=true, no/n=false, 그 외=재질문 루프
-4. --no-interaction 모드: default 즉시 반환
+4. --no-interaction 또는 콘솔 없는 STDIN: default 즉시 반환 (파괴적 확인의 default 는 false 여야 한다)
5. upgrade step / Symfony 외부 환경: \App\Console\Helpers\ConsoleConfirm::ask() FQN 직접 호출
```
@@ -88,10 +88,26 @@ protected function unifiedConfirm(string $question, bool $default = false): bool
| 환경 | 동작 |
|------|------|
| `--no-interaction` 옵션 | `$default` 즉시 반환 |
+| 콘솔 없는 STDIN (CI·스케줄러·에이전트) | `$default` 즉시 반환 |
| 대화형 + 유효 입력 | 정규화 후 즉시 반환 |
| 대화형 + 잘못된 입력 | "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문 |
| 대화형 + empty 입력 | `$default` 반환 |
+#### 비TTY 판정을 Symfony 에 맡기지 않는 이유
+
+Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로 내린다.
+그런데 **Windows PHP 에는 posix 확장이 없어 그 감지 분기 자체가 실행되지 않는다.** 결과적으로
+`--no-interaction` 없이 콘솔 없는 곳에서 부르면 `isInteractive()` 가 true 로 남고, 프롬프트는
+버퍼에 갇힌 채 `ask()` 가 오지 않을 응답을 무한히 기다린다. 예외도 로그도 없어 겉으로는
+**"그 커맨드가 느리다"** 로만 보인다 — 실제로는 영영 끝나지 않는다.
+
+그래서 `unifiedConfirm()` 은 `canPromptForAnswer()` 로 한 번 더 판정한다. `stream_isatty()` 는
+posix 확장 없이 Windows 를 포함해 동작하므로 이 판정의 근거로 쓴다. 판정 재료가 없으면
+`true`(질문함)로 떨어진다 — 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
+
+이 가드가 의미를 가지려면 **파괴적 확인의 `$default` 는 반드시 `false`(중단)** 여야 한다.
+콘솔이 없으면 그 기본값이 곧 결정이 되기 때문이다.
+
---
## ConsoleConfirm helper (Symfony 비의존)
diff --git a/docs/backend/exceptions.md b/docs/backend/exceptions.md
index 26298cf4..b0cf9f89 100644
--- a/docs/backend/exceptions.md
+++ b/docs/backend/exceptions.md
@@ -12,6 +12,8 @@
3. 파라미터 치환: __('exceptions.key', ['param' => $value])
4. 코어 예외: exceptions.template_engine.* 형식
5. 모듈 예외: vendor-module::exceptions.* 형식
+6. 응답에는 `getMessage()` 가 아니라 **메시지 키**를 넘긴다 (예외는 키를 들고 다닌다)
+7. typed 예외 → 기존 4xx 유지 / generic catch → 5xx (장애를 입력 오류로 위장하지 않는다)
```
---
@@ -21,9 +23,10 @@
1. [핵심 원칙](#핵심-원칙)
2. [다국어 파일 위치 및 구조](#다국어-파일-위치-및-구조)
3. [파라미터 치환 패턴](#파라미터-치환-패턴)
-4. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
-5. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
-6. [관련 문서](#관련-문서)
+4. [예외 → 응답 매핑](#예외--응답-매핑)
+5. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
+6. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
+7. [관련 문서](#관련-문서)
---
@@ -163,6 +166,92 @@ class MaxDepthExceededException extends Exception
---
+## 예외 → 응답 매핑
+
+예외를 응답으로 바꾸는 자리에서 두 가지가 자주 어긋난다. 둘 다 오류도 로그도 남기지 않아 코드를 나란히 놓고 보기 전에는 드러나지 않는다.
+
+시작하기 전에 통로부터 나눈다. `ResponseHelper::error($messageKey, $statusCode, $errors, $messageParams)` 는 인자마다 노출 규칙이 다르다.
+
+| 인자 | 통로 | 노출 규칙 |
+|---|---|---|
+| `$messageKey` (1번째) | 사용자에게 보이는 안내 문구 | **다국어 키 전용.** 번역문·예외 원문을 넣으면 키 해석에 실패해 원문이 그대로 나간다 |
+| `$errors` (3번째) — Throwable | 진단 | `app.debug` 에서만 `debug` 블록으로 펼쳐진다 |
+| `$errors` (3번째) — 문자열 | 진단 | `500` 이상 + 비디버그에서 **차단**, 그 외 노출 |
+| `$errors` (3번째) — 배열 | 구조화 페이로드 | **항상 노출** (검증 오류 구조가 이 통로를 쓴다) |
+| `$messageParams` (4번째) | 문구 치환 | 응답 본문에 직접 실리지 않는다 |
+
+그래서 "예외 원문을 응답에 싣느냐" 는 하나의 규칙으로 답할 수 없다. 판단 축은 셋이다.
+
+- **누구에게** — 관리자 전용 면인가, 공개(비인증) 엔드포인트인가. 공개 면에 SQL 상태코드·경로가 나가면 정보 노출이다. 반대로 관리자에게 실패 사유를 감추면, 서버 로그 접근 권한이 없는 운영자는 "실패했습니다" 외에 아무 근거도 얻지 못한다.
+- **무엇의 원문인가** — 우리 인프라 예외인가, 외부 시스템(결제대행사·배송사 API)이 돌려준 도메인 사유인가. 후자는 우리 스택 정보가 아니고 다국어 키로 옮길 수도 없다. 감추면 조치 가능한 정보가 사라진다.
+- **어느 통로인가** — 위 표. 노출 폭은 호출부가 문자열로 조립하지 말고 헬퍼에 맡긴다.
+
+다국어 키는 유한하고 실패 사유는 무한하다. 키만 고집하면 예상 못 한 실패가 전부 "작업에 실패했습니다" 하나로 붕괴하는데, 이는 아래 2번이 막으려는 "장애가 입력 오류로 위장" 과 같은 종류의 정보 손실이다.
+
+### 1. 응답에는 메시지 키를 넘긴다
+
+`ResponseHelper::error()` 의 첫 인자는 **다국어 키**다. 이미 번역된 `$e->getMessage()` 를 그 자리에 넘기면 키 해석에 실패해 원문이 그대로 사용자 화면에 나간다 — 원문에는 SQL 상태코드·경로·클래스명이 섞여 있을 수 있다.
+
+그래서 도메인 예외는 **번역문이 아니라 키를 들고 다닌다**. 생성자에서 키와 치환 파라미터를 보관하고 `getMessageKey()` / `getMessageParams()` 로 노출한다.
+
+```php
+class BoardTypeOperationException extends Exception
+{
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ public function getMessageKey(): string { return $this->messageKey; }
+
+ public function getMessageParams(): array { return $this->replace; }
+}
+```
+
+```php
+// ❌ 번역문을 키 자리에 — 해석 실패 → 원문 노출
+return $this->error($e->getMessage(), 422);
+
+// ✅ 키 + 치환 파라미터
+return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
+```
+
+사유 식별자만 들고 다니는 예외(`getReason()`)라면 호출부가 그 식별자로 키를 조립한다 — 이때도 응답에 들어가는 것은 키다.
+
+### 2. typed 는 기존 4xx, generic 은 5xx
+
+`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애와 코드 결함이 "입력 오류" 로 위장되어, 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다.
+
+```php
+try {
+ $this->service->doSomething($order, $data);
+} catch (OrderModificationException $e) {
+ // 도메인 규칙 위반 — 사용자가 고칠 수 있다. 기존 상태코드를 그대로 유지한다.
+ return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams());
+} catch (\Exception $e) {
+ // 그 외 — 서버 결함/인프라 장애. 메시지 키 자리에는 원문을 넣지 않는다.
+ // (진단이 필요하면 $errors 인자로 Throwable 을 넘겨 헬퍼의 debug 게이트에 맡긴다)
+ Log::error('...', ['error' => $e->getMessage()]);
+
+ return ResponseHelper::error('sirsoft-ecommerce::exceptions.operation_failed', 500);
+}
+```
+
+규칙 셋:
+
+- typed 예외를 잡는 분기의 상태코드는 **바꾸지 않는다**. 예외를 도입하는 작업이 사용자에게 보이는 계약을 함께 바꾸면 회귀다.
+- 서비스가 typed 예외를 던지는데 컨트롤러에 그 typed catch 가 없으면, 도메인 사유가 generic 으로 흘러 500 이 된다. 서비스를 typed 로 승격할 때는 **호출하는 컨트롤러 메서드를 전수로** 함께 본다.
+- 상태코드 인자를 생략한 `ResponseHelper::error('key')` / `moduleError($mod, 'key')` 는 기본값 **400** 이다. generic catch 안에서는 인자를 생략하지 않는다.
+- **도메인 예외의 부모를 잡지 않는다.** 도메인 예외는 대개 `\RuntimeException` 을 상속하므로, 서비스를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 을 남겨 두면 그 자리는 도메인 예외를 잡는 것처럼 보이지만 실제로 남는 것은 인프라 예외뿐이다. 그것까지 4xx 로 나가면 승격 작업이 아무것도 고치지 못한 셈이 된다. 승격한 예외 이름으로 좁힌다.
+
+의도적으로 4xx 를 유지하는 generic catch 도 있다 — 업로드 파일 해석처럼 실패 원인이 대부분 클라이언트 입력인 경우다. 그런 지점은 사유와 함께 명시적으로 선언하고, 판정에서 조용히 빠지게 두지 않는다.
+
+`tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 훑어 이 두 규칙을 고정한다. 예외 목록을 상수로 선언해 두었으므로, 새 예외를 만들려면 목록에 사유와 함께 추가해야 한다.
+
+---
+
## 템플릿 엔진 Custom Exception 예시
### CircularReferenceException (레이아웃 순환 참조)
@@ -303,6 +392,8 @@ class LayoutService
- [ ] `/lang/en/exceptions.php`에 영어 메시지 추가
- [ ] Exception 생성자에서 모든 메시지는 `__()` 함수 사용
- [ ] 동적 값은 파라미터 배열로 전달 (예: `['trace' => $stackTrace]`)
+- [ ] **메시지 키와 치환 파라미터를 보관하고 `getMessageKey()` / `getMessageParams()` 로 노출** (응답에 번역문 대신 키를 넘기기 위함)
+- [ ] **이 예외를 던지는 서비스 메서드를 호출하는 컨트롤러 메서드 전수에 typed catch 가 있는지 확인** (없으면 도메인 사유가 generic 으로 흘러 500 이 된다)
- [ ] 두 언어 모두에서 테스트 수행
- [ ] 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지
diff --git a/docs/backend/search-system.md b/docs/backend/search-system.md
index dc4da5bf..f3b7611f 100644
--- a/docs/backend/search-system.md
+++ b/docs/backend/search-system.md
@@ -10,7 +10,8 @@
```text
1. Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ngram 기반 검색 (기본 드라이버)
2. FulltextSearchable 인터페이스: searchableColumns() + searchableWeights() 구현 필수
-3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL)에서 자동 전환
+3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL) + 대상 테이블·컬럼
+ 조합의 FULLTEXT 인덱스 부재 시 (후자는 설치 결함 신호라 조합당 프로세스 1회 기록)
4. 확장 포인트: core.search.engine_drivers(엔진) + core.search.index_maintainers(인덱스 점검) 필터 훅
5. 인덱스 재생성은 언제나 선택 사항 — 자동 트리거 없음 (테이블 잠금·전체 재색인 비용)
6. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
@@ -176,6 +177,39 @@ DatabaseFulltextEngine::supportsFulltext();
DatabaseFulltextEngine::supportsNgramParser();
```
+### 인덱스 커버 판정 — MATCH 는 인덱스가 있어야만 조립한다
+
+드라이버가 FULLTEXT 를 지원해도, MySQL 의 `MATCH(a, b)` 는 어떤 FULLTEXT 인덱스의
+컬럼 **집합과 정확히 일치**(순서 무관)해야 실행된다. 복합 `(title, content)` 인덱스는
+`MATCH(title)` 단독을 커버하지 못한다. 인덱스가 없는 채 MATCH 를 실행하면
+`1191 Can't find FULLTEXT index` 가 되고, 그 오류가 소비처의 catch 에 삼켜지면
+화면에는 "검색 결과 0건" 으로만 나타난다 (부분 실패 설치에서 실제 발생 — 공개 #103).
+
+그래서 엔진은 MATCH 조립 전에 `fulltextIndexCoversColumns($table, $columns)` 로
+커버 여부를 판정한다:
+
+- 판정은 컬럼 한정자 제거·소문자·정렬 정규화 후 **집합 동등 비교**다. 순서가 달라도
+ 같은 집합이면 커버한다 (`['content','title']` ≡ `['title','content']`).
+- Scout 경로(`performSearch`)는 컬럼마다 단일 `MATCH(col)` 를 조립하므로, **각 컬럼이
+ 개별 단일 인덱스로** 커버될 때만 MATCH 를 쓴다. 복합 인덱스만 있는 테이블은 LIKE 로
+ 내려간다.
+- `applyAny()` 는 컬럼별 `apply(단일)` 재귀라 게이트도 단일 컬럼으로 판정한다 — 한 OR
+ 그룹 안에 MATCH 와 LIKE 가 혼재할 수 있으며, 유효한 SQL 이고 의도된 동작이다.
+- 커버되지 않아 내려가는 폴백은 **설치 결함의 신호**이므로 테이블+컬럼 조합당 프로세스
+ 1회 `Log::warning` 을 남긴다 (복구 안내 포함). 드라이버 미지원 폴백은
+ 종전대로 무경고다 — 그 DBMS 에서는 부분일치가 정상 경로다.
+- 이 경고의 복구 경로는 **해당 확장의 인덱스 생성 마이그레이션 재실행**이다.
+ `search:index` 를 안내하지 않는다 — 그 점검은 `INFORMATION_SCHEMA` 에 **이미 존재하는**
+ FULLTEXT 인덱스만 열거하고 `--repair` 는 그중 색인이 낡은 것만 재생성하므로, 인덱스가
+ 아예 없는 이 상황은 목록에 나타나지도 재생성 대상이 되지도 않는다. 안내가 그쪽을
+ 가리키면 운영자는 "이상 없음" 을 보고 원인 추적이 거기서 끊긴다.
+
+인덱스 카탈로그는 `INFORMATION_SCHEMA.STATISTICS` 에서 lazy 적재하는 **프로세스(static)
+캐시**다. 적재 실패(Throwable)는 캐시하지 않고 그 요청만 LIKE 로 내려간다 — 일시 장애가
+워커 수명 내내 강등으로 굳지 않게 하기 위함이다. `addFulltextIndex()` 성공 직후에는
+카탈로그를 무효화해 같은 프로세스에서 바로 반영된다. 그 외의 인덱스 변경(배포 등)은
+FPM 워커 재시작 시점에 반영된다.
+
### SCOUT_DRIVER 전환
`.env`에서 드라이버를 변경하면 즉시 적용됩니다:
@@ -313,7 +347,20 @@ public function boot(): void
}
```
-등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하면 해당 엔진이 사용됩니다.
+등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하거나, 관리자 환경설정 > 드라이버의
+검색엔진 항목에서 선택할 수 있습니다.
+
+#### 드라이버 폴백 가드
+
+검색엔진은 다른 드라이버 카테고리(스토리지·캐시·세션·큐·로그·메일 등)와 같은 폴백 가드를
+받습니다. 저장된 엔진을 제공하던 플러그인이 삭제되면, 부팅 시 그 값이 사용 불가로 판정되어
+기본 엔진(`mysql-fulltext`)으로 되돌아갑니다. 이 가드가 없으면 `scout.driver` 가 죽은 값으로
+남아 공개 검색이 오류로 멈춥니다.
+
+카탈로그 조회는 **두 훅을 함께 읽습니다** — 위의 `core.search.engine_drivers`(Scout 엔진 맵)와
+일반 드라이버 훅 `core.settings.available_search_drivers`(`{id, label}` 목록). 그래서 검색엔진
+플러그인은 Scout 등록 훅 하나만 구현하면 되고, 관리자 화면 선택지에도 자동으로 나타납니다.
+라벨은 `settings.drivers.search.{id}` 다국어 키에서 조회하며, 키가 없으면 ID 를 그대로 씁니다.
### ScoutServiceProvider 동작 흐름
diff --git a/docs/backend/service-repository.md b/docs/backend/service-repository.md
index b520e16c..d617e64b 100644
--- a/docs/backend/service-repository.md
+++ b/docs/backend/service-repository.md
@@ -433,41 +433,57 @@ sirsoft-ecommerce.product.after_update
sirsoft-ecommerce.product.filter_create_data
```
-### 서비스 내부 조건부 권한 체크
+### 서비스 내부 권한 상한(ceiling) 체크
-미들웨어가 아닌 **서비스 내부**에서 추가적인 권한 체크가 필요한 경우 `PermissionHelper`를 사용합니다.
-대표적인 예: 역할(role) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우.
+데이터의 일부 필드가 **권한 상승 벡터**일 때(대표적으로 역할 부여 — 역할을 붙이면 그 역할의 전
+권한을 넘기는 것과 같다), 서비스는 미들웨어 권한과 별개로 **상한 가드**를 적용한다.
+
+핵심 원칙 세 가지:
+
+1. **게이트는 그 엔드포인트의 라우트 권한(SSoT)과 같은 리소스여야 한다.** 역할 부여는 "사용자
+ 관리"(`core.users.update` — 이 경로는 라우트에서 이미 강제됨)의 일부이며, "역할 정의 수정"
+ (`core.permissions.update` — 역할에 권한을 가감하는 **타 리소스** 권한)을 요구하지 않는다. 연관/타
+ 리소스 권한으로 원 리소스 조작을 게이팅하면, 원 리소스 권한을 가진 액터가 정당한 작업을 못 한다.
+2. **권한 상승 방지는 상한 가드(`PermissionEscalationGuard`)가 담당한다.** 액터가 보유하지 않았거나
+ 자신의 범위보다 넓은 권한을 담은 역할은 부여할 수 없다.
+3. **위반은 명시적으로 거부(403)한다 — 조용히 무시하지 않는다.** 상한 위반 시
+ `PermissionEscalationException` 이 전파되어 컨트롤러가 403 으로 매핑한다.
```php
-use App\Helpers\PermissionHelper;
-use Illuminate\Support\Facades\Auth;
+use App\Support\PermissionEscalationGuard;
+
+public function __construct(
+ private readonly PermissionEscalationGuard $escalationGuard,
+ // ...
+) {}
public function updateUser(User $user, array $data): User
{
$roleIds = $data['role_ids'] ?? null;
unset($data['role_ids'], $data['roles']);
- // 훅 실행 (생략)...
+ // 훅 실행 / 필드 업데이트 (생략)...
- $user = $this->userRepository->update($user->id, $data);
-
- // 역할 변경은 별도 권한으로 보호
if ($roleIds !== null) {
$authUser = Auth::user();
- // 자기 자신의 역할은 항상 변경 불가 (보안)
- if ($authUser && $authUser->id === $user->id) {
- $roleIds = null;
+ // 상한 검사 대상 = 이번 변경으로 붙거나 떨어지는 역할(추가·제거 대칭 차분).
+ // 추가만 검사하면 하위 관리자가 상위 역할을 박탈하는 하향 조작이 상한 없이 뚫린다.
+ // 기존 유지 역할은 변경이 아니므로 제외한다.
+ $currentRoleIds = $user->roles->pluck('id')->all();
+ $changedRoleIds = array_values(array_unique(array_merge(
+ array_diff($roleIds, $currentRoleIds), // 추가되는 역할
+ array_diff($currentRoleIds, $roleIds), // 제거되는 역할
+ )));
+ if (! empty($changedRoleIds)) {
+ // 상한 위반 시 PermissionEscalationException throw → 컨트롤러가 403 매핑
+ $this->escalationGuard->assertRoleAssignmentWithinActorCeiling($changedRoleIds);
}
- // core.permissions.update 권한 없으면 역할 변경 무시
- if ($roleIds !== null && ! PermissionHelper::check('core.permissions.update', $authUser)) {
- $roleIds = null;
- }
+ // 자기잠금 방지: 마지막 admin 이 자기 admin 역할을 떼는 것만 별도 차단
+ // (자기 자신 수정 자체를 막지는 않는다)
- if ($roleIds !== null) {
- $user->roles()->sync($roleIds);
- }
+ $user->roles()->sync($roleIds);
}
return $user;
@@ -475,11 +491,16 @@ public function updateUser(User $user, array $data): User
```
```
-필수: 역할/권한 변경은 미들웨어 권한과 별개로 서비스에서 명시적 체크 필수
-필수: 자기 자신의 역할 변경은 항상 불가 (관리자 실수 방지)
-패턴: 민감 필드 분리 → 별도 권한 체크 → 권한 없으면 해당 필드 무시 (403이 아닌 무시)
+필수: 상승 벡터 필드의 게이트는 그 엔드포인트 라우트 권한(SSoT)과 같은 리소스 prefix 여야 함
+ (연관/타 리소스 권한이 원 리소스 조작을 침범 금지)
+필수: 권한 상승 방지는 foreign 권한 게이트가 아니라 escalation/rank-ceiling 가드로 처리
+필수: 상한 위반은 명시적 거부(403) — 조용히 무시(silent no-op/soft-block)하지 않음
+금지: `PermissionHelper::check('core.permissions.update')` 로 role_ids 를 drop 하는 silent-drop 패턴
+ (원 권한 보유자가 정당한 역할 부여를 못 하고, 200 성공을 반환하면서도 아무 변화가 없어 발견이 늦다)
```
+> 상세: [validation.md](validation.md) "계층 리소스"·"보안 게이트 대칭성"
+
---
## 트랜잭션 및 관계 삭제 패턴
@@ -1570,6 +1591,51 @@ public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
---
+## 보안 게이트 대칭성 (KVE-2026-1914/1919)
+
+접근 게이트와 권한 등급 상한은 데이터를 내보내는 **한 경로에만** 있으면 다른 경로가
+조용한 우회로가 된다(예외·오류·로그 없이 원문만 새 나간다). 판정을 한 지점으로 모으고
+(SSoT), 같은 데이터를 서빙하는 모든 소비 경로가 그 지점을 경유하게 한다.
+
+### 비밀 부모 → 하위 리소스 게이트 재적용
+
+비밀/비공개 부모(게시글)에 종속된 하위 리소스(댓글·첨부·문의)는 **각자의 독립
+엔드포인트**를 가진다. 부모 상세 Resource(PostResource) 한 곳에만 마스킹을 두면, 하위
+엔드포인트가 부모의 비밀 상태를 검사하지 않고 해시/ID 만으로 원문을 반환한다.
+
+| 항목 | 규칙 |
+| --- | --- |
+| 판정 SSoT | 열람 판정은 단일 게이트(예: `SecretContentGate::canView($post)`)에 모은다 — 작성자 본인 / 비밀번호 검증 / `posts.read-secret` / manager 규칙을 한 곳에서 |
+| 재적용 지점 | 하위 리소스를 서빙하는 **모든** 경로 — 목록 컨트롤러, 상세 Resource, 이커머스 연동 훅, 첨부 다운로드/미리보기 서비스 |
+| 목록 차단 | 부모가 비밀이고 무권한이면 하위 목록은 **빈 컬렉션**을 반환해 하위 항목이 Resource 에 도달조차 하지 않게 한다(1차 방어) |
+| fail-closed | 슬러그·부모를 해석할 수 없으면 안전하게 마스킹(false)으로 실패 — 첨부 서빙은 상세와 분리된 요청이라 `password_verified` 가 없으므로 해시만으로는 비밀 첨부를 못 가져간다 |
+
+### hash 기반 file-serving 게이트
+
+hash/ID 로 파일을 서빙하는 라우트(`preview`/`download`)는 **소유권·비밀·발행 상태**를
+반드시 거친다. `preview` 가 공개 썸네일 정책상 permission 미들웨어 없이(`optional.sanctum`)
+열려 있으면, 컨트롤러/서비스의 게이트만이 미인증 공격자(해시만 쥔 게스트)를 막는 유일한
+방어선이다 — `preview` 와 `download` 가 **동일 게이트**를 공유해야 한 쪽이 우회로가 되지 않는다.
+
+| 응답 | 상황 |
+| --- | --- |
+| 403 | 인증 사용자가 비밀/삭제 게이트에 걸림(`AccessDeniedHttpException`) |
+| 401 | 게스트가 permission 미들웨어(`download`)에 걸림 |
+| 정상 서빙 | 발행 + 비밀 아님(또는 열람 권한 보유) |
+
+### User/Role 등급 상한 대칭 (rank ceiling)
+
+삭제 경로에만 있던 슈퍼 관리자·보호 역할 보호를 **수정·상태변경·권한부여** 경로까지
+대칭 적용한다. 판정은 `UserGradeGuard::mayModify($target, $actor)` 단일 게이트로 모은다
+(대상이 슈퍼면 액터도 슈퍼여야 수정 가능). 정적 라우트인 일괄(bulk) 엔드포인트는 스코프
+미들웨어가 우회되므로 **서비스 계층에서 강제**하며, 일괄 대상 목록은 `filterModifiable`
+로 수정 불가 대상을 걸러낸다(액터 등급 기준 — 액터를 무시하고 대상만 보고 제외하면
+슈퍼 actor 의 정상 수행이 조용히 막힌다).
+
+> 저장측(FormRequest) 검증 강도 대칭과 레이아웃 표현식 검증 부착은 [validation.md "보안 게이트 대칭성"](validation.md) 참조.
+
+---
+
## 설정 기반 한계값
설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 **검증 책임은 검증 계층 단일**이다.
diff --git a/docs/backend/validation.md b/docs/backend/validation.md
index 4b31237b..9385ed2d 100644
--- a/docs/backend/validation.md
+++ b/docs/backend/validation.md
@@ -1212,6 +1212,41 @@ select 처럼 사용자가 URL 을 손으로 만들 일이 없는 면. 판정
---
+## 보안 게이트 대칭성 (KVE-2026-1914/1915/1919)
+
+같은 리소스를 다루는 두 엔드포인트가 서로 다른 검증 강도를 가지면, **약한 쪽이 우회로**가
+된다. 부모를 변경·서빙하는 모든 경로는 동일 강도의 검증을 거쳐야 한다.
+
+### 같은 리소스, 같은 검증 강도
+
+수정·순서변경·상태변경·권한부여처럼 같은 리소스를 바꾸는 경로가 여럿이면, 그중 하나라도
+검증이 약하면 공격자는 그 경로로 우회한다. 예: 삭제 FormRequest 만 등급 상한을 검사하고
+수정 FormRequest 는 검사하지 않으면, 수정 경로로 슈퍼 관리자를 조작할 수 있다. 판정 규칙은
+한 곳(게이트/Rule)에 두고 모든 경로가 그것을 재사용한다.
+
+### 레이아웃 표현식 검증은 표현식 트리에 부착한다
+
+`SafeLayoutExpressions` 처럼 값의 구조를 재귀 탐색하는 저장측 규칙은, **표현식이 실릴 수
+있는 배열/객체 트리 전체**(레이아웃의 `content`)에 부착해야 한다. 문자열 하위 필드
+(`content.endpoint` 등)에만 부착하면 규칙이 비-배열 값에서 조기 반환(`is_array` 가드)해
+**no-op** 이 된다 — 검증이 걸려 있는 것처럼 보이지만 실제로는 아무것도 검사하지 않는다.
+
+```php
+// ❌ 문자열 endpoint 에만 부착 — is_array 가드로 무력화(no-op)
+'content.endpoint' => ['string', new SafeLayoutExpressions],
+
+// ✅ 표현식 트리를 담는 content 배열에 부착
+'content' => ['required', 'array', new ValidLayoutStructure, new SafeLayoutExpressions],
+```
+
+부착 위치는 "어느 필드가 표현식 트리를 담는가" 라는 도메인 판정이라 정적으로 강제하기 어렵다 —
+레이아웃 저장 FormRequest(Store/Update/UpdateContent/UpdateExtensionContent) 4종의 부착을
+wiring 테스트로 회귀 고정한다.
+
+> 서비스/리포지토리 계층의 비밀 게이트 재적용·hash 서빙 게이트·등급 상한 대칭은 [service-repository.md "보안 게이트 대칭성"](service-repository.md) 참조.
+
+---
+
## Custom Rule 개발 체크리스트
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md
index dfcd3546..754ff3b9 100644
--- a/docs/cheatsheet.md
+++ b/docs/cheatsheet.md
@@ -181,7 +181,7 @@ php artisan template:install [identifier] [--force]
php artisan template:activate [identifier]
php artisan template:deactivate [identifier]
php artisan template:uninstall [identifier]
-php artisan template:cache-clear
+php artisan template:cache-clear [identifier?]
php artisan template:check-updates [identifier?]
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github]
diff --git a/docs/extension/extension-update-system.md b/docs/extension/extension-update-system.md
index 1c191d69..b7f04c1c 100644
--- a/docs/extension/extension-update-system.md
+++ b/docs/extension/extension-update-system.md
@@ -359,12 +359,22 @@ public static function getBundledPath(string $basePath, string $identifier): str
| 실패 시점 | 결과 |
| --------- | ---- |
| 1단계 실패 (복사) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시 디렉토리 정리 후 예외 |
-| 2단계 실패 (rename) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시만 정리 |
-| 3단계 실패 (rename) | `_pending/` 내 _old를 원래 위치로 **롤백**, 예외 |
+| 2단계 실패 (rename) | **파일 단위 제자리 동기화로 폴백** — 새 버전 파일을 활성 디렉토리에 직접 덮어쓰고, 새 버전에 없는 잔존 파일 제거 |
+| 3단계 실패 (rename) | **파일 단위 복사로 폴백** — 스테이징 내용을 활성 경로에 파일별 복사. 복사까지 실패하면 _old 를 원래 위치로 **롤백**, 예외 |
> **Windows 참고**: `deleteDirectory` 직후 같은 이름으로 `rename`이 실패하는 타이밍 이슈가 있어, delete→copy 대신 rename→rename→delete 패턴을 사용합니다.
> **오토로드 안전성**: 임시 디렉토리가 `_pending/` 하위에 생성되므로, IDE 잠금 등으로 잔존하더라도 `str_starts_with($name, '_')` 필터에 의해 오토로드에서 자동 제외됩니다.
+#### Windows 파일 잠금 폴백 (rename 차단 시)
+
+Windows 에서 디렉토리 rename 은 하위 트리에 열린 핸들(파일 워처의 디렉토리 핸들, 편집기·개발 도구가 열어 둔 파일 등)이 하나라도 있으면 실패한다. 잠금 프로세스의 식별·종료는 신뢰할 수 없고(디렉토리 핸들은 Restart Manager API 로 감지되지 않는다) 사용자의 도구를 예고 없이 종료시키는 부작용이 있으므로, **rename 이 차단되면 프로세스를 종료하는 대신 파일 단위 연산으로 폴백**한다:
+
+- 파일 생성·덮어쓰기·읽기는 디렉토리 핸들 잠금의 영향을 받지 않는다 (Windows 의 일반적인 파일 열기 모드는 읽기/쓰기 공유를 허용).
+- 덮어쓰기가 막힌 개별 파일은 삭제 후 재생성 → 옆으로 치우기(rename) 순으로 단계적 재시도한다.
+- 새 버전 파일을 심지 못하면 예외로 전파해 호출자(매니저)가 백업 복원으로 수습하고, 잔존 파일 삭제 실패는 로그만 남기고 진행한다 (업데이트 전체 실패보다 잔존이 낫다).
+- 폴백 경로는 원자적이지 않다 — 파일별로 순차 교체되므로 교체 도중의 요청은 신·구 파일이 섞인 상태를 볼 수 있다. 프로덕션(Linux)에서는 rename 이 차단되지 않아 항상 원자적 fast path 를 탄다.
+- 잠금으로 삭제하지 못한 `_updating_*`/`_old_*` 임시 디렉토리는 다음 교체 시작 시 자동으로 재정리된다.
+
이 메서드는 다음 위치에서 공통으로 사용됩니다:
- `ExtensionBackupHelper::restoreFromBackup()` — 백업 복원
diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md
index 6a79391b..3274ced7 100644
--- a/docs/extension/hooks.md
+++ b/docs/extension/hooks.md
@@ -121,6 +121,42 @@ core.attachment.download
core.attachment.update
core.attachment.delete
+# 스토리지 공개 URL 훅 (Filter) — StorageInterface::url() 결과 공급/수정/차단 (공개#100)
+# 컨텍스트 6키는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
+core.storage.filter_url
+
+# 보존 기간 자동 파기 (7.0.7) — 운영자 일괄 삭제 훅과 별개로 발행한다.
+# 운영자 삭제 훅에는 본인인증 같은 대화형 가드가 물려 있어 무인 예약이 탈 수 없고,
+# 그렇다고 훅 없이 지우면 확장이 가장 큰 삭제 경로(첫 실행의 누적분)를 볼 수 없다.
+# 인자는 (보존일) / (보존일, 삭제건수) — 대상을 ID 로 지목하지 않는다.
+core.activity_log.before_prune core.activity_log.after_prune
+core.notification_log.before_prune core.notification_log.after_prune
+core.schedule.before_prune_history core.schedule.after_prune_history
+
+# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
+# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
+# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
+# 포맷 변환(예: jpg → webp) 시에는 바이트만 제자리 덮어쓰지 말고, 변환된 임시 파일 경로와
+# 새 원본 파일명(xxx.webp)으로 UploadedFile 을 재구성해 반환할 것 — 일부 소비처는 저장
+# 확장자를 원본 파일명에서 얻으므로, 이름을 갱신하지 않으면 확장자와 내용이 어긋난다.
+core.attachment.filter_upload_file # 코어 첨부 (아바타 포함)
+core.template_layout_attachment.before_upload # 레이아웃 편집기 첨부 (7.0.7)
+core.template_layout_attachment.filter_upload_file # 레이아웃 편집기 첨부 (7.0.7)
+core.template_layout_attachment.after_upload # 레이아웃 편집기 첨부 (7.0.7)
+sirsoft-board.attachment.filter_upload_file # 게시판 첨부
+sirsoft-page.attachment.filter_upload_file # 페이지 첨부
+sirsoft-ecommerce.product-image.filter_upload_file # 상품 이미지
+sirsoft-ecommerce.category-image.filter_upload_file # 카테고리 이미지
+sirsoft-ecommerce.review-image.filter_upload_file # 리뷰 이미지 (공개 #96)
+sirsoft-ckeditor5.image.before_upload # 에디터 이미지
+sirsoft-ckeditor5.image.filter_upload_file # 에디터 이미지
+sirsoft-ckeditor5.image.after_upload # 에디터 이미지
+
+# 업로드 잔존물 회수 — 어떤 콘텐츠에서도 쓰이지 않는 에디터 이미지의 참조 판정 대상 선언 (필터)
+# 자기 콘텐츠를 가진 확장이 이 훅으로 테이블/컬럼을 등록하지 않으면, 그 확장에서만 쓰이는
+# 이미지가 "미참조" 로 판정돼 자동 정리 대상이 된다.
+sirsoft-ckeditor5.image.filter_reference_sources # 에디터 이미지 참조 소스
+
# FormRequest Validation Rules 훅 (Filter)
core.user.create_validation_rules
core.user.update_validation_rules
@@ -145,7 +181,9 @@ core.layout_extension.before_apply
core.layout_extension.after_apply
# 드라이버 확장 훅 (Filter) — 플러그인이 새 드라이버를 등록
+# 항목 3키 구조는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
core.settings.available_storage_drivers
+core.settings.available_public_asset_drivers # 공개 자산 직접 URL 서빙 디스크 (공개#100)
core.settings.available_cache_drivers
core.settings.available_session_drivers
core.settings.available_queue_drivers
@@ -178,6 +216,70 @@ core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'fai
> `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다.
+### 스토리지·드라이버 확장 훅 페이로드
+
+#### `core.storage.filter_url`
+
+`StorageInterface::url()` 의 결과를 공급·수정·차단합니다. 첫 인자는 생성된 URL(`?string`)이며, **디스크 종류와 무관하게 항상 발화**합니다 — 직접 URL 을 만들 수 없어 `null` 인 경우에도 발화하므로 확장이 서명 URL 등을 공급할 수 있습니다. 반환이 문자열이 아니거나 빈 문자열/공백이면 호출측이 스트리밍으로 폴백합니다.
+
+두 번째 인자는 컨텍스트 배열입니다.
+
+| 키 | 타입 | 값 |
+| --- | --- | --- |
+| `scope` | string | `core` / `module` / `plugin` — 호출한 드라이버 종류 |
+| `identifier` | ?string | 확장 식별자 (`sirsoft-ecommerce` 등). 코어 드라이버는 `null` |
+| `disk` | string | 대상 디스크명 |
+| `category` | string | 스토리지 카테고리 (`images`, `settings` 등) |
+| `path` | string | 카테고리 하위 상대 경로 |
+| `full_path` | string | 디스크 루트 기준 전체 경로 — `Storage::temporaryUrl()` 등에 그대로 사용 가능 |
+
+#### `core.settings.available_{category}_drivers`
+
+플러그인이 드라이버/디스크 선택지를 카탈로그에 추가합니다. 첫 인자인 드라이버 배열에 다음 구조의 항목을 append 합니다.
+
+| 키 | 타입 | 값 |
+| --- | --- | --- |
+| `id` | string | 드라이버/디스크 식별자 — 저장값이자 config 조회 키 |
+| `label` | array | 로케일별 표시 라벨 (예: `['ko' => 'CDN', 'en' => 'CDN']`) |
+| `provider` | string | 공급 플러그인 식별자 — 비활성화 시 "사용 중 드라이버" 경고 판정에 사용 (코어 기본 항목에는 없음) |
+
+`core.settings.available_public_asset_drivers` 로 등록하는 디스크는 플러그인 ServiceProvider 에서 `filesystems.disks.{id}` 정의가 함께 있어야 하며, 그 정의에 `url` 키가 있어야 직접 URL 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다.
+
+#### `sirsoft-ckeditor5.image.filter_reference_sources`
+
+에디터 업로드 이미지가 "어디선가 쓰이고 있는지" 판정할 때 훑을 테이블/컬럼 목록을 확장합니다. 첫 인자인 소스 배열에 다음 구조의 항목을 append 합니다.
+
+| 키 | 타입 | 값 |
+| --- | --- | --- |
+| `table` | string | 테이블명 — **프리픽스 제외 원시 이름** (`board_posts`) |
+| `columns` | list\ | 본문이 담긴 컬럼명 목록 (`['content']`) |
+
+자기 콘텐츠에 에디터를 노출하는 확장은 이 훅을 반드시 구독해야 합니다. 등록하지 않으면 그 확장에서만 쓰이는 이미지가 "미참조" 로 판정돼 자동 정리 대상이 됩니다.
+
+리스너는 테이블명 문자열만 덧붙이고 DB 에 접근하지 않습니다 — 실재 검증(테이블·컬럼 존재)과 조회는 플러그인이 수행하며, 존재하지 않는 선언은 경고만 남기고 건너뜁니다(한 확장의 잘못된 선언이 판정 전체를 멈추지 않습니다).
+
+로그 사본 테이블(발송 이력·신고 스냅샷 등)은 등록하지 않습니다. 이들은 자체 보존기간으로 삭제되므로 참조 소스로 삼으면 "로그가 지워지는 순간 이미지가 고아가 되는" 역전이 생깁니다.
+
+```php
+public static function getSubscribedHooks(): array
+{
+ return [
+ 'sirsoft-ckeditor5.image.filter_reference_sources' => [
+ 'method' => 'addSources',
+ 'priority' => 10,
+ 'type' => 'filter',
+ ],
+ ];
+}
+
+public function addSources(array $sources): array
+{
+ $sources[] = ['table' => 'my_documents', 'columns' => ['body']];
+
+ return $sources;
+}
+```
+
---
## 리스너 구현
diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md
index 3993ec73..5f146de4 100644
--- a/docs/extension/module-assets.md
+++ b/docs/extension/module-assets.md
@@ -123,6 +123,28 @@
| `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 |
| `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) |
| `github_changelog_url` | `string\|null` | 선택 | GitHub 변경 이력 URL |
+| `trusted_script_hosts` | `string[]` | 선택 | 레이아웃이 로드할 수 있는 외부 스크립트 신뢰 호스트 목록 (아래 참조) |
+
+#### `trusted_script_hosts` — 외부 스크립트 신뢰 호스트
+
+레이아웃 보안 정책은 `scripts[].src`·`data_sources[].endpoint` 를 기본적으로 same-origin
+경로(`/` 로 시작)만 허용하고, 외부 origin·protocol-relative(`//host`)·scheme 포함 URL 은
+저장 시점과 렌더 시점 양쪽에서 차단합니다. 확장이 정당하게 외부 CDN 스크립트를 써야 하면
+그 호스트를 이 배열에 선언합니다. 활성 확장이 선언한 호스트만 집계되며(편집자는 추가 불가 —
+manifest 는 배포물), 코어가 활성 확장 전체의 선언을 모아 allowlist 를 구성합니다.
+
+```jsonc
+{
+ "trusted_script_hosts": ["cdn.ckeditor.com"]
+}
+```
+
+- 값은 호스트명만(스킴/경로 없이). 예: `"cdn.ckeditor.com"`, `"t1.daumcdn.net"`.
+- 이 기능은 코어 7.0.7 에서 도입되었습니다. 선언하는 확장은 `g7_version` 을 `>=7.0.7` 로 두는
+ 것이 계약상 정확합니다(하위 코어에서는 필드가 무시되어 무해).
+- 관련 보안 정책 상세: [frontend/security.md](../frontend/security.md).
+
+플러그인(`plugin.json`)·템플릿(`template.json`)도 동일 필드를 지원합니다.
#### 에셋 필드
@@ -149,7 +171,7 @@ modules/_bundled/sirsoft-ecommerce/
├── package.json ← npm 패키지 정의
├── vite.config.ts ← Vite 빌드 설정
├── tsconfig.json ← TypeScript 설정
-├── dist/ ← 빌드 출력 (git ignore)
+├── dist/ ← 빌드 출력 (_bundled 은 Git 추적 — 배포 산출물, `*.map` 만 ignore)
│ ├── js/module.iife.js
│ ├── css/module.css
│ └── assets/
diff --git a/docs/extension/module-basics.md b/docs/extension/module-basics.md
index 4c9ab689..d9429367 100644
--- a/docs/extension/module-basics.md
+++ b/docs/extension/module-basics.md
@@ -491,7 +491,7 @@ modules/_bundled/sirsoft-ecommerce/
├── vite.config.ts # Vite 빌드 설정 (에셋 모듈만)
├── tsconfig.json # TypeScript 설정 (에셋 모듈만)
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
-├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만, gitignore 대상)
+├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만 — _bundled 은 Git 추적, `*.map` 만 ignore)
│ ├── js/module.iife.js
│ └── css/module.css
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 모듈 의무)
@@ -562,7 +562,7 @@ modules/_bundled/sirsoft-ecommerce/
| `package.json` | npm 패키지 정의 (에셋 모듈만 해당) |
| `vite.config.ts` | Vite IIFE 빌드 설정 (에셋 모듈만 해당) |
| `vendor/` | Composer 의존성 디렉토리 (자동 생성, gitignore 대상) |
-| `dist/` | 프론트엔드 빌드 출력 (에셋 모듈만 해당, gitignore 대상) |
+| `dist/` | 프론트엔드 빌드 출력 (에셋 모듈만 해당 — `_bundled` 소스는 Git 추적되는 배포 산출물, `*.map` 만 ignore. 소스 변경 시 `--production` 재빌드 산출물을 함께 커밋한다) |
| `config/` | 모듈별 설정 파일 |
| `database/factories/` | 테스트용 Factory (autoload 등록 필수) |
| `database/migrations/` | 데이터베이스 마이그레이션 |
diff --git a/docs/extension/module-settings.md b/docs/extension/module-settings.md
index 1e965f83..964d4ff3 100644
--- a/docs/extension/module-settings.md
+++ b/docs/extension/module-settings.md
@@ -15,7 +15,8 @@
7. [레이아웃 연동](#7-레이아웃-연동)
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
9. [카탈로그 병합 설정의 공개 응답](#9-카탈로그-병합-설정의-공개-응답)
-10. [관련 문서](#10-관련-문서)
+10. [항목 단위 보충 병합과 삭제 의도](#10-항목-단위-보충-병합과-삭제-의도)
+11. [관련 문서](#11-관련-문서)
---
@@ -526,6 +527,61 @@ $timing = $this->settingsService->getStockDeductionTiming($method);
> **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입
+### 8.4 저장하면 config 미러를 다시 채운다
+
+`g7_module_settings()` 가 읽는 값은 in-memory config 미러(`g7_settings.modules.{id}`)다. 이 미러는 부팅 때 채워지므로, 저장 경로가 아무것도 하지 않으면 **그 프로세스는 저장 후에도 옛 값을 계속 읽는다.**
+
+php-fpm 은 요청마다 부팅해 문제가 드러나지 않는다. 큐 워커 · `schedule:work` · Reverb 처럼 프로세스가 상주하는 환경에서만 나타나고, 예외도 경고도 로그도 남지 않는다 — 운영자는 저장했는데 배경 작업만 옛 설정으로 도는 상태가 된다.
+
+모듈 설정 저장에는 코어 공통 지점이 없다(각 모듈의 설정 서비스가 직접 저장한다). 그래서 **자기 캐시를 비우는 모든 자리에서** 미러 재채움을 함께 호출한다.
+
+```php
+public function saveSettings(array $settings): bool
+{
+ // ... 저장 ...
+
+ $this->settings = null; // 자기 캐시 비우기
+ g7_refresh_module_settings_config('vendor-module'); // config 미러 다시 채우기
+
+ return true;
+}
+
+public function clearCache(): void
+{
+ $this->settings = null;
+ g7_refresh_module_settings_config('vendor-module');
+}
+```
+
+호출 지점은 저장 메서드 하나가 아니다 — 초기화·복원·부분 저장 등 **값을 바꾸는 자리 전부**다. 한 곳만 빠지면 그 경로로 저장했을 때만 미러가 옛 값으로 남는다.
+
+- 이 헬퍼는 코어 7.0.7 부터 제공된다. 사용하는 모듈은 `module.json` 의 `requires.g7_version` 을 그에 맞춰 올린다.
+- 스키마에 `sensitive: true` 로 선언한 필드는 미러에 담기지 않는다. 민감값은 자체 설정 서비스(복호화 경로)로 읽는다.
+- 배경과 코어 축(코어/플러그인)의 처리는 [admin-settings-access.md](../backend/admin-settings-access.md) "config 미러 갱신 시점" 참조.
+
+### 8.5 저장 완료 훅 발화
+
+모듈 설정 저장을 알리는 코어 훅은 `core.module_settings.after_save` 이며, payload 는
+`($identifier, [category => fields], $result)` 다. SEO 캐시 무효화·활동 로그 등 코어와 타 확장의
+리스너가 이 훅을 구독한다.
+
+발화 지점은 **관리자 컨트롤러**다 — 설정 서비스가 아니다.
+
+- 훅의 의미가 "관리자가 설정을 저장했다" 이다. 서비스에 두면 내부 저장 호출(시드·마이그레이션·
+ 테스트 픽스처) 전부가 활동 로그와 캐시 무효화를 유발한다.
+- 각 모듈의 설정은 그 모듈의 SettingsService 가 직접 파일에 쓰므로, 코어에는 이 훅을 발화할
+ 공통 지점이 없다. 모듈이 자기 컨트롤러의 저장 성공 분기에서 직접 발화한다.
+
+```php
+if ($result) {
+ HookManager::doAction('core.module_settings.after_save', 'vendor-module', $settings, $result);
+}
+```
+
+단건 저장 경로(dot-key)는 payload 를 카테고리 하위 구조로 되돌려 벌크 저장과 같은 형태로
+맞춘다(`Arr::set($payload, $key, $value)`). 구독 리스너가 카테고리 기준으로 관심 키를 찾으므로,
+평탄한 dot-key 를 그대로 넘기면 그 리스너들이 아무것도 감지하지 못한다.
+
---
## 9. 카탈로그 병합 설정의 공개 응답
@@ -593,7 +649,34 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
---
-## 10. 관련 문서
+## 10. 항목 단위 보충 병합과 삭제 의도
+
+`defaults.json` 의 목록 설정(정수키 리스트)은 저장 시 `array_merge` 가 병합이 아니라 통째 교체를 수행한다. 그래서 관리자가 목록에서 일부 항목을 빼고 저장하면 defaults 항목이 저장본에서 사라지고, 그 상태가 그대로 유지된다. 이 통째 교체가 "관리자가 편집한 목록이 그대로 남는다" 는 뜻이라 대부분의 목록에는 옳은 동작이다.
+
+문제는 여기에 **항목 단위 보충 병합**(저장본에 없는 defaults 항목을 code/key 기준으로 다시 채워 넣는 처리)을 얹을 때다. 보충은 보통 "의도치 않은 소실 복구" 를 목적으로 도입되는데, 소실과 삭제는 저장본에서 똑같이 "그 항목이 없다" 로 보이기 때문에 구분할 근거가 없다. 그 결과 관리자의 의도적 삭제까지 되돌아간다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+| --- | --- |
+| 보충 병합이 저장본에 없는 defaults 항목을 무조건 다시 채움 | 저장 시점에 삭제 의도를 도출해 저장본에 기록하고, 병합이 그 기록을 존중 |
+| 삭제 기록을 클라이언트 제출값에서 받음 | 서버가 `defaults 항목 − 제출 항목 − 삭제 불가 항목` 으로 재계산 (제출된 같은 키는 버린다) |
+| 그 목록 키가 제출되지 않은 저장에서 기록만 이월 | 목록 값과 기록을 **함께** 이월 — 저장이 파일 통째 교체라 목록 키가 사라지면 defaults 가 다시 들어와 삭제가 전부 부활한다 |
+| 항상 살아 있어야 하는 항목(기본 통화 등)까지 기록 대상에 포함 | 그 항목은 기록에서 제외해 보충 경로로 항상 생존시킨다 |
+| 삭제 기록을 공개 응답에 그대로 노출 | `frontend_schema` 의 필드 화이트리스트로 차단 (관리자 응답에는 남겨도 무해) |
+| 기록 배열을 비연속 키로 저장 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
+
+이 결함은 예외도 경고도 로그도 남기지 않는다. 저장 요청은 200 을 반환하고, 그 응답 본문에 이미 삭제한 항목이 되살아나 있다.
+
+보충 병합과 통째 교체를 혼동하지 않도록, 목록 설정을 다룰 때는 다음 세 가지를 구분해 판정한다.
+
+| 모델 | 조회 시 처리 | 화면 | 삭제 의미 |
+| --- | --- | --- | --- |
+| 통째 교체 | 저장본을 그대로 사용 | 항목 추가·삭제 | 삭제가 그대로 유지된다 |
+| 항목 단위 보충 | 저장본에 없는 defaults 항목을 채움 | 항목 추가·삭제 | **삭제 기록이 없으면 되돌아간다** |
+| 카탈로그 병합 | 확장이 등록한 카탈로그와 저장값을 합침 | `is_active` 토글 | 삭제 개념이 없다 (9장 참조) |
+
+---
+
+## 11. 관련 문서
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
- [모듈 라우트](module-routing.md) - API 라우트 규칙
@@ -613,3 +696,4 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
- [ ] 레이아웃에 `data_sources` 추가
- [ ] 저장 버튼 액션 연결
- [ ] `frontend_schema`로 민감정보 필터링 설정
+- [ ] 값을 바꾸는 모든 자리에서 `g7_refresh_module_settings_config()` 호출 (8.4)
diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md
index e26e09df..ec8a2f9e 100644
--- a/docs/extension/storage-driver.md
+++ b/docs/extension/storage-driver.md
@@ -25,6 +25,7 @@
- [마이그레이션 가이드](#마이그레이션-가이드)
- [API 레퍼런스](#api-레퍼런스)
- [트러블슈팅](#트러블슈팅)
+- [S3 호환 스토리지 연결](#s3-호환-스토리지-연결)
- [FAQ](#faq)
---
@@ -866,7 +867,17 @@ public function url(string $category, string $path): ?string
- `$path` (string): 카테고리 하위 상대 경로
**반환값**:
-- `string|null`: 파일 URL (private disk인 경우 null)
+- `string|null`: 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
+
+**직접 URL 판정**: `public` 디스크이거나 `filesystems.disks.{disk}.url` 이 비어 있지 않은
+문자열로 설정된 디스크(S3+CDN 등)면 직접 URL 을 생성합니다. url 설정이 없거나
+빈 문자열/공백이면 null 입니다 (AWS_URL 미설정 방어).
+
+**필터 훅**: 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을
+**항상** 통과합니다 (null 도 발화 대상). 확장은 URL 을 공급(서명 URL 등)/수정(도메인
+교체)/차단(빈 문자열 반환 → 호출측 스트리밍 폴백)할 수 있습니다. 컨텍스트 6키:
+`scope`(core|module|plugin) / `identifier`(확장 식별자, 코어는 null) / `disk` /
+`category` / `path` / `full_path`.
**예시**:
```php
@@ -874,7 +885,11 @@ public function url(string $category, string $path): ?string
$url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg');
// → http://example.com/storage/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg
-// local (private) disk인 경우
+// url 설정 디스크(S3+CDN)인 경우
+$url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg');
+// → https://cdn.example.com/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg
+
+// url 미설정 디스크(local 등)인 경우
$url = $this->storage->url('attachments', 'notice/2024/01/19/file.pdf');
// → null (별도 API 엔드포인트 사용해야 함)
```
@@ -1232,19 +1247,42 @@ class BoardModule extends AbstractModule
- `$this->storage->url()` 호출 시 항상 null이 반환됩니다.
**원인**:
-- private disk (local, s3 등)를 사용 중입니다. URL 메서드는 public disk에서만 직접 URL을 반환합니다.
+- 사용 중인 디스크에 `filesystems.disks.{disk}.url` 설정이 없습니다. url() 은
+ `public` 디스크이거나 url 이 설정된 디스크에서만 직접 URL 을 반환합니다.
+ s3 디스크라도 url(AWS_URL/관리자 S3 URL 설정)이 비어 있으면 null 입니다.
**해결**:
```php
-// public disk를 사용하거나
+// public disk 를 사용하거나
$module->getStorageDisk(); // → 'public'
+// 디스크에 url 을 설정하거나 (config/filesystems.php 또는 관리자 S3 URL 설정)
+'my_cdn' => ['driver' => 's3', ..., 'url' => 'https://cdn.example.com'],
+
// 또는 별도 API 엔드포인트를 사용
Route::get('/api/attachments/{id}/download', [AttachmentController::class, 'download']);
```
---
+## S3 호환 스토리지 연결
+
+코어 7.0.7+ 의 S3 드라이버는 AWS 뿐 아니라 S3 API 를 제공하는 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등)에도 연결할 수 있다. 관리자 > 환경설정 > 드라이버 > 파일 스토리지의 두 항목이 이를 담당한다.
+
+| 항목 | 의미 | 예시 |
+| --- | --- | --- |
+| 엔드포인트 URL | SDK 가 요청을 보내는 API 주소. 비워 두면 AWS 리전 도메인을 사용 | `https://.r2.cloudflarestorage.com`, `http://minio.internal:9000` |
+| Path-style 주소 사용 | 버킷을 호스트가 아닌 경로에 두는 주소 형식(`endpoint/bucket/...`). MinIO 등 path-style 전용 스토리지에서 켠다 | — |
+
+- 리전은 자유 입력이다 — Cloudflare R2 는 `auto`, MinIO 는 관례상 `us-east-1` 을 쓴다.
+- **S3 URL(공개 URL)** 은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며 API 요청 주소가 아니다 — API 주소는 엔드포인트 URL 에만 넣는다.
+- IP 주소 엔드포인트는 SDK 가 path-style 을 자동 적용하므로 토글과 무관하게 동작한다. 호스트명 엔드포인트에서는 토글이 주소 형식을 결정한다.
+- 연결 테스트는 실제 저장 경로와 같은 설정(엔드포인트·path-style 포함)을 사용한다.
+
+`.env` 로 설정하는 경우 대응 키는 `AWS_ENDPOINT` / `AWS_USE_PATH_STYLE_ENDPOINT` 다 (아래 FAQ Q2 의 ② 경로 참조).
+
+---
+
## FAQ
### Q1: 기존 파일은 어떻게 되나요?
@@ -1270,7 +1308,19 @@ if (Storage::disk('local')->exists($oldPath)) {
### Q2: S3로 전환하려면 어떻게 하나요?
-**A**: 다음 3단계만 수행하면 됩니다.
+**A**: 코어 7.0.7+ 는 S3 어댑터(`league/flysystem-aws-s3-v3`)를 기본 포함하므로 별도 패키지 설치가 필요 없습니다. 전환 경로는 두 가지입니다.
+
+**① 관리자 설정 UI (권장)** — 관리자 > 환경설정 > 드라이버 > 파일 스토리지:
+
+1. 스토리지 드라이버를 `Amazon S3` 로 선택
+2. 버킷 / 리전 / Access Key / Secret Key 입력
+3. S3 호환 스토리지(Cloudflare R2, MinIO, NCP 등)는 위 [S3 호환 스토리지 연결](#s3-호환-스토리지-연결) 절의 두 항목(엔드포인트 URL·Path-style 주소)을 함께 설정
+4. 연결 테스트 성공 후 저장 — 코어 첨부 업로드 디스크가 s3 로 전환됩니다
+ (`ATTACHMENT_DISK` env 를 명시한 경우 env 가 항상 우선. 기존 파일은 저장 당시의 disk 로 계속 서빙되므로 혼재 안전)
+
+참고: **S3 URL(공개 URL)** 칸은 파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인이며, API 요청 주소가 아닙니다.
+
+**② `.env` (모듈별 커스텀)**:
1. `.env` 파일에 S3 설정 추가:
```env
@@ -1278,6 +1328,9 @@ if (Storage::disk('local')->exists($oldPath)) {
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=ap-northeast-2
AWS_BUCKET=your-bucket
+ # S3 호환 스토리지(R2/MinIO 등)만:
+ AWS_ENDPOINT=https://.r2.cloudflarestorage.com
+ AWS_USE_PATH_STYLE_ENDPOINT=false
```
2. 모듈 설정 변경:
@@ -1300,11 +1353,18 @@ if (Storage::disk('local')->exists($oldPath)) {
**끝!** Service 코드는 변경할 필요가 없습니다.
+**직접 URL(CDN) 서빙까지 원한다면**: `filesystems.disks.s3.url`(관리자 환경설정의
+S3 URL — CDN 도메인)이 반드시 설정되어야 합니다. url 이 비어 있으면 `url()` 은
+null 을 반환해 스트리밍 경로가 유지됩니다.
+
---
### Q3: 여러 disk를 동시에 사용할 수 있나요?
-**A**: 네, 카테고리별로 다른 disk를 사용할 수 있습니다.
+**A**: 네, `getStorageDiskFor(string $category)` 를 오버라이드하면 카테고리별로 다른
+disk 를 사용할 수 있습니다. `getStorageFor($category)` 가 그 결정에 따라 디스크 단위로
+memoize 된 인스턴스를 돌려주고, ServiceProvider 의 `$storageCategoryServices` 매핑이
+해당 서비스에 카테고리 디스크 스토리지를 자동 주입합니다.
```php
exists($oldPath)) {
namespace Modules\Sirsoft\Ecommerce;
use App\Extension\AbstractModule;
-use App\Contracts\Extension\StorageInterface;
-use App\Extension\Storage\ModuleStorageDriver;
-class EcommerceModule extends AbstractModule
+class Module extends AbstractModule
{
/**
- * 카테고리별 Storage 인스턴스 캐시
+ * 카테고리별 디스크 결정 — 기본 구현은 getStorageDisk() 와 동일.
+ *
+ * 주의: 'settings' 카테고리에서 모듈 설정을 조회하면 설정 로드와 재귀 고리가
+ * 생깁니다. 설정 조회는 설정 저장과 무관한 카테고리에서만 수행하세요.
*/
- private array $storageCache = [];
-
- /**
- * 카테고리별로 다른 Storage를 반환합니다.
- */
- public function getStorage(string $category = 'default'): StorageInterface
+ public function getStorageDiskFor(string $category): string
{
- if (isset($this->storageCache[$category])) {
- return $this->storageCache[$category];
+ if ($category !== 'images') {
+ return $this->getStorageDisk();
}
- // 카테고리별 disk 설정
- $disk = match ($category) {
- 'images' => config('sirsoft-ecommerce.image_disk', 's3'),
- 'attachments' => config('sirsoft-ecommerce.attachment_disk', 'local'),
- default => $this->getStorageDisk(),
- };
+ // 공개 자산 디스크 해석 (확장 개별 설정 > 코어 전역 > 미설정 → 기본 디스크)
+ $override = module_setting('sirsoft-ecommerce', 'basic_info.public_asset_disk', '');
- $this->storageCache[$category] = new ModuleStorageDriver($this->getIdentifier(), $disk);
-
- return $this->storageCache[$category];
+ return $this->resolvePublicAssetDisk(is_string($override) ? $override : '')
+ ?? $this->getStorageDisk();
}
}
```
+```php
+// ServiceProvider — 카테고리 디스크가 필요한 서비스는 $storageCategoryServices 에 매핑
+protected array $storageCategoryServices = [
+ ProductImageService::class => 'images',
+];
+```
+
---
### Q4: 플러그인에서도 사용할 수 있나요?
@@ -1491,6 +1549,80 @@ class AttachmentServiceTest extends TestCase
---
+## 공개 자산 전용 디스크 분리 (직접 URL/CDN 서빙)
+
+완전 공개 자산(상품/카테고리/리뷰/에디터 이미지 등)을 S3+CDN 등 원격 디스크에서
+직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이 100% 보존됩니다.
+
+### 설정 사슬
+
+```text
+관리자 환경설정 > 드라이버 탭 > 공개 자산 스토리지 (drivers.public_asset_disk)
+ → SettingsServiceProvider 가 core.storage.public_asset_disk 로 주입
+ → 확장의 getStorageDiskFor('images') 가 resolvePublicAssetDisk() 로 해석
+ (우선순위: 확장 개별 설정 public_asset_disk > 코어 전역 > 미설정 → 기본 디스크)
+ → ServiceProvider $storageCategoryServices 매핑으로 업로드 서비스에 주입
+ → put() 이 그 디스크에 저장, 행 disk 컬럼에 기록
+ → 모델 download_url accessor 가 행 disk 로 url() 시도 → null 이면 API 경로 폴백
+```
+
+- 선택지 카탈로그는 코어 3종(`none`/`public`/`s3`) + 플러그인이
+ `core.settings.available_public_asset_drivers` 필터 훅으로 등록한 디스크입니다.
+ 플러그인은 자기 ServiceProvider 에서 `Config::set('filesystems.disks.{id}', [... 'url' => CDN])`
+ 로 디스크를 정의하고 훅에 `{id, label, provider}` 를 append 합니다.
+- 확장 개별 오버라이드 화면의 카탈로그는 **그 확장의 설정 조회 응답에 부착**해 내립니다
+ (`available_public_asset_disks` 키 — ecommerce 는 모듈 설정 컨트롤러가, 플러그인은 설정
+ 스키마에 `public_asset_disk` 선언 시 코어 플러그인 설정 API 가 자동 부착). 화면이 코어
+ 환경설정 API 를 교차 조회하면 화면 권한과 카탈로그 권한(core.settings.read)이 갈려
+ 커스텀 역할에서 선택지만 조용히 비게 됩니다.
+- `none` 은 스트리밍 유지(확장 개별 설정에서는 전역이 CDN 이어도 강제 스트리밍)입니다.
+- 플러그인 비활성화로 디스크가 config 에서 사라지면(고아 디스크) 자동으로 스트리밍
+ 폴백합니다 — 저장값은 보존되므로 재활성화 시 되살아납니다.
+
+### 혼재 운용
+
+디스크 전환 시 기존 파일 이동이 필요 없습니다. 행마다 기록된 disk 를 기준으로
+서빙/삭제/이동이 동작하므로, 전환 이전 로컬 행은 스트리밍으로, 이후 원격 행은
+직접 URL 로 각자 올바르게 서빙됩니다.
+
+행에 기록된 disk 가 고아가 된 경우(그 디스크를 제공하던 플러그인을 비활성화한 뒤에도
+그 disk 로 기록된 행이 남아 있는 경우)에도 서빙·삭제는 예외 없이 동작합니다. 행 disk
+기준 스토리지를 만들 때 디스크 설정 존재 여부를 먼저 확인하고, 없으면 확장의 기본
+디스크로 폴백하기 때문입니다. 파일 자체는 도달 불가이므로 그 이미지는 404 가 되지만,
+목록·상세 화면과 삭제(상품/카테고리/리뷰 삭제 포함)는 정상 동작합니다. 이 검증을
+생략하면 미등록 디스크 접근이 예외가 되어 서빙과 삭제가 모두 500 이 됩니다.
+
+### 버킷/CDN 쪽 요구사항
+
+직접 URL 서빙은 브라우저가 그 URL 을 **인증 없이** 읽는다는 전제 위에 있습니다.
+따라서 대상 버킷/배포는 다음을 만족해야 합니다.
+
+| 요구사항 | 미충족 시 증상 |
+| --- | --- |
+| 객체가 익명 읽기 가능 (버킷 정책 `s3:GetObject` 공개 또는 CDN 공개 배포) | 화면의 그 이미지들이 전부 깨짐 (S3 는 `403 AccessDenied` 를 XML 로 응답) |
+| 공개 URL base(`S3 URL`)가 그 객체를 가리킴 | `url()` 이 null → 스트리밍 폴백(기능은 정상, CDN 이점만 없음) |
+
+관리자 화면의 썸네일은 교차 출처 공개 URL 을 `
` 로 직접 사용하므로 CORS 설정은
+필요하지 않습니다. 다만 그 URL 을 자바스크립트로 읽는 커스텀 확장을 만든다면 그때는
+버킷/CDN 에 CORS 규칙이 필요합니다.
+
+교차 출처 URL 에는 코어 API 클라이언트가 인증 토큰을 붙이지 않습니다 — 공개 자산은
+인증이 필요 없고, 세션 토큰이 제3자 origin 으로 나가서는 안 되기 때문입니다.
+같은 이유로 첨부 썸네일·다운로드도 교차 출처 URL 이면 인증 요청 대신 URL 을 직접 씁니다.
+
+### 서명 URL 등 커스텀 URL 공급
+
+`core.storage.filter_url` 필터 훅이 디스크 무관 항상 발화하므로, 확장이 서명 URL 을
+공급하거나 생성된 URL 을 수정/차단할 수 있습니다 (url() 절 참조).
+
+### 보안 전제
+
+권한 검사가 걸린 첨부파일(비밀글/회원 전용 게시판 첨부 등)은 배선 자체가 없습니다 —
+직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만
+공개 자산 디스크를 적용합니다.
+
+---
+
## 관련 문서
- [모듈 개발 가이드](module-basics.md)
diff --git a/docs/extension/template-caching.md b/docs/extension/template-caching.md
index d1fb7579..3349398f 100644
--- a/docs/extension/template-caching.md
+++ b/docs/extension/template-caching.md
@@ -34,23 +34,24 @@
## 캐싱 계층
-### 1. 활성 템플릿 캐시
+### 1. 활성 템플릿 상태 캐시
```php
-// 키: templates.active.{type}
-// TTL: 3600초 (1시간)
-// 값: Template 모델 객체
+// 키: ext.templates.active_identifiers / ext.templates.active_identifiers_{type} / ext.templates.installed_identifiers
+// TTL: extension_status_ttl 설정값 (기본 86400초)
+// 값: 템플릿 식별자 목록 (CachesTemplateStatus 트레이트가 관리)
-Cache::remember("templates.active.admin", 3600, function () {
+$activeIdentifiers = $cache->remember('ext.templates.active_identifiers_admin', function () {
return Template::where('type', 'admin')
->where('is_active', true)
- ->first();
-});
+ ->pluck('identifier')
+ ->all();
+}, $ttl);
```
**무효화 시점**:
-- 템플릿 활성화/비활성화 시
-- 템플릿 삭제 시
+- 템플릿 설치/활성화/비활성화/삭제/업데이트 시 (`TemplateManager::invalidateTemplateStatusCache()` — 상태 키 4종 일괄 삭제)
+- `template:cache-clear` 실행 시
---
@@ -93,22 +94,22 @@ Cache::remember("template.layout.merged.dashboard.ko", 3600, function () use ($l
---
-### 4. 컴포넌트 레지스트리 캐시
+### 4. 컴포넌트 매니페스트 캐시
```php
-// 키: template.components.{identifier}
-// TTL: 무제한 (수동 무효화)
-// 값: components.json 파싱 결과
+// 키: template.{templateId}.components_manifest (숫자 id / identifier 두 변종)
+// TTL: 3600초 (1시간)
+// 값: components.json 파싱 결과 (레이아웃 컴포넌트 검증용)
-Cache::rememberForever("template.components.sirsoft-admin_basic", function () use ($identifier) {
+$manifest = $cache->remember("template.{$templateId}.components_manifest", function () use ($identifier) {
$componentsPath = base_path("templates/{$identifier}/components.json");
return json_decode(file_get_contents($componentsPath), true);
});
```
**무효화 시점**:
-- 템플릿 재설치 시
-- components.json 수정 시 (개발 중)
+- 템플릿 라이프사이클 전 경로 (`TemplateManager::clearTemplateCache()` — update/deactivate/uninstall/`template:cache-clear`)
+- components.json 이 바뀌는 템플릿 업데이트 시 (위 경로에 포함)
---
@@ -326,11 +327,13 @@ use Illuminate\Support\Facades\Cache;
class TemplateCacheService
{
/**
- * 템플릿 활성화 캐시 무효화
+ * 템플릿 상태 캐시 무효화
*/
public function invalidateActiveTemplate(string $type): void
{
- Cache::forget("templates.active.{$type}");
+ // 상태 키(ext.templates.active_identifiers* / installed_identifiers)는
+ // CachesTemplateStatus 가 관리 — 단일 지점에 위임한다
+ TemplateManager::invalidateTemplateStatusCache();
}
/**
@@ -363,11 +366,12 @@ class TemplateCacheService
}
/**
- * 컴포넌트 레지스트리 캐시 무효화
+ * 컴포넌트 매니페스트 캐시 무효화 (숫자 id / identifier 두 변종)
*/
- public function invalidateComponentRegistry(string $identifier): void
+ public function invalidateComponentRegistry(int $templateId, string $identifier): void
{
- Cache::forget("template.components.{$identifier}");
+ Cache::forget("template.{$templateId}.components_manifest");
+ Cache::forget("template.{$identifier}.components_manifest");
}
/**
@@ -387,8 +391,8 @@ class TemplateCacheService
$this->invalidateLayoutChain($layout->layout_name);
}
- // 컴포넌트 레지스트리 캐시 무효화
- $this->invalidateComponentRegistry($identifier);
+ // 컴포넌트 매니페스트 캐시 무효화
+ $this->invalidateComponentRegistry($template->id, $identifier);
}
}
```
@@ -460,7 +464,7 @@ class LayoutService
`LayoutService`의 저장(`updateLayoutContent`) 및 버전 복원(`restoreVersion`) 메서드에서:
1. **`clearDependentLayoutsCache()`** 호출 — 해당 레이아웃 + 이를 extends하는 자식 레이아웃의 병합 캐시 + `PublicLayoutController` 서빙 캐시(`layout.{identifier}.{name}.v{version}`)를 재귀적으로 무효화
-2. **`extension_cache_version` 증가** (`Cache::put('extension_cache_version', time())`) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
+2. **`ext.cache_version` 증가** (`$this->incrementExtensionCacheVersion()` — `ClearsTemplateCaches` 트레이트) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다. 버전 키 쓰기는 반드시 트레이트를 경유합니다 — 캐시 드라이버에 직접 `put` 하면 스토어/네임스페이스가 어긋날 때 bump 가 읽기 경로에서 보이지 않습니다.
```php
// LayoutService::updateLayoutContent() 내부
@@ -469,8 +473,8 @@ $layout = $this->layoutRepository->update($layout->id, $updateData);
// 캐시 무효화 (현재 레이아웃 + 자식 레이아웃 + PublicLayoutController 서빙 캐시)
$this->clearDependentLayoutsCache($templateId, $name);
-// 프론트엔드 브라우저 캐시 무효화
-Cache::put('extension_cache_version', time());
+// 프론트엔드 브라우저 캐시 무효화 (ClearsTemplateCaches 트레이트)
+$this->incrementExtensionCacheVersion();
```
```php
@@ -479,12 +483,12 @@ $newVersion = $this->versionRepository->restoreVersion($layout->id, $versionId);
// 동일하게 캐시 무효화
$this->clearDependentLayoutsCache($templateId, $name);
-Cache::put('extension_cache_version', time());
+$this->incrementExtensionCacheVersion();
```
이 과정이 누락되면 편집/복원 내용이 실제 사이트에 반영되지 않습니다.
-> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제 + `clearPublicServingCache()`(기본 v0 + 현재 extension_cache_version 캐시 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
+> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제(일반 + `.with_source_meta` 편집기 변종) + `clearPublicServingCache()`(현재 `ext.cache_version` 버전 키 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
---
@@ -519,12 +523,31 @@ Cache::put('extension_cache_version', time());
## 캐시 키 요약
-| 캐시 타입 | 키 패턴 | TTL |
-|----------|---------|-----|
-| 활성 템플릿 | `templates.active.{type}` | 3600초 (1시간) |
-| 레이아웃 JSON | `template.layout.{name}.{locale}` | 1800초 (30분) |
-| 병합된 레이아웃 | `template.layout.merged.{name}.{locale}` | 3600초 (1시간) |
-| 컴포넌트 레지스트리 | `template.components.{identifier}` | 무제한 |
+서버 사이드 캐시는 모두 `CacheInterface`(`g7:core:` 네임스페이스) 에 저장됩니다. 무효화 방식은 두 갈래입니다 — **버전 키**(`.v{v}` 접미사)는 `ext.cache_version` bump 로 다음 요청부터 새 키를 쓰고 이전 키는 TTL 로 자연 만료되며, **고정 키**(버전 접미사 없음)는 라이프사이클에서 능동 `forget` 해야 합니다.
+
+| 캐시 타입 | 키 패턴 | TTL | 무효화 |
+|----------|---------|-----|--------|
+| 공개 템플릿 설정 (config.json) | `template.config.{identifier}` | 3600초 | 고정 키 — 라이프사이클 능동 삭제 |
+| 공개 라우트 (routes.json) | `template.routes.{identifier}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 |
+| 공개 다국어 (lang/{locale}.json) | `template.language.{identifier}.{locale}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 |
+| 공개 레이아웃 서빙 | `layout.{identifier}.{name}.v{v}` (+ 편집기 `.meta` 변종) | layout_ttl (기본 3600초) | 버전 키 + 현재 버전 키 능동 삭제 |
+| 병합 레이아웃 (내부) | `template.{templateId}.layout.{name}` (+ `.{sourceHash}` / `.with_source_meta` 변종) | layout_ttl | 고정 키 — 능동 삭제 |
+| 컴포넌트 매니페스트 (검증) | `template.{templateId}.components_manifest` (identifier 변종 포함) | 3600초 | 고정 키 — 능동 삭제 |
+| 템플릿 상태 | `ext.templates.active_identifiers*` / `ext.templates.installed_identifiers` | 86400초 | 라이프사이클 능동 삭제 |
+| 확장 캐시 버전 좌표 | `ext.cache_version` | 없음 | bump 전용 (`ClearsTemplateCaches`) |
+
+### `template:cache-clear` 가 무효화하는 대상
+
+`template:cache-clear {identifier?}` 는 템플릿 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점을 경유합니다.
+
+| 대상 | 단일 모드 | 전체 모드 |
+|------|-----------|-----------|
+| `template.config.{identifier}` | ✅ | ✅ (설치된 전 템플릿) |
+| `template.{id}.components_manifest` (identifier 변종 포함) | ✅ | ✅ |
+| 현재 버전 routes/language 키 | ✅ | ✅ |
+| 병합 레이아웃 캐시 (변종 포함) | ✅ | ✅ |
+| 템플릿 상태 키 + `ext.cache_version` bump | ✅ | ✅ (1회) |
+| 확장 프론트엔드 병합 번들 파일 | — | ✅ |
---
diff --git a/docs/extension/template-commands.md b/docs/extension/template-commands.md
index fe20e496..4b6a681c 100644
--- a/docs/extension/template-commands.md
+++ b/docs/extension/template-commands.md
@@ -79,7 +79,7 @@ php artisan template:activate [vendor-template]
**기능**:
- `templates.is_active` 플래그 설정
- 동일 타입의 기존 활성 템플릿 비활성화
-- 캐시 무효화 (`templates.active.{type}`)
+- 상태 캐시 무효화 (`ext.templates.active_identifiers*` / `ext.templates.installed_identifiers`)
---
diff --git a/docs/frontend/component-props.md b/docs/frontend/component-props.md
index bf9069b3..ab9c010d 100644
--- a/docs/frontend/component-props.md
+++ b/docs/frontend/component-props.md
@@ -367,6 +367,7 @@ export interface DataGridProps {
rowKey?: string; // 행 고유 키 필드 (기본: 'id')
selectable?: boolean; // 행 선택 기능
selectedRows?: any[]; // 선택된 행 배열
+ selectionScope?: 'page' | 'free'; // 선택의 유효 범위 (아래 "선택의 유효 범위" 참조)
loading?: boolean; // 로딩 상태
className?: string; // CSS 클래스
@@ -394,9 +395,43 @@ export interface DataGridProps {
| `rowKey` | `string` | ❌ | `'id'` | 행 고유 키로 사용할 필드명 |
| `selectable` | `boolean` | ❌ | `false` | 체크박스 선택 기능 활성화 |
| `selectedRows` | `any[]` | ❌ | `[]` | 선택된 행 배열 (ID 배열) |
+| `selectionScope` | `'page' \| 'free'` | ❌ | `'free'` | 선택의 유효 범위 (아래 참조) |
| `loading` | `boolean` | ❌ | `false` | 로딩 상태 표시 |
| `className` | `string` | ❌ | - | CSS 클래스 |
+### 선택의 유효 범위 (selectionScope)
+
+체크박스 선택은 컴포넌트 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 실행하면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다 — 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 오류도 남지 않는다.
+
+`onSelectionChange` 를 배선하는 DataGrid 는 둘 중 하나를 반드시 명시한다 (기본값에 기대지 않는다).
+
+| 값 | 동작 | 쓰는 곳 |
+|----|------|--------|
+| `"page"` | 지금 그려진 행에 없는 선택을 버린다. 대상은 언제나 "보이고 체크된 행" | 일괄 처리 버튼이 달린 목록 화면 |
+| `"free"` | 선택을 컴포넌트가 건드리지 않는다 | 선택 자체가 저장 대상인 폼(적용 대상 고르기 등) — 여러 페이지에 걸쳐 고르는 선택기 포함 |
+
+```json
+{
+ "type": "composite",
+ "name": "DataGrid",
+ "props": {
+ "selectable": true,
+ "selectionScope": "page",
+ "selectedIds": "{{_global.selectedIds_admin_users || []}}"
+ },
+ "actions": [
+ {
+ "event": "onSelectionChange",
+ "type": "change",
+ "handler": "setState",
+ "params": { "target": "global", "selectedIds_admin_users": "{{$args[0]}}" }
+ }
+ ]
+}
+```
+
+값은 리터럴로 고정한다. 표현식으로 분기하면 분기마다 보존 여부가 갈려 한쪽이 조용히 대상 밖 행을 싣는다. `selectable` 이 꺼진 화면이라고 안전한 것도 아니다 — 체크박스가 없으면 남은 선택이 더 안 보이므로, 범위 판정은 `selectable` 과 무관하게 적용된다.
+
### SubRow Props (engine-v1.6.0+)
행 아래에 추가 정보를 표시하는 서브 행 기능입니다.
diff --git a/docs/frontend/data-sources.md b/docs/frontend/data-sources.md
index 684aea93..8e280dca 100644
--- a/docs/frontend/data-sources.md
+++ b/docs/frontend/data-sources.md
@@ -373,6 +373,12 @@ auth_mode: "optional" 동작 흐름:
> `fallback`이 정의되어 있으면 fallback 데이터가 렌더링에 사용되고, 없으면 데이터 없이 처리됩니다.
> 이를 통해 비로그인 상태에서 인증 필수 API 호출로 인한 401 → 로그인 리다이렉트 문제를 방지합니다.
+### 라우트가 optional.sanctum 이면 optional 선언 의무
+
+로그인 여부에 따라 응답 범위가 달라지는 서버 라우트(`optional.sanctum` — 통합검색처럼 회원 열람 범위를 해석하는 API)를 소비하는 데이터 소스는 `auth_mode: "optional"` 을 반드시 선언한다.
+
+기본값이 `none` 이므로 미선언 시 로그인 회원의 요청에도 토큰이 실리지 않는다. 이 실패는 오류·경고 없이 "로그인 회원이 비회원 범위 응답을 받는" 형태로만 나타난다 — 서버측 권한 해석이 통째로 무력화되는데 화면·콘솔·네트워크 어디에도 실패 신호가 없다. 서버 라우트의 미들웨어와 데이터 소스의 `auth_mode` 는 한 계약의 양끝이므로, 어느 한쪽을 바꾸면 반대편도 함께 확인한다.
+
### 하위 호환성
`auth_required: true`는 `auth_mode: "required"`와 동일하게 동작합니다.
diff --git a/docs/frontend/security.md b/docs/frontend/security.md
index 30ec0a49..34d9d2f1 100644
--- a/docs/frontend/security.md
+++ b/docs/frontend/security.md
@@ -23,6 +23,7 @@
- [레이아웃 JSON 서버 검증](#레이아웃-json-서버-검증)
- [XSS 방지](#xss-방지)
- [표현식 평가 보안](#표현식-평가-보안)
+- [외부 스크립트 신뢰 출처 허용목록](#외부-스크립트-신뢰-출처-허용목록)
- [인증/토큰 프론트엔드 보안](#인증토큰-프론트엔드-보안)
- [상태 관리 및 데이터 노출 보안](#상태-관리-및-데이터-노출-보안)
- [렌더링 오류 방어](#렌더링-오류-방어)
@@ -126,9 +127,38 @@ HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) **
### 엔진 파서 메커니즘
-템플릿 엔진은 `{{expression}}` 내부를 JavaScript `new Function()` 기반으로 평가합니다.
+템플릿 엔진은 `{{expression}}` 내부를 **화이트리스트 AST 평가기**(`SafeExpressionEvaluator`)로 해석합니다. `new Function()`·`with(ctx)` 를 사용하지 않으므로, `''.constructor.constructor('code')()` 같은 프로토타입 체인 우회로 임의 코드를 실행할 수 없습니다.
-**보안 전제**: 레이아웃 JSON은 서버에서 4단계 Custom Rule 검증을 거쳐 저장되므로, 악의적 표현식이 포함될 가능성은 서버 검증으로 사전 차단됩니다. `new Function()`은 관리자가 작성한 검증된 표현식만 실행합니다.
+평가기는 프로퍼티/옵셔널체이닝 접근, 산술·비교·논리·삼항·nullish, 배열/객체/문자열 리터럴, 화살표 함수·템플릿 리터럴·스프레드, 그리고 화이트리스트 전역(`Math`/`JSON`/`Date`/`Array`/`Object`/`Number`/`String` 등)만 허용합니다. `constructor`/`__proto__`/`prototype` 프로퍼티 접근, `Function(`/`eval(`/`import(` 는 파싱·평가 양쪽에서 거부됩니다.
+
+### 표현식 샌드박스 우회 토큰
+
+레이아웃 표현식 문자열에는 다음 토큰을 넣지 않습니다 — 저장 시점 검증과 정적 검사가 함께 차단합니다.
+
+| 차단 대상 | 이유 |
+|----------|------|
+| `.constructor` / `['constructor']` | `Function` 도달 경로 (프로토타입 체인 우회) |
+| `.__proto__` / `__proto__` | 프로토타입 오염/우회 |
+| `.prototype` | 프로토타입 체인 접근 |
+| `Function(` / `eval(` | 함수 생성·임의 코드 실행 |
+| `import(` | 동적 모듈 로드·원격 코드 실행 |
+
+화살표 함수(`=>`)와 템플릿 리터럴(백틱)은 정상 표현식에서 널리 쓰이므로 차단하지 않습니다 — 평가기가 인터프리터로 안전하게 해석합니다.
+
+### 레이아웃 밖에서 저장되는 표현식
+
+표현식을 평가하는 것은 레이아웃 JSON 만이 아닙니다. 커스텀 번역 문구, 알림 템플릿, 본인인증 메시지 템플릿처럼 **레이아웃보다 낮은 권한으로 저장되는 콘텐츠**도 최종적으로 같은 엔진 평가 경로(`DataBindingEngine.evaluateExpression`)에 도달합니다.
+
+이 경로의 방어는 **런타임 평가기 한 겹**입니다.
+
+| 계층 | 레이아웃 JSON | 레이아웃 밖 편집 콘텐츠 |
+|------|--------------|----------------------|
+| 저장 시점 위험 토큰 검증 | 적용 | **미적용** (레이아웃 스키마가 아니므로 레이아웃 검증 규칙의 대상이 아님) |
+| 런타임 AST 화이트리스트 평가 | 적용 | **적용** |
+
+저장측 규칙을 이 콘텐츠까지 넓히지 않는 이유는, 그 규칙이 레이아웃 트리 구조(`components`/`computed`/`scripts`/`data_sources`)를 전제로 순회하기 때문입니다. 자유 텍스트에 붙이면 정상 문구의 오탐과 검증 누수가 동시에 생깁니다. 방어의 본질은 화이트리스트 평가기이고, 저장측 토큰 검증은 레이아웃에 한정된 보조 방어입니다.
+
+새로 표현식을 평가하는 저장 경로를 추가할 때는 그 값이 반드시 `SafeExpressionEvaluator` 를 거치게 하고, 자체 평가기(`new Function`·`eval`)를 두지 않습니다.
### 안전한 데이터 접근 (필수)
@@ -169,6 +199,52 @@ HTML을 렌더링해야 하는 경우 (게시판 본문, 상품 설명 등) **
---
+## 외부 스크립트 신뢰 출처 허용목록
+
+레이아웃의 `scripts[].src` 와 `data_sources[].endpoint` 는 기본적으로 **same-origin 절대 경로**(`/` 로 시작)만 허용합니다. `//`(protocol-relative)·scheme 포함 외부 URL 은 원격 코드 로드 경로이므로 런타임 스크립트 로더가 차단합니다.
+
+일부 확장은 외부 CDN 스크립트를 정당하게 사용합니다(예: CKEditor5 → `cdn.ckeditor.com`, Daum 우편번호 → `t1.daumcdn.net`). 이런 확장은 자신의 manifest 에 신뢰 호스트를 **선언**하고, 코어가 활성 확장 전수에서 이 목록을 집계해 `window.G7Config.trustedScriptHosts` 로 노출합니다. 런타임 로더·저장측 검증·정적 검사는 모두 이 목록에 속한 호스트만 예외로 허용합니다.
+
+```json
+// 확장 manifest (module.json / plugin.json / template.json)
+{
+ "trusted_script_hosts": ["cdn.ckeditor.com"]
+}
+```
+
+| 입력 자리 | 허용 판정 |
+|----------|----------|
+| 편집기로 저장하는 레이아웃 | same-origin 경로 + 신뢰 호스트만 (임의 외부 origin 차단) |
+| 확장이 커밋한 레이아웃 파일 | 확장이 선언한 신뢰 호스트 허용 |
+| 미선언 외부 origin | 항상 차단 (예외도 경고 토스트도 없이 skip) |
+
+신뢰 경계: 신뢰 호스트로 허용되는 것은 **확장이 코드로 선언한 호스트**뿐이며, 편집기 저장분에 임의의 원격 스크립트를 넣을 수는 없습니다. 새 CDN 을 쓰려면 그 확장 manifest 의 `trusted_script_hosts` 에 호스트를 추가해야 합니다.
+
+### same-origin 판정은 브라우저 URL 파서와 같아야 한다
+
+`//` 로 시작하는지, scheme 이 있는지, `/` 로 시작하는지만 문자열로 확인하는 판정은 **authority 우회를 막지 못합니다.** 브라우저(WHATWG URL)는 파싱 전에 ASCII tab·개행을 제거하고, http/https 에서 백슬래시를 슬래시와 동등하게 처리하기 때문입니다.
+
+| 입력 | 문자열 접두 검사 | 브라우저 해석 |
+|------|----------------|--------------|
+| `/api/widget.js` | same-origin | `https://내도메인/api/widget.js` (same-origin) |
+| `//evil.com/x.js` | 차단 | `https://evil.com/x.js` |
+| `/\/evil.com/x.js` | **same-origin 으로 오판** | `https://evil.com/x.js` |
+| `/\evil.com/x.js` | **same-origin 으로 오판** | `https://evil.com/x.js` |
+| `/{tab}/evil.com/x.js` | **same-origin 으로 오판** | `https://evil.com/x.js` |
+| `/\/cdn.신뢰.com/x.js` | **차단으로 오판** | `https://cdn.신뢰.com/x.js` (신뢰 출처 — 차단하면 과차단) |
+| `///evil.com/x.js` | 차단(호스트 추출 실패) | `https://evil.com/x.js` |
+| `/js/a\b.js` | same-origin | `https://내도메인/js/a/b.js` (same-origin — 차단하면 과차단) |
+
+판정 전에 **tab·LF·CR 를 제거하고, 백슬래시를 슬래시로 바꾸고, 선행 슬래시 런을 접은** 뒤 접두 검사를 적용합니다. 브라우저는 선행 슬래시가 몇 개든 authority 시작으로 접습니다(`///host` ≡ `//host`, `https:///host` ≡ `https://host`). 경로 중간의 백슬래시·탭·연속 슬래시는 authority 를 만들지 않으므로 그대로 통과합니다.
+
+이 정규화는 런타임 로더·저장측 검증·정적 검사 **세 계층이 공유**해야 합니다. 세 계층이 같은 판정 로직을 쓰므로, 한쪽만 고치면 나머지가 우회로로 남고 반대로 한 형태로 셋이 함께 뚫립니다. 새 URL 검증 지점을 추가할 때 접두 검사를 직접 작성하지 말고 기존 정규화를 경유하세요.
+
+**same-origin 판정과 신뢰 출처 판정도 같은 정규화를 씁니다.** 두 판정은 한 조건문에서 이어집니다("내 사이트 경로인가, 아니면 신뢰 출처인가"). 한쪽만 정규화하면 신뢰 출처 이름을 userinfo 자리에 끼워 넣은 주소(`https://evil.com\@cdn.신뢰.com/x.js`)가 저장 단계에서만 신뢰 출처로 보여 통과하고, 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 과차단도 생깁니다. 호스트 추출은 반드시 정규화를 경유하세요.
+
+> manifest 필드 스펙(값 형식·`g7_version` 제약·모듈/플러그인/템플릿 공통)은 [extension/module-assets.md](../extension/module-assets.md#trusted_script_hosts--외부-스크립트-신뢰-호스트) 참조.
+
+---
+
## 인증/토큰 프론트엔드 보안
### 인증 원칙
diff --git a/docs/requirements.md b/docs/requirements.md
index 9cfd6a97..68bf1032 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -7,9 +7,9 @@
```text
1. PHP 8.2+ 필수
2. MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4, utf8mb4_unicode_ci)
-3. PHP 필수 모듈 30개 (ctype, curl, gd, intl, redis, imagick 등)
-4. 하드웨어: 최소 2 vCPU·2GB / 권장 4 vCPU·8GB, 디스크 최소 700MB / 권장 2GB+
-5. 프로덕션: HTTPS 필수, Redis 권장, 큐 워커/스케줄러/Reverb 데몬 필요
+3. PHP 확장: 필수 16개 + 기능별 선택 17개 (설치 단계 자동 검사는 그중 14개)
+4. 하드웨어: 최소 2 vCPU·2GB / 권장 4 vCPU·8GB, 디스크: 설치 검사 기준 500MB, 권장 2GB+
+5. 프로덕션: HTTPS 필수(설치는 차단하지 않고 경고), Redis 권장, 큐 워커/스케줄러/Reverb 데몬 필요
```
---
@@ -21,6 +21,7 @@
| OS | 지원 수준 | 비고 |
|----|----------|------|
| Linux (Ubuntu 22.04+, CentOS/RHEL 8+) | 프로덕션 권장 | 가장 안정적 |
+| Windows | 개발 환경 지원 | XAMPP/Laragon 등 (§1.3.1, [INSTALL.md](../INSTALL.md) 참조) |
### 1.2 웹서버
@@ -34,10 +35,13 @@
### 1.3 PHP
-| 항목 | 요구사항 |
-|------|-------------------------------------------------------------|
-| 버전 | **8.2 이상** (`^8.2`) |
-| SAPI | FPM (권장) 또는 mod_php (Apache + mod_fcgid 환경은 §1.3.1) |
+| 항목 | 요구사항 |
+|------|---------|
+| 버전 | **8.2 이상** (`^8.2`) |
+| SAPI | FPM (권장) 또는 mod_php (Apache + mod_fcgid 환경은 §1.3.1) |
+| `disable_functions` | `exec`·`proc_open`·`shell_exec` 허용 필요 (설치·코어 업데이트가 자식 프로세스를 실행) |
+| 웹 ↔ CLI 버전 | 메이저·마이너 버전 일치 권장 (설치 프로그램이 확인 후 불일치 시 안내) |
+| OPcache | 활성화 권장 (성능 — 미활성화 시 설치는 진행되고 안내만 표시) |
#### 1.3.1 Apache + mod_fcgid 환경 추가 설정 (권장)
@@ -53,43 +57,52 @@ FcgidOutputBufferSize 0
- 인스톨러 SSE 호환성 사전 체크가 buffered 환경으로 자동 판정되어 폴링 모드로 fallback
- 폴링 모드 응답은 코드 레벨 64KB padding 워크어라운드로 동작은 보장되나, SSE 모드가 아닌 1초 간격 폴링으로 진행 상황이 표시됨
-### 1.4 PHP 필수 확장 모듈
+### 1.4 PHP 확장 모듈
-| 모듈 | 용도 |
-|------|------|
-| `bcmath` | 정밀 수학 연산 (가격 계산 등) |
-| `ctype` | 문자 타입 검사 |
-| `curl` | HTTP 클라이언트 |
-| `dom` | XML/HTML DOM 처리 |
-| `exif` | 이미지 메타데이터 읽기 |
-| `fileinfo` | MIME 타입 감지 |
-| `filter` | 데이터 필터링/검증 |
-| `gd` | 이미지 처리 (썸네일, 리사이징) |
-| `hash` | 해시 함수 |
-| `imagick` | 고급 이미지 처리 (ImageMagick) |
-| `intl` | 국제화 (다국어, 날짜/숫자 포맷) |
-| `json` | JSON 인코딩/디코딩 |
-| `ldap` | LDAP 인증 연동 |
-| `libxml` | XML 파싱 기반 라이브러리 |
-| `maxminddb` | GeoIP 데이터베이스 조회 |
-| `mbstring` | 멀티바이트 문자열 처리 |
-| `memcached` | Memcached 캐시 드라이버 |
-| `openssl` | 암호화/복호화 (AES-256-CBC) |
-| `pcntl` | 프로세스 제어 (큐 워커) |
-| `pcre` | 정규 표현식 |
-| `pdo` | 데이터베이스 추상화 |
-| `pdo_mysql` | MySQL/MariaDB PDO 드라이버 |
-| `phar` | Phar 아카이브 (Composer) |
-| `posix` | POSIX 함수 (프로세스 관리) |
-| `redis` | Redis 캐시/세션/큐 드라이버 |
-| `session` | 세션 관리 |
-| `simplexml` | 간편 XML 파싱 |
-| `sodium` | 최신 암호화 라이브러리 |
-| `tokenizer` | PHP 토큰 파싱 |
-| `xml` | XML 파서 |
-| `xmlwriter` | XML 문서 생성 |
-| `zip` | ZIP 압축/해제 |
-| `zlib` | 데이터 압축 |
+필수 16개 + 기능별 선택 17개.
+
+| 구분 | 모듈 | 용도 |
+|------|------|------|
+| 필수 | `ctype` | 문자 타입 검사 |
+| 필수 | `curl` | HTTP 클라이언트 |
+| 필수 | `dom` | XML/HTML DOM 처리 (메일 본문 인라인 CSS 등) |
+| 필수 | `fileinfo` | MIME 타입 감지 |
+| 필수 | `filter` | 데이터 필터링/검증 |
+| 필수 | `hash` | 해시 함수 |
+| 필수 | `json` | JSON 인코딩/디코딩 |
+| 필수 | `mbstring` | 멀티바이트 문자열 처리 |
+| 필수 | `openssl` | 암호화/복호화 (AES-256-CBC) |
+| 필수 | `pcre` | 정규 표현식 |
+| 필수 | `pdo` | 데이터베이스 추상화 |
+| 필수 | `pdo_mysql` | MySQL/MariaDB PDO 드라이버 |
+| 필수 | `session` | 세션 관리 |
+| 필수 | `tokenizer` | PHP 토큰 파싱 |
+| 필수 | `xml` | XML 파서 |
+| 필수 | `zip` | 확장·언어팩·코어 업데이트 패키지의 압축 해제 |
+| 선택 | `gd` | 이미지 처리 (썸네일, 리사이징) — `imagick` 과 택일 |
+| 선택 | `imagick` | 고급 이미지 처리 (ImageMagick) — `gd` 와 택일 |
+| 선택 | `exif` | 이미지 메타데이터 읽기 |
+| 선택 | `intl` | 국제화 (다국어 날짜/숫자 포맷) |
+| 선택 | `bcmath` | 정밀 수학 연산 (금액 계산 등) |
+| 선택 | `redis` | Redis 캐시/세션/큐 드라이버 (`predis` 동봉 — 확장 없이도 사용 가능) |
+| 선택 | `memcached` | Memcached 캐시 드라이버 |
+| 선택 | `pcntl` | 큐 워커 전체 기능 및 콘솔 시그널 처리 |
+| 선택 | `posix` | 큐 워커 전체 기능 및 코어 업데이트의 파일 소유자 확인 |
+| 선택 | `maxminddb` | GeoIP 데이터베이스 조회 (접속 국가·타임존 감지) |
+| 선택 | `simplexml` | AWS SDK(S3/SES/SQS) 응답 파싱 |
+| 선택 | `libxml` | XML 파싱 기반 라이브러리 (`dom`·`simplexml` 의 토대) |
+| 선택 | `sodium` | 최신 암호화 라이브러리 |
+| 선택 | `phar` | Phar 아카이브 (Composer 실행) |
+| 선택 | `xmlwriter` | XML 문서 생성 |
+| 선택 | `zlib` | 데이터 압축 (gzip 응답 압축) |
+| 선택 | `ldap` | LDAP 연동 확장을 사용할 때 (코어 기본 기능은 사용하지 않음) |
+
+설치 프로그램은 위 필수 확장 중 14개(`pdo`, `mbstring`, `openssl`, `tokenizer`, `xml`,
+`ctype`, `json`, `fileinfo`, `curl`, `dom`, `filter`, `hash`, `pcre`, `session`)를 설치
+단계에서 자동 검사하고 하나라도 없으면 진행을 막는다. `pdo_mysql` 과 `zip` 은 자동 검사
+대상이 아니지만 각각 데이터베이스 접속과 확장·언어팩 설치에 반드시 필요하므로 필수로
+분류한다. 선택 확장은 해당 기능을 사용하는 시점에 필요하며, 자동 검사 항목과 운영자가
+직접 확인해야 하는 항목의 전체 구분은 §9 를 참조한다.
### 1.5 PHP 설정 권장값 (php.ini)
@@ -150,6 +163,17 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
인스톨러의 기본 안내 명령은 보수적으로 `chmod -R 755` 를 제시한다. 방식 A 로 운영하려면 인스톨 완료 후 `775` 로 재조정 + 소유자/그룹을 본인 계정 + `www-data` 로 변경. 인스톨러는 `chmod` 를 직접 호출하지 않으므로 운영자가 쉘에서 1회 실행.
+### 1.7 개발/빌드 도구
+
+| 도구 | 버전 | 필요한 경우 |
+|------|------|------------|
+| Node.js | 20 이상 | 프론트엔드 소스를 직접 빌드할 때 |
+| Composer | 2.x | Composer 로 의존성을 설치할 때, 확장이 외부 패키지를 요구할 때 |
+
+배포본에는 빌드 산출물과 `vendor` 디렉토리가 동봉되어 있으므로 **일반적인 운영 설치에는
+두 도구 모두 필요하지 않다.** 소스에서 직접 빌드하거나 개발 환경을 구성할 때, 또는
+Composer 설치 방식을 선택할 때만 필요하다.
+
---
## 2. 데이터베이스
@@ -166,8 +190,8 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
| 항목 | 값 | 비고 |
|------|-----|------|
| charset | `utf8mb4` | 이모지 등 4바이트 문자 지원 |
-| collation | `utf8mb4_unicode_ci` | 유니코드 정렬 |
-| 테이블 접두어 | `g7_` (기본값) | `.env`에서 `DB_PREFIX`로 변경 가능 |
+| collation | `utf8mb4_unicode_ci` | 기본값이며 `.env`의 `DB_COLLATION`으로 변경 가능 |
+| 테이블 접두어 | `g7_` (기본값) | `.env`에서 `DB_PREFIX`로 변경 가능 (최대 6자 — 자동 생성 인덱스명이 MySQL 식별자 한도 64자를 넘지 않도록 제한) |
**선택 기능**:
- Write/Read 분리: Master-Replica 구성 지원 (`DB_WRITE_*` / `DB_READ_*` 환경 변수)
@@ -210,7 +234,7 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
| 수준 | 용량 | 포함 범위 |
|------|------|----------|
-| 최소 | **700MB** | 코어 + 기본 확장 |
+| 최소 | **500MB** (설치 프로그램 검사 기준) | 설치가 진행되기 위한 여유 공간의 하한. 코어 + 기본 확장의 실사용은 약 700MB 이므로 여유 확보 권장 |
| 권장 | **2GB 이상** | 코어 + 확장 + 첨부파일 + 캐시 + 로그 |
- 사용자 업로드 파일, 로그, 캐시 등은 별도 용량 산정 필요
@@ -237,7 +261,8 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
| AWS SES | 이메일 발송 | 선택 |
| AWS SQS | 큐 처리 (대규모 트래픽) | 선택 |
-- `aws/aws-sdk-php` 패키지 포함됨
+- `aws/aws-sdk-php` · `league/flysystem-aws-s3-v3` 패키지 포함됨 (S3 및 S3 호환 스토리지 — Cloudflare R2, MinIO 등 — 를 별도 설치 없이 사용 가능)
+- `predis/predis` 패키지 포함됨 (phpredis PHP 확장이 없는 서버에서도 Redis 드라이버 사용 가능)
### 4.3 메일 서비스
@@ -269,6 +294,8 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
|------|---------|
| 프로덕션 | **HTTPS 필수** |
+- 설치 단계에서는 HTTPS 여부를 경고로 안내하며 설치를 차단하지 않는다. 정책상 필수라는
+ 선언은 유지하되, 자동으로 강제되지는 않으므로 운영자가 직접 확인해야 한다
- Laravel Reverb WebSocket도 `wss://` 프로토콜 사용 (`REVERB_SCHEME=https`)
- Sanctum 세션 인증 시 `SESSION_SECURE_COOKIE=true` 설정 권장
@@ -293,13 +320,17 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
## 7. 지원 브라우저
-| 브라우저 | 지원 범위 |
-|---------|----------|
-| Chrome / Edge | 최신 2개 버전 |
-| Firefox | 최신 2개 버전 |
-| Safari | 최신 2개 버전 |
+| 브라우저 | 지원 기준 | 검증 방식 |
+|---------|----------|----------|
+| Chrome / Edge | React 19 + Tailwind CSS 4 호환 범위 | 자동 브라우저 테스트(Chromium)로 상시 검증 |
+| Firefox | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님) |
+| Safari | React 19 + Tailwind CSS 4 호환 범위 | 호환 범위 기준 지원 (상시 자동 테스트 대상 아님, 실기기 검증 미수행) |
-- React 19 + Tailwind CSS 4 호환 범위 기준
+- 지원 하한은 프론트엔드 핵심 의존성(React 19, Tailwind CSS 4)의 공식 지원 브라우저
+ 범위를 따르며, 해당 의존성 버전이 변경되면 이 기준도 함께 재검토한다
+- 참고: 2026-08 현재 Tailwind CSS 4 의 공식 하한은 Chrome 111 / Safari 16.4 / Firefox 128 이다
+- 자동 테스트는 테스트 도구에 포함된 단일 브라우저 빌드로 수행하므로 특정 버전
+ 목록(예: "최신 N개 버전")을 상시 보장하지 않는다
- Internet Explorer 미지원
---
@@ -321,3 +352,44 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
| 커스텀 포트 (80/443 외) | Reverb WebSocket (기본 8080 포트) | Pusher 등 외부 WebSocket 서비스 |
| 파일 권한 (`symlink` 등) | `storage/` 심볼릭 링크 | `php artisan storage:link` 대체 방식 확인 |
| 디스크 용량 | 첨부파일, 로그, 캐시 누적 | 플랜별 용량 확인, 정기 정리 |
+
+---
+
+## 9. 요구사항 검증 수준
+
+이 문서의 항목은 자동으로 확인되는 것과 운영자가 직접 확인해야 하는 것으로 나뉜다.
+"요구사항" 이라는 표현이 곧 "설치 프로그램이 검사한다" 를 뜻하지는 않는다.
+
+### 9.1 설치 프로그램이 자동 검사
+
+| 항목 | 기준 | 미충족 시 |
+|------|------|----------|
+| PHP 버전 | 8.2 이상 | 설치 차단 |
+| PHP 확장 | §1.4 필수 확장 중 14개 | 설치 차단 |
+| 차단 함수 | `exec`·`proc_open`·`shell_exec` 사용 가능 | 설치 차단 |
+| 디스크 여유 공간 | 500MB | 설치 차단 |
+| 디렉토리 쓰기 권한 | `storage/`, `bootstrap/cache`, `vendor`, 확장 디렉토리 등 | 설치 차단 |
+| `.env` 파일 | 존재 + 쓰기 가능 | 설치 차단 |
+| 데이터베이스 접속 | 접속 성공 여부 | 다음 단계 진행 불가 |
+| DB 테이블 접두어 | 최대 6자 | 입력 거부 |
+| 웹 ↔ CLI PHP 버전 | 메이저·마이너 일치 | 안내만 (설치 진행) |
+| OPcache | 활성화 여부 | 안내만 (설치 진행) |
+| HTTPS | 사용 여부 | 안내만 (설치 진행) |
+
+`gd`·`imagick`·`redis`·`intl`·`zlib` 은 설치 단계에서 상태를 표시하지만 설치를 막지 않는다.
+
+### 9.2 운영자 확인 필요 — 자동 검사 없음
+
+| 항목 | 문서상의 기준 | 확인 방법 |
+|------|--------------|----------|
+| 데이터베이스 버전 | MySQL 8.0+ / MariaDB 10.3+ | `SELECT VERSION()` |
+| DB charset·collation | `utf8mb4` / `utf8mb4_unicode_ci` | 접속 후 스키마 확인 |
+| php.ini 값 | §1.5 의 5개 항목 | `php -i` 또는 관리자 정보 화면 |
+| CPU·메모리 | §3.1 | 서버 사양 확인 |
+| 웹서버 종류·버전 | Nginx 1.18+ / Apache 2.4+ | 서버 설정 확인 |
+| `mod_rewrite` | Apache 사용 시 활성화 | 서버 설정 확인 |
+| Redis 버전 | 6.0 이상 | `redis-server --version` |
+| Node.js·Composer 버전 | §1.7 | 소스 빌드·Composer 설치를 할 때만 해당 |
+| 지원 브라우저 | §7 | 자동 브라우저 테스트는 Chromium 만 상시 수행 |
+| 데몬 프로세스 가동 | §6 의 3종 | 프로세스 관리 도구에서 확인 |
+| §1.4 의 선택 확장 | 사용하는 기능에 따라 | 관리자 정보 화면의 확장 목록 |
diff --git a/docs/testing-guide.md b/docs/testing-guide.md
index c57b6cf4..6720b766 100644
--- a/docs/testing-guide.md
+++ b/docs/testing-guide.md
@@ -341,6 +341,28 @@ TypeScript 테스트는 `// @scenario` / `// @effects` 주석 동일 사용.
정적 검사가 매니페스트 cross product 와 effects 항목을 테스트 docblock 마킹과 대조하여 누락을 검출한다 (자동 차단).
+#### 항목 구분자는 쉼표뿐이다
+
+마커 파서는 축과 effects 를 **쉼표로만** 분리한다. 요약을 적을 때 흔히 쓰는 `×`(곱)나 `+`(더하기)를 구분자로 두면 여러 항목이 **한 문자열로 뭉쳐** 등록된다.
+
+| ❌ 금지 | 실제 등록 결과 |
+| --- | --- |
+| `@scenario feat a=1 × b=2` | 축 1개 `{a: "1 × b=2"}` — 실재하지 않는 조합 |
+| `@effects x + y + z` | 효과 1개 `"x + y + z"` — x·y·z 는 미등록 |
+| `@scenario a + b + c` (`=` 없음) | 빈 조합 `{}` — 아무것도 커버하지 않음 |
+
+그렇게 만들어진 마커는 **어떤 조합도 커버하지 못하는 죽은 마커**다. 게다가 cross product 대조는 "매니페스트가 요구하는 항목이 마커에 있는가" 만 보고 마커 쪽에 생긴 쓰레기 항목은 무시하므로, 구분자를 틀려도 전 게이트가 조용히 통과한다 — 실제로 36건이 그렇게 쌓였다.
+
+항목이 여럿이면 `, ` 로 적고, 파일/클래스 레벨의 **요약**이라면 마커 토큰을 걷어내 평문으로 내린다. 파일 레벨에 effects 목록을 몰아 적으면 그 메서드가 하나도 없어도 "언급됨" 으로 집계되어 커버리지가 부풀고, 메서드 삭제가 무증상 green 이 된다 — 마커는 test 에만 둔다.
+
+```php
+/**
+ * 축 요약(마커 아님 — 평문): actor, operation, outcome.
+ */
+```
+
+구분자 형식은 정적 검사가 강제한다 (자동 차단).
+
### cross product 폭발 관리
축이 많아 cross product 가 비현실적으로 커질 때:
diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md
index 06290ada..bd099c88 100644
--- a/docs/testing/e2e-testing.md
+++ b/docs/testing/e2e-testing.md
@@ -11,6 +11,7 @@
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
- 편집기 저장 spec: 제품 화면 대신 전용 시드 화면(e2e_sandbox) 대상 — globalSetup 이 매 실행 원본으로 덮어씀 (§4.1)
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
+- 브라우저: 일상 E2E 는 Chromium 전용 — 확대 조건은 §3 브라우저 범위
```
## §1. 도구 선택 정당화
@@ -113,6 +114,35 @@ spec 이 전부 실패한다.
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 |
+### 브라우저 범위
+
+일상 E2E 는 Chromium 전용이 정책이다. 코어 config 와 확장 config 모두 `projects` 에
+chromium 하나만 두며, `npx playwright install chromium` 만 안내하는 것(§2)이 곧 이 정책이다.
+문서상의 지원 브라우저 선언(`docs/requirements.md` §7)은 이 자동 검증 범위와 구분해서 읽는다 —
+Firefox/Safari 는 프론트엔드 의존성(React 19, Tailwind CSS 4)의 호환 범위 기준 지원이며
+상시 자동 테스트 대상이 아니다.
+
+이 정책의 근거는 위험 표면의 위치다. 엔진과 레이아웃에는 브라우저별 분기 코드가 없어 JS
+레벨의 브라우저 편차 위험이 낮고, 실질 위험은 CSS 렌더링과 contenteditable(위지윅) 거동에
+몰려 있다. 그 두 축은 브라우저 프로젝트를 늘리는 것보다 해당 spec 을 두껍게 쓰는 편이
+비용 대비 회수가 크다.
+
+Firefox/WebKit 프로젝트를 상시 스위트에 넣지 않은 이유:
+
+- 프로젝트 간 병렬 실행이 공유 서버 상태를 경합시킨다 — 시드 화면 PUT 저장(§4.1)과 관리자
+ 설정 저장 spec 은 같은 레코드를 건드리므로 브라우저별 실행이 서로의 저장을 덮어쓴다.
+- 시나리오 매니페스트에 브라우저 축을 추가하면 cross product 가 브라우저 수만큼 배가된다.
+- 총 실행 시간이 프로젝트 수에 비례해 늘어, 개발 머신의 워커 예산(위 "실행 시 유의")과
+ 정면으로 충돌한다.
+
+향후 도입할 경우 아래 4가지를 조건으로 한다:
+
+- 상시 스위트가 아니라 **별도 opt-in 스위트**로만 실행한다 (기본 실행 대상에 넣지 않는다).
+- 대상은 `@smoke` 로 한정한다 — 레이아웃 편집기 저장 spec 을 배제해 시드 화면 경합을 피한다.
+- `--workers=1` 로 고정한다.
+- CDP 를 쓰는 spec(`specs/smoke/localinit-multi-progressive-datasource.spec.ts` 의
+ `newCDPSession`)은 `test.skip(browserName !== 'chromium')` 로 제외한다.
+
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index 5e2c39ae..7caf1bb9 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,35 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.6] - 2026-08-19
+
+### Changed
+
+- 회원 관리(생성·수정·삭제·탈퇴) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 재시도 안내 문구로 갱신했습니다 (`user.create_failed`, `user.update_failed`, `user.delete_failed`, `user.withdraw_failed`).
+- S3 리전 검증 메시지를 목록 선택 안내에서 형식 안내(소문자 영숫자·하이픈, R2 는 `auto`)로 개정했습니다.
+
+### Added
+
+- 검색엔진 드라이버 라벨 일본어 번역을 추가했습니다 (`settings.drivers.search.*`) — 환경설정 > 드라이버 탭의 검색엔진 선택지가 일본어 로케일에서 표시됩니다.
+- S3 호환 스토리지 설정 신설 항목(엔드포인트 URL·Path-style 주소)과 리전 형식 안내의 검증 메시지 일본어 번역 추가 (`validation.settings.s3_endpoint_*`, `s3_use_path_style_boolean`, `s3_region_*`) — 드라이버 설정 저장 시 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+- 드라이버 사용 불능 안내 문구 일본어 번역 추가 (`validation.settings.driver_unusable`, `settings.driver_unusable_*`, `settings.s3_adapter_missing`) — 필요한 라이브러리·PHP 확장이 없는 드라이버를 저장·테스트할 때의 사유 안내가 일본어 로케일에서 표시됩니다.
+- 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`).
+- 공개 자산 스토리지(공개 이미지 직접 URL 서빙) 설정의 드라이버 라벨과 검증 메시지 일본어 번역을 추가했습니다. 환경설정 > 드라이버 탭의 새 설정이 일본어 로케일에서 자연스럽게 표시됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
+- 설정 항목 단위 저장의 값 형식 안내 일본어 번역을 추가했습니다 (`validation.setting.value.*`) — 켜기/끄기·숫자 설정에 맞지 않는 값을 저장할 때의 안내가 일본어 로케일에서 표시됩니다.
+- 슈퍼 관리자 계정·역할 수정 상한 및 권한 부여 상한 위반 시 표시되는 예외 메시지(`cannot_modify_super_admin`, `cannot_grant_unheld_permission`, `cannot_modify_protected_role`)의 일본어 번역을 추가했습니다.
+- 레이아웃 저장 시 위험 표현식·외부 리소스 URL 거부 안내(`layout.dangerous_expression`, `layout.external_resource_url`)의 일본어 번역을 추가했습니다.
+- 고아 첨부 정리 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`validation.settings.orphan_*`) — 환경설정 > 업로드의 자동 정리·보존기간을 잘못 저장할 때의 안내가 일본어 로케일에서 항목 이름과 함께 표시됩니다.
+- 데이터베이스 백업 미제공 안내 문구의 일본어 번역을 추가했습니다 (`settings.database_backup_unavailable`).
+- 셸 예약 작업 허용목록에 인터프리터를 등록했을 때 인라인 명령 실행·안전하지 않은 스크립트 경로를 거부하는 안내 문구의 일본어 번역을 추가했습니다 (`validation.schedule_command.shell_interpreter_denied`).
+
+### Removed
+
+- 참조처가 사라진 S3 리전 선택 검증 메시지(`validation.settings.s3_region_required`)를 원본(ko/en)과 함께 정리했습니다.
+
+### Fixed
+
+- 범용 입력 항목의 검증 문구가 특정 화면 전용 이름(SMTP 비밀번호·기본 언어·기본 통화 등)으로 표시되던 문제를 수정했습니다 — 전역 라벨을 범용 문구로 되돌리고 SMTP·사용자 목록 등 도메인 전용 라벨을 별도 키로 분리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
+
## [1.0.5] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
index c48f93b0..52882c27 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
@@ -2,6 +2,9 @@
return [
'cannot_delete_super_admin' => 'スーパー管理者は削除できません。',
+ 'cannot_modify_super_admin' => 'スーパー管理者のアカウントまたはロールを変更する権限がありません。',
+ 'cannot_grant_unheld_permission' => '自身が保有していない権限、または自身より広い範囲の権限は付与できません。',
+ 'cannot_modify_protected_role' => 'システムまたは拡張機能が所有するロールを変更する権限がありません。',
'circular_reference' => 'レイアウト循環参照を検出しました: :trace',
'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。',
'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
index 120b375b..d6cc8a53 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
@@ -20,6 +20,7 @@ return [
'backup_success' => 'データベースバックアップが正常に開始されました。',
'backup_failed' => 'データベースバックアップに失敗しました。',
'backup_error' => 'データベースバックアップ中にエラーが発生しました。',
+ 'database_backup_unavailable' => 'データベースバックアップ機能はまだ提供されていません。設定のバックアップは設定バックアップ機能をご利用ください。',
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
@@ -36,6 +37,11 @@ return [
'local' => 'ローカル',
's3' => 'Amazon S3',
],
+ 'public_asset' => [
+ 'none' => '使用しない (ストリーミング)',
+ 'public' => 'Public ディスク',
+ 's3' => 'Amazon S3',
+ ],
'cache' => [
'file' => 'ファイル',
'redis' => 'Redis',
@@ -62,6 +68,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
+ 'search' => [
+ 'mysql-fulltext' => 'MySQL 全文検索',
+ ],
],
'driver_test_success' => 'すべてのドライバ接続テストが成功しました。',
'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
@@ -71,6 +80,7 @@ return [
's3_test_failed' => 'S3バケットへの接続に失敗しました。',
's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
's3_sdk_missing' => 'AWS SDKがインストールされていません。',
+ 's3_adapter_missing' => 'S3ストレージアダプター(league/flysystem-aws-s3-v3)がインストールされていません。',
's3_bucket_not_found' => 'S3バケットが見つかりません。',
's3_access_denied' => 'S3バケットへのアクセスが拒否されました。',
's3_invalid_credentials' => 'S3認証情報が正しくありません。',
@@ -86,6 +96,10 @@ return [
'memcached_connection_failed' => 'Memcachedサーバーに接続できません。',
'websocket_test_success' => 'Websocketサーバーに正常に接続されました。',
'websocket_test_failed' => 'Websocketサーバーへの接続に失敗しました。',
+ 'websocket_server_test_failed' => 'Websocketサーバー(バックエンド送信用)エンドポイントへの接続に失敗しました。クライアントエンドポイントは正常です。',
+ 'driver_unusable_s3_adapter' => 'S3ストレージアダプター(league/flysystem-aws-s3-v3)がインストールされていません。',
+ 'driver_unusable_redis_client' => 'phpredis拡張とpredisライブラリのどちらも利用できません。',
+ 'driver_unusable_memcached_extension' => 'memcached PHP拡張がインストールされていません。',
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)またはhttp/https以外のプロトコルは使用できません。',
'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行中であるか確認してください。',
'invalid_email' => '無効なメールアドレスです。',
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
index 7b523fbd..e63c77bd 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php
@@ -16,11 +16,11 @@ return [
'fetch_success' => 'ユーザー情報を正常に取得しました。',
'fetch_failed' => 'ユーザー情報の取得に失敗しました。',
'create_success' => 'ユーザーが正常に作成されました。',
- 'create_failed' => 'ユーザー作成に失敗しました: :error',
+ 'create_failed' => 'ユーザー作成に失敗しました。しばらくしてからもう一度お試しください。',
'update_success' => 'ユーザー情報が正常に更新されました。',
- 'update_failed' => 'ユーザー情報の更新に失敗しました: :error',
+ 'update_failed' => 'ユーザー情報の更新に失敗しました。しばらくしてからもう一度お試しください。',
'delete_success' => 'ユーザーが正常に削除されました。',
- 'delete_failed' => 'ユーザー削除に失敗しました: :error',
+ 'delete_failed' => 'ユーザー削除に失敗しました。しばらくしてからもう一度お試しください。',
'statistics_success' => 'ユーザー統計を正常に取得しました。',
'statistics_failed' => 'ユーザー統計の取得に失敗しました。',
'search_success' => 'ユーザー検索が正常に完了しました。',
@@ -54,7 +54,7 @@ return [
'password_change_success' => 'パスワードが正常に変更されました。',
'password_change_failed' => 'パスワード変更に失敗しました。',
'withdraw_success' => '会員退会が完了しました。',
- 'withdraw_failed' => '会員退会に失敗しました: :error',
+ 'withdraw_failed' => '会員退会に失敗しました。しばらくしてからもう一度お試しください。',
'withdraw_admin_forbidden' => '管理者アカウントは退会できません。',
'validation' => [
'page_integer' => 'ページ番号は数字である必要があります。',
@@ -123,4 +123,5 @@ return [
'exclude_user_id_uuid' => '除外するユーザーIDは有効なUUIDである必要があります。',
'exclude_user_id_exists' => '存在しないユーザーです。',
],
+ 'withdraw_failed_unknown' => '不明なエラーにより退会処理に失敗しました。',
];
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
index cd0e5b62..a0a08028 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
@@ -190,6 +190,8 @@ return [
],
'invalid_json' => '無効な JSON 形式です。',
'must_be_array' => 'レイアウトデータは配列である必要があります。',
+ 'dangerous_expression' => '許可されていない式が含まれています: :snippet',
+ 'external_resource_url' => '外部リソース URL は許可されていません(同一オリジンのパスのみ): :url',
'required_field_missing' => '必須フィールド \':field\' がありません。',
'version_must_be_string' => 'version フィールドは文字列である必要があります。',
'layout_name_must_be_string' => 'layout_name フィールドは文字列である必要があります。',
@@ -479,6 +481,7 @@ return [
],
'schedule_command' => [
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)·セミコロン(;)などの特殊文字は使用できません。',
+ 'shell_interpreter_denied' => 'インタープリタでインラインコマンド/コードを実行したり、安全でないパスを指定したりすることはできません。インタープリタの後には絶対パスのスクリプトファイルのみを指定してください。',
'artisan_denied' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
'artisan_not_allowlisted' => 'スケジュール実行が許可された Artisan コマンドではありません。キャッシュ削除·キュー処理などのメンテナンスコマンドのみ登録できます。',
'artisan_malformed' => 'Artisan コマンドの形式が正しくありません。引用符·バックスラッシュ·短縮オプション(-v)は使用できず、"コマンド名 --オプション[=値]" の形式のみ登録できます。',
@@ -750,9 +753,13 @@ return [
],
'setting' => [
'value' => [
- 'required' => '設定値は必須です。',
- 'string' => '設定値は文字列である必要があります。',
+ 'present' => 'リクエストに設定値(value)項目が含まれている必要があります。',
+ 'boolean' => 'この設定には有効/無効の値のみ保存できます。',
+ 'integer' => 'この設定には整数のみ保存できます。',
+ 'numeric' => 'この設定には数値のみ保存できます。',
+ 'type' => '保存できない形式の設定値です。',
'max' => '設定値は :max 文字を超えることはできません。',
+ 'array_max' => '設定値のサイズは :max 文字を超えることはできません。',
],
],
'exclude_current_user' => '現在ログインしているユーザーは一括変更の対象に含めることはできません。',
@@ -897,11 +904,16 @@ return [
'storage_driver_required' => 'ストレージドライバーを選択してください。',
'storage_driver_invalid' => '正しいストレージドライバーを選択してください。',
's3_bucket_max' => 'S3バケット名は255字を超えることはできません。',
- 's3_region_invalid' => '正しいS3リージョンを選択してください。',
+ 's3_region_invalid' => 'S3リージョンには小文字の英数字とハイフンのみ使用できます。(例: ap-northeast-2、Cloudflare R2 は auto)',
+ 's3_region_max' => 'S3リージョンは64字を超えることはできません。',
's3_access_key_max' => 'S3 Access Keyは255字を超えることはできません。',
's3_secret_key_max' => 'S3 Secret Keyは255字を超えることはできません。',
's3_url_invalid' => '正しいS3 URL形式ではありません。',
's3_url_max' => 'S3 URLは500字を超えることはできません。',
+ 's3_endpoint_invalid' => '正しいS3エンドポイントURL形式ではありません。',
+ 's3_endpoint_max' => 'S3エンドポイントURLは500字を超えることはできません。',
+ 's3_use_path_style_boolean' => 'Path-styleアドレス使用の設定はtrueまたはfalseである必要があります。',
+ 'driver_unusable' => '\':driver\' ドライバーはこのサーバーでは使用できません。:reason',
'cache_driver_required' => 'キャッシュドライバーを選択してください。',
'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。',
'redis_host_max' => 'Redisホストは255字を超えることはできません。',
@@ -949,8 +961,8 @@ return [
'log_days_integer' => 'ログ保持日数は整数である必要があります。',
'log_days_min' => 'ログ保持日数は1以上である必要があります。',
'log_days_max' => 'ログ保持日数は365を超えることはできません。',
+ 'public_asset_disk_invalid' => '正しい公開アセットディスクを選択してください。',
's3_bucket_required' => 'S3バケット名は必須です。',
- 's3_region_required' => 'S3リージョンを選択してください。',
's3_access_key_required' => 'S3 Access Keyは必須です。',
's3_secret_key_required' => 'S3 Secret Keyは必須です。',
's3_url_url' => 'S3 URLは有効なURLである必要があります。',
@@ -983,6 +995,10 @@ return [
'pagination_max_page_integer' => 'ページ番号上限は数値である必要があります。',
'pagination_max_page_min' => 'ページ番号上限は:min以上である必要があります。(0 = 無制限)',
'pagination_max_page_max' => 'ページ番号上限は:maxを超えることはできません。',
+ 'orphan_cleanup_enabled_boolean' => '孤立した添付ファイルの自動クリーンアップは有効化または無効化のいずれかである必要があります。',
+ 'orphan_retention_days_integer' => '孤立した添付ファイルの保持期間は整数である必要があります。',
+ 'orphan_retention_days_min' => '孤立した添付ファイルの保持期間は1日以上である必要があります。',
+ 'orphan_retention_days_max' => '孤立した添付ファイルの保持期間は3650日を超えることはできません。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
@@ -1024,7 +1040,8 @@ return [
'ses_key' => 'SES アクセスキー',
'ses_secret' => 'SES シークレットキー',
'ses_region' => 'SES リージョン',
- 'ids' => 'ユーザーID一覧',
+ 'ids' => 'ID一覧',
+ 'user_ids' => 'ユーザーID一覧',
'user_id' => 'ユーザーID',
'status' => 'ステータス',
'site_name' => 'サイト名',
@@ -1032,7 +1049,8 @@ return [
'site_description' => 'サイト説明',
'admin_email' => '管理者メール',
'timezone' => 'タイムゾーン',
- 'language' => 'デフォルト言語',
+ 'language' => '言語',
+ 'default_language' => 'デフォルト言語',
'identity_default_provider' => 'デフォルトプロバイダー',
'identity_purpose_providers' => '目的別プロバイダー',
'identity_challenge_ttl_minutes' => 'Challenge有効期限',
@@ -1047,10 +1065,14 @@ return [
'identity_policy_applies_to' => '適用対象',
'identity_policy_fail_mode' => '失敗モード',
'mailer' => 'メーラー',
- 'host' => 'SMTPホスト',
- 'port' => 'SMTPポート',
- 'username' => 'SMTPユーザー名',
- 'password' => 'SMTPパスワード',
+ 'host' => 'ホスト',
+ 'port' => 'ポート',
+ 'username' => 'ユーザー名',
+ 'password' => 'パスワード',
+ 'smtp_host' => 'SMTPホスト',
+ 'smtp_port' => 'SMTPポート',
+ 'smtp_username' => 'SMTPユーザー名',
+ 'smtp_password' => 'SMTPパスワード',
'encryption' => '暗号化',
'from_address' => '送信元メールアドレス',
'from_name' => '送信元名',
@@ -1073,6 +1095,8 @@ return [
's3_access_key' => 'S3 Access Key',
's3_secret_key' => 'S3 Secret Key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 エンドポイントURL',
+ 's3_use_path_style' => 'Path-styleアドレス使用',
'cache_driver' => 'キャッシュ ドライバー',
'redis_host' => 'Redis ホスト',
'redis_port' => 'Redis ポート',
@@ -1089,8 +1113,10 @@ return [
'websocket_port' => 'WebSocket ポート',
'websocket_scheme' => 'WebSocket プロトコル',
'mailgun_endpoint' => 'Mailgun エンドポイント',
- 'channels' => '通知チャンネル',
- 'currency' => 'デフォルト通貨',
+ 'channels' => 'チャンネル',
+ 'notification_channels' => '通知チャンネル',
+ 'currency' => '通貨',
+ 'default_currency' => 'デフォルト通貨',
'maintenance_mode' => 'メンテナンスモード',
'asset_url_mode' => 'アセットURL方式',
'site_logo' => 'サイトロゴ',
@@ -1103,7 +1129,8 @@ return [
'twitter_default_card' => 'Twitter デフォルトカード種類',
'twitter_default_site' => 'Twitter デフォルトアカウント',
'seo_page_cache_enabled' => 'SEO ページキャッシュの使用',
- 'cache_ttl' => 'SEO ページキャッシュ保持時間',
+ 'cache_ttl' => 'キャッシュ保持時間',
+ 'seo_page_cache_ttl' => 'SEO ページキャッシュ保持時間',
'sitemap_enabled' => 'サイトマップの使用',
'sitemap_cache_ttl' => 'サイトマップキャッシュ保持時間',
'sitemap_urls_per_file' => 'サイトマップ1ファイルあたりのURL数',
@@ -1151,6 +1178,9 @@ return [
'log_driver' => 'ログ ドライバー',
'log_level' => 'ログレベル',
'log_days' => 'ログ保持日数',
+ 'public_asset_disk' => '公開アセットディスク',
+ 'orphan_cleanup_enabled' => '孤立した添付ファイルの自動クリーンアップ',
+ 'orphan_retention_days' => '孤立した添付ファイルの保持期間',
],
'layout_version' => [
'limit' => [
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index 4d25cdab..f904c3e6 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.5",
+ "version": "1.0.6",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
index f8015f4b..1faf5bd9 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
@@ -4,6 +4,19 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-19
+
+### Added
+
+- 답글 삭제 정책(reply_delete_policy) 설정 라벨·옵션·차단 안내 문구의 일본어 번역을 추가했습니다.
+- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
+- 비공개(비밀) 게시글 목록 마스킹 시 표시되는 제목 플레이스홀더(`secret_post_title`)의 일본어 번역을 추가했습니다.
+- 첨부 영구 정리 설정의 라벨·도움말과 검증 항목명 일본어 번역을 추가했습니다 (`admin/settings.attachment_purge_*`, `validation.attributes.attachment_settings.*`) — 게시판 환경설정 > 기본 설정의 새 항목과 저장 시 오류 안내가 일본어 로케일에서 표시됩니다.
+
+### Changed
+
+- 신고 반려 누적 제한 안내 문구의 일본어 번역을 실제 동작(설정 건수 도달 시 차단)에 맞게 정정했습니다.
+
## [1.0.2] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php
index 41a8ec64..2c036e67 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php
@@ -71,6 +71,7 @@ return [
'use_comment' => 'コメント使用',
'use_reply' => '返信使用',
'max_reply_depth' => '最大返信深度',
+ 'reply_delete_policy' => '返信の削除方式',
'use_report' => '通報使用',
'use_file_upload' => 'ファイルアップロード使用',
'max_file_size' => '最大ファイルサイズ',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php
index b6ce372c..6c9a6632 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php
@@ -6,6 +6,10 @@ return [
'enabled' => '選択使用',
'always' => '必須使用',
],
+ 'reply_delete_policy' => [
+ 'block' => '削除をブロック(返信がある間は元の投稿を削除できません)',
+ 'cascade' => '一緒に削除(元の投稿の削除時に返信も削除されます)',
+ ],
'order_direction' => [
'asc' => '昇順',
'desc' => '降順',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
index 49e239e9..01a0cc78 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
@@ -34,6 +34,7 @@ return [
'secret_password_required' => '非公開投稿のパスワードが必要です。',
'secret_password_incorrect' => '非公開投稿のパスワードが一致しません。',
'secret_post_content' => '非公開投稿です。内容を表示するにはパスワードを入力してください。',
+ 'secret_post_title' => '非公開投稿',
'deleted_post_title' => '削除された投稿',
'deleted_post_content' => '削除された投稿です。',
'blinded_post_content' => '管理者によってブロック処理された投稿です。',
@@ -181,6 +182,7 @@ return [
'permission_denied' => '権限がありません。',
'validation_failed' => '入力値が正しくありません。',
'duplicate_report' => '既に通報された履歴があります。',
+ 'operation_failed' => '処理中にエラーが発生しました。',
],
'warnings' => [
'category_removal_attempted' => '使用中のカテゴリーの削除が試みられました。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
index 80da450a..73ed656a 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
@@ -219,6 +219,9 @@ return [
'notice_not_allowed' => 'お知らせには返信を作成することはできません。',
],
'reply_not_allowed' => 'この掲示板は返信機能が無効化されています。',
+ 'delete' => [
+ 'has_replies' => '返信が付いた投稿は削除できません。先に返信を削除してください。',
+ ],
'status' => [
'in' => '無効な投稿ステータスです。',
],
@@ -256,6 +259,7 @@ return [
'basic_defaults.use_comment' => 'コメント使用有無',
'basic_defaults.use_reply' => '返信使用有無',
'basic_defaults.max_reply_depth' => '最大返信深さ',
+ 'basic_defaults.reply_delete_policy' => '返信の削除方式',
'basic_defaults.max_comment_depth' => '最大コメント深さ',
'basic_defaults.comment_order' => 'コメント並べ替え',
'basic_defaults.show_view_count' => '閲覧数表示',
@@ -339,6 +343,7 @@ return [
'override_values.per_page' => 'ページあたりの投稿数',
'override_values.per_page_mobile' => 'モバイルページあたりの投稿数',
'override_values.max_reply_depth' => '最大返信深さ',
+ 'override_values.reply_delete_policy' => '返信の削除方式',
'override_values.max_comment_depth' => '最大コメント深さ',
'override_values.min_title_length' => '最小タイトル長',
'override_values.max_title_length' => '最大タイトル長',
@@ -349,6 +354,8 @@ return [
'override_values.max_file_size' => '最大ファイルサイズ',
'override_values.max_file_count' => '最大ファイル個数',
'override_values.new_display_hours' => '新規表示時間',
+ 'attachment_settings.purge_enabled' => '削除添付の永久クリーンアップ',
+ 'attachment_settings.purge_retention_days' => '削除添付の保持期間',
],
],
'blind' => [
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json
index 0610e730..d7650519 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json
@@ -926,7 +926,7 @@
"daily_report_limit_hint": "1日(午前0時基準)全体掲示板合わせて通報できる最大回数。0の場合は制限なし。 (0~100)",
"rejection_limit_count": "却下累積制限 (件数)",
"rejection_limit_days": "却下累積制限期間 (日)",
- "rejection_limit_hint": "この期間内に却下件数を超えると通報機能がブロックされます。件数が0の場合は制限なし。 (件数: 0~50、期間: 1~365日)",
+ "rejection_limit_hint": "この期間内の却下件数が設定値に達すると通報機能がブロックされます。件数が0の場合は制限なし。 (件数: 0~50、期間: 1~365日)",
"report_cooldown_seconds": "連続通報クールタイム (秒)",
"report_cooldown_seconds_hint": "連続通報の最小間隔。0の場合は制限なし。 (0~3600秒)",
"notify_author_on_report_action": "通報処理通知",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json
index 834d893f..c31e5f15 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json
@@ -110,6 +110,14 @@
"label": "返信投稿の最大深さ",
"hint": "{{min}}~{{max}}の範囲内の値を入力してください"
},
+ "reply_delete_policy": {
+ "label": "返信の削除方式",
+ "hint": "返信が付いた投稿を削除するときの動作を設定します",
+ "options": {
+ "block": "削除をブロック",
+ "cascade": "一緒に削除"
+ }
+ },
"max_comment_depth": {
"label": "返信への返信の最大深さ",
"hint": "{{min}}~{{max}}の範囲内の値を入力してください。0に設定すると返信への返信機能が無効化されます。"
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json
index 69a00fc2..41857176 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json
@@ -60,7 +60,9 @@
"report_notification": "通報通知",
"report_notification_description": "通報受け付け時に通知を受け取る管理者と通知発送方式を設定します。",
"report_permissions": "通報管理権限",
- "report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。"
+ "report_permissions_description": "通報リスト閲覧および通報処理(承認·却下·ブラインド)を実行できるロールを指定します。通報受け付け通知は「通報処理権限」を持つロール基準で発送されます。",
+ "attachment_cleanup": "添付ファイルの整理",
+ "attachment_cleanup_description": "削除された添付ファイルの物理ファイルをいつ廃棄するかを設定します。投稿作成中にアップロードされたが保存されなかった一時的な添付は、この設定に関わらず毎日自動で整理されます。"
},
"fields": {
"type": "掲示板タイプ",
@@ -78,6 +80,7 @@
"max_content_length": "最大投稿文字数",
"use_reply": "回答投稿の使用",
"max_reply_depth": "回答投稿の最大深さ",
+ "reply_delete_policy": "返信の削除方式",
"use_comment": "コメントの使用",
"comment_order": "コメント並べ替え順序",
"min_comment_length": "最小コメント文字数",
@@ -103,7 +106,7 @@
"daily_report_limit_hint": "1日(午前0時基準)全掲示板で通報できる最大回数です。0の場合は制限なし。(0~100)",
"rejection_limit_count": "却下累積制限 (件数)",
"rejection_limit_days": "却下累積制限期間 (日)",
- "rejection_limit_hint": "この期間内に却下件数を超えると通報機能がブロックされます。件数が0の場合は制限なし。(件数: 0~50、期間: 1~365日)",
+ "rejection_limit_hint": "この期間内の却下件数が設定値に達すると通報機能がブロックされます。件数が0の場合は制限なし。(件数: 0~50、期間: 1~365日)",
"report_cooldown_seconds": "連続通報クールタイム (秒)",
"report_cooldown_seconds_hint": "連続通報の最小間隔です。0の場合は制限なし。(0~3600秒)",
"notify_author_on_report_action": "通報処理通知",
@@ -166,6 +169,7 @@
"max_content_length": "投稿本文の最大文字数を設定します ({{min}}~{{max}})",
"use_reply": "ON に設定する必要があります。新規掲示板に返信投稿機能が適用されます。",
"max_reply_depth": "返信投稿の最大ネスト深さを設定します ({{min}}~{{max}})",
+ "reply_delete_policy": "返信が付いた投稿を削除するときの動作を設定します",
"blocked_keywords": "登録されたキーワードを含む投稿/コメントは作成がブロックされます",
"use_comment": "ON に設定する必要があります。新規掲示板にコメント機能が適用されます。以下のコメント詳細設定も一緒に適用されます。",
"comment_order": "コメントのデフォルト並べ替え方向を設定します",
@@ -176,7 +180,11 @@
"max_file_size": "アップロード可能な最大ファイルサイズです (MB単位、{{min}}~{{max}})",
"max_file_count": "投稿あたり添付可能な最大ファイル数です ({{min}}~{{max}})",
"allowed_extensions": "許可するファイル拡張子を最低1つ以上入力してください"
- }
+ },
+ "attachment_purge_enabled": "削除済み添付ファイルの永久削除",
+ "attachment_purge_enabled_hint": "デフォルトは無効です。運営者が有効にしたときにのみ毎日の自動整理が動作します。有効にすると、削除されてから保存期間が経過した添付ファイルとそのレコードが永久削除されます。サーバーで直接実行する整理コマンドはこの設定に左右されません。",
+ "attachment_purge_retention_days": "削除済み添付ファイル保存期間(日)",
+ "attachment_purge_retention_days_hint": "ゴミ箱から投稿を復元すると、この期間内の添付ファイルは一緒に復元されます。ゴミ箱の復元可能期間と同じに保つようにしてください。(1~3650 日)"
},
"options": {
"date_display_format": {
@@ -197,6 +205,10 @@
"enabled": "選択可能",
"always": "常に非公開"
},
+ "reply_delete_policy": {
+ "block": "削除をブロック",
+ "cascade": "一緒に削除"
+ },
"order_by": {
"created_at": "最新順",
"view_count": "閲覧数順",
@@ -280,6 +292,7 @@
"use_comment": "コメント使用",
"use_reply": "返信コメント使用",
"max_reply_depth": "返信コメント最大深さ",
+ "reply_delete_policy": "返信の削除方式",
"max_comment_depth": "返信の返信最大深さ",
"comment_order": "コメント並べ替え順序",
"show_view_count": "閲覧数表示",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json
index 0b311854..ff730d88 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-board",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 7f766eba..570efff3 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
@@ -4,6 +4,30 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.1] - 2026-08-19
+
+### Added
+
+- 리뷰 작성 불가 안내의 사유 라벨(`messages.reviews.reasons.*` — 주문 내역 없음·본인 주문 아님·구매확정 전·기한 경과·중복 작성) 일본어 번역을 추가했습니다.
+- 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다.
+- 이미 답변된 상품 문의에 답변을 재등록할 때의 거절 안내(`inquiries.reply_already_exists`) 일본어 번역을 추가했습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`). (#111 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다. (#111 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다. (#104 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다.
+- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다.
+- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다.
+- 이커머스 설정 기본 정보 탭의 "공개 자산 스토리지" 설정 문구 일본어 번역을 추가했습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
+- 공개 자산 디스크에 사용할 수 없는 값을 저장할 때 나오는 오류 안내와 필드명의 일본어 번역을 추가했습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
+
+### Changed
+
+- 도서산간 추가배송비의 "중복 부과" 안내를 배송 단위 기준으로 정정했습니다. 이전 문구는 건수 기준으로 읽혀 실제 부과 방식과 달랐습니다.
+
+### Removed
+
+- 화면에서 더 이상 쓰이지 않는 배송비 요약 접두 단위 문구와 배송정책 통화 선택 안내 문구를 제거했습니다.
+
## [1.1.0] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
index 2251139a..0774befb 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
@@ -83,4 +83,6 @@ return [
'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。',
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
+ 'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
+ 'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。',
];
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php
index f6fe2cf2..d7af31d2 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php
@@ -440,6 +440,11 @@ return [
'per_volume' => ':unitL当たり:fee',
'per_volume_weight' => ':unitkg当たり:fee',
'per_amount' => ':unit当たり:fee',
+ 'range_unit' => [
+ 'quantity' => '個',
+ 'weight' => 'kg',
+ 'volume' => 'L',
+ ],
],
'fee_details' => [
'base_fee' => '送料',
@@ -1039,6 +1044,14 @@ return [
'can_write_checked' => 'レビュー作成可否を確認しました。',
'can_write_check_failed' => 'レビュー作成可否の確認に失敗しました。',
'cannot_write' => 'レビューを作成できません。(理由: :reason)',
+ // 作成不可理由ラベル — :reason 置換子に生の識別子ではなく翻訳文を載せる
+ 'reasons' => [
+ 'order_option_not_found' => '注文内容が見つかりません',
+ 'not_own_order' => 'ご本人の注文ではありません',
+ 'not_confirmed' => '購入確定前です',
+ 'deadline_passed' => '作成期限が過ぎています',
+ 'already_written' => 'すでに作成済みのレビューです',
+ ],
'forbidden' => '権限がありません。',
'deleted' => 'レビューが削除されました。',
'delete_failed' => 'レビュー削除に失敗しました。',
@@ -1095,6 +1108,7 @@ return [
'board_changed' => '掲示板設定が変更されたため、該当する投稿が見つかりません。管理者にお問い合わせください。',
'reply_created' => '返信が登録されました。',
'reply_failed' => '返信登録に失敗しました。',
+ 'reply_already_exists' => 'すでに登録された回答があります。既存の回答を修正または削除してから再度登録してください。',
'reply_updated' => '返信が編集されました。',
'reply_update_failed' => '回答の編集に失敗しました。',
'reply_deleted' => '回答が削除されました。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
index f855125a..741512fa 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
@@ -787,6 +787,7 @@ return [
],
],
'order' => [
+ 'payment_method_unavailable' => '現在ご利用いただけない決済手段です。他の決済手段を選択してください。',
'ids' => [
'required' => '変更する注文を選択してください。',
'array' => '注文IDは配列形式である必要があります。',
@@ -935,6 +936,14 @@ return [
'tier_min_non_negative' => '区間開始値は0以上である必要があります。',
'tier_max_non_negative' => '区間終了値は0以上である必要があります。',
'unit_value_min' => '区間単位値は0より大きくする必要があります。',
+ 'tiers_required' => '区間別送料ポリシーは区間を1つ以上登録する必要があります。',
+ 'middle_max_required' => '最後の区間を除く区間には終了値を入力する必要があります。',
+ 'tier_value_integer' => '数量区間の開始値と終了値は整数である必要があります。',
+ 'unit_value_required' => '単位当たり送料ポリシーは単位値を入力する必要があります。',
+ ],
+ 'free_threshold_required' => '条件付き送料無料ポリシーは送料無料基準金額を入力する必要があります。',
+ 'extra_fee' => [
+ 'zipcode_format' => '郵便番号は「63000」「63000-63999」「63*」のいずれかの形式である必要があります。',
],
'country_settings' => [
'country_code' => [
@@ -1200,7 +1209,13 @@ return [
'min' => '注文金額は 0 以上である必要があります。',
],
],
+ // 公開アセットディスク (コアカタログ照会による検証)
+ 'public_asset_disk_invalid' => '有効な公開アセットディスクを選択してください。',
+
'attributes' => [
+ // 公開アセットストレージ
+ 'basic_info.public_asset_disk' => '公開アセットディスク',
+
'basic_info' => '基本情報',
'basic_info.shop_name' => 'ショップ名',
'basic_info.route_path' => 'ルートパス',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
index d756c545..60dd5aab 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
@@ -58,6 +58,13 @@
"telecom_number_placeholder": "付加通信事業者番号",
"postcode_plugin_required": "Daum郵便番号プラグインがインストールされていないか無効化されています。管理者>プラグインで有効化してください。"
},
+ "public_asset_storage": {
+ "title": "公開アセットストレージ",
+ "description": "商品・カテゴリ・レビュー画像など、このモジュールの完全公開アセットの保存ディスクを設定します。",
+ "disk": "公開アセットディスク",
+ "follow_core": "コア設定に従う",
+ "help": "このモジュールだけ別のディスクを使う場合に選択します。空にするとコア環境設定の公開アセットストレージ設定に従います。"
+ },
"inquiry_settings": {
"title": "お問い合わせ掲示板設定",
"description": "商品お問い合わせ機能を設定します。",
@@ -144,6 +151,7 @@
"min_order_amount": "最小注文金額",
"is_active": "使用有無",
"orphaned_badge": "プラグイン未インストール",
+ "orphaned_pg_badge": "指定PG削除済み",
"remove_orphaned": "削除",
"pg_provider": "PG社",
"pg_use_default": "デフォルトPG",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
index 77480c97..74d49a26 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
@@ -114,12 +114,6 @@
"free": "送料無料",
"fixed": "配送料金: {{fee}}",
"conditional": "{{threshold}}未満 {{fee}} / {{threshold}}以上 送料無料",
- "range_prefix": {
- "amount": "",
- "quantity": "個",
- "weight": "kg",
- "volume": "cm³"
- },
"api": "外部API連携(リアルタイム計算)",
"per_quantity": "{{unit}}個当たり {{fee}}",
"per_weight": "{{unit}}kg当たり {{fee}}",
@@ -196,14 +190,17 @@
"carrier": "配送業者",
"carrier_placeholder": "配送業者を選択してください",
"currency_code": "基準通貨",
- "currency_code_placeholder": "通貨を選択してください",
"charge_policy": "配送料金課金ポリシー",
"base_fee": "配送料金",
"free_threshold": "送料無料基準金額",
"free_threshold_hint": "この金額以上のご購入で配送料金が無料",
"range_tiers": "区間別配送料金",
"range_min": "開始値",
+ "range_min_auto": "自動",
"range_max": "終了値",
+ "unit_quantity": "個",
+ "unit_weight": "kg",
+ "unit_volume": "L",
"range_fee": "配送料金",
"range_unlimited": "無制限",
"range_add": "区間を追加",
@@ -213,6 +210,11 @@
"range_error_min_max": "開始値は終了値より小さい必要があります。",
"range_error_fee": "配送料は0以上である必要があります。",
"range_error_min_one": "最小1つの区間が必要です。",
+ "range_error_middle_max": "最後の区間を除く区間には終了値を入力する必要があります。",
+ "range_error_fee_required": "区間の送料を入力してください。",
+ "range_error_integer": "数量区間の開始値と終了値は整数である必要があります。",
+ "range_error_unit_value_required": "単位あたり送料ポリシーは単位値を入力する必要があります。",
+ "range_error_free_threshold_required": "条件付き送料無料ポリシーは送料無料基準金額を入力する必要があります。",
"per_unit_value": "単位基準値",
"per_unit_hint": "設定した単位ごとに上記の配送料が課金されます",
"api_endpoint": "APIエンドポイント",
@@ -225,8 +227,8 @@
"api_response_fee_field_placeholder": "shipping_fee",
"api_response_fee_field_hint": "API応答から配送料の値が含まれるフィールド名",
"extra_fee_enabled": "離島追加配送料の使用",
- "extra_fee_multiply": "建当たり重複課金",
- "extra_fee_multiply_hint": "単位ごとの配送料適用時に離島追加配送料を建当たり重複課金します",
+ "extra_fee_multiply": "配送単位当たり重複課金",
+ "extra_fee_multiply_hint": "単位ごとの配送料適用時に、離島追加配送料を基本送料と同じ配送単位数だけ重複課金します",
"extra_fee_zipcode": "郵便番号",
"extra_fee_zipcode_placeholder": "63000または63000-63644",
"extra_fee_amount": "追加配送料",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
index 2aa61bdb..547798e5 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
@@ -1,30 +1,30 @@
{
- "identifier": "g7-module-sirsoft-ecommerce-ja",
- "namespace": "g7",
- "vendor": "sirsoft",
- "name": {
- "ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩",
- "en": "G7 module (sirsoft-ecommerce) Japanese language pack",
- "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック"
- },
- "description": {
- "ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩 (번들)",
- "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
- "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
- },
- "version": "1.1.0",
- "license": "MIT",
- "scope": "module",
- "target_identifier": "sirsoft-ecommerce",
- "locale": "ja",
- "locale_name": "Japanese",
- "locale_native_name": "日本語",
- "text_direction": "ltr",
- "g7_version": ">=7.0.0",
- "requires": {
- "target_version": null,
- "depends_on_core_locale": true
- },
- "github_url": "",
- "github_changelog_url": ""
+ "identifier": "g7-module-sirsoft-ecommerce-ja",
+ "namespace": "g7",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩",
+ "en": "G7 module (sirsoft-ecommerce) Japanese language pack",
+ "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック"
+ },
+ "description": {
+ "ko": "G7 모듈 (sirsoft-ecommerce) 일본어 언어팩 (번들)",
+ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
+ "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
+ },
+ "version": "1.1.1",
+ "license": "MIT",
+ "scope": "module",
+ "target_identifier": "sirsoft-ecommerce",
+ "locale": "ja",
+ "locale_name": "Japanese",
+ "locale_native_name": "日本語",
+ "text_direction": "ltr",
+ "g7_version": ">=7.0.0",
+ "requires": {
+ "target_version": null,
+ "depends_on_core_locale": true
+ },
+ "github_url": "",
+ "github_changelog_url": ""
}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md
index 6145af21..368a5374 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md
@@ -4,6 +4,17 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.1] - 2026-08-19
+
+### Added
+
+- 일괄 삭제 부분 실패 안내(`messages.uploads.bulk_partially_deleted`)와 참조 판정 불완전 시 정리 건너뜀 안내(`messages.cleanup.sources_incomplete`), 이미지 삭제 실패 토스트(`admin.uploads.delete.failed`)의 일본어 번역을 추가했습니다.
+- 에디터 설정의 "공개 자산 디스크" 설정 문구 일본어 번역을 추가했습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
+- 「에디터 업로드 이미지」 관리 화면의 문구 일본어 번역을 추가했습니다 (`admin.uploads.*`) — 목록 제목·열 이름·참조 상태 배지·필터·삭제 확인 창과 판정 범위 안내가 일본어 로케일에서 표시됩니다.
+- 미사용 이미지 자동 정리 설정의 라벨·도움말 일본어 번역을 추가했습니다 (`settings.section_cleanup`, `settings.cleanup.*`). (#115 @Tuwasduliebst 님께서 건의해주셨습니다.)
+- 업로드 이미지 삭제 시의 안내·오류 메시지 일본어 번역을 추가했습니다 (`messages.uploads.*`, `messages.cleanup.*`).
+- 업로드 이미지 조회·삭제 권한의 이름과 설명 일본어 번역을 추가했습니다 — 역할 권한 화면에서 일본어 로케일로 표시됩니다.
+
## [1.0.0] - 2026-07-01
### Added
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php
index 8926889d..e837b69b 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php
@@ -13,4 +13,17 @@ return [
'forbidden' => '画像アップロード権限がありません。',
'failed' => '画像アップロードに失敗しました。',
],
+ 'uploads' => [
+ 'not_found' => 'アップロード画像が見つかりません。',
+ 'file_delete_failed' => '画像ファイルの削除に失敗しました。しばらく後に再度お試しください。',
+ 'ids_required' => '削除する画像を選択してください。',
+ 'deleted' => '画像を削除しました。',
+ 'bulk_deleted' => '選択した画像 :deleted件を削除しました。',
+ 'bulk_partially_deleted' => '選択した画像のうち :deleted件を削除し、:failed件は削除できませんでした。失敗した項目はリストに残っています。',
+ 'ids_invalid' => '選択した画像の中に存在しないアイテムがあります。リストをリフレッシュした後、再度選択してください。',
+ ],
+ 'cleanup' => [
+ 'retention_disabled' => '保存期間が1日未満に設定されているため、クリーンアップを実行しませんでした。',
+ 'sources_incomplete' => '無効状態のモジュールがあり参照判定が不完全になる可能性があるため、未参照画像のクリーンアップをスキップしました。モジュールを有効化または削除してから再実行してください。',
+ ],
];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json
index f07e97cb..d073d1d7 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json
@@ -1,7 +1,8 @@
{
"editor": {
"data_source": {
- "settings": "エディター設定"
+ "settings": "エディター設定",
+ "ckeditor5Uploads": "アップロード画像一覧"
}
},
"settings": {
@@ -29,6 +30,11 @@
"label": "最大画像サイズ (MB)",
"hint": "アップロード可能な画像の最大サイズです。(1 〜 10 MB)"
},
+ "public_asset_disk": {
+ "label": "公開アセットディスク",
+ "hint": "このプラグインだけ別のディスクを使う場合に選択します。空にするとコアの公開アセットディスク設定に従います。",
+ "follow_core": "コア設定に従う"
+ },
"editor_height": {
"label": "エディター高さ (px)",
"hint": "エディターの最小高さをピクセル単位で設定します。(200 〜 2000 px)"
@@ -41,6 +47,61 @@
"step3": "画像アップロードを有効化するとエディターで画像を直接貼り付けたりアップロードできます。アップロードされた画像はサーバーに保存されます。",
"step4": "設定変更後に保存すると即座にすべてのエディターに反映されます。",
"step5": "プラグインを無効化するとエディターは基本 HtmlEditor に、コンテンツ表示は基本 HtmlContent に自動復元されます。"
+ },
+ "section_cleanup": "未使用画像の整理",
+ "cleanup": {
+ "toggle_label": "未使用画像の自動整理",
+ "toggle_hint": "デフォルトはオフです。有効にするには運営者が手動で有効にする必要があります。有効にすると、毎日1回、保存期間を過ぎた未使用画像のファイルと記録を一緒に削除します。",
+ "retention_label": "保存期間(日)",
+ "retention_hint": "アップロード後、この期間を過ぎた未使用画像のみが整理対象になります。(1~3650日)",
+ "manual_hint": "今何が削除されるか事前に確認したい場合は、サーバーで次のコマンドを実行してください:php artisan sirsoft-ckeditor5:prune-unused-images --dry-run",
+ "open_uploads": "アップロード画像管理を開く"
+ }
+ },
+ "admin": {
+ "uploads": {
+ "title": "エディターアップロード画像",
+ "description": "エディターでアップロードされた画像を確認し、使用されなくなった画像を整理します。",
+ "refresh": "更新",
+ "open_settings": "整理設定を開く",
+ "reference_notice": "参照状態は投稿・ページ・商品説明などの登録されたコンテンツを確認して判定します。参照ソースを登録していない外部拡張のコンテンツのみで使用されている画像は「未参照」と表示される場合があるため、削除前に確認してください。",
+ "scan_limited_notice": "参照状態フィルターは最近{{count}}件を基準として判定します。それより古い画像はこのリストに表示されません。",
+ "empty": "アップロードされた画像がありません。",
+ "col": {
+ "thumbnail": "プレビュー",
+ "original_name": "元のファイル名",
+ "file_size": "サイズ",
+ "uploader": "アップローダー",
+ "created_at": "アップロード日時",
+ "referenced": "参照状態"
+ },
+ "badge": {
+ "referenced": "参照済み",
+ "unreferenced": "未参照"
+ },
+ "filter": {
+ "search_placeholder": "元のファイル名またはハッシュで検索",
+ "search_button": "検索",
+ "referenced_all": "すべての状態",
+ "referenced_only": "参照済みのみ",
+ "unreferenced_only": "未参照のみ",
+ "date_from": "開始日",
+ "date_to": "終了日",
+ "clear": "フィルターをリセット"
+ },
+ "delete": {
+ "button": "削除",
+ "confirm_title": "画像を削除しますか?",
+ "confirm_message": "選択した画像のファイルと記録が一緒に削除されます。この操作は取り消せません。",
+ "success": "画像を削除しました。",
+ "failed": "画像の削除に失敗しました。"
+ },
+ "bulk_delete": {
+ "button": "選択削除",
+ "selected": "{{count}}件選択されました",
+ "confirm_title": "選択した画像を削除しますか?",
+ "confirm_message": "選択した{{count}}件のファイルと記録が一緒に削除されます。この操作は取り消せません。"
+ }
}
}
}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json
index 11e85335..be35b069 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-ckeditor5) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-ckeditor5) 日本語 言語パック(バンドル)"
},
- "version": "1.0.0",
+ "version": "1.0.1",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-ckeditor5",
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json
new file mode 100644
index 00000000..86168eb6
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/permissions.json
@@ -0,0 +1,18 @@
+{
+ "sirsoft-ckeditor5": {
+ "name": "CKEditor 5 WYSIWYG エディター",
+ "description": "CKEditor5 プラグインが提供する権限"
+ },
+ "sirsoft-ckeditor5.uploads": {
+ "name": "エディター アップロード画像",
+ "description": "エディターにアップロードされた画像の閲覧・削除権限"
+ },
+ "sirsoft-ckeditor5.uploads.read": {
+ "name": "アップロード画像の閲覧",
+ "description": "エディター アップロード画像一覧・参照状態の閲覧"
+ },
+ "sirsoft-ckeditor5.uploads.delete": {
+ "name": "アップロード画像の削除",
+ "description": "エディター アップロード画像のファイル・記録の削除"
+ }
+}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
index 5a79c78b..a4a30e77 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.1] - 2026-08-19
+
+### Added
+
+- 에스크로 배송등록·구매거절확인 검증 안내 문구(운송장번호·택배사·기본 확인자명)의 일본어 번역을 추가했습니다.
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+
## [1.1.0] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
index 4fb37e19..6714f28e 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
@@ -5,6 +5,7 @@ return [
'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。',
'order_not_found' => '注文が見つかりません。',
'cbt_failed' => '海外決済の処理に失敗しました。',
+ 'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
],
'refund' => [
'missing_tid' => 'トランザクション ID(TID)がないため、返金を進めることができません。',
@@ -39,6 +40,11 @@ return [
'cash_receipt' => [
'provider_name' => 'KGイニシス',
],
+ 'escrow' => [
+ 'invoice_required' => '送り状番号を入力してください。',
+ 'courier_required' => '配送業者を選択してください。',
+ 'default_confirmer' => '管理者',
+ ],
'cbt_cvs' => [
'simulate_success' => '入金シミュレーションが完了しました。',
'simulate_failed' => '入金シミュレーションに失敗しました。',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
index 68ae76d0..b01d3975 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
@@ -1,30 +1,30 @@
{
- "identifier": "g7-plugin-sirsoft-pay_kginicis-ja",
- "namespace": "g7",
- "vendor": "sirsoft",
- "name": {
- "ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩",
- "en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack",
- "ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック"
- },
- "description": {
- "ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩 (번들)",
- "en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
- "ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
- },
- "version": "1.1.0",
- "license": "MIT",
- "scope": "plugin",
- "target_identifier": "sirsoft-pay_kginicis",
- "locale": "ja",
- "locale_name": "Japanese",
- "locale_native_name": "日本語",
- "text_direction": "ltr",
- "g7_version": ">=7.0.0",
- "requires": {
- "target_version": null,
- "depends_on_core_locale": true
- },
- "github_url": "",
- "github_changelog_url": ""
+ "identifier": "g7-plugin-sirsoft-pay_kginicis-ja",
+ "namespace": "g7",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩",
+ "en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack",
+ "ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック"
+ },
+ "description": {
+ "ko": "G7 플러그인 (sirsoft-pay_kginicis) 일본어 언어팩 (번들)",
+ "en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
+ "ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
+ },
+ "version": "1.1.1",
+ "license": "MIT",
+ "scope": "plugin",
+ "target_identifier": "sirsoft-pay_kginicis",
+ "locale": "ja",
+ "locale_name": "Japanese",
+ "locale_native_name": "日本語",
+ "text_direction": "ltr",
+ "g7_version": ">=7.0.0",
+ "requires": {
+ "target_version": null,
+ "depends_on_core_locale": true
+ },
+ "github_url": "",
+ "github_changelog_url": ""
}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
index 782858b9..7330b6a0 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-19
+
+### Added
+
+- 에스크로 배송등록 검증 안내 문구(운송장번호·택배사)의 일본어 번역을 추가했습니다.
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+- 결제 취소에 필요한 서버 IP 등록 안내의 일본어 번역을 추가했습니다 — 등록이 필요한 이유, 상점 관리자에서의 등록 경로, 등록할 IP 확인·복사 안내가 일본어로 표시됩니다.
+- 관리자 결제 상세의 결제 상태·취소 금액·취소 일시·환불 상태·환불 번호 항목명과, 취소 완료·부분 취소 상태 표시의 일본어 번역을 추가했습니다.
+- 가상계좌 입금 통보 주소의 등록 경로 안내와 간편결제 표시 설정 항목명의 일본어 번역을 추가했습니다.
+
## [1.0.2] - 2026-08-10
### Changed
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
index e520bb91..ac6bec25 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
@@ -6,7 +6,12 @@ return [
'default_reason' => '購入者返金リクエスト',
'in_progress' => 'NHN KCP 返金はすでに処理中です。',
],
+ 'escrow' => [
+ 'invoice_required' => '送り状番号を入力してください。',
+ 'courier_required' => '配送業者を選択してください。',
+ ],
'errors' => [
+ 'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
'wsdl_missing' => 'KCP WSDL ファイルがありません: :file',
'approval_key_error' => 'KCP 承認キーエラー [:code]: :message',
'soap_error' => 'KCP SOAP 連携エラー: :message',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
index 80e17850..7e1e38c4 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/frontend/ja.json
@@ -9,7 +9,8 @@
"nhnkcp_test_map": "テスト決済マップ",
"nicepay_test_map": "テスト決済マップ",
"settings": "決済設定",
- "vbank_info": "仮想口座情報"
+ "vbank_info": "仮想口座情報",
+ "nhnkcp_easy_pay_display_map": "簡易決済表示マップ"
}
},
"messages": {
@@ -78,7 +79,14 @@
"escrow_delivery_change_button": "配送情報編集",
"escrow_delivery_registering": "登録中...",
"escrow_delivery_success": "配送情報がKCPに登録されました。",
- "escrow_delivery_error": "配送登録に失敗しました。"
+ "escrow_delivery_error": "配送登録に失敗しました。",
+ "pay_status_cancel_completed": "キャンセル完了",
+ "pay_status_partial_cancelled": "部分キャンセル",
+ "result_payment_status": "現在の決済ステータス",
+ "result_cancelled_amount": "キャンセル金額",
+ "result_cancelled_at": "キャンセル日時",
+ "result_refund_status": "返金ステータス",
+ "result_refund_number": "返金番号"
},
"settings": {
"title": "NHN KCP設定",
@@ -133,7 +141,16 @@
"escrow_test_site_cd_hint": "KCPエスクロー テスト用site_cd(デフォルト:T0007)",
"section_escrow_notify": "エスクロー共通通知URL",
"escrow_common_notify_hint": "NHN KCP加盟店管理画面でこのURLを共通通知URLとして登録する必要があり、購入確認·キャンセルが自動的に処理されます。",
- "escrow_common_notify_copied": "URLがクリップボードにコピーされました。"
+ "escrow_common_notify_copied": "URLがクリップボードにコピーされました。",
+ "cancel_server_ip_title": "決済キャンセルサーバーIP登録が必要です",
+ "cancel_server_ip_body": "NHN KCPは取引キャンセルのセキュリティのため、ショッピングモールサーバーの外部送信IPが店舗管理者に登録された場合にのみ、ショッピングモール管理者から実決済のキャンセルを許可します。",
+ "cancel_server_ip_path": "NHN KCP店舗管理者 > 技術管理センター > セキュリティ管理 > サーバーIP設定(決済)",
+ "cancel_server_ip_effect": "IPが登録されていないか、サーバーIPが変更された場合、この管理者画面で決済キャンセルが失敗する可能性があり、この場合KCP店舗管理者から直接キャンセルする必要があります。",
+ "cancel_server_ip_value_label": "登録するサーバーIP",
+ "cancel_server_ip_unavailable": "サーバーIP確認が必要です",
+ "cancel_server_ip_copied": "サーバーIPがクリップボードにコピーされました。",
+ "cancel_server_ip_link": "NHN KCP店舗管理者を開く",
+ "vbank_notify_path": "登録パス: NHN KCP店舗管理者 > 技術管理センター > ウェブフック(Webhook)管理 > ウェブフックURL設定"
},
"easy_pay": {
"section_title": "簡便決済"
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
index 8dff9e27..1917e860 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
@@ -1,30 +1,30 @@
{
- "identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja",
- "namespace": "g7",
- "vendor": "sirsoft",
- "name": {
- "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩",
- "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack",
- "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック"
- },
- "description": {
- "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)",
- "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
- "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
- },
- "version": "1.0.2",
- "license": "MIT",
- "scope": "plugin",
- "target_identifier": "sirsoft-pay_nhnkcp",
- "locale": "ja",
- "locale_name": "Japanese",
- "locale_native_name": "日本語",
- "text_direction": "ltr",
- "g7_version": ">=7.0.0-beta.4",
- "requires": {
- "target_version": null,
- "depends_on_core_locale": true
- },
- "github_url": "",
- "github_changelog_url": ""
+ "identifier": "g7-plugin-sirsoft-pay_nhnkcp-ja",
+ "namespace": "g7",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩",
+ "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack",
+ "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック"
+ },
+ "description": {
+ "ko": "G7 플러그인 (sirsoft-pay_nhnkcp) 일본어 언어팩 (번들)",
+ "en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
+ "ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
+ },
+ "version": "1.0.3",
+ "license": "MIT",
+ "scope": "plugin",
+ "target_identifier": "sirsoft-pay_nhnkcp",
+ "locale": "ja",
+ "locale_name": "Japanese",
+ "locale_native_name": "日本語",
+ "text_direction": "ltr",
+ "g7_version": ">=7.0.0-beta.4",
+ "requires": {
+ "target_version": null,
+ "depends_on_core_locale": true
+ },
+ "github_url": "",
+ "github_changelog_url": ""
}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md
index 5faaef6a..5833ca02 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-19
+
+### Added
+
+- 가상계좌 환불 입력 필드 표시명(거래 ID·주문번호·취소 금액·취소 사유·환불 계좌번호·은행코드·예금주)의 일본어 번역을 추가했습니다.
+
+### Removed
+
+- 원본 플러그인에서 삭제된 가상계좌 환불 필수값 안내 문구(`errors.vbank_refund_required_fields`)를 함께 정리했습니다 — 개별 필드 검증 메시지로 대체되었습니다.
+
## [1.0.2] - 2026-08-10
### Changed
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php
index 5a1a876b..3a4148b8 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/backend/ja/messages.php
@@ -6,7 +6,6 @@ return [
'order_not_found' => '注文が見つかりません。',
'invalid_request' => '無効なリクエストです。',
'invalid_amount' => 'リクエスト金額が有効ではありません。',
- 'vbank_refund_required_fields' => 'TID、注文番号、返金金額、返金口座情報(口座番号·銀行コード·口座名義人)をすべて入力してください。',
'vbank_completed_requires_bank_info' => '仮想口座入金完了の場合は返金口座情報が必要です。管理者APIを通じて返金を進めてください。',
'invalid_refund_amount' => '返金金額が有効ではありません。(リクエスト::amount)',
'vbank_refund_order_not_found' => '返金する注文が見つかりません。',
@@ -21,4 +20,13 @@ return [
'defaults' => [
'vbank_refund_msg' => '仮想口座返金',
],
+ 'fields' => [
+ 'tid' => '取引ID(TID)',
+ 'moid' => '注文番号',
+ 'cancel_amt' => 'キャンセル金額',
+ 'cancel_msg' => 'キャンセル事由',
+ 'refund_acct_no' => '返金口座番号',
+ 'refund_bank_cd' => '返金銀行コード',
+ 'refund_acct_nm' => '返金口座名義人',
+ ],
];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json
index e62c7648..66bb487c 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nicepayments-ja/language-pack.json
@@ -1,30 +1,30 @@
{
- "identifier": "g7-plugin-sirsoft-pay_nicepayments-ja",
- "namespace": "g7",
- "vendor": "sirsoft",
- "name": {
- "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩",
- "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack",
- "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック"
- },
- "description": {
- "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)",
- "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
- "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
- },
- "version": "1.0.2",
- "license": "MIT",
- "scope": "plugin",
- "target_identifier": "sirsoft-pay_nicepayments",
- "locale": "ja",
- "locale_name": "Japanese",
- "locale_native_name": "日本語",
- "text_direction": "ltr",
- "g7_version": ">=7.0.0-beta.4",
- "requires": {
- "target_version": null,
- "depends_on_core_locale": true
- },
- "github_url": "",
- "github_changelog_url": ""
+ "identifier": "g7-plugin-sirsoft-pay_nicepayments-ja",
+ "namespace": "g7",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩",
+ "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack",
+ "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック"
+ },
+ "description": {
+ "ko": "G7 플러그인 (sirsoft-pay_nicepayments) 일본어 언어팩 (번들)",
+ "en": "G7 plugin (sirsoft-pay_nicepayments) Japanese language pack (bundled)",
+ "ja": "G7 プラグイン (sirsoft-pay_nicepayments) 日本語 言語パック(バンドル)"
+ },
+ "version": "1.0.3",
+ "license": "MIT",
+ "scope": "plugin",
+ "target_identifier": "sirsoft-pay_nicepayments",
+ "locale": "ja",
+ "locale_name": "Japanese",
+ "locale_native_name": "日本語",
+ "text_direction": "ltr",
+ "g7_version": ">=7.0.0-beta.4",
+ "requires": {
+ "target_version": null,
+ "depends_on_core_locale": true
+ },
+ "github_url": "",
+ "github_changelog_url": ""
}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
index ff1db298..2ab65cc6 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.2] - 2026-08-19
+
+### Added
+
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+
## [1.0.1] - 2026-08-10
### Changed
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
index 33071568..14d56527 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
@@ -16,4 +16,7 @@ return [
'invalid_order_id' => '現金領収書発行識別子がToss Payments形式(英数字·ハイフン·アンダースコア 6~64文字)に一致していません。',
'cancel_reason' => '注文金額変更に伴う再発行',
],
+ 'errors' => [
+ 'payment_failed' => '決済処理に失敗しました。しばらく後に再度お試しください。',
+ ],
];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
index 10faf0d2..058b2364 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-tosspayments",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 4a898f0b..a957a097 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,26 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.5] - 2026-08-19
+
+### Added
+
+- 환경설정 > 드라이버 > 파일 스토리지의 S3 신설 항목 일본어 번역 추가 (`settings.drivers.storage.s3_endpoint*`, `s3_use_path_style*`, `s3_region_desc`) — 엔드포인트 URL·Path-style 토글·리전 입력 도움말이 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
+- 환경설정 드라이버 탭의 "공개 자산 스토리지" 카드 문구 일본어 번역을 추가했습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
+- 회원 정보 수정 화면의 탈퇴 확인 창 문구 일본어 번역 추가 (`admin.users.modals.withdraw_confirm_*`) — 되돌릴 수 없는 처리라는 안내와 함께 처리되는 항목 목록이 일본어 로케일에서 표시됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
+
+- 드라이버 선택란에 저장된 값이 현재 사용할 수 없는 값일 때 표시되는 안내 문구 일본어 번역을 추가했습니다.
+- 환경설정 > 업로드 탭의 「고아 첨부 정리」 카드 문구 일본어 번역을 추가했습니다 (`settings.upload.orphan_cleanup*`, `orphan_retention_days*`) — 자동 정리 토글과 보존기간 입력의 라벨·도움말이 일본어 로케일에서 표시됩니다.
+
+### Changed
+
+- S3 URL 항목의 이름·설명을 공개 URL(CDN) 용도로 명확히 한 문구 개정을 반영했습니다.
+
+### Removed
+
+- 리전 목록 선택 라벨(`settings.drivers.storage.regions.*`) 5종 제거 — 리전이 자유 입력으로 바뀌어 더 이상 사용되지 않습니다.
+- 검색엔진 선택 옵션 라벨(`settings.drivers.search_engine.options.*`) 제거 — 검색엔진 목록이 서버 제공 목록으로 바뀌어 더 이상 사용되지 않습니다.
+
## [1.0.4] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 55b11d69..231be2e9 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -208,7 +208,16 @@
"bulk_block_success": "ユーザーがブロックされました。",
"bulk_withdraw_title": "ユーザー退会処理",
"bulk_withdraw_confirm": "{{count}}人のユーザーを退会処理します。この操作は取り消すが難しい場合があります。",
- "bulk_withdraw_success": "ユーザーが退会処理されました。"
+ "bulk_withdraw_success": "ユーザーが退会処理されました。",
+ "withdraw_confirm_title": "退会処理の確認",
+ "withdraw_confirm_lead": "ステータスを「退会」に保存すると、この会員は実際に退会処理されます。元に戻すことはできません。",
+ "withdraw_confirm_effects_title": "一緒に処理される項目",
+ "withdraw_confirm_effect_anonymize": "メールアドレス·名前·ニックネームが匿名化されます。",
+ "withdraw_confirm_effect_sessions": "この会員のすべてのログインセッションが解除されます。",
+ "withdraw_confirm_effect_linked": "この会員の規約同意履歴·プロフィール画像が削除され、マイレージ·本人認証記録など連携データが整理されます。",
+ "withdraw_confirm_submit": "退会処理",
+ "bulk_withdraw_partial": "{updated}名を退会処理しました。{failed}名は処理できませんでした — {reason}",
+ "bulk_withdraw_all_failed": "退会処理できませんでした — {reason}"
},
"form": {
"page_title_create": "ユーザー登録",
@@ -1383,7 +1392,13 @@
"image_max_width": "最大幅",
"image_max_height": "最大高さ",
"image_quality": "画像品質",
- "image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。"
+ "image_quality_hint": "1〜100の値を入力してください。値が大きいほど品質が良くなります。",
+ "orphan_cleanup": "孤立した添付ファイルのクリーンアップ",
+ "orphan_cleanup_desc": "フォームを保存せずに離脱して、所有者がいなくなった添付ファイルをクリーンアップします。",
+ "orphan_cleanup_enabled": "孤立した添付ファイルの自動クリーンアップ",
+ "orphan_cleanup_enabled_hint": "デフォルトはオフです — 管理者が直接有効化する必要があります。有効化すると、保持期間を過ぎた孤立した添付ファイルのファイルと記録を毎日削除します。サイトロゴなど現在使用中の添付ファイルは削除しません。",
+ "orphan_retention_days": "孤立した添付ファイルの保持期間(日数)",
+ "orphan_retention_days_hint": "アップロード後この期間が経過した孤立した添付ファイルのみがクリーンアップの対象になります。フォーム作成中の中断を考慮して、余裕を持って設定してください。(1~3650日)"
},
"seo": {
"meta_settings": "メタタグ設定",
@@ -1619,7 +1634,9 @@
},
"drivers": {
"common": {
- "optional": "オプション"
+ "optional": "オプション",
+ "unavailable_saved_value_prefix": "保存された値 ",
+ "unavailable_saved_value_suffix": " は現在使用できません。利用可能な値を選択して保存してください。"
},
"storage": {
"title": "ファイルストレージ",
@@ -1631,17 +1648,21 @@
},
"s3_bucket": "S3バケット",
"s3_region": "S3リージョン",
- "regions": {
- "ap_northeast_2": "アジアパシフィック(ソウル)",
- "ap_northeast_1": "アジアパシフィック(東京)",
- "us_east_1": "米国東部(バージニア)",
- "us_west_2": "米国西部(オレゴン)",
- "eu_west_1": "ヨーロッパ(アイルランド)"
- },
+ "s3_region_desc": "AWSリージョンコードまたはS3互換ストレージのリージョン値を入力してください。(例: ap-northeast-2、Cloudflare R2 は auto)",
"s3_access_key": "Access Key",
"s3_secret_key": "Secret Key",
- "s3_url": "S3 URL",
- "s3_url_desc": "カスタムエンドポイントまたはCDN URLを使用する場合のみ入力してください。"
+ "s3_endpoint": "エンドポイントURL",
+ "s3_endpoint_desc": "S3互換ストレージ(Cloudflare R2、MinIOなど)のAPIアドレスです。AWS S3の場合は空欄にしてください。",
+ "s3_use_path_style": "Path-styleアドレスを使用",
+ "s3_use_path_style_desc": "MinIOなどpath-styleアドレス専用ストレージで有効にします。",
+ "s3_url": "S3 URL (公開URL)",
+ "s3_url_desc": "ファイルの公開URL生成に使うCDN/カスタムドメインです。APIリクエストアドレスはエンドポイントURLに入力してください。"
+ },
+ "public_asset": {
+ "title": "公開アセットストレージ",
+ "desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。",
+ "disk": "ディスク",
+ "s3_help": "S3を選択する場合は、上のファイルストレージカードのS3 URL(CDNドメイン)設定が必要です。未設定の場合はストリーミングで動作します。"
},
"cache": {
"title": "キャッシュ",
@@ -1734,9 +1755,6 @@
"desc": "統合検索および管理者検索に使用する検索エンジンドライバーを設定します。",
"driver": "検索エンジンドライバー",
"driver_desc": "デフォルト提供のMySQL FULLTEXT(ngram)エンジンは、追加設定なしで日本語検索をサポートしています。",
- "options": {
- "mysql_fulltext": "MySQL FULLTEXT (ngram)"
- },
"plugin_notice": "検索エンジンプラグイン(Meilisearch、Elasticsearchなど)をインストールすると、追加ドライバーが表示されます。"
},
"test_connection": "接続テスト",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index d759ed88..063f3b9c 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.4",
+ "version": "1.0.5",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
index ad368308..4dadeb0c 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.1] - 2026-08-19
+
+### Added
+
+- 통합검색 실패 안내 문구(`search.failed.title`, `search.failed.suggestion`) 일본어 번역 추가 — 검색 중 오류가 발생했을 때 "검색 결과 없음" 과 구분되는 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+
## [1.1.0] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
index c4e9132a..d99323af 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
@@ -38,6 +38,10 @@
"pages_in_all": "ページの検索結果がありません",
"suggestion": "別の検索キーワードで試してみてください"
},
+ "failed": {
+ "title": "検索中にエラーが発生しました",
+ "suggestion": "しばらくしてからもう一度お試しください"
+ },
"page_published_at": "公開日:{{date}}",
"view_more": "さらに表示",
"view_all": "すべて表示",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
index 450ac498..70dc6acc 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php
index a876b138..87dd138b 100644
--- a/lang/en/exceptions.php
+++ b/lang/en/exceptions.php
@@ -3,6 +3,9 @@
return [
// User related exceptions
'cannot_delete_super_admin' => 'Super admin cannot be deleted.',
+ 'cannot_modify_super_admin' => 'You do not have permission to modify a super admin account or role.',
+ 'cannot_grant_unheld_permission' => 'You cannot grant permissions you do not hold or a broader scope than your own.',
+ 'cannot_modify_protected_role' => 'You do not have permission to modify a system or extension-owned role.',
'circular_reference' => 'Layout circular reference detected: :trace',
'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
diff --git a/lang/en/settings.php b/lang/en/settings.php
index a16d02bf..8b3f2797 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -21,6 +21,7 @@ return [
'backup_success' => 'Database backup started successfully.',
'backup_failed' => 'Failed to start database backup.',
'backup_error' => 'An error occurred while backing up database.',
+ 'database_backup_unavailable' => 'Database backup is not available yet. Use the settings backup feature to back up your settings.',
'backup_path_required' => 'Please enter a backup path.',
'restore_success' => 'Settings restored successfully.',
'restore_failed' => 'Failed to restore settings.',
@@ -43,6 +44,11 @@ return [
'local' => 'Local',
's3' => 'Amazon S3',
],
+ 'public_asset' => [
+ 'none' => 'Disabled (streaming)',
+ 'public' => 'Public disk',
+ 's3' => 'Amazon S3',
+ ],
'cache' => [
'file' => 'File',
'redis' => 'Redis',
@@ -69,6 +75,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
+ 'search' => [
+ 'mysql-fulltext' => 'MySQL Full-Text',
+ ],
],
// Driver connection test messages
@@ -82,6 +91,7 @@ return [
's3_test_failed' => 'Failed to connect to S3 bucket.',
's3_missing_config' => 'S3 configuration is missing. (bucket, region, access key, secret key)',
's3_sdk_missing' => 'AWS SDK is not installed.',
+ 's3_adapter_missing' => 'S3 storage adapter (league/flysystem-aws-s3-v3) is not installed.',
's3_bucket_not_found' => 'S3 bucket not found.',
's3_access_denied' => 'Access to S3 bucket was denied.',
's3_invalid_credentials' => 'S3 credentials are invalid.',
@@ -103,6 +113,10 @@ return [
// Websocket test messages
'websocket_test_success' => 'Successfully connected to Websocket server.',
'websocket_test_failed' => 'Failed to connect to Websocket server.',
+ 'websocket_server_test_failed' => 'Failed to connect to the Websocket server-side (backend broadcast) endpoint. The client endpoint is reachable.',
+ 'driver_unusable_s3_adapter' => 'S3 storage adapter (league/flysystem-aws-s3-v3) is not installed.',
+ 'driver_unusable_redis_client' => 'Neither the phpredis extension nor the predis library is available.',
+ 'driver_unusable_memcached_extension' => 'The memcached PHP extension is not installed.',
'websocket_invalid_host' => 'The Websocket host setting is invalid. Enter the address only — credentials (@) and protocols other than http/https are not allowed.',
'websocket_connection_refused' => 'Could not connect to Websocket server. Please check if the server is running.',
diff --git a/lang/en/user.php b/lang/en/user.php
index 51dbf232..8f26375a 100644
--- a/lang/en/user.php
+++ b/lang/en/user.php
@@ -21,11 +21,11 @@ return [
'fetch_success' => 'User information retrieved successfully.',
'fetch_failed' => 'Failed to retrieve user information.',
'create_success' => 'User created successfully.',
- 'create_failed' => 'Failed to create user: :error',
+ 'create_failed' => 'Failed to create user. Please try again later.',
'update_success' => 'User information updated successfully.',
- 'update_failed' => 'Failed to update user information: :error',
+ 'update_failed' => 'Failed to update user information. Please try again later.',
'delete_success' => 'User deleted successfully.',
- 'delete_failed' => 'Failed to delete user: :error',
+ 'delete_failed' => 'Failed to delete user. Please try again later.',
'statistics_success' => 'User statistics retrieved successfully.',
'statistics_failed' => 'Failed to retrieve user statistics.',
'search_success' => 'User search completed successfully.',
@@ -69,8 +69,9 @@ return [
// Withdraw messages
'withdraw_success' => 'Account withdrawal completed successfully.',
- 'withdraw_failed' => 'Failed to withdraw account: :error',
+ 'withdraw_failed' => 'Failed to withdraw account. Please try again later.',
'withdraw_admin_forbidden' => 'Administrator account cannot be withdrawn.',
+ 'withdraw_failed_unknown' => 'Withdrawal failed due to an unknown error.',
// Validation messages
'validation' => [
diff --git a/lang/en/validation.php b/lang/en/validation.php
index 2a851261..7305d75d 100644
--- a/lang/en/validation.php
+++ b/lang/en/validation.php
@@ -211,6 +211,8 @@ return [
'invalid_json' => 'Invalid JSON format.',
'must_be_array' => 'Layout data must be an array.',
+ 'dangerous_expression' => 'The layout contains a disallowed expression: :snippet',
+ 'external_resource_url' => 'External resource URLs are not allowed (same-origin paths only): :url',
'required_field_missing' => "Required field ':field' is missing.",
'version_must_be_string' => 'The version field must be a string.',
'layout_name_must_be_string' => 'The layout_name field must be a string.',
@@ -513,6 +515,7 @@ return [
// Schedule command (shell/Artisan) validation messages — these run on the server
'schedule_command' => [
'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server may be used, and special characters such as pipes (|) or semicolons (;) are not permitted.',
+ 'shell_interpreter_denied' => 'You cannot run inline commands/code through an interpreter or point it at an unsafe path. After an interpreter, specify only an absolute-path script file.',
'artisan_denied' => 'This Artisan command cannot be run as a schedule for security reasons.',
'artisan_not_allowlisted' => 'This Artisan command is not allowed to run on a schedule. Only maintenance commands such as cache clearing and queue processing can be registered.',
'artisan_malformed' => 'The Artisan command format is invalid. Quotes, backslashes, and short options (-v) are not allowed; use "command --option[=value]" only.',
@@ -825,9 +828,13 @@ return [
// Setting value validation messages
'setting' => [
'value' => [
- 'required' => 'Setting value is required.',
- 'string' => 'Setting value must be a string.',
+ 'present' => 'The value field must be present in the request.',
+ 'boolean' => 'This setting only accepts an on/off value.',
+ 'integer' => 'This setting only accepts an integer.',
+ 'numeric' => 'This setting only accepts a number.',
+ 'type' => 'The setting value has an unsupported format.',
'max' => 'Setting value may not be greater than :max characters.',
+ 'array_max' => 'Setting value may not be larger than :max characters.',
],
],
@@ -954,6 +961,10 @@ return [
'image_quality_integer' => 'Image quality must be an integer.',
'image_quality_min' => 'Image quality must be at least 1.',
'image_quality_max' => 'Image quality may not be greater than 100.',
+ 'orphan_cleanup_enabled_boolean' => 'Orphan attachment cleanup must be enabled or disabled.',
+ 'orphan_retention_days_integer' => 'Orphan attachment retention period must be an integer.',
+ 'orphan_retention_days_min' => 'Orphan attachment retention period must be at least 1 day.',
+ 'orphan_retention_days_max' => 'Orphan attachment retention period may not be greater than 3650 days.',
// SEO settings
'meta_title_suffix_max' => 'Meta title suffix may not be greater than 100 characters.',
@@ -1011,15 +1022,19 @@ return [
'storage_driver_invalid' => 'Please select a valid storage driver.',
's3_bucket_required' => 'S3 bucket name is required.',
's3_bucket_max' => 'S3 bucket name may not be greater than 255 characters.',
- 's3_region_required' => 'Please select an S3 region.',
- 's3_region_invalid' => 'Please select a valid S3 region.',
+ 's3_region_invalid' => 'S3 region may only contain lowercase letters, numbers, and hyphens. (e.g. ap-northeast-2, or auto for Cloudflare R2)',
+ 's3_region_max' => 'S3 region may not be greater than 64 characters.',
's3_access_key_required' => 'S3 access key is required.',
's3_access_key_max' => 'S3 access key may not be greater than 255 characters.',
's3_secret_key_required' => 'S3 secret key is required.',
's3_secret_key_max' => 'S3 secret key may not be greater than 255 characters.',
's3_url_url' => 'S3 URL must be a valid URL.',
's3_url_invalid' => 'S3 URL is not a valid URL format.',
- 's3_url_max' => 'S3 URL may not be greater than 255 characters.',
+ 's3_url_max' => 'S3 URL may not be greater than 500 characters.',
+ 's3_endpoint_invalid' => 'S3 endpoint is not a valid URL format.',
+ 's3_endpoint_max' => 'S3 endpoint may not be greater than 500 characters.',
+ 's3_use_path_style_boolean' => 'Path-style addressing must be true or false.',
+ 'driver_unusable' => "The ':driver' driver cannot be used on this server. :reason",
// Driver settings - Cache
'cache_driver_required' => 'Please select a cache driver.',
@@ -1084,6 +1099,9 @@ return [
'log_days_min' => 'Log retention days must be at least 1.',
'log_days_max' => 'Log retention days may not be greater than 365.',
+ // Public asset disk settings
+ 'public_asset_disk_invalid' => 'Please select a valid public asset disk.',
+
// Identity verification (IDV) settings
'identity_default_provider_string' => 'Default provider must be a string.',
'identity_default_provider_max' => 'Default provider identifier may not be longer than 100 characters.',
@@ -1146,7 +1164,8 @@ return [
// Validation attribute names (validation.attributes)
'attributes' => [
- 'ids' => 'user ID list',
+ 'ids' => 'ID list',
+ 'user_ids' => 'user ID list',
'user_id' => 'user ID',
'status' => 'status',
// Settings fields
@@ -1155,7 +1174,8 @@ return [
'site_description' => 'site description',
'admin_email' => 'admin email',
'timezone' => 'timezone',
- 'language' => 'default language',
+ 'language' => 'language',
+ 'default_language' => 'default language',
// Identity verification (IDV) fields
'identity_default_provider' => 'default provider',
'identity_purpose_providers' => 'purpose-to-provider mapping',
@@ -1173,10 +1193,14 @@ return [
'identity_policy_fail_mode' => 'fail mode',
// Mail settings
'mailer' => 'mailer',
- 'host' => 'SMTP host',
- 'port' => 'SMTP port',
- 'username' => 'SMTP username',
- 'password' => 'SMTP password',
+ 'host' => 'host',
+ 'port' => 'port',
+ 'username' => 'username',
+ 'password' => 'password',
+ 'smtp_host' => 'SMTP host',
+ 'smtp_port' => 'SMTP port',
+ 'smtp_username' => 'SMTP username',
+ 'smtp_password' => 'SMTP password',
'encryption' => 'encryption',
'from_address' => 'sender email',
'from_name' => 'sender name',
@@ -1186,6 +1210,8 @@ return [
'image_max_width' => 'max image width',
'image_max_height' => 'max image height',
'image_quality' => 'image quality',
+ 'orphan_cleanup_enabled' => 'orphan attachment cleanup',
+ 'orphan_retention_days' => 'orphan attachment retention period',
// SEO settings
'meta_title_suffix' => 'meta title suffix',
'meta_description' => 'meta description',
@@ -1200,6 +1226,8 @@ return [
's3_access_key' => 'S3 access key',
's3_secret_key' => 'S3 secret key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 endpoint URL',
+ 's3_use_path_style' => 'path-style addressing',
'cache_driver' => 'cache driver',
'redis_host' => 'Redis host',
'redis_port' => 'Redis port',
@@ -1228,8 +1256,10 @@ return [
'ses_region' => 'SES region',
'mailgun_endpoint' => 'Mailgun endpoint',
// General settings (additional)
- 'channels' => 'notification channels',
- 'currency' => 'default currency',
+ 'channels' => 'channels',
+ 'notification_channels' => 'notification channels',
+ 'currency' => 'currency',
+ 'default_currency' => 'default currency',
'maintenance_mode' => 'maintenance mode',
'asset_url_mode' => 'asset URL mode',
'site_logo' => 'site logo',
@@ -1243,7 +1273,8 @@ return [
'twitter_default_card' => 'Twitter default card type',
'twitter_default_site' => 'Twitter default account',
'seo_page_cache_enabled' => 'SEO page cache',
- 'cache_ttl' => 'SEO page cache lifetime',
+ 'cache_ttl' => 'cache lifetime',
+ 'seo_page_cache_ttl' => 'SEO page cache lifetime',
'sitemap_enabled' => 'sitemap',
'sitemap_cache_ttl' => 'sitemap cache lifetime',
'sitemap_urls_per_file' => 'sitemap URLs per file',
@@ -1294,5 +1325,6 @@ return [
'log_driver' => 'log driver',
'log_level' => 'log level',
'log_days' => 'log retention days',
+ 'public_asset_disk' => 'public asset disk',
],
];
diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php
index bb5bee32..ac818777 100644
--- a/lang/ko/exceptions.php
+++ b/lang/ko/exceptions.php
@@ -3,6 +3,9 @@
return [
// 사용자 관련 예외
'cannot_delete_super_admin' => '슈퍼 관리자는 삭제할 수 없습니다.',
+ 'cannot_modify_super_admin' => '슈퍼 관리자 계정 또는 역할은 수정할 권한이 없습니다.',
+ 'cannot_grant_unheld_permission' => '본인이 보유하지 않았거나 더 넓은 범위의 권한은 부여할 수 없습니다.',
+ 'cannot_modify_protected_role' => '시스템 또는 확장이 소유한 역할은 수정할 권한이 없습니다.',
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
diff --git a/lang/ko/settings.php b/lang/ko/settings.php
index 670016ce..7480e8ed 100644
--- a/lang/ko/settings.php
+++ b/lang/ko/settings.php
@@ -21,6 +21,7 @@ return [
'backup_success' => '데이터베이스 백업이 성공적으로 시작되었습니다.',
'backup_failed' => '데이터베이스 백업에 실패했습니다.',
'backup_error' => '데이터베이스 백업 중 오류가 발생했습니다.',
+ 'database_backup_unavailable' => '데이터베이스 백업 기능은 아직 제공하지 않습니다. 설정 백업은 설정 백업 기능을 이용해 주세요.',
'backup_path_required' => '백업 경로를 입력해주세요.',
'restore_success' => '설정이 성공적으로 복원되었습니다.',
'restore_failed' => '설정 복원에 실패했습니다.',
@@ -43,6 +44,11 @@ return [
'local' => '로컬',
's3' => 'Amazon S3',
],
+ 'public_asset' => [
+ 'none' => '사용 안 함 (스트리밍)',
+ 'public' => 'Public 디스크',
+ 's3' => 'Amazon S3',
+ ],
'cache' => [
'file' => '파일',
'redis' => 'Redis',
@@ -69,6 +75,9 @@ return [
'mailgun' => 'Mailgun',
'ses' => 'SES (Amazon)',
],
+ 'search' => [
+ 'mysql-fulltext' => 'MySQL 전문검색',
+ ],
],
// 드라이버 연결 테스트 메시지
@@ -82,6 +91,7 @@ return [
's3_test_failed' => 'S3 버킷 연결에 실패했습니다.',
's3_missing_config' => 'S3 설정이 누락되었습니다. (버킷, 리전, 액세스 키, 시크릿 키)',
's3_sdk_missing' => 'AWS SDK가 설치되어 있지 않습니다.',
+ 's3_adapter_missing' => 'S3 스토리지 어댑터(league/flysystem-aws-s3-v3)가 설치되어 있지 않습니다.',
's3_bucket_not_found' => 'S3 버킷을 찾을 수 없습니다.',
's3_access_denied' => 'S3 버킷에 대한 접근이 거부되었습니다.',
's3_invalid_credentials' => 'S3 인증 정보가 올바르지 않습니다.',
@@ -103,6 +113,10 @@ return [
// Websocket 테스트 메시지
'websocket_test_success' => 'Websocket 서버에 성공적으로 연결되었습니다.',
'websocket_test_failed' => 'Websocket 서버 연결에 실패했습니다.',
+ 'websocket_server_test_failed' => 'Websocket 서버(백엔드 발송용) endpoint 연결에 실패했습니다. 클라이언트 endpoint 는 정상입니다.',
+ 'driver_unusable_s3_adapter' => 'S3 스토리지 어댑터(league/flysystem-aws-s3-v3)가 설치되어 있지 않습니다.',
+ 'driver_unusable_redis_client' => 'phpredis 확장과 predis 라이브러리가 모두 없습니다.',
+ 'driver_unusable_memcached_extension' => 'memcached PHP 확장이 설치되어 있지 않습니다.',
'websocket_invalid_host' => 'Websocket 호스트 설정이 올바르지 않습니다. 호스트에는 주소만 입력하고 계정 정보(@)나 http/https 이외의 프로토콜은 사용할 수 없습니다.',
'websocket_connection_refused' => 'Websocket 서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
diff --git a/lang/ko/user.php b/lang/ko/user.php
index 3bbc06d6..fc000849 100644
--- a/lang/ko/user.php
+++ b/lang/ko/user.php
@@ -21,11 +21,11 @@ return [
'fetch_success' => '사용자 정보를 성공적으로 가져왔습니다.',
'fetch_failed' => '사용자 정보를 가져오는데 실패했습니다.',
'create_success' => '사용자가 성공적으로 생성되었습니다.',
- 'create_failed' => '사용자 생성에 실패했습니다: :error',
+ 'create_failed' => '사용자 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
'update_success' => '사용자 정보가 성공적으로 업데이트되었습니다.',
- 'update_failed' => '사용자 정보 업데이트에 실패했습니다: :error',
+ 'update_failed' => '사용자 정보 업데이트에 실패했습니다. 잠시 후 다시 시도해 주세요.',
'delete_success' => '사용자가 성공적으로 삭제되었습니다.',
- 'delete_failed' => '사용자 삭제에 실패했습니다: :error',
+ 'delete_failed' => '사용자 삭제에 실패했습니다. 잠시 후 다시 시도해 주세요.',
'statistics_success' => '사용자 통계를 성공적으로 가져왔습니다.',
'statistics_failed' => '사용자 통계를 가져오는데 실패했습니다.',
'search_success' => '사용자 검색을 성공적으로 완료했습니다.',
@@ -69,8 +69,9 @@ return [
// 회원 탈퇴 관련 메시지
'withdraw_success' => '회원 탈퇴가 완료되었습니다.',
- 'withdraw_failed' => '회원 탈퇴에 실패했습니다: :error',
+ 'withdraw_failed' => '회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.',
'withdraw_admin_forbidden' => '관리자 계정은 탈퇴할 수 없습니다.',
+ 'withdraw_failed_unknown' => '알 수 없는 오류로 탈퇴 처리에 실패했습니다.',
// 유효성 검사 메시지
'validation' => [
diff --git a/lang/ko/validation.php b/lang/ko/validation.php
index 2bb31837..ce529839 100644
--- a/lang/ko/validation.php
+++ b/lang/ko/validation.php
@@ -210,6 +210,8 @@ return [
'invalid_json' => '유효하지 않은 JSON 형식입니다.',
'must_be_array' => '레이아웃 데이터는 배열이어야 합니다.',
+ 'dangerous_expression' => '허용되지 않는 표현식이 포함되어 있습니다: :snippet',
+ 'external_resource_url' => '외부 리소스 URL은 허용되지 않습니다(동일 출처 경로만 허용): :url',
'required_field_missing' => "필수 필드 ':field'가 누락되었습니다.",
'version_must_be_string' => 'version 필드는 문자열이어야 합니다.',
'layout_name_must_be_string' => 'layout_name 필드는 문자열이어야 합니다.',
@@ -512,6 +514,7 @@ return [
// 서버에서 실행되는 스케줄 command(쉘·Artisan) 검증 메시지
'schedule_command' => [
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.',
+ 'shell_interpreter_denied' => '인터프리터로 인라인 명령/코드를 실행하거나 안전하지 않은 경로를 지정할 수 없습니다. 인터프리터 뒤에는 절대경로 스크립트 파일만 지정하세요.',
'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
'artisan_not_allowlisted' => '예약 실행이 허용된 Artisan 명령이 아닙니다. 캐시 정리·큐 처리 등 유지보수 명령만 등록할 수 있습니다.',
'artisan_malformed' => 'Artisan 명령 형식이 올바르지 않습니다. 따옴표·역슬래시·단축 옵션(-v)은 쓸 수 없고 "명령명 --옵션[=값]" 형태만 등록할 수 있습니다.',
@@ -826,9 +829,13 @@ return [
// 설정값 검증 메시지
'setting' => [
'value' => [
- 'required' => '설정 값은 필수입니다.',
- 'string' => '설정 값은 문자열이어야 합니다.',
+ 'present' => '설정 값 항목(value)이 요청에 포함되어야 합니다.',
+ 'boolean' => '이 설정은 사용/사용 안 함 값만 저장할 수 있습니다.',
+ 'integer' => '이 설정은 정수만 저장할 수 있습니다.',
+ 'numeric' => '이 설정은 숫자만 저장할 수 있습니다.',
+ 'type' => '저장할 수 없는 형식의 설정 값입니다.',
'max' => '설정 값은 :max자를 초과할 수 없습니다.',
+ 'array_max' => '설정 값의 크기가 :max자를 초과할 수 없습니다.',
],
],
@@ -893,6 +900,10 @@ return [
'image_quality_integer' => '이미지 품질은 정수여야 합니다.',
'image_quality_min' => '이미지 품질은 1 이상이어야 합니다.',
'image_quality_max' => '이미지 품질은 100을 초과할 수 없습니다.',
+ 'orphan_cleanup_enabled_boolean' => '고아 첨부 자동 정리는 사용/사용 안 함 중 하나여야 합니다.',
+ 'orphan_retention_days_integer' => '고아 첨부 보존기간은 정수여야 합니다.',
+ 'orphan_retention_days_min' => '고아 첨부 보존기간은 1일 이상이어야 합니다.',
+ 'orphan_retention_days_max' => '고아 첨부 보존기간은 3650일을 초과할 수 없습니다.',
// SEO 설정
'meta_title_suffix_max' => '타이틀 접미사는 100자를 초과할 수 없습니다.',
@@ -1006,11 +1017,16 @@ return [
'storage_driver_required' => '스토리지 드라이버를 선택해주세요.',
'storage_driver_invalid' => '올바른 스토리지 드라이버를 선택해주세요.',
's3_bucket_max' => 'S3 버킷 이름은 255자를 초과할 수 없습니다.',
- 's3_region_invalid' => '올바른 S3 리전을 선택해주세요.',
+ 's3_region_invalid' => 'S3 리전은 소문자 영숫자와 하이픈만 사용할 수 있습니다. (예: ap-northeast-2, Cloudflare R2 는 auto)',
+ 's3_region_max' => 'S3 리전은 64자를 초과할 수 없습니다.',
's3_access_key_max' => 'S3 Access Key는 255자를 초과할 수 없습니다.',
's3_secret_key_max' => 'S3 Secret Key는 255자를 초과할 수 없습니다.',
's3_url_invalid' => '올바른 S3 URL 형식이 아닙니다.',
's3_url_max' => 'S3 URL은 500자를 초과할 수 없습니다.',
+ 's3_endpoint_invalid' => '올바른 S3 엔드포인트 URL 형식이 아닙니다.',
+ 's3_endpoint_max' => 'S3 엔드포인트 URL은 500자를 초과할 수 없습니다.',
+ 's3_use_path_style_boolean' => 'Path-style 주소 사용 설정은 true 또는 false 값이어야 합니다.',
+ 'driver_unusable' => "':driver' 드라이버를 이 서버에서 사용할 수 없습니다. :reason",
'cache_driver_required' => '캐시 드라이버를 선택해주세요.',
'cache_driver_invalid' => '올바른 캐시 드라이버를 선택해주세요.',
'redis_host_max' => 'Redis 호스트는 255자를 초과할 수 없습니다.',
@@ -1061,9 +1077,11 @@ return [
'log_days_min' => '로그 보관 일수는 1 이상이어야 합니다.',
'log_days_max' => '로그 보관 일수는 365를 초과할 수 없습니다.',
+ // 공개 자산 디스크 설정
+ 'public_asset_disk_invalid' => '올바른 공개 자산 디스크를 선택해주세요.',
+
// 드라이버 조건부 필수 메시지 (선택 드라이버에 따라 필수)
's3_bucket_required' => 'S3 버킷 이름은 필수입니다.',
- 's3_region_required' => 'S3 리전을 선택해주세요.',
's3_access_key_required' => 'S3 Access Key는 필수입니다.',
's3_secret_key_required' => 'S3 Secret Key는 필수입니다.',
's3_url_url' => 'S3 URL은 유효한 URL이어야 합니다.',
@@ -1139,7 +1157,8 @@ return [
// 검증 속성명 (validation.attributes)
'attributes' => [
- 'ids' => '사용자 ID 목록',
+ 'ids' => 'ID 목록',
+ 'user_ids' => '사용자 ID 목록',
'user_id' => '사용자 ID',
'status' => '상태',
// 일반 설정 필드
@@ -1148,7 +1167,8 @@ return [
'site_description' => '사이트 설명',
'admin_email' => '관리자 이메일',
'timezone' => '시간대',
- 'language' => '기본 언어',
+ 'language' => '언어',
+ 'default_language' => '기본 언어',
// 본인인증(IDV) 필드
'identity_default_provider' => '기본 프로바이더',
'identity_purpose_providers' => '목적별 프로바이더',
@@ -1166,10 +1186,14 @@ return [
'identity_policy_fail_mode' => '실패 모드',
// 메일 설정 필드
'mailer' => '메일러',
- 'host' => 'SMTP 호스트',
- 'port' => 'SMTP 포트',
- 'username' => 'SMTP 사용자명',
- 'password' => 'SMTP 비밀번호',
+ 'host' => '호스트',
+ 'port' => '포트',
+ 'username' => '사용자명',
+ 'password' => '비밀번호',
+ 'smtp_host' => 'SMTP 호스트',
+ 'smtp_port' => 'SMTP 포트',
+ 'smtp_username' => 'SMTP 사용자명',
+ 'smtp_password' => 'SMTP 비밀번호',
'encryption' => '암호화',
'from_address' => '발신자 이메일',
'from_name' => '발신자 이름',
@@ -1179,6 +1203,8 @@ return [
'image_max_width' => '이미지 최대 너비',
'image_max_height' => '이미지 최대 높이',
'image_quality' => '이미지 품질',
+ 'orphan_cleanup_enabled' => '고아 첨부 자동 정리',
+ 'orphan_retention_days' => '고아 첨부 보존기간',
// SEO 설정 필드
'meta_title_suffix' => '메타 타이틀 접미사',
'meta_description' => '메타 설명',
@@ -1196,6 +1222,8 @@ return [
's3_access_key' => 'S3 Access Key',
's3_secret_key' => 'S3 Secret Key',
's3_url' => 'S3 URL',
+ 's3_endpoint' => 'S3 엔드포인트 URL',
+ 's3_use_path_style' => 'Path-style 주소 사용',
'cache_driver' => '캐시 드라이버',
'redis_host' => 'Redis 호스트',
'redis_port' => 'Redis 포트',
@@ -1221,8 +1249,10 @@ return [
'ses_region' => 'SES 리전',
'mailgun_endpoint' => 'Mailgun 엔드포인트',
// 일반 설정 (추가)
- 'channels' => '알림 채널',
- 'currency' => '기본 통화',
+ 'channels' => '채널',
+ 'notification_channels' => '알림 채널',
+ 'currency' => '통화',
+ 'default_currency' => '기본 통화',
'maintenance_mode' => '점검 모드',
'asset_url_mode' => '자산 주소 방식',
'site_logo' => '사이트 로고',
@@ -1236,7 +1266,8 @@ return [
'twitter_default_card' => '트위터 기본 카드 유형',
'twitter_default_site' => '트위터 기본 계정',
'seo_page_cache_enabled' => 'SEO 페이지 캐시 사용',
- 'cache_ttl' => 'SEO 페이지 캐시 유지시간',
+ 'cache_ttl' => '캐시 유지시간',
+ 'seo_page_cache_ttl' => 'SEO 페이지 캐시 유지시간',
'sitemap_enabled' => '사이트맵 사용',
'sitemap_cache_ttl' => '사이트맵 캐시 유지시간',
'sitemap_urls_per_file' => '사이트맵 파일당 URL 수',
@@ -1287,5 +1318,6 @@ return [
'log_driver' => '로그 드라이버',
'log_level' => '로그 레벨',
'log_days' => '로그 보관 일수',
+ 'public_asset_disk' => '공개 자산 디스크',
],
];
diff --git a/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md
index dcd51587..d593b246 100644
--- a/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md
+++ b/modules/_bundled/gnuboard7-hello_module/docs/api/memos.md
@@ -41,11 +41,73 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_목록 응답: `data.data` 가 항목 배열, `data.meta` 가 페이지 정보, `data.abilities` 가 컬렉션 레벨 권한 (`MemoCollection`)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| data | array | `[{...}]` | 메모 항목 배열 (각 항목은 `MemoResource` — 아래 표) |
+| meta.current_page | integer | `1` | 현재 페이지 번호 |
+| meta.last_page | integer | `3` | 마지막 페이지 번호 |
+| meta.per_page | integer | `10` | 페이지당 항목 수 (미지정 시 기본 `10`) |
+| meta.total | integer | `27` | 전체 항목 수 |
+| abilities.can_create | boolean | `true` | 요청자의 `gnuboard7-hello_module.memos.create` 보유 여부 |
+| abilities.can_update | boolean | `true` | 요청자의 `gnuboard7-hello_module.memos.update` 보유 여부 |
+| abilities.can_delete | boolean | `true` | 요청자의 `gnuboard7-hello_module.memos.delete` 보유 여부 |
+
+`data.data[]` 항목 (`MemoResource`):
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| id | integer | `1` | 메모 기본키 |
+| uuid | string(uuid) | `9f2c1b0e-…` | 외부 노출용 식별자 |
+| title | string | `첫 번째 메모` | 제목 |
+| content | string | `메모 본문입니다.` | 본문 내용 |
+| created_at | string | `2026-08-16 01:30:00` | 생성 일시 (요청자 타임존으로 포맷) |
+| updated_at | string | `2026-08-16 01:30:00` | 수정 일시 (요청자 타임존으로 포맷) |
+| abilities | object | `{"can_create":true, …}` | 항목 레벨 권한 (컬렉션 `abilities` 와 같은 3종) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "메모 목록을 조회했습니다.",
+ "data": {
+ "data": [
+ {
+ "id": 1,
+ "uuid": "9f2c1b0e-4d7a-4f10-9c33-1a5b6e8d2f04",
+ "title": "첫 번째 메모",
+ "content": "메모 본문입니다.",
+ "created_at": "2026-08-16 01:30:00",
+ "updated_at": "2026-08-16 01:30:00",
+ "abilities": {
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+ ],
+ "meta": {
+ "current_page": 1,
+ "last_page": 3,
+ "per_page": 10,
+ "total": 27
+ },
+ "abilities": {
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
+
+> 위 문서의 실측이 `403` 으로 관측된 것은 프로브 계정에 `gnuboard7-hello_module.memos.read` 권한이 없었기 때문이다. 권한을 갖춘 요청은 `200` 과 위 페이로드를 받는다.
**에러 응답**
@@ -53,11 +115,16 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.read`)이 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `page` 최소 1, `per_page` 1~100) |
+| 500 | Internal Server Error | 조회 중 예외 발생 (`gnuboard7-hello_module::messages.memo.fetch_failed`, `errors.error` 에 예외 메시지) |
-**설명**
+**설명**
+
+메모 목록을 페이지 단위로 조회한다. 학습용 샘플 모듈의 표준 목록 엔드포인트로, 코어의 `AdminBaseController` + `BaseApiCollection` 조합을 그대로 따른다.
+
+`per_page` 를 지정하지 않으면 기본값 `10` 이 적용된다. 상·하한(1~100)은 `MemoListRequest` 가 검증하므로 Service 는 검증 없이 값을 그대로 쓴다 — 검증은 FormRequest 책임이라는 규칙의 예시다.
### POST /api/modules/gnuboard7-hello_module/admin/memos
@@ -90,11 +157,45 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 가 생성된 메모 하나 (`MemoResource`)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| id | integer | `28` | 생성된 메모의 기본키 |
+| uuid | string(uuid) | `3b7e5a12-…` | 외부 노출용 식별자 (생성 시 자동 부여) |
+| title | string | `예시 제목` | 제목 |
+| content | string | `예시 내용입니다.` | 본문 내용 |
+| created_at | string | `2026-08-16 01:30:00` | 생성 일시 (요청자 타임존으로 포맷) |
+| updated_at | string | `2026-08-16 01:30:00` | 수정 일시 (생성 직후에는 `created_at` 과 같다) |
+| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 요청자의 메모 권한 3종 |
**응답 예시**
-
+```http
+HTTP/1.1 201
+```
+
+```json
+{
+ "success": true,
+ "message": "메모가 생성되었습니다.",
+ "data": {
+ "id": 28,
+ "uuid": "3b7e5a12-8c04-4d61-9b2f-7e0a1c4d5f88",
+ "title": "예시 제목",
+ "content": "예시 내용입니다.",
+ "created_at": "2026-08-16 01:30:00",
+ "updated_at": "2026-08-16 01:30:00",
+ "abilities": {
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
+
+> 성공 상태코드는 `200` 이 아니라 **`201 Created`** 다. 위 문서의 실측이 `403` 으로 관측된 것은 프로브 계정에 `gnuboard7-hello_module.memos.create` 권한이 없었기 때문이다.
**에러 응답**
@@ -102,11 +203,16 @@ Content-Type: application/json
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.create`)이 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `title` 필수·255자 이하, `content` 필수) |
+| 500 | Internal Server Error | 생성 중 예외 발생 (`gnuboard7-hello_module::messages.memo.create_failed`, `errors.error` 에 예외 메시지) |
-**설명**
+**설명**
+
+메모를 생성한다. 검증은 `StoreMemoRequest` 가 전담하고 Service 는 검증된 배열만 받는다 — Service 에 검증 로직을 두지 않는다는 규칙의 예시다.
+
+컨트롤러가 `$request->validated()` 를 넘기므로 FormRequest 에 정의되지 않은 필드는 모델에 도달하지 않는다. `$request->all()` / `except()` 를 쓰면 `$fillable` 을 통해 미정의 필드가 새므로 쓰지 않는다.
### DELETE /api/modules/gnuboard7-hello_module/admin/memos/{id}
@@ -132,11 +238,25 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_삭제 응답에는 페이로드가 없다. 컨트롤러가 `success(메시지)` 만 호출하므로 `data` 는 `null` 이다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| (없음) | null | `null` | 삭제 성공 시 `data` 는 항상 `null`. 결과 판정은 `success` 와 상태코드로 한다 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "메모가 삭제되었습니다.",
+ "data": null
+}
+```
**에러 응답**
@@ -144,11 +264,16 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.delete`)이 없는 경우 |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 404 | Not Found | 해당 `id` 의 메모가 없는 경우 (`gnuboard7-hello_module::messages.memo.not_found`) |
+| 500 | Internal Server Error | 삭제 중 예외 발생 (`gnuboard7-hello_module::messages.memo.delete_failed`, `errors.error` 에 예외 메시지) |
-**설명**
+**설명**
+
+메모를 삭제한다. 컨트롤러가 먼저 `getMemo($id)` 로 대상을 조회하므로, 존재하지 않는 `id` 는 삭제 시도 전에 `404` 로 걸러진다.
+
+삭제는 DB CASCADE 에 의존하지 않고 Service 가 명시적으로 수행한다 — 훅 발화·파일 정리·로깅을 보장하기 위해서다.
### GET /api/modules/gnuboard7-hello_module/admin/memos/{id}
@@ -174,11 +299,43 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 가 메모 하나 (`MemoResource`)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| id | integer | `2` | 기본 키 (내부 식별자) |
+| uuid | string(uuid) | `4473e9ee-ecdf-4ad0-afb3-78ada47265af` | 외부 노출용 UUID |
+| title | string | `두 번째 메모` | 제목 |
+| content | string | `Memo 엔티티의 CRUD 동작을 확인할 수 있는 추가 샘플입니다.` | 본문 내용 |
+| created_at | string | `2026-07-31 22:09:15` | 생성 일시 (요청자 타임존으로 포맷) |
+| updated_at | string | `2026-07-31 22:09:15` | 수정 일시 (요청자 타임존으로 포맷) |
+| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 요청자의 메모 권한 3종 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "메모를 조회했습니다.",
+ "data": {
+ "id": 2,
+ "uuid": "4473e9ee-ecdf-4ad0-afb3-78ada47265af",
+ "title": "두 번째 메모",
+ "content": "Memo 엔티티의 CRUD 동작을 확인할 수 있는 추가 샘플입니다.",
+ "created_at": "2026-07-31 22:09:15",
+ "updated_at": "2026-07-31 22:09:15",
+ "abilities": {
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -186,11 +343,16 @@ Authorization: Bearer {YOUR_TOKEN}
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.read`)이 없는 경우 |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 404 | Not Found | 해당 `id` 의 메모가 없는 경우 (`gnuboard7-hello_module::messages.memo.not_found`) |
+| 500 | Internal Server Error | 조회 중 예외 발생 (`gnuboard7-hello_module::messages.memo.fetch_failed`, `errors.error` 에 예외 메시지) |
-**설명**
+**설명**
+
+메모 단건을 조회하는 관리자 엔드포인트다. path 파라미터는 `int` 타입힌트를 받는 **기본키 `id`** 이며, 응답에 함께 실리는 `uuid` 가 아니다.
+
+같은 리소스의 공개 조회는 `GET /api/modules/gnuboard7-hello_module/memos/{id}` 로 별도 제공된다. 관리자 경로는 `permission:gnuboard7-hello_module.memos.read` 를 요구하는 반면 공개 경로는 `optional.sanctum` 이라 비회원도 접근한다.
### PUT /api/modules/gnuboard7-hello_module/admin/memos/{id}
@@ -224,11 +386,43 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 가 수정된 메모 하나 (`MemoResource`)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| id | integer | `2` | 기본 키 (수정으로 바뀌지 않는다) |
+| uuid | string(uuid) | `4473e9ee-ecdf-4ad0-afb3-78ada47265af` | 외부 노출용 UUID (수정으로 바뀌지 않는다) |
+| title | string | `예시 제목` | 수정된 제목 |
+| content | string | `예시 내용입니다.` | 수정된 본문 내용 |
+| created_at | string | `2026-07-31 22:09:15` | 생성 일시 (불변) |
+| updated_at | string | `2026-08-16 01:30:00` | 수정 일시 (이번 요청 시각으로 갱신) |
+| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 요청자의 메모 권한 3종 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "메모가 수정되었습니다.",
+ "data": {
+ "id": 2,
+ "uuid": "4473e9ee-ecdf-4ad0-afb3-78ada47265af",
+ "title": "예시 제목",
+ "content": "예시 내용입니다.",
+ "created_at": "2026-07-31 22:09:15",
+ "updated_at": "2026-08-16 01:30:00",
+ "abilities": {
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -236,12 +430,17 @@ Content-Type: application/json
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`gnuboard7-hello_module.memos.update`)이 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 404 | Not Found | 해당 `id` 의 메모가 없는 경우 (`gnuboard7-hello_module::messages.memo.not_found`) |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지 — `title` 필수·255자 이하, `content` 필수) |
+| 500 | Internal Server Error | 수정 중 예외 발생 (`gnuboard7-hello_module::messages.memo.update_failed`, `errors.error` 에 예외 메시지) |
-**설명**
+**설명**
+
+메모를 수정한다. `PUT` 이므로 `title` 과 `content` 를 **모두** 보내야 한다 (`UpdateMemoRequest` 가 둘 다 필수로 검증). 일부 필드만 보내면 `422` 다.
+
+컨트롤러는 `getMemo($id)` 로 대상을 먼저 조회하므로 존재하지 않는 `id` 는 수정 시도 전에 `404` 로 걸러지고, Service 에는 `$request->validated()` 결과만 전달되어 FormRequest 미정의 필드가 모델에 도달하지 않는다.
### GET /api/modules/gnuboard7-hello_module/memos
diff --git a/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php b/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
index a969f0b7..b04e4557 100644
--- a/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
+++ b/modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
@@ -3,6 +3,7 @@
namespace Modules\Gnuboard7\HelloModule\Tests;
use App\Enums\ExtensionStatus;
+use App\Extension\HookManager;
use App\Models\Module;
use App\Models\Permission;
use App\Models\Role;
@@ -10,6 +11,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
+use Modules\Gnuboard7\HelloModule\Providers\HelloModuleServiceProvider;
use Tests\TestCase;
/**
@@ -52,7 +54,7 @@ abstract class ModuleTestCase extends TestCase
$this->registerModuleAutoload();
- $this->app->register(\Modules\Gnuboard7\HelloModule\Providers\HelloModuleServiceProvider::class);
+ $this->app->register(HelloModuleServiceProvider::class);
$this->runModuleMigrationIfNeeded();
@@ -80,7 +82,7 @@ abstract class ModuleTestCase extends TestCase
*/
private function snapshotHookManager(): void
{
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(),
@@ -97,7 +99,7 @@ abstract class ModuleTestCase extends TestCase
return;
}
- $ref = new \ReflectionClass(\App\Extension\HookManager::class);
+ $ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
@@ -153,8 +155,12 @@ abstract class ModuleTestCase extends TestCase
$file = $moduleBasePath.'/src/'.str_replace('\\', '/', $relativeClass).'.php';
}
- if (file_exists($file)) {
- require $file;
+ if (file_exists($file)
+ && ! class_exists($class, false) && ! interface_exists($class, false)
+ && ! trait_exists($class, false) && ! enum_exists($class, false)) {
+ // 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
+ // 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
+ require_once $file;
}
});
}
@@ -221,12 +227,12 @@ abstract class ModuleTestCase extends TestCase
'version' => '0.1.0-beta.1',
]);
- $hookManager = app(\App\Extension\HookManager::class);
+ $hookManager = app(HookManager::class);
foreach ($module->getHookListeners() as $listenerClass) {
foreach ($listenerClass::getSubscribedHooks() as $hookName => $config) {
$method = is_array($config) ? ($config['method'] ?? 'handle') : 'handle';
$priority = is_array($config) ? ($config['priority'] ?? 10) : 10;
- $hookManager->addAction($hookName, [new $listenerClass(), $method], $priority);
+ $hookManager->addAction($hookName, [new $listenerClass, $method], $priority);
}
}
}
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 61e275d5..854e3cad 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,54 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.4] - 2026-08-19
+
+### Security
+
+- 게시글·댓글 삭제와 게시글 수정 시 비회원 확인용 비밀번호/검증 토큰 입력에 형식 검증을 추가해 비정상 형태(배열 주입 등)의 요청을 차단합니다.
+- 관리자 게시판 목록 조회의 정렬 기준과 페이지 크기를 허용된 값으로만 제한했습니다.
+
+- 비밀글의 내용이 열람 권한 없이 새어 나가던 경로를 모두 막았습니다. 게시글 상세 화면은 비밀글 내용을 가렸지만, 상품 문의 목록·비밀글의 댓글 목록·비밀글의 첨부파일(다운로드/미리보기)은 게시글의 비밀 여부를 확인하지 않아 주소만 알면 원문·첨부를 볼 수 있었습니다. 이제 이 경로 전부에서 작성자 본인 또는 게시판 관리 권한(비밀글 열람)을 가진 요청에만 내용을 제공하고, 그 외에는 내용·제목·답변·첨부를 가립니다. 열람 권한자의 상세 화면에 표시되는 비밀글·삭제글 첨부의 이미지 썸네일은 한시적으로만 유효한 서명 주소로 전달되어, 보호를 유지한 채 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
+- 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914)
+- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
+- 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다.
+- 로그인 없이 열리는 목록(최근 글·인기 글)과 관리 대시보드의 요약 목록에서, 열람 권한이 없는 게시판의 글과 블라인드·비활성 게시판의 글이 노출되던 문제를 막았습니다. 이제 이 목록들은 블라인드·비활성 게시판 글을 제외하고, 열람 권한이 있는 게시판의 글만 각 요청자에게 보여 줍니다. 인기 게시판 목록과 통합 검색의 게시판 필터에서도 열람 권한이 없는 비공개 게시판이 더 이상 나타나지 않습니다. 비밀글은 기존과 동일하게 목록에 제목과 🔒 표시가 유지되며 본문만 보호됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
+- 전체 게시판 목록(게시판 디렉토리)과 그 안의 게시판별 최신 글 목록에도 같은 열람 권한 기준을 적용했습니다. 위 목록들에는 제한이 적용되었지만 이 경로에는 빠져 있어, 첫 화면과 전체 게시판 화면에서 열람 권한이 없는 게시판의 이름과 글 제목이 로그인 없이도 보였습니다. 열람 권한이 있는 방문자에게는 종전과 동일하게 표시됩니다.
+- 게시판 관리 대시보드(개요·글 추이·최근 글·미처리 신고)에 별도의 열람 권한을 요구하도록 했습니다. 이전에는 관리자이기만 하면 게시판 권한이 전혀 없어도 이 화면의 정보를 볼 수 있었습니다. 미처리 신고 집계는 비활성 게시판을 제외합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
+- 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
+- 댓글 비밀번호 확인 후 받는 임시 토큰이 실제 검증에 연결되어 있지 않던 문제를 수정했습니다. 이제 이 토큰으로 댓글을 수정·삭제할 수 있으며(비밀번호 재입력 대체), 토큰은 한 번만 사용되고 만료됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
+
+### Added
+
+- 답글이 달린 글의 삭제 방식을 게시판마다 선택할 수 있습니다. "함께 삭제"(기본값)는 글을 삭제하면 달려 있던 답글도 함께 휴지통으로 이동하고, 글을 복원하면 그때 함께 지워진 답글만 같이 복원됩니다. "삭제 차단"은 답글이 남아 있는 동안 원글 삭제를 막고 사유를 안내합니다. 사용자가 직접 지운 답글은 원글을 복원해도 되살아나지 않습니다.
+- 글쓰기 중 파일만 올리고 저장하지 않은 채 나간 임시 첨부를 매일 자동 정리합니다. 종전에는 이런 파일이 서버에 계속 쌓여도 회수할 방법이 없었습니다.
+- 삭제한 첨부의 파일을 보존기간이 지난 뒤 완전히 파기하는 기능이 추가되었습니다. 게시판 환경설정 > 기본 설정에서 켤 수 있으며(기본 꺼짐), 보존기간은 휴지통 복원 가능 기간과 같게 유지하시면 됩니다.
+
+### Changed
+
+- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
+- 첨부 미리보기·작성자 아바타 주소 조립을 코어와 공유하는 단일 지점으로 정리했습니다. 표시되는 주소와 동작은 종전과 동일합니다.
+- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
+
+### Fixed
+
+- 통합검색에서 게시글 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록 실패 정보를 응답에 싣습니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 샘플 게시글 생성 도구가 데이터베이스 접두어를 기본값이 아닌 값으로 설치한 사이트에서 오류가 나던 문제를 수정했습니다. (#101 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 관리자 게시글 작성 화면의 안내 아이콘이 의도한 크기와 다르게 보이던 문제를 수정했습니다.
+- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
+- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
+- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
+- 첨부 개수 상한 초과·대댓글 깊이 초과·댓글을 달 수 없는 글·메뉴 중복 안내도 같은 방식으로 정리했습니다. 안내 문구를 사용자의 언어 설정으로 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
+- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
+- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
+- 댓글 목록을 불러올 때 댓글마다 원글을 반복 조회하던 비효율을 제거해 응답을 더 빠르게 했습니다. 댓글이 많은 글일수록 개선 폭이 큽니다.
+- 답글이 달린 글을 삭제해도 답글이 지워지지 않아, 목록에는 보이지 않으면서 주소로는 열리는 상태로 남던 문제를 수정했습니다. (#107 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 이커머스 문의 연동: 이미 답변이 있는 문의 글에는 연동 경로로도 답변 글이 중복 생성되지 않도록 방어를 추가했습니다. (#106 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 게시판을 삭제할 때 다른 기능(상품 문의 연동 등)이 그 게시판의 글과 연결해 둔 데이터를 함께 정리할 수 있도록, 삭제되는 글 목록을 연동 기능에 알려줍니다.
+- 신고 목록의 동작하지 않는 정렬 파라미터를 정리했습니다.
+- 관리자 화면(게시글 작성·환경설정 저장·기본값 일괄 적용·메일/알림 템플릿·SEO/스팸 캐시 정리·본인인증 정책)에서 저장·실행이 실패했을 때 실패 사유 대신 일반 안내 문구만 표시되던 문제를 수정했습니다. 이제 서버가 알려준 사유가 그대로 안내됩니다.
+
## [1.0.3] - 2026-08-10
### Security
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index 02e719ba..fb973132 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/config/board.php b/modules/_bundled/sirsoft-board/config/board.php
index 16f4a55b..2e6af244 100644
--- a/modules/_bundled/sirsoft-board/config/board.php
+++ b/modules/_bundled/sirsoft-board/config/board.php
@@ -155,6 +155,10 @@ return [
'max_comment_depth_min' => 0,
'max_comment_depth_max' => 10,
+ // 삭제 첨부 영구 정리 보존기간 (일)
+ 'attachment_purge_retention_days_min' => 1,
+ 'attachment_purge_retention_days_max' => 3650,
+
// 신고 정책
'auto_hide_threshold_min' => 0,
'auto_hide_threshold_max' => 100,
diff --git a/modules/_bundled/sirsoft-board/config/settings/defaults.json b/modules/_bundled/sirsoft-board/config/settings/defaults.json
index f7e2e7dc..d55e2029 100644
--- a/modules/_bundled/sirsoft-board/config/settings/defaults.json
+++ b/modules/_bundled/sirsoft-board/config/settings/defaults.json
@@ -2,7 +2,7 @@
"_meta": {
"version": "1.0.0",
"description": "게시판 모듈 환경설정",
- "categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications"]
+ "categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications", "attachment_settings"]
},
"defaults": {
"basic_defaults": {
@@ -15,6 +15,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -100,6 +101,10 @@
{ "id": "mail", "is_active": true, "sort_order": 0 },
{ "id": "database", "is_active": true, "sort_order": 1 }
]
+ },
+ "attachment_settings": {
+ "purge_enabled": false,
+ "purge_retention_days": 30
}
},
"frontend_schema": {
@@ -118,6 +123,9 @@
},
"seo": {
"expose": false
+ },
+ "attachment_settings": {
+ "expose": false
}
}
}
\ No newline at end of file
diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php
new file mode 100644
index 00000000..10f5d878
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000001_add_reply_delete_policy_to_boards_table.php
@@ -0,0 +1,49 @@
+string('reply_delete_policy', 20)
+ ->default('cascade')
+ ->comment('답글 삭제 정책 (block: 답글 있으면 삭제 차단, cascade: 함께 소프트 삭제)')
+ ->after('max_reply_depth');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (! Schema::hasTable('boards') || ! Schema::hasColumn('boards', 'reply_delete_policy')) {
+ return;
+ }
+
+ Schema::table('boards', function (Blueprint $table) {
+ $table->dropColumn('reply_delete_policy');
+ });
+ }
+};
diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php
new file mode 100644
index 00000000..a0ee27dc
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_17_000002_modify_trigger_type_in_board_posts_table.php
@@ -0,0 +1,51 @@
+where('trigger_type', 'cascade')
+ ->update(['trigger_type' => 'admin']);
+
+ DB::statement(
+ "ALTER TABLE {$prefix}board_posts MODIFY COLUMN trigger_type "
+ ."ENUM('report', 'admin', 'system', 'auto_hide', 'user') "
+ ."NOT NULL DEFAULT 'admin' "
+ ."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)'"
+ );
+ }
+};
diff --git a/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php b/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
index df014f21..d95db723 100644
--- a/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
+++ b/modules/_bundled/sirsoft-board/database/seeders/Sample/PostSampleSeeder.php
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Board\Database\Seeders\Sample;
+use App\Extension\Storage\ModuleStorageDriver;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
@@ -10,7 +11,6 @@ use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Models\Board;
@@ -362,71 +362,75 @@ class PostSampleSeeder extends Seeder
{
$this->command->info('비정규화 카운트 컬럼 동기화 중...');
+ // raw SQL 은 접두사가 자동 부착되지 않는다 — 리터럴 `g7_` 을 박으면
+ // DB_PREFIX 가 다른 설치에서 1146(테이블 없음)이 된다.
+ $prefix = DB::getTablePrefix();
+
// posts.comments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
- DB::statement('
- UPDATE g7_board_posts p
+ DB::statement("
+ UPDATE {$prefix}board_posts p
SET comments_count = (
- SELECT COUNT(*) FROM g7_board_comments c
+ SELECT COUNT(*) FROM {$prefix}board_comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
)
- ');
+ ");
// posts.replies_count (자기 참조 — 파생 테이블 사용)
- DB::statement('
- UPDATE g7_board_posts p
+ DB::statement("
+ UPDATE {$prefix}board_posts p
LEFT JOIN (
SELECT parent_id, COUNT(*) AS cnt
- FROM g7_board_posts
+ FROM {$prefix}board_posts
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
GROUP BY parent_id
) t ON t.parent_id = p.id
SET p.replies_count = COALESCE(t.cnt, 0)
- ');
+ ");
// posts.attachments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
- DB::statement('
- UPDATE g7_board_posts p
+ DB::statement("
+ UPDATE {$prefix}board_posts p
SET attachments_count = (
- SELECT COUNT(*) FROM g7_board_attachments a
+ SELECT COUNT(*) FROM {$prefix}board_attachments a
WHERE a.post_id = p.id AND a.deleted_at IS NULL
)
- ');
+ ");
// comments.replies_count (자기 참조 — 파생 테이블 사용)
- DB::statement('
- UPDATE g7_board_comments c
+ DB::statement("
+ UPDATE {$prefix}board_comments c
LEFT JOIN (
SELECT parent_id, COUNT(*) AS cnt
- FROM g7_board_comments
+ FROM {$prefix}board_comments
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
GROUP BY parent_id
) t ON t.parent_id = c.id
SET c.replies_count = COALESCE(t.cnt, 0)
- ');
+ ");
// boards.posts_count (게시판별 미삭제 게시글 수 — 답글 포함)
- DB::statement('
- UPDATE g7_boards b
+ DB::statement("
+ UPDATE {$prefix}boards b
LEFT JOIN (
SELECT board_id, COUNT(*) AS cnt
- FROM g7_board_posts
+ FROM {$prefix}board_posts
WHERE deleted_at IS NULL
GROUP BY board_id
) t ON t.board_id = b.id
SET b.posts_count = COALESCE(t.cnt, 0)
- ');
+ ");
// boards.comments_count (게시판별 미삭제 댓글 수)
- DB::statement('
- UPDATE g7_boards b
+ DB::statement("
+ UPDATE {$prefix}boards b
LEFT JOIN (
SELECT board_id, COUNT(*) AS cnt
- FROM g7_board_comments
+ FROM {$prefix}board_comments
WHERE deleted_at IS NULL
GROUP BY board_id
) t ON t.board_id = b.id
SET b.comments_count = COALESCE(t.cnt, 0)
- ');
+ ");
$this->command->info(' ✅ posts.comments_count / posts.replies_count / posts.attachments_count / comments.replies_count / boards.posts_count / boards.comments_count 일괄 갱신 완료');
}
@@ -470,6 +474,9 @@ class PostSampleSeeder extends Seeder
$datePath = date('Y/m/d');
+ // 모듈 스토리지 드라이버 경유 — 최종 경로는 {identifier}/attachments/{path} 로 종전과 동일
+ $storage = new ModuleStorageDriver('sirsoft-board', 'modules');
+
for ($i = 0; $i < self::SAMPLE_IMAGE_COUNT; $i++) {
$filename = Str::uuid().'.jpg';
$path = "samples/{$datePath}/{$filename}";
@@ -484,7 +491,7 @@ class PostSampleSeeder extends Seeder
// 스토리지에 파일 저장
$storagePath = "sirsoft-board/attachments/{$path}";
- Storage::disk('modules')->put($storagePath, $imageContent);
+ $storage->put('attachments', $path, $imageContent);
// 파일 정보 저장
$this->sampleImages[] = [
diff --git a/modules/_bundled/sirsoft-board/docs/api/board-types.md b/modules/_bundled/sirsoft-board/docs/api/board-types.md
index f19e409a..7d89b98c 100644
--- a/modules/_bundled/sirsoft-board/docs/api/board-types.md
+++ b/modules/_bundled/sirsoft-board/docs/api/board-types.md
@@ -206,6 +206,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 |
| 404 | Not Found | 해당 `id` 의 게시판 유형이 없는 경우 (`게시판 유형을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 해당 유형을 사용 중인 게시판이 있는 경우 (`:count개 게시판에서 사용 중인 유형은 삭제할 수 없습니다.`) 또는 게시판 기본 유형(`basic_defaults.type`)으로 설정된 경우 (`기본값으로 설정된 유형은 삭제할 수 없습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) — 도메인 규칙 위반이 아닌 예외는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md
index 209a90a0..d0b3c625 100644
--- a/modules/_bundled/sirsoft-board/docs/api/board.md
+++ b/modules/_bundled/sirsoft-board/docs/api/board.md
@@ -16,6 +16,16 @@
---
+## 비밀글 서버측 게이팅 (KVE-2026-1914)
+
+비밀글(`is_secret`)의 원문은 작성자 본인 또는 게시판 관리 권한(`posts.read-secret`/`manager`)을 가진 요청에만 제공됩니다. 판정은 `SecretContentGate`(SSoT)가 담당하며 게시글 상세 외 다음 경로에도 동일하게 적용됩니다.
+
+- **댓글 목록**(`GET .../posts/{postId}/comments`): 부모 게시글이 비밀글이고 열람 권한이 없으면 빈 목록(`200`)을 반환합니다.
+- **첨부 서빙**(`GET .../attachment/{hash}`, `.../attachment/{hash}/preview`): 부모 게시글이 비밀글이고 열람 권한이 없으면 `403`. 첨부 요청은 상세와 분리된 요청이라 비밀번호 검증(`password_verified`)은 적용되지 않으며 작성자/관리 권한만 인정합니다.
+- **상세/목록 응답**: 비열람자에게 `content`·`title`·`reply`·`attachments`가 마스킹됩니다.
+
+---
+
## 목록·검색의 총 건수와 답변·댓글 상한
게시판 목록에 `search` 를 얹으면 내부 검색이 수행됩니다. 매칭이 아주 많을 수 있으므로 총
@@ -351,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -981,10 +991,11 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.admin.posts.write\|sirsoft-board.{slug}.admin.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 422 | Unprocessable Entity | 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 대상 글에 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) |
-**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다.
+**설명** 게시판 관리자가 게시글 1건을 소프트 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `posts.write` 또는 `manage`입니다. 컨트롤러가 대상 게시글을 조회한 뒤 세분화된 권한 분기를 적용합니다: `admin.manage`는 모든 글(비회원 글 포함)을, `admin.posts.write`는 본인 글만 삭제할 수 있으며 이미 삭제된 글의 재처리는 `admin.manage`가 필요합니다. `PostService::deletePost()`가 'admin' 컨텍스트로 소프트 삭제를 수행합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분(`trigger_type='cascade'`)만 선택 복원되며, `block` 에서는 살아 있는 직계 답글이 있으면 `before_delete` 훅 발화 전에 차단되어(부수효과 없음) 422 를 반환합니다.
### GET /api/modules/sirsoft-board/admin/board/{slug}/posts/{id}
@@ -1029,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md
index 55f2117b..a964edb6 100644
--- a/modules/_bundled/sirsoft-board/docs/api/boards.md
+++ b/modules/_bundled/sirsoft-board/docs/api/boards.md
@@ -25,7 +25,14 @@
**요청 파라미터**
-_요청 파라미터 없음._
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
+| per_page | query | integer | 아니오 | min 1, max 999 | 페이지당 항목 수 (게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처의 전량 조회를 수용) |
+| type | query | string | 아니오 | max 50 | 게시판 타입 슬러그 필터 (basic, gallery 등) |
+| search | query | string | 아니오 | max 100 | 검색어 (게시판명/슬러그 부분 일치) |
+| sort_by | query | string | 아니오 | `id`, `slug`, `type`, `is_active`, `posts_count`, `comments_count`, `created_at`, `updated_at` | 정렬 기준 컬럼 (닫힌 집합 — `name` 은 다국어 JSON 컬럼이라 정렬 대상에서 제외) |
+| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (대문자 `ASC`/`DESC` 로 보내도 검증 전 소문자로 정규화되어 수용) |
**요청 예시**
@@ -102,10 +109,11 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.read`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) — `sort_by` 가 허용 컬럼 목록 밖이거나, `sort_order` 가 `asc`/`desc` 가 아니거나, `per_page` 가 1~999 범위를 벗어난 경우 등 (`IndexBoardRequest` 검증) |
-**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, 요청의 필터/페이징 파라미터를 그대로 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다.
+**설명** 관리자 화면의 게시판 관리 목록을 조회합니다. `auth:sanctum` + `sirsoft-board.boards.read` 권한이 필요하며, `IndexBoardRequest` 로 검증된 필터/정렬/페이징 파라미터만 서비스에 넘겨 전체 게시판을 페이지네이션합니다. 정렬 컬럼(`sort_by`)과 페이지 크기(`per_page` 1~999)는 닫힌 집합으로 제한되어 허용값 밖 요청은 422 로 거부됩니다. 각 항목은 `BoardCollection::withPermissions()` 로 감싸져 현재 관리자의 생성/수정/삭제 가능 여부(`abilities`)를 함께 반환합니다.
### POST /api/modules/sirsoft-board/admin/boards
@@ -150,6 +158,7 @@ HTTP/1.1 200
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 아니오 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 미지정 시 Service가 Manager/Step 역할을 주입) |
| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 |
+| reply_delete_policy | body | string | 예 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 검증 전 기본값 `cascade` 로 채워지며, 허용값 밖이면 422 |
| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 |
| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 |
| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 |
@@ -213,6 +222,7 @@ Content-Type: application/json
"예시값"
],
"max_reply_depth": 1,
+ "reply_delete_policy": "cascade",
"max_comment_depth": 1,
"notify_admin_on_post": true,
"notify_author": true,
@@ -244,6 +254,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시판 = `BoardResource`,
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 |
@@ -301,6 +312,7 @@ HTTP/1.1 201
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -390,6 +402,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 기본값 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| show_view_count | boolean | `true` | 조회수 노출 |
@@ -445,6 +458,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -755,6 +769,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -812,6 +827,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -973,6 +989,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -1030,6 +1047,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1267,6 +1285,7 @@ HTTP/1.1 200
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) |
| max_reply_depth | body | integer | 아니오 | min 1, max 10 | 답변글 최대 중첩 깊이 |
+| reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 달린 글의 삭제 정책 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 미전송 시 기존값 유지, 허용값 밖이면 422 |
| max_comment_depth | body | integer | 아니오 | min 0, max 10 | 대댓글 최대 중첩 깊이 |
| notify_admin_on_post | body | boolean | 예 | — | 게시글 등록 시 관리자에게 이메일 알림 발송 여부 |
| notify_author | body | boolean | 예 | — | 댓글·대댓글·답변글·관리자 처리 발생 시 작성자에게 이메일 알림 발송 여부 |
@@ -1329,6 +1348,7 @@ Content-Type: application/json
"예시값"
],
"max_reply_depth": 1,
+ "reply_delete_policy": "block",
"max_comment_depth": 1,
"notify_admin_on_post": true,
"notify_author": true,
@@ -1360,6 +1380,7 @@ _단건 응답: `data` 객체의 필드 (수정된 게시판 = `BoardResource`,
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC, DESC) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 |
@@ -1417,6 +1438,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1980,6 +2002,7 @@ _단건 응답: `data` 객체의 필드._
| use_comment | boolean | `true` | 댓글 기능 사용 |
| use_reply | boolean | `true` | 게시글 답변 기능 사용 (댓글에 대한 답글 아님) |
| max_reply_depth | integer | `5` | 답변글 최대 깊이 (1~10) |
+| reply_delete_policy | string | `cascade` | 답글 달린 글의 삭제 정책 (block: 답글이 살아 있으면 원글 삭제 차단, cascade: 원글 삭제 시 답글도 함께 소프트 삭제) |
| use_report | boolean | `true` | 게시글/댓글 신고 기능 사용 |
| comment_order | string | `ASC` | 댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순) |
| max_comment_depth | integer | `10` | 대댓글 최대 깊이 (1~10) |
@@ -2037,6 +2060,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": true,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -2346,7 +2370,9 @@ Cache-Control: public, max-age=86400
-**설명** 해시로 지정한 이미지 첨부파일을 인라인 미리보기로 제공합니다. 게시글 본문 내 이미지 표시용이라 비회원도 접근할 수 있으며(권한 체크 없음), 이미지가 아닌 파일 요청 시 400을 반환합니다. 응답에는 레이아웃 캐시 TTL(기본 24시간) 기반 캐싱 헤더가 포함되고, 삭제글 첨부 등 차단 대상은 403을 반환합니다.
+**설명** 해시로 지정한 이미지 첨부파일을 인라인 미리보기로 제공합니다. 게시글 본문 내 이미지 표시용이라 비회원도 접근할 수 있으며(권한 체크 없음), 이미지가 아닌 파일 요청 시 400을 반환합니다. 응답에는 레이아웃 캐시 TTL(기본 24시간) 기반 캐싱 헤더가 포함되고, 삭제글·비밀글 첨부 등 차단 대상은 403을 반환합니다.
+
+브라우저 `
` 는 Authorization 헤더를 실을 수 없으므로, 비밀글·삭제글의 열람 권한자에게 직렬화되는 상세 응답의 `preview_url` 에는 `expires`·`signature` 쿼리가 붙은 **한시 서명 URL** 이 실리며, 이 엔드포인트는 유효한 서명을 열람 권한과 동등한 자격으로 허용합니다. 서명이 변조·만료된 요청은 종전과 동일하게 403 으로 차단됩니다. 정상글의 `preview_url` 은 무서명 공개 경로입니다.
### POST /api/modules/sirsoft-board/boards/{slug}/attachments
@@ -2961,6 +2987,7 @@ HTTP/1.1 200
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
+| is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.store_validation_rules`).
@@ -3324,6 +3351,9 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
| id | path | string | 예 | — | 대상 리소스의 식별자 |
+| password | body | string | 아니오 | min 4 | 비회원 게시글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)은 `DestroyPostRequest` 가 검증하며, 배열 주입(`password[]=x`) 등 형식 위반은 422. 소유권 판정 자체는 컨트롤러(`canModifyPost`)가 수행 |
+| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 삭제 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 |
+| force_delete | body | boolean | 아니오 | — | 불리언 플래그 (형식 검증만 수행 — 불리언으로 해석 불가한 값은 422) |
**요청 예시**
@@ -3358,11 +3388,12 @@ HTTP/1.1 200
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.posts.write\|sirsoft-board.{slug}.manager`)이 없거나, 작성자 본인·게시판 관리자·비회원 글 검증(토큰/비밀번호) 중 어느 조건도 만족하지 않는 경우 (`게시글 삭제 권한이 없습니다.`) |
| 404 | Not Found | 활성 게시판 또는 게시글이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터(`password`/`verification_token`/`force_delete`)가 형식 검증을 위반한 경우 (`error.errors` 에 필드별 메시지) / 게시판의 답글 삭제 정책(`reply_delete_policy`)이 `block` 이고 살아 있는 답글이 있는 경우 (`답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.` — `sirsoft-board::validation.post.delete.has_replies`) |
| 500 | Internal Server Error | 삭제 처리 중 예외 (`게시글 삭제에 실패했습니다.`) |
-**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다.
+**설명** 게시글을 삭제(소프트 삭제)합니다. `auth:sanctum` + `sirsoft-board.{slug}.posts.write` 또는 게시판 manager 권한이 필요합니다. 작성자 본인, 게시판 관리자(admin.manage/manager), 또는 비회원 글의 경우 검증 토큰·비밀번호 확인 중 하나를 만족해야 삭제할 수 있으며, 조건 미충족 시 403을 반환합니다. 삭제 동작은 게시판의 답글 삭제 정책(`reply_delete_policy`)을 따릅니다 — `cascade`(기본)에서는 원글 삭제 시 살아 있는 답글 트리(및 그 답글들의 댓글·첨부)가 함께 소프트 삭제되고 이후 원글을 복원하면 연쇄 삭제분이 함께 복원되며, `block` 에서는 살아 있는 답글이 있으면 어떤 부수효과도 없이 422 로 삭제가 차단됩니다.
### GET /api/modules/sirsoft-board/boards/{slug}/posts/{id}
@@ -3628,6 +3659,9 @@ HTTP/1.1 200
| id | path | string | 예 | — | 대상 리소스의 식별자 |
| attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) |
| temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 |
+| is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) |
+| password | body | string | 아니오 | min 4 | 비회원 게시글 수정 시 소유권 확인용 비밀번호. 형식(문자열, 4자 이상)만 여기서 검증하며 배열 주입 등 형식 위반은 422. 소유권 판정은 컨트롤러(`canModifyPost`)가 수행 |
+| verification_token | body | string | 아니오 | max 255 | 비회원 게시글 수정 시 소유권 확인용 검증 토큰 (비밀번호 검증 후 발급). 문자열 형식 위반 시 422 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`).
@@ -4344,6 +4378,8 @@ HTTP/1.1 201
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
| postId | path | string | 예 | — | 대상 post의 식별자 |
| commentId | path | string | 예 | — | 대상 comment의 식별자 |
+| password | body | string | 아니오 | min 4, max 20 | 비회원 댓글 삭제 시 소유권 확인용 비밀번호. 형식(문자열, 4~20자)은 `DestroyCommentRequest` 가 검증하며, 배열 주입 등 형식 위반은 422. 소유권 판정 자체는 서비스(`canDelete`)가 수행 |
+| verification_token | body | string | 아니오 | max 255 | 비회원 댓글 삭제 시 비밀번호 재입력을 대체하는 임시 검증 토큰 (verify-password 로 발급, 1회 소비). 문자열 형식 위반(배열 주입 등)은 422 |
**요청 예시**
@@ -4378,6 +4414,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-board.{slug}.comments.write\|sirsoft-board.{slug}.manager`)이 없거나, 게시판의 댓글 기능이 꺼진 경우, 또는 작성자 본인·게시판 관리자·비회원 댓글 비밀번호 확인 중 어느 조건도 만족하지 않는 경우 (`댓글 삭제 권한이 없습니다.`) |
| 404 | Not Found | 게시판 또는 댓글이 없는 경우 (`댓글을 찾을 수 없습니다.`) |
+| 422 | Unprocessable Entity | `password` 가 형식 검증(문자열, 4~20자)을 위반한 경우 (`error.errors.password` — 배열 주입 차단) |
| 500 | Internal Server Error | 삭제 처리 중 예외 (`댓글 삭제에 실패했습니다.`) |
@@ -4398,6 +4435,9 @@ HTTP/1.1 200
| slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) |
| postId | path | string | 예 | — | 대상 post의 식별자 |
| commentId | path | string | 예 | — | 대상 comment의 식별자 |
+| content | body | string | 예 | 게시판 설정의 min/max 길이 | 수정할 댓글 본문 (금지 키워드 검사 포함) |
+| password | body | string | 조건부 | min 4, max 20 | 비회원 댓글 수정 시 소유권 확인용 비밀번호 (검증 토큰이 있으면 생략 가능) |
+| verification_token | body | string | 아니오 | max 255 | 비회원 댓글 수정 시 비밀번호 재입력을 대체하는 임시 검증 토큰 (verify-password 로 발급, 1회 소비) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.comment.update_validation_rules`).
diff --git a/modules/_bundled/sirsoft-board/docs/api/dashboard.md b/modules/_bundled/sirsoft-board/docs/api/dashboard.md
index fa32f66f..c4fcf863 100644
--- a/modules/_bundled/sirsoft-board/docs/api/dashboard.md
+++ b/modules/_bundled/sirsoft-board/docs/api/dashboard.md
@@ -21,7 +21,7 @@
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.overview`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@overview`
-- **인증/권한**: `auth:sanctum`
+- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
**요청 파라미터**
@@ -77,7 +77,7 @@ HTTP/1.1 200
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.pending-reports`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@pendingReports`
-- **인증/권한**: `auth:sanctum`
+- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
**요청 파라미터**
@@ -151,7 +151,7 @@ HTTP/1.1 200
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.post-graph`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@postGraph`
-- **인증/권한**: `auth:sanctum`
+- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
**요청 파라미터**
@@ -253,7 +253,7 @@ HTTP/1.1 200
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.recent-posts`
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@recentPosts`
-- **인증/권한**: `auth:sanctum`
+- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
**요청 파라미터**
diff --git a/modules/_bundled/sirsoft-board/docs/api/settings.md b/modules/_bundled/sirsoft-board/docs/api/settings.md
index 432e3097..1bb600e3 100644
--- a/modules/_bundled/sirsoft-board/docs/api/settings.md
+++ b/modules/_bundled/sirsoft-board/docs/api/settings.md
@@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
-| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
+| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 게시판 생성 시 적용되는 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. |
| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. |
| display | object | `{"date_display_format":"standard"}` | 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. |
@@ -73,6 +73,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -362,6 +363,7 @@ HTTP/1.1 200
| _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. |
| notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. |
+| basic_defaults.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 게시판 생성 시 적용될 답글 삭제 정책 기본값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). 허용값 밖이면 422 |
| report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. |
| report_policy.auto_hide_threshold | body | integer | 아니오 | min 0, max 100 | 자동 숨김 신고 수 (이 횟수 이상 신고되면 자동 숨김 처리) |
| report_policy.auto_hide_target | body | string | 아니오 | `post`, `comment`, `both` | 자동 숨김 대상 (게시글 / 댓글 / 둘 다) |
@@ -393,6 +395,9 @@ HTTP/1.1 200
| seo.seo_boards | body | boolean | 아니오 | — | 게시판 목록 페이지의 SEO 페이지 생성 활성화 여부 |
| seo.seo_board | body | boolean | 아니오 | — | 개별 게시판 페이지의 SEO 페이지 생성 활성화 여부 |
| seo.seo_post_detail | body | boolean | 아니오 | — | 게시글 상세 페이지의 SEO 페이지 생성 활성화 여부 |
+| attachment_settings | body | array | 아니오 | — | 첨부 정리 정책 카테고리 값. 삭제된 첨부의 물리 파일을 언제 파기할지 정합니다. |
+| attachment_settings.purge_enabled | body | boolean | 아니오 | — | 삭제 첨부 영구 정리 활성화 여부. **기본 false** — 사용자 파일을 실제로 파기하므로 운영자가 직접 켜야 동작합니다. 예약 작업(`sirsoft-board:prune-attachments --scheduled`)이 이 값을 false 폴백으로 재확인합니다. |
+| attachment_settings.purge_retention_days | body | integer | 아니오 | min 1, max 3650 | 삭제 첨부 보존기간(일, 기본 30). 게시글을 휴지통에서 복원하면 이 기간 안의 첨부는 함께 복원되므로, 휴지통 복원 가능 기간과 같게 유지해야 합니다. 하한·상한은 `config('sirsoft-board.limits')` 가 정하며 화면 입력도 같은 값을 읽습니다. |
**요청 예시**
@@ -423,6 +428,7 @@ Content-Type: application/json
"basic_defaults.use_comment": true,
"basic_defaults.use_reply": true,
"basic_defaults.max_reply_depth": 1,
+ "basic_defaults.reply_delete_policy": "cascade",
"basic_defaults.max_comment_depth": 1,
"basic_defaults.comment_order": "ASC",
"basic_defaults.show_view_count": true,
@@ -506,7 +512,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
-| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
+| basic_defaults | object | `{"type":"basic","per_page":20,"per_page_mobile":15,"order…` | 저장 후 갱신된 기본 설정 카테고리. 게시판 타입, 페이지당 글 수(PC/모바일), 정렬 기준/방향, 댓글·답글 사용 여부와 깊이, 답글 삭제 방식(reply_delete_policy: block/cascade), 제목·내용·댓글 길이 제한, 파일 업로드 허용/용량/개수/확장자, 새 글 표시 시간, 게시판 기본 권한(default_board_permissions) 등을 포함합니다. |
| report_policy | object | `{"auto_hide_threshold":5,"auto_hide_target":"both","daily…` | 저장 후 갱신된 신고 정책 카테고리. 자동 숨김 임계치(auto_hide_threshold)와 대상(auto_hide_target: post/comment/both), 사용자별 일일 신고 한도, 신고 거부 누적 제한(횟수/기간), 관리자·작성자 신고 알림 발송 여부와 채널을 포함합니다. |
| spam_security | object | `{"post_cooldown_seconds":0,"comment_cooldown_seconds":0,"…` | 저장 후 갱신된 스팸·보안 카테고리. 글·댓글·신고 작성 사이의 도배 방지 쿨다운 시간(초)과 조회수 캐시 TTL을 포함합니다. |
| display | object | `{"date_display_format":"standard"}` | 저장 후 갱신된 표시 설정 카테고리. 날짜 표시 형식(date_display_format: standard 절대 표기 / relative 상대 표기)을 포함합니다. |
@@ -535,6 +541,7 @@ HTTP/1.1 200
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
@@ -775,6 +782,8 @@ HTTP/1.1 200
**설명** 게시판 모듈의 환경설정을 저장합니다. `auth:sanctum` 인증과 `sirsoft-board.settings.update` 권한이 필요합니다. `_tab`으로 지정한 탭 단위로 검증된 설정을 저장하며, `report_permissions`가 포함된 경우 신고 권한 역할도 함께 동기화합니다. 저장 성공 시 갱신된 전체 설정과 신고 권한 역할을 반환합니다.
+저장이 성공하면 코어 모듈 설정 저장 훅(`core.module_settings.after_save`)이 `('sirsoft-board', 저장한 카테고리 배열, true)` 페이로드로 발화합니다. 이 훅을 구독하는 리스너가 게시판 SEO 미리 생성 캐시를 무효화하고 설정 변경을 활동 로그에 남기므로, 확장도 같은 훅으로 저장 완료 시점에 개입할 수 있습니다. 검증 실패(422)나 저장 실패 시에는 발화하지 않습니다.
+
### POST /api/modules/sirsoft-board/admin/settings/bulk-apply
@@ -789,7 +798,8 @@ HTTP/1.1 200
| fields | body | array | 예 | min 1 | 대상 게시판에 일괄 적용할 필드 목록(최소 1개). boards 테이블 컬럼(type, per_page, use_comment, allowed_extensions 등)이나 권한 필드(default_board_permissions, manager), 점(.)을 포함한 개별 권한 키(예: `posts.read`)를 허용합니다. |
| apply_all | body | boolean | 예 | — | 전체 게시판 적용 여부. true면 모든 게시판에 적용하고, false면 `board_ids`로 지정한 게시판에만 적용합니다(false 시 board_ids 필수). |
| board_ids | body | array | 아니오 | — | board 식별자 배열 |
-| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. |
+| override_values | body | array | 아니오 | — | 환경설정 기본값 대신 사용할 재정의 값 맵. 지정한 필드에 대해 기본값이 아닌 임의의 값으로 일괄 적용할 때 사용합니다. 각 키는 게시판 생성/수정과 동일한 제한값으로 검증됩니다 (범위 밖 값은 422). |
+| override_values.reply_delete_policy | body | string | 아니오 | `block`, `cascade` | 답글 삭제 정책을 일괄 적용할 때의 재정의 값 (block 답글이 살아 있으면 원글 삭제 차단 / cascade 원글 삭제 시 답글도 함께 소프트 삭제). `fields` 에 `reply_delete_policy` 를 포함해 함께 사용하며, 허용값 밖이면 422 |
**요청 예시**
diff --git a/modules/_bundled/sirsoft-board/editor-spec.json b/modules/_bundled/sirsoft-board/editor-spec.json
index f8c12d50..d79c29f4 100644
--- a/modules/_bundled/sirsoft-board/editor-spec.json
+++ b/modules/_bundled/sirsoft-board/editor-spec.json
@@ -1036,6 +1036,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1144,6 +1145,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
@@ -1599,6 +1601,7 @@
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
+ "reply_delete_policy": "cascade",
"use_report": false,
"comment_order": "ASC",
"max_comment_depth": 10,
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 14ab4275..519aac98 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
- "g7_version": ">=7.0.6",
+ "g7_version": ">=7.0.7",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-board/module.php b/modules/_bundled/sirsoft-board/module.php
index bd478cd0..ee208eb6 100644
--- a/modules/_bundled/sirsoft-board/module.php
+++ b/modules/_bundled/sirsoft-board/module.php
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationDataListener;
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
+use Modules\Sirsoft\Board\Listeners\Ckeditor5ReferenceSourcesListener;
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Listeners\PostAttachmentCountSyncListener;
@@ -72,6 +73,15 @@ class Module extends AbstractModule
'schedule' => 'hourly',
'description' => '대시보드 게시물 현황 집계',
],
+ [
+ 'command' => 'sirsoft-board:prune-attachments --scheduled',
+ 'schedule' => 'daily',
+ 'description' => '방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리',
+ // 임시 첨부 정리 파트는 상시 동작해야 하므로 스케줄 자체는 항상 등록한다.
+ // 사용자 파일을 실제로 파기하는 영구 정리 파트만 커맨드 내부에서 게이트한다
+ // (attachment_settings.purge_enabled, false 폴백).
+ 'enabled_config' => null,
+ ],
];
}
@@ -246,6 +256,33 @@ class Module extends AbstractModule
],
],
],
+ // 대시보드 조회 권한 (type: admin)
+ [
+ 'identifier' => 'dashboard',
+ 'name' => [
+ 'ko' => '게시판 대시보드',
+ 'en' => 'Board Dashboard',
+ ],
+ 'description' => [
+ 'ko' => '게시판 대시보드 조회 권한',
+ 'en' => 'Board dashboard view permissions',
+ ],
+ 'permissions' => [
+ [
+ 'action' => 'view',
+ 'name' => [
+ 'ko' => '대시보드 조회',
+ 'en' => 'View Dashboard',
+ ],
+ 'description' => [
+ 'ko' => '게시판 대시보드(현황/추세/최신글/미처리 신고) 조회',
+ 'en' => 'View board dashboard (overview, trend, recent posts, pending reports)',
+ ],
+ 'type' => 'admin',
+ 'roles' => ['admin', 'manager'],
+ ],
+ ],
+ ],
// 신고 관리 권한 (type: admin)
[
'identifier' => 'reports',
@@ -416,6 +453,7 @@ class Module extends AbstractModule
CommentReplySyncListener::class,
BoardPostsCountSyncListener::class,
BoardCommentsCountSyncListener::class,
+ Ckeditor5ReferenceSourcesListener::class,
];
}
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index 8c3338ef..0b905c91 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.3",
+ "version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.0.3",
+ "version": "1.0.4",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 92974da1..76312ed7 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,13 +1,10 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.3",
+ "version": "1.0.4",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
"scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts
new file mode 100644
index 00000000..f89434b9
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-attachment-cleanup-settings.test.ts
@@ -0,0 +1,187 @@
+/**
+ * 게시판 환경설정 — 첨부 정리 섹션 계약 테스트 (공개 #115)
+ *
+ * @description
+ * 삭제 첨부 영구 정리는 사용자 파일을 파기하므로 기본 꺼짐이다. 화면 바인딩 키가
+ * 저장 카테고리·기본값·서버 검증 세 지점과 일치하는지를 고정한다. 한 곳만 어긋나도
+ * 저장은 성공하는데 값이 조용히 버려진다.
+ *
+ * @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+import generalTab from '../../../layouts/admin/partials/admin_board_settings/_tab_general.json';
+
+/**
+ * 모듈 루트(module.json 기준)를 위로 훑어 찾는다.
+ *
+ * @returns 모듈 루트 절대경로
+ */
+function moduleRoot(): string {
+ let current = path.dirname(fileURLToPath(import.meta.url));
+
+ for (let depth = 0; depth < 10; depth++) {
+ if (fs.existsSync(path.join(current, 'module.json'))) {
+ return current;
+ }
+ current = path.dirname(current);
+ }
+
+ throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.');
+}
+
+/**
+ * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다.
+ *
+ * @param node 탐색 시작 노드
+ * @param predicate 노드 판정 함수
+ * @returns 찾은 노드 또는 null
+ */
+function findNode(node: any, predicate: (n: any) => boolean): any {
+ if (!node || typeof node !== 'object') return null;
+ if (predicate(node)) return node;
+
+ for (const child of node.children ?? []) {
+ const found = findNode(child, predicate);
+ if (found) return found;
+ }
+
+ return null;
+}
+
+/**
+ * 이 화면을 렌더하는 admin 템플릿의 매니페스트가 선언한 컴포넌트 종류를 읽는다.
+ *
+ * 레이아웃 노드의 `type` 은 렌더러가 DOM 안전 필터링을 고르는 기준이라
+ * 매니페스트 선언과 어긋나면 `editorAttrs` 가 제거된다(DynamicRenderer).
+ *
+ * @param name 컴포넌트 이름
+ * @returns 매니페스트가 선언한 type
+ */
+function manifestType(name: string): string {
+ // 모듈 루트(modules/_bundled/sirsoft-board) 에서 저장소 루트로 두 단계 올라간다.
+ const repoRoot = path.dirname(path.dirname(path.dirname(moduleRoot())));
+ const manifest = JSON.parse(
+ fs.readFileSync(
+ path.join(repoRoot, 'templates/_bundled/sirsoft-admin_basic/components.json'),
+ 'utf-8',
+ ),
+ );
+
+ for (const [kind, entries] of Object.entries(manifest.components ?? {})) {
+ if ((entries as any[]).some((entry) => entry.name === name)) {
+ return kind;
+ }
+ }
+
+ throw new Error(`매니페스트에 ${name} 선언이 없습니다.`);
+}
+
+const section = findNode(generalTab, (n) => n.id === 'attachment_cleanup_section');
+
+describe('게시판 첨부 정리 설정 — 계약', () => {
+ it('기본 설정 탭에 첨부 정리 섹션이 있다', () => {
+ expect(section).not.toBeNull();
+ });
+
+ it('토글 노드의 type 이 매니페스트 선언과 같다', () => {
+ const toggle = findNode(section, (n) => n.name === 'Toggle');
+
+ // 어긋나면 렌더러가 editorAttrs 를 떼어내 이 노드만 레이아웃 편집기에서 표식을 잃는다.
+ expect(toggle.type).toBe(manifestType('Toggle'));
+ });
+
+ it('토글과 보존기간이 attachment_settings 카테고리 키를 바인딩한다', () => {
+ const toggle = findNode(section, (n) => n.name === 'Toggle');
+ const input = findNode(section, (n) => n.name === 'Input');
+
+ expect(toggle.props.name).toBe('attachment_settings.purge_enabled');
+ expect(input.props.name).toBe('attachment_settings.purge_retention_days');
+ expect(input.props.min).toContain('attachment_purge_retention_days_min');
+ expect(input.props.max).toContain('attachment_purge_retention_days_max');
+ });
+
+ it('변경 시 form 상태와 hasChanges 를 함께 갱신한다', () => {
+ const toggle = findNode(section, (n) => n.name === 'Toggle');
+ const action = toggle.actions.find((a: any) => a.type === 'change');
+
+ expect(action.params.hasChanges).toBe(true);
+
+ // 값 갱신은 정적 점 경로 키로 한다. 중첩 객체(`form: { attachment_settings: {...} }`)로
+ // 되돌리면 spread 누적이 되살아나 저장은 200 인데 값이 조용히 버려진다.
+ expect(action.params['form.attachment_settings.purge_enabled']).toBe('{{$event.target.checked}}');
+ expect(action.params.form).toBeUndefined();
+
+ // 키 자체에 표현식을 쓰면 경로가 해석되지 않는다 (setState 규약).
+ Object.keys(action.params).forEach((key) => {
+ expect(key).not.toContain('{{');
+ });
+ });
+
+ it('기본값이 꺼짐이고 보존기간 기본이 30일이다 (defaults.json)', () => {
+ const defaults = JSON.parse(
+ fs.readFileSync(path.join(moduleRoot(), 'config/settings/defaults.json'), 'utf-8'),
+ );
+
+ expect(defaults._meta.categories).toContain('attachment_settings');
+ expect(defaults.defaults.attachment_settings.purge_enabled).toBe(false);
+ expect(defaults.defaults.attachment_settings.purge_retention_days).toBe(30);
+ });
+
+ it('서버 검증이 같은 키를 받고 화면과 같은 한계값 출처를 쓴다', () => {
+ const request = fs.readFileSync(
+ path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'),
+ 'utf-8',
+ );
+ const boardConfig = fs.readFileSync(path.join(moduleRoot(), 'config/board.php'), 'utf-8');
+
+ expect(request).toContain("'attachment_settings.purge_enabled' => ['nullable', 'boolean']");
+ // 화면 입력과 저장 규칙이 같은 limits 키를 읽어야 "화면이 허용한 값인데 422" 가 생기지 않는다.
+ expect(request).toContain("\$limits['attachment_purge_retention_days_min']");
+ expect(request).toContain("\$limits['attachment_purge_retention_days_max']");
+ expect(boardConfig).toContain("'attachment_purge_retention_days_min' => 1");
+ expect(boardConfig).toContain("'attachment_purge_retention_days_max' => 3650");
+ });
+
+ it('기본 설정 탭 저장 payload 에 attachment_settings 가 실린다', () => {
+ // 화면에 필드가 있어도 저장 요청 본문에 카테고리가 빠지면 저장은 200 인데 값만 사라진다
+ // (브라우저 실측 — 토글을 켜고 저장해도 파일에 반영되지 않음).
+ const settingsLayout = JSON.parse(
+ fs.readFileSync(
+ path.join(moduleRoot(), 'resources/layouts/admin/admin_board_settings.json'),
+ 'utf-8',
+ ),
+ );
+ const raw = JSON.stringify(settingsLayout);
+ const generalBranchStart = raw.indexOf("if (tab === 'general')");
+ const generalBranch = raw.slice(generalBranchStart, raw.indexOf('; }', generalBranchStart));
+
+ expect(generalBranchStart).toBeGreaterThan(-1);
+ expect(generalBranch).toContain('attachment_settings: form.attachment_settings || {}');
+ });
+
+ it('서버가 attachment_settings 카테고리를 저장 대상으로 받는다', () => {
+ const request = fs.readFileSync(
+ path.join(moduleRoot(), 'src/Http/Requests/Admin/StoreBoardSettingsRequest.php'),
+ 'utf-8',
+ );
+ const validCategories = request.match(/\$validCategories = \[[^\]]*\]/)?.[0] ?? '';
+
+ expect(validCategories).toContain("'attachment_settings'");
+ });
+
+ it('스케줄이 등록되고 영구 정리 파트만 커맨드 내부에서 게이트된다', () => {
+ const manifest = fs.readFileSync(path.join(moduleRoot(), 'module.php'), 'utf-8');
+ const command = fs.readFileSync(
+ path.join(moduleRoot(), 'src/Console/Commands/PruneAttachmentsCommand.php'),
+ 'utf-8',
+ );
+
+ expect(manifest).toContain('sirsoft-board:prune-attachments --scheduled');
+ expect(command).toContain("module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false)");
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx
index d4ea78a9..16f3f47b 100644
--- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-form.test.tsx
@@ -317,6 +317,30 @@ describe('admin_board_form/_tab_post.json', () => {
expect(maxReplyDepthField.if).toContain('use_reply');
});
+ it('reply_delete_policy_field 가 use_reply ON 조건부로 존재하고 block/cascade Select 를 렌더한다 (#573)', () => {
+ const policyField = findById(tabPost, 'reply_delete_policy_field');
+ expect(policyField).toBeDefined();
+ expect(policyField.if).toBeDefined();
+ expect(policyField.if).toContain('use_reply');
+
+ const select = findById(policyField, 'reply_delete_policy_select');
+ expect(select).toBeDefined();
+ expect(select.name).toBe('Select');
+ expect(select.props.name).toBe('reply_delete_policy');
+
+ const values = (select.props.options as Array<{ value: string }>).map((o) => o.value).sort();
+ expect(values).toEqual(['block', 'cascade']);
+ });
+
+ it('reply_delete_policy 라벨/힌트/옵션의 ko 다국어 키가 실재한다 (#573)', () => {
+ const field = (koFormLang as any).fields?.reply_delete_policy;
+ expect(field).toBeDefined();
+ expect(field.label).toBeTruthy();
+ expect(field.hint).toBeTruthy();
+ expect(field.options?.block).toBeTruthy();
+ expect(field.options?.cascade).toBeTruthy();
+ });
+
it('row-stack 컨테이너 section_post_fields 에 use_comment 토글이 있다', () => {
const fieldsContainer = findById(tabPost, 'section_post_fields');
expect(fieldsContainer).toBeDefined();
diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx
index a92c2ff7..8a594e2a 100644
--- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings.test.tsx
@@ -727,6 +727,7 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => {
const fields = findFormFields(tabBoardSettingsReply);
expect(fields).toContain('basic_defaults.use_reply');
expect(fields).toContain('basic_defaults.max_reply_depth');
+ expect(fields).toContain('basic_defaults.reply_delete_policy');
});
it('답변글 깊이 필드가 항상 표시된다 (if 조건 없음)', () => {
@@ -734,6 +735,34 @@ describe('_tab_board_settings_reply.json - 답변글 하위 탭', () => {
expect(maxReplyDepth).toBeDefined();
expect(maxReplyDepth.if).toBeUndefined();
});
+
+ it('답글 삭제 정책 Select 가 block/cascade 옵션으로 렌더된다 (#573)', () => {
+ const policyField = findById(tabBoardSettingsReply, 'field_reply_delete_policy');
+ expect(policyField).toBeDefined();
+ expect(policyField.if).toBeUndefined();
+
+ const collect: any[] = [];
+ const walk = (node: any) => {
+ if (!node || typeof node !== 'object') return;
+ if (node.name === 'Select') collect.push(node);
+ for (const child of node.children ?? []) walk(child);
+ };
+ walk(policyField);
+
+ expect(collect).toHaveLength(1);
+ expect(collect[0].props.name).toBe('basic_defaults.reply_delete_policy');
+ const values = (collect[0].props.options as Array<{ value: string }>).map((o) => o.value).sort();
+ expect(values).toEqual(['block', 'cascade']);
+ });
+
+ it('답글 삭제 정책 ko 다국어 키(라벨/설명/옵션/일괄적용)가 실재한다 (#573)', () => {
+ const lang = koSettingsLang as any;
+ expect(lang.fields?.reply_delete_policy).toBeTruthy();
+ expect(lang.fields?.descriptions?.reply_delete_policy).toBeTruthy();
+ expect(lang.options?.reply_delete_policy?.block).toBeTruthy();
+ expect(lang.options?.reply_delete_policy?.cascade).toBeTruthy();
+ expect(lang.bulk_apply?.field_labels?.reply_delete_policy).toBeTruthy();
+ });
});
// =============================================
@@ -1611,10 +1640,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
expect(option).not.toBeNull();
// click 액션으로 date_display_format: "standard" 설정
+ // 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다.
const clickAction = option.actions?.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
expect(clickAction.handler).toBe('setState');
- expect(clickAction.params?.form?.display?.date_display_format).toBe('standard');
+ expect(clickAction.params?.['form.display.date_display_format']).toBe('standard');
+ expect(clickAction.params?.form).toBeUndefined();
});
it('relative 옵션 라디오 버튼이 존재한다', () => {
@@ -1622,10 +1653,12 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
expect(option).not.toBeNull();
// click 액션으로 date_display_format: "relative" 설정
+ // 키는 점 경로다 — 중첩 객체로 넘기면 spread 누적이 생겨 저장 payload 가 어긋난다.
const clickAction = option.actions?.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
expect(clickAction.handler).toBe('setState');
- expect(clickAction.params?.form?.display?.date_display_format).toBe('relative');
+ expect(clickAction.params?.['form.display.date_display_format']).toBe('relative');
+ expect(clickAction.params?.form).toBeUndefined();
});
it('라디오 버튼 name이 display.date_display_format이다', () => {
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json
index 1fa0071b..9938bb4f 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin.json
@@ -926,7 +926,7 @@
"daily_report_limit_hint": "Max reports per user per day across all boards. 0 = no limit. (0–100)",
"rejection_limit_count": "Rejection Limit (count)",
"rejection_limit_days": "Rejection Limit Period (days)",
- "rejection_limit_hint": "Reporting will be blocked when rejections exceed the limit within this period. 0 count = no limit. (Count: 0–50, Period: 1–365 days)",
+ "rejection_limit_hint": "Reporting is blocked once rejections within this period reach the limit. 0 count = no limit. (Count: 0–50, Period: 1–365 days)",
"report_cooldown_seconds": "Report Cooldown (sec)",
"report_cooldown_seconds_hint": "Minimum interval between consecutive reports. 0 = no limit. (0–3600 sec)",
"notify_author_on_report_action": "Report Action Notification",
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json
index 6f819468..40fe8b8a 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/form.json
@@ -110,6 +110,14 @@
"label": "Max Reply Depth",
"hint": "Enter a value between {{min}} and {{max}}"
},
+ "reply_delete_policy": {
+ "label": "Reply Delete Policy",
+ "hint": "Set what happens when deleting a post that has replies",
+ "options": {
+ "block": "Block deletion",
+ "cascade": "Delete together"
+ }
+ },
"max_comment_depth": {
"label": "Max Comment Depth",
"hint": "Enter a value between {{min}} and {{max}}. Setting to 0 disables the nested comment feature."
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json
index c8e3f84c..79f55810 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/en/admin/settings.json
@@ -60,9 +60,15 @@
"report_notification": "Report Notification",
"report_notification_description": "Configure which administrators receive notifications and how notifications are sent when a report is submitted.",
"report_permissions": "Report Management Permissions",
- "report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role."
+ "report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role.",
+ "attachment_cleanup": "Attachment Cleanup",
+ "attachment_cleanup_description": "Decide when the files of deleted attachments are destroyed. Temporary attachments uploaded while writing but never saved are cleaned up daily regardless of this setting."
},
"fields": {
+ "attachment_purge_enabled": "Purge deleted attachments",
+ "attachment_purge_enabled_hint": "Off by default — the operator must turn it on for the daily automatic cleanup to run. When on, files and records of attachments deleted longer ago than the retention period are permanently removed. Cleanup commands run directly on the server are not affected by this setting.",
+ "attachment_purge_retention_days": "Deleted attachment retention (days)",
+ "attachment_purge_retention_days_hint": "Restoring a post from the trash also restores attachments within this period. Keep it aligned with your trash retention window. (1 ~ 3650 days)",
"type": "Board Type",
"secret_mode": "Secret Mode",
"per_page": "Posts per Page (PC)",
@@ -78,6 +84,7 @@
"max_content_length": "Maximum Content Length",
"use_reply": "Enable Reply Posts",
"max_reply_depth": "Max Reply Depth",
+ "reply_delete_policy": "Reply Delete Policy",
"use_comment": "Enable Comments",
"comment_order": "Comment Sort Order",
"min_comment_length": "Min Comment Length",
@@ -103,7 +110,7 @@
"daily_report_limit_hint": "Max reports per user per day across all boards. 0 = no limit. (0\u2013100)",
"rejection_limit_count": "Rejection Limit (count)",
"rejection_limit_days": "Rejection Limit Period (days)",
- "rejection_limit_hint": "Reporting will be blocked when rejections exceed the limit within this period. 0 count = no limit. (Count: 0\u201350, Period: 1\u2013365 days)",
+ "rejection_limit_hint": "Reporting is blocked once rejections within this period reach the limit. 0 count = no limit. (Count: 0\u201350, Period: 1\u2013365 days)",
"report_cooldown_seconds": "Report Cooldown (sec)",
"report_cooldown_seconds_hint": "Minimum interval between consecutive reports. 0 = no limit. (0\u20133600 sec)",
"notify_author_on_report_action": "Report Action Notification",
@@ -166,6 +173,7 @@
"max_content_length": "Set the maximum character count for post content ({{min}}–{{max}})",
"use_reply": "Must be ON to apply reply post feature to new boards.",
"max_reply_depth": "Set the maximum nesting depth for replies ({{min}}–{{max}})",
+ "reply_delete_policy": "Set what happens when deleting a post that has replies",
"blocked_keywords": "Posts or comments containing registered keywords will be blocked",
"use_comment": "Must be ON to apply comment feature to new boards. Comment detail settings below will also apply.",
"comment_order": "Set the default sort direction for comments",
@@ -197,6 +205,10 @@
"enabled": "Optional",
"always": "Always Secret"
},
+ "reply_delete_policy": {
+ "block": "Block deletion",
+ "cascade": "Delete together"
+ },
"order_by": {
"created_at": "Latest",
"view_count": "View Count",
@@ -281,6 +293,7 @@
"use_comment": "Enable Comments",
"use_reply": "Enable Replies",
"max_reply_depth": "Max Reply Depth",
+ "reply_delete_policy": "Reply Delete Policy",
"max_comment_depth": "Max Comment Depth",
"comment_order": "Comment Sort Order",
"show_view_count": "Show View Count",
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json
index 7ae527d5..577fa4ab 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin.json
@@ -926,7 +926,7 @@
"daily_report_limit_hint": "하루(자정 기준) 전체 게시판 통틀어 신고할 수 있는 최대 횟수. 0이면 제한 없음. (0~100)",
"rejection_limit_count": "반려 누적 제한 (건수)",
"rejection_limit_days": "반려 누적 제한 기간 (일)",
- "rejection_limit_hint": "이 기간 내 반려 건수 초과 시 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)",
+ "rejection_limit_hint": "이 기간 내 반려 건수가 설정값에 도달하면 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)",
"report_cooldown_seconds": "연속 신고 쿨타임 (초)",
"report_cooldown_seconds_hint": "연속 신고 최소 간격. 0이면 제한 없음. (0~3600초)",
"notify_author_on_report_action": "신고 처리 알림",
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json
index eb7c3863..d1704af3 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/form.json
@@ -110,6 +110,14 @@
"label": "답변글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
+ "reply_delete_policy": {
+ "label": "답변글 삭제 방식",
+ "hint": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다",
+ "options": {
+ "block": "삭제 차단",
+ "cascade": "함께 삭제"
+ }
+ },
"max_comment_depth": {
"label": "대댓글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요. 0으로 설정하면 대댓글 기능이 비활성화됩니다."
diff --git a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json
index b1d38d1e..ba6ccf07 100644
--- a/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json
+++ b/modules/_bundled/sirsoft-board/resources/lang/partial/ko/admin/settings.json
@@ -60,9 +60,15 @@
"report_notification": "신고 알림",
"report_notification_description": "신고 접수 시 알림을 받을 관리자와 알림 발송 방식을 설정합니다.",
"report_permissions": "신고 관리 권한",
- "report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다."
+ "report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다.",
+ "attachment_cleanup": "첨부파일 정리",
+ "attachment_cleanup_description": "삭제한 첨부파일의 물리 파일을 언제 파기할지 정합니다. 글쓰기 중 올렸다가 저장하지 않은 임시 첨부는 이 설정과 무관하게 매일 자동 정리됩니다."
},
"fields": {
+ "attachment_purge_enabled": "삭제 첨부 영구 정리",
+ "attachment_purge_enabled_hint": "기본 꺼짐 — 운영자가 직접 켜야 매일 자동 정리가 동작합니다. 켜면 삭제된 지 보존기간이 지난 첨부의 파일과 기록을 영구 삭제합니다. 서버에서 직접 실행하는 정리 명령은 이 설정과 무관하게 동작합니다.",
+ "attachment_purge_retention_days": "삭제 첨부 보존기간 (일)",
+ "attachment_purge_retention_days_hint": "휴지통에서 게시글을 복원하면 이 기간 안의 첨부는 함께 복원됩니다. 휴지통 복원 가능 기간과 같게 유지하십시오. (1 ~ 3650 일)",
"type": "게시판 유형",
"secret_mode": "비밀글 모드",
"per_page": "페이지당 게시글 수 (PC)",
@@ -78,6 +84,7 @@
"max_content_length": "최대 게시글 글자 수",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
+ "reply_delete_policy": "답변글 삭제 방식",
"use_comment": "댓글 사용",
"comment_order": "댓글 정렬 순서",
"min_comment_length": "최소 댓글 글자 수",
@@ -103,7 +110,7 @@
"daily_report_limit_hint": "하루(자정 기준) 전체 게시판 통틀어 신고할 수 있는 최대 횟수. 0이면 제한 없음. (0~100)",
"rejection_limit_count": "반려 누적 제한 (건수)",
"rejection_limit_days": "반려 누적 제한 기간 (일)",
- "rejection_limit_hint": "이 기간 내 반려 건수 초과 시 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)",
+ "rejection_limit_hint": "이 기간 내 반려 건수가 설정값에 도달하면 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)",
"report_cooldown_seconds": "연속 신고 쿨타임 (초)",
"report_cooldown_seconds_hint": "연속 신고 최소 간격. 0이면 제한 없음. (0~3600초)",
"notify_author_on_report_action": "신고 처리 알림",
@@ -166,6 +173,7 @@
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다 ({{min}}~{{max}})",
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 ({{min}}~{{max}})",
+ "reply_delete_policy": "답변글이 달린 글을 삭제할 때의 동작을 설정합니다",
"blocked_keywords": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
@@ -197,6 +205,10 @@
"enabled": "선택 가능",
"always": "항상 비밀글"
},
+ "reply_delete_policy": {
+ "block": "삭제 차단",
+ "cascade": "함께 삭제"
+ },
"order_by": {
"created_at": "최신순",
"view_count": "조회수순",
@@ -281,6 +293,7 @@
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
+ "reply_delete_policy": "답변글 삭제 방식",
"max_comment_depth": "대댓글 최대 깊이",
"comment_order": "댓글 정렬 순서",
"show_view_count": "조회수 표시",
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json
index 0cdf2fc9..8d332ca3 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_post_form.json
@@ -227,7 +227,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.posts.messages.save_failed",
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}",
"icon": "triangle-exclamation"
}
}
@@ -260,7 +260,7 @@
"name": "Icon",
"props": {
"name": "lock",
- "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
+ "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
@@ -294,7 +294,7 @@
"name": "Icon",
"props": {
"name": "triangle-exclamation",
- "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
+ "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -396,7 +396,7 @@
"name": "Icon",
"props": {
"name": "circle-dot",
- "className": "w-3 h-3"
+ "className": "text-xs"
}
},
{
@@ -523,7 +523,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.posts.messages.save_failed",
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.posts.messages.save_failed'}}",
"icon": "triangle-exclamation"
}
}
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json
index 1288dd10..fcb0e787 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_reports_index.json
@@ -2099,6 +2099,7 @@
"serverTotalPages": "{{reports?.data?.pagination?.last_page ?? null}}",
"serverHasMorePages": "{{reports?.data?.pagination?.has_more_pages ?? false}}",
"selectable": true,
+ "selectionScope": "page",
"selectedIds": "{{_global.selectedIds || []}}",
"idField": "id",
"showColumnSelector": false,
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json
index 5ad705ee..50b883ea 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/admin_board_settings.json
@@ -641,7 +641,7 @@
"target": "/api/modules/sirsoft-board/admin/settings",
"params": {
"method": "PUT",
- "body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}"
+ "body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {}, attachment_settings: form.attachment_settings || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}"
},
"onSuccess": [
{
@@ -697,7 +697,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.save_error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.save_error'}}"
}
}
]
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json
index d54430e0..2516f022 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_form/_tab_post.json
@@ -489,6 +489,70 @@
}
]
},
+ {
+ "id": "reply_delete_policy_field",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.form.use_reply}}",
+ "props": {
+ "className": ""
+ },
+ "children": [
+ {
+ "id": "reply_delete_policy_label",
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "form-label"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.label"
+ }
+ ]
+ },
+ {
+ "id": "reply_delete_policy_select",
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "name": "reply_delete_policy",
+ "options": [
+ {
+ "value": "cascade",
+ "label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.cascade"
+ },
+ {
+ "value": "block",
+ "label": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.options.block"
+ }
+ ],
+ "disabled": "{{_computed.isReadOnly}}",
+ "className": ""
+ }
+ },
+ {
+ "id": "reply_delete_policy_hint",
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "form-hint"
+ },
+ "text": "$t:sirsoft-board.admin.form.fields.reply_delete_policy.hint"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{_local.errors?.reply_delete_policy}}",
+ "props": {
+ "className": "form-error-xs mt-1"
+ },
+ "text": "{{_local.errors?.reply_delete_policy?.[0] ?? ''}}"
+ }
+ ]
+ },
{
"id": "use_comment_toggle",
"type": "basic",
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json
index be44282c..3b983317 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json
@@ -525,7 +525,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.bulk_apply_modal.error'}}"
}
}
]
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json
index 6b17bf67..70800b9b 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json
@@ -633,7 +633,7 @@
{
"handler": "toast",
"params": {
- "message": "$t:sirsoft-board.admin.settings.mail_templates.save_error",
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.mail_templates.save_error'}}",
"type": "error"
}
}
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
index c2519458..830721d2 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
@@ -969,7 +969,7 @@
{
"handler": "toast",
"params": {
- "message": "$t:sirsoft-board.admin.settings.notification_definitions.save_error",
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.notification_definitions.save_error'}}",
"type": "error"
}
}
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json
index 87376641..a3eb0ad1 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json
@@ -212,6 +212,91 @@
]
}
]
+ },
+ {
+ "id": "field_reply_delete_policy",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": ""
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "flex-start gap-2 text-body font-medium flex-1 cursor-pointer"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "checkbox",
+ "className": "checkbox-aligned",
+ "checked": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy')}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('reply_delete_policy') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'reply_delete_policy') : [...(_local.bulkApplyFields ?? []), 'reply_delete_policy']}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-board.admin.settings.fields.reply_delete_policy"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-field-hint"
+ },
+ "text": "$t:sirsoft-board.admin.settings.fields.descriptions.reply_delete_policy"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "name": "basic_defaults.reply_delete_policy",
+ "options": [
+ {
+ "value": "cascade",
+ "label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.cascade"
+ },
+ {
+ "value": "block",
+ "label": "$t:sirsoft-board.admin.settings.options.reply_delete_policy.block"
+ }
+ ],
+ "className": "w-48 flex-shrink-0 text-sm",
+ "disabled": "{{_computed.isReadOnly}}"
+ }
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json
index 908e4a24..6bf3092a 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_general.json
@@ -81,14 +81,8 @@
"handler": "setState",
"params": {
"target": "local",
- "form": {
- "...": "{{_local.form}}",
- "display": {
- "...": "{{_local.form?.display}}",
- "date_display_format": "standard"
- }
- },
- "hasChanges": true
+ "hasChanges": true,
+ "form.display.date_display_format": "standard"
}
}
],
@@ -110,14 +104,8 @@
"handler": "setState",
"params": {
"target": "local",
- "form": {
- "...": "{{_local.form}}",
- "display": {
- "...": "{{_local.form?.display}}",
- "date_display_format": "standard"
- }
- },
- "hasChanges": true
+ "hasChanges": true,
+ "form.display.date_display_format": "standard"
}
}
]
@@ -170,14 +158,8 @@
"handler": "setState",
"params": {
"target": "local",
- "form": {
- "...": "{{_local.form}}",
- "display": {
- "...": "{{_local.form?.display}}",
- "date_display_format": "relative"
- }
- },
- "hasChanges": true
+ "hasChanges": true,
+ "form.display.date_display_format": "relative"
}
}
],
@@ -199,14 +181,8 @@
"handler": "setState",
"params": {
"target": "local",
- "form": {
- "...": "{{_local.form}}",
- "display": {
- "...": "{{_local.form?.display}}",
- "date_display_format": "relative"
- }
- },
- "hasChanges": true
+ "hasChanges": true,
+ "form.display.date_display_format": "relative"
}
}
]
@@ -251,6 +227,128 @@
]
}
]
+ },
+ {
+ "id": "attachment_cleanup_section",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "admin-card mt-6"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "H2",
+ "props": {
+ "className": "card-title"
+ },
+ "text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "card-description"
+ },
+ "text": "$t:sirsoft-board.admin.settings.sections.attachment_cleanup_description"
+ },
+ {
+ "id": "attachment_purge_enabled_field",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "mt-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "form-label"
+ },
+ "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-tertiary"
+ },
+ "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_enabled_hint"
+ },
+ {
+ "type": "composite",
+ "name": "Toggle",
+ "props": {
+ "name": "attachment_settings.purge_enabled",
+ "disabled": "{{_computed.isReadOnly}}",
+ "checked": "{{!!_local.form?.attachment_settings?.purge_enabled}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "hasChanges": true,
+ "form.attachment_settings.purge_enabled": "{{$event.target.checked}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "attachment_purge_retention_days_field",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "mt-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "form-label"
+ },
+ "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-tertiary"
+ },
+ "text": "$t:sirsoft-board.admin.settings.fields.attachment_purge_retention_days_hint"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "number",
+ "name": "attachment_settings.purge_retention_days",
+ "min": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_min ?? 1}}",
+ "max": "{{_local?.form?._meta?.limits?.attachment_purge_retention_days_max ?? 3650}}",
+ "className": "input w-40",
+ "disabled": "{{_computed.isReadOnly}}",
+ "value": "{{_local.form?.attachment_settings?.purge_retention_days ?? 30}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "hasChanges": true,
+ "form.attachment_settings.purge_retention_days": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json
index 31eedfd4..96ca0804 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json
@@ -569,7 +569,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:admin.identity.policies.toggle_failed"
+ "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}"
}
},
{
@@ -906,7 +906,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:admin.identity.policies.toggle_failed"
+ "message": "{{error.message ?? '$t:admin.identity.policies.toggle_failed'}}"
}
},
{
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json
index 162a2c9f..f9db2dda 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_seo.json
@@ -620,7 +620,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
@@ -637,7 +637,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
@@ -654,7 +654,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error'}}"
}
}
]
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json
index 3f749d81..7b4a3f4e 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_spam_security.json
@@ -336,7 +336,7 @@
"handler": "toast",
"params": {
"type": "error",
- "message": "$t:sirsoft-board.admin.settings.fields.clear_cache_error"
+ "message": "{{error.message ?? '$t:sirsoft-board.admin.settings.fields.clear_cache_error'}}"
}
}
]
diff --git a/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php b/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php
new file mode 100644
index 00000000..6fd0d62f
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Console/Commands/PruneAttachmentsCommand.php
@@ -0,0 +1,180 @@
+option('limit'));
+ $isDryRun = (bool) $this->option('dry-run');
+
+ $this->pruneTemp($limit, $isDryRun);
+ $this->purgeSoftDeleted($limit, $isDryRun);
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * 임시 첨부 정리 파트 (항상 실행).
+ *
+ * @param int $limit 최대 처리 건수
+ * @param bool $isDryRun 판정만 수행할지 여부
+ */
+ private function pruneTemp(int $limit, bool $isDryRun): void
+ {
+ $days = (int) $this->option('temp-days');
+
+ // 보존기간 0 이하 = 방금 올린 임시 첨부까지 지우게 되므로 차단한다.
+ if ($days < 1) {
+ $this->info('임시 첨부 보존기간이 1일 미만이어서 임시 첨부 정리를 수행하지 않았습니다.');
+
+ return;
+ }
+
+ $result = $this->attachmentService->pruneTempUploads($days, $limit, $isDryRun);
+
+ if ($isDryRun) {
+ $this->info("[DRY RUN] 보존기간({$days}일) 경과 미연결 임시 첨부: {$result['scanned']}건");
+
+ return;
+ }
+
+ $this->info(sprintf(
+ '보존기간(%d일) 경과 임시 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
+ $days,
+ $result['scanned'],
+ $result['deleted'],
+ $result['failed'],
+ ));
+
+ Log::info('PruneAttachmentsCommand: 임시 첨부 정리 완료', [
+ 'days' => $days,
+ 'limit' => $limit,
+ ] + $result);
+ }
+
+ /**
+ * 소프트 삭제 첨부 영구 정리 파트 (운영자 옵트인).
+ *
+ * @param int $limit 최대 처리 건수
+ * @param bool $isDryRun 판정만 수행할지 여부
+ */
+ private function purgeSoftDeleted(int $limit, bool $isDryRun): void
+ {
+ if ($this->option('scheduled') && ! $this->isPurgeEnabled()) {
+ $this->info('삭제 첨부 영구 정리가 꺼져 있어 실행하지 않았습니다. (attachment_settings.purge_enabled = false)');
+
+ return;
+ }
+
+ $days = $this->resolvePurgeDays();
+
+ if ($days < 1) {
+ $this->info('삭제 첨부 보존기간이 1일 미만이어서 영구 정리를 수행하지 않았습니다.');
+
+ return;
+ }
+
+ $result = $this->attachmentService->purgeSoftDeleted($days, $limit, $isDryRun);
+
+ if ($isDryRun) {
+ $this->info("[DRY RUN] 보존기간({$days}일) 경과 삭제 첨부: {$result['scanned']}건");
+
+ return;
+ }
+
+ $this->info(sprintf(
+ '보존기간(%d일) 경과 삭제 첨부 %d건 중 %d건을 영구 삭제했습니다. (실패 %d건)',
+ $days,
+ $result['scanned'],
+ $result['deleted'],
+ $result['failed'],
+ ));
+
+ Log::info('PruneAttachmentsCommand: 삭제 첨부 영구 정리 완료', [
+ 'days' => $days,
+ 'limit' => $limit,
+ ] + $result);
+ }
+
+ /**
+ * 영구 정리 토글을 false 폴백으로 조회합니다.
+ *
+ * @return bool 영구 정리 활성 여부
+ */
+ private function isPurgeEnabled(): bool
+ {
+ return (bool) module_setting('sirsoft-board', 'attachment_settings.purge_enabled', false);
+ }
+
+ /**
+ * 영구 정리 보존기간을 해석합니다 (옵션 > 모듈 설정 > 기본 30일).
+ *
+ * @return int 보존기간(일)
+ */
+ private function resolvePurgeDays(): int
+ {
+ $option = $this->option('purge-days');
+
+ if ($option !== null && $option !== '') {
+ return (int) $option;
+ }
+
+ return (int) module_setting('sirsoft-board', 'attachment_settings.purge_retention_days', 30);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php b/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php
new file mode 100644
index 00000000..a6a44a4d
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Enums/ReplyDeletePolicy.php
@@ -0,0 +1,80 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 유효한 값인지 확인
+ *
+ * @param string $value 검증할 값
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+
+ /**
+ * 번역 키를 반환합니다.
+ *
+ * @return string 번역 키
+ */
+ public function labelKey(): string
+ {
+ return 'sirsoft-board::enums.reply_delete_policy.'.$this->value;
+ }
+
+ /**
+ * 다국어 라벨 반환
+ *
+ * @return string 번역된 라벨
+ */
+ public function label(): string
+ {
+ return __($this->labelKey());
+ }
+
+ /**
+ * 모든 값을 배열로 반환 (value, label 포함)
+ *
+ * @return array
+ */
+ public static function toArray(): array
+ {
+ return array_map(
+ fn (self $item) => [
+ 'value' => $item->value,
+ 'label' => $item->label(),
+ ],
+ self::cases()
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
index fea8598d..afeb2b67 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
@@ -52,6 +52,29 @@ class AttachmentLimitExceededException extends Exception
return $this->attempted;
}
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 응답을 만들 때 이미 번역된 `getMessage()` 대신 이 키를 넘긴다 — 번역문을 키로
+ * 해석하려 하면 실패해 원문이 그대로 노출된다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return 'sirsoft-board::messages.errors.attachment_limit_exceeded';
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return ['limit' => $this->limit, 'attempted' => $this->attempted];
+ }
+
/**
* 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다.
*
@@ -59,6 +82,11 @@ class AttachmentLimitExceededException extends Exception
*/
public function render(): JsonResponse
{
- return ResponseHelper::error($this->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return ResponseHelper::error(
+ $this->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $this->getMessageParams()
+ );
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php b/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php
new file mode 100644
index 00000000..396aab70
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
index 2f2c4046..17e520ca 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
@@ -48,4 +48,24 @@ class CommentDepthExceededException extends Exception
{
return $this->attempted;
}
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return 'sirsoft-board::validation.comment.depth.exceeded';
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return ['max' => $this->limit];
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php b/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
index 46af07d5..754d4240 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
@@ -11,19 +11,55 @@ use Exception;
*/
class MenuAlreadyExistsException extends Exception
{
+ /**
+ * 기본 안내 문구의 다국어 키
+ */
+ private const DEFAULT_MESSAGE_KEY = 'sirsoft-board::messages.boards.menu_already_exists';
+
+ /**
+ * 메시지 키 (직접 문구를 받은 경우 null)
+ */
+ private ?string $messageKey;
+
/**
* 메뉴 중복 예외 생성자
*
- * @param string $message 예외 메시지
+ * @param string $message 예외 메시지 (생략 시 기본 키로 해석)
* @param int $code 예외 코드
* @param \Throwable|null $previous 이전 예외
*/
public function __construct(string $message = '', int $code = 409, ?\Throwable $previous = null)
{
- if (empty($message)) {
- $message = __('sirsoft-board::messages.boards.menu_already_exists');
+ $this->messageKey = $message === '' ? self::DEFAULT_MESSAGE_KEY : null;
+
+ if ($message === '') {
+ $message = __(self::DEFAULT_MESSAGE_KEY);
}
parent::__construct($message, $code, $previous);
}
-}
\ No newline at end of file
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 호출부가 직접 문구를 넘긴 경우엔 키가 없으므로 기본 키를 돌려준다 — 응답에는
+ * 언제나 키가 전달되어야 하며, 이미 번역된 문구가 키 자리에 들어가면 해석에
+ * 실패해 원문이 그대로 노출된다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey ?? self::DEFAULT_MESSAGE_KEY;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return [];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php
new file mode 100644
index 00000000..6e45b043
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostHasRepliesException.php
@@ -0,0 +1,43 @@
+ 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return [];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
index bce1a68f..3dbb57c8 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
@@ -18,13 +18,13 @@ class PostNotCommentableException extends Exception
{
/**
* @param string $reason 거절 사유 (blinded | deleted)
- * @param string $message 사용자에게 보일 메시지
+ * @param string $messageKey 사용자에게 보일 문구의 다국어 키
*/
public function __construct(
private string $reason,
- string $message,
+ private string $messageKey,
) {
- parent::__construct($message);
+ parent::__construct(__($messageKey));
}
/**
@@ -34,7 +34,7 @@ class PostNotCommentableException extends Exception
*/
public static function blinded(): self
{
- return new self('blinded', __('sirsoft-board::messages.comment.post_blinded'));
+ return new self('blinded', 'sirsoft-board::messages.comment.post_blinded');
}
/**
@@ -44,7 +44,27 @@ class PostNotCommentableException extends Exception
*/
public static function deleted(): self
{
- return new self('deleted', __('sirsoft-board::messages.comment.post_deleted'));
+ return new self('deleted', 'sirsoft-board::messages.comment.post_deleted');
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return [];
}
/**
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
index db7a56da..6e81fc50 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
@@ -84,7 +84,12 @@ class AttachmentController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
@@ -113,7 +118,7 @@ class AttachmentController extends AdminBaseController
}
// 삭제 (Service에서 처리)
- $result = $this->attachmentService->delete($slug, $id);
+ $result = $this->attachmentService->delete($slug, $id, 'admin');
if (! $result) {
return $this->error('sirsoft-board::messages.attachment.delete_failed', 500);
@@ -144,7 +149,7 @@ class AttachmentController extends AdminBaseController
// FileUploader가 [{id, order}] 형태로 전송 → [ID => order] 매핑으로 변환
$orders = collect($validated['order'])->pluck('order', 'id')->all();
- $result = $this->attachmentService->reorder($slug, $orders);
+ $result = $this->attachmentService->reorder($slug, $orders, 'admin');
if (! $result) {
return $this->error('sirsoft-board::messages.attachment.reorder_failed', 500);
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
index b4b1bee9..87e77d81 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Board\Exceptions\MenuAlreadyExistsException;
+use Modules\Sirsoft\Board\Http\Requests\Admin\IndexBoardRequest;
use Modules\Sirsoft\Board\Http\Requests\StoreBoardRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateBoardRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardCollection;
@@ -46,14 +47,13 @@ class BoardController extends AdminBaseController
/**
* 게시판 목록을 조회합니다.
*
- * @param Request $request HTTP 요청
+ * @param IndexBoardRequest $request 목록 조회 요청 (정렬 컬럼·페이지 크기 화이트리스트 검증)
* @return JsonResponse 게시판 목록 응답
*/
- // audit:allow controller-base-request-injection reason: GET 목록 조회. all()로 필터·페이징 파라미터만 읽음 (검증 불필요)
- public function index(Request $request): JsonResponse
+ public function index(IndexBoardRequest $request): JsonResponse
{
try {
- $boards = $this->boardService->getBoards($request->all());
+ $boards = $this->boardService->getBoards($request->validated());
// BoardCollection의 withPermissions 사용
$collection = new BoardCollection($boards);
@@ -335,7 +335,7 @@ class BoardController extends AdminBaseController
return $this->success('sirsoft-board::messages.boards.menu_added_success', ['menu' => $menu]);
} catch (MenuAlreadyExistsException $e) {
- return $this->error($e->getMessage(), $e->getCode());
+ return $this->error($e->getMessageKey(), $e->getCode(), null, $e->getMessageParams());
} catch (ModelNotFoundException $e) {
return $this->notFound('sirsoft-board::messages.boards.error_404');
} catch (AccessDeniedHttpException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardSettingsController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardSettingsController.php
index d498c4a9..2c89c05c 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardSettingsController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardSettingsController.php
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
+use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
@@ -117,6 +118,10 @@ class BoardSettingsController extends AdminBaseController
$this->settingsService->syncReportPermissionRoles($request->input('report_permissions'));
}
+ // 코어 모듈 설정 저장 훅 — SEO 캐시 무효화 등 코어/타 확장 리스너가 구독한다.
+ // 훅 의미가 "관리자가 설정을 저장했다" 이므로 서비스가 아니라 여기서 발화한다.
+ HookManager::doAction('core.module_settings.after_save', 'sirsoft-board', $settings, $result);
+
$updatedSettings = $this->settingsService->getAllSettings();
$updatedSettings['report_permissions'] = $this->settingsService->getReportPermissionRoles();
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
index d1ac36f9..d3d24a77 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Board\Exceptions\BoardTypeOperationException;
use Modules\Sirsoft\Board\Http\Requests\StoreBoardTypeRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateBoardTypeRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardTypeResource;
@@ -32,7 +33,7 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param StoreBoardTypeRequest $request
+ * @param StoreBoardTypeRequest $request
* @return JsonResponse
*/
public function store(StoreBoardTypeRequest $request): JsonResponse
@@ -47,8 +48,8 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param UpdateBoardTypeRequest $request
- * @param int $id
+ * @param UpdateBoardTypeRequest $request
+ * @param int $id
* @return JsonResponse
*/
public function update(UpdateBoardTypeRequest $request, int $id): JsonResponse
@@ -66,7 +67,7 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
@@ -77,8 +78,13 @@ class BoardTypeController extends AdminBaseController
return $this->success('sirsoft-board::messages.board_type.deleted');
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-board::messages.board_type.not_found');
+ } catch (BoardTypeOperationException $e) {
+ // 삭제 불가 사유(사용 중/기본 유형)를 전용 키로 안내 — 예외 메시지 원문을
+ // 키 자리에 넘기면(종전 동작) 키 해석에 실패해 원문이 그대로 노출된다.
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
- return $this->error($e->getMessage(), 422);
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return $this->error('sirsoft-board::messages.errors.operation_failed', 500);
}
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
index 20943d3b..1201e600 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
@@ -114,10 +114,10 @@ class CommentController extends AdminBaseController
return $this->notFound('sirsoft-board::messages.boards.not_found');
} catch (CommentDepthExceededException $e) {
// 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (PostNotCommentableException $e) {
// 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.comment.create_failed', 500, $e->getMessage());
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
index 0af8fe5a..dee8eb51 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
+use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormDataRequest;
use Modules\Sirsoft\Board\Http\Requests\Admin\PostFormMetaRequest;
@@ -197,7 +198,12 @@ class PostController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.posts.create_failed', 500, $e->getMessage());
}
@@ -252,7 +258,12 @@ class PostController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
} catch (AccessDeniedHttpException $e) {
@@ -305,6 +316,9 @@ class PostController extends AdminBaseController
'sirsoft-board::messages.posts.delete_success',
new PostResource($deletedPost)
);
+ } catch (PostHasRepliesException $e) {
+ // 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422
+ return $this->error($e->getMessageKey(), 422);
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
} catch (AccessDeniedHttpException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php
index 8d0c4bbb..640722b1 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReportController.php
@@ -70,7 +70,6 @@ class ReportController extends AdminBaseController
'board_id' => $request->input('board_id'),
'reported_at_from' => $request->input('reported_at_from'),
'reported_at_to' => $request->input('reported_at_to'),
- 'sort_by' => $request->input('sort_by'),
'sort_order' => $request->input('sort_order'),
];
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
index f51568c6..2e3f877d 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
@@ -83,11 +84,17 @@ class AttachmentController extends PublicBaseController
* 이미지 파일만 미리보기를 제공합니다.
* 비회원도 이미지를 볼 수 있습니다.
*
+ * 비밀글·삭제글 첨부는 서비스의 콘텐츠 상태 게이트가 차단하되, 게이트를 통과한
+ * 응답 직렬화(PostResource)가 발급한 한시 서명 URL 은 동등한 자격으로 허용한다 —
+ *
는 Authorization 헤더를 실을 수 없는 렌더 경로이기 때문이다.
+ *
+ * @param Request $request HTTP 요청 객체 (한시 서명 검증용)
* @param string $slug 게시판 슬러그
* @param string $hash 첨부파일 해시 (12자)
* @return BinaryFileResponse|Response|JsonResponse 이미지 응답 또는 에러 응답
*/
- public function preview(string $slug, string $hash): BinaryFileResponse|Response|JsonResponse
+ // audit:allow controller-base-request-injection reason: GET 이미지 서빙. 요청 URL 의 한시 서명 검증(hasValidSignature)만 수행. 검증할 body 없음(slug/hash 는 라우트 파라미터)
+ public function preview(Request $request, string $slug, string $hash): BinaryFileResponse|Response|JsonResponse
{
try {
// 게시판 존재 여부 확인
@@ -105,8 +112,12 @@ class AttachmentController extends PublicBaseController
return $this->badRequest(__('sirsoft-board::messages.attachment.not_image'));
}
- // 파일 정보 조회
- $fileInfo = $this->attachmentService->getFileInfo($slug, $attachment->id);
+ // 파일 정보 조회 (유효 서명은 콘텐츠 상태 게이트 통과 자격의 위임)
+ $fileInfo = $this->attachmentService->getFileInfo(
+ $slug,
+ $attachment->id,
+ signatureVerified: $request->hasValidSignature(absolute: false)
+ );
if (! $fileInfo) {
return $this->notFound(__('sirsoft-board::messages.attachment.not_found'));
@@ -178,7 +189,12 @@ class AttachmentController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
} catch (\Exception $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php
index d2eb02c1..a6ed7784 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/BoardController.php
@@ -45,8 +45,9 @@ class BoardController extends PublicBaseController
*/
public function index(IndexBoardRequest $request): JsonResponse
{
- // 활성화된 게시판만 조회
- $boards = $this->boardService->getActiveBoards();
+ // 활성 게시판 중 호출자가 열람 가능한 게시판만 조회 — 디렉토리와 내장 최근글이
+ // 비공개 게시판·글 제목의 우회 노출로가 되지 않게 한다 (최근글·인기글과 동일 기준)
+ $boards = $this->boardService->getReadableActiveBoards();
// limit 파라미터 (0이면 최신글 미포함, 최대 10개)
$recentPostsLimit = $request->recentPostsLimit();
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
index bcd13397..77e22db5 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
@@ -10,10 +10,13 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Exceptions\CommentDepthExceededException;
use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException;
+use Modules\Sirsoft\Board\Http\Requests\DestroyCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
+use Modules\Sirsoft\Board\Http\Resources\PostResource;
+use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
@@ -32,7 +35,8 @@ class CommentController extends PublicBaseController
*/
public function __construct(
private CommentService $commentService,
- private BoardService $boardService
+ private BoardService $boardService,
+ private PostRepositoryInterface $postRepository
) {
parent::__construct();
}
@@ -57,6 +61,32 @@ class CommentController extends PublicBaseController
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
+ // 비밀글 댓글 게이팅(KVE-2026-1914): 부모 게시글이 비밀글이면 열람 권한이 없는
+ // 요청에는 댓글 목록을 노출하지 않는다(게시글 상세와 동일 정책, SecretContentGate SSoT).
+ $post = $this->postRepository->find($slug, $postId);
+
+ // 부모 글을 못 읽으면 막는다(fail-closed). `find` 는 슬러그로 게시판을 먼저 찾는데
+ // 그 게시판이 없으면 null 을 돌려주므로, 통과시키면 비밀 게이트가 있어야 할 자리에서
+ // 무게이트로 댓글 목록이 나간다.
+ if (! $post) {
+ return $this->error('sirsoft-board::messages.posts.not_found', 404);
+ }
+
+ if ($post->is_secret && ! PostResource::canViewSecretForPost($post)) {
+ return $this->success(
+ 'sirsoft-board::messages.comments.index_success',
+ CommentResource::collection([])
+ );
+ }
+
+ // 이미 조회한 부모 post 를 CommentResource 로 전달한다(KVE-2026-1914 이중 방어 A-4b).
+ // Resource 는 이 인스턴스를 재사용해 (a) 2차 비밀 게이트를 댓글당 lazy-load 없이
+ // 재확인하고 (b) toArray 의 slug 도출도 재사용한다 — 목록의 댓글당 board_posts
+ // 조회(N+1)를 제거한다. 컨트롤러가 SSoT 로 부모 post 를 쥐고 있으므로 추가 쿼리 0.
+ if ($post) {
+ request()->attributes->set('sirsoft_board_parent_post', $post);
+ }
+
$comments = $this->commentService->getCommentsByPostId($slug, $postId);
return $this->success(
@@ -118,10 +148,10 @@ class CommentController extends PublicBaseController
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (CommentDepthExceededException $e) {
// 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (PostNotCommentableException $e) {
// 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.comment.create_failed', 500);
}
@@ -160,12 +190,22 @@ class CommentController extends PublicBaseController
$slug
);
+ // 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형)
+ if (! $canUpdate && $request->filled('verification_token')) {
+ $canUpdate = $this->commentService->consumeCommentVerifyToken(
+ $slug,
+ $commentId,
+ (string) $request->input('verification_token')
+ );
+ }
+
if (! $canUpdate) {
return $this->forbidden('sirsoft-board::messages.comment.update_forbidden');
}
- // 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). password는 검증용이므로 제거
- $data = collect($request->validated())->except('password')->toArray();
+ // 검증된 필드만 반영 (미검증 입력의 대량 할당 차단).
+ // password/verification_token 은 본인 확인용이므로 저장 데이터에서 제거
+ $data = collect($request->validated())->except(['password', 'verification_token'])->toArray();
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data, $postId);
return $this->successWithResource(
@@ -182,12 +222,13 @@ class CommentController extends PublicBaseController
/**
* 댓글을 삭제합니다.
*
+ * @param DestroyCommentRequest $request 댓글 삭제 요청 (비회원 password 형식 검증)
* @param string $slug 게시판 slug
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return JsonResponse 댓글 삭제 결과 응답
*/
- public function destroy(string $slug, int $postId, int $commentId): JsonResponse
+ public function destroy(DestroyCommentRequest $request, string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
@@ -203,8 +244,8 @@ class CommentController extends PublicBaseController
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
- // 비회원인 경우 password 파라미터 필요
- $password = request()->input('password');
+ // 비회원인 경우 password 파라미터 필요 (형식 검증은 DestroyCommentRequest — 배열 주입 422 차단)
+ $password = $request->validated('password');
// 권한 확인 (Service에서 처리)
$canDelete = $this->commentService->canDelete(
@@ -214,6 +255,16 @@ class CommentController extends PublicBaseController
$slug
);
+ // 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형)
+ $verificationToken = $request->validated('verification_token');
+ if (! $canDelete && ! empty($verificationToken)) {
+ $canDelete = $this->commentService->consumeCommentVerifyToken(
+ $slug,
+ $commentId,
+ $verificationToken
+ );
+ }
+
if (! $canDelete) {
return $this->forbidden('sirsoft-board::messages.comment.delete_forbidden');
}
@@ -259,16 +310,18 @@ class CommentController extends PublicBaseController
return $this->error('sirsoft-board::messages.comment.invalid_password', 401);
}
- // 검증 성공 시 임시 토큰 생성 (프론트엔드에서 로컬 스토리지에 저장)
+ // 검증 성공 시 1회용 토큰을 발급하고 캐시에 저장한다.
+ // (게시글과 동형 — update/destroy 가 이 토큰을 소비해 평문 비밀번호 재전송을 대체)
$verificationToken = Str::random(32);
+ $tokenResult = $this->commentService->storeCommentVerifyToken($slug, $commentId, $verificationToken);
return $this->success(
'sirsoft-board::messages.comment.password_verified',
[
'verified' => true,
'comment_id' => $commentId,
- 'verification_token' => $verificationToken,
- 'expires_at' => now()->addHours(1)->toIso8601String(), // 1시간 유효
+ 'verification_token' => $tokenResult['token'],
+ 'expires_at' => $tokenResult['expires_at'],
]
);
} catch (ModelNotFoundException) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
index 0be7a176..0c7baf31 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
@@ -17,7 +17,9 @@ use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\SecretMode;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
+use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
+use Modules\Sirsoft\Board\Http\Requests\User\DestroyPostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\StorePostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\UpdatePostRequest;
use Modules\Sirsoft\Board\Http\Requests\User\VerifyGuestPasswordRequest;
@@ -400,7 +402,12 @@ class PostController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
throw $e;
} catch (\Exception $e) {
@@ -455,7 +462,12 @@ class PostController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (ModelNotFoundException $e) {
@@ -470,13 +482,15 @@ class PostController extends PublicBaseController
/**
* 게시글을 삭제합니다.
*
- * @param Request $request HTTP 요청
+ * 비회원 소유권 확인용 password/verification_token 형식 검증은 DestroyPostRequest 가
+ * 담당한다 (배열 주입 422 차단). 소유권 판정 자체는 canModifyPost 가 수행한다.
+ *
+ * @param DestroyPostRequest $request 게시글 삭제 요청
* @param string $slug 게시판 슬러그
* @param string|int $id 게시글 ID
* @return JsonResponse 삭제 결과 응답
*/
- // audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
- public function destroy(Request $request, string $slug, string|int $id): JsonResponse
+ public function destroy(DestroyPostRequest $request, string $slug, string|int $id): JsonResponse
{
$id = (int) $id;
@@ -499,6 +513,9 @@ class PostController extends PublicBaseController
$this->postService->deletePost($slug, $id, 'user');
return $this->success('sirsoft-board::messages.posts.delete_success');
+ } catch (PostHasRepliesException $e) {
+ // 답글 삭제 정책(block): 살아있는 답글이 있어 삭제 거부 — 입력(대상 선택) 문제이므로 422
+ return $this->error($e->getMessageKey(), 422);
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (ModelNotFoundException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php
index 1e38bbfe..7046884a 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/BulkApplySettingsRequest.php
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Models\Board;
@@ -30,6 +31,7 @@ class BulkApplySettingsRequest extends FormRequest
'use_comment',
'use_reply',
'max_reply_depth',
+ 'reply_delete_policy',
'max_comment_depth',
'comment_order',
'show_view_count',
@@ -117,6 +119,7 @@ class BulkApplySettingsRequest extends FormRequest
'override_values.per_page' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.per_page_mobile' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.max_reply_depth' => ['nullable', 'integer', "min:{$limits['max_reply_depth_min']}", "max:{$limits['max_reply_depth_max']}"],
+ 'override_values.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'override_values.max_comment_depth' => ['nullable', 'integer', "min:{$limits['max_comment_depth_min']}", "max:{$limits['max_comment_depth_max']}"],
'override_values.min_title_length' => ['nullable', 'integer', "min:{$limits['min_title_length_min']}", "max:{$limits['min_title_length_max']}"],
'override_values.max_title_length' => ['nullable', 'integer', "min:{$limits['max_title_length_min']}", "max:{$limits['max_title_length_max']}"],
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php
new file mode 100644
index 00000000..c2132e7b
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexBoardRequest.php
@@ -0,0 +1,58 @@
+input('sort_order'))) {
+ $this->merge(['sort_order' => strtolower($this->input('sort_order'))]);
+ }
+ }
+
+ /**
+ * 검증 규칙을 반환합니다.
+ *
+ * @return array> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'page' => ['nullable', 'integer', 'min:1'],
+ // 게시판은 운영자 등록 설정성 테이블 — 전체 목록 셀렉트 소비처
+ // (게시판 설정 999 / 쇼핑몰 환경설정 문의 카드 200)가 이 상한에 묶인다.
+ // 100 으로 조이면 두 화면 진입이 422 로 깨진다 (BoardIndexSortTest 회귀 고정).
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:999'],
+ 'type' => ['nullable', 'string', 'max:50'],
+ 'search' => ['nullable', 'string', 'max:100'],
+ 'sort_by' => ['nullable', 'string', 'in:id,slug,type,is_active,posts_count,comments_count,created_at,updated_at'],
+ 'sort_order' => ['nullable', 'string', 'in:asc,desc'],
+ ];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php
index fb82b9a8..22894892 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/IndexReportRequest.php
@@ -40,7 +40,7 @@ class IndexReportRequest extends FormRequest
'board_id' => ['nullable', 'integer'],
'reported_at_from' => ['nullable', 'string'],
'reported_at_to' => ['nullable', 'string'],
- 'sort_by' => ['nullable', 'string'],
+ // sort_by 는 받지 않는다 — 저장소가 last_reported_at 고정 정렬 (死규칙 정리)
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
'per_page' => ['nullable', 'integer'],
];
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php
index 15f81a67..eafcf38b 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Admin/StoreBoardSettingsRequest.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use App\Models\Role;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
/**
@@ -58,6 +59,7 @@ class StoreBoardSettingsRequest extends FormRequest
'spam_security.comment_cooldown_seconds',
'spam_security.report_cooldown_seconds',
'spam_security.view_count_cache_ttl',
+ 'attachment_settings.purge_retention_days',
];
$booleanFields = [
@@ -73,6 +75,7 @@ class StoreBoardSettingsRequest extends FormRequest
'seo.seo_boards',
'seo.seo_board',
'seo.seo_post_detail',
+ 'attachment_settings.purge_enabled',
];
$data = $this->all();
@@ -172,6 +175,7 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.use_comment' => ['nullable', 'boolean'],
'basic_defaults.use_reply' => ['nullable', 'boolean'],
'basic_defaults.max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
+ 'basic_defaults.reply_delete_policy' => ['nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'basic_defaults.max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'],
'basic_defaults.show_view_count' => ['nullable', 'boolean'],
@@ -262,6 +266,11 @@ class StoreBoardSettingsRequest extends FormRequest
'seo.seo_boards' => ['nullable', 'boolean'],
'seo.seo_board' => ['nullable', 'boolean'],
'seo.seo_post_detail' => ['nullable', 'boolean'],
+
+ // 첨부 정리 정책 — 사용자 파일을 파기하므로 보존기간 하한을 서버가 강제한다.
+ // 상한(3650일)은 "사실상 정리하지 않음" 과 구분되지 않는 값을 저장하지 않기 위함이다.
+ 'attachment_settings.purge_enabled' => ['nullable', 'boolean'],
+ 'attachment_settings.purge_retention_days' => ['nullable', 'integer', 'min:'.$limits['attachment_purge_retention_days_min'], 'max:'.$limits['attachment_purge_retention_days_max']],
];
}
@@ -311,7 +320,7 @@ class StoreBoardSettingsRequest extends FormRequest
public function validatedSettings(): array
{
$validated = $this->validated();
- $validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications'];
+ $validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications', 'attachment_settings'];
return array_filter(
$validated,
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php
index 7bdc21dd..b7efa253 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindCommentRequest.php
@@ -39,7 +39,7 @@ class BlindCommentRequest extends FormRequest
public function messages(): array
{
return [
- 'reason.min' => __('sirsoft-board::validation.blind.reason.min'),
+ // reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지)
'reason.max' => __('sirsoft-board::validation.blind.reason.max'),
];
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php
index 4a46c791..6d392945 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/BlindPostRequest.php
@@ -39,7 +39,7 @@ class BlindPostRequest extends FormRequest
public function messages(): array
{
return [
- 'reason.min' => __('sirsoft-board::validation.blind.reason.min'),
+ // reason 규칙에 min 이 없으므로 min 메시지는 매핑하지 않는다 (死매핑 정리 — lang 키 자체는 유지)
'reason.max' => __('sirsoft-board::validation.blind.reason.max'),
];
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php
index 8323a609..248380f3 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/Concerns/ReadsBoardLimits.php
@@ -67,6 +67,9 @@ trait ReadsBoardLimits
'new_display_hours_min' => 0,
'new_display_hours_max' => 720,
+
+ 'attachment_purge_retention_days_min' => 1,
+ 'attachment_purge_retention_days_max' => 3650,
];
/**
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php
new file mode 100644
index 00000000..83086a49
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/DestroyCommentRequest.php
@@ -0,0 +1,51 @@
+input('password')` 로 읽어(저장소 유일 사례) 배열 주입이 그대로
+ * 비교 로직에 유입될 수 있었다. 소유권 판정 자체는 CommentService::canDelete 가
+ * 담당하므로 여기서는 형식만 닫는다 (형제 store/update/verifyPassword 와 대칭).
+ */
+class DestroyCommentRequest extends FormRequest
+{
+ /**
+ * 요청 권한 — 소유권/권한 판정은 Service(canDelete)가 담당.
+ *
+ * @return bool 항상 true
+ */
+ public function authorize(): bool
+ {
+ return true;
+ }
+
+ /**
+ * 검증 규칙을 반환합니다.
+ *
+ * @return array> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'password' => ['nullable', 'string', 'min:4', 'max:20'],
+ 'verification_token' => ['nullable', 'string', 'max:255'],
+ ];
+ }
+
+ /**
+ * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다.
+ *
+ * @return array 필드별 표시명
+ */
+ public function attributes(): array
+ {
+ return [
+ 'password' => __('sirsoft-board::validation.attributes.comment.password'),
+ ];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php
index 38c7ff38..3a65281c 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/RestorePostRequest.php
@@ -45,4 +45,16 @@ class RestorePostRequest extends FormRequest
'reason.max' => __('sirsoft-board::validation.restore.reason.max'),
];
}
+
+ /**
+ * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다 (RestoreCommentRequest 와 대칭).
+ *
+ * @return array 필드별 표시명
+ */
+ public function attributes(): array
+ {
+ return [
+ 'reason' => __('sirsoft-board::validation.attributes.restore.reason'),
+ ];
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php
index f67978bc..3221d9ef 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreBoardRequest.php
@@ -10,6 +10,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
@@ -54,6 +55,7 @@ class StoreBoardRequest extends FormRequest
'use_comment' => $settings['use_comment'] ?? true,
'use_reply' => $settings['use_reply'] ?? true,
'max_reply_depth' => $settings['max_reply_depth'] ?? 5,
+ 'reply_delete_policy' => $settings['reply_delete_policy'] ?? ReplyDeletePolicy::Cascade->value,
'max_comment_depth' => $settings['max_comment_depth'] ?? 10,
'use_file_upload' => $settings['use_file_upload'] ?? false,
'comment_order' => $settings['comment_order'] ?? 'ASC',
@@ -207,6 +209,7 @@ class StoreBoardRequest extends FormRequest
// 답글/대댓글 깊이 제한
'max_reply_depth' => ['nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
+ 'reply_delete_policy' => ['required', 'string', Rule::in(ReplyDeletePolicy::values())],
'max_comment_depth' => ['nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
// 알림 설정
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php
index d2e37eb8..992a83e6 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateBoardRequest.php
@@ -10,6 +10,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
@@ -207,6 +208,7 @@ class UpdateBoardRequest extends FormRequest
// 답글/대댓글 깊이 제한
'max_reply_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxReplyDepthMin}", "max:{$maxReplyDepthMax}"],
+ 'reply_delete_policy' => ['sometimes', 'nullable', 'string', Rule::in(ReplyDeletePolicy::values())],
'max_comment_depth' => ['sometimes', 'nullable', 'integer', "min:{$maxCommentDepthMin}", "max:{$maxCommentDepthMax}"],
// 알림 설정
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php
index 302054c7..b8f25b0e 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/UpdateCommentRequest.php
@@ -6,7 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\PostStatus;
-use Modules\Sirsoft\Board\Models\Board;
+use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
/**
@@ -32,7 +32,7 @@ class UpdateCommentRequest extends FormRequest
public function rules(): array
{
$slug = $this->route('slug');
- $board = Board::where('slug', $slug)->first();
+ $board = app(BoardRepositoryInterface::class)->findBySlug($slug);
if (! $board) {
return [];
@@ -41,6 +41,10 @@ class UpdateCommentRequest extends FormRequest
// 비회원 여부 확인 (request()->user()를 사용해야 PermissionMiddleware에서 설정한 사용자를 인식)
$isGuest = ! $this->user();
+ // 검증 토큰(verify-password 로 발급)이 있으면 평문 비밀번호 재전송을 요구하지 않는다.
+ // (게시글 수정 경로와 동형 — 토큰이 본인 확인을 대체하며, 컨트롤러가 1회 소비한다)
+ $hasVerificationToken = $this->filled('verification_token');
+
// 금지 키워드 목록 가져오기 (게시글과 동일하게 게시판 설정 기준)
$blockedKeywords = $board->blocked_keywords ?? [];
@@ -54,8 +58,9 @@ class UpdateCommentRequest extends FormRequest
],
'is_secret' => ['boolean'],
'status' => ['nullable', 'string', Rule::in(PostStatus::values())],
- // 비회원인 경우 비밀번호 필수 (수정 권한 검증용)
- 'password' => [$isGuest ? 'required' : 'nullable', 'string', 'min:4', 'max:20'],
+ // 비회원인 경우 비밀번호 필수 (수정 권한 검증용). 단, 검증 토큰이 있으면 선택.
+ 'password' => [$isGuest && ! $hasVerificationToken ? 'required' : 'nullable', 'string', 'min:4', 'max:20'],
+ 'verification_token' => ['nullable', 'string', 'max:255'],
];
// 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php
new file mode 100644
index 00000000..fbf934bb
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/DestroyPostRequest.php
@@ -0,0 +1,51 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'password' => ['nullable', 'string', 'min:4'],
+ 'verification_token' => ['nullable', 'string', 'max:255'],
+ 'force_delete' => ['nullable', 'boolean'],
+ ];
+ }
+
+ /**
+ * 검증 오류 메시지에 쓰일 필드 표시명을 반환합니다.
+ *
+ * @return array 필드별 표시명
+ */
+ public function attributes(): array
+ {
+ return [
+ 'password' => __('sirsoft-board::validation.attributes.post.password'),
+ ];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php
index 7c53f6a8..a989ad79 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php
@@ -11,8 +11,8 @@ use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount;
-use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
+use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
use Modules\Sirsoft\Board\Rules\CooldownRule;
use Modules\Sirsoft\Board\Rules\ParentPostValidationRule;
@@ -40,6 +40,28 @@ class StorePostRequest extends FormRequest
return true;
}
+ /**
+ * 검증 전 입력값을 정규화합니다.
+ *
+ * 비밀글 여부를 문자열 "true"/"false" 로 보내는 클라이언트도 수용하도록
+ * boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리)
+ */
+ protected function prepareForValidation(): void
+ {
+ $merge = [];
+
+ if ($this->has('is_secret')) {
+ $normalized = filter_var($this->input('is_secret'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if ($normalized !== null) {
+ $merge['is_secret'] = $normalized;
+ }
+ }
+
+ if ($merge) {
+ $this->merge($merge);
+ }
+ }
+
/**
* 요청에 적용할 검증 규칙
*
@@ -48,7 +70,7 @@ class StorePostRequest extends FormRequest
public function rules(): array
{
$slug = $this->route('slug');
- $board = Board::where('slug', $slug)->first();
+ $board = app(BoardRepositoryInterface::class)->findBySlug((string) $slug);
// 게시판이 없으면 모듈 환경설정 기본값 사용
$defaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
@@ -225,7 +247,7 @@ class StorePostRequest extends FormRequest
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
- $board = Board::where('slug', $this->route('slug'))->first();
+ $board = app(BoardRepositoryInterface::class)->findBySlug((string) $this->route('slug'));
$this->validateAttachmentTotal($validator, $board);
});
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php
index ce75e6ec..ff4b7a87 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php
@@ -8,7 +8,7 @@ use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount;
-use Modules\Sirsoft\Board\Models\Board;
+use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
/**
@@ -33,6 +33,28 @@ class UpdatePostRequest extends FormRequest
return true;
}
+ /**
+ * 검증 전 입력값을 정규화합니다.
+ *
+ * 비밀글 여부를 문자열 "true"/"false" 로 보내는 클라이언트도 수용하도록
+ * boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리)
+ */
+ protected function prepareForValidation(): void
+ {
+ $merge = [];
+
+ if ($this->has('is_secret')) {
+ $normalized = filter_var($this->input('is_secret'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if ($normalized !== null) {
+ $merge['is_secret'] = $normalized;
+ }
+ }
+
+ if ($merge) {
+ $this->merge($merge);
+ }
+ }
+
/**
* 요청에 적용할 검증 규칙
*
@@ -41,7 +63,7 @@ class UpdatePostRequest extends FormRequest
public function rules(): array
{
$slug = $this->route('slug');
- $board = Board::where('slug', $slug)->first();
+ $board = app(BoardRepositoryInterface::class)->findBySlug((string) $slug);
if (! $board) {
return [];
@@ -81,6 +103,9 @@ class UpdatePostRequest extends FormRequest
'attachment_ids.*' => ['integer', 'min:1'],
// temp_key는 게시판 존재 여부와 무관하게 항상 허용
'temp_key' => ['nullable', 'string', 'max:64'],
+ // 비회원 소유권 확인용 — 판정은 컨트롤러(canModifyPost), 여기서는 형식만 닫는다 (배열 주입 422)
+ 'password' => ['nullable', 'string', 'min:4'],
+ 'verification_token' => ['nullable', 'string', 'max:255'],
];
// 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공
@@ -134,7 +159,7 @@ class UpdatePostRequest extends FormRequest
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
- $board = Board::where('slug', $this->route('slug'))->first();
+ $board = app(BoardRepositoryInterface::class)->findBySlug((string) $this->route('slug'));
$postId = $this->route('id');
$this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null);
});
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/AttachmentResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/AttachmentResource.php
index f615e51d..be8d8795 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/AttachmentResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/AttachmentResource.php
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+use Modules\Sirsoft\Board\Models\Attachment;
/**
* 첨부파일 API 리소스
@@ -13,6 +15,35 @@ use Illuminate\Http\Request;
*/
class AttachmentResource extends BaseApiResource
{
+ /**
+ * 서명 preview URL 발급 대상 게시판 슬러그 (null 이면 무서명 공개 URL).
+ *
+ * 비밀글·삭제글처럼 무서명 preview 가 게이트에 차단되는 게시글의 첨부를,
+ * 게이트를 통과한 응답(PostResource 상세 직렬화)에 한해
렌더 가능한
+ * 한시 서명 URL 로 직렬화하기 위한 플래그다.
+ */
+ private ?string $signedPreviewSlug = null;
+
+ /**
+ * 첨부파일 목록을 리소스 배열로 변환합니다.
+ *
+ * @param iterable|null $attachments 첨부파일 목록
+ * @param string|null $signedPreviewSlug 서명 preview URL 발급 대상 슬러그 (비밀글·삭제글 전용)
+ * @return array
+ */
+ public static function collectionFor($attachments, ?string $signedPreviewSlug = null): array
+ {
+ return Collection::make($attachments ?? [])
+ ->map(function ($attachment) use ($signedPreviewSlug) {
+ $resource = new self($attachment);
+ $resource->signedPreviewSlug = $signedPreviewSlug;
+
+ return $resource;
+ })
+ ->values()
+ ->all();
+ }
+
/**
* 리소스를 배열로 변환합니다.
*
@@ -31,7 +62,9 @@ class AttachmentResource extends BaseApiResource
'collection' => $this->collection,
'order' => $this->order,
'download_url' => $this->download_url,
- 'preview_url' => $this->preview_url,
+ 'preview_url' => $this->signedPreviewSlug !== null && $this->resource->is_image
+ ? $this->resource->previewUrlForSlug($this->signedPreviewSlug, signed: true)
+ : $this->preview_url,
'is_image' => $this->is_image,
'meta' => $this->meta,
];
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php
index e1e997b5..4b6d66f5 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php
@@ -56,6 +56,7 @@ class BoardResource extends BaseApiResource
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
'max_reply_depth' => $this->max_reply_depth,
+ 'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'comment_order' => $this->comment_order,
'max_comment_depth' => $this->max_comment_depth,
@@ -192,6 +193,7 @@ class BoardResource extends BaseApiResource
'use_file_upload' => $this->use_file_upload,
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
+ 'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'secret_mode' => $this->secret_mode,
'show_view_count' => $this->show_view_count,
@@ -224,6 +226,7 @@ class BoardResource extends BaseApiResource
'use_file_upload' => $this->use_file_upload,
'use_comment' => $this->use_comment,
'use_reply' => $this->use_reply,
+ 'reply_delete_policy' => $this->reply_delete_policy,
'use_report' => $this->use_report,
'secret_mode' => $this->secret_mode,
'per_page' => $this->per_page,
@@ -401,7 +404,7 @@ class BoardResource extends BaseApiResource
/** @var User|null $user */
$user = Auth::user();
$slug = $this->getValue('slug');
- $isAdminRoute = $this->isAdminRoute($request);
+ $isAdminRoute = $this->isAdminRequest($request);
// 컨텍스트에 맞는 permissionMap 구성 (PostResource.resolveAbilities()와 동일 키)
$permissionMap = $isAdminRoute
@@ -490,23 +493,4 @@ class BoardResource extends BaseApiResource
return $guestRole->permissions()->where('permissions.id', $permission->id)->exists();
}
-
- /**
- * Admin 라우트 여부를 확인합니다.
- *
- * Controller 네임스페이스로 판단합니다.
- *
- * @param Request $request HTTP 요청
- * @return bool Admin 라우트 여부
- */
- private function isAdminRoute(Request $request): bool
- {
- $controller = $request->route()?->getController();
-
- if (! $controller) {
- return false;
- }
-
- return str_contains(get_class($controller), '\\Admin\\');
- }
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/CommentResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/CommentResource.php
index 3dddc4dd..020ec762 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/CommentResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/CommentResource.php
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
+use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
@@ -31,7 +32,11 @@ class CommentResource extends BaseApiResource
*/
public function toArray(Request $request): array
{
- $slug = $this->post?->board?->slug ?? $request->route('slug');
+ // 컨트롤러가 넘긴 부모 post(요청 속성)를 재사용해 slug 를 도출한다 — 목록에서 댓글당
+ // `$this->post` lazy-load(N+1)를 피한다(KVE-2026-1914 A-4b). 미주입 경로(상세/생성/
+ // admin)는 종전대로 이미 로드된 관계 또는 라우트 slug 로 폴백한다.
+ $parentPost = $this->resolveParentPost($request);
+ $slug = $parentPost?->board?->slug ?? $request->route('slug');
return [
'id' => $this->id,
@@ -170,8 +175,9 @@ class CommentResource extends BaseApiResource
}
// fallback: 개별 쿼리 (목록 등 사전 로드 미적용 경로)
+ // 부모 post 는 컨트롤러가 넘긴 인스턴스를 재사용해 댓글당 lazy-load 를 피한다.
$user = $request->user();
- $boardId = $this->post?->board?->id ?? null;
+ $boardId = $this->resolveParentPost($request)?->board?->id ?? null;
if (! $user || ! $boardId) {
return false;
@@ -206,7 +212,7 @@ class CommentResource extends BaseApiResource
*/
protected function resolveAbilities(Request $request): array
{
- $slug = $this->post?->board?->slug ?? $request->route('slug');
+ $slug = $this->resolveParentPost($request)?->board?->slug ?? $request->route('slug');
if (! $slug) {
return [];
}
@@ -228,29 +234,33 @@ class CommentResource extends BaseApiResource
->toArray();
}
- /**
- * Admin 요청 여부를 확인합니다.
- *
- * Controller 네임스페이스로 판단합니다.
- *
- * @param Request $request HTTP 요청
- * @return bool Admin 요청 여부
- */
- private function isAdminRequest(Request $request): bool
- {
- $controller = $request->route()?->getController();
-
- if (! $controller) {
- return false;
- }
-
- return str_contains(get_class($controller), '\\Admin\\');
- }
-
// =========================================================================
// 콘텐츠 필터링 메서드
// =========================================================================
+ /**
+ * 부모 게시글(Post)을 조회 없이 해석합니다.
+ *
+ * 우선순위:
+ * 1. 컨트롤러가 요청 속성으로 넘긴 인스턴스(`sirsoft_board_parent_post`) — 목록 경로에서
+ * 댓글당 lazy-load(N+1)를 피하는 SSoT. 모든 댓글이 같은 인스턴스를 공유한다.
+ * 2. 이미 로드된 `post` 관계(상세/생성/admin 등 미주입 경로 하위호환).
+ *
+ * 둘 다 없으면 null — 추가 쿼리를 유발하지 않는다(2차 게이트는 1차 방어가 담당).
+ *
+ * @param Request $request HTTP 요청
+ * @return Post|null 부모 게시글
+ */
+ private function resolveParentPost(Request $request): ?Post
+ {
+ $injected = $request->attributes->get('sirsoft_board_parent_post');
+ if ($injected instanceof Post) {
+ return $injected;
+ }
+
+ return $this->resource->relationLoaded('post') ? $this->post : null;
+ }
+
/**
* 권한에 따라 필터링된 댓글 내용을 반환합니다.
*
@@ -260,6 +270,14 @@ class CommentResource extends BaseApiResource
*/
private function getFilteredContent(Request $request, ?string $slug): ?string
{
+ // 부모 게시글이 비밀글이면 열람 권한 없는 요청에는 댓글 원문을 숨긴다
+ // (KVE-2026-1914 이중 방어 — 1차 차단은 CommentController::index).
+ // 컨트롤러가 넘긴 부모 post(요청 속성)를 재사용해 댓글당 lazy-load 없이 재확인한다.
+ $parentPost = $this->resolveParentPost($request);
+ if ($parentPost && $parentPost->is_secret && ! PostResource::canViewSecretForPost($parentPost, $request)) {
+ return null;
+ }
+
// 게시글 삭제로 함께 숨겨진(cascade) 댓글은 사용자가 직접 지운 것이 아니므로
// 마스킹하지 않고 원문을 그대로 노출한다 (글을 볼 수 있는 사람이면 누구나).
$isCascadeDeleted = $this->deleted_at !== null
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php
index 7c9f9e31..437db4b0 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostCollection.php
@@ -339,23 +339,4 @@ class PostCollection extends BaseApiCollection
'can_access_admin' => $this->checkBoardPermission($slug, 'admin.manage'),
];
}
-
- /**
- * Admin 요청 여부를 확인합니다.
- *
- * Controller 네임스페이스로 판단합니다.
- *
- * @param Request $request HTTP 요청
- * @return bool Admin 요청 여부
- */
- private function isAdminRequest(Request $request): bool
- {
- $controller = $request->route()?->getController();
-
- if (! $controller) {
- return false;
- }
-
- return str_contains(get_class($controller), '\\Admin\\');
- }
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
index e50baa6b..cab4a70e 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
@@ -10,8 +10,10 @@ use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Enums\TriggerType;
+use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Modules\Sirsoft\Board\Traits\FormatsBoardDate;
@@ -312,7 +314,7 @@ class PostResource extends BaseApiResource
$slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null);
if ($slug && $attachment->hash) {
- return '/api/modules/sirsoft-board/boards/'.$slug.'/attachment/'.$attachment->hash.'/preview';
+ return $attachment->previewUrlForSlug($slug);
}
return null;
@@ -537,6 +539,13 @@ class PostResource extends BaseApiResource
return null;
}
+ // 비밀글·삭제글 첨부는 무서명 preview 가 서빙 게이트에 차단되므로,
+ // 이 직렬화(게이트 통과가 확인된 응답)에 한해
렌더 가능한
+ // 한시 서명 preview URL 을 발급한다. 정상글은 무서명 공개 URL 유지.
+ $signedPreviewSlug = ($this->deleted_at || $this->is_secret)
+ ? ($slug ?? $this->getSlug($request))
+ : null;
+
// 삭제된 게시글: 관리 권한자가 아니면 첨부 목록 미노출.
// 단, 게시글 삭제로 함께 숨겨진(cascade) 첨부는 사용자가 직접 지운 것이 아니므로
// 글을 볼 수 있는 사람에게는 노출한다 (cascade 댓글 노출과 일관).
@@ -546,14 +555,14 @@ class PostResource extends BaseApiResource
&& $attachment->trigger_type === TriggerType::Cascade->value
)->values();
- return AttachmentResource::collection($cascadeAttachments);
+ return AttachmentResource::collectionFor($cascadeAttachments, $signedPreviewSlug);
}
if ($this->is_secret && ! $this->canViewSecretContent($request, $slug)) {
return [];
}
- return AttachmentResource::collection($this->attachments);
+ return AttachmentResource::collectionFor($this->attachments, $signedPreviewSlug);
}
// =========================================================================
@@ -654,23 +663,6 @@ class PostResource extends BaseApiResource
return $abilities;
}
- /**
- * Admin 요청 여부를 확인합니다.
- *
- * @param Request $request HTTP 요청
- * @return bool Admin 요청 여부
- */
- private function isAdminRequest(Request $request): bool
- {
- $controller = $request->route()?->getController();
-
- if (! $controller) {
- return false;
- }
-
- return str_contains(get_class($controller), '\\Admin\\');
- }
-
/**
* 비밀글 내용 열람 가능 여부를 확인합니다.
*
@@ -686,30 +678,24 @@ class PostResource extends BaseApiResource
*/
private function canViewSecretContent(Request $request, ?string $slug = null): bool
{
- // 1. 작성자 본인 (회원 게시글)
- $user = Auth::user();
- if ($user && $this->user_id && $this->user_id === $user->id) {
- return true;
- }
+ // 판정 규칙은 SecretContentGate(SSoT)에 있다 — 리스너·댓글 경로와 규칙을 공유해
+ // 드리프트를 방지한다.
+ return self::canViewSecretForPost($this->resource, $request);
+ }
- // 2. 비밀번호 검증 완료
- if ($this->password_verified === true) {
- return true;
- }
-
- // 3-4. 게시판별 권한 체크
- $slug = $slug ?? $this->getSlug($request);
- if (! $slug) {
- return false;
- }
-
- if ($this->isAdminRequest($request)) {
- return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
- || $this->checkBoardPermission($slug, 'admin.manage');
- }
-
- return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
- || $this->checkBoardPermission($slug, 'manager', PermissionType::User);
+ /**
+ * 주어진 게시글의 비밀 원문 열람 권한을 판정합니다 (SSoT 진입점).
+ *
+ * PostResource 외 경로(이커머스 문의 연동 훅, 댓글 목록/리소스, 첨부 서빙)가
+ * 동일 규칙으로 서버측 마스킹을 수행하도록 공유합니다(KVE-2026-1914).
+ *
+ * @param Post $post 대상 게시글
+ * @param Request|null $request HTTP 요청 (미지정 시 현재 요청)
+ * @return bool 열람 가능 여부
+ */
+ public static function canViewSecretForPost(Post $post, ?Request $request = null): bool
+ {
+ return app(SecretContentGate::class)->canView($post, $request);
}
// =========================================================================
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php b/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php
new file mode 100644
index 00000000..cfc50493
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Listeners/Ckeditor5ReferenceSourcesListener.php
@@ -0,0 +1,57 @@
+
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'sirsoft-ckeditor5.image.filter_reference_sources' => [
+ 'method' => 'addBoardSources',
+ 'priority' => 10,
+ 'type' => 'filter',
+ ],
+ ];
+ }
+
+ /**
+ * 훅 이벤트를 처리합니다.
+ *
+ * Filter 훅은 getSubscribedHooks 에서 지정한 메서드를 직접 호출하므로
+ * 이 메서드는 인터페이스 요구사항 충족을 위해서만 존재합니다.
+ *
+ * @param mixed ...$args 훅에서 전달된 인수들
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 게시판 참조 소스를 추가합니다.
+ *
+ * @param array $sources 기존 참조 소스 목록
+ * @return array 게시판 소스가 추가된 목록
+ */
+ public function addBoardSources(array $sources): array
+ {
+ $sources[] = ['table' => 'board_posts', 'columns' => ['content']];
+
+ return $sources;
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php b/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php
index 6a9c816b..84bb1760 100644
--- a/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php
+++ b/modules/_bundled/sirsoft-board/src/Listeners/EcommerceInquiryHookListener.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
@@ -84,13 +85,19 @@ class EcommerceInquiryHookListener implements HookListenerInterface
'priority' => 10,
'type' => 'filter',
],
+ // 이커머스 → 게시판: 살아있는 답변 수 조회 (Filter 훅)
+ 'sirsoft-ecommerce.inquiry.count_replies' => [
+ 'method' => 'countReplies',
+ 'priority' => 10,
+ 'type' => 'filter',
+ ],
];
}
/**
* 기본 훅 핸들러 (HookListenerInterface 필수 메서드)
*
- * @param mixed ...$args 훅 인자
+ * @param mixed ...$args 훅 인자
* @return void
*/
public function handle(...$args): void
@@ -106,7 +113,8 @@ class EcommerceInquiryHookListener implements HookListenerInterface
* @param mixed $carry 이전 필터 결과 (초기값: null)
* @param string $slug 게시판 슬러그
* @param array $data 게시글 생성 데이터
- * @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string], 실패 시 null
+ * @return array|null 성공 시 ['post_id' => int, 'inquirable_type' => string],
+ * 중복 답변 차단 시 ['duplicate' => true], 실패 시 null
*/
public function createAndReturn(mixed $carry, string $slug, array $data): ?array
{
@@ -116,9 +124,25 @@ class EcommerceInquiryHookListener implements HookListenerInterface
$data['user_id'] = null;
}
- // ip_address: board_posts.ip_address NOT NULL 제약 충족
+ // ip_address: board_posts.ip_address NOT NULL 제약 충족.
+ // 클라이언트 IP 는 요청 경계(호출 서비스 ProductInquiryService)가 payload 로
+ // 주입한다 — Listener 는 request() 를 직접 참조하지 않는다(입력 우회 방지).
if (empty($data['ip_address'])) {
- $data['ip_address'] = request()->ip() ?? '0.0.0.0';
+ $data['ip_address'] = '0.0.0.0';
+ }
+
+ // 2차 방어(게시판 실데이터): 이미 살아있는 답변이 있는 부모에는 자식 Post 생성 거부.
+ // 1차 방어(피벗 is_answered)는 서비스가 담당하지만, API 동시 호출/직접 호출로
+ // 플래그가 어긋난 경우에도 게시판에 중복 답변이 쌓이지 않도록 여기서 최종 차단한다.
+ if (! empty($data['parent_id']) && $this->postRepository->findFirstReplyWithBoard((int) $data['parent_id']) !== null) {
+ Log::warning('EcommerceInquiryHookListener: 기존 답변이 있는 문의에 중복 답변 생성 시도 차단', [
+ 'slug' => $slug,
+ 'parent_id' => $data['parent_id'],
+ ]);
+
+ // null(훅 무응답/실패)과 구분되는 중복 마커 — 호출 서비스가 이 마커로
+ // "이미 등록된 답변" 사유를 사용자에게 그대로 안내한다.
+ return ['duplicate' => true];
}
// parent_id 있으면 답변글 → 부모 Post 제목으로 Re: 원글제목 설정
@@ -151,13 +175,20 @@ class EcommerceInquiryHookListener implements HookListenerInterface
}
/**
- * ID 목록으로 Post 데이터 배열 반환
+ * ID 목록으로 Post 데이터 배열 반환 (`sirsoft-ecommerce.inquiry.get_by_ids` 필터 훅)
*
* 이커머스 모듈이 문의 목록을 구성할 때 게시글 데이터를 일괄 조회합니다.
*
+ * 반환 payload 계약(KVE-2026-1914): 각 항목은 비밀글 열람 권위 플래그
+ * `can_view_secret`(bool)을 반드시 포함해야 한다. 소비자(`ProductInquiryService`)는
+ * 이 플래그로 title/content/reply/attachments 마스킹을 최종 확정하며, **플래그가
+ * 없으면 fail-closed 로 전부 마스킹**한다. 3자 확장이 이 훅을 대체 구현할 때
+ * `can_view_secret` 를 누락하면 비밀 아닌 문의까지 조용히 마스킹되는 기능 회귀가
+ * 발생하므로, 대체 리스너도 요청자 신원으로 이 플래그를 채워야 한다.
+ *
* @param array $carry 이전 필터 결과 (초기값: [])
* @param array $context 조회 컨텍스트 ['ids' => int[], 'slug' => string]
- * @return array Post 데이터 배열
+ * @return array Post 데이터 배열 (각 항목에 `can_view_secret` bool 필수)
*/
public function getByIds(array $carry, array $context): array
{
@@ -171,6 +202,12 @@ class EcommerceInquiryHookListener implements HookListenerInterface
$posts = $this->postRepository->findByIdsWithRelations($ids);
return $posts->map(function (Post $post) {
+ // 비밀글 서버측 게이팅(KVE-2026-1914): 열람 권한이 없으면 원문을 마스킹한다.
+ // 규칙은 PostResource 와 동일한 SecretContentGate(SSoT)를 공유한다.
+ // 리스트 컨텍스트라 password_verified 는 적용되지 않는다(작성자/관리 권한만).
+ $isSecret = (bool) $post->is_secret;
+ $canViewSecret = ! $isSecret || PostResource::canViewSecretForPost($post);
+
return [
'id' => $post->id,
'board_id' => $post->board_id,
@@ -178,26 +215,34 @@ class EcommerceInquiryHookListener implements HookListenerInterface
'parent_id' => $post->parent_id,
'user_id' => $post->user_id,
'author_name' => $post->author_name,
- 'title' => $post->title,
- 'content' => $post->content,
+ 'title' => $canViewSecret
+ ? $post->title
+ : __('sirsoft-board::messages.post.secret_post_title'),
+ 'content' => $canViewSecret ? $post->content : null,
'category' => $post->category,
- 'is_secret' => (bool) $post->is_secret,
+ 'is_secret' => $isSecret,
+ // 서버가 요청자 신원으로 내린 열람 판정(SSoT). 소비 서비스가 이 값으로
+ // 마스킹을 재확인(이중 방어)할 수 있도록 함께 실어 보낸다. 소비측은 자기
+ // 권한을 재계산하지 말고 이 값만 신뢰해야 게이트 강도가 갈리지 않는다.
+ 'can_view_secret' => $canViewSecret,
'status' => $post->status?->value,
'view_count' => $post->view_count,
'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_at?->toIso8601String(),
- // 첨부파일 목록
- 'attachments' => $post->attachments->map(fn ($a) => [
- 'id' => $a->id,
- 'original_filename' => $a->original_filename,
- 'size' => $a->size,
- 'size_formatted' => $a->size_formatted,
- 'is_image' => $a->is_image,
- 'preview_url' => $a->preview_url,
- 'download_url' => $a->download_url,
- ])->values()->all(),
- // 답변 게시글 (parent_id가 있는 자식 글)
- 'reply' => $this->getReplyForPost($post),
+ // 첨부파일 목록 (비밀글 비열람자는 빈 배열)
+ 'attachments' => $canViewSecret
+ ? $post->attachments->map(fn ($a) => [
+ 'id' => $a->id,
+ 'original_filename' => $a->original_filename,
+ 'size' => $a->size,
+ 'size_formatted' => $a->size_formatted,
+ 'is_image' => $a->is_image,
+ 'preview_url' => $a->preview_url,
+ 'download_url' => $a->download_url,
+ ])->values()->all()
+ : [],
+ // 답변 게시글 (parent_id가 있는 자식 글, 비밀글 비열람자는 null)
+ 'reply' => $canViewSecret ? $this->getReplyForPost($post) : null,
];
})->all();
} catch (\Exception $e) {
@@ -229,18 +274,18 @@ class EcommerceInquiryHookListener implements HookListenerInterface
}
return [
- 'secret_mode' => $board->secret_mode?->value ?? 'disabled',
- 'categories' => $board->categories ?? [],
- 'use_file_upload' => (bool) $board->use_file_upload,
- 'max_file_count' => $board->max_file_count ?? 5,
- 'max_file_size' => $board->max_file_size ?? 10,
- 'allowed_extensions' => $board->allowed_extensions ?? [],
- 'min_title_length' => $board->min_title_length ?? 2,
- 'max_title_length' => $board->max_title_length ?? 200,
- 'min_content_length' => $board->min_content_length ?? 10,
- 'max_content_length' => $board->max_content_length ?? 10000,
- 'attachment_upload_url' => '/api/modules/sirsoft-board/boards/' . $board->slug . '/attachments',
- 'attachment_delete_url' => '/api/modules/sirsoft-board/boards/' . $board->slug . '/attachments/:id',
+ 'secret_mode' => $board->secret_mode?->value ?? 'disabled',
+ 'categories' => $board->categories ?? [],
+ 'use_file_upload' => (bool) $board->use_file_upload,
+ 'max_file_count' => $board->max_file_count ?? 5,
+ 'max_file_size' => $board->max_file_size ?? 10,
+ 'allowed_extensions' => $board->allowed_extensions ?? [],
+ 'min_title_length' => $board->min_title_length ?? 2,
+ 'max_title_length' => $board->max_title_length ?? 200,
+ 'min_content_length' => $board->min_content_length ?? 10,
+ 'max_content_length' => $board->max_content_length ?? 10000,
+ 'attachment_upload_url' => '/api/modules/sirsoft-board/boards/'.$board->slug.'/attachments',
+ 'attachment_delete_url' => '/api/modules/sirsoft-board/boards/'.$board->slug.'/attachments/:id',
];
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: 게시판 설정 조회 실패', [
@@ -270,7 +315,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
$post = $this->postRepository->findWithBoard($postId);
if (! $post || ! $post->board) {
- throw new ModelNotFoundException("Post {$postId} 또는 소속 Board를 찾을 수 없습니다.");
+ throw new ModelNotFoundException("Post {$postId} or its board could not be found.");
}
$attachmentIds = $data['attachment_ids'] ?? [];
@@ -278,7 +323,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (ModelNotFoundException $e) {
Log::warning('EcommerceInquiryHookListener: Post 수정 실패 - 게시글 또는 게시판 없음', [
'post_id' => $postId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -287,7 +332,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: Post 수정 실패', [
'post_id' => $postId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -315,15 +360,21 @@ class EcommerceInquiryHookListener implements HookListenerInterface
$post = $this->postRepository->findWithBoard($postId);
if (! $post || ! $post->board) {
- throw new ModelNotFoundException("Post {$postId} 또는 소속 Board를 찾을 수 없습니다.");
+ throw new ModelNotFoundException("Post {$postId} or its board could not be found.");
}
- // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
- $this->postService->deletePost($post->board->slug, $postId, options: ['skip_notification' => true]);
+ // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴).
+ // cascade_replies: 훅 경유 삭제는 게시판 답글 삭제 정책(block)과 무관하게 답변을
+ // 함께 정리한다 — 문의 답변은 시스템 생성이므로 정책에 막히면 안 되고,
+ // 기설치본의 다건 고아 답변도 cascade 스윕이 함께 정리한다.
+ $this->postService->deletePost($post->board->slug, $postId, options: [
+ 'skip_notification' => true,
+ 'cascade_replies' => true,
+ ]);
} catch (ModelNotFoundException $e) {
Log::warning('EcommerceInquiryHookListener: Post 삭제 실패 - 게시글 또는 게시판 없음', [
'post_id' => $postId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -332,7 +383,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: Post 삭제 실패', [
'post_id' => $postId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -367,7 +418,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
}
if (! $reply->board) {
- throw new ModelNotFoundException("Reply Post {$reply->id} 소속 Board를 찾을 수 없습니다.");
+ throw new ModelNotFoundException("Reply Post {$reply->id}'s board could not be found.");
}
$this->postService->updatePost($reply->board->slug, $reply->id, $data);
@@ -376,7 +427,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (ModelNotFoundException $e) {
Log::warning('EcommerceInquiryHookListener: Reply Post 수정 실패 - 게시글 또는 게시판 없음', [
'parent_post_id' => $parentPostId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -385,7 +436,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: Reply Post 수정 실패', [
'parent_post_id' => $parentPostId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -419,17 +470,21 @@ class EcommerceInquiryHookListener implements HookListenerInterface
}
if (! $reply->board) {
- throw new ModelNotFoundException("Reply Post {$reply->id} 소속 Board를 찾을 수 없습니다.");
+ throw new ModelNotFoundException("Reply Post {$reply->id}'s board could not be found.");
}
- // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴)
- $this->postService->deletePost($reply->board->slug, $reply->id, options: ['skip_notification' => true]);
+ // 이커머스 경로: 알림 발송 SKIP (createPost와 동일한 skip_notification 패턴).
+ // cascade_replies: 답변 밑에 달린 자식 글까지 함께 정리 (정책 무관 — 시스템 생성 답변)
+ $this->postService->deletePost($reply->board->slug, $reply->id, options: [
+ 'skip_notification' => true,
+ 'cascade_replies' => true,
+ ]);
} catch (\RuntimeException $e) {
throw $e;
} catch (ModelNotFoundException $e) {
Log::warning('EcommerceInquiryHookListener: Reply Post 삭제 실패 - 게시글 또는 게시판 없음', [
'parent_post_id' => $parentPostId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -438,7 +493,7 @@ class EcommerceInquiryHookListener implements HookListenerInterface
} catch (\Exception $e) {
Log::error('EcommerceInquiryHookListener: Reply Post 삭제 실패', [
'parent_post_id' => $parentPostId,
- 'error' => $e->getMessage(),
+ 'error' => $e->getMessage(),
]);
throw new \RuntimeException(
@@ -449,6 +504,33 @@ class EcommerceInquiryHookListener implements HookListenerInterface
return $carry;
}
+ /**
+ * 부모 문의 Post 의 살아있는 답변 수 반환 (`sirsoft-ecommerce.inquiry.count_replies` 필터 훅)
+ *
+ * 이커머스 모듈이 답변 삭제 후 `is_answered` 재계산, 게시판 직권 삭제 시 답변완료
+ * 해제 판정에 사용합니다. SoftDeletes 전역 스코프가 삭제된 답변을 제외하므로
+ * "살아있는 답변" 만 집계됩니다.
+ *
+ * @param mixed $carry 이전 필터 결과 (초기값: null = 판정 불가)
+ * @param int $parentPostId 부모 문의 Post ID
+ * @return int|null 살아있는 답변 수 (조회 실패 시 이전 필터 결과 유지 — null 이면 소비측이 판정 불가로 no-op)
+ */
+ public function countReplies(mixed $carry, int $parentPostId): ?int
+ {
+ try {
+ return $this->postRepository->countRepliesByParentId($parentPostId);
+ } catch (\Exception $e) {
+ Log::error('EcommerceInquiryHookListener: 답변 수 조회 실패', [
+ 'parent_post_id' => $parentPostId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ // 실패를 0(답변 없음)으로 접으면 소비측이 답변완료를 오해제한다 —
+ // 판정 불가(null)를 그대로 흘려보낸다.
+ return is_numeric($carry) ? (int) $carry : null;
+ }
+ }
+
// ─── 내부 유틸리티 ────────────────────────────────────────
/**
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
index d0400174..c0093e2a 100644
--- a/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
+++ b/modules/_bundled/sirsoft-board/src/Listeners/SearchPostsListener.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Helpers\PermissionHelper;
use App\Search\SearchCategoryPayload;
+use App\Search\SearchHighlighter;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\PostService;
@@ -120,7 +121,10 @@ class SearchPostsListener implements HookListenerInterface
$results['posts'] = $this->buildSearchResult($boards, $q, $context);
} catch (\Exception $e) {
- Log::error('Search posts error', ['message' => $e->getMessage(), 'q' => $q]);
+ // 실패를 카테고리 키 미설정으로 삼키면 화면이 "검색 결과 없음" 을 그린다 —
+ // failed 페이로드로 표면화하고, 원인 추적을 위해 스택을 함께 남긴다 (#103).
+ Log::error('Search posts error', ['message' => $e->getMessage(), 'q' => $q, 'exception' => $e]);
+ $results['posts'] = SearchCategoryPayload::failed(['available_boards' => []]);
}
return $results;
@@ -210,7 +214,9 @@ class SearchPostsListener implements HookListenerInterface
$type = $context['type'] ?? 'all';
$boardIds = collect($boards)->pluck('id')->all();
- $extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter()];
+ // available_boards(필터 드롭다운)는 검색 결과 필터와 동일하게 읽기 권한을 통과한
+ // 게시판만 담는다 — 권한 없는 게시판이 필터 목록으로 노출되지 않도록 컨텍스트 사용자를 전달.
+ $extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter($context['user'] ?? null)];
/** 조회 결과 항목을 화면 형태로 가공한다. */
$format = fn (iterable $items): array => collect($items)
@@ -321,14 +327,9 @@ class SearchPostsListener implements HookListenerInterface
$contentPreview = $this->extractContentPreview($post->content, $keyword, 150, $contentMode);
$boardSlug = $post->board?->slug ?? '';
- // 텍스트 모드: 태그 문자열이 그대로 보존되므로 htmlspecialchars로 이스케이프 후 하이라이트
- // HTML 모드: strip_tags 처리된 평문이므로 바로 하이라이트
- if ($contentMode === 'text') {
- $escapedPreview = htmlspecialchars($contentPreview, ENT_QUOTES, 'UTF-8');
- $contentPreviewHighlighted = $this->highlightKeyword($escapedPreview, $keyword);
- } else {
- $contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword);
- }
+ // 하이라이트 필드는 소비 측이 HTML 로 렌더하므로, 공유 헬퍼가 원문을 이스케이프한 뒤
+ // 검색어만 로 감싼다. 텍스트 모드의 리터럴 태그 문자열도 여기서 이스케이프된다.
+ $contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword);
return [
'id' => $post->id,
@@ -363,13 +364,7 @@ class SearchPostsListener implements HookListenerInterface
*/
private function highlightKeyword(?string $text, string $keyword): string
{
- if (empty($text) || empty($keyword)) {
- return $text ?? '';
- }
-
- $escapedKeyword = preg_quote($keyword, '/');
-
- return preg_replace('/('.$escapedKeyword.')/iu', '$1', $text);
+ return SearchHighlighter::highlight($text, $keyword);
}
/**
@@ -390,9 +385,10 @@ class SearchPostsListener implements HookListenerInterface
return '';
}
- // HTML 모드: 태그 제거 후 평문 추출, 텍스트 모드: 태그 문자열 그대로 보존
+ // HTML 모드: 태그 제거 후 평문 추출(엔티티 디코드를 태그 제거보다 먼저 수행),
+ // 텍스트 모드: 태그 문자열 그대로 보존(하이라이트 시점에 이스케이프됨)
if ($contentMode === 'html') {
- $plainText = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($content))));
+ $plainText = SearchHighlighter::toPlainText($content);
} else {
$plainText = trim(preg_replace('/\s+/', ' ', $content));
}
diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardSettingsCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardSettingsCacheListener.php
index f597ca0e..612f3067 100644
--- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardSettingsCacheListener.php
+++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardSettingsCacheListener.php
@@ -27,6 +27,12 @@ class SeoBoardSettingsCacheListener implements HookListenerInterface
'method' => 'onModuleSettingsSave',
'priority' => 20,
],
+ // 환경설정 기본값 일괄 적용도 게시판 화면 출력을 바꾸므로 같은 무효화가 필요하다.
+ // payload 가 ($fields, $updatedCount) 라 식별자 인자가 없어 전용 핸들러로 받는다.
+ 'sirsoft-board.settings.after_bulk_apply' => [
+ 'method' => 'onBulkApply',
+ 'priority' => 20,
+ ],
];
}
@@ -56,6 +62,27 @@ class SeoBoardSettingsCacheListener implements HookListenerInterface
return;
}
+ $this->invalidateBoardSeoCache();
+ }
+
+ /**
+ * 환경설정 기본값 일괄 적용 후 게시판 SEO 캐시를 무효화합니다.
+ *
+ * 이 훅의 payload 는 ($fields, $updatedCount) 로 모듈 식별자를 담지 않는다 —
+ * 게시판 모듈이 발행하는 훅이므로 식별자 가드 자체가 불필요하다.
+ *
+ * @param mixed ...$args 훅 인자 ($fields, $updatedCount)
+ */
+ public function onBulkApply(...$args): void
+ {
+ $this->invalidateBoardSeoCache();
+ }
+
+ /**
+ * 게시판 관련 SEO 레이아웃 캐시와 sitemap 캐시를 무효화합니다.
+ */
+ private function invalidateBoardSeoCache(): void
+ {
try {
$cache = app(SeoCacheManagerInterface::class);
diff --git a/modules/_bundled/sirsoft-board/src/Models/Attachment.php b/modules/_bundled/sirsoft-board/src/Models/Attachment.php
index dd8c06ca..a6d80b8a 100644
--- a/modules/_bundled/sirsoft-board/src/Models/Attachment.php
+++ b/modules/_bundled/sirsoft-board/src/Models/Attachment.php
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
/**
@@ -127,6 +128,8 @@ class Attachment extends Model
/**
* 게시판과의 관계를 정의합니다.
+ *
+ * @return BelongsTo 게시판 관계
*/
public function board(): BelongsTo
{
@@ -135,6 +138,8 @@ class Attachment extends Model
/**
* 게시글과의 관계를 정의합니다.
+ *
+ * @return BelongsTo 게시글 관계
*/
public function post(): BelongsTo
{
@@ -143,6 +148,8 @@ class Attachment extends Model
/**
* 업로더와의 관계를 정의합니다.
+ *
+ * @return BelongsTo 업로더 사용자 관계
*/
public function creator(): BelongsTo
{
@@ -160,6 +167,8 @@ class Attachment extends Model
? $this->board->slug
: Board::find($this->board_id)?->slug;
+ // route() 생성은 빈 슬러그(미해석 board)에서 예외가 되므로 문자열 조립을 유지한다
+ // — 빈 슬러그 허용은 AttachmentPreviewUrlTest 가 고정한 기존 계약이다.
return '/api/modules/sirsoft-board/boards/'.$boardSlug.'/attachment/'.$this->hash;
}
@@ -178,9 +187,48 @@ class Attachment extends Model
? $this->board->slug
: Board::find($this->board_id)?->slug;
- return '/api/modules/sirsoft-board/boards/'.$boardSlug.'/attachment/'.$this->hash.'/preview';
+ return $this->previewUrlForSlug((string) $boardSlug);
}
+ /**
+ * 슬러그가 이미 해석된 컨텍스트의 미리보기 URL 조립 단일 지점.
+ *
+ * 목록 직렬화(PostResource)처럼 slug 를 라우트/관계에서 이미 알고 있어
+ * Board::find() 재조회(N+1)를 피해야 하는 호출측이 사용합니다.
+ *
+ * 브라우저
는 Authorization 헤더를 실을 수 없으므로, 비밀글·삭제글
+ * 첨부를 열람 권한자 화면에 표시할 때는 한시 서명 URL 을 발급한다($signed).
+ * 서명 URL 은 게이트를 통과한 응답 직렬화 시점에만 발급되고(PostResource),
+ * 서빙 엔드포인트가 유효 서명을 허용한다 — 무서명 게이트는 종전과 동일.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param bool $signed 한시 서명 URL 발급 여부 (비밀글·삭제글
렌더용)
+ * @return string 미리보기 URL
+ */
+ public function previewUrlForSlug(string $slug, bool $signed = false): string
+ {
+ if ($signed) {
+ return URL::temporarySignedRoute(
+ 'api.modules.sirsoft-board.boards.attachment.preview',
+ now()->addMinutes(self::SIGNED_PREVIEW_TTL_MINUTES),
+ ['slug' => $slug, 'hash' => $this->hash],
+ absolute: false
+ );
+ }
+
+ // route() 생성은 빈 슬러그(미해석 board)에서 예외가 되므로 문자열 조립을 유지한다
+ // — 빈 슬러그 허용은 AttachmentPreviewUrlTest 가 고정한 기존 계약이다.
+ return '/api/modules/sirsoft-board/boards/'.$slug.'/attachment/'.$this->hash.'/preview';
+ }
+
+ /**
+ * 서명 preview URL 의 유효 시간(분).
+ *
+ * 상세 화면이 열려 있는 동안 썸네일이 유지될 만큼 길고,
+ * URL 유출 시 노출 창을 좁힐 만큼 짧은 값.
+ */
+ public const SIGNED_PREVIEW_TTL_MINUTES = 30;
+
/**
* 이미지 여부 확인
*
diff --git a/modules/_bundled/sirsoft-board/src/Models/Board.php b/modules/_bundled/sirsoft-board/src/Models/Board.php
index 0e0afef0..82fcb877 100644
--- a/modules/_bundled/sirsoft-board/src/Models/Board.php
+++ b/modules/_bundled/sirsoft-board/src/Models/Board.php
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Casts\AsAllowedExtensions;
use Modules\Sirsoft\Board\Database\Factories\BoardFactory;
use Modules\Sirsoft\Board\Enums\BoardOrderBy;
use Modules\Sirsoft\Board\Enums\OrderDirection;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
use Modules\Sirsoft\Board\Enums\SecretMode;
class Board extends Model
@@ -33,6 +34,7 @@ class Board extends Model
'use_comment' => ['label_key' => 'sirsoft-board::activity_log.fields.use_comment', 'type' => 'boolean'],
'use_reply' => ['label_key' => 'sirsoft-board::activity_log.fields.use_reply', 'type' => 'boolean'],
'max_reply_depth' => ['label_key' => 'sirsoft-board::activity_log.fields.max_reply_depth', 'type' => 'number'],
+ 'reply_delete_policy' => ['label_key' => 'sirsoft-board::activity_log.fields.reply_delete_policy', 'type' => 'enum', 'enum' => ReplyDeletePolicy::class],
'use_report' => ['label_key' => 'sirsoft-board::activity_log.fields.use_report', 'type' => 'boolean'],
'use_file_upload' => ['label_key' => 'sirsoft-board::activity_log.fields.use_file_upload', 'type' => 'boolean'],
'max_file_size' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_size', 'type' => 'number'],
@@ -126,6 +128,7 @@ class Board extends Model
'use_comment',
'use_reply',
'max_reply_depth',
+ 'reply_delete_policy',
'use_report',
'new_display_hours',
@@ -180,6 +183,7 @@ class Board extends Model
'use_comment' => 'boolean',
'use_reply' => 'boolean',
'max_reply_depth' => 'integer',
+ 'reply_delete_policy' => ReplyDeletePolicy::class,
// Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다.
'new_display_hours' => 'integer',
'use_report' => 'boolean',
diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
index cc2e521a..60826b1c 100644
--- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
+++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Providers;
use App\Extension\BaseModuleServiceProvider;
use App\Seo\SitemapGenerator;
use Modules\Sirsoft\Board\Console\Commands\AggregateBoardStatsCommand;
+use Modules\Sirsoft\Board\Console\Commands\PruneAttachmentsCommand;
use Modules\Sirsoft\Board\Repositories\AttachmentRepository;
use Modules\Sirsoft\Board\Repositories\BoardRepository;
use Modules\Sirsoft\Board\Repositories\BoardStatRepository;
@@ -88,6 +89,7 @@ class BoardServiceProvider extends BaseModuleServiceProvider
*/
protected array $commands = [
AggregateBoardStatsCommand::class,
+ PruneAttachmentsCommand::class,
];
/**
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php
index 1549224b..af2419f6 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/AttachmentRepository.php
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Board\Repositories;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
@@ -77,6 +78,27 @@ class AttachmentRepository implements AttachmentRepositoryInterface
return $post->deleted_at !== null || $post->status === PostStatus::Deleted;
}
+ /**
+ * 첨부파일이 속한 게시글을 게시판 스코프로 조회합니다(비밀 게이팅용).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @return Post|null 게시글 모델 또는 null
+ */
+ public function findPostForGate(string $slug, int $postId): ?Post
+ {
+ $board = Board::where('slug', $slug)->first();
+
+ if (! $board) {
+ return null;
+ }
+
+ return Post::withTrashed()
+ ->where('board_id', $board->id)
+ ->where('id', $postId)
+ ->first(['id', 'board_id', 'user_id', 'is_secret', 'status', 'deleted_at']);
+ }
+
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -396,6 +418,60 @@ class AttachmentRepository implements AttachmentRepositoryInterface
]);
}
+ /**
+ * 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의
+ * 첨부 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다.
+ * 물리 파일은 보존하며 deleted_at 마킹만 수행합니다. (복원 가능성 유지)
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 삭제된 첨부 수
+ */
+ public function softDeleteByPostIds(string $slug, array $postIds): int
+ {
+ if ($postIds === []) {
+ return 0;
+ }
+
+ $board = Board::where('slug', $slug)->first();
+
+ return Attachment::where('board_id', $board?->id)
+ ->whereIn('post_id', $postIds)
+ ->update([
+ 'trigger_type' => TriggerType::Cascade->value,
+ 'deleted_at' => now(),
+ ]);
+ }
+
+ /**
+ * 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다.
+ *
+ * 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의
+ * 첨부 복원용입니다. 사용자가 직접 지운 첨부(trigger_type='user')는 유지됩니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 복원된 첨부 수
+ */
+ public function restoreCascadedByPostIds(string $slug, array $postIds): int
+ {
+ if ($postIds === []) {
+ return 0;
+ }
+
+ $board = Board::where('slug', $slug)->first();
+
+ return Attachment::onlyTrashed()
+ ->where('board_id', $board?->id)
+ ->whereIn('post_id', $postIds)
+ ->where('trigger_type', TriggerType::Cascade->value)
+ ->update([
+ 'deleted_at' => null,
+ ]);
+ }
+
/**
* 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다.
*
@@ -410,4 +486,51 @@ class AttachmentRepository implements AttachmentRepositoryInterface
{
return Attachment::where('board_id', $boardId)->forceDelete();
}
+
+ /**
+ * 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다.
+ *
+ * @param Carbon $threshold 기준 시각
+ * @param int $limit 최대 조회 건수
+ * @return Collection 임시 첨부 목록
+ */
+ public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection
+ {
+ return Attachment::query()
+ ->whereNotNull('temp_key')
+ ->whereNull('post_id')
+ ->where('created_at', '<', $threshold)
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->limit($limit)
+ ->get(['id', 'board_id', 'temp_key', 'disk', 'path', 'created_at']);
+ }
+
+ /**
+ * 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다.
+ *
+ * @param Carbon $threshold 기준 시각
+ * @param int $limit 최대 조회 건수
+ * @return Collection 소프트 삭제 첨부 목록
+ */
+ public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection
+ {
+ return Attachment::onlyTrashed()
+ ->where('deleted_at', '<', $threshold)
+ ->orderBy('deleted_at')
+ ->orderBy('id')
+ ->limit($limit)
+ ->get(['id', 'board_id', 'post_id', 'disk', 'path', 'deleted_at']);
+ }
+
+ /**
+ * 첨부 레코드를 영구 삭제합니다.
+ *
+ * @param Attachment $attachment 첨부파일 모델
+ * @return bool 삭제 성공 여부
+ */
+ public function forceDelete(Attachment $attachment): bool
+ {
+ return (bool) $attachment->forceDelete();
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
index 83915bbb..25bd56f1 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php
@@ -126,6 +126,21 @@ class BoardRepository implements BoardRepositoryInterface
return $board->delete();
}
+ /**
+ * 게시판을 영구 삭제합니다.
+ *
+ * @param int $id 게시판 ID
+ * @return bool 삭제 성공 여부
+ *
+ * @throws ModelNotFoundException
+ */
+ public function forceDelete(int $id): bool
+ {
+ $board = $this->findOrFail($id);
+
+ return (bool) $board->forceDelete();
+ }
+
/**
* 모든 게시판을 조회합니다.
*
@@ -180,6 +195,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function filterByType(string $type): Collection
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
return Board::where('type', $type)->get();
}
@@ -203,6 +219,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getRecentPosts(int $limit): array
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
$activeBoardIds = Board::where('is_active', true)->pluck('id');
if ($activeBoardIds->isEmpty()) {
@@ -220,6 +237,8 @@ class BoardRepository implements BoardRepositoryInterface
->whereNull('deleted_at')
->whereNull('parent_id')
->where('status', 'published')
+ // 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책. 목록/검색/홈과 동일.
+ // 게시판별 열람 권한은 BoardService::getCachedRecentPosts 가 응답 시점에 적용한다.
->orderBy('created_at', 'desc')
->limit($limit)
);
@@ -400,6 +419,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getTotalCommentsCount(): int
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
$activeBoardIds = Board::where('is_active', true)->pluck('id');
return Comment::query()
@@ -424,6 +444,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getPopularPosts(string $period, int $limit): array
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
$activeBoardIds = Board::where('is_active', true)->pluck('id');
if ($activeBoardIds->isEmpty()) {
@@ -486,10 +507,10 @@ class BoardRepository implements BoardRepositoryInterface
$viewCount = $post->view_count ?? 0;
$commentCount = $post->comments_count ?? 0;
- // Avatar URL 결정
+ // Avatar URL 결정 — 코어 첨부 URL 조립은 코어 모델 단일 지점에 위임
$avatarUrl = null;
if ($post->user_id && $post->attachment_hash) {
- $avatarUrl = '/api/attachment/'.$post->attachment_hash;
+ $avatarUrl = Attachment::urlForHash($post->attachment_hash);
}
// 사용자 상태 처리 (탈퇴한 사용자는 익명화)
@@ -560,6 +581,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getActiveBoardsList(): Collection
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
return Board::where('is_active', true)
->orderBy('created_at', 'asc')
->get();
@@ -620,6 +642,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
return Board::where('is_active', true)
->orderBy($orderBy, $orderDirection)
->get();
@@ -648,6 +671,7 @@ class BoardRepository implements BoardRepositoryInterface
*/
public function getActiveBoardsForMenu(): Collection
{
+ // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다
return Board::where('is_active', true)
->select(['id', 'name', 'slug'])
->orderBy('created_at', 'asc')
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php
index 671fe972..0bd5157c 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php
@@ -700,6 +700,59 @@ class CommentRepository implements CommentRepositoryInterface
]);
}
+ /**
+ * 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * 답글 트리 연쇄 삭제(softDeleteCascadeByParentId)로 지워지는 자손 게시글들의
+ * 댓글 정리용입니다. 단건 softDeleteByPostId 와 동일한 쿼리를 whereIn 으로 확장했습니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 삭제된 댓글 수
+ */
+ public function softDeleteByPostIds(string $slug, array $postIds): int
+ {
+ if ($postIds === []) {
+ return 0;
+ }
+
+ $board = Board::where('slug', $slug)->first();
+
+ return Comment::where('board_id', $board?->id)
+ ->whereIn('post_id', $postIds)
+ ->update([
+ 'trigger_type' => TriggerType::Cascade->value,
+ 'deleted_at' => now(),
+ ]);
+ }
+
+ /**
+ * 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다.
+ *
+ * 답글 트리 연쇄 복원(restoreCascadedByParentId)으로 되살아난 자손 게시글들의
+ * 댓글 복원용입니다. 사용자가 직접 지운 댓글(trigger_type='user')은 유지됩니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 복원된 댓글 수
+ */
+ public function restoreCascadedByPostIds(string $slug, array $postIds): int
+ {
+ if ($postIds === []) {
+ return 0;
+ }
+
+ $board = Board::where('slug', $slug)->first();
+
+ return Comment::onlyTrashed()
+ ->where('board_id', $board?->id)
+ ->whereIn('post_id', $postIds)
+ ->where('trigger_type', TriggerType::Cascade->value)
+ ->update([
+ 'deleted_at' => null,
+ ]);
+ }
+
/**
* 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php
index 7f3cb3e3..64dacb29 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/AttachmentRepositoryInterface.php
@@ -3,7 +3,9 @@
namespace Modules\Sirsoft\Board\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Carbon;
use Modules\Sirsoft\Board\Models\Attachment;
+use Modules\Sirsoft\Board\Models\Post;
/**
* 게시판 첨부파일 Repository 인터페이스
@@ -39,6 +41,18 @@ interface AttachmentRepositoryInterface
*/
public function isPostDeleted(string $slug, int $postId): bool;
+ /**
+ * 첨부파일이 속한 게시글을 게시판 스코프로 조회합니다(비밀 게이팅용).
+ *
+ * 비밀글 첨부 서빙 시 부모 게시글의 소유자/비밀 여부를 판정하기 위해 사용합니다.
+ * 게시판을 찾을 수 없거나 게시글이 없으면 null 을 반환합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @return Post|null 게시글 모델 또는 null
+ */
+ public function findPostForGate(string $slug, int $postId): ?Post;
+
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -174,6 +188,24 @@ interface AttachmentRepositoryInterface
*/
public function restoreCascadedByPostId(string $slug, int $postId): int;
+ /**
+ * 여러 게시글의 살아있는 첨부를 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 삭제된 첨부 수
+ */
+ public function softDeleteByPostIds(string $slug, array $postIds): int;
+
+ /**
+ * 여러 게시글의 cascade 로 지워진 첨부만 일괄 복원합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 복원된 첨부 수
+ */
+ public function restoreCascadedByPostIds(string $slug, array $postIds): int;
+
/**
* 게시판 ID 기준으로 첨부파일을 일괄 영구 삭제합니다.
*
@@ -185,4 +217,36 @@ interface AttachmentRepositoryInterface
* @return int 삭제된 첨부파일 수
*/
public function forceDeleteByBoardId(int $boardId): int;
+
+ /**
+ * 게시글에 연결되지 않은 채 방치된 임시 첨부를 오래된 순으로 조회합니다.
+ *
+ * 글쓰기 폼에서 업로드만 하고 저장 없이 이탈하면 `temp_key` 가 남은 채
+ * `board_id = 0` 인 행과 그 파일이 영구 잔존합니다. 그 회수 대상을 찾습니다.
+ *
+ * @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
+ * @param int $limit 최대 조회 건수
+ * @return Collection 임시 첨부 목록 (created_at 오름차순)
+ */
+ public function findStaleTempAttachments(Carbon $threshold, int $limit): Collection;
+
+ /**
+ * 소프트 삭제된 지 오래된 첨부를 오래된 순으로 조회합니다.
+ *
+ * 첨부 삭제는 소프트 삭제만 수행하므로(휴지통 복원 대비) 물리 파일은 남아 있습니다.
+ * 보존기간이 지난 뒤 파일과 기록을 함께 파기할 대상을 찾습니다.
+ *
+ * @param Carbon $threshold 기준 시각 (이 시각 이전 삭제분이 대상)
+ * @param int $limit 최대 조회 건수
+ * @return Collection 소프트 삭제 첨부 목록 (deleted_at 오름차순)
+ */
+ public function findSoftDeletedOlderThan(Carbon $threshold, int $limit): Collection;
+
+ /**
+ * 첨부 레코드를 영구 삭제합니다.
+ *
+ * @param Attachment $attachment 첨부파일 모델
+ * @return bool 삭제 성공 여부
+ */
+ public function forceDelete(Attachment $attachment): bool;
}
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
index b31f471a..db070514 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php
@@ -68,6 +68,16 @@ interface BoardRepositoryInterface
*/
public function delete(int $id): bool;
+ /**
+ * 게시판을 영구 삭제합니다.
+ *
+ * @param int $id 게시판 ID
+ * @return bool 삭제 성공 여부
+ *
+ * @throws ModelNotFoundException
+ */
+ public function forceDelete(int $id): bool;
+
/**
* 모든 게시판을 조회합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php
index e2eb7458..7f924965 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php
@@ -213,6 +213,24 @@ interface CommentRepositoryInterface
*/
public function restoreCascadedByPostId(string $slug, int $postId): int;
+ /**
+ * 여러 게시글의 살아있는 댓글을 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 삭제된 댓글 수
+ */
+ public function softDeleteByPostIds(string $slug, array $postIds): int;
+
+ /**
+ * 여러 게시글의 cascade 로 지워진 댓글만 일괄 복원합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $postIds 게시글 ID 배열
+ * @return int 복원된 댓글 수
+ */
+ public function restoreCascadedByPostIds(string $slug, array $postIds): int;
+
/**
* 사용자가 작성한 댓글 목록을 페이지네이션하여 조회합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
index 128721f8..61869259 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php
@@ -368,6 +368,59 @@ interface PostRepositoryInterface
*/
public function findFirstReplyWithBoard(int $parentPostId): ?Post;
+ /**
+ * 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다.
+ *
+ * @param int $parentPostId 부모 게시글 ID
+ * @return int 살아있는 자식 게시글 수
+ */
+ public function countRepliesByParentId(int $parentPostId): int;
+
+ /**
+ * 게시글에 살아있는 직계 답글이 있는지 확인합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @return bool 살아있는 직계 답글 존재 여부
+ */
+ public function hasAliveReplies(string $slug, int $postId): bool;
+
+ /**
+ * 게시글의 전체 자손(답글 트리) ID 를 수집합니다 (withTrashed 순회 + 방문 가드).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 루트 게시글 ID
+ * @return array 자손 게시글 ID 배열 (루트 미포함)
+ */
+ public function collectDescendantIds(string $slug, int $postId): array;
+
+ /**
+ * 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 부모 게시글 ID
+ * @return array 소프트 삭제된 자손 게시글 ID 배열
+ */
+ public function softDeleteCascadeByParentId(string $slug, int $postId): array;
+
+ /**
+ * 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 복원된 부모 게시글 ID
+ * @return array 복원된 자손 게시글 ID 배열
+ */
+ public function restoreCascadedByParentId(string $slug, int $postId): array;
+
+ /**
+ * 게시판의 전체 게시글 ID(withTrashed) 를 청크 단위로 순회하며 콜백에 전달합니다.
+ *
+ * @param int $boardId 게시판 ID
+ * @param int $size 청크 크기
+ * @param callable $callback 청크마다 호출될 콜백 (int[] $postIds)
+ */
+ public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void;
+
/**
* 게시글의 comments_count 컬럼을 활성 댓글 수로 재계산해 갱신합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
index 83614e87..b4213362 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
@@ -1064,7 +1064,7 @@ class PostRepository implements PostRepositoryInterface
* 사용자가 작성한 게시글, 댓글을 단 게시글을 통합하여 반환합니다.
*
* @param int $userId 사용자 ID
- * @param array $filters 필터 조건 (board_slug, search, activity_type, sort, is_public)
+ * @param array $filters 필터 조건 (board_slug, search, activity_type, sort, viewer_id, exclude_board_slugs)
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 게시글 활동 목록
*/
@@ -1074,7 +1074,16 @@ class PostRepository implements PostRepositoryInterface
$search = $filters['search'] ?? null;
$activityType = $filters['activity_type'] ?? 'authored';
$sort = $filters['sort'] ?? 'latest'; // latest, oldest, views
- $isPublic = $filters['is_public'] ?? false; // 공개 프로필용 필터 (비밀글 제외, 공개 게시글만)
+ // 열람자 관점. 이 값이 대상 사용자와 다르면(비로그인 포함) **타인 관점**이므로
+ // 비밀글·미발행글의 **본문(content_plain)만 비운다** — 행·제목·배지는 목록 화면과
+ // 동일하게 유지한다(제목 공개 정책 2026-02-04, 행 제거가 아니라 본문 마스킹이 의도).
+ //
+ // 종전에는 `is_public` 옵트인 플래그로 이 판정을 했는데, 그 키를 설정하는 코드가
+ // 저장소 어디에도 없어서 필터가 한 번도 적용되지 않았다(사문). 옵트인은 호출부가
+ // 빠뜨리면 조용히 열리는 방향이라, 열람자 신원으로 판정하는 fail-closed 로 뒤집는다 —
+ // viewer_id 가 없으면 자동으로 가장 좁은 가시성이 된다.
+ $viewerId = $filters['viewer_id'] ?? null;
+ $isOwnView = $viewerId !== null && $viewerId === $userId;
$excludeBoardSlugs = $filters['exclude_board_slugs'] ?? [];
// board_slug 필터용 board_id 조회
@@ -1099,12 +1108,15 @@ class PostRepository implements PostRepositoryInterface
$cachedTotal = $filters['cached_total'] ?? null;
- if ($activityType === 'commented' && ! $isPublic) {
- return $this->getUserCommentedActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $orderColumn, $orderDirection, $perPage, $cachedTotal);
+ // 분기 선택은 activity_type 만 본다. 가시성은 각 분기 안에서 처리한다 —
+ // 여기서 열람자까지 보고 분기를 바꾸면 `commented` 요청이 조용히 `authored` 결과를
+ // 돌려주게 되어 저장소 계약이 깨진다.
+ if ($activityType === 'commented') {
+ return $this->getUserCommentedActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $orderColumn, $orderDirection, $perPage, $cachedTotal, $viewerId);
}
// authored (기본값, 공개 프로필 포함)
- return $this->getUserAuthoredActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $isPublic, $orderColumn, $orderDirection, $perPage, $cachedTotal);
+ return $this->getUserAuthoredActivities($userId, $boardIdFilter, $excludeBoardIds, $search, $isOwnView, $orderColumn, $orderDirection, $perPage, $cachedTotal);
}
/**
@@ -1113,7 +1125,7 @@ class PostRepository implements PostRepositoryInterface
* @param int $userId 사용자 ID
* @param int|null $boardIdFilter 게시판 ID 필터
* @param string|null $search 검색 키워드
- * @param bool $isPublic 공개 프로필 여부 (비밀글 제외)
+ * @param bool $isOwnView 열람자가 대상 본인인지 여부 (아니면 비밀글·미발행글 제외)
* @param string $orderColumn 정렬 컬럼
* @param string $orderDirection 정렬 방향
* @param int $perPage 페이지당 항목 수
@@ -1123,7 +1135,7 @@ class PostRepository implements PostRepositoryInterface
?int $boardIdFilter,
array $excludeBoardIds,
?string $search,
- bool $isPublic,
+ bool $isOwnView,
string $orderColumn,
string $orderDirection,
int $perPage,
@@ -1145,11 +1157,6 @@ class PostRepository implements PostRepositoryInterface
$query->whereNotIn('board_posts.board_id', $allExcludeIds);
}
- if ($isPublic) {
- $query->where('board_posts.status', PostStatus::Published->value)
- ->where('board_posts.is_secret', false);
- }
-
if ($search) {
$keyword = $this->escapeLikeKeyword($search);
$query->where(function ($q) use ($keyword) {
@@ -1180,7 +1187,15 @@ class PostRepository implements PostRepositoryInterface
);
// paginate 후 10건에만 PHP 가공 적용 (N+1 아님)
- $paginator->through(function ($post) {
+ $paginator->through(function ($post) use ($isOwnView) {
+ // 이 목록은 본문 일부(content_plain)를 함께 싣는다. 타인이 볼 때 비밀글·블라인드
+ // 글의 본문이 그대로 나가던 것이 결함이었다 — 행과 제목은 게시판 목록에서 이미
+ // 같은 수준으로 보이므로(PostResource 의 목록 규칙: 제목은 노출, 본문만 차단)
+ // 여기서도 **행은 남기고 본문만** 비운다. 행을 지우면 프로필의 비밀글/블라인드
+ // 배지가 사문이 되어 필요 이상으로 기능이 깎인다.
+ $hideContent = ! $isOwnView
+ && ((bool) $post->is_secret || $post->status === PostStatus::Blinded);
+
return [
'id' => $post->id,
'board_slug' => $post->board?->slug,
@@ -1194,9 +1209,11 @@ class PostRepository implements PostRepositoryInterface
'comment_count' => (int) ($post->comments_count ?? 0),
'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
- 'content_plain' => ($post->content_mode ?? 'text') === 'html'
- ? $this->stripHtmlToPlainText($post->content ?? '')
- : ($post->content ?? ''),
+ 'content_plain' => $hideContent
+ ? ''
+ : (($post->content_mode ?? 'text') === 'html'
+ ? $this->stripHtmlToPlainText($post->content ?? '')
+ : ($post->content ?? '')),
];
});
@@ -1222,7 +1239,8 @@ class PostRepository implements PostRepositoryInterface
string $orderColumn,
string $orderDirection,
int $perPage,
- ?int $cachedTotal = null
+ ?int $cachedTotal = null,
+ ?int $viewerId = null
): LengthAwarePaginator {
// 테이블명은 모델에서 얻는다 — 문자열로 박으면 테이블명이 바뀔 때 조용히 깨진다
$postsTable = (new Post)->getTable();
@@ -1308,7 +1326,15 @@ class PostRepository implements PostRepositoryInterface
);
// paginate 후 10건에만 PHP 가공 적용
- $paginator->through(function ($post) {
+ $paginator->through(function ($post) use ($viewerId) {
+ // 이 목록은 "내가 댓글 단 글" 이라 **타인이 쓴 비밀글**이 섞인다. 행은 내 활동
+ // 기록이므로 남기되 본문은 내보내지 않는다 — 목록 미리보기를 빈 문자열로 만드는
+ // PostResource::getMaskedContentPreviewForList 와 같은 규칙이다.
+ // (블라인드 글도 동일: 상세·목록 어느 경로에서도 본문이 나가지 않는다.)
+ // 열람자 미상($viewerId === null)이면 비밀글은 전부 가린다(fail-closed).
+ $hideContent = ((bool) $post->is_secret && (int) $post->user_id !== $viewerId)
+ || $post->status === PostStatus::Blinded;
+
return [
'id' => $post->id,
'board_slug' => $post->board?->slug,
@@ -1322,9 +1348,11 @@ class PostRepository implements PostRepositoryInterface
'comment_count' => (int) ($post->comments_count ?? 0),
'created_at' => $this->formatCreatedAt($post->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')),
- 'content_plain' => ($post->content_mode ?? 'text') === 'html'
- ? $this->stripHtmlToPlainText($post->content ?? '')
- : ($post->content ?? ''),
+ 'content_plain' => $hideContent
+ ? ''
+ : (($post->content_mode ?? 'text') === 'html'
+ ? $this->stripHtmlToPlainText($post->content ?? '')
+ : ($post->content ?? '')),
];
});
@@ -1692,6 +1720,207 @@ class PostRepository implements PostRepositoryInterface
->first();
}
+ /**
+ * 부모 게시글 ID로 살아있는 답변(자식) 게시글 수를 조회합니다.
+ *
+ * SoftDeletes 전역 스코프가 삭제된 답변을 자동 제외하므로,
+ * "삭제된 답변 후 재등록 허용" 판정의 근거로 사용됩니다.
+ *
+ * @param int $parentPostId 부모 게시글 ID
+ * @return int 살아있는 자식 게시글 수
+ */
+ public function countRepliesByParentId(int $parentPostId): int
+ {
+ return Post::where('parent_id', $parentPostId)->count();
+ }
+
+ /**
+ * 게시글에 살아있는 직계 답글이 있는지 확인합니다.
+ *
+ * 답글 삭제 정책(block) 의 차단 판정 기준입니다. 살아있는 자손은 살아있는
+ * 부모 체인이 필요하므로 직계 검사만으로 판정이 완결됩니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 게시글 ID
+ * @return bool 살아있는 직계 답글 존재 여부
+ */
+ public function hasAliveReplies(string $slug, int $postId): bool
+ {
+ $board = Board::where('slug', $slug)->first();
+
+ return Post::where('board_id', $board?->id)
+ ->where('parent_id', $postId)
+ ->exists();
+ }
+
+ /**
+ * 게시글의 전체 자손(답글 트리) ID 를 수집합니다.
+ *
+ * 반복 BFS + 방문 ID 집합 가드로 순환 데이터에서도 유한 종료를 보장합니다(규정).
+ * 삭제된 중간 노드 밑의 자손도 도달해야 하므로 순회는 withTrashed 로 수행합니다
+ * (끊긴 체인 밑 과거 고아 스윕의 전제).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 루트 게시글 ID
+ * @return array 자손 게시글 ID 배열 (루트 미포함)
+ */
+ public function collectDescendantIds(string $slug, int $postId): array
+ {
+ $board = Board::where('slug', $slug)->first();
+
+ if ($board === null) {
+ return [];
+ }
+
+ $visited = [$postId => true];
+ $frontier = [$postId];
+ $descendants = [];
+
+ while ($frontier !== []) {
+ // audit:allow query-unbounded-get reason: 대상은 한 원글의 답글 트리 한 레벨 — 삭제/복원은 트리 전체가 하나의 의사표시라 단일 트랜잭션에서 전량 확보해야 하며(부분 반영 시 원글과 답글 상태가 어긋남), visited 가드가 유한 종료를 보장한다
+ $children = Post::withTrashed()
+ ->where('board_id', $board->id)
+ ->whereIn('parent_id', $frontier)
+ ->pluck('id')
+ ->all();
+
+ $next = [];
+ foreach ($children as $childId) {
+ if (isset($visited[$childId])) {
+ continue;
+ }
+ $visited[$childId] = true;
+ $next[] = $childId;
+ $descendants[] = $childId;
+ }
+
+ $frontier = $next;
+ }
+
+ return $descendants;
+ }
+
+ /**
+ * 게시글의 살아있는 자손 답글 전체를 cascade 로 일괄 소프트 삭제합니다.
+ *
+ * 자손 중 살아있는 것만 단일 UPDATE 로 `status='deleted', trigger_type='cascade',
+ * deleted_at=now()` 마킹합니다 (status 포함: 관리자 삭제 탭 필터가 status 기준).
+ * 이미 trashed 인 자손(trigger 'user' 등)은 기본 스코프 밖이라 변조되지 않습니다.
+ * 작업 이력(action_log)은 부모에만 남깁니다 — 댓글 cascade 와 동일한 규약입니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 부모 게시글 ID
+ * @return array 소프트 삭제된 자손 게시글 ID 배열
+ */
+ public function softDeleteCascadeByParentId(string $slug, int $postId): array
+ {
+ $descendantIds = $this->collectDescendantIds($slug, $postId);
+
+ if ($descendantIds === []) {
+ return [];
+ }
+
+ // 살아있는 자손만 선별 (기본 스코프가 trashed 제외)
+ $aliveIds = Post::whereIn('id', $descendantIds)->pluck('id')->all();
+
+ if ($aliveIds === []) {
+ return [];
+ }
+
+ Post::whereIn('id', $aliveIds)->update([
+ 'status' => PostStatus::Deleted->value,
+ 'trigger_type' => TriggerType::Cascade->value,
+ 'deleted_at' => now(),
+ ]);
+
+ return $aliveIds;
+ }
+
+ /**
+ * 게시글 복원 시, cascade 로 지워진 자손 답글만 top-down 으로 선택 복원합니다.
+ *
+ * 레벨 순회 + 방문 가드: 각 레벨에서 `parent_id ∈ (복원됨 ∪ alive)` 인
+ * cascade-trashed 자손만 복원합니다. 사용자 직접 삭제('user')로 trashed 인
+ * 중간 노드는 복원되지 않고 그 서브트리도 그대로 유지됩니다(고아 재생성 금지).
+ *
+ * 트레이드오프(의도): 삭제 전 blinded 였던 자손도 복원 시 published 가 됩니다 —
+ * 부모 restorePost 의 기존 의미론(updateStatus published)과 동일합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $postId 복원된 부모 게시글 ID
+ * @return array 복원된 자손 게시글 ID 배열
+ */
+ public function restoreCascadedByParentId(string $slug, int $postId): array
+ {
+ $board = Board::where('slug', $slug)->first();
+
+ if ($board === null) {
+ return [];
+ }
+
+ $visited = [$postId => true];
+ $frontier = [$postId];
+ $restored = [];
+
+ while ($frontier !== []) {
+ // 이미 살아있는 자식 — 더 깊은 레벨의 cascade 복원 통로로만 사용
+ $aliveChildren = Post::where('board_id', $board->id)
+ ->whereIn('parent_id', $frontier)
+ ->pluck('id')
+ ->all();
+
+ // cascade 로 지워진 자식만 복원 대상
+ $toRestore = Post::onlyTrashed()
+ ->where('board_id', $board->id)
+ ->whereIn('parent_id', $frontier)
+ ->where('trigger_type', TriggerType::Cascade->value)
+ ->pluck('id')
+ ->all();
+
+ if ($toRestore !== []) {
+ Post::onlyTrashed()->whereIn('id', $toRestore)->update([
+ 'status' => PostStatus::Published->value,
+ 'deleted_at' => null,
+ ]);
+ $restored = array_merge($restored, $toRestore);
+ }
+
+ $next = [];
+ foreach (array_merge($aliveChildren, $toRestore) as $childId) {
+ if (isset($visited[$childId])) {
+ continue;
+ }
+ $visited[$childId] = true;
+ $next[] = $childId;
+ }
+
+ $frontier = $next;
+ }
+
+ return $restored;
+ }
+
+ /**
+ * 게시판의 전체 게시글 ID 를 청크 단위로 순회하며 콜백에 전달합니다.
+ *
+ * 게시판 삭제 벌크 훅(board.posts.before_force_delete)의 페이로드 공급용입니다.
+ * withTrashed 포함(삭제 대상은 trashed 도 물리 제거되므로), chunkById(키셋) 로
+ * OFFSET 밀림 없이 순회합니다.
+ *
+ * @param int $boardId 게시판 ID
+ * @param int $size 청크 크기
+ * @param callable $callback 청크마다 호출될 콜백 (int[] $postIds)
+ */
+ public function eachIdChunkByBoardId(int $boardId, int $size, callable $callback): void
+ {
+ Post::withTrashed()
+ ->where('board_id', $boardId)
+ ->select('id')
+ ->chunkById($size, function ($posts) use ($callback) {
+ $callback($posts->pluck('id')->all());
+ });
+ }
+
/**
* 비활성 게시판 ID 목록을 조회합니다.
*
@@ -1780,6 +2009,10 @@ class PostRepository implements PostRepositoryInterface
return Post::query()
->whereNull('deleted_at')
->whereNull('parent_id')
+ // 노출 제한 필터 — 미발행(블라인드·삭제)·비활성 게시판 글은 대시보드 최신글에서 제외한다.
+ // 비밀글은 제목 공개 정책(2026-02-04)에 따라 관리자에게 제목을 노출한다(제외하지 않음).
+ ->where('status', PostStatus::Published->value)
+ ->whereHas('board', fn ($q) => $q->where('is_active', true))
->with(['board', 'user'])
->orderByDesc('created_at')
->limit($limit)
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php
index bc373e19..a6a7eac8 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/ReportRepository.php
@@ -668,8 +668,15 @@ class ReportRepository implements ReportRepositoryInterface
*/
public function getPendingAcrossBoards(int $limit): Collection
{
- return Report::query()
+ $query = Report::query()
->whereIn('status', [ReportStatus::Pending, ReportStatus::Review])
+ // 비활성 게시판의 신고는 대시보드 집계에서 제외한다(활성 게시판 스코프).
+ ->whereHas('board', fn ($q) => $q->where('is_active', true));
+
+ // 신고 조회 권한 스코프 필터링 (목록 엔드포인트와 동일 게이트)
+ PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view');
+
+ return $query
->with(['board', 'author'])
->orderByDesc('last_reported_at')
->limit($limit)
@@ -683,8 +690,13 @@ class ReportRepository implements ReportRepositoryInterface
*/
public function countPendingAcrossBoards(): int
{
- return Report::query()
+ $query = Report::query()
->whereIn('status', [ReportStatus::Pending, ReportStatus::Review])
- ->count();
+ // 목록(getPendingAcrossBoards)과 동일 스코프여야 배지 건수와 목록 건수가 일치한다.
+ ->whereHas('board', fn ($q) => $q->where('is_active', true));
+
+ PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view');
+
+ return $query->count();
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
index 5109ed07..9e335886 100644
--- a/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/AttachmentService.php
@@ -9,6 +9,7 @@ use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -16,6 +17,7 @@ use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
+use Modules\Sirsoft\Board\Support\SecretContentGate;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -345,6 +347,45 @@ class AttachmentService
}
}
+ /**
+ * 비밀글 첨부파일 접근 권한을 검증합니다(KVE-2026-1914).
+ *
+ * 첨부가 속한 게시글이 비밀글이면 SecretContentGate(SSoT) 판정을 통과한
+ * 요청(작성자 본인 또는 게시판 manager/posts.read-secret)에만 서빙합니다.
+ * 첨부 서빙은 상세 요청과 분리된 별도 요청이라 password_verified 는 세팅되지
+ * 않으므로, 비회원이 비밀번호로 검증한 경우는 이 경로에서 인정되지 않습니다
+ * (안전 측 실패 — 해시/ID 만으로 비밀글 첨부를 가져가는 것을 차단).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param Attachment $attachment 첨부파일 모델
+ *
+ * @throws AccessDeniedHttpException 비밀글 첨부에 권한 없이 접근한 경우
+ */
+ private function assertSecretPostAttachmentAccess(string $slug, Attachment $attachment): void
+ {
+ if (! $attachment->post_id) {
+ return;
+ }
+
+ $post = $this->repository->findPostForGate($slug, $attachment->post_id);
+
+ // 부모 글을 못 읽으면 막는다(fail-closed). 첨부에 post_id 가 있는데 그 글을 못 찾는
+ // 것은 정상 상태가 아니다 — 슬러그가 다른 게시판이거나 글이 사라진 경우이며, 통과시키면
+ // 게이트가 있어야 할 자리에서 무게이트가 된다. 조회는 withTrashed 라 소프트 삭제로는
+ // null 이 되지 않는다.
+ if (! $post) {
+ throw new AccessDeniedHttpException(__('auth.scope_denied'));
+ }
+
+ if (! $post->is_secret) {
+ return;
+ }
+
+ if (! app(SecretContentGate::class)->canView($post)) {
+ throw new AccessDeniedHttpException(__('auth.scope_denied'));
+ }
+ }
+
/**
* ID로 첨부파일 조회
*
@@ -380,9 +421,10 @@ class AttachmentService
*
* @param string $slug 게시판 슬러그
* @param int $id 첨부파일 ID
+ * @param string $context 호출 컨텍스트 (admin | user) — 스코프 권한 식별자 결정에 쓰인다
* @return bool 삭제 성공 여부
*/
- public function delete(string $slug, int $id): bool
+ public function delete(string $slug, int $id, string $context = 'user'): bool
{
$attachment = $this->repository->findById($slug, $id);
@@ -390,6 +432,8 @@ class AttachmentService
return false;
}
+ $this->assertAttachmentWithinScope($slug, $attachment, $context);
+
// 삭제 후 재정렬을 위해 정보 저장
$postId = $attachment->post_id;
$collection = $attachment->collection;
@@ -397,8 +441,9 @@ class AttachmentService
// Before 훅
HookManager::doAction('sirsoft-board.attachment.before_delete', $attachment);
- // 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행
- // 추후 배치 작업(Artisan Command + Scheduler)으로 보존 기간 경과 후 정리 예정
+ // 물리 파일은 삭제하지 않음 — 소프트 딜리트만 수행 (휴지통 복원 대비).
+ // 보존기간이 지난 뒤의 파일·기록 파기는 `sirsoft-board:prune-attachments` 가
+ // 담당한다 (purgeSoftDeleted). 운영자가 설정에서 켰을 때만 동작한다.
// DB에서 소프트 삭제
$result = $this->repository->delete($slug, $id);
@@ -420,15 +465,163 @@ class AttachmentService
return $result;
}
+ /**
+ * 게시글에 연결되지 않은 채 방치된 임시 첨부를 정리합니다.
+ *
+ * 글쓰기 폼에서 파일만 올리고 저장하지 않고 이탈하면 `temp_key` 만 남은 행과 그 파일이
+ * 남습니다. 연결 시점에 본경로로 옮겨지므로 `temp_key` 가 남아 있다는 것은 "끝내 연결되지
+ * 않았다" 는 뜻이고, 그래서 오탐 여지가 없습니다.
+ *
+ * 파일 → 행 순서를 지켜, 행이 먼저 사라져 파일을 못 찾는 상태를 만들지 않습니다.
+ *
+ * @param int $days 보존기간(일)
+ * @param int $limit 한 회차에 처리할 최대 건수
+ * @param bool $dryRun true 면 대상만 세고 삭제하지 않음
+ * @return array{scanned: int, deleted: int, failed: int} 처리 결과
+ */
+ public function pruneTempUploads(int $days, int $limit, bool $dryRun = false): array
+ {
+ $threshold = Carbon::now()->subDays($days);
+ $attachments = $this->repository->findStaleTempAttachments($threshold, $limit);
+
+ $result = $this->purgeCollection($attachments, $dryRun, '임시 게시판 첨부');
+
+ if (! $dryRun) {
+ $this->removeEmptyTempDirectories($attachments);
+ }
+
+ return $result;
+ }
+
+ /**
+ * 파일을 모두 지운 temp_key 디렉토리를 정리합니다.
+ *
+ * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
+ * 저장소에는 흔적이 계속 늘어납니다.
+ *
+ * 디렉토리에 파일이 남아 있으면(같은 temp_key 의 다른 첨부가 limit 에 걸려 이번 회차에서
+ * 빠진 경우 등) 삭제하지 않습니다.
+ *
+ * @param Collection $attachments 이번 회차에 처리한 첨부 목록
+ */
+ private function removeEmptyTempDirectories(Collection $attachments): void
+ {
+ $directories = [];
+
+ foreach ($attachments as $attachment) {
+ $directory = dirname((string) $attachment->path);
+
+ if ($directory === '' || $directory === '.') {
+ continue;
+ }
+
+ $directories[$attachment->disk.'|'.$directory] = [$attachment->disk, $directory];
+ }
+
+ foreach ($directories as [$disk, $directory]) {
+ $storage = $this->storageForRow($disk);
+
+ if ($storage->files('attachments', $directory) !== []) {
+ continue;
+ }
+
+ $storage->deleteDirectory('attachments', $directory);
+ }
+ }
+
+ /**
+ * 소프트 삭제된 지 보존기간이 지난 첨부를 파일까지 영구 파기합니다.
+ *
+ * 첨부 삭제는 소프트 삭제만 수행하므로 파일이 남아 있습니다. 게시글 복원
+ * (restoreCascadedByPostId)은 첨부의 `deleted_at` 이 보존기간 안에 있을 때에만 첨부까지
+ * 온전히 복원되므로, 보존기간은 휴지통 복원 가능 기간과 같게 유지해야 합니다.
+ *
+ * @param int $days 보존기간(일)
+ * @param int $limit 한 회차에 처리할 최대 건수
+ * @param bool $dryRun true 면 대상만 세고 삭제하지 않음
+ * @return array{scanned: int, deleted: int, failed: int} 처리 결과
+ */
+ public function purgeSoftDeleted(int $days, int $limit, bool $dryRun = false): array
+ {
+ $threshold = Carbon::now()->subDays($days);
+ $attachments = $this->repository->findSoftDeletedOlderThan($threshold, $limit);
+
+ return $this->purgeCollection($attachments, $dryRun, '소프트 삭제 게시판 첨부');
+ }
+
+ /**
+ * 첨부 컬렉션을 파일 → 기록 순으로 영구 파기합니다.
+ *
+ * @param Collection $attachments 대상 첨부 목록
+ * @param bool $dryRun true 면 대상만 세고 삭제하지 않음
+ * @param string $label 로그용 대상 설명
+ * @return array{scanned: int, deleted: int, failed: int} 처리 결과
+ */
+ private function purgeCollection(Collection $attachments, bool $dryRun, string $label): array
+ {
+ $result = ['scanned' => $attachments->count(), 'deleted' => 0, 'failed' => 0];
+
+ if ($dryRun) {
+ return $result;
+ }
+
+ foreach ($attachments as $attachment) {
+ $storage = $this->storageForRow($attachment->disk);
+
+ if ($storage->exists('attachments', $attachment->path) && ! $storage->delete('attachments', $attachment->path)) {
+ Log::warning("{$label} 파일 삭제 실패 — 기록 보존", [
+ 'attachment_id' => $attachment->id,
+ 'disk' => $attachment->disk,
+ 'path' => $attachment->path,
+ ]);
+
+ $result['failed']++;
+
+ continue;
+ }
+
+ $this->repository->forceDelete($attachment);
+ $result['deleted']++;
+ }
+
+ return $result;
+ }
+
+ /**
+ * 첨부 행에 기록된 disk 기준 스토리지를 반환합니다.
+ *
+ * 디스크를 전환한 뒤에도 전환 이전 행의 파일을 그 행의 실제 저장 위치에서 지우기 위한
+ * 해석입니다. 미등록 disk(그 디스크를 제공하던 확장이 비활성화된 경우)는 주입 스토리지로
+ * 폴백합니다 — withDisk 로 미등록 disk 인스턴스를 만들면 이후 호출이 예외가 됩니다.
+ *
+ * @param string|null $disk 행의 disk 컬럼 값
+ * @return StorageInterface 행 disk 의 스토리지
+ */
+ private function storageForRow(?string $disk): StorageInterface
+ {
+ if ($disk === null || $disk === '' || $disk === $this->storage->getDisk()) {
+ return $this->storage;
+ }
+
+ if (config("filesystems.disks.{$disk}") === null) {
+ return $this->storage;
+ }
+
+ return $this->storage->withDisk($disk);
+ }
+
/**
* 순서 변경
*
* @param string $slug 게시판 슬러그
* @param array $orders 첨부파일 ID => order 매핑
+ * @param string $context 호출 컨텍스트 (admin | user) — 스코프 권한 식별자 결정에 쓰인다
* @return bool 성공 여부
*/
- public function reorder(string $slug, array $orders): bool
+ public function reorder(string $slug, array $orders, string $context = 'user'): bool
{
+ $this->assertReorderWithinScope($slug, $orders, $context);
+
// Before 훅
HookManager::doAction('sirsoft-board.attachment.before_reorder', $slug, $orders);
@@ -440,6 +633,63 @@ class AttachmentService
return $result;
}
+ /**
+ * 첨부가 액터의 스코프 안에 있는지 검사합니다.
+ *
+ * 첨부 관리 라우트는 `{id}`(정수)로 선언돼 라우트 모델 바인딩이 일어나지 않고, 순서
+ * 변경은 아예 정적 경로다. 두 경우 모두 PermissionMiddleware 가 모델을 resolve 하지
+ * 못해 스코프 검사를 건너뛰므로(목록 엔드포인트로 간주) 서비스가 재적용한다.
+ * 사용자 경로는 컨트롤러가 `canDelete`(작성자 본인)로 막고 있었으나 관리자 경로는
+ * 그 대응물이 없어 비어 있었다 — 두 경로 모두 여기서 같은 판정을 받는다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param Attachment $attachment 대상 첨부
+ *
+ * @throws AccessDeniedHttpException 스코프 밖 첨부인 경우
+ */
+ private function assertAttachmentWithinScope(string $slug, Attachment $attachment, string $context): void
+ {
+ // 컨텍스트는 호출부가 명시한다 — PostService 가 쓰는 방식과 같다. 요청에서
+ // 컨트롤러 네임스페이스를 스니핑하는 사설 복제본이 이미 4곳에 있는데 5번째를
+ // 만들지 않는다.
+ $scopePermission = $context === 'admin'
+ ? "sirsoft-board.{$slug}.admin.attachments.upload"
+ : "sirsoft-board.{$slug}.attachments.upload";
+
+ if (! PermissionHelper::checkScopeAccess($attachment, $scopePermission)) {
+ throw new AccessDeniedHttpException(__('auth.scope_denied'));
+ }
+ }
+
+ /**
+ * 순서 변경 대상 첨부 전체가 액터의 스코프 안에 있는지 검사합니다.
+ *
+ * 순서는 집합 전체에 대한 하나의 배열이라 일부만 반영하면 나머지와 어긋난다 —
+ * 걸러내지 않고 전량 거부한다(코어 첨부/메뉴 순서 변경과 같은 의미론).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param array $orders 순서 데이터
+ *
+ * @throws AccessDeniedHttpException 스코프 밖 첨부가 하나라도 포함된 경우
+ */
+ private function assertReorderWithinScope(string $slug, array $orders, string $context): void
+ {
+ $ids = array_values(array_unique(array_filter(
+ array_map(static fn ($item): int => (int) ($item['id'] ?? 0), $orders)
+ )));
+
+ foreach ($ids as $id) {
+ $attachment = $this->repository->findById($slug, $id);
+
+ // 이 게시판 소속이 아닌 id 는 통과시키지 않는다.
+ if (! $attachment) {
+ throw new AccessDeniedHttpException(__('auth.scope_denied'));
+ }
+
+ $this->assertAttachmentWithinScope($slug, $attachment, $context);
+ }
+ }
+
/**
* 삭제 후 남은 파일들의 순서를 재정렬합니다.
*
@@ -517,6 +767,9 @@ class AttachmentService
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
+ // 비밀글 첨부파일은 열람 권한자만 접근
+ $this->assertSecretPostAttachmentAccess($slug, $attachment);
+
// 다운로드 활동이력 기록 훅
// 권한/삭제글 가드 통과 후 발화 → 차단된 시도는 기록하지 않음.
// $context('user'|'admin')는 로그 부가정보용 (log_type 은 요청 경로로 자동 결정).
@@ -580,6 +833,9 @@ class AttachmentService
// 삭제된 게시글의 첨부파일은 관리 권한자만 접근
$this->assertDeletedPostAttachmentAccess($slug, $attachment);
+ // 비밀글 첨부파일은 열람 권한자만 접근
+ $this->assertSecretPostAttachmentAccess($slug, $attachment);
+
return $this->storage->response(
'attachments',
$attachment->path,
@@ -597,11 +853,17 @@ class AttachmentService
* 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있도록
* 파일 경로와 메타 정보를 반환합니다.
*
+ * $signatureVerified 는 요청 URL 의 한시 서명이 검증된 경우다 — 서명은
+ * 비밀글·삭제글 게이트를 통과한 응답 직렬화(PostResource)만 발급하므로,
+ * 검증된 서명은 그 게이트 통과 자격의 위임으로 보고 콘텐츠 상태 게이트를
+ * 재적용하지 않는다 (
는 인증 헤더를 실을 수 없는 렌더 경로).
+ *
* @param string $slug 게시판 슬러그
* @param int $id 첨부파일 ID
+ * @param bool $signatureVerified 한시 서명 검증 통과 여부
* @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
*/
- public function getFileInfo(string $slug, int $id): ?array
+ public function getFileInfo(string $slug, int $id, bool $signatureVerified = false): ?array
{
$attachment = $this->repository->findById($slug, $id);
@@ -609,8 +871,13 @@ class AttachmentService
return null;
}
- // 삭제된 게시글의 첨부파일은 관리 권한자만 접근
- $this->assertDeletedPostAttachmentAccess($slug, $attachment);
+ if (! $signatureVerified) {
+ // 삭제된 게시글의 첨부파일은 관리 권한자만 접근
+ $this->assertDeletedPostAttachmentAccess($slug, $attachment);
+
+ // 비밀글 첨부파일은 열람 권한자만 접근
+ $this->assertSecretPostAttachmentAccess($slug, $attachment);
+ }
// 파일 존재 확인
if (! $this->storage->exists('attachments', $attachment->path)) {
diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardService.php b/modules/_bundled/sirsoft-board/src/Services/BoardService.php
index c6eaa3fa..93e44263 100644
--- a/modules/_bundled/sirsoft-board/src/Services/BoardService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/BoardService.php
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Board\Services;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\MenuRepositoryInterface;
+use App\Contracts\Repositories\RoleRepositoryInterface;
+use App\Contracts\Repositories\UserRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\HookManager;
use App\Extension\Traits\ClearsTemplateCaches;
@@ -12,6 +14,7 @@ use App\Helpers\PermissionHelper;
use App\Models\Menu;
use App\Models\Role;
use App\Models\User;
+use App\Repositories\Concerns\ResolvesSortSpec;
use App\Services\MenuService;
use App\Services\RoleService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -40,6 +43,26 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class BoardService
{
use ClearsTemplateCaches;
+ use ResolvesSortSpec;
+
+ /**
+ * 관리자 게시판 목록 정렬 허용 컬럼 화이트리스트
+ *
+ * IndexBoardRequest 의 sort_by in: 목록과 동일해야 한다 (화면 ⊆ 게이트 ⊆ 저장소).
+ * `name` 은 JSON 다국어 컬럼이라 제외한다.
+ *
+ * @var array
+ */
+ private const BOARD_SORT_COLUMNS = [
+ 'id',
+ 'slug',
+ 'type',
+ 'is_active',
+ 'posts_count',
+ 'comments_count',
+ 'created_at',
+ 'updated_at',
+ ];
/**
* BoardService 생성자
@@ -53,6 +76,9 @@ class BoardService
* @param RoleService $roleService 역할 서비스
* @param CacheInterface $cache 캐시 드라이버
* @param MenuRepositoryInterface $menuRepository 코어 메뉴 리포지토리 (메뉴 조회용)
+ * @param StorageInterface $storage 스토리지 드라이버
+ * @param RoleRepositoryInterface $roleRepository 코어 역할 리포지토리 (게시판별 역할 동기화용)
+ * @param UserRepositoryInterface $userRepository 코어 사용자 리포지토리 (통계·UUID 변환용)
*/
public function __construct(
private BoardRepositoryInterface $boardRepository,
@@ -64,7 +90,9 @@ class BoardService
private RoleService $roleService,
private CacheInterface $cache,
private MenuRepositoryInterface $menuRepository,
- private StorageInterface $storage
+ private StorageInterface $storage,
+ private RoleRepositoryInterface $roleRepository,
+ private UserRepositoryInterface $userRepository
) {}
/**
@@ -99,13 +127,21 @@ class BoardService
});
}
- // 정렬
- $sortBy = $filters['sort_by'] ?? 'created_at';
- $sortOrder = $filters['sort_order'] ?? 'desc';
- $query->orderBy($sortBy, $sortOrder);
+ // 정렬 — 요청 유래 값은 닫힌 집합으로만 해석 (미허용 컬럼은 기본값 fallback: 이중 방어)
+ [$sortSpec] = $this->resolveSortSpec(
+ $filters,
+ self::BOARD_SORT_COLUMNS,
+ 'created_at',
+ );
+ $query->orderBy($sortSpec['column'], $sortSpec['direction']);
- // 페이지네이션
- $perPage = $filters['per_page'] ?? 20;
+ // 비고유 컬럼 동률 구간에서도 전순서 보장 (페이지 경계 중복/누락 방지 규정)
+ $query->orderBy('id', 'desc');
+
+ // 페이지네이션 (1~999 클램프 — 게이트 우회 내부 호출 대비 이중 방어)
+ // 상한 999 는 IndexBoardRequest 와 동일해야 한다: 게시판은 운영자 등록
+ // 설정성 테이블이라 전체 목록 셀렉트 소비처(999/200)가 이 값을 쓴다.
+ $perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 999);
return $query->paginate($perPage);
}
@@ -114,6 +150,7 @@ class BoardService
* ID로 게시판을 조회합니다.
*
* @param int $id 게시판 ID
+ * @return Board 게시판 모델
*
* @throws ModelNotFoundException
*/
@@ -153,6 +190,7 @@ class BoardService
*
* @param string $orderBy 정렬 기준 (기본: created_at)
* @param string $orderDirection 정렬 방향 (기본: desc)
+ * @return Collection 활성 게시판 컬렉션
*/
public function getActiveBoards(
string $orderBy = 'created_at',
@@ -161,6 +199,29 @@ class BoardService
return $this->boardRepository->getActiveBoardsOrdered($orderBy, $orderDirection);
}
+ /**
+ * 활성 게시판 중 호출자가 열람할 수 있는 게시판만 조회합니다.
+ *
+ * 게시판 디렉토리(전체 게시판 목록 + 내장 최근글)처럼 비로그인에게도 열리는
+ * 응답에 사용합니다 — 최근글·인기글·인기게시판과 동일한 열람 권한 기준
+ * (sirsoft-board.{slug}.posts.read)을 호출자 시점에 적용해, 디렉토리 경로가
+ * 비공개 게시판·글 제목의 우회 노출로가 되지 않게 합니다.
+ *
+ * @param string $orderBy 정렬 기준 컬럼
+ * @param string $orderDirection 정렬 방향
+ * @return Collection 호출자가 열람 가능한 활성 게시판 컬렉션
+ */
+ public function getReadableActiveBoards(
+ string $orderBy = 'created_at',
+ string $orderDirection = 'desc'
+ ): Collection {
+ $user = Auth::user();
+
+ return $this->getActiveBoards($orderBy, $orderDirection)
+ ->filter(fn ($board) => PermissionHelper::check("sirsoft-board.{$board->slug}.posts.read", $user))
+ ->values();
+ }
+
/**
* 메뉴용 경량 게시판 목록을 조회합니다.
*
@@ -211,7 +272,7 @@ class BoardService
$boardStats = $this->getActiveBoardStats();
return [
- 'users' => User::count(),
+ 'users' => $this->userRepository->getStatistics()['total_users'],
'boards' => $boardStats['boards'],
'posts' => $boardStats['posts'],
'comments' => $boardStats['comments'],
@@ -229,12 +290,18 @@ class BoardService
{
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
- return $this->cache->remember(
+ // 캐시는 사용자와 무관한 안전집합(발행 + 활성 게시판)으로만 채운다.
+ // 비밀글은 제목 공개 정책(2026-02-04)에 따라 제목을 포함한다(본문 미포함 — 사용자 무관 데이터).
+ // 게시판별 열람 권한은 캐시에 담지 않고 응답 시점에 호출자 기준으로 적용해,
+ // 고권한 사용자의 결과가 캐시를 오염시켜 저권한 사용자에게 새는 것을 막는다.
+ $posts = $this->cache->remember(
"recent_posts_{$limit}",
fn () => $this->getRecentPosts($limit),
$ttl,
tags: ['board-posts']
);
+
+ return $this->filterItemsByBoardReadPermission($posts, 'board_slug');
}
/**
@@ -244,17 +311,22 @@ class BoardService
*
* @param string $period 기간 (today, week, month, year)
* @param int $limit 조회 개수
+ * @return array 캐시된 인기글 목록
*/
public function getCachedPopularPosts(string $period = 'week', int $limit = 20): array
{
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
- return $this->cache->remember(
+ // 캐시는 안전집합(비밀글 제외 + 발행 + 활성 게시판)으로만 채우고, 게시판별
+ // 열람 권한은 응답 시점에 호출자 기준으로 적용한다(캐시 오염 회피).
+ $posts = $this->cache->remember(
"popular_posts_{$period}_{$limit}",
fn () => $this->getPopularPosts($period, $limit),
$ttl,
tags: ['board-posts']
);
+
+ return $this->filterItemsByBoardReadPermission($posts, 'board_slug');
}
/**
@@ -267,12 +339,16 @@ class BoardService
{
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
- return $this->cache->remember(
+ // 캐시는 활성 게시판 전체로 채우고, 게시판별 열람 권한은 응답 시점에
+ // 호출자 기준으로 적용한다(캐시 오염 회피 — 저권한 사용자에게 고권한 결과가 새지 않도록).
+ $boards = $this->cache->remember(
"popular_boards_{$limit}",
fn () => $this->getPopularBoards($limit),
$ttl,
tags: ['board-list']
);
+
+ return $this->filterItemsByBoardReadPermission($boards, 'slug');
}
/**
@@ -508,6 +584,15 @@ class BoardService
// (소프트 삭제 시 게시판이 사라진 뒤 접근 불가한 고아 데이터로 잔존)
$this->attachmentRepository->forceDeleteByBoardId($board->id);
$this->commentRepository->forceDeleteByBoardId($board->id);
+
+ // 2-1. 훅: 삭제될 글 ID 목록을 연동 확장에 통지 (이커머스 문의 피벗 정리 등)
+ // withTrashed 전체를 1,000건 chunk 당 1회씩 다회 발화한다 — 리스너는 멱등 의무.
+ // 트랜잭션 내부 발화라 게시판 삭제와 원자적이다. per-post after_delete 훅을
+ // 재사용하지 않는 이유: 글마다 모델 로드 N회 + 소프트/하드 삭제 의미 불일치.
+ $this->postRepository->eachIdChunkByBoardId($board->id, 1000, function (array $postIds) use ($board) {
+ HookManager::doAction('sirsoft-board.board.posts.before_force_delete', $board, $postIds);
+ });
+
$this->postRepository->forceDeleteByBoardId($board->id);
// 3. 게시판 권한 삭제 (그누보드7 규정: detach 후 삭제)
@@ -519,13 +604,13 @@ class BoardService
HookManager::doAction('sirsoft-board.roles.after_delete', $board->slug);
// 5. 등록된 메뉴 제거 (addToAdminMenu()로 등록된 경우에만)
- $menu = Menu::where('url', '/admin/board/'.$board->slug)->first();
+ $menu = $this->menuRepository->findByUrl('/admin/board/'.$board->slug);
if ($menu) {
$this->menuService->deleteMenu($menu);
}
// 6. 게시판 영구 삭제
- $board->forceDelete();
+ $this->boardRepository->forceDelete($board->id);
});
Log::info('Board deleted', [
@@ -665,10 +750,10 @@ class BoardService
$boardNameKo = $board->name['ko'] ?? $board->name['en'] ?? $board->slug;
$boardNameEn = $board->name['en'] ?? $board->name['ko'] ?? $board->slug;
- // 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음
- $managerRole = Role::firstOrCreate(
- ['identifier' => "sirsoft-board.{$board->slug}.manager"],
- [
+ // 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음 (기존 role 은 갱신 없이 재사용 — firstOrCreate 동형)
+ $managerRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.manager")
+ ?? $this->roleRepository->create([
+ 'identifier' => "sirsoft-board.{$board->slug}.manager",
'name' => [
'ko' => "{$boardNameKo} 게시판 관리자",
'en' => "{$boardNameEn} Board Manager",
@@ -679,12 +764,11 @@ class BoardService
],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
- ]
- );
+ ]);
- $stepRole = Role::firstOrCreate(
- ['identifier' => "sirsoft-board.{$board->slug}.step"],
- [
+ $stepRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.step")
+ ?? $this->roleRepository->create([
+ 'identifier' => "sirsoft-board.{$board->slug}.step",
'name' => [
'ko' => "{$boardNameKo} 게시판 스텝",
'en' => "{$boardNameEn} Board Step",
@@ -695,8 +779,7 @@ class BoardService
],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
- ]
- );
+ ]);
return ['manager' => $managerRole, 'step' => $stepRole];
}
@@ -713,13 +796,16 @@ class BoardService
"sirsoft-board.{$board->slug}.step",
];
- $roles = Role::whereIn('identifier', $roleIdentifiers)->get();
+ foreach ($roleIdentifiers as $identifier) {
+ $role = $this->roleRepository->findByIdentifier($identifier);
+ if (! $role) {
+ continue;
+ }
- foreach ($roles as $role) {
// 그누보드7 규정: detach 후 삭제
- $role->permissions()->detach();
+ $this->roleRepository->detachAllPermissions($role);
$role->users()->detach();
- $role->delete();
+ $this->roleRepository->delete($role);
}
}
@@ -757,7 +843,10 @@ class BoardService
];
foreach ($roleUpdates as $identifier => $attributes) {
- Role::where('identifier', $identifier)->update($attributes);
+ $role = $this->roleRepository->findByIdentifier($identifier);
+ if ($role) {
+ $this->roleRepository->update($role, $attributes);
+ }
}
}
@@ -779,7 +868,7 @@ class BoardService
continue;
}
- $role = Role::where('identifier', $roleIdentifier)->first();
+ $role = $this->roleRepository->findByIdentifier($roleIdentifier);
if (! $role) {
Log::warning('게시판 역할을 찾을 수 없습니다.', [
'board_slug' => $board->slug,
@@ -790,7 +879,7 @@ class BoardService
}
$userUuids = $data[$dataKey] ?? [];
- $userIds = User::whereIn('uuid', $userUuids)->pluck('id')->toArray();
+ $userIds = $this->userRepository->getIdsByUuids($userUuids);
$role->users()->sync($userIds);
}
}
@@ -886,6 +975,7 @@ class BoardService
*
* @param string $period 기간 (today, week, month, all)
* @param int $limit 조회 개수
+ * @return array 인기글 목록
*/
public function getPopularPosts(string $period = 'week', int $limit = 20): array
{
@@ -938,7 +1028,7 @@ class BoardService
$menuUrl = '/admin/board/'.$board->slug;
// 중복 체크: 동일한 URL의 메뉴가 이미 존재하는지 확인
- $existingMenu = Menu::where('url', $menuUrl)->first();
+ $existingMenu = $this->menuRepository->findByUrl($menuUrl);
if ($existingMenu) {
throw new MenuAlreadyExistsException(__('sirsoft-board::messages.boards.menu_already_exists'));
@@ -1104,18 +1194,49 @@ class BoardService
/**
* 필터용 전체 활성 게시판 목록을 배열로 반환합니다.
*
+ * $user 가 전달되면 그 사용자가 읽기 권한(posts.read)을 가진 게시판만 남긴다.
+ * 통합 검색 필터 드롭다운(available_boards)이 열람 불가 게시판까지 노출하지 않도록,
+ * 검색 결과 필터와 동일한 게이트를 목록 구성에도 적용한다.
+ *
+ * @param User|null $user 기준 사용자 (null 이면 현재 인증 사용자 또는 guest)
* @return array 활성 게시판 목록
*/
- public function getActiveBoardsListForFilter(): array
+ public function getActiveBoardsListForFilter(?User $user = null): array
{
return $this->boardRepository->getActiveBoardsList()
+ ->filter(fn ($board) => PermissionHelper::check("sirsoft-board.{$board->slug}.posts.read", $user))
->map(fn ($board) => [
'slug' => $board->slug,
'name' => $board->getLocalizedName(),
])
+ ->values()
->toArray();
}
+ /**
+ * 공개 목록의 각 항목을 호출자 읽기 권한(posts.read) 기준으로 필터링합니다.
+ *
+ * 공개 엔드포인트(최근/인기 게시글·인기 게시판)의 전역 캐시는 사용자와 무관하게
+ * 안전집합으로만 채워지므로, 게시판별 열람 권한은 캐시가 아니라 응답 시점에
+ * 현재 호출자 기준으로 적용한다. 고권한 사용자의 결과가 캐시에 남아 저권한/비회원
+ * 사용자에게 새는 것을 막는다.
+ *
+ * @param array> $items 캐시된 목록
+ * @param string $slugKey 각 항목에서 게시판 슬러그를 담은 키
+ * @return array> 읽기 권한을 통과한 항목만 남긴 목록
+ */
+ private function filterItemsByBoardReadPermission(array $items, string $slugKey): array
+ {
+ $user = Auth::user();
+
+ return array_values(array_filter($items, function ($item) use ($slugKey, $user) {
+ $slug = $item[$slugKey] ?? null;
+
+ return $slug !== null
+ && PermissionHelper::check("sirsoft-board.{$slug}.posts.read", $user);
+ }));
+ }
+
/**
* 환경설정 기본값을 기존 게시판에 일괄 적용합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardSettingsService.php b/modules/_bundled/sirsoft-board/src/Services/BoardSettingsService.php
index be8a15d6..78d4d47b 100644
--- a/modules/_bundled/sirsoft-board/src/Services/BoardSettingsService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/BoardSettingsService.php
@@ -75,20 +75,39 @@ class BoardSettingsService implements ModuleSettingsInterface
/**
* 설정값 저장
*
- * @param string $key 설정 키
+ * 벌크 저장(saveSettings)과 같은 본문을 경유해 정규화·신고 알림 강제 활성·캐시 무효화·
+ * 알림 정의 동기화를 함께 수행한다 (공개 #114 동종). 예전에는 `Arr::set` 결과를 카테고리
+ * 파일에 통째로 덮어써서 이 단계들을 모두 건너뛰었고, 자기 캐시도 비우지 않아 같은
+ * 요청 안의 곧이은 조회가 저장 전 값을 반환했다.
+ *
+ * 위임 payload 의 기저는 **저장본**(loadCategorySettings)이다. 조회 결과를 기저로 삼으면
+ * defaults 병합분이 저장 파일에 통째로 영속화된다.
+ *
+ * boolean backfill 은 적용하지 않는다 — 폼 Toggle-OFF 미전송 대응이라 부분 저장에 태우면
+ * 제출하지 않은 boolean 이 전부 false 로 박제되어 기본값 true 인 항목이 뒤집힌다.
+ *
+ * @param string $key 설정 키 (예: 'basic_defaults.per_page', 카테고리 통째 지정도 허용)
* @param mixed $value 저장할 값
* @return bool 성공 여부
*/
public function setSetting(string $key, mixed $value): bool
{
- $settings = $this->getAllSettings();
- Arr::set($settings, $key, $value);
-
- // 카테고리 추출
$parts = explode('.', $key);
- $category = $parts[0];
+ $category = array_shift($parts);
- return $this->saveCategorySettings($category, $settings[$category] ?? []);
+ if ($parts === []) {
+ // 카테고리 통째 저장 — 배열이 아니면 저장할 카테고리 데이터가 없다
+ if (! is_array($value)) {
+ return false;
+ }
+
+ $categoryData = $value;
+ } else {
+ $categoryData = $this->loadCategorySettings($category);
+ Arr::set($categoryData, implode('.', $parts), $value);
+ }
+
+ return $this->persistCategories([$category => $categoryData], backfillBooleans: false);
}
/**
@@ -141,6 +160,19 @@ class BoardSettingsService implements ModuleSettingsInterface
* @return bool 성공 여부
*/
public function saveSettings(array $settings): bool
+ {
+ return $this->persistCategories($settings, backfillBooleans: true);
+ }
+
+ /**
+ * 카테고리 설정을 정규화 파이프라인에 태워 저장합니다. (벌크/단건 공통 본문)
+ *
+ * @param array $settings [카테고리 => 카테고리 설정] 배열
+ * @param bool $backfillBooleans 미제출 boolean 필드를 false 로 채울지 여부
+ * (폼 전체 제출을 전제로 하는 벌크 저장만 true)
+ * @return bool 성공 여부
+ */
+ private function persistCategories(array $settings, bool $backfillBooleans): bool
{
$success = true;
$defaults = $this->getDefaults();
@@ -160,9 +192,12 @@ class BoardSettingsService implements ModuleSettingsInterface
$categoryDefaults = $defaultValues[$category] ?? [];
// Toggle/체크박스 OFF 시 키 미전송 대응: boolean 기본값 필드가 누락되면 false로 채움
- foreach ($categoryDefaults as $key => $defaultValue) {
- if (is_bool($defaultValue) && ! array_key_exists($key, $categorySettings)) {
- $categorySettings[$key] = false;
+ // (폼 전체 제출을 전제로 한 보정이라 부분 저장 경로에서는 적용하지 않는다)
+ if ($backfillBooleans) {
+ foreach ($categoryDefaults as $key => $defaultValue) {
+ if (is_bool($defaultValue) && ! array_key_exists($key, $categorySettings)) {
+ $categorySettings[$key] = false;
+ }
}
}
@@ -186,6 +221,9 @@ class BoardSettingsService implements ModuleSettingsInterface
// 캐시 초기화
$this->settings = null;
+ // 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
+ g7_refresh_module_settings_config('sirsoft-board');
+
// report_policy 설정 변경 시 알림 정의 활성 상태 동기화
// 저장된 최종 설정을 사용 (boolean 보정 후 값)
if (isset($settings['report_policy'])) {
@@ -323,6 +361,9 @@ class BoardSettingsService implements ModuleSettingsInterface
{
$this->defaults = null;
$this->settings = null;
+
+ // 상주 프로세스의 config 미러도 함께 갱신한다 (공개이슈 #109)
+ g7_refresh_module_settings_config('sirsoft-board');
}
/**
@@ -412,10 +453,18 @@ class BoardSettingsService implements ModuleSettingsInterface
/**
* 설정 저장 경로 반환
*
+ * testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
+ * 격리된 임시 경로를 사용합니다. 설정 저장을 수행하는 테스트가 운영 basic_defaults.json
+ * 등을 덮어쓰거나 지우는 것을 차단합니다(운영 설정 영구 보존).
+ *
* @return string 설정 파일 저장 디렉토리 경로
*/
private function getStoragePath(): string
{
+ if (app()->runningUnitTests()) {
+ return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
+ }
+
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php b/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
index 8b5f26f1..d33df028 100644
--- a/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Board\Exceptions\BoardTypeOperationException;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardTypeRepositoryInterface;
@@ -27,7 +28,7 @@ class BoardTypeService
}
/**
- * @param array $data
+ * @param array $data
* @return BoardType
*/
public function createBoardType(array $data): BoardType
@@ -46,8 +47,8 @@ class BoardTypeService
}
/**
- * @param int $id
- * @param array $data
+ * @param int $id
+ * @param array $data
* @return BoardType
*/
public function updateBoardType(int $id, array $data): BoardType
@@ -55,7 +56,7 @@ class BoardTypeService
$boardType = $this->boardTypeRepository->findById($id);
if (! $boardType) {
- throw (new ModelNotFoundException())->setModel(BoardType::class, $id);
+ throw (new ModelNotFoundException)->setModel(BoardType::class, $id);
}
HookManager::doAction('sirsoft-board.board_type.before_update', $boardType, $data);
@@ -74,7 +75,7 @@ class BoardTypeService
}
/**
- * @param int $id
+ * @param int $id
* @return bool
*
* @throws \Exception 사용 중인 유형 삭제 시도 시
@@ -84,23 +85,19 @@ class BoardTypeService
$boardType = $this->boardTypeRepository->findById($id);
if (! $boardType) {
- throw (new ModelNotFoundException())->setModel(BoardType::class, $id);
+ throw (new ModelNotFoundException)->setModel(BoardType::class, $id);
}
$usageCount = $this->boardRepository->countByType($boardType->slug);
if ($usageCount > 0) {
- throw new \Exception(
- __('sirsoft-board::messages.board_type.delete_in_use', [
- 'count' => $usageCount,
- ])
- );
+ throw new BoardTypeOperationException('sirsoft-board::messages.board_type.delete_in_use', [
+ 'count' => $usageCount,
+ ]);
}
$defaultType = $this->boardSettingsService->getSetting('basic_defaults.type');
if ($defaultType === $boardType->slug) {
- throw new \Exception(
- __('sirsoft-board::messages.board_type.delete_is_default')
- );
+ throw new BoardTypeOperationException('sirsoft-board::messages.board_type.delete_is_default');
}
HookManager::doAction('sirsoft-board.board_type.before_delete', $boardType);
diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php
index c7f68546..b87747ce 100644
--- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php
@@ -575,6 +575,51 @@ class CommentService
$this->cache->put("comment_cooldown_{$slug}_{$identifier}", true, $seconds);
}
+ /**
+ * 댓글 비밀번호 검증 토큰을 캐시에 저장하고 만료 시각을 반환합니다.
+ *
+ * 게시글(PostService::storeDeleteVerifyToken)과 동형 — 비회원이 비밀번호를
+ * 확인하면 1회용 토큰을 발급해, 이후 수정/삭제 요청에서 평문 비밀번호 재전송 대신
+ * 이 토큰으로 본인 확인을 대체한다.
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $commentId 댓글 ID
+ * @param string $token 검증 토큰
+ * @return array{token: string, expires_at: string} 토큰 및 만료 시각
+ */
+ public function storeCommentVerifyToken(string $slug, int $commentId, string $token): array
+ {
+ $ttl = (int) g7_core_settings('cache.post_verify_token_ttl', 3600);
+ $expiresAt = now()->addSeconds($ttl);
+ $this->cache->put("board_comment_verify_{$slug}_{$commentId}_{$token}", true, $ttl);
+
+ return [
+ 'token' => $token,
+ 'expires_at' => $expiresAt->toIso8601String(),
+ ];
+ }
+
+ /**
+ * 댓글 비밀번호 검증 토큰의 유효성을 확인하고 소비합니다.
+ *
+ * 토큰이 유효하면 즉시 삭제하여 재사용을 방지합니다(단일 사용).
+ *
+ * @param string $slug 게시판 슬러그
+ * @param int $commentId 댓글 ID
+ * @param string $token 검증 토큰
+ * @return bool 토큰 유효 여부
+ */
+ public function consumeCommentVerifyToken(string $slug, int $commentId, string $token): bool
+ {
+ $key = "board_comment_verify_{$slug}_{$commentId}_{$token}";
+ if (! $this->cache->has($key)) {
+ return false;
+ }
+ $this->cache->forget($key);
+
+ return true;
+ }
+
/**
* 관리자 작업 이력 배열을 생성합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/Services/PostService.php b/modules/_bundled/sirsoft-board/src/Services/PostService.php
index 676b41a4..5c1727d5 100644
--- a/modules/_bundled/sirsoft-board/src/Services/PostService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/PostService.php
@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Enums\PostStatus;
+use Modules\Sirsoft\Board\Enums\ReplyDeletePolicy;
+use Modules\Sirsoft\Board\Exceptions\PostHasRepliesException;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface;
@@ -376,33 +378,57 @@ class PostService
* ① 게시글 상태 변경(deleted) + 소프트 삭제
* ② 살아있는 댓글을 cascade 로 소프트 삭제 (trigger_type='cascade')
* ③ 살아있는 첨부를 cascade 로 소프트 삭제 (trigger_type='cascade')
+ * ④ 살아있는 자손 답글 전체를 cascade 로 소프트 삭제 (+ 그 답글들의 댓글/첨부)
*
- * 이미 사용자가 직접 삭제한 댓글/첨부(trigger_type='user' 등)는 영향을 받지 않으며,
- * 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다.
- * 위 3단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다.
+ * 게시판 설정 `reply_delete_policy` 가 'block' 이면 살아있는 직계 답글 존재 시
+ * before_delete 훅 발화 전에 차단합니다(부수효과 0). 훅 경유 삭제는
+ * `options['cascade_replies']=true` 로 정책을 우회해 cascade 를 고정합니다
+ * (문의 답변은 시스템 생성이므로 block 정책에 막히면 안 됨).
+ *
+ * 이미 사용자가 직접 삭제한 답글/댓글/첨부(trigger_type='user' 등)는 영향을 받지
+ * 않으며, 게시글 복원 시 cascade 로 지워진 항목만 선택 복원됩니다.
+ * 위 단계는 단일 트랜잭션으로 묶여 부분 실패 시 전체 롤백됩니다.
+ * after_delete 훅은 부모 게시글에 대해서만 발화합니다 (자손별 발화 시 알림 폭주).
*
* @param string $slug 게시판 슬러그
* @param int $id 게시글 ID
* @param string|null $triggerType 트리거 유형 (admin, report 등)
- * @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP)
+ * @param array $options 옵션 배열 (skip_notification: 알림 발송 SKIP, cascade_replies: 답글 삭제 정책 우회)
* @return Post 삭제 처리된 게시글
*
* @throws ModelNotFoundException 게시판 또는 게시글을 찾을 수 없는 경우
+ * @throws PostHasRepliesException block 정책 게시판에서 살아있는 답글이 있는 경우
*/
public function deletePost(string $slug, int $id, ?string $triggerType = null, array $options = []): Post
{
- // 게시판 존재성 검증
- $this->validateBoardExists($slug);
+ // 게시판 존재성 검증 (정책 판정에 게시판 모델이 필요해 직접 조회)
+ $board = $this->boardService->getBoardBySlug($slug, checkScope: false);
+
+ if (! $board) {
+ throw new ModelNotFoundException(__('sirsoft-board::messages.errors.board_not_found'));
+ }
$post = $this->postRepository->findOrFail($slug, $id);
+ // 답글 삭제 정책 판정 — 훅 경유 삭제(cascade_replies 옵션)는 시스템 생성 답글을
+ // 함께 정리해야 하므로 게시판 정책과 무관하게 cascade 고정
+ $policy = ($options['cascade_replies'] ?? false)
+ ? ReplyDeletePolicy::Cascade
+ : ($board->reply_delete_policy ?? ReplyDeletePolicy::Cascade);
+
+ // block 정책: 살아있는 직계 답글이 있으면 before_delete 훅 발화 전에 차단
+ // (차단 시 부수효과 0 — 훅 리스너의 외부 정리가 먼저 실행되면 되돌릴 수 없다)
+ if ($policy === ReplyDeletePolicy::Block && $this->postRepository->hasAliveReplies($slug, $id)) {
+ throw new PostHasRepliesException;
+ }
+
// 훅: before_delete
HookManager::doAction('sirsoft-board.post.before_delete', $post, $slug);
// 작업 이력 생성
$actionLog = $this->buildActionLog('delete', null);
- // 게시글 + 하위 데이터(댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리
+ // 게시글 + 하위 데이터(답글/댓글/첨부) 연쇄 소프트 삭제를 단일 트랜잭션으로 처리
$deletedPost = DB::transaction(function () use ($slug, $id, $actionLog, $triggerType) {
// ① 상태 변경 (deleted로 변경하고 소프트 삭제)
$deletedPost = $this->postRepository->updateStatus($slug, $id, 'deleted', $actionLog, $triggerType);
@@ -414,10 +440,22 @@ class PostService
// ③ 첨부 cascade 소프트 삭제 (사용자가 이미 삭제한 항목은 미영향)
$this->attachmentRepository->softDeleteByPostId($slug, $id);
+ // ④ 살아있는 자손 답글 cascade 소프트 삭제 — block 통과 시에도 실행
+ // (끊긴 체인 밑 과거 고아 답글 스윕. 살아있는 직계가 없어도 고아 자손은 존재 가능)
+ $descendantIds = $this->postRepository->softDeleteCascadeByParentId($slug, $id);
+
+ // ⑤ 함께 지워진 자손 답글들의 댓글/첨부도 cascade 소프트 삭제
+ if ($descendantIds !== []) {
+ $this->commentRepository->softDeleteByPostIds($slug, $descendantIds);
+ $this->attachmentRepository->softDeleteByPostIds($slug, $descendantIds);
+ }
+
return $deletedPost;
});
// 훅: after_delete ($options 전달 — skip_notification 등 수신 리스너에서 활용)
+ // 자손 답글별로는 발화하지 않는다 — 자손별 발화 시 알림이 폭주하며,
+ // 이커머스 문의 피벗은 문의 원글에만 걸려 있어 부모 발화만으로 충분하다.
HookManager::doAction('sirsoft-board.post.after_delete', $deletedPost, $slug, $options);
// 캐시 무효화
@@ -508,6 +546,17 @@ class PostService
// ③ cascade 로 지워진 첨부만 선택 복원
$this->attachmentRepository->restoreCascadedByPostId($slug, $id);
+ // ④ cascade 로 지워진 자손 답글만 top-down 선택 복원 — 정책값과 무관하게 항상 실행
+ // (trigger_type='cascade' 로 마킹된 것만 복원되므로 안전. 사용자 직접 삭제 답글의
+ // 서브트리는 복원되지 않는다)
+ $restoredIds = $this->postRepository->restoreCascadedByParentId($slug, $id);
+
+ // ⑤ 되살아난 자손 답글들의 댓글/첨부도 cascade 분만 복원
+ if ($restoredIds !== []) {
+ $this->commentRepository->restoreCascadedByPostIds($slug, $restoredIds);
+ $this->attachmentRepository->restoreCascadedByPostIds($slug, $restoredIds);
+ }
+
return $restoredPost;
});
@@ -882,9 +931,12 @@ class PostService
$filters['user_id'] = $requestParams['user_id'] ?? null;
$filters['created_at_from'] = $requestParams['created_at_from'] ?? null;
$filters['created_at_to'] = $requestParams['created_at_to'] ?? null;
- } else {
- $filters['exclude_blinded'] = true;
}
+ // 사용자 컨텍스트에는 추가 필터가 없다. 예전에는 여기서 `exclude_blinded` 를 세웠지만
+ // 저장소가 그 키를 읽지 않아 한 번도 적용되지 않았고, 뒤늦게 배선하면 블라인드 글이
+ // 사용자 목록에서 통째로 사라진다 — 이 모듈은 블라인드 글을 **행은 남기고 본문만
+ // 가리는** 방식으로 다루며(PostResource::getMaskedContentPreviewForList) 레이아웃도
+ // 블라인드 배지를 그린다. 죽은 키를 살리는 대신 제거해 표시만 오해를 주던 상태를 없앤다.
// 페이지당 항목 수 계산
$requestedPerPage = isset($requestParams['per_page']) ? (int) $requestParams['per_page'] : null;
@@ -1070,6 +1122,10 @@ class PostService
}
}
+ // 본인 활동 화면(`/me/board-activities`)이므로 열람자 = 대상 본인이다.
+ // 저장소는 이 값이 없으면 타인 관점으로 보고 비밀글·미발행글을 걸러낸다(fail-closed).
+ $filters['viewer_id'] = $userId;
+
$result = $this->postRepository->getUserActivities($userId, $filters, $perPage);
// 캐시 미적중 시 paginate 결과의 total을 캐시에 저장
@@ -1105,9 +1161,12 @@ class PostService
/**
* 사용자의 공개 게시글 목록을 조회합니다 (공개 프로필용).
*
- * 기존 getUserActivities()를 재사용합니다.
- * 타인 프로필에서 해당 사용자의 모든 게시글을 표시합니다 (비밀글/블라인드 포함).
- * UI에서 배지로 비밀글/블라인드 상태를 구분합니다.
+ * 기존 getUserActivities()를 재사용합니다. 이 목록은 본문 일부(content_plain)를 함께
+ * 싣기 때문에, 열람자가 본인이 아니면 비밀글·블라인드 글의 **본문만** 비운다. 행과
+ * 제목은 남는다 — 게시판 목록에서 이미 같은 수준으로 보이고(PostResource 의 목록 규칙:
+ * 제목은 노출, 본문만 차단) 프로필 UI 가 그 배지를 그리므로, 행을 지우면 결함 차단에
+ * 필요한 범위를 넘어 기능이 깎인다.
+ * 판정은 저장소가 `viewer_id` 로 수행한다(fail-closed — 값이 없으면 타인 관점).
*
* @param int $userId 사용자 ID
* @param array $filters 필터 옵션 (board_slug, sort 등)
@@ -1116,10 +1175,9 @@ class PostService
*/
public function getUserPublicPosts(int $userId, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
- // 기존 getUserActivities 재사용
- // 타인 프로필에서 해당 사용자의 모든 게시글 표시 (비밀글/블라인드 포함, 배지로 구분)
return $this->postRepository->getUserActivities($userId, array_merge($filters, [
'activity_type' => 'authored', // 작성글만
+ 'viewer_id' => Auth::id(), // 비로그인은 null → 타인 관점
]), $perPage);
}
diff --git a/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php b/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php
new file mode 100644
index 00000000..414f1841
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Support/SecretContentGate.php
@@ -0,0 +1,84 @@
+user_id && $post->user_id === $user->id) {
+ return true;
+ }
+
+ // 2. 비밀번호 검증 완료 (상세 컨텍스트에서만 세팅됨)
+ if (($post->password_verified ?? false) === true) {
+ return true;
+ }
+
+ // 3-4. 게시판별 권한 체크
+ $slug = $this->resolveSlug($post, $request);
+ if (! $slug) {
+ return false;
+ }
+
+ if ($this->isAdminRequest($request)) {
+ return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
+ || $this->checkBoardPermission($slug, 'admin.manage');
+ }
+
+ return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
+ || $this->checkBoardPermission($slug, 'manager', PermissionType::User);
+ }
+
+ /**
+ * 게시판 슬러그를 해석합니다.
+ *
+ * 라우트 슬러그를 우선 사용하고, 없으면 로드된 board 관계에서 가져옵니다.
+ * board 가 미로딩이면 lazy loading N+1 을 피하기 위해 null 을 반환합니다
+ * (호출부가 fail-closed 마스킹).
+ *
+ * @param Post $post 대상 게시글
+ * @param Request $request HTTP 요청
+ * @return string|null 게시판 슬러그
+ */
+ private function resolveSlug(Post $post, Request $request): ?string
+ {
+ return $request->route('slug') ?? ($post->relationLoaded('board') ? $post->board?->slug : null);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Traits/ChecksBoardPermission.php b/modules/_bundled/sirsoft-board/src/Traits/ChecksBoardPermission.php
index 4372e3d5..2a1cd1a4 100644
--- a/modules/_bundled/sirsoft-board/src/Traits/ChecksBoardPermission.php
+++ b/modules/_bundled/sirsoft-board/src/Traits/ChecksBoardPermission.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Traits;
use App\Enums\PermissionType;
use App\Http\Middleware\PermissionMiddleware;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
@@ -16,6 +17,26 @@ use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys;
*/
trait ChecksBoardPermission
{
+ /**
+ * Admin 화면 요청 여부를 컨트롤러 네임스페이스로 판정합니다.
+ *
+ * 판정 규칙의 단일 지점 — Resource/게이트마다 사설 복제본을 두면 규칙 변경 시
+ * 한 곳만 누락되어 경로별 판정이 갈린다.
+ *
+ * @param Request $request HTTP 요청
+ * @return bool Admin 요청 여부
+ */
+ protected function isAdminRequest(Request $request): bool
+ {
+ $controller = $request->route()?->getController();
+
+ if (! $controller) {
+ return false;
+ }
+
+ return str_contains(get_class($controller), '\\Admin\\');
+ }
+
/**
* 게시판 권한을 확인합니다.
*
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php
index d77bb3b6..4489de1a 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php
@@ -90,6 +90,7 @@ return [
'use_comment' => 'Use Comments',
'use_reply' => 'Use Replies',
'max_reply_depth' => 'Max Reply Depth',
+ 'reply_delete_policy' => 'Reply Delete Policy',
'use_report' => 'Use Reports',
'use_file_upload' => 'Use File Upload',
'max_file_size' => 'Max File Size',
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/enums.php b/modules/_bundled/sirsoft-board/src/lang/en/enums.php
index 1046da3f..a82761a2 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/enums.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/enums.php
@@ -14,6 +14,12 @@ return [
'always' => 'Always Required',
],
+ // 답글 삭제 정책
+ 'reply_delete_policy' => [
+ 'block' => 'Block deletion (cannot delete posts with replies)',
+ 'cascade' => 'Delete together (replies are deleted with the post)',
+ ],
+
// Order Direction
'order_direction' => [
'asc' => 'Ascending',
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
index 925196f5..cf7b2428 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
@@ -44,6 +44,7 @@ return [
'secret_password_incorrect' => 'Secret post password is incorrect.',
// Secret post filtering messages
'secret_post_content' => 'This is a secret post. Please enter the password to view the content.',
+ 'secret_post_title' => 'Secret post',
'deleted_post_title' => 'Deleted post',
'deleted_post_content' => 'This post has been deleted.',
'blinded_post_content' => 'This post has been blinded by administrator.',
@@ -225,6 +226,7 @@ return [
'permission_denied' => 'Permission denied.',
'validation_failed' => 'Validation failed.',
'duplicate_report' => 'You have already reported this content.',
+ 'operation_failed' => 'An error occurred while processing the operation.',
],
// Warning messages
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/validation.php b/modules/_bundled/sirsoft-board/src/lang/en/validation.php
index ae4efe83..b0931c47 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/validation.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/validation.php
@@ -263,6 +263,9 @@ return [
'notice_not_allowed' => 'Replies cannot be created on notice posts.',
],
'reply_not_allowed' => 'Reply feature is disabled for this board.',
+ 'delete' => [
+ 'has_replies' => 'Posts with replies cannot be deleted. Please delete the replies first.',
+ ],
'status' => [
'in' => 'Invalid post status.',
],
@@ -303,6 +306,7 @@ return [
'basic_defaults.use_comment' => 'Use Comment',
'basic_defaults.use_reply' => 'Use Reply',
'basic_defaults.max_reply_depth' => 'Max Reply Depth',
+ 'basic_defaults.reply_delete_policy' => 'Reply Delete Policy',
'basic_defaults.max_comment_depth' => 'Max Comment Depth',
'basic_defaults.comment_order' => 'Comment Order',
'basic_defaults.show_view_count' => 'Show View Count',
@@ -391,6 +395,7 @@ return [
'override_values.per_page' => 'Posts Per Page',
'override_values.per_page_mobile' => 'Posts Per Page (Mobile)',
'override_values.max_reply_depth' => 'Max Reply Depth',
+ 'override_values.reply_delete_policy' => 'Reply Delete Policy',
'override_values.max_comment_depth' => 'Max Comment Depth',
'override_values.min_title_length' => 'Min Title Length',
'override_values.max_title_length' => 'Max Title Length',
@@ -401,6 +406,10 @@ return [
'override_values.max_file_size' => 'Max File Size',
'override_values.max_file_count' => 'Max File Count',
'override_values.new_display_hours' => 'New Display Hours',
+
+ // attachment_settings
+ 'attachment_settings.purge_enabled' => 'Permanent Cleanup Of Deleted Attachments',
+ 'attachment_settings.purge_retention_days' => 'Deleted Attachment Retention Period',
],
],
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php
index c32f08b0..54e30ac9 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php
@@ -90,6 +90,7 @@ return [
'use_comment' => '댓글 사용',
'use_reply' => '답글 사용',
'max_reply_depth' => '최대 답글 깊이',
+ 'reply_delete_policy' => '답글 삭제 방식',
'use_report' => '신고 사용',
'use_file_upload' => '파일 업로드 사용',
'max_file_size' => '최대 파일 크기',
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/enums.php b/modules/_bundled/sirsoft-board/src/lang/ko/enums.php
index 383a9deb..d9c72040 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/enums.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/enums.php
@@ -14,6 +14,12 @@ return [
'always' => '필수 사용',
],
+ // 답글 삭제 정책
+ 'reply_delete_policy' => [
+ 'block' => '삭제 차단 (답글이 있으면 원글 삭제 불가)',
+ 'cascade' => '함께 삭제 (원글 삭제 시 답글도 함께 삭제)',
+ ],
+
// 정렬 방향
'order_direction' => [
'asc' => '오름차순',
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
index 10041c1e..1d25fa58 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
@@ -44,6 +44,7 @@ return [
'secret_password_incorrect' => '비밀글 비밀번호가 일치하지 않습니다.',
// 비밀글 필터링 메시지
'secret_post_content' => '비밀글입니다. 내용을 보려면 비밀번호를 입력해 주세요.',
+ 'secret_post_title' => '비밀글',
'deleted_post_title' => '삭제된 게시글',
'deleted_post_content' => '삭제된 게시글입니다.',
'blinded_post_content' => '관리자에 의해 블라인드 처리된 게시글입니다.',
@@ -225,6 +226,7 @@ return [
'permission_denied' => '권한이 없습니다.',
'validation_failed' => '입력값이 올바르지 않습니다.',
'duplicate_report' => '이미 신고한 내역이 있습니다.',
+ 'operation_failed' => '작업 처리 중 오류가 발생했습니다.',
],
// 경고 메시지 (Warning)
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
index a4f87097..e5f33fa8 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php
@@ -263,6 +263,9 @@ return [
'notice_not_allowed' => '공지사항에는 답글을 작성할 수 없습니다.',
],
'reply_not_allowed' => '이 게시판은 답글 기능이 비활성화되어 있습니다.',
+ 'delete' => [
+ 'has_replies' => '답글이 달린 글은 삭제할 수 없습니다. 답글을 먼저 삭제해 주세요.',
+ ],
'status' => [
'in' => '유효하지 않은 게시글 상태입니다.',
],
@@ -303,6 +306,7 @@ return [
'basic_defaults.use_comment' => '댓글 사용 여부',
'basic_defaults.use_reply' => '답글 사용 여부',
'basic_defaults.max_reply_depth' => '최대 답글 깊이',
+ 'basic_defaults.reply_delete_policy' => '답글 삭제 방식',
'basic_defaults.max_comment_depth' => '최대 댓글 깊이',
'basic_defaults.comment_order' => '댓글 정렬',
'basic_defaults.show_view_count' => '조회수 표시',
@@ -391,6 +395,7 @@ return [
'override_values.per_page' => '페이지당 게시글 수',
'override_values.per_page_mobile' => '모바일 페이지당 게시글 수',
'override_values.max_reply_depth' => '최대 답글 깊이',
+ 'override_values.reply_delete_policy' => '답글 삭제 방식',
'override_values.max_comment_depth' => '최대 댓글 깊이',
'override_values.min_title_length' => '최소 제목 길이',
'override_values.max_title_length' => '최대 제목 길이',
@@ -401,6 +406,10 @@ return [
'override_values.max_file_size' => '최대 파일 크기',
'override_values.max_file_count' => '최대 파일 개수',
'override_values.new_display_hours' => '신규 표시 시간',
+
+ // attachment_settings
+ 'attachment_settings.purge_enabled' => '삭제 첨부 영구 정리',
+ 'attachment_settings.purge_retention_days' => '삭제 첨부 보존기간',
],
],
diff --git a/modules/_bundled/sirsoft-board/src/routes/api.php b/modules/_bundled/sirsoft-board/src/routes/api.php
index 2ca4aee6..a004a833 100644
--- a/modules/_bundled/sirsoft-board/src/routes/api.php
+++ b/modules/_bundled/sirsoft-board/src/routes/api.php
@@ -126,20 +126,24 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
->middleware('permission:admin,sirsoft-board.settings.read')
->name('admin.settings.show');
- // 대시보드 - 오늘 새 글/댓글 현황 (진입 가드는 코어 core.dashboard.read + admin)
+ // 대시보드 - 오늘 새 글/댓글 현황 (형제 settings 라우트와 동일하게 명시적 permission 가드)
Route::get('dashboard/overview', [DashboardController::class, 'overview'])
+ ->middleware('permission:admin,sirsoft-board.dashboard.view')
->name('admin.dashboard.overview');
// 대시보드 - 7일 추세 그래프 (막대 + 합계 + 변화율)
Route::get('dashboard/post-graph', [DashboardController::class, 'postGraph'])
+ ->middleware('permission:admin,sirsoft-board.dashboard.view')
->name('admin.dashboard.post-graph');
// 대시보드 - 최신 게시글
Route::get('dashboard/recent-posts', [DashboardController::class, 'recentPosts'])
+ ->middleware('permission:admin,sirsoft-board.dashboard.view')
->name('admin.dashboard.recent-posts');
// 대시보드 - 미처리 신고
Route::get('dashboard/pending-reports', [DashboardController::class, 'pendingReports'])
+ ->middleware('permission:admin,sirsoft-board.dashboard.view')
->name('admin.dashboard.pending-reports');
});
@@ -504,8 +508,8 @@ Route::post('boards/{slug}/comments/{commentId}/verify-password', [UserCommentCo
| 로그인한 회원의 게시글 활동(작성, 댓글, 신고)을 조회하는 API입니다.
| - 회원만 접근 가능하므로 auth:sanctum 미들웨어 필요
|
-| 최종 URL 예시: /api/me/board-activities
-| 최종 Name 예시: api.me.board-activities.index
+| 최종 URL 예시: /api/modules/sirsoft-board/me/board-activities
+| 최종 Name 예시: api.modules.sirsoft-board.me.board-activities.index
|
*/
Route::get('/me/board-activities', [UserActivityController::class, 'index'])
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php
index ce3ac68f..198210f2 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardDeletionTest.php
@@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Contracts\Extension\StorageInterface;
+use App\Extension\HookManager;
use App\Extension\Storage\ModuleStorageDriver;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@@ -61,6 +62,7 @@ class BoardDeletionTest extends ModuleTestCase
* 현행(soft delete) 코드에서는 withTrashed count 가 0 이 아니어서 fail (red).
*
* @scenario child_type=post
+ *
* @effects post_records_force_deleted,comment_records_force_deleted,attachment_records_force_deleted,board_itself_force_deleted
*/
public function test_deleting_board_force_deletes_child_records(): void
@@ -117,6 +119,7 @@ class BoardDeletionTest extends ModuleTestCase
* 남아 fail (red), slug 기반 삭제로 수정하면 green.
*
* @scenario child_type=attachment
+ *
* @effects attachment_physical_files_removed
*/
public function test_deleting_board_removes_attachment_physical_files(): void
@@ -146,6 +149,7 @@ class BoardDeletionTest extends ModuleTestCase
* 게시판이 사라져도 신고 레코드는 보존되고 board_id 만 NULL 이 된다.
*
* @scenario child_type=report
+ *
* @effects reports_preserved_with_null_board_id
*/
public function test_deleting_board_preserves_reports(): void
@@ -173,6 +177,7 @@ class BoardDeletionTest extends ModuleTestCase
* 댓글·첨부 등 하위 데이터가 영향받지 않음을 board 격리 관점에서 함께 보장)
*
* @scenario child_type=comment
+ *
* @effects other_board_files_not_affected
*/
public function test_deleting_board_does_not_affect_other_board_files(): void
@@ -197,6 +202,66 @@ class BoardDeletionTest extends ModuleTestCase
$this->boardService->deleteBoard($boardB->id);
}
+ /**
+ * 게시판 삭제 시 posts.before_force_delete 훅이 게시판 인스턴스와 함께
+ * 전체 글 ID(soft-deleted 포함)를 페이로드로 발화하는지 검증합니다.
+ *
+ * BoardService::deleteBoard 는 posts forceDelete 직전에 withTrashed 전체를
+ * 1,000건 chunk 당 1회 발화합니다 — 수집된 ID 합집합이 전체 글 ID 와 일치해야
+ * 연동 확장(이커머스 문의 피벗 정리 등)이 누락 없이 통지받습니다.
+ */
+ public function test_fires_posts_before_force_delete_hook_with_all_ids(): void
+ {
+ $board = $this->createTestBoard('del-test-5');
+
+ // 일반 글 + soft-deleted 글 (둘 다 물리 삭제 대상이므로 훅 페이로드에 포함되어야 함)
+ $alivePost = Post::create([
+ 'board_id' => $board->id,
+ 'title' => '살아있는 게시글',
+ 'content' => '본문',
+ 'status' => PostStatus::Published,
+ 'ip_address' => '127.0.0.1',
+ ]);
+
+ $trashedPost = Post::create([
+ 'board_id' => $board->id,
+ 'title' => '소프트 삭제된 게시글',
+ 'content' => '본문',
+ 'status' => PostStatus::Deleted,
+ 'ip_address' => '127.0.0.1',
+ ]);
+ $trashedPost->delete();
+
+ $receivedIds = [];
+ $receivedBoardIds = [];
+
+ // 훅 스파이 — ModuleTestCase 가 setUp/tearDown 에서 HookManager 상태를
+ // 스냅샷/복원하므로 별도 정리는 불필요
+ HookManager::addAction(
+ 'sirsoft-board.board.posts.before_force_delete',
+ function ($hookBoard, $postIds) use (&$receivedIds, &$receivedBoardIds) {
+ $receivedIds = array_merge($receivedIds, $postIds);
+ $receivedBoardIds[] = $hookBoard->id;
+ }
+ );
+
+ $this->boardService->deleteBoard($board->id);
+
+ // 수집된 ID 합집합 = 전체 글 ID (중복 발화 가능성 대비 unique 후 비교)
+ $union = array_values(array_unique($receivedIds));
+ sort($union);
+ $expected = [$alivePost->id, $trashedPost->id];
+ sort($expected);
+
+ $this->assertSame($expected, $union, '훅 페이로드 ID 합집합이 soft-deleted 포함 전체 글 ID 와 일치해야 합니다.');
+
+ // board 인자 전달 검증
+ $this->assertNotEmpty($receivedBoardIds, '훅이 최소 1회 발화되어야 합니다.');
+ foreach (array_unique($receivedBoardIds) as $receivedBoardId) {
+ $this->assertSame($board->id, $receivedBoardId, '훅의 첫 인자는 삭제 대상 게시판이어야 합니다.');
+ }
+ }
+
/**
* 테스트용 게시판을 생성합니다.
*
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php
new file mode 100644
index 00000000..ea87cd51
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardIndexSortTest.php
@@ -0,0 +1,138 @@
+ 'basic'],
+ ['name' => ['ko' => '기본', 'en' => 'Basic'], 'is_active' => true, 'is_default' => true]
+ );
+
+ $this->adminUser = $this->createAdminUser([
+ 'sirsoft-board.boards.read',
+ ]);
+ }
+
+ /**
+ * 화이트리스트 밖 정렬 컬럼(sort_by=settings) → 422.
+ */
+ public function test_rejects_unknown_sort_column(): void
+ {
+ $this->actingAs($this->adminUser)
+ ->getJson(self::INDEX_URL.'?sort_by=settings')
+ ->assertStatus(422);
+ }
+
+ /**
+ * per_page 상한(999) 초과(per_page=1000) → 422.
+ */
+ public function test_rejects_out_of_range_per_page(): void
+ {
+ $this->actingAs($this->adminUser)
+ ->getJson(self::INDEX_URL.'?per_page=1000')
+ ->assertStatus(422);
+ }
+
+ /**
+ * 전체 목록 셀렉트 소비처의 per_page 를 게이트가 수용한다 (회귀).
+ *
+ * 게시판은 운영자 등록 설정성 테이블이라 레이아웃이 사실상 전량 조회를 쓴다:
+ * - 게시판 설정 화면(admin_board_settings.json): per_page=999
+ * - 쇼핑몰 환경설정 문의 카드(admin-ecommerce-inquiry-settings.json): per_page=200
+ * 게이트를 100 으로 조였을 때 두 화면 진입이 422 토스트로 깨졌다 (#573 회귀).
+ */
+ public function test_accepts_full_list_consumer_per_page(): void
+ {
+ foreach ([200, 999] as $perPage) {
+ $this->actingAs($this->adminUser)
+ ->getJson(self::INDEX_URL.'?per_page='.$perPage)
+ ->assertStatus(200);
+ }
+ }
+
+ /**
+ * 화이트리스트 컬럼 정렬(sort_by=slug&sort_order=asc)은 200 + 순서 보장.
+ */
+ public function test_sorts_by_whitelisted_column(): void
+ {
+ $prefix = 'sort-'.uniqid();
+
+ // 생성 순서를 정렬 기대 순서와 다르게 섞는다 (기본 정렬 fallback 오탐 방지)
+ foreach (['c', 'a', 'b'] as $suffix) {
+ Board::create([
+ 'name' => ['ko' => "정렬 테스트 {$suffix}"],
+ 'slug' => "{$prefix}-{$suffix}",
+ 'type' => 'basic',
+ 'is_active' => true,
+ ]);
+ }
+
+ $rows = $this->actingAs($this->adminUser)
+ ->getJson(self::INDEX_URL."?search={$prefix}&sort_by=slug&sort_order=asc")
+ ->assertStatus(200)
+ ->json('data.data');
+
+ $slugs = array_column($rows, 'slug');
+
+ $this->assertSame(
+ ["{$prefix}-a", "{$prefix}-b", "{$prefix}-c"],
+ $slugs,
+ 'slug 오름차순 정렬이 적용되어야 합니다.'
+ );
+ }
+
+ /**
+ * BoardService::getBoards 직접 호출(게이트 우회 내부 호출)에 미허용 정렬 컬럼을
+ * 넘겨도 예외 없이 기본 정렬로 fallback 한다 (ResolvesSortSpec 이중 방어).
+ */
+ public function test_service_ignores_unlisted_sort_column(): void
+ {
+ $this->actingAs($this->adminUser);
+
+ $result = app(BoardService::class)->getBoards([
+ 'sort_by' => 'evil',
+ 'sort_order' => 'asc',
+ ]);
+
+ $this->assertInstanceOf(
+ LengthAwarePaginator::class,
+ $result,
+ '미허용 정렬 컬럼은 예외 없이 기본 정렬로 fallback 되어야 합니다.'
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php
index 5e0504de..2c2f331f 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsControllerTest.php
@@ -144,13 +144,14 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
- // 저장된 값이 파일에 반영되었는지 확인
- $filePath = $this->settingsStoragePath.'/basic_defaults.json';
- $this->assertFileExists($filePath);
+ // 저장 결과는 조회 API 로 확인한다 — settings 디스크는 테스트 하네스가 페이크로
+ // 대체하므로 실제 storage 경로를 직접 읽으면 저장 성공에도 파일이 없다.
+ $saved = $this->actingAs($this->adminUser)
+ ->getJson('/api/modules/sirsoft-board/admin/settings/basic_defaults');
- $content = json_decode(File::get($filePath), true);
- $this->assertEquals(30, $content['per_page']);
- $this->assertEquals(10, $content['per_page_mobile']);
+ $saved->assertStatus(200);
+ $this->assertEquals(30, $saved->json('data.settings.per_page'));
+ $this->assertEquals(10, $saved->json('data.settings.per_page_mobile'));
}
/**
@@ -170,13 +171,13 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
- // 저장된 값이 파일에 반영되었는지 확인
- $filePath = $this->settingsStoragePath.'/notifications.json';
- $this->assertFileExists($filePath);
+ // 저장 결과는 조회 API 로 확인한다 (settings 디스크 페이크 — 위 주석 참조).
+ $saved = $this->actingAs($this->adminUser)
+ ->getJson('/api/modules/sirsoft-board/admin/settings/notifications');
- $content = json_decode(File::get($filePath), true);
- $this->assertIsArray($content['channels']);
- $this->assertEquals('mail', $content['channels'][0]['id']);
+ $saved->assertStatus(200);
+ $this->assertIsArray($saved->json('data.settings.channels'));
+ $this->assertEquals('mail', $saved->json('data.settings.channels.0.id'));
}
/**
@@ -617,8 +618,11 @@ class BoardSettingsControllerTest extends ModuleTestCase
$response->assertStatus(200);
- $content = json_decode(File::get($this->settingsStoragePath.'/report_policy.json'), true);
- $this->assertSame(0, (int) $content['auto_hide_threshold']);
+ $saved = $this->actingAs($this->adminUser)
+ ->getJson('/api/modules/sirsoft-board/admin/settings/report_policy');
+
+ $saved->assertStatus(200);
+ $this->assertSame(0, (int) $saved->json('data.settings.auto_hide_threshold'));
}
/**
@@ -759,4 +763,49 @@ class BoardSettingsControllerTest extends ModuleTestCase
$board->refresh();
$this->assertEquals(10, $board->per_page);
}
+
+ /**
+ * 첨부 정리 설정이 general 탭 저장으로 실제 반영되는지 확인 (회귀)
+ *
+ * 화면은 general 탭에서 이 카테고리를 함께 제출한다. 서버가 카테고리를 허용하지 않으면
+ * 저장은 200 인데 값만 사라져, 운영자가 토글을 켜도 정리가 영원히 꺼진 상태로 남는다.
+ */
+ public function test_attachment_cleanup_settings_are_saved_on_general_tab(): void
+ {
+ $response = $this->actingAs($this->adminUser)
+ ->putJson('/api/modules/sirsoft-board/admin/settings', [
+ '_tab' => 'general',
+ 'display' => ['date_display_format' => 'standard'],
+ 'attachment_settings' => [
+ 'purge_enabled' => true,
+ 'purge_retention_days' => 15,
+ ],
+ ]);
+
+ $response->assertStatus(200);
+
+ $followUp = $this->actingAs($this->adminUser)
+ ->getJson('/api/modules/sirsoft-board/admin/settings/attachment_settings');
+
+ $followUp->assertStatus(200);
+ $this->assertTrue($followUp->json('data.settings.purge_enabled'));
+ $this->assertSame(15, $followUp->json('data.settings.purge_retention_days'));
+ }
+
+ /**
+ * 보존기간 하한(1일) 미만은 거부되는지 확인 — 사용자 파일을 파기하는 설정이므로.
+ */
+ public function test_attachment_purge_retention_days_below_minimum_is_rejected(): void
+ {
+ $response = $this->actingAs($this->adminUser)
+ ->putJson('/api/modules/sirsoft-board/admin/settings', [
+ '_tab' => 'general',
+ 'attachment_settings' => [
+ 'purge_enabled' => true,
+ 'purge_retention_days' => 0,
+ ],
+ ]);
+
+ $response->assertStatus(422);
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsSeoCacheInvalidationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsSeoCacheInvalidationTest.php
new file mode 100644
index 00000000..db024d88
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardSettingsSeoCacheInvalidationTest.php
@@ -0,0 +1,180 @@
+
+ */
+ private array $received = [];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->adminUser = $this->createAdminUser([
+ 'sirsoft-board.settings.read',
+ 'sirsoft-board.settings.update',
+ ]);
+
+ $this->received = [];
+ HookManager::addAction('core.module_settings.after_save', function (...$args) {
+ $this->received[] = $args;
+ }, 5);
+ }
+
+ /**
+ * 게시판 설정 저장 API 가 모듈 설정 저장 훅을 발화한다. (실패-먼저)
+ *
+ * @scenario save_path=bulk
+ *
+ * @effects module_settings_after_save_hook_fired
+ */
+ public function test_settings_save_fires_module_settings_after_save_hook(): void
+ {
+ $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'basic_defaults',
+ 'basic_defaults' => ['per_page' => 30],
+ ])->assertOk();
+
+ $this->assertCount(1, $this->received, '모듈 설정 저장 훅이 발화되지 않았습니다.');
+ [$identifier, $settings, $result] = $this->received[0];
+
+ $this->assertSame('sirsoft-board', $identifier);
+ $this->assertArrayHasKey('basic_defaults', $settings);
+ $this->assertTrue($result);
+ }
+
+ /**
+ * 게시판 SEO 캐시 리스너가 일괄 적용 훅도 구독한다. (실패-먼저)
+ *
+ * @scenario save_path=bulk_apply
+ *
+ * @effects seo_cache_invalidated_on_bulk_apply
+ */
+ public function test_seo_listener_subscribes_bulk_apply_hook(): void
+ {
+ $hooks = SeoBoardSettingsCacheListener::getSubscribedHooks();
+
+ $this->assertArrayHasKey(
+ 'sirsoft-board.settings.after_bulk_apply',
+ $hooks,
+ '일괄 적용 경로가 SEO 캐시 무효화를 거치지 않습니다.'
+ );
+ $this->assertSame('onBulkApply', $hooks['sirsoft-board.settings.after_bulk_apply']['method']);
+ }
+
+ /**
+ * 일괄 적용 훅 핸들러는 식별자 인자 없이도 안전하게 동작한다.
+ *
+ * `after_bulk_apply` payload 는 ($fields, $updatedCount) 라 모듈 식별자가 없다 —
+ * 식별자 가드를 그대로 재사용하면 조용히 무효화가 건너뛰어진다.
+ *
+ * @scenario save_path=bulk_apply
+ *
+ * @effects seo_cache_invalidated_on_bulk_apply
+ */
+ public function test_bulk_apply_handler_invalidates_without_identifier_arg(): void
+ {
+ $listener = new SeoBoardSettingsCacheListener;
+
+ $this->assertTrue(
+ method_exists($listener, 'onBulkApply'),
+ '일괄 적용 전용 핸들러가 없습니다.'
+ );
+
+ // 예외 없이 완료되어야 한다 (내부 실패는 로깅으로 흡수)
+ $listener->onBulkApply(['per_page' => 20], 3);
+ $this->addToAssertionCount(1);
+ }
+
+ // ─── 실제 무효화 수행 ──────────────────────────────────────
+
+ /**
+ * 일괄 적용 핸들러가 게시판 레이아웃 캐시를 실제로 지운다.
+ *
+ * 예외가 안 나는 것과 무효화를 수행하는 것은 다르다 — 리스너 본문이 통째로 비어도
+ * "예외 없음" 은 그대로 통과한다. 캐시 매니저를 mock 해 호출 자체를 단언한다.
+ *
+ * @scenario save_path=bulk_apply
+ *
+ * @effects seo_cache_invalidated_on_bulk_apply
+ */
+ public function test_bulk_apply_actually_invalidates_board_layouts(): void
+ {
+ $cache = Mockery::mock(SeoCacheManagerInterface::class);
+ foreach (['board/index', 'board/show', 'board/boards'] as $layout) {
+ $cache->shouldReceive('invalidateByLayout')->with($layout)->once();
+ }
+ $cache->shouldReceive('invalidateByLayout')->andReturnNull();
+ $this->app->instance(SeoCacheManagerInterface::class, $cache);
+
+ (new SeoBoardSettingsCacheListener)->onBulkApply(['per_page' => 20], 3);
+
+ $this->addToAssertionCount(1);
+ }
+
+ /**
+ * 설정 저장 훅도 같은 무효화를 수행한다.
+ *
+ * @scenario save_path=bulk
+ *
+ * @effects seo_cache_invalidated_on_module_settings_save
+ */
+ public function test_settings_save_actually_invalidates_board_layouts(): void
+ {
+ $cache = Mockery::mock(SeoCacheManagerInterface::class);
+ $cache->shouldReceive('invalidateByLayout')->with('board/index')->atLeast()->once();
+ $cache->shouldReceive('invalidateByLayout')->andReturnNull();
+ $this->app->instance(SeoCacheManagerInterface::class, $cache);
+
+ $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'basic_defaults',
+ 'basic_defaults' => ['per_page' => 30],
+ ])->assertOk();
+
+ $this->addToAssertionCount(1);
+ }
+
+ /**
+ * 다른 모듈의 저장 훅에는 반응하지 않는다.
+ *
+ * @scenario save_path=bulk
+ *
+ * @effects seo_cache_untouched_for_other_module
+ */
+ public function test_other_module_save_does_not_invalidate_board_layouts(): void
+ {
+ $cache = Mockery::mock(SeoCacheManagerInterface::class);
+ $cache->shouldNotReceive('invalidateByLayout');
+ $this->app->instance(SeoCacheManagerInterface::class, $cache);
+
+ (new SeoBoardSettingsCacheListener)->onModuleSettingsSave('sirsoft-ecommerce', ['seo' => []], true);
+
+ $this->addToAssertionCount(1);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
index 13c85a40..221fcbe7 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
+use Modules\Sirsoft\Board\Services\BoardTypeService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
class BoardTypeManagementTest extends ModuleTestCase
@@ -258,6 +259,64 @@ class BoardTypeManagementTest extends ModuleTestCase
}
}
+ /**
+ * 삭제 불가 응답의 메시지가 예외 원문이 아니라 해석된 다국어 문구여야 한다.
+ *
+ * 종전에는 예외 메시지(이미 번역된 문장)를 다시 메시지 키 자리에 넘겨서, 키 해석에
+ * 실패한 원문이 그대로 사용자 화면에 나갔다. 상태코드만 보는 단언은 이 결함을
+ * 통과시킨다 — 422 는 그대로였기 때문이다.
+ *
+ * @scenario error_class=domain
+ *
+ * @effects board_type_delete_domain_exception_returns_422_with_resolved_message
+ */
+ public function test_delete_in_use_response_message_is_resolved_key_not_exception_text(): void
+ {
+ $boardType = BoardType::create([
+ 'slug' => 'test_in_use_msg',
+ 'name' => ['ko' => '사용중 유형'],
+ ]);
+
+ Board::factory()->create(['type' => 'test_in_use_msg']);
+
+ $response = $this->actingAs($this->adminUser)
+ ->deleteJson("/api/modules/sirsoft-board/admin/board-types/{$boardType->id}");
+
+ $response->assertStatus(422);
+
+ $expected = __('sirsoft-board::messages.board_type.delete_in_use', ['count' => 1]);
+ $this->assertSame($expected, $response->json('message'));
+ // 키 문자열 자체가 노출되면(해석 실패) 그것도 결함이다
+ $this->assertStringNotContainsString('sirsoft-board::', (string) $response->json('message'));
+ }
+
+ /**
+ * 인프라 예외는 422 로 뭉개지 않고 500 으로 구분하며 예외 원문을 노출하지 않는다.
+ *
+ * @scenario error_class=infrastructure
+ *
+ * @effects board_type_delete_infrastructure_exception_returns_500
+ */
+ public function test_delete_infrastructure_exception_returns_500(): void
+ {
+ $boardType = BoardType::create([
+ 'slug' => 'test_infra_500',
+ 'name' => ['ko' => '인프라 예외 유형'],
+ ]);
+
+ $this->mock(BoardTypeService::class, function ($mock) {
+ $mock->shouldReceive('deleteBoardType')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->adminUser)
+ ->deleteJson("/api/modules/sirsoft-board/admin/board-types/{$boardType->id}");
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
/**
* 존재하지 않는 유형 삭제 시 404 테스트
*/
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php
index eddeb818..f888e524 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php
@@ -33,9 +33,8 @@ class DashboardApiTest extends ModuleTestCase
BoardStat::query()->delete();
- // isAdmin() 은 admin 타입 권한 보유 여부로 판정되므로 임의 권한을 부여한다.
- // 대시보드 라우트는 admin 미들웨어만 사용 (별도 permission 가드 없음).
- $this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read']);
+ // 대시보드 라우트는 admin 미들웨어 + permission:admin,sirsoft-board.dashboard.view 가드를 사용한다.
+ $this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']);
$this->normalUser = $this->createUser();
Board::create([
@@ -59,6 +58,11 @@ class DashboardApiTest extends ModuleTestCase
$this->actingAs($this->normalUser)->getJson(self::BASE.'/overview')->assertStatus(403);
}
+ /**
+ * @scenario case=dashboard_permission_gate
+ *
+ * @effects dashboard_requires_view_permission
+ */
#[Test]
public function test_overview_returns_today_counts_for_admin(): void
{
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php
new file mode 100644
index 00000000..eaec1924
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReplyDeletePolicyTest.php
@@ -0,0 +1,430 @@
+ $slug,
+ 'name' => ['ko' => '답글 삭제 정책 테스트 게시판', 'en' => 'Reply Delete Policy Board'],
+ 'is_active' => true,
+ 'secret_mode' => 'disabled',
+ 'blocked_keywords' => [],
+ // 이전 테스트가 block 으로 바꾼 값이 잔류하지 않도록 매 테스트 기본값으로 리셋
+ 'reply_delete_policy' => 'cascade',
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->postService = app(PostService::class);
+
+ $slug = $this->board->slug;
+
+ $this->adminWithManage = $this->createAdminUser([
+ "sirsoft-board.{$slug}.admin.manage",
+ ]);
+
+ // 사용자 삭제 경로(회원 본인 글) 검증용
+ $this->grantUserRolePermissions(['posts.read', 'posts.write']);
+ }
+
+ // ==========================================
+ // Helper
+ // ==========================================
+
+ /**
+ * 관리자 게시글 API URL 을 반환합니다.
+ *
+ * @param string $suffix URL 접미사
+ * @return string 관리자 게시글 API URL
+ */
+ private function adminUrl(string $suffix): string
+ {
+ return "/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts{$suffix}";
+ }
+
+ /**
+ * 사용자 게시글 API URL 을 반환합니다.
+ *
+ * @param string $suffix URL 접미사
+ * @return string 사용자 게시글 API URL
+ */
+ private function userUrl(string $suffix): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts{$suffix}";
+ }
+
+ /**
+ * 답글(자식 게시글)을 직접 DB 에 생성합니다.
+ *
+ * @param int $parentId 부모 게시글 ID
+ * @param int $depth 답글 깊이
+ * @param array $attributes 덮어쓸 속성
+ * @return int 생성된 답글 ID
+ */
+ private function createReply(int $parentId, int $depth = 1, array $attributes = []): int
+ {
+ return $this->createTestPost(array_merge([
+ 'title' => "답글 (depth {$depth})",
+ 'parent_id' => $parentId,
+ 'depth' => $depth,
+ ], $attributes));
+ }
+
+ /**
+ * 첨부파일을 직접 DB 에 생성합니다 (PostDeleteCascadeTest 와 동일 패턴).
+ *
+ * @param int $postId 게시글 ID
+ * @param array $attributes 덮어쓸 속성
+ * @return int 생성된 첨부 ID
+ */
+ private function createTestAttachment(int $postId, array $attributes = []): int
+ {
+ static $seq = 0;
+ $seq++;
+
+ $defaults = [
+ 'board_id' => $this->board->id,
+ 'post_id' => $postId,
+ 'hash' => 'r'.str_pad((string) $seq, 11, '0', STR_PAD_LEFT),
+ 'original_filename' => 'test.png',
+ 'stored_filename' => 'stored-'.$seq.'.png',
+ 'disk' => 'local',
+ 'path' => 'attachments/reply-'.$seq.'.png',
+ 'mime_type' => 'image/png',
+ 'size' => 1024,
+ 'collection' => 'default',
+ 'order' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ];
+
+ return DB::table('board_attachments')->insertGetId(array_merge($defaults, $attributes));
+ }
+
+ /**
+ * 게시글 행을 조회합니다.
+ *
+ * @param int $postId 게시글 ID
+ * @return object|null 게시글 행
+ */
+ private function postRow(int $postId): ?object
+ {
+ return DB::table('board_posts')->where('id', $postId)->first();
+ }
+
+ // ==========================================
+ // cascade 정책 (기본)
+ // ==========================================
+
+ /**
+ * cascade 정책: 부모 삭제 시 깊이 3 답글 트리 전체 + 자손별 댓글/첨부가
+ * cascade soft delete + trigger_type='cascade' + status='deleted' 로 마킹된다.
+ */
+ public function test_cascade_policy_soft_deletes_all_descendant_replies(): void
+ {
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $reply1 = $this->createReply($parentId, 1);
+ $reply2 = $this->createReply($reply1, 2);
+ $reply3 = $this->createReply($reply2, 3);
+
+ $commentIds = [];
+ $attachmentIds = [];
+ foreach ([$reply1, $reply2, $reply3] as $replyId) {
+ $commentIds[$replyId] = $this->createTestComment($replyId);
+ $attachmentIds[$replyId] = $this->createTestAttachment($replyId);
+ }
+
+ $this->postService->deletePost($this->board->slug, $parentId, 'admin');
+
+ foreach ([$reply1, $reply2, $reply3] as $replyId) {
+ $row = $this->postRow($replyId);
+ $this->assertNotNull($row->deleted_at, "자손 답글 {$replyId} 이 soft delete 되어야 합니다.");
+ $this->assertSame('cascade', $row->trigger_type, "자손 답글 {$replyId} 이 cascade 로 마킹되어야 합니다.");
+ $this->assertSame('deleted', $row->status, "자손 답글 {$replyId} 의 status 가 deleted 여야 합니다.");
+
+ $comment = DB::table('board_comments')->where('id', $commentIds[$replyId])->first();
+ $this->assertNotNull($comment->deleted_at, "자손 답글 {$replyId} 의 댓글이 soft delete 되어야 합니다.");
+ $this->assertSame('cascade', $comment->trigger_type);
+
+ $attachment = DB::table('board_attachments')->where('id', $attachmentIds[$replyId])->first();
+ $this->assertNotNull($attachment->deleted_at, "자손 답글 {$replyId} 의 첨부가 soft delete 되어야 합니다.");
+ $this->assertSame('cascade', $attachment->trigger_type);
+ }
+ }
+
+ /**
+ * 복원 시 cascade 로 지워졌던 자손 답글 + 그 댓글/첨부가 함께 복원된다.
+ */
+ public function test_cascade_restore_restores_descendants_and_their_children(): void
+ {
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $reply1 = $this->createReply($parentId, 1);
+ $reply2 = $this->createReply($reply1, 2);
+
+ $commentId = $this->createTestComment($reply1);
+ $attachmentId = $this->createTestAttachment($reply2);
+
+ $this->postService->deletePost($this->board->slug, $parentId, 'admin');
+ $this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin');
+
+ foreach ([$reply1, $reply2] as $replyId) {
+ $row = $this->postRow($replyId);
+ $this->assertNull($row->deleted_at, "cascade 자손 답글 {$replyId} 은 복원되어야 합니다.");
+ $this->assertSame('published', $row->status, "복원된 자손 답글 {$replyId} 의 status 가 published 여야 합니다.");
+ }
+
+ $comment = DB::table('board_comments')->where('id', $commentId)->first();
+ $this->assertNull($comment->deleted_at, 'cascade 자손 답글의 댓글도 복원되어야 합니다.');
+
+ $attachment = DB::table('board_attachments')->where('id', $attachmentId)->first();
+ $this->assertNull($attachment->deleted_at, 'cascade 자손 답글의 첨부도 복원되어야 합니다.');
+ }
+
+ /**
+ * 사용자 직접 삭제(trigger 'user') 로 먼저 지워진 중간 자손의 서브트리는
+ * 부모 삭제 → 복원 후에도 trashed 로 유지된다 (고아 재생성 금지).
+ */
+ public function test_user_predeleted_reply_subtree_stays_trashed_after_restore(): void
+ {
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $reply1 = $this->createReply($parentId, 1);
+ $reply2 = $this->createReply($reply1, 2);
+
+ // 중간 자손(reply1)을 사용자 삭제 경로로 먼저 삭제 → reply1 trigger 'user',
+ // 그 자식 reply2 는 reply1 삭제의 cascade 로 trashed
+ $this->postService->deletePost($this->board->slug, $reply1, 'user');
+
+ $this->assertSame('user', $this->postRow($reply1)->trigger_type, '테스트 전제: reply1 은 user 삭제여야 합니다.');
+ $this->assertNotNull($this->postRow($reply2)->deleted_at, '테스트 전제: reply2 는 reply1 삭제로 trashed 여야 합니다.');
+
+ // 부모 삭제 → 복원
+ $this->postService->deletePost($this->board->slug, $parentId, 'admin');
+ $this->postService->restorePost($this->board->slug, $parentId, '복원', 'admin');
+
+ $this->assertNull($this->postRow($parentId)->deleted_at, '부모 글은 복원되어야 합니다.');
+
+ $reply1Row = $this->postRow($reply1);
+ $this->assertNotNull($reply1Row->deleted_at, '사용자가 직접 지운 답글은 복원되면 안 됩니다.');
+ $this->assertSame('user', $reply1Row->trigger_type, '사용자 삭제 trigger_type 은 유지되어야 합니다.');
+
+ $this->assertNotNull(
+ $this->postRow($reply2)->deleted_at,
+ '사용자 삭제 답글의 서브트리도 trashed 로 유지되어야 합니다 (고아 재생성 금지).'
+ );
+ }
+
+ /**
+ * cascade 는 이미 trashed 인 자손(trigger 'user')을 변조하지 않는다
+ * (trigger_type 유지 + deleted_at 불변).
+ */
+ public function test_cascade_skips_already_trashed_descendants(): void
+ {
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $this->createReply($parentId, 1); // 살아있는 답글 — cascade 가 실제로 수행되는 전제
+
+ $preDeletedAt = now()->subHour();
+ $preDeletedReplyId = $this->createReply($parentId, 1, [
+ 'status' => 'deleted',
+ 'trigger_type' => 'user',
+ 'deleted_at' => $preDeletedAt,
+ ]);
+
+ $before = $this->postRow($preDeletedReplyId);
+
+ $this->postService->deletePost($this->board->slug, $parentId, 'admin');
+
+ $after = $this->postRow($preDeletedReplyId);
+ $this->assertSame('user', $after->trigger_type, '이미 trashed 인 자손의 trigger_type 은 유지되어야 합니다.');
+ $this->assertSame($before->deleted_at, $after->deleted_at, '이미 trashed 인 자손의 deleted_at 은 불변이어야 합니다.');
+ }
+
+ // ==========================================
+ // block 정책
+ // ==========================================
+
+ /**
+ * block 정책: 살아있는 답글이 있는 글의 관리자 삭제 → 422 + 정책 메시지, 부수효과 0.
+ */
+ public function test_block_policy_returns_422_with_message_when_alive_reply_exists(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $replyId = $this->createReply($parentId, 1);
+
+ $response = $this->actingAs($this->adminWithManage)
+ ->deleteJson($this->adminUrl("/{$parentId}"));
+
+ $response->assertStatus(422);
+ $this->assertSame(
+ __('sirsoft-board::validation.post.delete.has_replies'),
+ $response->json('message'),
+ '답글 삭제 정책 차단 메시지가 응답되어야 합니다.'
+ );
+
+ // 차단 시 부수효과 0: 부모/답글 모두 미삭제
+ $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.');
+ $this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.');
+ }
+
+ /**
+ * block 정책: 답글이 전부 trashed 면 삭제가 허용된다.
+ */
+ public function test_block_policy_allows_delete_when_replies_all_trashed(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $this->createReply($parentId, 1, [
+ 'status' => 'deleted',
+ 'trigger_type' => 'user',
+ 'deleted_at' => now()->subHour(),
+ ]);
+
+ $this->actingAs($this->adminWithManage)
+ ->deleteJson($this->adminUrl("/{$parentId}"))
+ ->assertStatus(200);
+
+ $this->assertNotNull($this->postRow($parentId)->deleted_at, '답글이 전부 trashed 면 부모 글 삭제가 허용되어야 합니다.');
+ }
+
+ /**
+ * block 정책은 사용자 삭제 경로(회원 본인 글)에도 동일하게 적용된다 → 422.
+ */
+ public function test_user_delete_path_applies_policy(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $owner = $this->createUser();
+ $parentId = $this->createTestPost([
+ 'status' => 'published',
+ 'user_id' => $owner->id,
+ 'author_name' => $owner->name,
+ ]);
+ $this->createReply($parentId, 1);
+
+ $response = $this->actingAs($owner)
+ ->deleteJson($this->userUrl("/{$parentId}"));
+
+ $response->assertStatus(422);
+ $this->assertSame(
+ __('sirsoft-board::validation.post.delete.has_replies'),
+ $response->json('message')
+ );
+ $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 글은 삭제되면 안 됩니다.');
+ }
+
+ /**
+ * 신고 조치 삭제(trigger 'report')에도 block 정책이 동일하게 적용된다.
+ * 정책 우회는 오직 cascade_replies 옵션(훅 경유)뿐 — trigger 문자열은 우회 수단이 아니다.
+ */
+ public function test_report_trigger_delete_applies_block_policy(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $replyId = $this->createReply($parentId, 1);
+
+ try {
+ $this->postService->deletePost($this->board->slug, $parentId, 'report');
+ $this->fail('block 정책에서는 신고 조치 삭제도 PostHasRepliesException 으로 차단되어야 합니다.');
+ } catch (PostHasRepliesException $e) {
+ // 기대 경로: ReportService::deleteContent 의 기존 generic catch 가 이 예외를
+ // 조치 실패 사유로 로그 변환한다 (계획서 WP2 §차단 예외 — 정책 적용 유지).
+ }
+
+ $this->assertNull($this->postRow($parentId)->deleted_at, '차단된 부모 글은 삭제되면 안 됩니다.');
+ $this->assertNull($this->postRow($replyId)->deleted_at, '차단 시 답글도 영향받지 않아야 합니다.');
+ }
+
+ /**
+ * 끊긴 체인 밑 고아 답글: 직계 답글이 전부 trashed 라 block 정책이 삭제를 허용하는
+ * 경우에도 cascade 스윕이 실행되어, trashed 자손 밑에 살아남아 있던 고아 답글을
+ * 함께 정리한다 (계획서 WP2 §PostService — "block 통과 시에도 실행").
+ */
+ public function test_orphan_reply_under_trashed_chain_is_swept_on_delete(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $trashedChildId = $this->createReply($parentId, 1, [
+ 'status' => 'deleted',
+ 'trigger_type' => 'user',
+ 'deleted_at' => now()->subHour(),
+ ]);
+ $orphanGrandchildId = $this->createReply($trashedChildId, 2); // 끊긴 체인 밑에 살아있는 고아
+
+ // 직계 답글이 전부 trashed → block 정책이 삭제를 허용
+ $this->actingAs($this->adminWithManage)
+ ->deleteJson($this->adminUrl("/{$parentId}"))
+ ->assertStatus(200);
+
+ $orphanRow = $this->postRow($orphanGrandchildId);
+ $this->assertNotNull($orphanRow->deleted_at, '끊긴 체인 밑 고아 답글도 cascade 스윕으로 정리되어야 합니다.');
+ $this->assertSame('cascade', $orphanRow->trigger_type, '고아 답글은 cascade 로 마킹되어야 합니다.');
+ $this->assertSame('deleted', $orphanRow->status);
+
+ // 사용자 선삭제 중간 노드는 불변 (trigger_type/deleted_at 유지)
+ $this->assertSame('user', $this->postRow($trashedChildId)->trigger_type, '기존 trashed 중간 노드는 변조되면 안 됩니다.');
+ }
+
+ /**
+ * 훅 경유 삭제(options cascade_replies=true)는 block 정책을 우회하고
+ * 자손 답글까지 cascade 로 함께 정리한다 (시스템 생성 답변 정리 보장).
+ */
+ public function test_ecommerce_hook_delete_bypasses_block_policy(): void
+ {
+ $this->updateBoardSettings(['reply_delete_policy' => 'block']);
+
+ $parentId = $this->createTestPost(['status' => 'published']);
+ $replyId = $this->createReply($parentId, 1);
+
+ $this->postService->deletePost($this->board->slug, $parentId, options: ['cascade_replies' => true]);
+
+ $this->assertNotNull($this->postRow($parentId)->deleted_at, 'cascade_replies 옵션은 block 정책을 우회해야 합니다.');
+
+ $replyRow = $this->postRow($replyId);
+ $this->assertNotNull($replyRow->deleted_at, '자손 답글도 함께 cascade 삭제되어야 합니다.');
+ $this->assertSame('cascade', $replyRow->trigger_type);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php
new file mode 100644
index 00000000..af2b36e5
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php
@@ -0,0 +1,211 @@
+update(['is_active' => false]);
+ Cache::flush();
+
+ $this->readableBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '공개', 'en' => 'Readable']]);
+ $this->hiddenBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '비공개', 'en' => 'Hidden']]);
+
+ // 읽기 가능한 게시판: 공개글 + 비밀글
+ $this->insertPost($this->readableBoard->id, '공개글읽기가능', 100, false, PostStatus::Published->value);
+ $this->insertPost($this->readableBoard->id, '비밀글읽기가능', 90, true, PostStatus::Published->value);
+ // 읽기 불가능한 게시판: 공개글
+ $this->insertPost($this->hiddenBoard->id, '숨김게시판글', 200, false, PostStatus::Published->value);
+
+ // 비회원 읽기 권한은 readableBoard 에만 부여
+ $this->grantRead('guest', $this->readableBoard->slug);
+ PermissionMiddleware::clearGuestRoleCache();
+ }
+
+ // ========== N-3 최근글 ==========
+
+ /**
+ * @scenario case=recent_readable_filter
+ *
+ * @effects unreadable_board_titles_absent_for_caller
+ */
+ #[Test]
+ public function guest_recent_posts_shows_readable_titles_including_secret_but_excludes_unreadable_board(): void
+ {
+ $titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20'));
+
+ $this->assertContains('공개글읽기가능', $titles);
+ // 비밀글도 제목은 공개된다(본문만 보호) — 2026-02-04 확정 정책.
+ $this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 최근글에 노출된다(본문만 보호).');
+ $this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 노출되면 안 된다.');
+ }
+
+ /**
+ * @scenario case=recent_readable_filter
+ *
+ * @effects unreadable_board_titles_absent_for_caller
+ */
+ #[Test]
+ public function low_permission_member_recent_posts_excludes_unreadable_board_titles(): void
+ {
+ $member = $this->memberWithReadOn($this->readableBoard->slug);
+
+ $titles = $this->titles(
+ $this->actingAs($member)->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20')
+ );
+
+ $this->assertContains('공개글읽기가능', $titles);
+ $this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 노출된다(본문만 보호).');
+ $this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 저권한 회원에게도 노출되면 안 된다.');
+ }
+
+ // ========== N-4 인기글 ==========
+
+ /**
+ * @scenario case=popular_readable_filter
+ *
+ * @effects unreadable_board_titles_absent_for_caller
+ */
+ #[Test]
+ public function guest_popular_posts_excludes_unreadable_board_titles(): void
+ {
+ $titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/popular?limit=20'));
+
+ $this->assertContains('공개글읽기가능', $titles);
+ $this->assertNotContains('숨김게시판글', $titles, '인기글도 읽기 권한 없는 게시판 글을 제외해야 한다.');
+ // 인기글은 비밀글도 원래 제외한다(2026-01-16 컨벤션).
+ $this->assertNotContains('비밀글읽기가능', $titles);
+ }
+
+ // ========== 게시판 디렉토리(boards index + 내장 최근글) ==========
+ //
+ // 최근글·인기글·인기게시판·대시보드에는 열람 권한 필터가 적용되었지만,
+ // 게시판 디렉토리(GET /boards?limit=N)는 활성 게시판 전체 + 내장 최근글 제목을
+ // 무필터로 직렬화해 우회로가 남아 있었다 (7.0.7 사전점검 SEO 봇 렌더 실측 발견 —
+ // 게스트 홈 화면에 회원전용 게시판 글 제목이 노출).
+
+ /**
+ * @scenario case=board_directory_readable_filter
+ *
+ * @effects board_directory_excludes_unreadable_board_and_posts
+ */
+ #[Test]
+ public function guest_board_directory_excludes_unreadable_board_and_its_post_titles(): void
+ {
+ $response = $this->getJson('/api/modules/sirsoft-board/boards?limit=3');
+ $response->assertStatus(200);
+
+ $slugs = array_column($response->json('data'), 'slug');
+ $this->assertContains($this->readableBoard->slug, $slugs);
+ $this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 디렉토리에 노출되면 안 된다.');
+
+ // 내장 최근글 제목까지 전수 검사 — 게시판 행이 아닌 다른 경로로도 새면 안 된다
+ $raw = json_encode($response->json('data'), JSON_UNESCAPED_UNICODE);
+ $this->assertStringContainsString('공개글읽기가능', $raw);
+ $this->assertStringNotContainsString('숨김게시판글', $raw, '읽기 권한 없는 게시판의 글 제목이 디렉토리 응답에 노출되면 안 된다.');
+ }
+
+ /**
+ * @scenario case=board_directory_readable_filter
+ *
+ * @effects board_directory_includes_readable_board_for_permitted_member
+ */
+ #[Test]
+ public function member_with_read_permission_sees_restricted_board_in_directory(): void
+ {
+ $member = $this->memberWithReadOn($this->hiddenBoard->slug);
+
+ $response = $this->actingAs($member)->getJson('/api/modules/sirsoft-board/boards?limit=3');
+ $response->assertStatus(200);
+
+ $slugs = array_column($response->json('data'), 'slug');
+ $this->assertContains($this->hiddenBoard->slug, $slugs, '열람 권한을 가진 회원에게는 해당 게시판이 디렉토리에 보여야 한다.');
+
+ $raw = json_encode($response->json('data'), JSON_UNESCAPED_UNICODE);
+ $this->assertStringContainsString('숨김게시판글', $raw, '열람 권한자는 그 게시판의 최근글 제목도 볼 수 있어야 한다.');
+ }
+
+ // ========== 헬퍼 ==========
+
+ /**
+ * @param TestResponse $response
+ * @return array 응답 데이터의 title 목록
+ */
+ private function titles($response): array
+ {
+ $response->assertStatus(200);
+
+ return array_column($response->json('data'), 'title');
+ }
+
+ private function insertPost(int $boardId, string $title, int $viewCount, bool $isSecret, string $status): void
+ {
+ DB::table('board_posts')->insert([
+ 'board_id' => $boardId,
+ 'title' => $title,
+ 'content' => '내용',
+ 'author_name' => '작성자',
+ 'view_count' => $viewCount,
+ 'is_secret' => $isSecret,
+ 'status' => $status,
+ 'ip_address' => '127.0.0.1',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function grantRead(string $roleIdentifier, string $slug): void
+ {
+ $role = Role::where('identifier', $roleIdentifier)->first();
+ if (! $role) {
+ return;
+ }
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $role->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+
+ private function memberWithReadOn(string $slug): User
+ {
+ $member = User::factory()->create();
+ $userRole = Role::where('identifier', 'user')->first();
+ $member->roles()->attach($userRole->id);
+ $this->grantRead('user', $slug);
+ PermissionMiddleware::clearGuestRoleCache();
+
+ return $member;
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php
new file mode 100644
index 00000000..6d586d90
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php
@@ -0,0 +1,233 @@
+ $slug,
+ 'name' => ['ko' => '댓글 검증 토큰 테스트', 'en' => 'Comment Verify Token'],
+ 'is_active' => true,
+ 'use_comment' => true,
+ 'comment_order' => 'DESC',
+ 'min_comment_length' => 2,
+ 'max_comment_length' => 1000,
+ 'secret_mode' => 'disabled',
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ private int $postId;
+
+ private int $commentId;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ // 비회원이 verify/수정/삭제에 접근하려면 comments.write + comments.read 권한 필요
+ $this->setGuestPermissions(['posts.read', 'comments.read', 'comments.write']);
+
+ $this->postId = $this->createTestPost([
+ 'title' => '댓글 대상 게시글',
+ 'status' => 'published',
+ ]);
+
+ // 비회원(guest) 댓글 — 비밀번호 보호
+ $this->commentId = $this->createTestComment($this->postId, [
+ 'user_id' => null,
+ 'author_name' => '비회원',
+ 'content' => '원본 댓글 내용',
+ 'password' => Hash::make('pw1234'),
+ ]);
+
+ $this->resetPermissionMiddlewareCache();
+ }
+
+ /**
+ * 올바른 비밀번호 확인 시 verification_token 이 발급된다.
+ *
+ * @return string 발급된 토큰
+ */
+ private function issueToken(): string
+ {
+ $response = $this->postJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$this->commentId}/verify-password",
+ ['password' => 'pw1234']
+ );
+
+ $response->assertStatus(200)
+ ->assertJsonPath('data.verified', true);
+
+ $token = $response->json('data.verification_token');
+ $this->assertNotEmpty($token, 'verify-password 는 verification_token 을 발급해야 한다.');
+
+ return $token;
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function verify_password_issues_token(): void
+ {
+ $token = $this->issueToken();
+
+ $this->assertIsString($token);
+ $this->assertMatchesRegularExpression('/^[A-Za-z0-9]{32}$/', $token);
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function update_succeeds_with_valid_token(): void
+ {
+ $token = $this->issueToken();
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['content' => '토큰으로 수정한 댓글', 'verification_token' => $token]
+ );
+
+ $response->assertStatus(200);
+
+ $this->assertSame(
+ '토큰으로 수정한 댓글',
+ DB::table('board_comments')->where('id', $this->commentId)->value('content')
+ );
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function delete_succeeds_with_valid_token(): void
+ {
+ $token = $this->issueToken();
+
+ $response = $this->deleteJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['verification_token' => $token]
+ );
+
+ $response->assertStatus(200);
+
+ $this->assertNotNull(
+ DB::table('board_comments')->where('id', $this->commentId)->value('deleted_at'),
+ '토큰 삭제 후 댓글은 소프트 삭제되어야 한다.'
+ );
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function update_is_rejected_with_wrong_token(): void
+ {
+ $this->issueToken(); // 실제 토큰은 발급하되, 요청에는 다른 값을 보낸다
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['content' => '잘못된 토큰 수정 시도', 'verification_token' => str_repeat('x', 32)]
+ );
+
+ $response->assertStatus(403);
+
+ $this->assertSame(
+ '원본 댓글 내용',
+ DB::table('board_comments')->where('id', $this->commentId)->value('content'),
+ '잘못된 토큰이면 내용이 변경되면 안 된다.'
+ );
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function token_is_single_use(): void
+ {
+ $token = $this->issueToken();
+
+ // 1회차: 성공
+ $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['content' => '첫 수정', 'verification_token' => $token]
+ )->assertStatus(200);
+
+ // 2회차: 같은 토큰 재사용 → 소비되어 거부
+ $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['content' => '두 번째 수정', 'verification_token' => $token]
+ )->assertStatus(403);
+
+ $this->assertSame(
+ '첫 수정',
+ DB::table('board_comments')->where('id', $this->commentId)->value('content'),
+ '소비된 토큰의 재사용은 반영되면 안 된다.'
+ );
+ }
+
+ /**
+ * @scenario case=comment_token_lifecycle
+ *
+ * @effects comment_verify_token_single_use_lifecycle
+ */
+ #[Test]
+ public function expired_token_is_rejected(): void
+ {
+ $token = $this->issueToken();
+
+ // 토큰 TTL(기본 1시간)을 넘겨 이동 → 캐시에서 만료
+ $this->travel(2)->hours();
+
+ try {
+ $this->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
+ ['content' => '만료 토큰 수정 시도', 'verification_token' => $token]
+ )->assertStatus(403);
+ } finally {
+ $this->travelBack();
+ }
+
+ $this->assertSame(
+ '원본 댓글 내용',
+ DB::table('board_comments')->where('id', $this->commentId)->value('content'),
+ '만료 토큰이면 내용이 변경되면 안 된다.'
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php
new file mode 100644
index 00000000..6061bd81
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Console/PruneAttachmentsCommandTest.php
@@ -0,0 +1,339 @@
+ $values 설정 키 => 값
+ */
+ private function fakeModuleSettings(array $values): void
+ {
+ $service = Mockery::mock(ModuleSettingsService::class);
+
+ $service->shouldReceive('get')
+ ->andReturnUsing(function (string $identifier, ?string $key = null, mixed $default = null) use ($values) {
+ return array_key_exists($key, $values) ? $values[$key] : $default;
+ });
+
+ $this->app->instance(ModuleSettingsService::class, $service);
+ }
+
+ /**
+ * 첨부 파일과 기록을 함께 만듭니다.
+ *
+ * @param array $attributes 덮어쓸 속성
+ * @return Attachment 생성된 첨부
+ */
+ private function makeAttachment(array $attributes = []): Attachment
+ {
+ $filename = uniqid('board-att-').'.png';
+ $path = $attributes['path'] ?? "free/temp/tempkey/{$filename}";
+
+ (new ModuleStorageDriver('sirsoft-board', 'modules'))->put('attachments', $path, 'bytes');
+
+ return Attachment::create(array_merge([
+ 'board_id' => 0,
+ 'post_id' => null,
+ 'temp_key' => 'tempkey',
+ 'original_filename' => $filename,
+ 'stored_filename' => $filename,
+ 'disk' => 'modules',
+ 'path' => $path,
+ 'mime_type' => 'image/png',
+ 'size' => 5,
+ 'collection' => 'attachments',
+ 'order' => 1,
+ ], $attributes));
+ }
+
+ /**
+ * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
+ *
+ * @effects board_temp_prune_deletes_file_and_record
+ */
+ #[Test]
+ public function stale_temp_attachments_lose_both_file_and_record(): void
+ {
+ $attachment = $this->makeAttachment();
+ $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
+
+ $this->assertFalse(
+ (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
+ );
+ $this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
+ }
+
+ /**
+ * @scenario age=within_retention, attachment_state=temp_unlinked, purge_toggle=off
+ *
+ * @effects board_temp_prune_keeps_within_retention
+ */
+ #[Test]
+ public function temp_attachment_within_retention_is_kept(): void
+ {
+ $attachment = $this->makeAttachment();
+
+ $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
+
+ $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
+ }
+
+ /**
+ * 게시글에 연결된(temp_key 가 비워진) 첨부는 임시 정리 대상이 아니다.
+ *
+ * @scenario age=past_retention, attachment_state=linked, purge_toggle=off
+ *
+ * @effects board_temp_prune_skips_linked
+ */
+ #[Test]
+ public function linked_attachment_is_never_treated_as_temporary(): void
+ {
+ $attachment = $this->makeAttachment([
+ 'board_id' => 1,
+ 'post_id' => 1,
+ 'temp_key' => null,
+ 'path' => 'free/2026/08/14/'.uniqid('linked-').'.png',
+ ]);
+ $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
+
+ $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
+ }
+
+ /**
+ * @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=off
+ *
+ * @effects board_purge_gate_off_by_default
+ */
+ #[Test]
+ public function scheduled_run_does_not_purge_soft_deleted_when_the_toggle_is_off(): void
+ {
+ $this->fakeModuleSettings(['attachment_settings.purge_enabled' => false]);
+
+ $attachment = $this->makeAttachment([
+ 'board_id' => 1,
+ 'post_id' => 1,
+ 'temp_key' => null,
+ 'path' => 'free/2026/08/14/'.uniqid('soft-').'.png',
+ ]);
+ $attachment->delete();
+ $attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
+
+ $this->assertNotNull(Attachment::withTrashed()->find($attachment->id));
+ }
+
+ /**
+ * @scenario age=past_retention, attachment_state=soft_deleted, purge_toggle=on
+ *
+ * @effects board_purge_gate_on
+ */
+ #[Test]
+ public function scheduled_run_purges_soft_deleted_when_the_toggle_is_on(): void
+ {
+ $this->fakeModuleSettings([
+ 'attachment_settings.purge_enabled' => true,
+ 'attachment_settings.purge_retention_days' => 30,
+ ]);
+
+ $attachment = $this->makeAttachment([
+ 'board_id' => 1,
+ 'post_id' => 1,
+ 'temp_key' => null,
+ 'path' => 'free/2026/08/14/'.uniqid('purge-').'.png',
+ ]);
+ $path = $attachment->path;
+ $attachment->delete();
+ $attachment->forceFill(['deleted_at' => now()->subDays(90)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
+
+ $this->assertNull(Attachment::withTrashed()->find($attachment->id));
+ $this->assertFalse(
+ (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $path)
+ );
+ }
+
+ /**
+ * 보존기간 안의 소프트 삭제 첨부는 복원 가능해야 하므로 파기하지 않는다.
+ *
+ * @scenario age=within_retention, attachment_state=soft_deleted, purge_toggle=on
+ *
+ * @effects board_purge_keeps_restorable_window
+ */
+ #[Test]
+ public function soft_deleted_attachment_within_retention_stays_restorable(): void
+ {
+ $this->fakeModuleSettings([
+ 'attachment_settings.purge_enabled' => true,
+ 'attachment_settings.purge_retention_days' => 30,
+ ]);
+
+ $attachment = $this->makeAttachment([
+ 'board_id' => 1,
+ 'post_id' => 1,
+ 'temp_key' => null,
+ 'path' => 'free/2026/08/14/'.uniqid('recent-').'.png',
+ ]);
+ $attachment->delete();
+
+ $this->artisan('sirsoft-board:prune-attachments --scheduled')->assertSuccessful();
+
+ $trashed = Attachment::withTrashed()->find($attachment->id);
+ $this->assertNotNull($trashed);
+ $this->assertTrue(
+ (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path),
+ '복원 가능 기간 안에는 파일도 남아 있어야 한다.'
+ );
+ }
+
+ /**
+ * @effects board_prune_dry_run_no_delete
+ */
+ #[Test]
+ public function dry_run_reports_targets_without_deleting(): void
+ {
+ $attachment = $this->makeAttachment();
+ $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments --dry-run')
+ ->expectsOutputToContain('[DRY RUN]')
+ ->assertSuccessful();
+
+ $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
+ }
+
+ /**
+ * @effects board_temp_prune_days_guard
+ */
+ #[Test]
+ public function temp_retention_below_one_day_performs_no_cleanup(): void
+ {
+ $attachment = $this->makeAttachment();
+ $attachment->forceFill(['created_at' => now()->subDays(90)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments --temp-days=0 --purge-days=0')
+ ->assertSuccessful();
+
+ $this->assertDatabaseHas('board_attachments', ['id' => $attachment->id]);
+ }
+
+ /**
+ * 파일을 비운 temp_key 디렉토리는 남기지 않는다 (회귀)
+ *
+ * 파일만 지우고 디렉토리를 남기면 폼 세션마다 빈 디렉토리가 쌓여, 정리를 돌려도
+ * 저장소 흔적은 계속 늘어난다.
+ *
+ * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
+ *
+ * @effects board_temp_prune_removes_empty_directory
+ */
+ #[Test]
+ public function emptied_temp_directory_is_removed(): void
+ {
+ $attachment = $this->makeAttachment(['path' => 'free/temp/tempkey-empty-dir/'.uniqid('t-').'.png']);
+ $attachment->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
+
+ $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
+
+ $storage = new ModuleStorageDriver('sirsoft-board', 'modules');
+
+ $this->assertSame(
+ [],
+ $storage->files('attachments', 'free/temp/tempkey-empty-dir'),
+ '디렉토리 안의 파일은 모두 삭제되어야 한다.'
+ );
+ $this->assertDirectoryDoesNotExist(
+ rtrim($storage->getBasePath('attachments'), '/\\').'/free/temp/tempkey-empty-dir',
+ '비워진 temp 디렉토리는 남기지 않는다.'
+ );
+ }
+
+ /**
+ * 같은 temp_key 에 처리되지 않은 파일이 남아 있으면 디렉토리를 지우지 않는다.
+ *
+ * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
+ *
+ * @effects board_temp_prune_keeps_directory_with_remaining_files
+ */
+ #[Test]
+ public function temp_directory_with_remaining_files_is_kept(): void
+ {
+ $directory = 'free/temp/tempkey-partial';
+
+ $processed = $this->makeAttachment(['path' => $directory.'/'.uniqid('done-').'.png']);
+ $processed->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
+
+ // 같은 디렉토리에 이번 회차 대상이 아닌 파일이 남아 있는 상태
+ $storage = new ModuleStorageDriver('sirsoft-board', 'modules');
+ $storage->put('attachments', $directory.'/keep.png', 'bytes');
+
+ $this->artisan('sirsoft-board:prune-attachments')->assertSuccessful();
+
+ $this->assertTrue($storage->exists('attachments', $directory.'/keep.png'));
+ }
+
+ /**
+ * 대상이 한 회차 상한을 넘어도 누락 없이 전수 처리된다 (경계).
+ *
+ * 순회는 `--limit` 으로 잘린 단일 윈도우를 훑는다. 처리하며 행을 지우므로, 남은 대상이
+ * 다음 회차에서 반드시 잡혀야 적체가 수렴한다 — 커서가 밀려 영영 건너뛰는 행이 있으면
+ * 정리를 매일 돌려도 잔존물이 남는다.
+ *
+ * @scenario age=past_retention, attachment_state=temp_unlinked, purge_toggle=off
+ *
+ * @effects board_temp_prune_processes_all_across_runs
+ */
+ #[Test]
+ public function targets_beyond_the_limit_are_processed_on_the_next_run(): void
+ {
+ $attachments = [];
+
+ for ($i = 0; $i < 3; $i++) {
+ $attachment = $this->makeAttachment([
+ 'path' => "free/temp/tempkey-batch-{$i}/".uniqid('b-').'.png',
+ ]);
+ $attachment->forceFill(['created_at' => now()->subDays(5 + $i)])->saveQuietly();
+ $attachments[] = $attachment;
+ }
+
+ // 1회차: 상한 2건까지만 처리된다
+ $this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
+
+ $remaining = Attachment::withTrashed()
+ ->whereIn('id', array_column($attachments, 'id'))
+ ->count();
+
+ $this->assertSame(1, $remaining, '한 회차는 --limit 건까지만 처리한다.');
+
+ // 2회차: 남은 1건이 반드시 잡힌다 (커서 밀림으로 건너뛰지 않는다)
+ $this->artisan('sirsoft-board:prune-attachments --limit=2')->assertSuccessful();
+
+ foreach ($attachments as $attachment) {
+ $this->assertDatabaseMissing('board_attachments', ['id' => $attachment->id]);
+ $this->assertFalse(
+ (new ModuleStorageDriver('sirsoft-board', 'modules'))->exists('attachments', $attachment->path)
+ );
+ }
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php
new file mode 100644
index 00000000..c1057dfe
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php
@@ -0,0 +1,164 @@
+adminWithout = $this->createIsolatedAdminWithoutDashboardView();
+ // adminWith: dashboard.view 보유 (공유 admin 역할 사용 — 프로덕션 기본 상태와 동일)
+ $this->adminWith = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']);
+ }
+
+ /**
+ * dashboard.view 가 없는 권한-격리 관리자(고유 역할)를 생성합니다.
+ *
+ * admin 가드(isAdmin) 통과를 위해 admin.access 를, 무해한 admin 권한으로
+ * boards.read 를 부여하되 dashboard.view 는 부여하지 않습니다.
+ *
+ * @return User 대시보드 권한이 없는 관리자
+ */
+ private function createIsolatedAdminWithoutDashboardView(): User
+ {
+ $user = User::factory()->create();
+
+ $role = Role::create([
+ 'identifier' => 'board-dash-guard-'.$user->id,
+ 'name' => ['ko' => '대시보드 미보유 관리자', 'en' => 'Admin without dashboard'],
+ ]);
+ $user->roles()->attach($role->id);
+
+ foreach (['admin.access', 'sirsoft-board.boards.read'] as $identifier) {
+ $permission = Permission::firstOrCreate(
+ ['identifier' => $identifier],
+ ['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => PermissionType::Admin],
+ );
+ $role->permissions()->syncWithoutDetaching([$permission->id]);
+ }
+
+ return $user;
+ }
+
+ // ========== ⑱-1 permission 가드 ==========
+
+ /**
+ * @scenario case=dashboard_permission_gate
+ *
+ * @effects dashboard_requires_view_permission
+ */
+ #[Test]
+ public function admin_without_dashboard_view_is_forbidden_on_all_endpoints(): void
+ {
+ foreach (self::ENDPOINTS as $endpoint) {
+ $this->actingAs($this->adminWithout)
+ ->getJson(self::BASE.'/'.$endpoint)
+ ->assertStatus(403);
+ }
+ }
+
+ /**
+ * @scenario case=dashboard_permission_gate
+ *
+ * @effects dashboard_requires_view_permission
+ */
+ #[Test]
+ public function admin_with_dashboard_view_can_access_all_endpoints(): void
+ {
+ foreach (self::ENDPOINTS as $endpoint) {
+ $this->actingAs($this->adminWith)
+ ->getJson(self::BASE.'/'.$endpoint)
+ ->assertStatus(200);
+ }
+ }
+
+ /**
+ * @scenario case=dashboard_permission_gate
+ *
+ * @effects dashboard_requires_view_permission
+ */
+ #[Test]
+ public function unauthenticated_request_is_rejected(): void
+ {
+ foreach (self::ENDPOINTS as $endpoint) {
+ $this->getJson(self::BASE.'/'.$endpoint)->assertStatus(401);
+ }
+ }
+
+ // ========== N-5 미처리 신고 board-active 스코프 ==========
+
+ /**
+ * @scenario case=pending_reports_scope
+ *
+ * @effects pending_reports_scoped_to_active_boards
+ */
+ #[Test]
+ public function pending_reports_excludes_inactive_board_reports(): void
+ {
+ $activeBoard = Board::factory()->create(['is_active' => true]);
+ $inactiveBoard = Board::factory()->create(['is_active' => false]);
+
+ $this->createPendingReport($activeBoard);
+ $this->createPendingReport($inactiveBoard);
+
+ $response = $this->actingAs($this->adminWith)
+ ->getJson(self::BASE.'/pending-reports');
+
+ $response->assertStatus(200);
+
+ // 비활성 게시판 신고는 제외되어 총 1건, 목록도 1건이어야 한다.
+ $this->assertSame(1, $response->json('data.total'), '미처리 신고 총계는 활성 게시판 신고만 세야 한다.');
+ $this->assertCount(1, $response->json('data.items'), '미처리 신고 목록은 활성 게시판 신고만 담아야 한다.');
+ }
+
+ /**
+ * 지정 게시판에 미처리(pending) 신고 케이스를 생성합니다.
+ *
+ * @param Board $board 대상 게시판
+ * @return Report 생성된 신고
+ */
+ private function createPendingReport(Board $board): Report
+ {
+ return Report::create([
+ 'board_id' => $board->id,
+ 'target_type' => 'post',
+ 'target_id' => 1,
+ 'status' => ReportStatus::Pending,
+ 'last_reported_at' => now(),
+ ]);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php
new file mode 100644
index 00000000..b296edfb
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php
@@ -0,0 +1,81 @@
+admin = $this->createAdminUser(['sirsoft-board.dashboard.view']);
+ }
+
+ /**
+ * @scenario case=dashboard_recent_visibility
+ *
+ * @effects dashboard_summary_excludes_hidden_posts
+ */
+ #[Test]
+ public function recent_posts_shows_secret_but_excludes_blinded_and_inactive_board_posts(): void
+ {
+ $activeBoard = Board::factory()->create(['is_active' => true]);
+ $inactiveBoard = Board::factory()->create(['is_active' => false]);
+
+ $this->insertPost($activeBoard->id, '공개 발행글', ['is_secret' => false, 'status' => PostStatus::Published->value]);
+ $this->insertPost($activeBoard->id, '비밀글', ['is_secret' => true, 'status' => PostStatus::Published->value]);
+ $this->insertPost($activeBoard->id, '블라인드글', ['is_secret' => false, 'status' => PostStatus::Blinded->value]);
+ $this->insertPost($inactiveBoard->id, '비활성게시판글', ['is_secret' => false, 'status' => PostStatus::Published->value]);
+
+ $response = $this->actingAs($this->admin)->getJson(self::ENDPOINT.'?limit=20');
+ $response->assertStatus(200);
+
+ $titles = array_column($response->json('data'), 'title');
+
+ $this->assertContains('공개 발행글', $titles, '공개 발행글은 최신글에 노출되어야 한다.');
+ $this->assertContains('비밀글', $titles, '비밀글은 제목 공개 정책에 따라 관리자 대시보드에 노출되어야 한다.');
+ $this->assertNotContains('블라인드글', $titles, '블라인드글은 최신글에서 제외되어야 한다.');
+ $this->assertNotContains('비활성게시판글', $titles, '비활성 게시판 글은 최신글에서 제외되어야 한다.');
+ }
+
+ /**
+ * 게시글을 직접 삽입합니다.
+ *
+ * @param int $boardId 게시판 ID
+ * @param string $title 제목
+ * @param array $attributes 추가 속성
+ */
+ private function insertPost(int $boardId, string $title, array $attributes = []): void
+ {
+ DB::table('board_posts')->insert(array_merge([
+ 'board_id' => $boardId,
+ 'title' => $title,
+ 'content' => '내용',
+ 'author_name' => '작성자',
+ 'ip_address' => '127.0.0.1',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $attributes));
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php
index 39500b8d..39fafa33 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/FormRequestValidationTest.php
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
+use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
@@ -32,6 +33,16 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
*
* UpdatePostRequest:
* - title 최소 길이 미만 → 422
+ * - password 배열 주입 → 422
+ *
+ * DestroyCommentRequest:
+ * - password 배열 주입 → 422
+ * - password 최소 길이(4) 미만 → 422
+ * - 회원 본인 댓글 password 없이 삭제 → 200
+ *
+ * DestroyPostRequest:
+ * - verification_token 배열 주입 → 422
+ * - 비회원 글 올바른 password 로 삭제 → 200
*
* @group board
* @group feature
@@ -300,4 +311,111 @@ class FormRequestValidationTest extends BoardTestCase
['title' => '가'] // min_title_length=2 미만
)->assertStatus(422);
}
+
+ /**
+ * 게시글 수정에 password 배열 주입 → 422 (string 규칙 차단)
+ */
+ public function test_post_update_rejects_array_password(): void
+ {
+ $user = $this->createUser();
+ $postId = $this->createTestPost(['user_id' => $user->id]);
+
+ $this->actingAs($user)->putJson(
+ "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}",
+ [
+ 'title' => '정상 제목',
+ 'password' => ['x'], // 배열 주입
+ ]
+ )->assertStatus(422);
+ }
+
+ // ==========================================
+ // DestroyCommentRequest
+ // ==========================================
+
+ /**
+ * 댓글 삭제에 password 배열 주입 → 422 (string 규칙 차단)
+ */
+ public function test_comment_destroy_rejects_array_password(): void
+ {
+ $postId = $this->createTestPost();
+ $commentId = $this->createTestComment($postId, [
+ 'password' => Hash::make('pass1234'),
+ ]);
+
+ $this->deleteJson(
+ $this->commentUrl($postId)."/{$commentId}",
+ ['password' => ['x']] // 배열 주입
+ )->assertStatus(422);
+ }
+
+ /**
+ * 댓글 삭제에 최소 길이(4) 미만 password → 422
+ */
+ public function test_comment_destroy_rejects_too_short_password(): void
+ {
+ $postId = $this->createTestPost();
+ $commentId = $this->createTestComment($postId, [
+ 'password' => Hash::make('pass1234'),
+ ]);
+
+ $this->deleteJson(
+ $this->commentUrl($postId)."/{$commentId}",
+ ['password' => '123'] // min:4 미만
+ )->assertStatus(422);
+ }
+
+ /**
+ * 회원 본인 댓글은 password 없이 삭제 가능 → 200
+ * (password 는 nullable — 비회원 소유권 확인용으로만 요구)
+ */
+ public function test_member_comment_destroy_without_password_succeeds(): void
+ {
+ $user = $this->createUser();
+ $postId = $this->createTestPost();
+ $commentId = $this->createTestComment($postId, [
+ 'user_id' => $user->id,
+ 'author_name' => $user->name,
+ ]);
+
+ $this->actingAs($user)
+ ->deleteJson($this->commentUrl($postId)."/{$commentId}")
+ ->assertStatus(200);
+ }
+
+ // ==========================================
+ // DestroyPostRequest
+ // ==========================================
+
+ /**
+ * 게시글 삭제에 verification_token 배열 주입 → 422 (string 규칙 차단)
+ */
+ public function test_post_destroy_rejects_array_verification_token(): void
+ {
+ $postId = $this->createTestPost([
+ 'user_id' => null,
+ 'password' => Hash::make('pass1234'),
+ ]);
+
+ $this->deleteJson(
+ $this->postUrl()."/{$postId}",
+ ['verification_token' => ['x']] // 배열 주입
+ )->assertStatus(422);
+ }
+
+ /**
+ * 비회원 글은 올바른 password 로 삭제 가능 → 200
+ */
+ public function test_guest_post_destroy_with_valid_password_succeeds(): void
+ {
+ $postId = $this->createTestPost([
+ 'user_id' => null,
+ 'password' => Hash::make('pass1234'),
+ ]);
+
+ $this->deleteJson(
+ $this->postUrl()."/{$postId}",
+ ['password' => 'pass1234']
+ )->assertStatus(200);
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php
index 8c6d580b..a8158f4f 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/ReportNotificationTest.php
@@ -5,14 +5,19 @@ namespace Modules\Sirsoft\Board\Tests\Feature;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
+use App\Extension\Helpers\NotificationSyncHelper;
+use App\Extension\ModuleManager;
+use App\Listeners\NotificationHookListener;
+use App\Models\Permission;
+use App\Models\Role;
use App\Models\User;
+use App\Notifications\GenericNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Report;
-use Modules\Sirsoft\Board\Models\ReportLog;
-use App\Notifications\GenericNotification;
+use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -47,7 +52,7 @@ class ReportNotificationTest extends ModuleTestCase
// NotificationHookListener 동적 훅 등록 (테스트 환경에서는 부트 시점에 실행되지 않음 —
// 알림 정의 기반 훅 구독을 수동으로 활성화하여 actual notification flow 복원)
$this->syncBoardNotificationDefinitions();
- app(\App\Listeners\NotificationHookListener::class)->registerDynamicHooks();
+ app(NotificationHookListener::class)->registerDynamicHooks();
// 테스트 잔여 데이터 정리
DB::table('boards_reports')->delete();
@@ -191,7 +196,7 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 처리 정책 OFF → 알림 미발송
*/
#[Test]
- public function test_게시글_신고_처리시_정책OFF_알림_미발송(): void
+ public function test_게시글_신고_처리시_정책_off_알림_미발송(): void
{
// Given: 신고 처리 알림 정책 OFF
$this->setReportPolicy(false);
@@ -254,7 +259,7 @@ class ReportNotificationTest extends ModuleTestCase
* 댓글 신고 처리 정책 OFF → 댓글 작성자에게 알림 미발송
*/
#[Test]
- public function test_댓글_신고_처리시_정책OFF_알림_미발송(): void
+ public function test_댓글_신고_처리시_정책_off_알림_미발송(): void
{
// Given: 신고 처리 알림 정책 OFF
$this->setReportPolicy(false);
@@ -286,7 +291,7 @@ class ReportNotificationTest extends ModuleTestCase
* → 관리자에게 ReportReceivedAdminNotification 발송 (postTitle, reasonType 내용 포함)
*/
#[Test]
- public function test_신고_접수시_정책ON_권한자에게_관리자알림_발송(): void
+ public function test_신고_접수시_정책_on_권한자에게_관리자알림_발송(): void
{
// Given: 관리자 알림 정책 ON
$this->setAdminReportPolicy(true);
@@ -322,7 +327,7 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 접수 관리자 알림 정책 OFF → 관리자 알림 미발송
*/
#[Test]
- public function test_신고_접수시_정책OFF_관리자알림_미발송(): void
+ public function test_신고_접수시_정책_off_관리자알림_미발송(): void
{
// Given: 관리자 알림 정책 OFF
$this->setAdminReportPolicy(false);
@@ -345,14 +350,14 @@ class ReportNotificationTest extends ModuleTestCase
* 신고 접수 관리자 알림 정책 ON + reports.manage 권한자 없음 → 알림 미발송
*/
#[Test]
- public function test_신고_접수시_정책ON_권한자없음_알림_미발송(): void
+ public function test_신고_접수시_정책_on_권한자없음_알림_미발송(): void
{
// Given: 관리자 알림 정책 ON + admin 역할에서 권한 제거 (권한자 0명 상태 시뮬레이션)
$this->setAdminReportPolicy(true);
// admin 역할에서 reports.manage 권한 제거
- $permission = \App\Models\Permission::where('identifier', 'sirsoft-board.reports.manage')->first();
- $adminRole = \App\Models\Role::where('identifier', 'admin')->first();
+ $permission = Permission::where('identifier', 'sirsoft-board.reports.manage')->first();
+ $adminRole = Role::where('identifier', 'admin')->first();
if ($permission && $adminRole) {
$adminRole->permissions()->detach($permission->id);
}
@@ -508,7 +513,7 @@ class ReportNotificationTest extends ModuleTestCase
/**
* report_policy.notify_author_on_report_action 설정을 주입합니다.
*
- * @param bool $enabled 알림 발송 여부
+ * @param bool $enabled 알림 발송 여부
* @return void
*/
/**
@@ -535,12 +540,22 @@ class ReportNotificationTest extends ModuleTestCase
/**
* report_policy.notify_admin_on_report 설정을 주입합니다.
*
- * @param bool $enabled 알림 발송 여부
- * @param string $scope 발송 범위 ('per_case' | 'per_report')
+ * @param bool $enabled 알림 발송 여부
+ * @param string $scope 발송 범위 ('per_case' | 'per_report')
* @return void
*/
private function setAdminReportPolicy(bool $enabled, string $scope = 'per_case'): void
{
+ // checkAndApplyAutoHide()는 BoardSettingsService(파일)에서 읽으므로
+ // auto_hide_threshold를 충분히 높게 설정하여 테스트 중 자동 블라인드 방지
+ //
+ // 순서 주의: 설정 저장은 config 미러를 저장본 기준으로 다시 채운다. 그래서 아래
+ // config() 주입을 먼저 하면 이 호출이 그 주입을 덮어써 정책이 기본값(활성)으로 돌아간다.
+ /** @var BoardSettingsService $settingsService */
+ $settingsService = app(BoardSettingsService::class);
+ $settingsService->setSetting('report_policy.auto_hide_threshold', 100);
+ $settingsService->clearCache();
+
// g7_module_settings()는 Config에서 읽으므로 config 주입으로 알림 정책 제어
config([
'g7_settings.modules.sirsoft-board.report_policy' => [
@@ -549,22 +564,14 @@ class ReportNotificationTest extends ModuleTestCase
'notify_admin_on_report_channels' => ['mail'],
],
]);
-
- // checkAndApplyAutoHide()는 BoardSettingsService(파일)에서 읽으므로
- // auto_hide_threshold를 충분히 높게 설정하여 테스트 중 자동 블라인드 방지
- /** @var \Modules\Sirsoft\Board\Services\BoardSettingsService $settingsService */
- $settingsService = app(\Modules\Sirsoft\Board\Services\BoardSettingsService::class);
- $settingsService->setSetting('report_policy.auto_hide_threshold', 100);
- $settingsService->clearCache();
}
-
/**
* 테스트용 게시글 생성 헬퍼
*
- * @param int $userId 작성자 ID
- * @param string $status 게시글 상태
- * @param string $triggerType 처리 유형
+ * @param int $userId 작성자 ID
+ * @param string $status 게시글 상태
+ * @param string $triggerType 처리 유형
* @return int 생성된 게시글 ID
*/
private function createTestPost(int $userId, string $status = 'published', string $triggerType = 'admin'): int
@@ -589,8 +596,8 @@ class ReportNotificationTest extends ModuleTestCase
/**
* 테스트용 댓글 생성 헬퍼
*
- * @param int $postId 게시글 ID
- * @param int $userId 작성자 ID
+ * @param int $postId 게시글 ID
+ * @param int $userId 작성자 ID
* @return int 생성된 댓글 ID
*/
private function createTestComment(int $postId, int $userId): int
@@ -619,12 +626,12 @@ class ReportNotificationTest extends ModuleTestCase
*/
private function syncBoardNotificationDefinitions(): void
{
- $module = app(\App\Extension\ModuleManager::class)->getModule('sirsoft-board');
+ $module = app(ModuleManager::class)->getModule('sirsoft-board');
if (! $module) {
return;
}
- $helper = app(\App\Extension\Helpers\NotificationSyncHelper::class);
+ $helper = app(NotificationSyncHelper::class);
foreach ($module->getNotificationDefinitions() as $data) {
$data['extension_type'] = 'module';
$data['extension_identifier'] = 'sirsoft-board';
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php
new file mode 100644
index 00000000..89792f46
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php
@@ -0,0 +1,186 @@
+update(['is_active' => false]);
+ Cache::flush();
+
+ $this->readableBoard = Board::factory()->create([
+ 'is_active' => true,
+ 'name' => ['ko' => '공개 게시판', 'en' => 'Readable Board'],
+ ]);
+ $this->hiddenBoard = Board::factory()->create([
+ 'is_active' => true,
+ 'name' => ['ko' => '비공개 게시판', 'en' => 'Hidden Board'],
+ ]);
+
+ // 비회원 읽기 권한은 readableBoard 에만 부여한다.
+ $this->grantGuestRead($this->readableBoard);
+
+ PermissionMiddleware::clearGuestRoleCache();
+ }
+
+ /**
+ * ⑦ getActiveBoardsListForFilter 는 비회원에게 읽기 권한 없는 게시판을 제외한다.
+ *
+ * @scenario case=available_boards_guest
+ *
+ * @effects available_boards_match_read_permission
+ */
+ #[Test]
+ public function available_boards_excludes_boards_guest_cannot_read(): void
+ {
+ $list = app(BoardService::class)->getActiveBoardsListForFilter(null);
+
+ $slugs = array_column($list, 'slug');
+
+ $this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 필터 목록에 포함되어야 한다.');
+ $this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 필터 목록에서 제외되어야 한다.');
+ }
+
+ /**
+ * ⑦ 회원 기준으로도 읽기 권한 있는 게시판만 필터 목록에 포함된다.
+ *
+ * @scenario case=available_boards_member
+ *
+ * @effects available_boards_match_read_permission
+ */
+ #[Test]
+ public function available_boards_filters_by_member_read_permission(): void
+ {
+ $member = User::factory()->create();
+ $userRole = Role::where('identifier', 'user')->first();
+ $member->roles()->attach($userRole->id);
+
+ // 회원 역할에 readableBoard 읽기 권한만 부여
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$this->readableBoard->slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $userRole->permissions()->syncWithoutDetaching([$perm->id]);
+ PermissionMiddleware::clearGuestRoleCache();
+
+ $list = app(BoardService::class)->getActiveBoardsListForFilter($member);
+
+ $slugs = array_column($list, 'slug');
+
+ $this->assertContains($this->readableBoard->slug, $slugs);
+ $this->assertNotContains($this->hiddenBoard->slug, $slugs);
+ }
+
+ /**
+ * N-2 공개 인기 게시판 목록은 비회원 읽기 권한을 통과한 게시판만 노출한다.
+ *
+ * @scenario case=popular_boards_readable
+ *
+ * @effects popular_boards_exclude_unreadable
+ */
+ #[Test]
+ public function popular_boards_endpoint_excludes_boards_guest_cannot_read(): void
+ {
+ $response = $this->getJson('/api/modules/sirsoft-board/boards/popular-boards?limit=20');
+ $response->assertStatus(200);
+
+ $slugs = array_column($response->json('data'), 'slug');
+
+ $this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 인기 게시판 목록에 포함되어야 한다.');
+ $this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 인기 게시판 목록에서 제외되어야 한다.');
+ }
+
+ /**
+ * ⑦ HTTP 검색 엔드포인트가 Bearer 회원을 해석해 available_boards 에 반영한다.
+ *
+ * 라우트에 optional.sanctum 이 없으면 Bearer 토큰이 있어도 $request->user() 가
+ * null 이라 모든 호출자가 guest 로 판정된다 — 서비스 직접 호출 테스트로는 이
+ * 사각이 보이지 않으므로 실제 HTTP 경로로 검증한다 (actingAs 금지: actingAs 는
+ * 미들웨어 없이도 유저를 심어 라이브와 어긋난다).
+ *
+ * @scenario case=available_boards_bearer_http
+ *
+ * @effects search_route_resolves_bearer_user, available_boards_match_read_permission
+ */
+ #[Test]
+ public function search_endpoint_resolves_bearer_user_for_available_boards(): void
+ {
+ $member = User::factory()->create();
+ $userRole = Role::where('identifier', 'user')->first();
+ $member->roles()->attach($userRole->id);
+
+ // 회원 역할에 두 게시판 읽기 권한 부여 — hiddenBoard 는 회원 역할에만 있으므로
+ // (guest 미보유) Bearer 유저가 해석될 때만 목록에 나타난다.
+ foreach ([$this->hiddenBoard, $this->readableBoard] as $board) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $userRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ PermissionMiddleware::clearGuestRoleCache();
+
+ $token = $member->createToken('search-bearer-test')->plainTextToken;
+
+ $response = $this->getJson('/api/search?q=테스트', ['Authorization' => 'Bearer '.$token]);
+ $response->assertStatus(200);
+
+ $slugs = array_column($response->json('data.posts.available_boards') ?? [], 'slug');
+
+ $this->assertContains(
+ $this->hiddenBoard->slug,
+ $slugs,
+ 'Bearer 인증 회원의 읽기 권한이 available_boards 에 반영되어야 한다 (검색 라우트 optional.sanctum).'
+ );
+ $this->assertContains($this->readableBoard->slug, $slugs);
+ }
+
+ /**
+ * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
+ *
+ * @param Board $board 대상 게시판
+ */
+ private function grantGuestRead(Board $board): void
+ {
+ $guestRole = Role::where('identifier', 'guest')->first();
+ if (! $guestRole) {
+ return;
+ }
+
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $guestRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardAccessTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardAccessTest.php
index f836663c..e4813225 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardAccessTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardAccessTest.php
@@ -3,8 +3,11 @@
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
-require_once __DIR__ . '/../../ModuleTestCase.php';
+require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Http\Middleware\PermissionMiddleware;
+use App\Models\Permission;
+use App\Models\Role;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
@@ -16,7 +19,6 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
*/
class BoardAccessTest extends ModuleTestCase
{
-
protected function setUp(): void
{
parent::setUp();
@@ -45,6 +47,11 @@ class BoardAccessTest extends ModuleTestCase
'is_active' => false,
]);
+ // 디렉토리는 호출자 열람 권한 필터를 적용하므로 게스트 읽기 권한을 부여한다
+ $this->grantGuestRead('active-1');
+ $this->grantGuestRead('active-2');
+ $this->grantGuestRead('inactive');
+
// When: 비로그인 상태에서 게시판 목록 API 호출
$response = $this->getJson('/api/modules/sirsoft-board/boards');
@@ -83,8 +90,14 @@ class BoardAccessTest extends ModuleTestCase
public function test_inactive_boards_not_included_in_list(): void
{
// Given: 비활성화된 게시판 3개, 활성화된 게시판 1개 생성
- Board::factory()->count(3)->create(['is_active' => false]);
- Board::factory()->create(['is_active' => true]);
+ $inactiveBoards = Board::factory()->count(3)->create(['is_active' => false]);
+ $activeBoard = Board::factory()->create(['is_active' => true]);
+
+ // 디렉토리는 호출자 열람 권한 필터를 적용하므로 게스트 읽기 권한을 부여한다
+ foreach ($inactiveBoards as $b) {
+ $this->grantGuestRead($b->slug);
+ }
+ $this->grantGuestRead($activeBoard->slug);
// When: 게시판 목록 조회
$response = $this->getJson('/api/modules/sirsoft-board/boards');
@@ -107,7 +120,7 @@ class BoardAccessTest extends ModuleTestCase
]);
// When: 게시판 상세 조회
- $response = $this->getJson("/api/modules/sirsoft-board/boards/test-board");
+ $response = $this->getJson('/api/modules/sirsoft-board/boards/test-board');
// Then: 성공 응답, 게시판 정보 반환
$response->assertStatus(200);
@@ -132,7 +145,7 @@ class BoardAccessTest extends ModuleTestCase
]);
// When: 비활성화된 게시판 상세 조회 시도
- $response = $this->getJson("/api/modules/sirsoft-board/boards/inactive-board");
+ $response = $this->getJson('/api/modules/sirsoft-board/boards/inactive-board');
// Then: 404 응답
$response->assertStatus(404);
@@ -144,7 +157,7 @@ class BoardAccessTest extends ModuleTestCase
public function test_accessing_non_existent_board_returns_404(): void
{
// When: 존재하지 않는 게시판 조회
- $response = $this->getJson("/api/modules/sirsoft-board/boards/non-existent");
+ $response = $this->getJson('/api/modules/sirsoft-board/boards/non-existent');
// Then: 404 응답
$response->assertStatus(404);
@@ -155,8 +168,11 @@ class BoardAccessTest extends ModuleTestCase
*/
public function test_board_list_response_does_not_include_pagination(): void
{
- // Given: 게시판 여러 개 생성
- Board::factory()->count(5)->create(['is_active' => true]);
+ // Given: 게시판 여러 개 생성 (디렉토리 열람 권한 필터 대응 — 게스트 읽기 권한 부여)
+ $boards = Board::factory()->count(5)->create(['is_active' => true]);
+ foreach ($boards as $b) {
+ $this->grantGuestRead($b->slug);
+ }
// When: 게시판 목록 조회
$response = $this->getJson('/api/modules/sirsoft-board/boards');
@@ -166,4 +182,28 @@ class BoardAccessTest extends ModuleTestCase
$this->assertArrayNotHasKey('pagination', $response->json('data'));
$this->assertIsArray($response->json('data'));
}
-}
\ No newline at end of file
+
+ /**
+ * guest 역할에 게시판 읽기 권한을 부여합니다.
+ *
+ * 디렉토리(전체 게시판 목록)는 호출자 열람 권한 필터를 적용하므로,
+ * 게스트 시점 검증용 게시판에는 명시적으로 읽기 권한을 부여한다
+ * (PublicRecentPostsVisibilityTest 와 동일 관례).
+ *
+ * @param string $slug 게시판 슬러그
+ */
+ private function grantGuestRead(string $slug): void
+ {
+ $role = Role::where('identifier', 'guest')->first();
+ if (! $role) {
+ return;
+ }
+
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $role->permissions()->syncWithoutDetaching([$perm->id]);
+ PermissionMiddleware::clearGuestRoleCache();
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardListApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardListApiTest.php
index edff1294..38e08acf 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardListApiTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardListApiTest.php
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Http\Middleware\PermissionMiddleware;
+use App\Models\Permission;
+use App\Models\Role;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
@@ -13,10 +16,12 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase;
* 전체 게시판 목록 API 테스트
*
* 전체 게시판 목록 페이지용 경량 API의 동작을 검증합니다.
+ * 디렉토리는 호출자 열람 권한(sirsoft-board.{slug}.posts.read) 필터를 적용하므로,
+ * 게스트 시점 검증용 게시판에는 setUp 에서 guest 읽기 권한을 부여한다
+ * (최근글·인기글 가시성 테스트와 동일 관례 — PublicRecentPostsVisibilityTest).
*/
class BoardListApiTest extends ModuleTestCase
{
-
/**
* 테스트 환경 설정
*/
@@ -27,6 +32,31 @@ class BoardListApiTest extends ModuleTestCase
DB::table('boards')->update(['is_active' => false]);
// 이 테스트 클래스에서 사용하는 슬러그 정리 (커밋된 경우 대비)
DB::table('boards')->whereIn('slug', ['free', 'inactive', 'old', 'new'])->delete();
+
+ // 디렉토리 열람 권한 필터 대응 — 이 클래스가 쓰는 슬러그에 guest 읽기 권한 부여
+ foreach (['free', 'inactive', 'old', 'new'] as $slug) {
+ $this->grantGuestRead($slug);
+ }
+ PermissionMiddleware::clearGuestRoleCache();
+ }
+
+ /**
+ * guest 역할에 게시판 읽기 권한을 부여합니다.
+ *
+ * @param string $slug 게시판 슬러그
+ */
+ private function grantGuestRead(string $slug): void
+ {
+ $role = Role::where('identifier', 'guest')->first();
+ if (! $role) {
+ return;
+ }
+
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $role->permissions()->syncWithoutDetaching([$perm->id]);
}
/**
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php
index 8a86a861..5edd92a6 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Http\Middleware\PermissionMiddleware;
+use App\Models\Permission;
+use App\Models\Role;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
@@ -49,6 +52,10 @@ class BoardPopularApiTest extends ModuleTestCase
/**
* 인기 게시글 API가 올바른 구조로 응답하는지 테스트
+ *
+ * @scenario case=popular_readable_filter
+ *
+ * @effects unreadable_board_titles_absent_for_caller
*/
public function test_popular_returns_correct_structure(): void
{
@@ -139,6 +146,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: view_count가 다른 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Post 1', 'content' => 'Content 1', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Post 2', 'content' => 'Content 2', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
@@ -164,6 +172,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 오늘과 어제 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Today Post', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Yesterday Post', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
@@ -187,6 +196,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 최근 1주일과 2주 전 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'This Week', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDays(3), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Two Weeks Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subWeeks(2), 'updated_at' => now()],
@@ -211,6 +221,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 1년 이내/이전 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Six Months Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(6), 'updated_at' => now()],
@@ -242,6 +253,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 1년 이내/이전 게시글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
['board_id' => $board->id, 'title' => 'Over One Year', 'content' => 'Content', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(13), 'updated_at' => now()],
@@ -313,6 +325,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 게시글과 댓글 생성
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
// comments_count 컬럼에 직접 값 설정 (캐시 컬럼 방식)
$postId = DB::table('board_posts')->insertGetId([
'board_id' => $board->id,
@@ -343,6 +356,7 @@ class BoardPopularApiTest extends ModuleTestCase
{
// Given: 게스트가 작성한 게시글
$board = Board::factory()->create(['is_active' => true]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
'board_id' => $board->id,
'title' => 'Guest Post',
@@ -455,6 +469,10 @@ class BoardPopularApiTest extends ModuleTestCase
]);
}
+ // 공개 인기글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다.
+ // 프로덕션 공개 게시판과 동일하게 guest read 권한을 부여한다.
+ $this->grantGuestRead($board);
+
for ($i = 0; $i < $postCount; $i++) {
DB::table('board_posts')->insert([
'board_id' => $board->id,
@@ -471,4 +489,28 @@ class BoardPopularApiTest extends ModuleTestCase
return $board;
}
+
+ /**
+ * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
+ *
+ * 공개 인기글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로,
+ * 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다.
+ *
+ * @param Board $board 대상 게시판
+ */
+ private function grantGuestRead(Board $board): void
+ {
+ $guestRole = Role::where('identifier', 'guest')->first();
+ if (! $guestRole) {
+ return;
+ }
+
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $guestRole->permissions()->syncWithoutDetaching([$perm->id]);
+
+ PermissionMiddleware::clearGuestRoleCache();
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php
index 6f4c9e6c..8554c501 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardRecentPostsApiTest.php
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Http\Middleware\PermissionMiddleware;
+use App\Models\Permission;
+use App\Models\Role;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\PostStatus;
@@ -178,17 +181,20 @@ class BoardRecentPostsApiTest extends ModuleTestCase
}
/**
- * 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트
+ * 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트.
+ *
+ * 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책.
+ * (게시판별 열람 권한 필터는 별도 — createBoardWithPosts 가 guest read 권한을 부여한다.)
*/
public function test_recent_posts_includes_secret_posts_with_is_secret_field(): void
{
- // Given: 비밀글 포함 게시글 생성
- $this->createBoardWithPosts(3, includingSecret: true);
+ // Given: 공개글 + 비밀글 혼합 생성 (i%2==0 이 비밀글, 게시판에 guest read 권한 부여됨)
+ $this->createBoardWithPosts(4, includingSecret: true);
- // When: API 호출
+ // When: API 호출 (비로그인)
$response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent');
- // Then: is_secret 필드 포함 및 비밀글 반환
+ // Then: is_secret 필드 포함 및 비밀글도 응답에 포함
$response->assertStatus(200);
$data = $response->json('data');
@@ -199,21 +205,22 @@ class BoardRecentPostsApiTest extends ModuleTestCase
$this->assertArrayHasKey('is_secret', $post);
}
- // 비밀글이 포함되어 있어야 함
+ // 비밀글이 포함되어 있어야 함 (제목 공개 정책 — 비밀글도 목록/최근글에 표시)
$secretPosts = array_filter($data, fn ($post) => $post['is_secret'] === true);
- $this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다');
+ $this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다(제목 공개).');
}
/**
- * 비밀글도 제목이 정상적으로 표시되는지 테스트
+ * 비밀글도 제목이 정상적으로 표시되는지 테스트 (제목 공개 정책 — 본문만 보호).
*/
public function test_secret_post_title_is_visible_in_recent_posts(): void
{
- // Given: 비밀글이 있는 게시판 생성
+ // Given: 비밀글이 있는 게시판 생성 (guest read 권한 부여)
$board = Board::factory()->create([
'is_active' => true,
'name' => ['ko' => '비밀게시판', 'en' => 'Secret Board'],
]);
+ $this->grantGuestRead($board);
DB::table('board_posts')->insert([
'board_id' => $board->id,
@@ -227,10 +234,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase
'updated_at' => now(),
]);
- // When: API 호출
+ // When: API 호출 (비로그인)
$response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent');
- // Then: 비밀글 제목이 보여야 함 (마스킹 안 됨)
+ // Then: 비밀글 제목이 보여야 함 (마스킹 안 됨 — 제목 공개 정책)
$response->assertStatus(200);
$data = $response->json('data');
@@ -270,8 +277,8 @@ class BoardRecentPostsApiTest extends ModuleTestCase
/**
* 게시판과 게시글을 생성하는 헬퍼
*
- * @param int $postCount 생성할 게시글 수
- * @param bool $includingSecret 비밀글 포함 여부
+ * @param int $postCount 생성할 게시글 수
+ * @param bool $includingSecret 비밀글 포함 여부
* @return Board 생성된 게시판
*/
private function createBoardWithPosts(int $postCount, bool $includingSecret = false): Board
@@ -280,6 +287,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase
'is_active' => true,
]);
+ // 공개 최근글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다.
+ // 프로덕션에서 공개 게시판은 guest read 권한을 갖도록 생성되므로 동일하게 부여한다.
+ $this->grantGuestRead($board);
+
for ($i = 0; $i < $postCount; $i++) {
$isSecret = $includingSecret && ($i % 2 === 0);
@@ -299,4 +310,28 @@ class BoardRecentPostsApiTest extends ModuleTestCase
return $board;
}
+
+ /**
+ * 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
+ *
+ * 공개 최근글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로,
+ * 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다.
+ *
+ * @param Board $board 대상 게시판
+ */
+ private function grantGuestRead(Board $board): void
+ {
+ $guestRole = Role::where('identifier', 'guest')->first();
+ if (! $guestRole) {
+ return;
+ }
+
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
+ ['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
+ );
+ $guestRole->permissions()->syncWithoutDetaching([$perm->id]);
+
+ PermissionMiddleware::clearGuestRoleCache();
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php
new file mode 100644
index 00000000..f7770fb3
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php
@@ -0,0 +1,187 @@
+ $slug,
+ 'name' => ['ko' => '비밀글 boolean 테스트 게시판', 'en' => 'Secret Boolean Test Board'],
+ 'is_active' => true,
+ 'secret_mode' => 'enabled',
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->grantUserRolePermissions();
+
+ $this->memberUser = User::factory()->create(['email' => 'secret-bool@test.com']);
+ $userRole = Role::where('identifier', 'user')->first();
+ $this->memberUser->roles()->attach($userRole->id);
+ }
+
+ /**
+ * 수용해야 하는 boolean 표기 4종(문자열)과 기대 저장값
+ *
+ * @return array [입력값, 기대 boolean]
+ */
+ private function acceptedNotations(): array
+ {
+ return [
+ "문자열 'true'" => ['true', true],
+ "문자열 'false'" => ['false', false],
+ "문자열 '1'" => ['1', true],
+ "문자열 '0'" => ['0', false],
+ ];
+ }
+
+ /**
+ * 작성: 문자열 boolean 표기 4종 모두 201 + DB boolean 저장
+ */
+ public function test_store_accepts_string_boolean_notations(): void
+ {
+ foreach ($this->acceptedNotations() as $label => [$input, $expected]) {
+ $response = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => "비밀글 표기 테스트 {$label}",
+ 'content' => '비밀글 boolean 정규화 검증용 내용입니다.',
+ 'is_secret' => $input,
+ ]);
+
+ $response->assertStatus(201);
+
+ $postId = $response->json('data.id');
+ $stored = (bool) DB::table('board_posts')->where('id', $postId)->value('is_secret');
+ $this->assertSame(
+ $expected,
+ $stored,
+ "작성 {$label} 입력이 boolean 으로 저장되어야 합니다."
+ );
+ }
+ }
+
+ /**
+ * 수정: 문자열 boolean 표기 4종 모두 200 + DB boolean 저장
+ */
+ public function test_update_accepts_string_boolean_notations(): void
+ {
+ $createResponse = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '비밀글 수정 표기 테스트',
+ 'content' => '비밀글 boolean 정규화 수정 검증용 내용입니다.',
+ 'is_secret' => false,
+ ]);
+ $createResponse->assertStatus(201);
+ $postId = $createResponse->json('data.id');
+
+ foreach ($this->acceptedNotations() as $label => [$input, $expected]) {
+ $response = $this->actingAs($this->memberUser)
+ ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
+ 'is_secret' => $input,
+ ]);
+
+ $response->assertStatus(200);
+
+ $stored = (bool) DB::table('board_posts')->where('id', $postId)->value('is_secret');
+ $this->assertSame(
+ $expected,
+ $stored,
+ "수정 {$label} 입력이 boolean 으로 저장되어야 합니다."
+ );
+ }
+ }
+
+ /**
+ * 작성: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리
+ */
+ public function test_store_rejects_unparsable_value_with_422(): void
+ {
+ $response = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '비밀글 해석불가 테스트',
+ 'content' => '비밀글 boolean 해석 불가값 검증용 내용입니다.',
+ 'is_secret' => 'abc',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['is_secret']);
+ }
+
+ /**
+ * 수정: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리
+ */
+ public function test_update_rejects_unparsable_value_with_422(): void
+ {
+ $createResponse = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '비밀글 수정 해석불가 테스트',
+ 'content' => '비밀글 boolean 수정 해석 불가값 검증용 내용입니다.',
+ 'is_secret' => true,
+ ]);
+ $createResponse->assertStatus(201);
+ $postId = $createResponse->json('data.id');
+
+ $response = $this->actingAs($this->memberUser)
+ ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
+ 'is_secret' => 'abc',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['is_secret']);
+
+ // 실패한 요청이 저장값을 건드리지 않아야 한다
+ $this->assertSame(1, (int) DB::table('board_posts')->where('id', $postId)->value('is_secret'));
+ }
+
+ /**
+ * 작성: is_secret 미전송이면 정규화가 개입하지 않고 그대로 통과 (has 가드)
+ */
+ public function test_store_without_is_secret_passes(): void
+ {
+ $response = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '비밀글 미전송 테스트',
+ 'content' => '비밀글 필드 미전송 검증용 내용입니다.',
+ ]);
+
+ $response->assertStatus(201);
+
+ $postId = $response->json('data.id');
+ $this->assertSame(0, (int) DB::table('board_posts')->where('id', $postId)->value('is_secret'));
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostAttachmentAccessTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostAttachmentAccessTest.php
new file mode 100644
index 00000000..d3d29082
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostAttachmentAccessTest.php
@@ -0,0 +1,443 @@
+ $slug,
+ 'name' => ['ko' => '비밀 첨부 테스트 게시판', 'en' => 'Secret Attachment Test Board'],
+ 'is_active' => true,
+ 'use_comment' => true,
+ 'secret_mode' => 'enabled',
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $slug = $this->board->slug;
+
+ // 일반 사용자 (posts.read + attachments.download, 비밀 열람 권한 없음)
+ $this->regularUser = User::factory()->create();
+ $this->ownerUser = User::factory()->create();
+ $userRole = Role::where('identifier', 'user')->first();
+ if ($userRole) {
+ foreach (['posts.read', 'attachments.download'] as $key) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$key}"],
+ ['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
+ );
+ $userRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ $this->regularUser->roles()->attach($userRole->id);
+ $this->ownerUser->roles()->attach($userRole->id);
+ }
+
+ // manager 권한 사용자
+ $this->managerUser = User::factory()->create();
+ $managerRole = Role::firstOrCreate(
+ ['identifier' => "{$slug}-manager"],
+ ['name' => ['ko' => '게시판 매니저', 'en' => 'Board Manager']]
+ );
+ foreach (['posts.read', 'attachments.download', 'manager'] as $key) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$key}"],
+ ['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
+ );
+ $managerRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ $this->managerUser->roles()->attach($managerRole->id);
+ }
+
+ private function createAttachment(int $postId, string $hash, bool $image = false): int
+ {
+ $ext = $image ? 'jpg' : 'pdf';
+ $mime = $image ? 'image/jpeg' : 'application/pdf';
+
+ return DB::table('board_attachments')->insertGetId([
+ 'board_id' => $this->board->id,
+ 'post_id' => $postId,
+ 'original_filename' => "doc.{$ext}",
+ 'stored_filename' => "{$hash}.{$ext}",
+ 'hash' => $hash,
+ 'mime_type' => $mime,
+ 'size' => 1024,
+ 'path' => "attachments/doc.{$ext}",
+ 'collection' => 'attachments',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function downloadUrl(string $hash): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/attachment/{$hash}";
+ }
+
+ private function previewUrl(string $hash): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/attachment/{$hash}/preview";
+ }
+
+ private function secretPost(): int
+ {
+ return $this->createTestPost([
+ 'title' => '비밀글 첨부',
+ 'status' => 'published',
+ 'is_secret' => true,
+ 'user_id' => $this->ownerUser->id,
+ 'author_name' => 'owner',
+ ]);
+ }
+
+ // ==========================================
+ // 비밀글 첨부 차단
+ // ==========================================
+
+ /**
+ * @scenario viewer=regular
+ *
+ * @effects regular_user_cannot_download_secret_post_attachment
+ */
+ public function test_regular_user_cannot_download_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secdlregAAAA');
+
+ $response = $this->actingAs($this->regularUser, 'sanctum')
+ ->get($this->downloadUrl('secdlregAAAA'));
+
+ // attachments.download 권한은 있으나 비밀 게이트로 차단(403)
+ $response->assertStatus(403);
+ }
+
+ /**
+ * @scenario viewer=regular
+ *
+ * @effects regular_user_cannot_preview_secret_post_attachment
+ */
+ public function test_regular_user_cannot_preview_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secprevregAA', image: true);
+
+ $response = $this->actingAs($this->regularUser, 'sanctum')
+ ->get($this->previewUrl('secprevregAA'));
+
+ $response->assertStatus(403);
+ }
+
+ // ==========================================
+ // 미인증(게스트) 차단 — 실제 공격자 프로필
+ // ==========================================
+
+ /**
+ * 미인증(게스트)은 비밀글 첨부 미리보기를 볼 수 없다 (403).
+ *
+ * preview 라우트는 permission 미들웨어가 없어(optional.sanctum 만) 컨트롤러/서비스의
+ * 비밀 게이트만이 게스트를 막는다 — 해시만 쥔 미인증 공격자가 실제로 차단되는지 고정한다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_cannot_preview_secret_post_attachment
+ */
+ public function test_guest_cannot_preview_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secprevgstAA', image: true);
+
+ // actingAs 없음 = 미인증 게스트
+ $response = $this->get($this->previewUrl('secprevgstAA'));
+
+ $response->assertStatus(403);
+ }
+
+ /**
+ * 미인증(게스트)은 비밀글 첨부 다운로드를 할 수 없다 (401).
+ *
+ * download 라우트는 attachments.download permission 미들웨어가 걸려 있어 게스트를
+ * 컨트롤러 도달 전에 401(guest_permission_denied)로 선차단한다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_cannot_download_secret_post_attachment
+ */
+ public function test_guest_cannot_download_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secdlgstAAAA');
+
+ $response = $this->get($this->downloadUrl('secdlgstAAAA'));
+
+ $response->assertStatus(401);
+ }
+
+ /**
+ * 미인증(게스트)도 정상글(비밀 아님) 첨부 미리보기는 차단되지 않는다 (게이트 과차단 회귀 방지).
+ *
+ * 공개 썸네일 정책상 preview 는 공개다 — 비밀 게이트가 정상글 게스트 미리보기까지
+ * 막으면 안 된다. 실제 파일이 없어 404 여도 403(비밀 차단)은 아니어야 한다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_can_preview_normal_post_attachment
+ */
+ public function test_guest_can_preview_normal_post_attachment(): void
+ {
+ $postId = $this->createTestPost([
+ 'title' => '정상글 첨부(게스트 미리보기)',
+ 'status' => 'published',
+ 'is_secret' => false,
+ ]);
+ $this->createAttachment($postId, 'normgstAAAAA', image: true);
+
+ $response = $this->get($this->previewUrl('normgstAAAAA'));
+
+ $this->assertNotSame(403, $response->getStatusCode(), '정상글 첨부 미리보기는 게스트에게 비밀 차단되면 안 됩니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ // ==========================================
+ // 작성자/manager 는 접근 가능 (회귀 방지)
+ // ==========================================
+
+ /**
+ * @scenario viewer=owner
+ *
+ * @effects owner_can_access_secret_post_attachment
+ */
+ public function test_owner_can_access_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secdlownerAA');
+
+ $response = $this->actingAs($this->ownerUser, 'sanctum')
+ ->get($this->downloadUrl('secdlownerAA'));
+
+ // 작성자 본인은 비밀 게이트 통과 (실제 파일 없어 404 여도 403 은 아님)
+ $this->assertNotSame(403, $response->getStatusCode(), '작성자 본인은 비밀글 첨부에 접근 가능해야 합니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ /**
+ * @scenario viewer=manager
+ *
+ * @effects manager_can_access_secret_post_attachment
+ */
+ public function test_manager_can_access_secret_post_attachment(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secdlmgrAAAA');
+
+ $response = $this->actingAs($this->managerUser, 'sanctum')
+ ->get($this->downloadUrl('secdlmgrAAAA'));
+
+ $this->assertNotSame(403, $response->getStatusCode(), 'manager 는 비밀글 첨부에 접근 가능해야 합니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ // ==========================================
+ // 정상글 첨부는 그대로 (회귀 방지)
+ // ==========================================
+
+ /**
+ * @scenario viewer=regular
+ *
+ * @effects normal_post_attachment_still_public
+ */
+ public function test_normal_post_attachment_still_public(): void
+ {
+ $postId = $this->createTestPost([
+ 'title' => '정상글 첨부',
+ 'status' => 'published',
+ 'is_secret' => false,
+ ]);
+ $this->createAttachment($postId, 'normsecAAAAA');
+
+ $response = $this->actingAs($this->regularUser, 'sanctum')
+ ->get($this->downloadUrl('normsecAAAAA'));
+
+ $this->assertNotSame(403, $response->getStatusCode(), '정상글 첨부는 권한 차단되면 안 됩니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ // ==========================================
+ // 서명 preview URL (비밀글
썸네일 렌더 경로)
+ //
+ // 브라우저
는 Authorization 헤더를 실을 수 없어, 비밀 게이트 도입 후
+ // 열람 권한자(작성자·manager) 화면의 비밀글 첨부 썸네일이 무인증 요청 → 403 으로
+ // 깨졌다. 게이트를 통과한 응답 직렬화(PostResource)가 한시 서명 URL 을 발급하고,
+ // 서빙 엔드포인트가 유효 서명을 허용한다 — 무서명 게이트는 종전과 동일하다.
+ // ==========================================
+
+ /**
+ * 서명 preview URL 을 만든다.
+ *
+ * @param string $hash 첨부 해시
+ * @param int $minutes 유효 시간(분, 음수면 만료된 URL)
+ * @return string 상대경로 서명 URL
+ */
+ private function signedPreviewUrl(string $hash, int $minutes = 30): string
+ {
+ return URL::temporarySignedRoute(
+ 'api.modules.sirsoft-board.boards.attachment.preview',
+ now()->addMinutes($minutes),
+ ['slug' => $this->board->slug, 'hash' => $hash],
+ absolute: false
+ );
+ }
+
+ /**
+ * 유효한 한시 서명 preview URL 은 무인증(게스트) 요청도 비밀 게이트를 통과한다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_with_valid_signature_passes_secret_preview_gate
+ */
+ public function test_guest_with_valid_signed_url_passes_secret_preview_gate(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secsignedAAA', image: true);
+
+ $response = $this->get($this->signedPreviewUrl('secsignedAAA'));
+
+ // 실제 파일이 없어 404 여도 403(비밀 차단)은 아니어야 한다
+ $this->assertNotSame(403, $response->getStatusCode(), '유효 서명 URL 은 비밀 게이트를 통과해야 합니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ /**
+ * 변조된 서명 preview URL 은 종전과 동일하게 비밀 게이트에 차단된다 (403).
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_with_tampered_signature_still_blocked
+ */
+ public function test_guest_with_tampered_signature_still_blocked(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'sectamperAAA', image: true);
+
+ $tampered = preg_replace_callback(
+ '/(signature=)([0-9a-f]+)/',
+ fn ($m) => $m[1].substr($m[2], 0, -8).strrev(substr($m[2], -8)),
+ $this->signedPreviewUrl('sectamperAAA')
+ );
+
+ $this->get($tampered)->assertStatus(403);
+ }
+
+ /**
+ * 만료된 서명 preview URL 은 비밀 게이트에 차단된다 (403, 한시성 보장).
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_with_expired_signature_still_blocked
+ */
+ public function test_guest_with_expired_signature_still_blocked(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secexpireAAA', image: true);
+
+ $this->get($this->signedPreviewUrl('secexpireAAA', minutes: -1))->assertStatus(403);
+ }
+
+ /**
+ * 열람 권한자(작성자)에게 직렬화되는 비밀글 상세 응답의 첨부 preview_url 은
+ * 한시 서명 URL 이고, 그 URL 은 무인증
요청으로도 게이트를 통과한다
+ * (렌더 계약의 양끝 검증).
+ *
+ * @scenario viewer=owner
+ *
+ * @effects secret_post_detail_serializes_signed_preview_url
+ */
+ public function test_secret_post_detail_serializes_signed_preview_url_for_owner(): void
+ {
+ $postId = $this->secretPost();
+ $this->createAttachment($postId, 'secserialAAA', image: true);
+
+ $previewUrl = $this->actingAs($this->ownerUser, 'sanctum')
+ ->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}")
+ ->assertStatus(200)
+ ->json('data.attachments.0.preview_url');
+
+ $this->assertIsString($previewUrl);
+ $this->assertStringContainsString('signature=', $previewUrl);
+
+ $response = $this->get($previewUrl);
+ $this->assertNotSame(403, $response->getStatusCode(), '직렬화된 서명 URL 은 무인증 게이트를 통과해야 합니다');
+ $this->assertLessThan(500, $response->getStatusCode(), '게이트 통과 시 서버 오류가 아니어야 합니다');
+ }
+
+ /**
+ * 정상글(비밀 아님) 상세 응답의 첨부 preview_url 은 종전과 동일한 무서명
+ * 공개 hash 경로다 (공개 콘텐츠에 만료성 URL 이 섞이는 회귀 방지).
+ *
+ * @scenario viewer=regular
+ *
+ * @effects normal_post_detail_serializes_plain_preview_url
+ */
+ public function test_normal_post_detail_serializes_plain_preview_url(): void
+ {
+ $postId = $this->createTestPost([
+ 'title' => '정상글 첨부 직렬화',
+ 'status' => 'published',
+ 'is_secret' => false,
+ ]);
+ $this->createAttachment($postId, 'normserialAA', image: true);
+
+ $previewUrl = $this->actingAs($this->regularUser, 'sanctum')
+ ->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}")
+ ->assertStatus(200)
+ ->json('data.attachments.0.preview_url');
+
+ $this->assertSame($this->previewUrl('normserialAA'), $previewUrl);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostCommentAccessTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostCommentAccessTest.php
new file mode 100644
index 00000000..dba23b28
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/SecretPostCommentAccessTest.php
@@ -0,0 +1,314 @@
+ $slug,
+ 'name' => ['ko' => '비밀 댓글 테스트 게시판', 'en' => 'Secret Comment Test Board'],
+ 'is_active' => true,
+ 'use_comment' => true,
+ 'secret_mode' => 'enabled',
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $slug = $this->board->slug;
+
+ $this->regularUser = User::factory()->create();
+ $this->ownerUser = User::factory()->create();
+ $userRole = Role::where('identifier', 'user')->first();
+ if ($userRole) {
+ foreach (['posts.read', 'comments.read'] as $key) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$key}"],
+ ['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
+ );
+ $userRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ $this->regularUser->roles()->attach($userRole->id);
+ $this->ownerUser->roles()->attach($userRole->id);
+ }
+
+ $this->managerUser = User::factory()->create();
+ $managerRole = Role::firstOrCreate(
+ ['identifier' => "{$slug}-manager"],
+ ['name' => ['ko' => '게시판 매니저', 'en' => 'Board Manager']]
+ );
+ foreach (['posts.read', 'comments.read', 'manager'] as $key) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$key}"],
+ ['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
+ );
+ $managerRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ $this->managerUser->roles()->attach($managerRole->id);
+ }
+
+ private function commentsUrl(int $postId): string
+ {
+ return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
+ }
+
+ private function secretPostWithComment(): int
+ {
+ $postId = $this->createTestPost([
+ 'title' => '비밀글',
+ 'status' => 'published',
+ 'is_secret' => true,
+ 'user_id' => $this->ownerUser->id,
+ 'author_name' => 'owner',
+ ]);
+ $this->createTestComment($postId, [
+ 'content' => '비밀 댓글 내용',
+ 'user_id' => $this->ownerUser->id,
+ 'author_name' => 'owner',
+ ]);
+
+ return $postId;
+ }
+
+ /**
+ * @scenario viewer=regular
+ *
+ * @effects regular_user_gets_empty_comment_list_on_secret_post
+ */
+ public function test_regular_user_gets_empty_comment_list_on_secret_post(): void
+ {
+ $postId = $this->secretPostWithComment();
+
+ $response = $this->actingAs($this->regularUser, 'sanctum')
+ ->getJson($this->commentsUrl($postId));
+
+ $response->assertStatus(200);
+ $this->assertCount(0, $response->json('data'), '비열람자에게는 비밀글 댓글이 노출되면 안 됩니다');
+ }
+
+ /**
+ * @scenario viewer=owner
+ *
+ * @effects owner_sees_secret_post_comments
+ */
+ public function test_owner_sees_secret_post_comments(): void
+ {
+ $postId = $this->secretPostWithComment();
+
+ $response = $this->actingAs($this->ownerUser, 'sanctum')
+ ->getJson($this->commentsUrl($postId));
+
+ $response->assertStatus(200);
+ $this->assertGreaterThanOrEqual(1, count($response->json('data')), '작성자 본인은 비밀글 댓글을 볼 수 있어야 합니다');
+ }
+
+ /**
+ * @scenario viewer=manager
+ *
+ * @effects manager_sees_secret_post_comments
+ */
+ public function test_manager_sees_secret_post_comments(): void
+ {
+ $postId = $this->secretPostWithComment();
+
+ $response = $this->actingAs($this->managerUser, 'sanctum')
+ ->getJson($this->commentsUrl($postId));
+
+ $response->assertStatus(200);
+ $this->assertGreaterThanOrEqual(1, count($response->json('data')), 'manager 는 비밀글 댓글을 볼 수 있어야 합니다');
+ }
+
+ /**
+ * 미인증(게스트)은 비밀글 댓글 목록에 접근할 수 없다 (401).
+ *
+ * 사용자 댓글 목록 라우트는 comments.read permission 미들웨어가 걸려 있어, 그 권한이
+ * 없는 게스트는 컨트롤러(비밀 게이트) 도달 전에 401 로 차단된다 — 해시/ID 로 비밀글
+ * 댓글을 훑는 미인증 공격자를 막는다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects guest_blocked_from_secret_post_comments
+ */
+ public function test_guest_blocked_from_secret_post_comments(): void
+ {
+ $postId = $this->secretPostWithComment();
+
+ // actingAs 없음 = 미인증 게스트
+ $response = $this->getJson($this->commentsUrl($postId));
+
+ $response->assertStatus(401);
+ }
+
+ /**
+ * @scenario viewer=regular
+ *
+ * @effects normal_post_comments_still_visible
+ */
+ public function test_normal_post_comments_still_visible(): void
+ {
+ $postId = $this->createTestPost([
+ 'title' => '정상글',
+ 'status' => 'published',
+ 'is_secret' => false,
+ ]);
+ $this->createTestComment($postId, ['content' => '정상 댓글']);
+
+ $response = $this->actingAs($this->regularUser, 'sanctum')
+ ->getJson($this->commentsUrl($postId));
+
+ $response->assertStatus(200);
+ $this->assertGreaterThanOrEqual(1, count($response->json('data')), '정상글 댓글은 노출되어야 합니다');
+ }
+
+ /**
+ * 2차 방어(이중 방어): 컨트롤러가 넘긴 부모 post 를 CommentResource 가 요청 속성으로 받아
+ * 비밀글 댓글 원문을 마스킹한다 — 1차 방어(index 빈 컬렉션)가 회귀해 비뷰어가 목록에
+ * 도달하더라도 Resource 층에서 원문이 새지 않음을 고정한다(KVE-2026-1914 A-4b).
+ *
+ * 이 테스트는 수정 전(2차 방어가 relationLoaded('post') 만 검사, 목록에서 post 미로드라 항상
+ * no-op) 에는 content 가 노출되어 실패한다.
+ *
+ * @scenario viewer=guest
+ *
+ * @effects second_defense_masks_secret_comment_content
+ */
+ public function test_second_defense_masks_secret_comment_content_via_request_attribute(): void
+ {
+ $postId = $this->secretPostWithComment();
+ $post = Post::find($postId);
+ $comment = Comment::where('post_id', $postId)->first();
+ $this->assertNotNull($comment, '비밀글에 댓글이 존재해야 합니다');
+
+ // 게스트(비뷰어) 요청 + 컨트롤러 index 가 넘기는 부모 post 속성
+ $request = Request::create($this->commentsUrl($postId), 'GET');
+ $request->attributes->set('sirsoft_board_parent_post', $post);
+ app()->instance('request', $request);
+
+ $arr = (new CommentResource($comment))->toArray($request);
+
+ $this->assertArrayHasKey('content', $arr);
+ $this->assertNull($arr['content'], '부모가 비밀글인 댓글은 2차 방어로 원문이 마스킹되어야 합니다');
+ }
+
+ /**
+ * 성능 실측: 댓글 목록 Resource 해석 시 부모 post 를 댓글당 lazy-load 하지 않아야 한다.
+ * CommentResource::toArray 34행 `$this->post?->board?->slug` 가 content 계산 전 post 를
+ * lazy-load 하면 댓글 N건에 post 쿼리 N건(N+1)이 발생한다 — 컨트롤러가 부모 post 를
+ * 요청 속성으로 전달하면 0건이어야 한다.
+ *
+ * @scenario viewer=regular
+ *
+ * @effects comment_list_no_per_comment_post_query
+ */
+ public function test_comment_list_does_not_lazy_load_post_per_comment(): void
+ {
+ $postId = $this->createTestPost([
+ 'title' => '정상글(쿼리 실측)',
+ 'status' => 'published',
+ 'is_secret' => false,
+ ]);
+ for ($i = 0; $i < 5; $i++) {
+ $this->createTestComment($postId, ['content' => "댓글 {$i}"]);
+ }
+ $post = Post::find($postId);
+ $comments = app(CommentService::class)
+ ->getCommentsByPostId($this->board->slug, $postId, 'user');
+
+ // 컨트롤러 index 가 넘기는 부모 post 속성
+ $request = Request::create($this->commentsUrl($postId), 'GET');
+ $request->attributes->set('sirsoft_board_parent_post', $post);
+ app()->instance('request', $request);
+
+ DB::enableQueryLog();
+ CommentResource::collection($comments)->toArray($request);
+ $queries = DB::getQueryLog();
+ DB::disableQueryLog();
+
+ $postQueries = array_filter($queries, fn ($q) => str_contains($q['query'], 'board_posts'));
+ fwrite(STDERR, "\n[PERF] board_posts 쿼리 ".count($postQueries).'건 / 댓글 '.$comments->count()."건\n");
+
+ // 컨트롤러가 부모 post 를 요청 속성으로 넘기므로 Resource 해석은 board_posts 를 한 번도
+ // 조회하지 않아야 한다(정확히 0건). ≤1 로 두면 단일 post 재조회 회귀를 놓친다.
+ $this->assertSame(
+ 0,
+ count($postQueries),
+ '댓글 목록 Resource 해석에서 부모 post 를 조회하면 안 됩니다(N+1). 컨트롤러가 넘긴 post 재사용 → 0건'
+ );
+ }
+
+ /**
+ * 2차 방어가 부모 post 컨텍스트 없이는(상세/생성 경로 하위호환) relationLoaded('post') 로
+ * 폴백함을 고정 — 정상 뷰어(작성자)는 마스킹되지 않는다.
+ *
+ * @scenario viewer=owner
+ *
+ * @effects owner_second_defense_not_masked
+ */
+ public function test_second_defense_does_not_mask_for_owner(): void
+ {
+ $postId = $this->secretPostWithComment();
+ $post = Post::find($postId);
+ $comment = Comment::where('post_id', $postId)->first();
+
+ $this->actingAs($this->ownerUser, 'sanctum');
+ $request = Request::create($this->commentsUrl($postId), 'GET');
+ $request->setUserResolver(fn () => $this->ownerUser);
+ $request->attributes->set('sirsoft_board_parent_post', $post);
+ app()->instance('request', $request);
+
+ $arr = (new CommentResource($comment))->toArray($request);
+
+ $this->assertNotNull($arr['content'], '작성자 본인에게는 비밀글 댓글 원문이 노출되어야 합니다');
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivitySecretExposureTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivitySecretExposureTest.php
new file mode 100644
index 00000000..29924770
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivitySecretExposureTest.php
@@ -0,0 +1,282 @@
+ $slug,
+ 'name' => ['ko' => '활동 노출 테스트 게시판', 'en' => 'Activity Exposure Test Board'],
+ 'is_active' => true,
+ 'use_comment' => true,
+ 'secret_mode' => 'enabled',
+ 'blocked_keywords' => [],
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $slug = $this->board->slug;
+
+ $this->author = User::factory()->create();
+ $this->stranger = User::factory()->create();
+
+ $userRole = Role::where('identifier', 'user')->first();
+ if ($userRole) {
+ foreach (['posts.read', 'comments.read', 'comments.create'] as $key) {
+ $perm = Permission::firstOrCreate(
+ ['identifier' => "sirsoft-board.{$slug}.{$key}"],
+ ['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
+ );
+ $userRole->permissions()->syncWithoutDetaching([$perm->id]);
+ }
+ $this->author->roles()->attach($userRole->id);
+ $this->stranger->roles()->attach($userRole->id);
+ }
+ }
+
+ private function profileUrl(User $user): string
+ {
+ return "/api/modules/sirsoft-board/users/{$user->uuid}/posts";
+ }
+
+ /**
+ * 마이페이지 활동 목록(내가 댓글 단 글) URL.
+ *
+ * 라우트 이름으로 뽑는다 — 모듈 라우트는 `api/modules/{id}` 프리픽스가 그룹에서 붙으므로
+ * 경로를 손으로 적으면 프리픽스를 빠뜨려 404 가 되고, 그 404 는 마스킹 단언에 도달하기
+ * 전에 났다는 사실이 드러나지 않으면 "차단됐다" 로 오독되기 쉽다.
+ */
+ private function commentedActivityUrl(): string
+ {
+ return route('api.modules.sirsoft-board.me.board-activities.index', [
+ 'activity_type' => 'commented',
+ ], false);
+ }
+
+ private function createAuthorPost(array $overrides = []): int
+ {
+ return $this->createTestPost(array_merge([
+ 'title' => '작성자 글',
+ 'content' => '대외비 본문입니다',
+ 'status' => 'published',
+ 'is_secret' => false,
+ 'user_id' => $this->author->id,
+ 'author_name' => 'author',
+ ], $overrides));
+ }
+
+ /**
+ * 미인증 요청에 타인 비밀글의 **본문**이 나가지 않는다 (행·제목은 유지).
+ *
+ * 이 라우트는 optional.sanctum 이라 로그인 없이 도달한다 — 결함의 실제 공격 프로필이다.
+ *
+ * @scenario viewer=guest, activity_type=authored
+ *
+ * @effects public_profile_masks_secret_post_content_for_others, activity_rows_and_titles_remain_visible
+ */
+ public function test_guest_sees_secret_post_row_without_content(): void
+ {
+ $this->createAuthorPost(['title' => '공개글', 'is_secret' => false]);
+ $this->createAuthorPost(['title' => '비밀글', 'is_secret' => true, 'content' => '비밀 본문']);
+
+ $response = $this->getJson($this->profileUrl($this->author));
+
+ $response->assertStatus(200);
+
+ $rows = $response->json('data.data') ?? [];
+ $titles = array_column($rows, 'title');
+
+ // 행·제목은 게시판 목록에서 이미 같은 수준으로 보인다 — 가리는 것은 본문뿐이다.
+ $this->assertContains('공개글', $titles);
+ $this->assertContains('비밀글', $titles, '비밀글도 목록에는 나와야 합니다(배지로 구분)');
+
+ $this->assertStringNotContainsString('비밀 본문', $response->getContent(), '비밀글 본문이 응답에 실리면 안 됩니다');
+ }
+
+ /**
+ * 미인증 요청에 블라인드 글의 **본문**도 나가지 않는다 (행은 유지).
+ *
+ * 블라인드 본문은 이 모듈의 다른 목록에서도 비워진다
+ * (PostResource::getMaskedContentPreviewForList) — 같은 규칙이다.
+ *
+ * @scenario viewer=guest, activity_type=authored
+ *
+ * @effects public_profile_masks_blinded_post_content_for_others, activity_rows_and_titles_remain_visible
+ */
+ public function test_guest_sees_blinded_post_row_without_content(): void
+ {
+ $this->createAuthorPost(['title' => '공개글']);
+ $this->createAuthorPost(['title' => '블라인드글', 'status' => 'blinded', 'content' => '가려진 본문']);
+
+ $response = $this->getJson($this->profileUrl($this->author));
+
+ $titles = array_column($response->json('data.data') ?? [], 'title');
+ $this->assertContains('블라인드글', $titles, '블라인드 글도 목록에는 나와야 합니다');
+ $this->assertStringNotContainsString('가려진 본문', $response->getContent());
+ }
+
+ /**
+ * 로그인한 타인에게도 동일하게 본문만 가려진다 (인증 여부와 무관한 판정).
+ *
+ * @scenario viewer=other_user, activity_type=authored
+ *
+ * @effects public_profile_masks_secret_post_content_for_others
+ */
+ public function test_other_authenticated_user_sees_secret_post_row_without_content(): void
+ {
+ $this->createAuthorPost(['title' => '비밀글', 'is_secret' => true, 'content' => '비밀 본문']);
+
+ $response = $this->actingAs($this->stranger, 'sanctum')
+ ->getJson($this->profileUrl($this->author));
+
+ $titles = array_column($response->json('data.data') ?? [], 'title');
+ $this->assertContains('비밀글', $titles);
+ $this->assertStringNotContainsString('비밀 본문', $response->getContent(), '로그인한 타인에게도 본문은 나가면 안 됩니다');
+ }
+
+ /**
+ * (과차단 회귀) 본인이 자기 프로필을 보면 자기 비밀글의 본문까지 그대로 보인다.
+ *
+ * fail-closed 로 뒤집으면서 본인 시야까지 좁히면 기능 축소다.
+ *
+ * @scenario viewer=owner, activity_type=authored
+ *
+ * @effects public_profile_shows_own_secret_content_to_owner
+ */
+ public function test_owner_still_sees_own_secret_post_content(): void
+ {
+ $this->createAuthorPost(['title' => '비밀글', 'is_secret' => true, 'content' => '비밀 본문']);
+
+ $response = $this->actingAs($this->author, 'sanctum')
+ ->getJson($this->profileUrl($this->author));
+
+ $rows = $response->json('data.data') ?? [];
+ $titles = array_column($rows, 'title');
+ $this->assertContains('비밀글', $titles, '본인에게는 자기 비밀글이 보여야 합니다');
+ $this->assertStringContainsString('비밀 본문', $response->getContent(), '본인에게는 본문도 보여야 합니다');
+ }
+
+ /**
+ * "내가 댓글 단 글" 목록에는 **타인이 쓴** 비밀글이 섞인다 — 그 본문은 나가지 않는다.
+ *
+ * authored 축과 다른 코드 경로다. authored 는 목록 주인이 곧 글쓴이라 열람자 일치만
+ * 보면 되지만(`$isOwnView`), commented 는 글마다 작성자가 달라 **글 단위**로 판정한다
+ * (`$post->user_id !== $viewerId`). authored 만 검증하면 이 분기가 무보호로 남는다.
+ *
+ * @scenario viewer=other_user, activity_type=commented
+ *
+ * @effects commented_activity_masks_others_secret_content, activity_rows_and_titles_remain_visible
+ */
+ public function test_commented_activity_masks_others_secret_post_content(): void
+ {
+ $postId = $this->createAuthorPost([
+ 'title' => '남의 비밀글',
+ 'is_secret' => true,
+ 'content' => '남의 비밀 본문',
+ ]);
+
+ // 열람자(stranger)가 그 글에 댓글을 달아 자기 활동 목록에 올린다.
+ $this->createTestComment($postId, [
+ 'user_id' => $this->stranger->id,
+ 'author_name' => 'stranger',
+ 'content' => '댓글 답니다',
+ ]);
+
+ $response = $this->actingAs($this->stranger, 'sanctum')
+ ->getJson($this->commentedActivityUrl());
+
+ $response->assertStatus(200);
+
+ $titles = array_column($response->json('data.data') ?? [], 'title');
+
+ // 행은 내 활동 기록이므로 남는다 — 가리는 것은 남의 본문뿐이다.
+ $this->assertContains('남의 비밀글', $titles, '내가 댓글 단 글은 활동 목록에 남아야 합니다');
+ $this->assertStringNotContainsString(
+ '남의 비밀 본문',
+ $response->getContent(),
+ '타인이 쓴 비밀글의 본문이 댓글 활동 목록으로 새면 안 됩니다'
+ );
+ }
+
+ /**
+ * (과차단 회귀) 자기 비밀글에 자기가 댓글을 달았으면 본문이 그대로 보인다.
+ *
+ * 글 단위 판정을 "비밀글이면 무조건 마스킹" 으로 조이면 자기 글까지 가려져 기능이 깎인다.
+ *
+ * @scenario viewer=owner, activity_type=commented
+ *
+ * @effects commented_activity_shows_own_secret_content_to_owner
+ */
+ public function test_commented_activity_shows_own_secret_post_content_to_owner(): void
+ {
+ $postId = $this->createAuthorPost([
+ 'title' => '내 비밀글',
+ 'is_secret' => true,
+ 'content' => '내 비밀 본문',
+ ]);
+
+ $this->createTestComment($postId, [
+ 'user_id' => $this->author->id,
+ 'author_name' => 'author',
+ 'content' => '자문자답',
+ ]);
+
+ $response = $this->actingAs($this->author, 'sanctum')
+ ->getJson($this->commentedActivityUrl());
+
+ $response->assertStatus(200);
+
+ $titles = array_column($response->json('data.data') ?? [], 'title');
+ $this->assertContains('내 비밀글', $titles);
+ $this->assertStringContainsString(
+ '내 비밀 본문',
+ $response->getContent(),
+ '본인이 쓴 비밀글이면 댓글 활동 목록에서도 본문이 보여야 합니다'
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php
index 95c8b07a..52ff4836 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php
@@ -98,11 +98,14 @@ class UserPublicPostsApiTest extends BoardTestCase
}
/**
- * 비밀글도 사용자 프로필 게시글 목록에 포함된다 (is_secret 배지로 구분).
+ * 타인 프로필에서도 비밀글은 목록에 나오되 본문은 나가지 않는다.
*
- * getUserPublicPosts는 모든 게시글을 표시하며 비밀글/블라인드는 배지로 구분합니다.
+ * 이 목록은 본문 일부(content_plain)를 함께 싣는데 라우트가 optional.sanctum 이라
+ * **미인증 요청에 타인의 비밀글 본문이 그대로 나갔다**(KVE-2026-1914 형제).
+ * 행·제목은 게시판 목록에서 이미 같은 수준으로 보이므로(PostResource 의 목록 규칙)
+ * 행은 남기고 본문만 비운다 — UI 의 비밀글 배지도 그대로 동작한다.
*/
- public function test_secret_posts_are_included_with_badge(): void
+ public function test_secret_posts_are_listed_without_content_for_other_viewers(): void
{
// Given: 공개글과 비밀글을 작성
$this->createPost($this->board->slug, [
@@ -122,19 +125,27 @@ class UserPublicPostsApiTest extends BoardTestCase
// When: 사용자 게시글을 조회하면
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
- // Then: 모든 게시글이 반환되며 is_secret 배지로 구분
+ // Then: 두 건 모두 나오되 비밀글의 본문만 비어 있다
$response->assertOk();
$data = $response->json('data.data');
$this->assertCount(2, $data);
+
+ $secret = collect($data)->firstWhere('is_secret', true);
+ $public = collect($data)->firstWhere('is_secret', false);
+
+ $this->assertNotNull($secret, '비밀글도 목록에는 나와야 합니다(배지로 구분)');
+ $this->assertSame('', $secret['content_plain'], '비밀글 본문은 나가면 안 됩니다');
+ $this->assertNotSame('', $public['content_plain'], '공개글 본문은 그대로여야 합니다');
}
/**
- * 블라인드 처리된 게시글도 사용자 프로필 게시글 목록에 포함된다 (status 배지로 구분).
+ * 타인 프로필에서도 블라인드 게시글은 목록에 나오되 본문은 나가지 않는다.
*
- * getUserPublicPosts는 모든 게시글을 표시하며 비밀글/블라인드는 배지로 구분합니다.
+ * 블라인드 글의 본문은 이 모듈의 다른 목록에서도 비워진다
+ * (PostResource::getMaskedContentPreviewForList) — 같은 규칙을 적용한다.
*/
- public function test_blinded_posts_are_included_with_badge(): void
+ public function test_blinded_posts_are_listed_without_content_for_other_viewers(): void
{
// Given: 공개된 게시글과 블라인드 처리된 게시글
$this->createPost($this->board->slug, [
@@ -152,11 +163,15 @@ class UserPublicPostsApiTest extends BoardTestCase
// When: 사용자 게시글을 조회하면
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
- // Then: 모든 게시글이 반환되며 status 배지로 구분
+ // Then: 두 건 모두 나오되 블라인드 글의 본문만 비어 있다
$response->assertOk();
$data = $response->json('data.data');
$this->assertCount(2, $data);
+
+ $blinded = collect($data)->firstWhere('status', 'blinded');
+ $this->assertNotNull($blinded, '블라인드 글도 목록에는 나와야 합니다(배지로 구분)');
+ $this->assertSame('', $blinded['content_plain'], '블라인드 글 본문은 나가면 안 됩니다');
}
/**
diff --git a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
index 6c6890a0..d3eb3485 100644
--- a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
+++ b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php
@@ -261,8 +261,12 @@ abstract class ModuleTestCase extends TestCase
$relativeClass = substr($class, $len);
$file = $moduleBasePath.str_replace('\\', '/', $relativeClass).'.php';
- if (file_exists($file)) {
- require $file;
+ if (file_exists($file)
+ && ! class_exists($class, false) && ! interface_exists($class, false)
+ && ! trait_exists($class, false) && ! enum_exists($class, false)) {
+ // 활성 디렉토리 사본이 이미 로드된 심볼을 다시 선언하면 fatal 이 된다 —
+ // 선언 여부를 자체 확인하고 require_once 로 이중 방어한다
+ require_once $file;
}
});
}
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
index d6f3d45e..733e0686 100644
--- a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
@@ -19,6 +19,8 @@ import { issueToken, authenticatePage } from '../../../../../../tests/Playwright
type BoardAuthFixtures = {
/** 대시보드 진입 권한 + 게시판 조회/신고 조회 권한 토큰 */
dashboardToken: string;
+ /** 답글 삭제 정책 E2E 용 — 게시판 CRUD + 환경설정 + 전용 게시판(e2e-reply-policy) 글 관리 토큰 */
+ replyPolicyToken: string;
/** 환경설정 조회/수정 권한 토큰 */
settingsToken: string;
/** 권한 없는 일반 사용자 토큰 (영역 부재 검증용) */
@@ -47,6 +49,24 @@ export const test = base.extend({
),
);
},
+ replyPolicyToken: async ({}, use) => {
+ // 게시판 스코프 권한은 slug 가 식별자에 박히므로 전용 게시판 slug 로 발급.
+ // core.profile.read: 게시판 생성 필수값(board_manager_ids)에 넣을 본인 uuid 조회용.
+ await use(
+ issueToken(
+ 'core.profile.read',
+ 'sirsoft-board.boards.read',
+ 'sirsoft-board.boards.create',
+ 'sirsoft-board.boards.update',
+ 'sirsoft-board.boards.delete',
+ 'sirsoft-board.settings.read',
+ 'sirsoft-board.settings.update',
+ 'sirsoft-board.e2e-reply-policy.admin.posts.read',
+ 'sirsoft-board.e2e-reply-policy.admin.posts.write',
+ 'sirsoft-board.e2e-reply-policy.admin.manage',
+ ),
+ );
+ },
noPermissionToken: async ({}, use) => {
await use(issueToken());
},
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts
new file mode 100644
index 00000000..1dd74d14
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reply-delete-policy.spec.ts
@@ -0,0 +1,182 @@
+/**
+ * 게시판 답글 삭제 정책 (block/cascade) — 설정 노출·저장 왕복 + block 차단 (#573, 공개 #106/#107 후속).
+ *
+ * 검증 축:
+ * ① 게시판 환경설정 "답변글" 탭에 정책 Select 렌더 + 기본값 cascade
+ * ② 게시판 편집 폼에서 정책을 block 으로 저장 → 재조회 왕복
+ * ③ block 게시판에서 답글 달린 글 삭제 → 422 + has_replies 메시지 (DB 무변화)
+ *
+ * 단위(PHPUnit ReplyDeletePolicyTest)는 서비스/저장소 cascade 의미론을 담당하고,
+ * 이 spec 은 화면 노출과 API 계약(저장 왕복·422)의 브라우저 수준 확인을 담당한다.
+ *
+ * 전용 게시판(e2e-reply-policy)을 spec 안에서 생성/삭제하므로 시드 의존이 없다.
+ *
+ * @scenario post-reply-delete-policy
+ * @axes policy=block, policy=cascade
+ * @effects settings_and_form_expose_policy, block_returns_422_with_message
+ */
+import { test, expect, authenticatePage } from '../../fixtures/board-auth';
+
+const SLUG = 'e2e-reply-policy';
+const API = '/api/modules/sirsoft-board';
+
+/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */
+async function api(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ method: string,
+ path: string,
+ body?: unknown,
+): Promise<{ status: number; body: any }> {
+ return page.evaluate(
+ async ({ bearer, method, path, body }) => {
+ const response = await fetch(path, {
+ method,
+ headers: {
+ Authorization: `Bearer ${bearer}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ let parsed: unknown = null;
+ try {
+ parsed = await response.json();
+ } catch {
+ /* 비 JSON 응답 허용 */
+ }
+ return { status: response.status, body: parsed as any };
+ },
+ { bearer, method, path, body },
+ );
+}
+
+async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise {
+ await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`);
+}
+
+/** 전용 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */
+async function createBoard(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ extra: Record = {},
+): Promise<{ status: number; body: any }> {
+ // board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다
+ // (프로필 엔드포인트는 user 타입 권한 게이트라 admin 토큰으로도 403 — 실측)
+ const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`);
+ const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0];
+
+ if (!managerUuid) {
+ throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`);
+ }
+
+ return api(page, bearer, 'POST', `${API}/admin/boards`, {
+ slug: SLUG,
+ name: { ko: '[검수용] 답글정책', en: 'Reply Policy E2E' },
+ type: 'basic',
+ use_reply: true,
+ show_view_count: true,
+ use_report: false,
+ board_manager_ids: [managerUuid],
+ ...extra,
+ });
+}
+
+// 두 테스트가 같은 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정
+test.describe.configure({ mode: 'serial' });
+
+test.describe('게시판 답글 삭제 정책 (#573)', () => {
+ test('환경설정 답변글 탭에 정책 Select 가 렌더되고 기본값이 cascade 다', async ({ page, replyPolicyToken }) => {
+ await authenticatePage(page, replyPolicyToken);
+
+ // 답변글 서브탭은 basic_defaults 탭의 스크롤 섹션으로 함께 렌더된다 (TabNavigationScroll)
+ await page.goto('/admin/boards/settings?tab=basic_defaults');
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ // 필드 라벨 + Select 렌더 (admin_basic Select 는 커스텀 드롭다운 — name 루트로 조회)
+ await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 15_000 });
+
+ // 저장값(기본 cascade)이 응답에 실려 있는지 API 로 대조
+ const settings = await api(page, replyPolicyToken, 'GET', `${API}/admin/settings`);
+ expect(settings.status).toBe(200);
+ const basicDefaults =
+ settings.body?.data?.settings?.basic_defaults ?? settings.body?.data?.basic_defaults ?? {};
+ expect(basicDefaults.reply_delete_policy ?? 'cascade').toBe('cascade');
+ });
+
+ test('게시판 편집 폼 저장 왕복 — block 저장 후 재조회에 반영된다', async ({ page, replyPolicyToken }) => {
+ await authenticatePage(page, replyPolicyToken);
+ await page.goto('/admin/boards');
+ await deleteBoardIfExists(page, replyPolicyToken);
+
+ // 전용 게시판 생성 (기본 cascade)
+ const created = await createBoard(page, replyPolicyToken);
+ expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
+ expect(created.body?.data?.reply_delete_policy ?? 'cascade').toBe('cascade');
+
+ // 편집 폼에서 필드 렌더 확인 (use_reply ON 조건부)
+ await page.goto(`/admin/boards/${SLUG}/edit`);
+ await expect(page.getByText('답변글 삭제 방식').first()).toBeVisible({ timeout: 20_000 });
+
+ // block 으로 저장 왕복 (API PUT — 폼과 동일 엔드포인트/검증 경로)
+ const updated = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, {
+ reply_delete_policy: 'block',
+ });
+ expect(updated.status).toBe(200);
+
+ const fetched = await api(page, replyPolicyToken, 'GET', `${API}/admin/boards/${SLUG}`);
+ expect(fetched.body?.data?.reply_delete_policy).toBe('block');
+
+ // 허용 밖 값은 422 (닫힌 집합)
+ const invalid = await api(page, replyPolicyToken, 'PUT', `${API}/admin/boards/${SLUG}`, {
+ reply_delete_policy: 'evil',
+ });
+ expect(invalid.status).toBe(422);
+
+ await deleteBoardIfExists(page, replyPolicyToken);
+ });
+
+ test('block 게시판에서 답글 달린 글 삭제는 422 로 거부되고 DB 가 변하지 않는다', async ({ page, replyPolicyToken }) => {
+ await authenticatePage(page, replyPolicyToken);
+ await page.goto('/admin/boards');
+ await deleteBoardIfExists(page, replyPolicyToken);
+
+ const created = await createBoard(page, replyPolicyToken, { reply_delete_policy: 'block' });
+ expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
+
+ // 부모 글 + 답글 생성 (admin posts API)
+ const parent = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, {
+ title: '[검수용] 원글',
+ content: '답글 삭제 정책 검증용 원글입니다.',
+ });
+ expect([200, 201]).toContain(parent.status);
+ const parentId = parent.body?.data?.id;
+ expect(parentId).toBeTruthy();
+
+ const reply = await api(page, replyPolicyToken, 'POST', `${API}/admin/board/${SLUG}/posts`, {
+ title: 'Re: [검수용] 원글',
+ content: '답글 삭제 정책 검증용 답글입니다.',
+ parent_id: parentId,
+ });
+ expect([200, 201], `reply create failed: ${JSON.stringify(reply.body).slice(0, 400)}`).toContain(reply.status);
+ const replyId = reply.body?.data?.id;
+
+ // block: 원글 삭제 422 + 메시지
+ const blocked = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`);
+ expect(blocked.status).toBe(422);
+ expect(String(blocked.body?.message ?? '')).toContain('답글');
+
+ // DB 무변화 — 원글이 여전히 조회된다
+ const stillThere = await api(page, replyPolicyToken, 'GET', `${API}/admin/board/${SLUG}/posts/${parentId}`);
+ expect(stillThere.status).toBe(200);
+
+ // 답글 먼저 삭제하면 원글 삭제가 허용된다
+ const replyDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${replyId}`);
+ expect(replyDeleted.status).toBe(200);
+
+ const parentDeleted = await api(page, replyPolicyToken, 'DELETE', `${API}/admin/board/${SLUG}/posts/${parentId}`);
+ expect(parentDeleted.status).toBe(200);
+
+ await deleteBoardIfExists(page, replyPolicyToken);
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reports-search.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reports-search.spec.ts
index 86b0f4f6..9c5de1b9 100644
--- a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reports-search.spec.ts
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-reports-search.spec.ts
@@ -13,11 +13,7 @@
*
* @scenario board-reports-search
* @axes field=all field=post_title field=board_name field=author_name field=reporter_name
- * @effects search_keyword_propagated_to_url_query,
- * search_field_propagated_to_url_query,
- * search_input_value_retained_after_navigation,
- * reset_clears_search_keyword,
- * mobile_search_propagates_to_url_query
+ * 효과 요약(마커 아님 — 평문): search_keyword_propagated_to_url_query, search_field_propagated_to_url_query, search_input_value_retained_after_navigation, reset_clears_search_keyword, mobile_search_propagates_to_url_query.
*
* 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe.
*/
@@ -104,6 +100,35 @@ test.describe.skip('게시판 신고현황 — 검색 기능 동작 (#413-72)',
expect(url.searchParams.get('filters[0][value]')).toBeFalsy();
});
+ // @scenario field=reporter_name
+ // @effects search_field_propagated_to_url_query, search_keyword_propagated_to_url_query, search_input_value_retained_after_navigation
+ test('검색 필드(신고자명) 선택 + 검색어가 URL query 에 전달되고 재진입 시 복원된다', async ({
+ page,
+ settingsToken,
+ }) => {
+ await authenticatePage(page, settingsToken);
+ await page.goto(REPORTS_URL);
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ // 검색 필드 select → reporter_name (게시글 작성자가 아니라 신고한 사람 기준)
+ await page.locator('#search_field_select select, #search_field_select').first()
+ .selectOption('reporter_name');
+ await page.locator('#search_input input, #search_input').first().fill('김신고');
+ await page.locator('#search_button').click();
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ const url = new URL(page.url());
+ expect(url.searchParams.get('filters[0][field]')).toBe('reporter_name');
+ expect(url.searchParams.get('filters[0][value]')).toBe('김신고');
+
+ // author_name 과 값 공간이 겹치므로, 선택한 필드가 그대로 유지되는지까지 확인한다
+ // (필드가 조용히 all/author_name 으로 되돌아가면 다른 행이 걸려 결과가 맞는 것처럼 보인다)
+ const fieldSelect = page.locator('#search_field_select select, #search_field_select').first();
+ await expect(fieldSelect).toHaveValue('reporter_name', { timeout: 5_000 });
+ const searchInput = page.locator('#search_input input, #search_input').first();
+ await expect(searchInput).toHaveValue('김신고', { timeout: 5_000 });
+ });
+
// @scenario field=post_title
// @effects mobile_search_propagates_to_url_query
test('모바일 뷰포트에서 검색어가 URL query 에 전달된다', async ({ page, settingsToken }) => {
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts
new file mode 100644
index 00000000..0156f33e
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-settings-attachment-cleanup.spec.ts
@@ -0,0 +1,129 @@
+/**
+ * E2E: 게시판 환경설정 > 기본 설정 — "삭제 첨부 영구 정리" (공개 #115)
+ *
+ * @scenario board-settings-attachment-cleanup
+ * @effects board_settings_purge_toggle_default_off, board_settings_purge_keys_declared
+ *
+ * 배경: 삭제한 첨부의 파일을 보존기간 경과 후 완전히 파기하는 운영자 스위치다. 저장 payload
+ * 빌더가 이 카테고리를 싣지 않아 "저장 200 인데 값이 사라지던" 결함이 있었고, 그 증상은
+ * 응답이 정상이라 화면만 봐서는 드러나지 않았다. 단위 테스트는 레이아웃 JSON 의 바인딩만
+ * 보므로, 실제 요청 본문에 카테고리가 실리는지는 브라우저로만 확인된다.
+ *
+ * 검증:
+ * 1. 기본 설정 탭에 토글·보존기간 필드가 번역문과 함께 마운트된다
+ * 2. 토글은 기본 꺼짐으로 렌더된다 (사용자 파일을 지우는 기능의 인수 기준)
+ * 3. 값을 바꿔 저장하면 요청 본문에 `attachment_settings` 가 실제로 실린다
+ *
+ */
+import { test, expect, authenticatePage } from '../../fixtures/board-auth';
+
+// 탭 판정은 `_global.activeBoardSettingsTab || query.tab || 'general'` 이다.
+// 다른 값을 실으면 탭 콘텐츠 자체가 렌더되지 않아 섹션을 찾을 수 없다.
+const SETTINGS_URL = '/admin/boards/settings?tab=general';
+
+const SECTION = '#attachment_cleanup_section';
+const TOGGLE_ROW = '#attachment_purge_enabled_field';
+const RETENTION_ROW = '#attachment_purge_retention_days_field';
+
+/** 설정 저장 PUT 인지 판정한다. */
+const isSettingsPut = (url: string, method: string) => url.includes('/settings') && method === 'PUT';
+
+/**
+ * 보존기간 값을 바꿔 저장하고, 요청 본문을 돌려준다.
+ *
+ * 응답 상태까지 확인한다 — 요청만 관찰하면 서버가 4xx 로 거부해도 단언이 통과해
+ * "저장됐다" 는 잘못된 결론이 남는다.
+ *
+ * @param page 대상 페이지
+ * @param input 보존기간 입력
+ * @param saveButton 저장 버튼
+ * @param value 채워 넣을 값
+ * @returns 저장 요청 본문
+ */
+async function save(
+ page: import('@playwright/test').Page,
+ input: import('@playwright/test').Locator,
+ saveButton: import('@playwright/test').Locator,
+ value: string,
+): Promise> {
+ await input.fill(value);
+ await expect(saveButton).toBeEnabled({ timeout: 20_000 });
+
+ const requestPromise = page.waitForRequest(
+ (req) => isSettingsPut(req.url(), req.method()),
+ { timeout: 20_000 },
+ );
+ const responsePromise = page.waitForResponse(
+ (res) => isSettingsPut(res.url(), res.request().method()),
+ { timeout: 20_000 },
+ );
+
+ await saveButton.click();
+
+ const payload = (await requestPromise).postDataJSON() as Record;
+ expect((await responsePromise).status()).toBe(200);
+
+ return payload;
+}
+
+test.describe('게시판 환경설정 — 삭제 첨부 영구 정리 (#115)', () => {
+ test('토글·보존기간 필드가 번역문과 함께 마운트되고 기본 꺼짐이다', async ({
+ page,
+ settingsToken,
+ }) => {
+ await authenticatePage(page, settingsToken);
+ await page.goto(SETTINGS_URL);
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ await expect(page.locator(SECTION)).toBeAttached({ timeout: 20_000 });
+ await expect(page.locator(TOGGLE_ROW)).toBeAttached();
+ await expect(page.locator(RETENTION_ROW)).toBeAttached();
+
+ const text = (await page.locator(SECTION).innerText()).trim();
+ expect(text).not.toContain('$t:');
+ expect(text).not.toContain('purge_enabled');
+
+ const toggle = page.locator(TOGGLE_ROW).locator('input[type="checkbox"]').first();
+ expect(await toggle.isChecked()).toBe(false);
+ });
+
+ test('저장 요청 본문에 attachment_settings 카테고리가 실린다', async ({
+ page,
+ settingsToken,
+ }) => {
+ await authenticatePage(page, settingsToken);
+ await page.goto(SETTINGS_URL);
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
+
+ await expect(page.locator(RETENTION_ROW)).toBeAttached({ timeout: 20_000 });
+
+ const input = page.locator(RETENTION_ROW).locator('input').first();
+ const saveButton = page.locator('#footer_save_button');
+ const original = (await input.inputValue()) || '30';
+
+ // 현재값을 그대로 채우면 hasChanges 가 서지 않아 저장 버튼이 비활성인 채로 남고,
+ // PUT 이 영원히 오지 않아 테스트가 타임아웃한다. 반드시 다른 값으로 바꾼다.
+ const target = original === '15' ? '20' : '15';
+
+ const payload = await save(page, input, saveButton, target);
+
+ // 카테고리 자체가 빠지면 서버는 200 을 돌려주고 값만 조용히 사라진다.
+ expect(payload).toHaveProperty('attachment_settings');
+
+ // number 입력의 값은 와이어에 문자열로 실리고(서버가 정수로 캐스팅한다) —
+ // 여기서 검증할 것은 표현 타입이 아니라 "그 값이 실제로 실렸는가" 다.
+ expect(
+ Number((payload.attachment_settings as Record).purge_retention_days),
+ ).toBe(Number(target));
+
+ // 요청 본문만 보면 서버가 그 값을 버려도 통과한다. 새로고침 후 값이 살아 있어야
+ // "실제로 저장됐다" 가 증명된다 (F4 가 정확히 이 지점에서 200 인 채 값을 잃었다).
+ await page.reload();
+ await expect(input).toHaveValue(target, { timeout: 20_000 });
+
+ // 검증 때문에 운영 설정이 바뀐 채로 남지 않도록 원래 값으로 되돌린다.
+ await save(page, input, saveButton, original);
+ await page.reload();
+ await expect(input).toHaveValue(original, { timeout: 20_000 });
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php
new file mode 100644
index 00000000..4f18582f
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php
@@ -0,0 +1,46 @@
+hash = 'abc123def456';
+
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/notice/attachment/abc123def456/preview',
+ $attachment->previewUrlForSlug('notice')
+ );
+ }
+
+ #[Test]
+ public function preview_url_accessor_delegates_to_single_assembly_point(): void
+ {
+ $attachment = new Attachment;
+ $attachment->hash = 'abc123def456';
+ $attachment->mime_type = 'image/png';
+ $attachment->board_id = 0;
+
+ // board 관계 미로딩 + 존재하지 않는 board_id → slug 는 빈 문자열로 조립 (기존 동작 보존)
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards//attachment/abc123def456/preview',
+ $attachment->preview_url
+ );
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/BoardSetSettingPipelineTest.php b/modules/_bundled/sirsoft-board/tests/Unit/BoardSetSettingPipelineTest.php
new file mode 100644
index 00000000..8a917200
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Unit/BoardSetSettingPipelineTest.php
@@ -0,0 +1,253 @@
+createMock(BoardPermissionService::class);
+ $notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
+ $notificationDefinitionRepository->method('getByExtension')->willReturn(new Collection);
+
+ $this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
+ $this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
+
+ if (File::isDirectory($this->storagePath)) {
+ File::deleteDirectory($this->storagePath);
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ if (File::isDirectory($this->storagePath)) {
+ File::deleteDirectory($this->storagePath);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * 카테고리 저장 파일을 그대로 읽습니다.
+ *
+ * @param string $category 카테고리명
+ * @return array 저장 파일의 디코드 결과 (파일 부재 시 빈 배열)
+ */
+ private function savedFile(string $category): array
+ {
+ $path = $this->storagePath.'/'.$category.'.json';
+
+ if (! File::exists($path)) {
+ return [];
+ }
+
+ return json_decode(File::get($path), true) ?? [];
+ }
+
+ /**
+ * 단건 저장 직후 같은 인스턴스의 조회가 신값을 반환한다. (실패-먼저)
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects single_key_save_invalidates_service_cache
+ */
+ public function test_single_key_save_invalidates_cache_without_manual_clear(): void
+ {
+ // 조회로 캐시를 채운 뒤 저장
+ $this->assertNotSame(31, $this->service->getSetting('basic_defaults.per_page'));
+
+ $this->service->setSetting('basic_defaults.per_page', 31);
+
+ $this->assertSame(
+ 31,
+ $this->service->getSetting('basic_defaults.per_page'),
+ '저장 후에도 조회가 저장 전 값을 반환했습니다 (캐시 무효화 누락).'
+ );
+ }
+
+ /**
+ * 단건 저장은 미제출 boolean 을 false 로 박제하지 않는다.
+ *
+ * 벌크 저장의 backfill 은 폼 전체 제출을 전제로 한 보정이므로, 부분 저장에 태우면
+ * 기본값 true 인 항목이 통째로 뒤집힌다.
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects single_key_save_does_not_backfill_booleans
+ */
+ public function test_single_key_save_does_not_backfill_unsubmitted_booleans(): void
+ {
+ $this->service->setSetting('basic_defaults.per_page', 25);
+
+ $saved = $this->savedFile('basic_defaults');
+
+ $this->assertArrayNotHasKey(
+ 'use_comment',
+ $saved,
+ '제출하지 않은 boolean 이 단건 저장으로 박제되었습니다.'
+ );
+ $this->assertTrue(
+ $this->service->getSetting('basic_defaults.use_comment'),
+ '기본값 true 인 boolean 설정이 단건 저장으로 뒤집혔습니다.'
+ );
+ }
+
+ /**
+ * 벌크 저장의 boolean backfill 은 그대로 유지된다. (비회귀 pin)
+ *
+ * @scenario save_path=bulk
+ *
+ * @effects bulk_save_backfills_unsubmitted_booleans
+ */
+ public function test_bulk_save_still_backfills_unsubmitted_booleans(): void
+ {
+ $this->service->saveSettings(['basic_defaults' => ['per_page' => 25]]);
+
+ $saved = $this->savedFile('basic_defaults');
+
+ $this->assertArrayHasKey('use_comment', $saved, '벌크 저장의 boolean backfill 이 사라졌습니다.');
+ $this->assertFalse($saved['use_comment']);
+ }
+
+ /**
+ * 신고 정책 단건 저장도 알림 강제 활성 규칙을 거친다. (실패-먼저)
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects report_notification_flags_forced_on_single_key_save
+ */
+ public function test_single_key_save_forces_report_notification_flags(): void
+ {
+ $this->service->setSetting('report_policy', [
+ 'auto_hide_threshold' => 7,
+ 'notify_admin_on_report' => false,
+ 'notify_author_on_report_action' => false,
+ ]);
+
+ $saved = $this->savedFile('report_policy');
+
+ $this->assertTrue(
+ $saved['notify_admin_on_report'] ?? false,
+ '신고 알림 강제 활성 규칙이 단건 저장 경로에서 적용되지 않았습니다.'
+ );
+ $this->assertTrue($saved['notify_author_on_report_action'] ?? false);
+ $this->assertSame(7, $saved['auto_hide_threshold'] ?? null);
+ }
+
+ /**
+ * 신고 정책 단건 저장은 알림 정의의 활성 상태까지 동기화한다.
+ *
+ * 저장 파일만 보면 강제 활성 규칙이 통과한 것처럼 보인다. 그러나 알림 정의가 비활성인
+ * 채로 남으면 `NotificationHookListener` 가 훅을 구독하지 않아 설정은 켜져 있는데
+ * 알림만 오지 않는다 — 화면에도 로그에도 흔적이 없다.
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects report_notification_flags_forced_on_single_key_save
+ */
+ public function test_single_key_save_syncs_notification_definition_status(): void
+ {
+ $definition = new NotificationDefinition(['type' => 'report_received_admin']);
+ $definition->is_active = false;
+
+ $repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
+ $repository->method('getByExtension')->willReturn(new Collection([$definition]));
+ $repository->expects($this->once())
+ ->method('update')
+ ->with($definition, ['is_active' => true]);
+
+ $service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
+
+ $service->setSetting('report_policy', [
+ 'auto_hide_threshold' => 7,
+ 'notify_admin_on_report' => false,
+ ]);
+ }
+
+ /**
+ * 이미 원하는 상태인 알림 정의는 다시 쓰지 않는다. (불필요 쓰기 차단)
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects report_notification_flags_forced_on_single_key_save
+ */
+ public function test_single_key_save_skips_notification_definition_update_when_already_active(): void
+ {
+ $definition = new NotificationDefinition(['type' => 'report_received_admin']);
+ $definition->is_active = true;
+
+ $repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
+ $repository->method('getByExtension')->willReturn(new Collection([$definition]));
+ $repository->expects($this->never())->method('update');
+
+ $service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
+
+ $service->setSetting('report_policy', [
+ 'auto_hide_threshold' => 7,
+ 'notify_admin_on_report' => true,
+ ]);
+ }
+
+ /**
+ * 단건 저장도 defaults 스키마 기준 숫자 정규화를 거친다. (실패-먼저)
+ *
+ * @scenario save_path=single_key
+ *
+ * @effects numeric_string_normalized_on_single_key_save
+ */
+ public function test_single_key_save_normalizes_numeric_strings(): void
+ {
+ $this->service->setSetting('basic_defaults.per_page', '42');
+
+ $saved = $this->savedFile('basic_defaults');
+
+ $this->assertSame(42, $saved['per_page'] ?? null, '숫자 문자열이 정규화되지 않고 저장되었습니다.');
+ }
+
+ /**
+ * 테스트 실행 중에는 운영 설정 경로를 쓰지 않는다. (실패-먼저)
+ *
+ * @effects board_settings_storage_isolated_in_tests
+ */
+ public function test_storage_path_is_isolated_during_tests(): void
+ {
+ $productionPath = storage_path('app/modules/sirsoft-board/settings/basic_defaults.json');
+ // 운영 파일의 존재 여부·내용을 그대로 스냅샷 (환경마다 상태가 다르므로 변화 없음만 단언)
+ $before = File::exists($productionPath) ? File::get($productionPath) : null;
+
+ $this->service->setSetting('basic_defaults.per_page', 33);
+
+ $this->assertFileExists(
+ $this->storagePath.'/basic_defaults.json',
+ '테스트 격리 경로에 저장되지 않았습니다.'
+ );
+
+ $after = File::exists($productionPath) ? File::get($productionPath) : null;
+ $this->assertSame($before, $after, '테스트가 운영 설정 파일을 생성/변경했습니다.');
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/BoardSettingsServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/BoardSettingsServiceTest.php
index 66556251..08d319fd 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/BoardSettingsServiceTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/BoardSettingsServiceTest.php
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Board\Tests\Unit;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Board\Services\BoardPermissionService;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
@@ -27,9 +28,10 @@ class BoardSettingsServiceTest extends ModuleTestCase
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
// report_policy 저장 시 호출되는 알림 정의 동기화 경로가 빈 컬렉션을 안전하게 다루도록 기본 stub
$notificationDefinitionRepository->method('getByExtension')
- ->willReturn(new \Illuminate\Database\Eloquent\Collection());
+ ->willReturn(new Collection);
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
- $this->storagePath = storage_path('app/modules/sirsoft-board/settings');
+ // 테스트 격리 경로 — 운영 설정(storage/app/modules/...)을 지우거나 덮어쓰지 않는다
+ $this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
// 테스트 전 저장소 정리
if (File::isDirectory($this->storagePath)) {
@@ -213,8 +215,7 @@ class BoardSettingsServiceTest extends ModuleTestCase
$this->assertTrue($result);
- // 캐시 초기화 후 다시 조회
- $this->service->clearCache();
+ // 단건 저장이 자기 캐시를 비우므로 수동 초기화 없이 신값이 조회된다 (공개 #114 동종)
$perPage = $this->service->getSetting('basic_defaults.per_page');
$this->assertEquals(30, $perPage);
}
@@ -359,8 +360,17 @@ class BoardSettingsServiceTest extends ModuleTestCase
// 첫 번째 조회로 캐시 생성
$first = $this->service->getAllSettings();
- // 파일 직접 수정 (캐시 우회)
- $this->service->setSetting('basic_defaults.per_page', 77);
+ // 파일 직접 수정 (서비스를 경유하지 않아 캐시가 갱신되지 않는 상태를 만든다)
+ File::ensureDirectoryExists($this->storagePath);
+ File::put(
+ $this->storagePath.'/basic_defaults.json',
+ json_encode(['per_page' => 77], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
+ );
+ $this->assertEquals(
+ $first['basic_defaults']['per_page'],
+ $this->service->getAllSettings()['basic_defaults']['per_page'],
+ '캐시 초기화 전인데 파일 변경이 조회에 반영되었습니다.'
+ );
// 캐시 초기화
$this->service->clearCache();
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
new file mode 100644
index 00000000..192eefee
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/Ckeditor5ReferenceSourcesListenerTest.php
@@ -0,0 +1,60 @@
+assertArrayHasKey('sirsoft-ckeditor5.image.filter_reference_sources', $hooks);
+ $this->assertSame('filter', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['type']);
+ $this->assertSame('addBoardSources', $hooks['sirsoft-ckeditor5.image.filter_reference_sources']['method']);
+ }
+
+ /**
+ * @effects reference_source_listener_appends_without_dropping
+ */
+ #[Test]
+ public function it_appends_board_sources_while_preserving_existing_ones(): void
+ {
+ $existing = [['table' => 'mail_templates', 'columns' => ['body']]];
+
+ $result = (new Ckeditor5ReferenceSourcesListener)->addBoardSources($existing);
+
+ $this->assertContains($existing[0], $result);
+ $this->assertContains(['table' => 'board_posts', 'columns' => ['content']], $result);
+ }
+
+ /**
+ * 선언 형태가 계약(table/columns)을 지켜야 플러그인 판정부가 해석할 수 있다.
+ *
+ * @effects reference_source_listener_schema_contract
+ */
+ #[Test]
+ public function appended_sources_follow_the_declared_schema(): void
+ {
+ foreach ((new Ckeditor5ReferenceSourcesListener)->addBoardSources([]) as $source) {
+ $this->assertArrayHasKey('table', $source);
+ $this->assertArrayHasKey('columns', $source);
+ $this->assertIsString($source['table']);
+ $this->assertIsArray($source['columns']);
+ $this->assertNotEmpty($source['columns']);
+ }
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php
index fdefc73a..a9025fa3 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/EcommerceInquiryHookListenerTest.php
@@ -4,6 +4,8 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
require_once __DIR__.'/../../ModuleTestCase.php';
+use App\Models\User;
+use Illuminate\Http\Request;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
@@ -13,12 +15,14 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
* EcommerceInquiryHookListener 단위 테스트
*
* 검증 목적:
- * - getSubscribedHooks: 7개 훅 등록, 모두 filter 타입
- * - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null
+ * - getSubscribedHooks: 8개 훅 등록, 모두 filter 타입
+ * - createAndReturn: 성공(post_id/inquirable_type 반환), title 자동생성, parent_id Re: 처리, 예외 시 null,
+ * 기존 살아있는 답변 존재 시 중복 생성 차단(duplicate 마커), 답변 soft delete 후 재등록 허용
* - getByIds: 빈 배열 → carry 그대로, 유효 ID 목록 → 필드 포함 배열 반환
* - getBoardSettings: 존재하지 않는 slug → carry 그대로, 유효 slug → 설정 배열 반환
- * - deletePost: 성공 → carry 반환, 존재하지 않는 PostId → RuntimeException
+ * - deletePost: 성공 → carry 반환, 답글 자식까지 cascade soft delete, 존재하지 않는 PostId → RuntimeException
* - deleteReplyPost: reply 없음 → RuntimeException, 성공 → carry 반환
+ * - countReplies: 살아있는 자식만 집계
*
* @group board
* @group unit
@@ -63,9 +67,9 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
// ==========================================
/**
- * 7개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다.
+ * 8개 훅이 모두 등록되어 있고, 모두 filter 타입이어야 합니다.
*/
- public function test_subscribed_hooks_registers_all_seven_filter_hooks(): void
+ public function test_subscribed_hooks_registers_all_eight_filter_hooks(): void
{
$hooks = EcommerceInquiryHookListener::getSubscribedHooks();
@@ -77,6 +81,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
'sirsoft-ecommerce.inquiry.delete_reply',
'sirsoft-ecommerce.inquiry.get_by_ids',
'sirsoft-ecommerce.inquiry.get_settings',
+ 'sirsoft-ecommerce.inquiry.count_replies',
];
foreach ($expectedHooks as $hookName) {
@@ -84,7 +89,7 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSame('filter', $hooks[$hookName]['type'], "훅 {$hookName}은 filter 타입이어야 합니다.");
}
- $this->assertCount(7, $hooks, '총 7개의 훅이 등록되어 있어야 합니다.');
+ $this->assertCount(8, $hooks, '총 8개의 훅이 등록되어 있어야 합니다.');
}
// ==========================================
@@ -146,6 +151,60 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertStringContainsString('원본 문의 제목', $post->title);
}
+ /**
+ * createAndReturn: 저장되는 ip_address 는 호출 서비스가 payload 로 넘긴 값이어야 한다.
+ * 요청 경계인 ProductInquiryService 가 IP 를 주입하고 Listener 는 그것을 그대로 쓴다.
+ *
+ * 입력 경계는 viewer 축과 교차하지 않는 별개 관심사라 매니페스트 sub_flow
+ * (`listener_ip_boundary`)로 두고 여기서는 효과만 마킹한다 — 매니페스트 axes 에 없는
+ * 축을 @scenario 에 적으면 어떤 cross product 조합도 커버하지 못하는 죽은 마커가 된다.
+ *
+ * @effects listener_uses_service_provided_ip_not_request
+ */
+ public function test_create_and_return_persists_ip_from_payload(): void
+ {
+ $result = $this->listener->createAndReturn(null, $this->board->slug, [
+ 'title' => '문의',
+ 'content' => '내용',
+ 'ip_address' => '198.51.100.7',
+ ]);
+
+ $post = Post::find($result['post_id']);
+ $this->assertSame('198.51.100.7', $post->ip_address, 'Listener 는 payload 의 ip_address 를 그대로 저장해야 합니다');
+ }
+
+ /**
+ * createAndReturn: payload 에 ip_address 가 없으면 '0.0.0.0' 폴백을 쓰고 request()->ip() 로
+ * 되돌아가지 않는다(입력 우회 방지 경계 — 정정2 회귀 가드).
+ *
+ * request IP 를 **비-0.0.0.0** 으로 세팅한 상태에서 폴백값(0.0.0.0)이 나와야 한다. Listener 가
+ * request()->ip() 를 재도입하면 이 테스트가 request IP('203.0.113.99')를 잡아 fail 한다.
+ * (테스트 기본 request IP 0.0.0.0 으로는 재도입해도 폴백과 같아 silent green — 그래서 비-0 IP 사용)
+ *
+ * 축이 아닌 sub_flow (`listener_ip_boundary`) 소속이라 효과만 마킹한다.
+ *
+ * @effects listener_does_not_reach_into_request_for_ip
+ */
+ public function test_create_and_return_does_not_fall_back_to_request_ip(): void
+ {
+ $request = Request::create('/'.$this->board->slug, 'POST', server: ['REMOTE_ADDR' => '203.0.113.99']);
+ $this->app->instance('request', $request);
+ $this->assertSame('203.0.113.99', $request->ip(), '테스트 전제: request IP 가 비-0.0.0.0 이어야 재도입을 잡는다');
+
+ $result = $this->listener->createAndReturn(null, $this->board->slug, [
+ 'title' => '문의',
+ 'content' => '내용',
+ // ip_address 의도적 누락
+ ]);
+
+ $post = Post::find($result['post_id']);
+ $this->assertSame(
+ '0.0.0.0',
+ $post->ip_address,
+ 'ip_address 미제공 시 폴백(0.0.0.0)이어야 하며 request()->ip() 로 되돌아가면 안 됩니다'
+ );
+ }
+
/**
* createAndReturn: 존재하지 않는 slug → 예외 발생하지 않고 null 반환
*/
@@ -160,6 +219,65 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertNull($result);
}
+ /**
+ * createAndReturn: 부모에 이미 살아있는 답변이 있으면 중복 생성 차단 → null 반환
+ *
+ * 2차 방어(게시판 실데이터 기준): 피벗 is_answered 플래그가 어긋난 경우에도
+ * 게시판에 중복 답변이 쌓이지 않아야 합니다.
+ */
+ public function test_create_and_return_returns_duplicate_marker_when_parent_already_has_reply(): void
+ {
+ $parentPostId = $this->createTestPost(['title' => '원본 문의']);
+ $this->createTestPost([
+ 'title' => 'Re: 원본 문의',
+ 'parent_id' => $parentPostId,
+ 'depth' => 1,
+ ]);
+
+ $result = $this->listener->createAndReturn(null, $this->board->slug, [
+ 'content' => '두 번째 답변 시도',
+ 'parent_id' => $parentPostId,
+ 'ip_address' => '127.0.0.1',
+ ]);
+
+ // null(무응답/실패)이 아닌 중복 마커 — 호출 서비스가 "이미 등록된 답변" 사유로
+ // 변환한다. null 로 되돌리면 사유가 generic 실패로 위장된다 (운영 실측 제보 회귀).
+ $this->assertSame(
+ ['duplicate' => true],
+ $result,
+ '살아있는 답변이 이미 있으면 중복 마커로 차단되어야 합니다.'
+ );
+ }
+
+ /**
+ * createAndReturn: 기존 답변이 soft delete 된 뒤에는 답변 재등록이 허용된다.
+ *
+ * 중복 차단 판정은 살아있는 답변만 대상으로 해야 합니다 (SoftDeletes 기본 스코프).
+ */
+ public function test_create_and_return_allows_reply_after_previous_reply_soft_deleted(): void
+ {
+ $parentPostId = $this->createTestPost(['title' => '원본 문의']);
+ $replyPostId = $this->createTestPost([
+ 'title' => 'Re: 원본 문의',
+ 'parent_id' => $parentPostId,
+ 'depth' => 1,
+ ]);
+
+ // 기존 답변 soft delete
+ Post::find($replyPostId)->delete();
+
+ $result = $this->listener->createAndReturn(null, $this->board->slug, [
+ 'content' => '삭제 후 재등록 답변',
+ 'parent_id' => $parentPostId,
+ 'ip_address' => '127.0.0.1',
+ ]);
+
+ $this->assertIsArray($result, '기존 답변이 삭제된 뒤에는 답변 재등록이 허용되어야 합니다.');
+ $this->assertArrayHasKey('post_id', $result);
+ $this->assertIsInt($result['post_id']);
+ $this->assertNotSame($replyPostId, $result['post_id']);
+ }
+
// ==========================================
// getByIds
// ==========================================
@@ -214,6 +332,68 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSame($postId, $result[0]['id']);
}
+ /**
+ * getByIds: 비밀글은 비열람자에게 content/title/reply/attachments 가 마스킹된다 (KVE-2026-1914 A-1)
+ *
+ * @scenario layer=hook, viewer=non_viewer
+ *
+ * @effects hook_masks_secret_post_for_non_viewer, hook_emits_authoritative_can_view_secret_flag
+ */
+ public function test_get_by_ids_masks_secret_post_for_non_viewer(): void
+ {
+ $owner = User::factory()->create();
+ $postId = $this->createTestPost([
+ 'title' => '비밀 문의 제목',
+ 'content' => '비밀 문의 내용',
+ 'is_secret' => true,
+ 'user_id' => $owner->id,
+ 'author_name' => 'owner',
+ ]);
+
+ // 비인증(비열람자) 컨텍스트
+ $result = $this->listener->getByIds([], ['ids' => [$postId], 'slug' => $this->board->slug]);
+
+ $this->assertCount(1, $result);
+ $item = $result[0];
+ $this->assertNull($item['content'], '비밀글 content 는 비열람자에게 null 이어야 합니다');
+ $this->assertNotSame('비밀 문의 제목', $item['title'], '비밀글 title 은 플레이스홀더로 대체되어야 합니다');
+ $this->assertNull($item['reply'], '비밀글 reply 는 비열람자에게 null 이어야 합니다');
+ $this->assertSame([], $item['attachments'], '비밀글 attachments 는 비열람자에게 빈 배열이어야 합니다');
+ $this->assertTrue($item['is_secret']);
+ // 소비 서비스의 이중 방어(A-2)를 위해 서버 열람 판정을 함께 실어 보낸다
+ $this->assertFalse($item['can_view_secret'], '비열람자에게 can_view_secret 은 false 여야 합니다');
+ }
+
+ /**
+ * getByIds: 작성자 본인에게는 비밀글 원문이 그대로 노출된다 (회귀 방지)
+ *
+ * @scenario layer=hook, viewer=owner
+ *
+ * @effects hook_exposes_secret_post_to_owner
+ */
+ public function test_get_by_ids_shows_secret_post_to_owner(): void
+ {
+ $owner = User::factory()->create();
+ $postId = $this->createTestPost([
+ 'title' => '내 비밀 문의',
+ 'content' => '내 비밀 내용',
+ 'is_secret' => true,
+ 'user_id' => $owner->id,
+ 'author_name' => 'owner',
+ ]);
+
+ $this->actingAs($owner, 'sanctum');
+ $result = $this->listener->getByIds([], ['ids' => [$postId], 'slug' => $this->board->slug]);
+
+ $this->assertSame('내 비밀 내용', $result[0]['content']);
+ $this->assertSame('내 비밀 문의', $result[0]['title']);
+ $this->assertTrue($result[0]['can_view_secret'], '작성자 본인에게 can_view_secret 은 true 여야 합니다');
+ }
+
+ // 게시판 manager 의 비밀글 원문 열람은 HTTP 컨텍스트(라우트 slug)가 필요한
+ // PermissionMiddleware 경로라, SecretPostAttachmentAccessTest·SecretPostCommentAccessTest
+ // 에서 실제 요청으로 검증한다(여기서는 소유자/비열람자 마스킹만 단위 검증).
+
// ==========================================
// getBoardSettings
// ==========================================
@@ -266,6 +446,27 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSoftDeleted('board_posts', ['id' => $postId]);
}
+ /**
+ * deletePost: 부모 문의 삭제 시 답글 자식도 함께 cascade soft delete 된다.
+ *
+ * 훅 경유 삭제는 cascade_replies 옵션으로 게시판 답글 삭제 정책과 무관하게
+ * 시스템 생성 답변을 함께 정리해야 합니다.
+ */
+ public function test_delete_post_soft_deletes_reply_children_too(): void
+ {
+ $parentPostId = $this->createTestPost(['title' => '원본 문의']);
+ $replyPostId = $this->createTestPost([
+ 'title' => 'Re: 원본 문의',
+ 'parent_id' => $parentPostId,
+ 'depth' => 1,
+ ]);
+
+ $this->listener->deletePost(null, $this->board->slug, $parentPostId);
+
+ $this->assertSoftDeleted('board_posts', ['id' => $parentPostId]);
+ $this->assertSoftDeleted('board_posts', ['id' => $replyPostId]);
+ }
+
/**
* deletePost: 존재하지 않는 postId → RuntimeException
*/
@@ -306,4 +507,34 @@ class EcommerceInquiryHookListenerTest extends BoardTestCase
$this->assertSame($carry, $result);
$this->assertSoftDeleted('board_posts', ['id' => $replyPostId]);
}
+
+ // ==========================================
+ // countReplies
+ // ==========================================
+
+ /**
+ * countReplies: 살아있는 자식만 집계한다 (soft delete 된 답변 제외).
+ */
+ public function test_count_replies_counts_only_live_children(): void
+ {
+ $parentPostId = $this->createTestPost(['title' => '원본 문의']);
+ $this->createTestPost([
+ 'title' => 'Re: 살아있는 답변',
+ 'parent_id' => $parentPostId,
+ 'depth' => 1,
+ ]);
+ $trashedReplyId = $this->createTestPost([
+ 'title' => 'Re: 삭제될 답변',
+ 'parent_id' => $parentPostId,
+ 'depth' => 1,
+ ]);
+
+ Post::find($trashedReplyId)->delete();
+
+ $this->assertSame(
+ 1,
+ $this->listener->countReplies(0, $parentPostId),
+ 'soft delete 된 답변은 집계에서 제외되어야 합니다.'
+ );
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
index 35e3724c..3041f24a 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
@@ -7,15 +7,16 @@ use App\Models\User;
use App\Support\Query\BoundedPage;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
+use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\PostService;
-use Tests\TestCase;
+use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* SearchPostsListener 단위 테스트 — 게시판별 권한 필터링 및 날짜 포맷 검증
*/
-class SearchPostsListenerTest extends TestCase
+class SearchPostsListenerTest extends ModuleTestCase
{
/**
* 검색 결과 페이지(BoundedPage)를 만듭니다.
@@ -224,6 +225,109 @@ class SearchPostsListenerTest extends TestCase
$this->assertNotEmpty($item['created_at_formatted']);
}
+ /**
+ * 검색 실패가 "결과 0건" 으로 위장되지 않고 failed 페이로드로 표면화되는지 확인 (#103)
+ *
+ * 수정 전에는 catch 가 로그만 남기고 카테고리 키를 설정하지 않아, 화면이
+ * "검색 결과가 없습니다" 를 그렸다.
+ *
+ * @effects failed_flag_in_response, exception_stack_logged
+ */
+ public function test_search_failure_surfaces_failed_payload_and_logs_exception(): void
+ {
+ Log::spy();
+
+ $exception = new \RuntimeException('DB 오류 재현');
+ $this->boardService
+ ->method('getActiveBoardsForSearch')
+ ->willThrowException($exception);
+
+ $context = [
+ 'type' => 'posts',
+ 'q' => '문의',
+ 'sort' => 'relevance',
+ 'page' => 1,
+ 'per_page' => 10,
+ 'user' => null,
+ 'request' => null,
+ ];
+
+ $result = $this->listener->searchPosts([], $context);
+
+ $this->assertArrayHasKey('posts', $result);
+ $this->assertTrue($result['posts']['failed'] ?? false, '실패 카테고리에는 failed 플래그가 실려야 합니다.');
+ $this->assertFalse($result['posts']['total_is_exact'], '실패한 0건을 "정확한 0건" 으로 말하면 안 됩니다.');
+ $this->assertSame([], $result['posts']['items']);
+ $this->assertSame([], $result['posts']['available_boards'], '기존 응답 키 집합(available_boards)은 유지되어야 합니다.');
+
+ Log::shouldHaveReceived('error')
+ ->withArgs(fn (string $message, array $ctx = []) => ($ctx['exception'] ?? null) === $exception)
+ ->once();
+ }
+
+ /**
+ * 제목/본문에 삽입된 태그가 하이라이트 필드에서 이스케이프되는지 확인 (⑧/N-8)
+ *
+ * @scenario case=search_highlight_escape
+ *
+ * @effects highlighted_fields_escaped
+ */
+ public function test_format_post_result_escapes_markup_in_highlighted_fields(): void
+ {
+ $user = User::factory()->make(['id' => 9999]);
+
+ $board = $this->createBoardStub(1, 'notice', '공지사항');
+
+ $this->boardService
+ ->method('getActiveBoardsForSearch')
+ ->willReturn(new Collection([$board]));
+
+ $this->boardService
+ ->method('getActiveBoardsListForFilter')
+ ->willReturn([]);
+
+ $post = (object) [
+ 'id' => 1,
+ 'title' => '
테스트',
+ // 엔티티로 인코딩된 태그가 html 모드 프리뷰에서 부활하면 안 된다 (N-8).
+ 'content' => '<script>alert(1)</script> 테스트 본문',
+ 'content_mode' => 'html',
+ 'author_name' => '작성자',
+ 'created_at' => now(),
+ 'view_count' => 0,
+ 'comments_count' => 0,
+ 'user' => null,
+ 'board' => $this->createBoardStub(1, 'notice', '공지사항'),
+ ];
+
+ $this->postService
+ ->method('searchAcrossBoards')
+ ->willReturn($this->boundedPage(new Collection([$post]), 1));
+
+ Gate::before(fn ($u) => $u->id === 9999 ? true : null);
+
+ $result = $this->listener->searchPosts([], [
+ 'type' => 'all',
+ 'q' => '테스트',
+ 'sort' => 'relevance',
+ 'page' => 1,
+ 'per_page' => 10,
+ 'user' => $user,
+ 'request' => null,
+ ]);
+
+ $item = $result['posts']['items'][0];
+
+ // 제목 하이라이트: 태그 이스케이프 + 검색어만
+ $this->assertStringNotContainsString('
assertStringContainsString('<img', $item['title_highlighted']);
+ $this->assertStringContainsString('테스트', $item['title_highlighted']);
+
+ // 본문 프리뷰: 부활한